seisin 0.4.2 → 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 +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- 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 +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- 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 +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
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
|
@@ -17,20 +17,22 @@
|
|
|
17
17
|
* never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
|
|
18
18
|
* environment even if the role names it;
|
|
19
19
|
* - the URL is not readable from any territory. A ntfy topic or a webhook is
|
|
20
|
-
* a credential — whoever can read it can send the person a fake "
|
|
20
|
+
* a credential — whoever can read it can send the person a fake "grant
|
|
21
21
|
* #3". So it lives in a key directory no role declares (`url_file`), or in
|
|
22
22
|
* the parent's environment, and never as a plain value in seisin.toml,
|
|
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
|
-
|
|
30
|
-
|
|
31
|
+
const FORMATS = ["text", "json", "slack"];
|
|
32
|
+
const ENV_URL = "SEISIN_NOTIFY_URL";
|
|
31
33
|
|
|
32
34
|
/** The URL to notify, or null. Read in the parent only. */
|
|
33
|
-
|
|
35
|
+
function notifyUrl(config, env = process.env) {
|
|
34
36
|
if (env[ENV_URL]) return env[ENV_URL].trim();
|
|
35
37
|
if (!config.notify?.urlFile) return null;
|
|
36
38
|
try {
|
|
@@ -41,19 +43,34 @@ export 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
|
-
`seisin (${basename(config.root)}): ${req.role} was
|
|
49
|
-
`
|
|
50
|
-
|
|
60
|
+
`seisin (${basename(config.root)}): ${req.role} was denied ${req.action} on ${req.target}. ${owner} ` +
|
|
61
|
+
`Grant: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
|
|
62
|
+
// Slack reads `<url|label>` as a link and `<!channel>` as a mention, and the
|
|
63
|
+
// path is chosen by the agent: `<https://evil|approve here>` in a target
|
|
64
|
+
// would arrive as a clickable link in the channel a person approves from.
|
|
65
|
+
// Its own escaping, and only these three characters, is what it asks for.
|
|
66
|
+
if (format === "slack")
|
|
67
|
+
return { type: "application/json", body: JSON.stringify({ text: text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") }) };
|
|
51
68
|
if (format === "json")
|
|
52
69
|
return {
|
|
53
70
|
type: "application/json",
|
|
54
71
|
body: JSON.stringify({
|
|
55
72
|
text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
|
|
56
|
-
target: req.target, owners
|
|
73
|
+
target: req.target, owners, grant: grantFor(req), handoff: d,
|
|
57
74
|
}),
|
|
58
75
|
};
|
|
59
76
|
return { type: "text/plain; charset=utf-8", body: text };
|
|
@@ -81,10 +98,10 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
|
|
|
81
98
|
const key = keyOf(req);
|
|
82
99
|
if (known.has(key)) return false;
|
|
83
100
|
known.add(key);
|
|
84
|
-
const queue =
|
|
101
|
+
const queue = queueOf(config);
|
|
85
102
|
const number = queue.findIndex((p) => p.key === key) + 1;
|
|
86
103
|
if (number < 1) return false;
|
|
87
|
-
const { type, body } = message(config,
|
|
104
|
+
const { type, body } = message(config, queue[number - 1], number, format);
|
|
88
105
|
inflight.push(
|
|
89
106
|
fetchImpl(url, {
|
|
90
107
|
method: "POST",
|
|
@@ -95,20 +112,37 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
|
|
|
95
112
|
signal: AbortSignal.timeout(timeoutMs),
|
|
96
113
|
})
|
|
97
114
|
.then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
|
|
98
|
-
|
|
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))),
|
|
99
118
|
);
|
|
100
119
|
return true;
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
async function settle(capMs = timeoutMs + 500) {
|
|
104
123
|
if (!inflight.length) return failures;
|
|
105
|
-
|
|
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
|
+
}
|
|
106
130
|
return failures;
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
return { enabled: !!url, maybe, settle };
|
|
110
134
|
}
|
|
111
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
|
+
|
|
112
146
|
/**
|
|
113
147
|
* `[notify]` from seisin.toml, refused rather than guessed when it would leak.
|
|
114
148
|
* Called by the config loader.
|
|
@@ -127,20 +161,32 @@ export function readNotify(table, { path, root, keyDirs, roles, own }) {
|
|
|
127
161
|
if (urlFile === undefined) return { urlFile: null, format };
|
|
128
162
|
if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
|
|
129
163
|
throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
|
|
130
|
-
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));
|
|
131
167
|
if (!inKeyDir)
|
|
132
168
|
throw new Error(
|
|
133
169
|
`${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
|
|
134
170
|
`it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
|
|
135
171
|
// A file key is written bare (`netlify.txt`, meaning the first key directory)
|
|
136
172
|
// or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
|
|
137
|
-
const
|
|
173
|
+
const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
|
|
138
174
|
const holders = Object.entries(roles)
|
|
139
|
-
.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
|
+
}))
|
|
140
186
|
.map(([n]) => n);
|
|
141
187
|
if (holders.length)
|
|
142
188
|
throw new Error(
|
|
143
189
|
`${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
|
|
144
|
-
`that role could read it and send the person a fake "
|
|
190
|
+
`that role could read it and send the person a fake "grant this". No role may hold it.`);
|
|
145
191
|
return { urlFile, format };
|
|
146
192
|
}
|