seisin 0.5.0 → 0.6.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 +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
package/src/mcp.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* If granting were a tool here, an agent holding it could widen its own
|
|
18
18
|
* territory and the record would say a human did it. See
|
|
19
|
-
* `docs/
|
|
19
|
+
* `docs/requests.md`.
|
|
20
20
|
*
|
|
21
21
|
* ── Why there is no SDK ──
|
|
22
22
|
* The official SDK pulls 91 packages and 26 MB — express, hono, cors, jose —
|
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
import { toRepoRelative } from "./paths.js";
|
|
30
30
|
import { loadConfig, findConfig } from "./config.js";
|
|
31
31
|
import { inspect } from "./inspect.js";
|
|
32
|
-
import { explain,
|
|
33
|
-
import { CREDENTIAL_HOMES, expand } from "./grants.js";
|
|
32
|
+
import { explain, ownersOf } from "./owners.js";
|
|
34
33
|
import { settingsFor } from "./srt.js";
|
|
35
|
-
import {
|
|
34
|
+
import { queueOf, queueData, refuseIfBarred, shellId } from "./requests.js";
|
|
36
35
|
import { read, logPath } from "./log.js";
|
|
37
36
|
import { causesOf, parseSince, queue as requestQueue, verdicts, wallsByRole } from "./views.js";
|
|
38
37
|
import { walls, wasted } from "./walls.js";
|
|
38
|
+
import { lineReader } from "./lines.js";
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Versions this server will agree to.
|
|
@@ -80,9 +80,11 @@ const TOOLS = [
|
|
|
80
80
|
name: "seisin_requests",
|
|
81
81
|
description:
|
|
82
82
|
"Requests: access agents asked for and cannot have yet, waiting on a person, with how many " +
|
|
83
|
-
"times each was asked
|
|
84
|
-
"to
|
|
85
|
-
"
|
|
83
|
+
"times each was asked and where each goes: `handoff` is seisin's decision from the policy " +
|
|
84
|
+
"(route to the owning role, or a person decides; a cycle or a chain too deep stops). " +
|
|
85
|
+
"A denial files one of these automatically, so this is where to look after being denied " +
|
|
86
|
+
"rather than retrying. Read-only — granting is not available here, and nothing here " +
|
|
87
|
+
"starts a role, by design.",
|
|
86
88
|
inputSchema: { type: "object", properties: {} },
|
|
87
89
|
},
|
|
88
90
|
{
|
|
@@ -105,7 +107,7 @@ const TOOLS = [
|
|
|
105
107
|
"what was denied. What is measured against the policy as it stands is where each " +
|
|
106
108
|
"path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
|
|
107
109
|
"(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
|
|
108
|
-
"outside the repository) — and, per cause, `
|
|
110
|
+
"outside the repository) — and, per cause, `stillDenied`, how many of the roles " +
|
|
109
111
|
"that hit it would hit it today. An unowned cause also carries `kind` and `hint`: " +
|
|
110
112
|
"git (lock or metadata), credential, temporary, build or territory, and the move " +
|
|
111
113
|
"that fits it (`standing.unowned.kinds` counts them). Only `territory` is a question " +
|
|
@@ -119,7 +121,7 @@ const TOOLS = [
|
|
|
119
121
|
type: "object",
|
|
120
122
|
properties: {
|
|
121
123
|
limit: { type: "number" },
|
|
122
|
-
since: { type: "string", description: "ISO date; only denials at or after it" },
|
|
124
|
+
since: { type: "string", description: "ISO date, or a duration back from now (7d, 12h); only denials at or after it" },
|
|
123
125
|
},
|
|
124
126
|
},
|
|
125
127
|
},
|
|
@@ -165,6 +167,8 @@ const HANDLERS = {
|
|
|
165
167
|
return {
|
|
166
168
|
config: cfg.path,
|
|
167
169
|
keyDirectories: cfg.keyDirs,
|
|
170
|
+
read: cfg.read,
|
|
171
|
+
isolate: cfg.isolate,
|
|
168
172
|
roles: report.roles.map((r) => ({
|
|
169
173
|
...r,
|
|
170
174
|
sandbox: settingsFor(cfg, r.name).filesystem,
|
|
@@ -194,10 +198,7 @@ const HANDLERS = {
|
|
|
194
198
|
// A read of a file outside every key dir is open, as it is for the CLI.
|
|
195
199
|
// The server has no working directory of its own, so the root stands in.
|
|
196
200
|
if (action === "read") {
|
|
197
|
-
|
|
198
|
-
return asked.key
|
|
199
|
-
? explain(cfg, role, "read", asked.target)
|
|
200
|
-
: explainFileRead(cfg, role, asked.target, CREDENTIAL_HOMES.map(expand));
|
|
201
|
+
return explain(cfg, role, "read", target, cfg.root);
|
|
201
202
|
}
|
|
202
203
|
const rel = toRepoRelative(cfg, target);
|
|
203
204
|
const verdict = explain(cfg, role, action, rel);
|
|
@@ -206,13 +207,12 @@ const HANDLERS = {
|
|
|
206
207
|
|
|
207
208
|
seisin_requests() {
|
|
208
209
|
const cfg = config();
|
|
209
|
-
const queue = requestQueue(cfg
|
|
210
|
+
const queue = requestQueue(cfg, read(logPath(cfg.root)), { readOnly: true });
|
|
210
211
|
return {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
})),
|
|
212
|
+
// The same shape as `seisin requests --json`, each with its handoff
|
|
213
|
+
// decision. Reading it tells an agent where its request goes; it cannot
|
|
214
|
+
// change where, and nothing here starts the role it names.
|
|
215
|
+
...queueData(queue),
|
|
216
216
|
// Said in the payload and not only in the tool description, because a
|
|
217
217
|
// model reading this is deciding what to do next.
|
|
218
218
|
note: queue.length
|
|
@@ -274,7 +274,8 @@ const HANDLERS = {
|
|
|
274
274
|
|
|
275
275
|
seisin_draft_grant({ id, number } = {}) {
|
|
276
276
|
const cfg = config();
|
|
277
|
-
|
|
277
|
+
// Owners as the policy says now, the same as seisin_requests shows.
|
|
278
|
+
const queue = queueOf(cfg, null, { readOnly: true });
|
|
278
279
|
let req;
|
|
279
280
|
if (typeof id === "string" && id) {
|
|
280
281
|
req = queue.find((r) => r.key === id);
|
|
@@ -388,30 +389,14 @@ function handle(message, { write, version }) {
|
|
|
388
389
|
export function serveMcp(version = "0.0.0", input = process.stdin, output = process.stdout) {
|
|
389
390
|
const ctx = { version, write: (s) => output.write(s) };
|
|
390
391
|
input.setEncoding("utf8");
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
// spool.js already caps its input; this one did not.
|
|
398
|
-
if (buffer.length > 1_000_000) {
|
|
399
|
-
buffer = "";
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
let nl;
|
|
403
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
404
|
-
const line = buffer.slice(0, nl).trim();
|
|
405
|
-
buffer = buffer.slice(nl + 1);
|
|
406
|
-
if (!line) continue;
|
|
407
|
-
try {
|
|
408
|
-
handle(JSON.parse(line), ctx);
|
|
409
|
-
} catch {
|
|
410
|
-
// Unparseable input has no id to answer to, so there is nobody to tell.
|
|
411
|
-
// Staying alive is the only useful response.
|
|
412
|
-
}
|
|
392
|
+
input.on("data", lineReader((line) => {
|
|
393
|
+
try {
|
|
394
|
+
handle(JSON.parse(line), ctx);
|
|
395
|
+
} catch {
|
|
396
|
+
// Unparseable input has no id to answer to, so there is nobody to tell.
|
|
397
|
+
// Staying alive is the only useful response.
|
|
413
398
|
}
|
|
414
|
-
});
|
|
399
|
+
}));
|
|
415
400
|
|
|
416
401
|
return new Promise((done) => input.on("end", done));
|
|
417
402
|
}
|
package/src/nested.js
CHANGED
|
@@ -41,7 +41,15 @@
|
|
|
41
41
|
const SELF_SANDBOXING = {
|
|
42
42
|
codex: {
|
|
43
43
|
on: true,
|
|
44
|
-
|
|
44
|
+
// `-s danger-full-access` first: it is the one to suggest. It turns off only
|
|
45
|
+
// the sandbox, and inside `seisin run` that is the whole change wanted —
|
|
46
|
+
// `--dangerously-bypass…` also turns off approvals, which says more than
|
|
47
|
+
// the situation needs. Warning about `-s danger-full-access` was a warning
|
|
48
|
+
// about a thing that was not happening: measured on 2026-09-30 with codex
|
|
49
|
+
// 0.150.1, a turn under seisin with that flag ran its commands and finished.
|
|
50
|
+
off: ["--sandbox=danger-full-access", "--dangerously-bypass-approvals-and-sandbox"],
|
|
51
|
+
// The same setting spelt as a flag and its value, two words.
|
|
52
|
+
offPairs: [["-s", "danger-full-access"], ["--sandbox", "danger-full-access"]],
|
|
45
53
|
// The subcommand whose entire job is to confine. No flag turns that off.
|
|
46
54
|
never: ["sandbox"],
|
|
47
55
|
},
|
|
@@ -71,7 +79,8 @@ export function nestedSandboxWarning(cmd = []) {
|
|
|
71
79
|
` Run the command directly — seisin is already the boundary here.`
|
|
72
80
|
);
|
|
73
81
|
|
|
74
|
-
const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "=")))
|
|
82
|
+
const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "="))) ||
|
|
83
|
+
(spec.offPairs ?? []).some(([flag, value]) => rest.some((a, i) => a === flag && rest[i + 1] === value));
|
|
75
84
|
if (turnedOff) return null;
|
|
76
85
|
const confining = spec.on || (spec.onWith ?? []).some((f) => rest.includes(f));
|
|
77
86
|
if (!confining) return null;
|
|
@@ -81,6 +90,6 @@ export function nestedSandboxWarning(cmd = []) {
|
|
|
81
90
|
`second profile to a process that already has one.\n` +
|
|
82
91
|
` This does not fail here — it fails on the first command ${name} tries to run, which ` +
|
|
83
92
|
`looks like an agent that did nothing.\n` +
|
|
84
|
-
` Run it with its own sandbox off: \`${name} … ${(spec.off ?? ["--no-sandbox"])[0]}\`.`
|
|
93
|
+
` Run it with its own sandbox off: \`${name} … ${spec.offPairs ? spec.offPairs[0].join(" ") : (spec.off ?? ["--no-sandbox"])[0]}\`.`
|
|
85
94
|
);
|
|
86
95
|
}
|
package/src/notify.js
CHANGED
|
@@ -23,8 +23,10 @@
|
|
|
23
23
|
* which every role can read.
|
|
24
24
|
*/
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
|
-
import { basename, join } from "node:path";
|
|
27
|
-
import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
|
|
26
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
27
|
+
import { keyOf, pending, requestsPath, grantFor, shellId, queueOf, withHandoffs } from "./requests.js";
|
|
28
|
+
import { describeHandoff } from "./handoff.js";
|
|
29
|
+
import { realAncestor } from "./paths.js";
|
|
28
30
|
|
|
29
31
|
const FORMATS = ["text", "json", "slack"];
|
|
30
32
|
const ENV_URL = "SEISIN_NOTIFY_URL";
|
|
@@ -41,9 +43,19 @@ function notifyUrl(config, env = process.env) {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The body for one request, in the chosen format.
|
|
48
|
+
*
|
|
49
|
+
* Whose it is and where it goes are the queue's decision (handoff.js), the
|
|
50
|
+
* same one `seisin requests` prints; a request handed in without one is
|
|
51
|
+
* decided here by the same function, never described from its stored owners.
|
|
52
|
+
*/
|
|
45
53
|
export function message(config, req, number, format = "text") {
|
|
46
|
-
const
|
|
54
|
+
const d = req.handoff ?? withHandoffs(config, [{ ...req, key: keyOf(req) }])[0].handoff;
|
|
55
|
+
const owners = d.owners ?? [];
|
|
56
|
+
const where = describeHandoff(d, req.attempt);
|
|
57
|
+
const owner = (owners.length ? `It belongs to ${owners.join(", ")}.` : "Nobody owns it.") +
|
|
58
|
+
(where ? ` ${where[0].toUpperCase()}${where.slice(1)}.` : "");
|
|
47
59
|
const text =
|
|
48
60
|
`seisin (${basename(config.root)}): ${req.role} was denied ${req.action} on ${req.target}. ${owner} ` +
|
|
49
61
|
`Grant: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
|
|
@@ -58,7 +70,7 @@ export function message(config, req, number, format = "text") {
|
|
|
58
70
|
type: "application/json",
|
|
59
71
|
body: JSON.stringify({
|
|
60
72
|
text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
|
|
61
|
-
target: req.target, owners
|
|
73
|
+
target: req.target, owners, grant: grantFor(req), handoff: d,
|
|
62
74
|
}),
|
|
63
75
|
};
|
|
64
76
|
return { type: "text/plain; charset=utf-8", body: text };
|
|
@@ -86,10 +98,10 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
|
|
|
86
98
|
const key = keyOf(req);
|
|
87
99
|
if (known.has(key)) return false;
|
|
88
100
|
known.add(key);
|
|
89
|
-
const queue =
|
|
101
|
+
const queue = queueOf(config);
|
|
90
102
|
const number = queue.findIndex((p) => p.key === key) + 1;
|
|
91
103
|
if (number < 1) return false;
|
|
92
|
-
const { type, body } = message(config,
|
|
104
|
+
const { type, body } = message(config, queue[number - 1], number, format);
|
|
93
105
|
inflight.push(
|
|
94
106
|
fetchImpl(url, {
|
|
95
107
|
method: "POST",
|
|
@@ -100,20 +112,37 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
|
|
|
100
112
|
signal: AbortSignal.timeout(timeoutMs),
|
|
101
113
|
})
|
|
102
114
|
.then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
|
|
103
|
-
|
|
115
|
+
// Native fetch errors can embed the whole URL, including a webhook
|
|
116
|
+
// credential. Only report known categories, never the raw message.
|
|
117
|
+
.catch((e) => failures.push(notificationFailure(e))),
|
|
104
118
|
);
|
|
105
119
|
return true;
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
async function settle(capMs = timeoutMs + 500) {
|
|
109
123
|
if (!inflight.length) return failures;
|
|
110
|
-
|
|
124
|
+
let timer;
|
|
125
|
+
try {
|
|
126
|
+
await Promise.race([Promise.allSettled(inflight), new Promise((ok) => { timer = setTimeout(ok, capMs); })]);
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
111
130
|
return failures;
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
return { enabled: !!url, maybe, settle };
|
|
115
134
|
}
|
|
116
135
|
|
|
136
|
+
function notificationFailure(error) {
|
|
137
|
+
if (error?.name === "TimeoutError") return "timed out";
|
|
138
|
+
if (error?.name === "AbortError") return "aborted";
|
|
139
|
+
const code = error?.cause?.code ?? error?.code;
|
|
140
|
+
if (code === "ERR_INVALID_URL") return "invalid URL";
|
|
141
|
+
if (code === "ENOTFOUND") return "host not found";
|
|
142
|
+
if (code === "ECONNREFUSED") return "connection unavailable";
|
|
143
|
+
return "request failed";
|
|
144
|
+
}
|
|
145
|
+
|
|
117
146
|
/**
|
|
118
147
|
* `[notify]` from seisin.toml, refused rather than guessed when it would leak.
|
|
119
148
|
* Called by the config loader.
|
|
@@ -132,16 +161,28 @@ export function readNotify(table, { path, root, keyDirs, roles, own }) {
|
|
|
132
161
|
if (urlFile === undefined) return { urlFile: null, format };
|
|
133
162
|
if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
|
|
134
163
|
throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
|
|
135
|
-
const
|
|
164
|
+
const canonical = (p) => realAncestor(resolve(root, p));
|
|
165
|
+
const file = canonical(urlFile);
|
|
166
|
+
const inKeyDir = keyDirs.some((d) => file.startsWith(canonical(d) + sep));
|
|
136
167
|
if (!inKeyDir)
|
|
137
168
|
throw new Error(
|
|
138
169
|
`${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
|
|
139
170
|
`it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
|
|
140
171
|
// A file key is written bare (`netlify.txt`, meaning the first key directory)
|
|
141
172
|
// or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
|
|
142
|
-
const
|
|
173
|
+
const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
|
|
143
174
|
const holders = Object.entries(roles)
|
|
144
|
-
.filter(([, r]) => r.keyEntries?.some((k) =>
|
|
175
|
+
.filter(([, r]) => r.keyEntries?.some((k) => {
|
|
176
|
+
if (k.kind === "file") {
|
|
177
|
+
const held = fileKey(k.raw);
|
|
178
|
+
return file === held || file.startsWith(held + sep);
|
|
179
|
+
}
|
|
180
|
+
if (k.kind !== "ref" || k.scheme !== "file") return false;
|
|
181
|
+
// Match the builtin resolver's last-fragment rule. A reference delivers
|
|
182
|
+
// the contents even though it opens no filesystem read for the role.
|
|
183
|
+
const hash = k.ref.lastIndexOf("#");
|
|
184
|
+
return canonical(hash === -1 ? k.ref : k.ref.slice(0, hash)) === file;
|
|
185
|
+
}))
|
|
145
186
|
.map(([n]) => n);
|
|
146
187
|
if (holders.length)
|
|
147
188
|
throw new Error(
|
package/src/owners.js
CHANGED
|
@@ -14,8 +14,10 @@ import { createHash } from "node:crypto";
|
|
|
14
14
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
15
|
import { isAbsolute, join } from "node:path";
|
|
16
16
|
import { protectedBy } from "./surface.js";
|
|
17
|
-
import { entriesOf } from "./keys.js";
|
|
17
|
+
import { entriesOf, parseKey, BUILTIN } from "./keys.js";
|
|
18
18
|
import { WILD, fromCwd, toRepoRelative } from "./paths.js";
|
|
19
|
+
import { AGENTS, AGENT_CREDENTIALS, CREDENTIAL_HOMES, expand } from "./grants.js";
|
|
20
|
+
import { keptFor, shutByTerritory } from "./territory.js";
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
|
|
@@ -243,11 +245,55 @@ export function ownersOf(config, path) {
|
|
|
243
245
|
if (!config.root || !path.startsWith(root)) return [];
|
|
244
246
|
path = path.slice(root.length);
|
|
245
247
|
}
|
|
248
|
+
// A private folder has one owner, whatever else covers it: every other role
|
|
249
|
+
// is given a denyWrite on it (srt.js), and denyWrite wins over any territory.
|
|
250
|
+
const mine = privateOwner(config, path);
|
|
246
251
|
return Object.values(config.roles)
|
|
247
252
|
.filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
|
|
253
|
+
.filter((r) => !mine || r.name === mine.role)
|
|
248
254
|
.map((r) => r.name);
|
|
249
255
|
}
|
|
250
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The private folder that holds `path` — `{ role, entry }` — or null.
|
|
259
|
+
*
|
|
260
|
+
* `path` is repo-relative, or absolute inside the repo. One answer for the
|
|
261
|
+
* settings, `explain`, the hook and `check`, so the denies the kernel is given
|
|
262
|
+
* and the sentence about them cannot drift apart.
|
|
263
|
+
*/
|
|
264
|
+
export function privateOwner(config, path) {
|
|
265
|
+
let p = String(path ?? "");
|
|
266
|
+
if (p.startsWith("/")) {
|
|
267
|
+
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
268
|
+
if (!config.root || !p.startsWith(root)) return null;
|
|
269
|
+
p = p.slice(root.length);
|
|
270
|
+
}
|
|
271
|
+
for (const r of Object.values(config.roles ?? {}))
|
|
272
|
+
for (const entry of r.private ?? [])
|
|
273
|
+
if (covers(entry.replace(/\/\*\*$/, "").replace(/\/+$/, ""), p)) return { role: r.name, entry };
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The answer for `role` touching another role's private folder, or null. */
|
|
278
|
+
function privateAnswer(config, role, target, read = false) {
|
|
279
|
+
const hit = privateOwner(config, target);
|
|
280
|
+
if (!hit) return null;
|
|
281
|
+
// The owner's write goes on through the ordinary questions (never_writes
|
|
282
|
+
// still subtracts inside it); its read is answered here, because "every
|
|
283
|
+
// role reads it" is exactly what is no longer true.
|
|
284
|
+
if (hit.role === role)
|
|
285
|
+
return read
|
|
286
|
+
? { allowed: true, owners: [role], privateTo: role,
|
|
287
|
+
reason: `${target} is in ${role}'s private folder (private = ["${hit.entry}"]): ${role} reads it, and no other role does` }
|
|
288
|
+
: null;
|
|
289
|
+
return {
|
|
290
|
+
allowed: false, owners: [hit.role], privateTo: hit.role,
|
|
291
|
+
reason: `${target} is in ${hit.role}'s private folder (private = ["${hit.entry}"]): only ${hit.role} ` +
|
|
292
|
+
`reads or writes it, and every other role is denied both, in any read mode. ` +
|
|
293
|
+
`There is nothing to grant — ask ${hit.role} for what you need from it`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
251
297
|
/**
|
|
252
298
|
* Where a refused target stands against the policy as it is now.
|
|
253
299
|
*
|
|
@@ -271,6 +317,8 @@ export function standing(config, entry) {
|
|
|
271
317
|
const target = entry.target;
|
|
272
318
|
if (entry.kind === "network")
|
|
273
319
|
return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
|
|
320
|
+
if (entry.kind === "system")
|
|
321
|
+
return { kind: "outside", owners: [], why: "a system service, which no role owns" };
|
|
274
322
|
if (entry.kind === "key")
|
|
275
323
|
return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
|
|
276
324
|
const guard = protectedBy(config, target);
|
|
@@ -401,10 +449,30 @@ export function keyName(config, target) {
|
|
|
401
449
|
* inside a key directory is a key question; anything else is answered as the
|
|
402
450
|
* open read it is, with how to make it a key.
|
|
403
451
|
*/
|
|
452
|
+
/**
|
|
453
|
+
* An agent's sign-in file: closed to every run but a run of that agent, which
|
|
454
|
+
* is a fact about the command and not the role (AGENT_CREDENTIALS, grants.js).
|
|
455
|
+
* explain has no command, so it answers for the ordinary case and names the
|
|
456
|
+
* exception. Nothing to grant either way, and the sentence says so: the advice
|
|
457
|
+
* it replaced was "add it to a role's keys", which would hand a ChatGPT token
|
|
458
|
+
* to a role by policy.
|
|
459
|
+
*/
|
|
460
|
+
function signInAnswer(role, target) {
|
|
461
|
+
if (!String(target ?? "").startsWith("/")) return null;
|
|
462
|
+
const agent = AGENTS.find((a) => AGENT_CREDENTIALS[a].map(expand).includes(target));
|
|
463
|
+
if (!agent) return null;
|
|
464
|
+
return {
|
|
465
|
+
allowed: false, owners: [], signIn: agent,
|
|
466
|
+
reason: `${target} is ${agent}'s sign-in: only a run whose command is ${agent} reads it ` +
|
|
467
|
+
`(seisin run ${role} -- ${agent} …), and the commands that run inside it. ` +
|
|
468
|
+
`Any other command run as ${role} is denied it, and there is nothing to grant`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
404
472
|
export function explainFileRead(config, role, rel, credentialHomes = []) {
|
|
405
473
|
// Only a key directory makes a key: settingsFor refuses a key declared
|
|
406
474
|
// anywhere else, so nothing outside one is denied by being a key.
|
|
407
|
-
if (inKeyDir(config, rel)) return
|
|
475
|
+
if (inKeyDir(config, rel)) return explainPolicy(config, role, "read", rel);
|
|
408
476
|
const level = config.isolate === true ? "home" : config.isolate;
|
|
409
477
|
if ((level === "credentials" || level === "home") && rel.startsWith("/")) {
|
|
410
478
|
const hit = credentialHomes.find((h) => rel === h || rel.startsWith(h + "/"));
|
|
@@ -414,6 +482,17 @@ export function explainFileRead(config, role, rel, credentialHomes = []) {
|
|
|
414
482
|
reason: `${rel} is under ${hit}, which isolate = "${level}" closes to every role`,
|
|
415
483
|
};
|
|
416
484
|
}
|
|
485
|
+
const signIn = signInAnswer(role, rel);
|
|
486
|
+
if (signIn) return signIn;
|
|
487
|
+
const shut = privateAnswer(config, role, rel, true);
|
|
488
|
+
if (shut) return shut;
|
|
489
|
+
if (config.read === "territory" && rel.startsWith("/") &&
|
|
490
|
+
shutByTerritory(rel, keptFor(config, config.roles[role])))
|
|
491
|
+
return {
|
|
492
|
+
allowed: false, owners: [], readTerritory: true,
|
|
493
|
+
reason: `${rel} is outside what ${role} keeps readable under read = "territory". ` +
|
|
494
|
+
`Declare it in reads, or declare the tool that needs it in toolchain`,
|
|
495
|
+
};
|
|
417
496
|
const dirs = (config.keyDirs ?? []).map(dirSpelling);
|
|
418
497
|
return {
|
|
419
498
|
allowed: true, owners: [], open: true,
|
|
@@ -433,6 +512,10 @@ export function explainFileRead(config, role, rel, credentialHomes = []) {
|
|
|
433
512
|
* where you stand, and inside a key directory it is a key question again.
|
|
434
513
|
*/
|
|
435
514
|
export function readTarget(config, target, cwd = process.cwd()) {
|
|
515
|
+
// A reference is a key by its spelling, never a path: resolving
|
|
516
|
+
// `keychain://x` from where you stand printed `keychain:/x` and answered
|
|
517
|
+
// a question about a file nobody asked about.
|
|
518
|
+
if (refOf(target)) return { key: true, target };
|
|
436
519
|
if (!target.includes("/") && !isAbsolute(target)) {
|
|
437
520
|
const declared = keyHolders(config, target).length > 0;
|
|
438
521
|
const bare = (s) => s.replace(/\.[^.]+$/, "");
|
|
@@ -487,6 +570,64 @@ export function keyHolders(config, key) {
|
|
|
487
570
|
.map((r) => r.name);
|
|
488
571
|
}
|
|
489
572
|
|
|
573
|
+
/** `scheme://…`, optionally `NAME=scheme://…`, read as a key entry — or null. */
|
|
574
|
+
function refOf(target) {
|
|
575
|
+
try {
|
|
576
|
+
const e = parseKey(target);
|
|
577
|
+
return e.kind === "ref" ? e : null;
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* A reference key is a question about the policy, not about the disk.
|
|
585
|
+
*
|
|
586
|
+
* Two facts answer it: whether the role lists the reference in its `keys`,
|
|
587
|
+
* and which provider turns it into a value. The role never reads where the
|
|
588
|
+
* secret lives — the parent resolves it before the sandbox starts and hands
|
|
589
|
+
* over the value — so there is no path to normalise and no key directory to
|
|
590
|
+
* be inside of. Matched on scheme and reference; the variable name in front is
|
|
591
|
+
* how it is delivered, not which secret it is.
|
|
592
|
+
*/
|
|
593
|
+
function explainRef(config, role, ref) {
|
|
594
|
+
const spelled = `${ref.scheme}://${ref.ref}`;
|
|
595
|
+
const declaring = (r) => {
|
|
596
|
+
try {
|
|
597
|
+
return entriesOf(r).find((e) => e.kind === "ref" && e.scheme === ref.scheme && e.ref === ref.ref);
|
|
598
|
+
} catch {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
const holders = Object.values(config.roles).filter(declaring).map((r) => r.name);
|
|
603
|
+
const provider = BUILTIN.has(ref.scheme)
|
|
604
|
+
? `seisin's built-in ${ref.scheme}:// provider`
|
|
605
|
+
: Object.hasOwn(config.keyProviders ?? {}, ref.scheme) ? `[keys.providers.${ref.scheme}]` : null;
|
|
606
|
+
if (!provider)
|
|
607
|
+
return {
|
|
608
|
+
allowed: false, owners: holders, key: "ref",
|
|
609
|
+
reason: `${spelled} uses the "${ref.scheme}" scheme and no [keys.providers.${ref.scheme}] is declared, ` +
|
|
610
|
+
`so nothing resolves it — declare the provider before a role can hold this key`,
|
|
611
|
+
};
|
|
612
|
+
const mine = config.roles[role] && declaring(config.roles[role]);
|
|
613
|
+
if (mine)
|
|
614
|
+
return {
|
|
615
|
+
allowed: true, owners: holders, key: "ref", provider,
|
|
616
|
+
reason: `${role} declares ${mine.raw}: the parent resolves it through ${provider} and hands ` +
|
|
617
|
+
`${role} the value as ${mine.name} — the role never reads where it is kept`,
|
|
618
|
+
};
|
|
619
|
+
if (!holders.length)
|
|
620
|
+
return {
|
|
621
|
+
allowed: false, owners: [], key: "ref", provider,
|
|
622
|
+
reason: `no role declares ${spelled} (${provider} would resolve it) — ` +
|
|
623
|
+
`add it to a role's keys: [roles.${role}] keys = ["${ref.raw}"]`,
|
|
624
|
+
};
|
|
625
|
+
return {
|
|
626
|
+
allowed: false, owners: holders, key: "ref", provider,
|
|
627
|
+
reason: `${spelled} belongs to ${holders.join(", ")}, through ${provider}`,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
490
631
|
/** The keys of `role` that grant a read: the path ones. A malformed entry grants nothing. */
|
|
491
632
|
function fileKeys(role) {
|
|
492
633
|
let entries;
|
|
@@ -568,10 +709,14 @@ function explainMcp(config, role, name) {
|
|
|
568
709
|
* not a permission problem, it is a hole in the map. Saying so is more useful
|
|
569
710
|
* than denying quietly, and it is the only way the hole ever gets fixed.
|
|
570
711
|
*/
|
|
571
|
-
|
|
712
|
+
function explainPolicy(config, role, action, target) {
|
|
572
713
|
if (action === "connect") return explainConnect(config, role, target);
|
|
573
714
|
if (action === "mcp") return explainMcp(config, role, target);
|
|
574
715
|
if (action === "read") {
|
|
716
|
+
const ref = refOf(target);
|
|
717
|
+
if (ref) return explainRef(config, role, ref);
|
|
718
|
+
const signIn = signInAnswer(role, target);
|
|
719
|
+
if (signIn) return signIn;
|
|
575
720
|
const holders = keyHolders(config, target);
|
|
576
721
|
if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
|
|
577
722
|
if (holders.length === 0) {
|
|
@@ -617,6 +762,8 @@ export function explain(config, role, action, target) {
|
|
|
617
762
|
* approves it, and the subtraction is undone from the other side without
|
|
618
763
|
* anyone deciding to undo it.
|
|
619
764
|
*/
|
|
765
|
+
const shut = privateAnswer(config, role, target);
|
|
766
|
+
if (shut) return shut;
|
|
620
767
|
const r = config.roles[role];
|
|
621
768
|
const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
|
|
622
769
|
if (barred)
|
|
@@ -648,3 +795,18 @@ export function explain(config, role, action, target) {
|
|
|
648
795
|
return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
|
|
649
796
|
return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
|
|
650
797
|
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* One public answer for CLI, MCP and library callers.
|
|
801
|
+
*
|
|
802
|
+
* Reads first decide whether the target is a declared key or a file, then ask
|
|
803
|
+
* the same territory/isolation question. Callers may supply their cwd; servers
|
|
804
|
+
* use the policy root, while the CLI uses the directory it is standing in.
|
|
805
|
+
*/
|
|
806
|
+
export function explain(config, role, action, target, cwd = config.root) {
|
|
807
|
+
if (action !== "read") return explainPolicy(config, role, action, target);
|
|
808
|
+
const asked = readTarget(config, target, cwd);
|
|
809
|
+
return asked.key
|
|
810
|
+
? explainPolicy(config, role, "read", asked.target)
|
|
811
|
+
: explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
|
|
812
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* project refuses. Found by the read-only review of 2026-09-22.
|
|
13
13
|
*/
|
|
14
14
|
import { realpathSync } from "node:fs";
|
|
15
|
-
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* `p` with its deepest existing ancestor resolved through symlinks and the rest
|
|
@@ -57,6 +57,22 @@ export function toRepoRelative(config, target) {
|
|
|
57
57
|
return target;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* One spelling for a path, so two of them can be compared with `===`.
|
|
62
|
+
*
|
|
63
|
+
* Repo-relative and normalized inside the repo (`./a/`, `a/../a` and the
|
|
64
|
+
* absolute path through either symlinked spelling of the root are all `a`);
|
|
65
|
+
* absolute and resolved through its existing ancestors outside it. Used where
|
|
66
|
+
* a path a caller gave must match the path a denial names exactly — never as
|
|
67
|
+
* a prefix and never as a glob.
|
|
68
|
+
*/
|
|
69
|
+
export function samePath(config, target) {
|
|
70
|
+
if (typeof target !== "string" || !target.trim()) return null;
|
|
71
|
+
const rel = toRepoRelative(config, isAbsolute(target) ? normalize(target) : target);
|
|
72
|
+
if (isAbsolute(rel)) return realAncestor(normalize(rel)).replace(/(.)\/+$/, "$1");
|
|
73
|
+
return normalize(rel).replace(/\/+$/, "") || ".";
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
/**
|
|
61
77
|
* A path typed at a prompt, made absolute the way a shell user means it:
|
|
62
78
|
* against the directory they are standing in. Relative arguments used to be
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one key provider and reports what it printed, as JSON on stdout.
|
|
3
|
+
*
|
|
4
|
+
* A separate process because `spawnSync` cannot do two things a provider
|
|
5
|
+
* needs, and `resolveRef` is synchronous by contract (keys.js):
|
|
6
|
+
*
|
|
7
|
+
* - **Take the answer when the provider exits.** `spawnSync` returns when
|
|
8
|
+
* the child has exited AND every copy of its stdout is closed. A provider
|
|
9
|
+
* that printed the value, exited 0 and left a background process holding
|
|
10
|
+
* the pipe made seisin wait the whole timeout and then say the provider
|
|
11
|
+
* "did not answer". Here the answer is what was read by the time the
|
|
12
|
+
* provider exited, plus a short drain for bytes still in the pipe.
|
|
13
|
+
* - **Stop it for good.** A timeout is SIGKILL, not SIGTERM: a provider that
|
|
14
|
+
* ignored SIGTERM held the run forever. And it goes to every descendant
|
|
15
|
+
* too, so a child the provider was waiting on dies with it instead of
|
|
16
|
+
* living on. Found by walking `ps`, not by a process group of its own: a
|
|
17
|
+
* new session would take the provider's terminal away, and a pinentry
|
|
18
|
+
* needs it. A provider that exits on its own keeps whatever it left
|
|
19
|
+
* running: an agent it starts (gpg-agent, an op daemon) is its business.
|
|
20
|
+
*
|
|
21
|
+
* stderr is read and counted, never kept: a provider may print the value
|
|
22
|
+
* there, and nothing downstream shows it.
|
|
23
|
+
*
|
|
24
|
+
* argv: <timeoutMs> <maxBytes> <drainMs> <command> [args…]
|
|
25
|
+
*/
|
|
26
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
const [timeoutMs, maxBytes, drainMs] = process.argv.slice(2, 5).map(Number);
|
|
29
|
+
const argv = process.argv.slice(5);
|
|
30
|
+
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0, errBytes = 0, timedOut = false, tooBig = false, done = false;
|
|
33
|
+
|
|
34
|
+
function finish(result) {
|
|
35
|
+
if (done) return;
|
|
36
|
+
done = true;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
const out = tooBig ? "" : Buffer.concat(chunks).toString("base64");
|
|
39
|
+
process.stdout.write(JSON.stringify({ ...result, out, errBytes, timedOut, tooBig }), () => process.exit(0));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The provider and every process below it. */
|
|
43
|
+
function tree(root) {
|
|
44
|
+
const kids = new Map();
|
|
45
|
+
try {
|
|
46
|
+
for (const line of execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }).split("\n")) {
|
|
47
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
48
|
+
if (pid && ppid) kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
const all = [root];
|
|
52
|
+
for (let i = 0; i < all.length; i++) all.push(...(kids.get(all[i]) ?? []));
|
|
53
|
+
return all;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
|
|
57
|
+
const kill = () => {
|
|
58
|
+
if (!child.pid) return;
|
|
59
|
+
// Stopped first, so nothing forks between the listing and the kill.
|
|
60
|
+
const pids = tree(child.pid);
|
|
61
|
+
for (const pid of pids) try { process.kill(pid, "SIGSTOP"); } catch {}
|
|
62
|
+
for (const pid of tree(child.pid)) if (!pids.includes(pid)) pids.push(pid);
|
|
63
|
+
for (const pid of pids) try { process.kill(pid, "SIGKILL"); } catch {}
|
|
64
|
+
};
|
|
65
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
66
|
+
|
|
67
|
+
child.stdout.on("data", (c) => {
|
|
68
|
+
size += c.length;
|
|
69
|
+
if (size > maxBytes) { tooBig = true; chunks.length = 0; kill(); return; }
|
|
70
|
+
if (!tooBig) chunks.push(c);
|
|
71
|
+
});
|
|
72
|
+
child.stderr.on("data", (c) => { errBytes += c.length; });
|
|
73
|
+
child.on("error", (e) => finish({ error: e.code ?? e.message }));
|
|
74
|
+
child.on("exit", (status, signal) => {
|
|
75
|
+
// What the provider wrote before exiting may still be in the pipe. Wait for
|
|
76
|
+
// the end of it, but not for a descendant that kept the pipe open.
|
|
77
|
+
const drained = () => finish({ status, signal });
|
|
78
|
+
if (child.stdout.readableEnded) return drained();
|
|
79
|
+
child.stdout.once("end", drained);
|
|
80
|
+
setTimeout(drained, drainMs).unref();
|
|
81
|
+
});
|