seisin 0.2.0 → 0.4.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 +4 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +28 -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 +72 -6
- 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 +62 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +243 -0
- package/src/keys.js +108 -6
- package/src/log.js +203 -3
- 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 +134 -22
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/uilink.js +55 -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/intake.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent's intake: what arrives during a run, checked, and written down.
|
|
3
|
+
*
|
|
4
|
+
* Two sources, and they are not equally trustworthy. The hook runs inside the
|
|
5
|
+
* box, so everything it sends is a claim by the process being recorded. The
|
|
6
|
+
* kernel is the thing that actually refused. Both end up as log lines and,
|
|
7
|
+
* sometimes, as requests in front of a person; this module is where each is
|
|
8
|
+
* recomputed from the policy rather than believed.
|
|
9
|
+
*
|
|
10
|
+
* Every line written here carries two fields that make it evidence rather
|
|
11
|
+
* than a sentence:
|
|
12
|
+
*
|
|
13
|
+
* - `run`, the id of the run that produced it — the same id as the nonce in
|
|
14
|
+
* the kernel's command tag and the run's private directory (rundir.js).
|
|
15
|
+
* Before, the log had no run marker and "how many runs since" was guessed
|
|
16
|
+
* from 10-minute gaps.
|
|
17
|
+
* - `policy`, a hash of the policy file the run STARTED with. The kernel
|
|
18
|
+
* enforces that one for the whole run, whatever happens to the file
|
|
19
|
+
* afterwards, and `review` reading today's policy over last week's lines
|
|
20
|
+
* could not tell a grant made since from one that was always there.
|
|
21
|
+
*
|
|
22
|
+
* Pulled out of run.js, which held both handlers inline between the spawn and
|
|
23
|
+
* the signal handling — 180 lines of policy arithmetic inside the launcher.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { toRepoRelative } from "./paths.js";
|
|
29
|
+
import { explain, ownersOf } from "./owners.js";
|
|
30
|
+
import { append, logPath } from "./log.js";
|
|
31
|
+
import { record, requestsPath } from "./requests.js";
|
|
32
|
+
import { inScope, scopeOf, reachedForContent } from "./violations.js";
|
|
33
|
+
import { mcpServer } from "./hook.js";
|
|
34
|
+
|
|
35
|
+
/** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
|
|
36
|
+
export function policyId(config) {
|
|
37
|
+
try {
|
|
38
|
+
return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The handlers for one run.
|
|
46
|
+
*
|
|
47
|
+
* `fromHook(to, entry)` is the spool's sink; `fromKernel(denial)` is
|
|
48
|
+
* watchDenials' callback; `stats` counts what the kernel said that was left
|
|
49
|
+
* out on purpose, for the line at the end of the run.
|
|
50
|
+
*/
|
|
51
|
+
export function intake({ config, role, runId, observe = false, settings, notify }) {
|
|
52
|
+
const stamp = { run: runId.slice(0, 8), policy: policyId(config) };
|
|
53
|
+
const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
|
|
54
|
+
const ask = (asked) => { record(requestsPath(config.root), asked); notify?.maybe(asked); };
|
|
55
|
+
|
|
56
|
+
const scope = scopeOf(settings, config.root);
|
|
57
|
+
const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
|
|
58
|
+
const connects = new Set(); // one line per refused target per run
|
|
59
|
+
const stats = { offPolicy: 0, walks: 0 };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Nothing from inside the box is taken at its word.
|
|
63
|
+
*
|
|
64
|
+
* The sender is the process being recorded, so every field it supplies is
|
|
65
|
+
* a claim. Two of them matter. `role` decides whose request this is — left
|
|
66
|
+
* alone, a frontend agent could file one as backend and wait for a human to
|
|
67
|
+
* approve it. `owners` decides who the queue says it belongs to, and the
|
|
68
|
+
* parent can work that out itself from the policy.
|
|
69
|
+
*
|
|
70
|
+
* So the role is overwritten with the role of this run, the owners are
|
|
71
|
+
* recomputed, and anything shaped wrong is dropped. Forging a *log* line is
|
|
72
|
+
* noise. Forging a *request* is a sentence placed in front of a person for
|
|
73
|
+
* approval, and that is a different thing entirely.
|
|
74
|
+
*/
|
|
75
|
+
function fromHook(to, entry) {
|
|
76
|
+
if (!entry || typeof entry !== "object") return;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An MCP tool call. It names a resource, not a path, so it is recorded and
|
|
80
|
+
* never queued — which servers a role may load is a change to its `mcp`
|
|
81
|
+
* list, not a grant a person approves, the same shape as never_writes. The
|
|
82
|
+
* verdict is recomputed here from the same list rather than believed from
|
|
83
|
+
* inside the box: the server is parsed from the tool name and asked of the
|
|
84
|
+
* policy, so a line claiming "allowed" for a server the role may not load
|
|
85
|
+
* comes back marked `disputed`.
|
|
86
|
+
*/
|
|
87
|
+
if (to === "log" && entry.action === "use") {
|
|
88
|
+
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
89
|
+
if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
|
|
90
|
+
const server = mcpServer(entry.target);
|
|
91
|
+
const v = server ? explain(config, role, "mcp", server) : { allowed: false };
|
|
92
|
+
const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
|
|
93
|
+
return void log({
|
|
94
|
+
at: entry.at, role,
|
|
95
|
+
tool: String(entry.tool ?? "").slice(0, 40),
|
|
96
|
+
action: "use", kind: "tool",
|
|
97
|
+
target: entry.target.slice(0, 1000),
|
|
98
|
+
verdict: entry.verdict, owners: [],
|
|
99
|
+
reason: String(entry.reason ?? "").slice(0, 500),
|
|
100
|
+
...(expected !== entry.verdict ? { disputed: expected } : {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (entry.action !== "read" && entry.action !== "write") return;
|
|
105
|
+
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
106
|
+
|
|
107
|
+
if (to === "log") {
|
|
108
|
+
// The same treatment the queue already got. The role was overwritten but
|
|
109
|
+
// everything else was passed through, so a process inside the box could
|
|
110
|
+
// write "allowed" lines for paths it never touched — which would not move
|
|
111
|
+
// the boundary, but would move `review`, and `init --from-observations`
|
|
112
|
+
// builds a policy out of exactly this.
|
|
113
|
+
if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
|
|
114
|
+
/**
|
|
115
|
+
* The verdict is the hook's account of what it did, so it stays — but it
|
|
116
|
+
* is checked. The parent works out what the policy this run started with
|
|
117
|
+
* says, and when the two differ the line carries `disputed` with the
|
|
118
|
+
* parent's answer. Two causes, both worth seeing: the policy file changed
|
|
119
|
+
* since the run started (the hook reads it fresh, the kernel does not), or
|
|
120
|
+
* the line was not written by the hook at all.
|
|
121
|
+
*/
|
|
122
|
+
const expected = observe
|
|
123
|
+
? "observed"
|
|
124
|
+
: explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
|
|
125
|
+
return void log({
|
|
126
|
+
at: entry.at,
|
|
127
|
+
role,
|
|
128
|
+
tool: String(entry.tool ?? "").slice(0, 40),
|
|
129
|
+
action: entry.action,
|
|
130
|
+
kind: entry.kind === "key" ? "key" : "file",
|
|
131
|
+
target: entry.target.slice(0, 1000),
|
|
132
|
+
verdict: entry.verdict,
|
|
133
|
+
owners: ownersOf(config, entry.target), // recomputed, never taken
|
|
134
|
+
reason: String(entry.reason ?? "").slice(0, 500),
|
|
135
|
+
...(expected !== entry.verdict ? { disputed: expected } : {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The policy has to actually refuse it, or it does not belong in a queue.
|
|
141
|
+
*
|
|
142
|
+
* Nothing checked. A process inside the box could file a request for a path
|
|
143
|
+
* its own role already owns, and the queue would print *first refused on
|
|
144
|
+
* …* about something that was never refused — a sentence placed in front of
|
|
145
|
+
* a person for approval, describing an event that did not happen.
|
|
146
|
+
*
|
|
147
|
+
* Same treatment as `owners` above: the claim is recomputed here rather
|
|
148
|
+
* than believed. It is the policy answering a question about itself, which
|
|
149
|
+
* is arithmetic, and it is the parent asking — the one process the confined
|
|
150
|
+
* side cannot reach.
|
|
151
|
+
*
|
|
152
|
+
* This does not require the agent to have *tried*. Asking for a permission
|
|
153
|
+
* before reaching for it is a reasonable thing to do, and the wording says
|
|
154
|
+
* "asked" rather than "refused" for exactly that case. What it rules out is
|
|
155
|
+
* a request for something that is not refused at all — nor for a
|
|
156
|
+
* `never_writes` path, git's metadata, or a protected one, none of which a
|
|
157
|
+
* grant can open.
|
|
158
|
+
*/
|
|
159
|
+
const v = explain(config, role, entry.action, entry.target);
|
|
160
|
+
if (v.allowed || v.neverWrites || v.gitMetadata || v.protected) return;
|
|
161
|
+
// The owners explain found: for a key that is who declares it, not who
|
|
162
|
+
// writes the directory it sits in — the queue said "owned by dev" about
|
|
163
|
+
// backend's key because dev writes `**`.
|
|
164
|
+
ask({ role, action: entry.action, target: entry.target, owners: v.owners });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function fromKernel(d) {
|
|
168
|
+
/**
|
|
169
|
+
* A refused connection: logged, never queued.
|
|
170
|
+
*
|
|
171
|
+
* Once per target per run, because the thing that dials a closed port is
|
|
172
|
+
* usually a readiness loop, and a loop polling a database every half second
|
|
173
|
+
* would otherwise write the log the size of the wait. And no request: a port
|
|
174
|
+
* is not a territory anyone owns, so there is nobody to hand it to — the
|
|
175
|
+
* sentence says what the policy would have to change instead.
|
|
176
|
+
*/
|
|
177
|
+
if (d.action === "connect") {
|
|
178
|
+
if (connects.has(d.path)) return;
|
|
179
|
+
connects.add(d.path);
|
|
180
|
+
const v = explain(config, role, "connect", d.path);
|
|
181
|
+
log({
|
|
182
|
+
at: new Date().toISOString(), role, tool: "kernel", source: "kernel",
|
|
183
|
+
action: "connect", kind: "network", target: d.path, verdict: "denied",
|
|
184
|
+
owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!inScope(d.path, scope)) { stats.offPolicy++; return; }
|
|
189
|
+
if (!reachedForContent(d)) { stats.walks++; return; }
|
|
190
|
+
// Relative inside the repo, absolute outside it. A role can be refused at
|
|
191
|
+
// ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
|
|
192
|
+
// answer to "what was refused" than the path itself.
|
|
193
|
+
const rel = toRepoRelative(config, d.path);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A refusal the hook never saw still leaves a request behind.
|
|
197
|
+
*
|
|
198
|
+
* Without this the two halves of the record disagree in the worst
|
|
199
|
+
* direction: `review` would show a role stopped repeatedly on a directory
|
|
200
|
+
* while the queue held nothing to approve, so the one refusal a person most
|
|
201
|
+
* needed to see — the one that escaped the hook — would be the one with no
|
|
202
|
+
* way to act on it.
|
|
203
|
+
*
|
|
204
|
+
* There is no double counting to avoid. When the hook catches something it
|
|
205
|
+
* denies the tool call outright and the command never runs, so the kernel
|
|
206
|
+
* never sees it. These are the ones that got past it, which is the whole
|
|
207
|
+
* reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
|
|
208
|
+
* under `isolate` and is not a territory anyone is meant to ask for.
|
|
209
|
+
*/
|
|
210
|
+
const verdict = rel !== d.path && d.action === "write"
|
|
211
|
+
? explain(config, role, "write", rel)
|
|
212
|
+
: null;
|
|
213
|
+
const barred = verdict?.neverWrites ?? null;
|
|
214
|
+
const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
|
|
215
|
+
// A request for something the policy already grants cannot be approved into
|
|
216
|
+
// anything: it is misattribution or a kernel/policy mismatch, and either way
|
|
217
|
+
// the line in the log is the evidence, not a question for a person. The hook
|
|
218
|
+
// path has always had this check; the kernel path did not, which is how a
|
|
219
|
+
// role ended up asking for its own territory.
|
|
220
|
+
const granted = verdict?.allowed === true;
|
|
221
|
+
if (rel !== d.path && !barred && !unaskable && !granted)
|
|
222
|
+
ask({ role, action: d.action, target: rel, owners: ownersOf(config, rel) });
|
|
223
|
+
|
|
224
|
+
log({
|
|
225
|
+
at: new Date().toISOString(),
|
|
226
|
+
role,
|
|
227
|
+
tool: "kernel",
|
|
228
|
+
source: "kernel",
|
|
229
|
+
action: d.action,
|
|
230
|
+
kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
|
|
231
|
+
target: rel,
|
|
232
|
+
verdict: "denied",
|
|
233
|
+
owners: ownersOf(config, rel),
|
|
234
|
+
reason: d.operation,
|
|
235
|
+
// So the log can tell a subtraction doing its job from a missing permission,
|
|
236
|
+
// and a protected file from a territory somebody could be asked for.
|
|
237
|
+
...(barred ? { neverWrites: barred } : {}),
|
|
238
|
+
...(verdict?.protected ? { protected: verdict.protected } : {}),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { fromHook, fromKernel, stats };
|
|
243
|
+
}
|
package/src/keys.js
CHANGED
|
@@ -41,9 +41,21 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { spawnSync } from "node:child_process";
|
|
43
43
|
import { readFileSync } from "node:fs";
|
|
44
|
-
import { resolve, isAbsolute } from "node:path";
|
|
44
|
+
import { resolve, isAbsolute, delimiter } from "node:path";
|
|
45
45
|
import { BASE, DEFAULTS } from "./env.js";
|
|
46
46
|
import { SOCK_ENV } from "./spool.js";
|
|
47
|
+
import { resolveExecutable, trustedPath } from "./surface.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a provider may take. A minute, because the slow case is a person:
|
|
51
|
+
* `op` and the keychain can put a prompt in front of someone who is not
|
|
52
|
+
* looking at the screen yet. Before this there was no limit, and a provider
|
|
53
|
+
* stuck on a prompt nobody could see held the run with no message at all.
|
|
54
|
+
*/
|
|
55
|
+
export const PROVIDER_TIMEOUT_MS = 60_000;
|
|
56
|
+
|
|
57
|
+
/** A credential is bytes, not megabytes. Past this it is not a key. */
|
|
58
|
+
const PROVIDER_MAX_BYTES = 1024 * 1024;
|
|
47
59
|
|
|
48
60
|
/**
|
|
49
61
|
* `scheme://rest`, and nothing cleverer.
|
|
@@ -202,15 +214,78 @@ function fromJson(obj, key, ref) {
|
|
|
202
214
|
return String(v);
|
|
203
215
|
}
|
|
204
216
|
|
|
217
|
+
/**
|
|
218
|
+
* One `NAME=value` line's value, the way dotenv reads it — or a refusal.
|
|
219
|
+
*
|
|
220
|
+
* Compared against dotenv 16 on the same file (2026-09-23), three lines came
|
|
221
|
+
* back different, and a different value is a wrong credential handed over
|
|
222
|
+
* without a word:
|
|
223
|
+
*
|
|
224
|
+
* - `A=abc # comment` — dotenv drops the comment; this kept it.
|
|
225
|
+
* - a name defined twice — dotenv takes the last; this took the first.
|
|
226
|
+
* - a quoted value spanning lines — dotenv reads it whole; this returned
|
|
227
|
+
* the first line with its quote.
|
|
228
|
+
*
|
|
229
|
+
* The comment is read like dotenv, because there is one reading of it. The
|
|
230
|
+
* other two are refused, because loaders disagree about them and picking one
|
|
231
|
+
* is guessing which credential somebody meant.
|
|
232
|
+
*/
|
|
233
|
+
function dotEnvValue(raw, ref, key) {
|
|
234
|
+
const v = raw.trim();
|
|
235
|
+
const q = v[0];
|
|
236
|
+
if (q === '"' || q === "'" || q === "`") {
|
|
237
|
+
// The closing quote is the first one not escaped with a backslash, as in
|
|
238
|
+
// dotenv's own pattern: `"a\"b"` is one value, `a\"b`, not `a\`.
|
|
239
|
+
let end = -1;
|
|
240
|
+
for (let i = 1; i < v.length; i++) {
|
|
241
|
+
if (v[i] === "\\") { i++; continue; }
|
|
242
|
+
if (v[i] === q) { end = i; break; }
|
|
243
|
+
}
|
|
244
|
+
if (end === -1)
|
|
245
|
+
throw new Error(
|
|
246
|
+
`key "${ref}": ${key}= opens a ${q} quote that does not close on its line.\n` +
|
|
247
|
+
` A value across several lines is read differently by different loaders, so seisin ` +
|
|
248
|
+
`does not read it. Put the credential on one line, or in a file of its own.`);
|
|
249
|
+
// Only a comment may follow the closing quote. `A="a" b` is read by dotenv
|
|
250
|
+
// as the whole line, quotes included, and by others as `a`.
|
|
251
|
+
const rest = v.slice(end + 1).trim();
|
|
252
|
+
if (rest && !rest.startsWith("#"))
|
|
253
|
+
throw new Error(
|
|
254
|
+
`key "${ref}": ${key}= has text after its closing quote (${rest.slice(0, 20)}…).\n` +
|
|
255
|
+
` Loaders disagree on what that value is. Quote the whole value.`);
|
|
256
|
+
const inner = v.slice(1, end);
|
|
257
|
+
// Inside double quotes dotenv turns \n and \r into the characters — the
|
|
258
|
+
// shape a PEM key takes in a .env. Handed over literally, the key has a
|
|
259
|
+
// backslash and an n where its line breaks go, and it does not parse.
|
|
260
|
+
return q === '"' ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r") : inner;
|
|
261
|
+
}
|
|
262
|
+
const bare = v.replace(/\s+#.*$/, "");
|
|
263
|
+
// A `#` inside an unquoted value: dotenv 16 cuts the value there
|
|
264
|
+
// (`tok_#_123` → `tok_`), python-dotenv and docker compose keep it.
|
|
265
|
+
if (bare.includes("#"))
|
|
266
|
+
throw new Error(
|
|
267
|
+
`key "${ref}": the value of ${key}= contains a # without quotes.\n` +
|
|
268
|
+
` Some loaders read everything after it as a comment and some do not, so the ` +
|
|
269
|
+
`credential depends on who reads the file. Quote it: ${key}="…".`);
|
|
270
|
+
return bare;
|
|
271
|
+
}
|
|
272
|
+
|
|
205
273
|
function fromDotEnv(text, key, ref) {
|
|
206
274
|
let sawAny = false;
|
|
275
|
+
let found = null;
|
|
207
276
|
for (const line of text.split(/\r?\n/)) {
|
|
208
277
|
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
209
278
|
if (!m) continue;
|
|
210
279
|
sawAny = true;
|
|
211
280
|
if (m[1] !== key) continue;
|
|
212
|
-
|
|
281
|
+
if (found !== null)
|
|
282
|
+
throw new Error(
|
|
283
|
+
`key "${ref}": ${key}= is defined more than once in that file.\n` +
|
|
284
|
+
` dotenv takes the last one and other loaders the first, so which credential is meant ` +
|
|
285
|
+
`is not written down. Keep one.`);
|
|
286
|
+
found = dotEnvValue(m[2], ref, key);
|
|
213
287
|
}
|
|
288
|
+
if (found !== null) return found;
|
|
214
289
|
/**
|
|
215
290
|
* Two different failures, and telling them apart is the whole message.
|
|
216
291
|
*
|
|
@@ -437,16 +512,43 @@ export function modeOf(config, role, entry) {
|
|
|
437
512
|
* is — a provider that prints the secret and then exits non-zero would
|
|
438
513
|
* otherwise put it in the terminal and the log.
|
|
439
514
|
*/
|
|
440
|
-
export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}) {
|
|
515
|
+
export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env } = {}) {
|
|
441
516
|
if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
|
|
442
517
|
return readFileRef(entry, root);
|
|
443
518
|
const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
|
|
444
|
-
|
|
519
|
+
/**
|
|
520
|
+
* Found where no role can write, and handed the same PATH.
|
|
521
|
+
*
|
|
522
|
+
* `spawnSync("fakeprov")` searched the parent's PATH, and a PATH directory
|
|
523
|
+
* inside a territory made the next run execute whatever that role had put
|
|
524
|
+
* there — outside the box, as you. Measured on 2026-09-23 with a marker file.
|
|
525
|
+
* The provider's own PATH is narrowed the same way, because a provider that
|
|
526
|
+
* is a script starts with `#!/usr/bin/env bash` and looks its interpreter up
|
|
527
|
+
* again. Without a config (a caller embedding seisin), or with a `run` of the
|
|
528
|
+
* caller's own, the argv is handed over as given: finding the program is part
|
|
529
|
+
* of spawning it, and whoever replaced the spawn owns that too.
|
|
530
|
+
*/
|
|
531
|
+
const opts = { encoding: "utf8", timeout: PROVIDER_TIMEOUT_MS, maxBuffer: PROVIDER_MAX_BYTES };
|
|
532
|
+
if (config && run === spawnSync) {
|
|
533
|
+
argv[0] = resolveExecutable(argv[0], config, env);
|
|
534
|
+
opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
|
|
535
|
+
}
|
|
536
|
+
const r = run(argv[0], argv.slice(1), opts);
|
|
537
|
+
if (r.error?.code === "ETIMEDOUT")
|
|
538
|
+
throw new Error(
|
|
539
|
+
`key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
|
|
540
|
+
`${PROVIDER_TIMEOUT_MS / 1000} s and was stopped.\n` +
|
|
541
|
+
` A provider that waits on a prompt nobody sees would otherwise hold the run forever.`);
|
|
445
542
|
if (r.error)
|
|
446
543
|
throw new Error(
|
|
447
544
|
`key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
|
|
448
545
|
if (r.status !== 0) {
|
|
449
|
-
|
|
546
|
+
// Whatever it printed on stdout is taken out of stderr before stderr is
|
|
547
|
+
// shown: a provider that echoes the secret to both and then fails would
|
|
548
|
+
// otherwise put it in the terminal through the error meant to explain it.
|
|
549
|
+
const leaked = (r.stdout ?? "").trim();
|
|
550
|
+
const clean = leaked.length >= 4 ? (r.stderr ?? "").split(leaked).join("‹redacted›") : (r.stderr ?? "");
|
|
551
|
+
const said = clean.trim().split("\n").slice(0, 3).map((l) => l.slice(0, 200)).join("\n ");
|
|
450
552
|
throw new Error(
|
|
451
553
|
`key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}.` +
|
|
452
554
|
(said ? `\n ${said}` : "") +
|
|
@@ -473,7 +575,7 @@ export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}
|
|
|
473
575
|
* should say so without asking anyone's keychain for a password first.
|
|
474
576
|
*/
|
|
475
577
|
export function resolveKeys(config, role, opts = {}) {
|
|
476
|
-
opts = { root: config.root ?? ".", ...opts };
|
|
578
|
+
opts = { root: config.root ?? ".", config, ...opts };
|
|
477
579
|
const refs = role.keyEntries.filter((e) => e.kind === "ref");
|
|
478
580
|
const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
|
|
479
581
|
return plan.map((p) => ({
|
package/src/log.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* tries the parent's socket first for exactly that reason; writing the file
|
|
14
14
|
* directly is what happens when there is no parent, outside the box.
|
|
15
15
|
*/
|
|
16
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
17
|
-
import {
|
|
16
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { basename, dirname, join } from "node:path";
|
|
18
19
|
import { STATE_DIR, LOG_NAME } from "./layout.js";
|
|
19
20
|
import { send } from "./spool.js";
|
|
20
21
|
|
|
@@ -37,13 +38,211 @@ export function append(file, entry) {
|
|
|
37
38
|
if (send("log", entry)) return true;
|
|
38
39
|
try {
|
|
39
40
|
mkdirSync(dirname(file), { recursive: true });
|
|
40
|
-
|
|
41
|
+
withLock(file, () => {
|
|
42
|
+
// Rotate first, under the same lock: a full segment is renamed away and a
|
|
43
|
+
// fresh one seeded so the chain continues, then this line lands in the new
|
|
44
|
+
// segment. Done here so the size check and the append never race.
|
|
45
|
+
maybeRotate(file);
|
|
46
|
+
// `prev` goes last and is computed from the previous line exactly as it
|
|
47
|
+
// sits on disk, so verifying needs nothing but the file.
|
|
48
|
+
const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
|
|
49
|
+
appendFileSync(file, line + "\n");
|
|
50
|
+
});
|
|
41
51
|
return true;
|
|
42
52
|
} catch {
|
|
43
53
|
return false;
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
/**
|
|
58
|
+
* When the current segment fills, retire it and start a fresh one that still
|
|
59
|
+
* chains.
|
|
60
|
+
*
|
|
61
|
+
* An append-only file that only grows is the right default and the wrong
|
|
62
|
+
* forever: a busy team writes megabytes of it, and `verify` walks all of them
|
|
63
|
+
* on every run. Rotation caps the size of the file three tools tail while
|
|
64
|
+
* keeping the property that made the chain worth having — that a deletion shows.
|
|
65
|
+
*
|
|
66
|
+
* The retired segment keeps its own chain intact; the new segment's first line
|
|
67
|
+
* is a `rotated` marker whose `prev` is the retired segment's last hash, so the
|
|
68
|
+
* two verify as one chain across the cut (`verifyLog`). The comment on GENESIS
|
|
69
|
+
* asked for exactly this. Segments are numbered upward, `.1` the oldest, and
|
|
70
|
+
* the oldest beyond the keep count are dropped — the only place seisin removes
|
|
71
|
+
* a record, and it removes whole retired segments, never a line.
|
|
72
|
+
*/
|
|
73
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB per segment
|
|
74
|
+
const DEFAULT_KEEP = 5; // retired segments kept beside the current one
|
|
75
|
+
|
|
76
|
+
function envInt(name, fallback) {
|
|
77
|
+
const n = Number(process.env[name]);
|
|
78
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every segment of the log, oldest → newest, current last. */
|
|
82
|
+
export function logSegments(file) {
|
|
83
|
+
const dir = dirname(file);
|
|
84
|
+
const base = basename(file);
|
|
85
|
+
let names = [];
|
|
86
|
+
try { names = readdirSync(dir); } catch { return existsSync(file) ? [file] : []; }
|
|
87
|
+
const archives = names
|
|
88
|
+
.map((n) => {
|
|
89
|
+
if (!n.startsWith(base + ".")) return null;
|
|
90
|
+
const seq = Number(n.slice(base.length + 1));
|
|
91
|
+
return Number.isInteger(seq) && seq > 0 ? { seq, path: join(dir, n) } : null;
|
|
92
|
+
})
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.sort((a, b) => a.seq - b.seq)
|
|
95
|
+
.map((a) => a.path);
|
|
96
|
+
if (existsSync(file)) archives.push(file);
|
|
97
|
+
return archives;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function maybeRotate(file) {
|
|
101
|
+
let sz = 0;
|
|
102
|
+
try { sz = statSync(file).size; } catch { return; }
|
|
103
|
+
if (sz < envInt("SEISIN_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)) return;
|
|
104
|
+
|
|
105
|
+
const tail = hashOf(lastLine(file));
|
|
106
|
+
const archives = logSegments(file).filter((p) => p !== file);
|
|
107
|
+
const seqs = archives.map((p) => Number(p.slice((file + ".").length))).filter(Number.isInteger);
|
|
108
|
+
const next = (seqs.length ? Math.max(...seqs) : 0) + 1;
|
|
109
|
+
renameSync(file, `${file}.${next}`);
|
|
110
|
+
// Seed the new current so the first real append chains from the retired tail.
|
|
111
|
+
const marker = JSON.stringify({ at: new Date().toISOString(), event: "rotated", from: `${basename(file)}.${next}`, prev: tail });
|
|
112
|
+
appendFileSync(file, marker + "\n");
|
|
113
|
+
|
|
114
|
+
const keep = envInt("SEISIN_LOG_KEEP", DEFAULT_KEEP);
|
|
115
|
+
const retired = logSegments(file).filter((p) => p !== file); // oldest → newest, includes the new archive
|
|
116
|
+
for (let i = 0; i < retired.length - keep; i++) { try { unlinkSync(retired[i]); } catch {} }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The log is chained: every line carries `prev`, the hash of the line before it.
|
|
121
|
+
*
|
|
122
|
+
* The log is the record of who touched what and who was refused what, and it
|
|
123
|
+
* could be edited without a trace. A chain does not stop anyone with write
|
|
124
|
+
* access from rewriting it — nothing inside a file can — but it makes an edit,
|
|
125
|
+
* a deletion or a reordering show, and `seisin log verify` says where. Taken
|
|
126
|
+
* from nono's audit trail; the Merkle root and the signature it adds on top
|
|
127
|
+
* are left for when someone asks for them.
|
|
128
|
+
*
|
|
129
|
+
* The first chained line of a file points at GENESIS. A file started by
|
|
130
|
+
* rotation should point at the last line of the one before it; there is no
|
|
131
|
+
* rotation yet, and when there is, that is what it has to do.
|
|
132
|
+
*/
|
|
133
|
+
export const GENESIS = "0".repeat(32);
|
|
134
|
+
|
|
135
|
+
export function hashOf(line) {
|
|
136
|
+
return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The last complete line of the file, or null when there is none. */
|
|
140
|
+
function lastLine(file) {
|
|
141
|
+
if (!existsSync(file)) return null;
|
|
142
|
+
const { size } = statSync(file);
|
|
143
|
+
if (size === 0) return null;
|
|
144
|
+
const from = Math.max(0, size - 64 * 1024);
|
|
145
|
+
const fd = openSync(file, "r");
|
|
146
|
+
try {
|
|
147
|
+
const buf = Buffer.alloc(size - from);
|
|
148
|
+
readSync(fd, buf, 0, buf.length, from);
|
|
149
|
+
const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
|
|
150
|
+
return lines.length ? lines[lines.length - 1] : null;
|
|
151
|
+
} finally {
|
|
152
|
+
closeSync(fd);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One writer at a time. Several roles of a round run at once and write this
|
|
158
|
+
* same file; two of them reading the same last line would fork the chain.
|
|
159
|
+
* A lock left by a killed process is taken over after a few seconds.
|
|
160
|
+
*/
|
|
161
|
+
function withLock(file, fn) {
|
|
162
|
+
const lock = file + ".lock";
|
|
163
|
+
const deadline = Date.now() + 2000;
|
|
164
|
+
for (;;) {
|
|
165
|
+
try {
|
|
166
|
+
closeSync(openSync(lock, "wx"));
|
|
167
|
+
break;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e.code !== "EEXIST") throw e;
|
|
170
|
+
try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
|
|
171
|
+
if (Date.now() > deadline) break; // never block the run over bookkeeping
|
|
172
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Walk the file and say where the chain breaks.
|
|
180
|
+
*
|
|
181
|
+
* Lines written before the chain existed have no `prev`; they are counted as an
|
|
182
|
+
* unchained prefix, not reported as tampering. After the first chained line,
|
|
183
|
+
* a line without `prev` is itself a break.
|
|
184
|
+
*/
|
|
185
|
+
function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
|
|
186
|
+
const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
|
|
187
|
+
if (!existsSync(file)) return out;
|
|
188
|
+
let previous = null;
|
|
189
|
+
let started = false;
|
|
190
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
191
|
+
if (!line.trim()) continue;
|
|
192
|
+
out.lines++;
|
|
193
|
+
let e = null;
|
|
194
|
+
try { e = JSON.parse(line); } catch {}
|
|
195
|
+
const prev = e?.prev;
|
|
196
|
+
if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
|
|
197
|
+
started = true;
|
|
198
|
+
const first = out.chained === 0 && out.unchained === 0;
|
|
199
|
+
// A rotation marker leading a segment we have nothing to check it against —
|
|
200
|
+
// a file verified on its own, or the oldest segment left after the prefix
|
|
201
|
+
// was pruned for retention (seed still GENESIS) — is accepted rather than
|
|
202
|
+
// called a break. For any later segment the seed is the real previous tail,
|
|
203
|
+
// so the marker's `prev` IS checked, and a whole segment deleted or
|
|
204
|
+
// reordered in the middle shows.
|
|
205
|
+
const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
|
|
206
|
+
const expected = first ? seed : hashOf(previous);
|
|
207
|
+
const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
|
|
208
|
+
if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
|
|
209
|
+
out.chained++;
|
|
210
|
+
previous = line;
|
|
211
|
+
}
|
|
212
|
+
out.tail = previous;
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function verifyChain(file, seed = GENESIS) {
|
|
217
|
+
const r = walk(file, { seed });
|
|
218
|
+
return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Verify the whole log, across every rotated segment, as one chain.
|
|
223
|
+
*
|
|
224
|
+
* Each retired segment is walked in order, and its last hash becomes the seed
|
|
225
|
+
* the next segment's `rotated` marker must match — so deleting or reordering a
|
|
226
|
+
* whole segment shows, not only an edit inside one. With no rotation there is a
|
|
227
|
+
* single segment and this is exactly `verifyChain`. What it cannot see is a
|
|
228
|
+
* segment dropped off the *oldest* end by the keep limit: that is retention, not
|
|
229
|
+
* tampering, and the count of segments is reported so the drop is not silent.
|
|
230
|
+
*/
|
|
231
|
+
export function verifyLog(file) {
|
|
232
|
+
const segs = logSegments(file);
|
|
233
|
+
const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length };
|
|
234
|
+
let seed = GENESIS;
|
|
235
|
+
for (const seg of segs) {
|
|
236
|
+
const r = walk(seg, { seed, startLine: agg.lines });
|
|
237
|
+
agg.lines += r.lines;
|
|
238
|
+
agg.unchained += r.unchained;
|
|
239
|
+
agg.chained += r.chained;
|
|
240
|
+
agg.breaks.push(...r.breaks);
|
|
241
|
+
seed = hashOf(r.tail);
|
|
242
|
+
}
|
|
243
|
+
return agg;
|
|
244
|
+
}
|
|
245
|
+
|
|
47
246
|
/** Reads entries, newest last. A malformed line is skipped, never fatal. */
|
|
48
247
|
export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
49
248
|
if (!existsSync(file)) return [];
|
|
@@ -56,6 +255,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
|
56
255
|
} catch {
|
|
57
256
|
continue; // a half-written line from a killed process is not an error
|
|
58
257
|
}
|
|
258
|
+
if (e.event === "rotated") continue; // a structural marker, not a decision
|
|
59
259
|
if (role && e.role !== role) continue;
|
|
60
260
|
if (verdict && e.verdict !== verdict) continue;
|
|
61
261
|
if (since && e.at < since) continue;
|