seisin 0.3.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 +1 -1
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands/log.js +4 -3
- package/src/commands/ui.js +59 -7
- package/src/hook.js +50 -0
- package/src/intake.js +28 -0
- package/src/log.js +113 -7
- package/src/serve.js +3 -1
- package/src/uilink.js +55 -0
package/README.md
CHANGED
|
@@ -704,7 +704,7 @@ measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run
|
|
|
704
704
|
|
|
705
705
|
## Status
|
|
706
706
|
|
|
707
|
-
`0.
|
|
707
|
+
`0.4.0`, 470 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
|
|
708
708
|
and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
|
|
709
709
|
a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
|
|
710
710
|
those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -57,7 +57,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
57
57
|
seisin log verify does the log's hash chain hold
|
|
58
58
|
seisin watch follow the log live
|
|
59
59
|
seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
|
|
60
|
-
seisin ui [--port n]
|
|
60
|
+
seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
|
|
61
61
|
seisin mcp an MCP server on stdio — read-only
|
|
62
62
|
|
|
63
63
|
Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
|
package/src/commands/log.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createReadStream, watch as watchDir } from "node:fs";
|
|
12
12
|
import { dirname, relative } from "node:path";
|
|
13
|
-
import { read, logPath, size,
|
|
13
|
+
import { read, logPath, size, verifyLog } from "../log.js";
|
|
14
14
|
import { renderEntry, C, out } from "../render.js";
|
|
15
15
|
|
|
16
16
|
const flag = (argv, name) => {
|
|
@@ -43,14 +43,15 @@ export function log(config, argv = []) {
|
|
|
43
43
|
* before the chain existed are reported as such, not as tampering.
|
|
44
44
|
*/
|
|
45
45
|
function verify(config) {
|
|
46
|
-
const r =
|
|
46
|
+
const r = verifyLog(logPath(config.root));
|
|
47
47
|
if (r.lines === 0) {
|
|
48
48
|
out(`\n ${C.dim}nothing recorded yet${C.off}\n\n`);
|
|
49
49
|
return r;
|
|
50
50
|
}
|
|
51
51
|
const head = r.unchained ? `${r.unchained} line(s) from before the chain, then ` : "";
|
|
52
|
+
const segs = r.segments > 1 ? ` across ${r.segments} segments` : "";
|
|
52
53
|
if (!r.breaks.length) {
|
|
53
|
-
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)\n\n`);
|
|
54
|
+
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n\n`);
|
|
54
55
|
return r;
|
|
55
56
|
}
|
|
56
57
|
out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
|
package/src/commands/ui.js
CHANGED
|
@@ -5,22 +5,72 @@
|
|
|
5
5
|
* foreground command you start and stop, serving one page and one JSON
|
|
6
6
|
* document, on loopback. Nothing in the enforcement path touches it. If it is
|
|
7
7
|
* not running you lose a window, not a boundary.
|
|
8
|
+
*
|
|
9
|
+
* The token that authorizes the page travels only in the link's fragment and is
|
|
10
|
+
* never in an HTTP response (see serve.js). So a lost link cannot be recovered
|
|
11
|
+
* over the wire — only from the terminal that printed it. `writeUiLink` keeps a
|
|
12
|
+
* user-only copy on disk so it can be recovered without reopening that leak:
|
|
13
|
+
* `--link` prints it, and starting over a busy port reopens it instead of just
|
|
14
|
+
* failing. See uilink.js.
|
|
8
15
|
*/
|
|
9
16
|
import { spawnSync } from "node:child_process";
|
|
10
17
|
import { relative } from "node:path";
|
|
11
18
|
import { serve } from "../serve.js";
|
|
19
|
+
import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
|
|
12
20
|
import { C, out } from "../render.js";
|
|
13
21
|
|
|
22
|
+
function open(url) {
|
|
23
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
24
|
+
spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export async function ui(config, argv = []) {
|
|
15
28
|
const i = argv.indexOf("--port");
|
|
16
29
|
const port = i === -1 ? 4178 : Number(argv[i + 1]);
|
|
17
30
|
|
|
18
|
-
|
|
31
|
+
// `seisin ui --link`: print the live link a running console left, and open
|
|
32
|
+
// it, without starting anything. For when the terminal that had it is gone.
|
|
33
|
+
if (argv.includes("--link")) {
|
|
34
|
+
const found = readUiLink(port);
|
|
35
|
+
if (found && found.alive !== false) {
|
|
36
|
+
out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n\n`);
|
|
37
|
+
open(found.url);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (found && found.alive === false) clearUiLink(port);
|
|
41
|
+
out(`\n ${C.dim}no seisin ui running on ${port} — start one with: seisin ui${port === 4178 ? "" : ` --port ${port}`}${C.off}\n\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let server;
|
|
46
|
+
try {
|
|
47
|
+
server = await serve(config.path, port);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// A console is already on this port: hand back its live link rather than
|
|
50
|
+
// erroring, which is the case that sends people digging in the terminal.
|
|
51
|
+
if (e.code === "EADDRINUSE") {
|
|
52
|
+
const found = readUiLink(port);
|
|
53
|
+
if (found && found.alive !== false) {
|
|
54
|
+
out(
|
|
55
|
+
`\n ${C.b}${found.url}${C.off}\n` +
|
|
56
|
+
` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n\n`
|
|
57
|
+
);
|
|
58
|
+
open(found.url);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// The port is busy but no live console left a link — a stale record, or
|
|
62
|
+
// something else is on the port. Drop the stale one and let the original
|
|
63
|
+
// "port is busy" message stand.
|
|
64
|
+
if (found && found.alive === false) clearUiLink(port);
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
|
|
19
69
|
const bound = server.address().port;
|
|
20
|
-
// The token rides in the fragment, which the browser keeps to itself: no
|
|
21
|
-
// request to this server, or to anything else, ever carries it. The page
|
|
22
|
-
// moves it into the tab's storage and strips it from the address bar.
|
|
23
70
|
const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
|
|
71
|
+
// Recorded before we print, so `--link` and a second `seisin ui` can find it.
|
|
72
|
+
writeUiLink(bound, url);
|
|
73
|
+
|
|
24
74
|
// local_binding opens every port (macOS); local_ports opens the ones it
|
|
25
75
|
// names, on every platform — so only a role that names THIS port counts.
|
|
26
76
|
const reach = Object.values(config.roles)
|
|
@@ -31,15 +81,17 @@ export async function ui(config, argv = []) {
|
|
|
31
81
|
`\n ${C.b}${url}${C.off}\n` +
|
|
32
82
|
` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
|
|
33
83
|
` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
|
|
84
|
+
` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
|
|
34
85
|
(reach.length
|
|
35
86
|
? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
|
|
36
87
|
`but not the token, which only travels in the link above.${C.off}\n`
|
|
37
88
|
: "") + "\n"
|
|
38
89
|
);
|
|
39
90
|
|
|
40
|
-
|
|
41
|
-
spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
91
|
+
open(url);
|
|
42
92
|
|
|
43
|
-
|
|
93
|
+
const stop = () => { clearUiLink(bound); server.close(); process.exit(0); };
|
|
94
|
+
process.on("SIGINT", stop);
|
|
95
|
+
process.on("SIGTERM", stop);
|
|
44
96
|
return server;
|
|
45
97
|
}
|
package/src/hook.js
CHANGED
|
@@ -38,12 +38,41 @@ const FILE_TOOLS = {
|
|
|
38
38
|
const REDIRECT = /(?:^|\s|;|&&|\|\|)>{1,2}\s*("[^"]+"|'[^']+'|[^\s;|&<>]+)/g;
|
|
39
39
|
const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|truncate|dd)\s+([^\n;|&]+)/g;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The server in an `mcp__<server>__<tool>` name, or null if it does not parse.
|
|
43
|
+
*
|
|
44
|
+
* The split is on the double underscore: a server name carries single ones
|
|
45
|
+
* (`claude_ai_Gmail`) and so does a tool name (`execute_sql`), so the server is
|
|
46
|
+
* everything between `mcp__` and the next `__`. Shared with the parent's intake,
|
|
47
|
+
* which recomputes the same verdict rather than trusting the line from the box.
|
|
48
|
+
*/
|
|
49
|
+
export function mcpServer(tool) {
|
|
50
|
+
const m = typeof tool === "string" && /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
51
|
+
return m ? m[1] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function targetsOf(tool, input = {}) {
|
|
42
55
|
const kind = FILE_TOOLS[tool];
|
|
43
56
|
if (kind) {
|
|
44
57
|
const p = input.file_path ?? input.path ?? input.notebook_path;
|
|
45
58
|
return p ? [{ action: kind, path: p }] : [];
|
|
46
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* An MCP tool call names a resource, not a path: `mcp__<server>__<tool>`.
|
|
62
|
+
*
|
|
63
|
+
* The kernel never sees it — the MCP server runs outside the box — so a call
|
|
64
|
+
* that goes unrecorded here goes unrecorded everywhere. Measured 2026-09-24:
|
|
65
|
+
* the event reaches PreToolUse with the tool's own arguments as `tool_input`,
|
|
66
|
+
* which is exactly why the file-tool code above returns nothing for it. We
|
|
67
|
+
* resolve the call to the *server* it belongs to, the unit the policy already
|
|
68
|
+
* declares in `mcp = [...]`, and leave the finer resource (which table, which
|
|
69
|
+
* project) for later — deriving it means a per-server adapter, and that is the
|
|
70
|
+
* line past which this stops being "whose is it" and becomes a tool gateway.
|
|
71
|
+
* A name that does not parse is returned as an unknown tool, not dropped.
|
|
72
|
+
*/
|
|
73
|
+
if (typeof tool === "string" && tool.startsWith("mcp__")) {
|
|
74
|
+
return [{ action: "use", tool, server: mcpServer(tool) }];
|
|
75
|
+
}
|
|
47
76
|
if (tool !== "Bash" || typeof input.command !== "string") return [];
|
|
48
77
|
|
|
49
78
|
const out = [];
|
|
@@ -140,6 +169,25 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
140
169
|
const verdicts = [];
|
|
141
170
|
|
|
142
171
|
for (const t of targets) {
|
|
172
|
+
// A tool call, resolved to its MCP server. "Whose is it" is the question
|
|
173
|
+
// `mcp = [...]` already answers, so it is asked through the same map rather
|
|
174
|
+
// than a second one. No request is filed on a denial: a server absent from
|
|
175
|
+
// a role's list is a policy subtraction a person edits — the shape of
|
|
176
|
+
// never_writes — not a grant a person approves, and filing one would be a
|
|
177
|
+
// request nobody can grant. An unparsable name is closed, not waved through.
|
|
178
|
+
if (t.action === "use") {
|
|
179
|
+
const v = t.server
|
|
180
|
+
? explain(config, role, "mcp", t.server)
|
|
181
|
+
: { allowed: false, owners: [], mcp: true, reason: `${t.tool} is not a recognisable mcp__<server>__<tool> name` };
|
|
182
|
+
now(file, {
|
|
183
|
+
role, tool: event.tool_name, action: "use", kind: "tool", target: t.tool,
|
|
184
|
+
verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
|
|
185
|
+
owners: v.owners ?? [], reason: v.reason,
|
|
186
|
+
});
|
|
187
|
+
verdicts.push({ ...v, target: t.tool, kind: "tool", action: "use" });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
143
191
|
const rel = toRepoRelative(config, t.path);
|
|
144
192
|
const kind = kindOf(config, rel);
|
|
145
193
|
|
|
@@ -225,6 +273,8 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
225
273
|
? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
|
|
226
274
|
: denied.gitMetadata
|
|
227
275
|
? `${denied.reason}. Nothing was queued.` + again
|
|
276
|
+
: denied.mcp
|
|
277
|
+
? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not a grant they approve.` + again
|
|
228
278
|
: (denied.owners?.length
|
|
229
279
|
? wasRead
|
|
230
280
|
? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
|
package/src/intake.js
CHANGED
|
@@ -30,6 +30,7 @@ import { explain, ownersOf } from "./owners.js";
|
|
|
30
30
|
import { append, logPath } from "./log.js";
|
|
31
31
|
import { record, requestsPath } from "./requests.js";
|
|
32
32
|
import { inScope, scopeOf, reachedForContent } from "./violations.js";
|
|
33
|
+
import { mcpServer } from "./hook.js";
|
|
33
34
|
|
|
34
35
|
/** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
|
|
35
36
|
export function policyId(config) {
|
|
@@ -73,6 +74,33 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
73
74
|
*/
|
|
74
75
|
function fromHook(to, entry) {
|
|
75
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
|
+
|
|
76
104
|
if (entry.action !== "read" && entry.action !== "write") return;
|
|
77
105
|
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
78
106
|
|
package/src/log.js
CHANGED
|
@@ -13,9 +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, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
18
|
+
import { basename, dirname, join } from "node:path";
|
|
19
19
|
import { STATE_DIR, LOG_NAME } from "./layout.js";
|
|
20
20
|
import { send } from "./spool.js";
|
|
21
21
|
|
|
@@ -39,6 +39,10 @@ export function append(file, entry) {
|
|
|
39
39
|
try {
|
|
40
40
|
mkdirSync(dirname(file), { recursive: true });
|
|
41
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);
|
|
42
46
|
// `prev` goes last and is computed from the previous line exactly as it
|
|
43
47
|
// sits on disk, so verifying needs nothing but the file.
|
|
44
48
|
const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
|
|
@@ -50,6 +54,68 @@ export function append(file, entry) {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
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
|
+
|
|
53
119
|
/**
|
|
54
120
|
* The log is chained: every line carries `prev`, the hash of the line before it.
|
|
55
121
|
*
|
|
@@ -116,8 +182,8 @@ function withLock(file, fn) {
|
|
|
116
182
|
* unchained prefix, not reported as tampering. After the first chained line,
|
|
117
183
|
* a line without `prev` is itself a break.
|
|
118
184
|
*/
|
|
119
|
-
|
|
120
|
-
const out = { lines: 0, unchained: 0, chained: 0, breaks: [] };
|
|
185
|
+
function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
|
|
186
|
+
const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
|
|
121
187
|
if (!existsSync(file)) return out;
|
|
122
188
|
let previous = null;
|
|
123
189
|
let started = false;
|
|
@@ -129,15 +195,54 @@ export function verifyChain(file) {
|
|
|
129
195
|
const prev = e?.prev;
|
|
130
196
|
if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
|
|
131
197
|
started = true;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
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 });
|
|
135
209
|
out.chained++;
|
|
136
210
|
previous = line;
|
|
137
211
|
}
|
|
212
|
+
out.tail = previous;
|
|
138
213
|
return out;
|
|
139
214
|
}
|
|
140
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
|
+
|
|
141
246
|
/** Reads entries, newest last. A malformed line is skipped, never fatal. */
|
|
142
247
|
export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
143
248
|
if (!existsSync(file)) return [];
|
|
@@ -150,6 +255,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
|
150
255
|
} catch {
|
|
151
256
|
continue; // a half-written line from a killed process is not an error
|
|
152
257
|
}
|
|
258
|
+
if (e.event === "rotated") continue; // a structural marker, not a decision
|
|
153
259
|
if (role && e.role !== role) continue;
|
|
154
260
|
if (verdict && e.verdict !== verdict) continue;
|
|
155
261
|
if (since && e.at < since) continue;
|
package/src/serve.js
CHANGED
|
@@ -406,7 +406,9 @@ export function serve(configPath, port = 4178) {
|
|
|
406
406
|
});
|
|
407
407
|
server.on("error", (e) =>
|
|
408
408
|
fail(e.code === "EADDRINUSE"
|
|
409
|
-
|
|
409
|
+
// Keep the code on the rejection: `seisin ui` reads it to tell "a
|
|
410
|
+
// console is already here, reopen its link" from any other failure.
|
|
411
|
+
? Object.assign(new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`), { code: "EADDRINUSE" })
|
|
410
412
|
: e));
|
|
411
413
|
});
|
|
412
414
|
}
|
package/src/uilink.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a running `seisin ui` leaves its live link, so it can be found again.
|
|
3
|
+
*
|
|
4
|
+
* The console's token never travels in an HTTP response — that was the whole
|
|
5
|
+
* point of the redesign in serve.js: a page that could GET `/` used to walk
|
|
6
|
+
* away with it. The cost was that a tab which lost the fragment had nowhere to
|
|
7
|
+
* recover the token from but the terminal that printed it once. When that
|
|
8
|
+
* terminal is gone, so is the link.
|
|
9
|
+
*
|
|
10
|
+
* This is the recovery channel that does not reopen the leak: the link (token
|
|
11
|
+
* and all) is written to a file only this user can read — under `$TMPDIR/snr/`,
|
|
12
|
+
* the same 0700 directory runs already keep their sockets and keys in — and
|
|
13
|
+
* never sent over the wire. `seisin ui` on a busy port reads it and reopens the
|
|
14
|
+
* live link instead of failing; `seisin ui --link` prints it. The token now
|
|
15
|
+
* rests on disk (0600) for the life of the run rather than only in memory, a
|
|
16
|
+
* bounded, local-user exposure, and it is removed when the run exits.
|
|
17
|
+
*
|
|
18
|
+
* The pid is stored so a file left by a crashed run is recognisable: a link
|
|
19
|
+
* whose writer is gone is stale, not a running console.
|
|
20
|
+
*/
|
|
21
|
+
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { runsRoot } from "./rundir.js";
|
|
24
|
+
|
|
25
|
+
function linkFile(port, base) {
|
|
26
|
+
return join(runsRoot(base), `ui-${port}.url`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Records this run's live URL for the port it bound. */
|
|
30
|
+
export function writeUiLink(port, url, base) {
|
|
31
|
+
writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid }) + "\n", { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The live URL a `seisin ui` left for this port, with whether its writer is
|
|
36
|
+
* still alive — or null if there is no readable record.
|
|
37
|
+
*/
|
|
38
|
+
export function readUiLink(port, base) {
|
|
39
|
+
let raw;
|
|
40
|
+
try { raw = readFileSync(linkFile(port, base), "utf8"); } catch { return null; }
|
|
41
|
+
let e;
|
|
42
|
+
try { e = JSON.parse(raw); } catch { return null; }
|
|
43
|
+
if (!e || typeof e.url !== "string") return null;
|
|
44
|
+
let alive = null;
|
|
45
|
+
if (typeof e.pid === "number") {
|
|
46
|
+
// Signal 0 tests for the process without touching it: it throws if gone.
|
|
47
|
+
try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
|
|
48
|
+
}
|
|
49
|
+
return { url: e.url, pid: e.pid ?? null, alive };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Removes the record. Called on exit, and when a stale one is found. */
|
|
53
|
+
export function clearUiLink(port, base) {
|
|
54
|
+
try { unlinkSync(linkFile(port, base)); } catch {}
|
|
55
|
+
}
|