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/commands/init.js
CHANGED
|
@@ -11,62 +11,120 @@
|
|
|
11
11
|
* The second is better and needs a run first. Neither writes a policy you did
|
|
12
12
|
* not read: `--from-observations` lands as `.observed`, not as the config.
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { join, relative } from "node:path";
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync, appendFileSync } from "node:fs";
|
|
15
|
+
import { join, relative, resolve, isAbsolute } from "node:path";
|
|
16
16
|
import { CONFIG_NAME, tomlString, tomlName } from "../layout.js";
|
|
17
17
|
import { read, logPath, observed, generalise } from "../log.js";
|
|
18
18
|
import { C, out } from "../render.js";
|
|
19
|
+
import { loadConfig } from "../config.js";
|
|
20
|
+
import { applyGrant } from "../requests.js";
|
|
21
|
+
import { setKey } from "../controls.js";
|
|
22
|
+
import { keyName } from "../owners.js";
|
|
23
|
+
import { toRepoRelative } from "../paths.js";
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Three places a repo already says who does what, in order of how much it
|
|
22
27
|
* actually means. None is a policy, so all of them are proposals.
|
|
23
28
|
*/
|
|
24
29
|
export function discover(root) {
|
|
30
|
+
const keyDir = KEY_DIRS.find((d) => isDir(join(root, d))) ?? null;
|
|
25
31
|
const agents = join(root, ".claude", "agents");
|
|
26
32
|
if (existsSync(agents)) {
|
|
27
33
|
const roles = readdirSync(agents)
|
|
28
34
|
.filter((f) => f.endsWith(".md"))
|
|
29
35
|
.map((f) => ({ name: f.replace(/\.md$/, ""), writes: [], keys: [] }));
|
|
30
|
-
if (roles.length) return { source: ".claude/agents/", roles };
|
|
36
|
+
if (roles.length) return { source: ".claude/agents/", roles, agents: roles.length, keyDir };
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
for (const p of ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]) {
|
|
34
40
|
const file = join(root, p);
|
|
35
41
|
if (!existsSync(file)) continue;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
.map((l) => l.replace(/#.*$/, "").trim())
|
|
39
|
-
.filter(Boolean)
|
|
40
|
-
.map((l) => l.split(/\s+/)[0])
|
|
41
|
-
.filter((g) => g && g !== "*");
|
|
42
|
-
if (paths.length)
|
|
43
|
-
return {
|
|
44
|
-
source: p,
|
|
45
|
-
roles: [...new Set(paths)].slice(0, 8).map((g, i) => {
|
|
46
|
-
// CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
|
|
47
|
-
// are already repo-relative, so a literal "/apps/**" would ask the
|
|
48
|
-
// kernel for the machine's /apps and make `explain` (which reads it as
|
|
49
|
-
// outside the repo) disagree with what the profile grants. Strip the
|
|
50
|
-
// anchor. A trailing "/" means a directory, so it becomes "/**".
|
|
51
|
-
const rel = g.replace(/^\/+/, "");
|
|
52
|
-
return {
|
|
53
|
-
name: rel.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
|
|
54
|
-
writes: [rel.endsWith("/") ? rel + "**" : rel],
|
|
55
|
-
keys: [],
|
|
56
|
-
};
|
|
57
|
-
}),
|
|
58
|
-
};
|
|
42
|
+
const roles = fromCodeowners(readFileSync(file, "utf8"));
|
|
43
|
+
if (roles.length) return { source: p, roles, agents: 0, keyDir };
|
|
59
44
|
}
|
|
60
45
|
|
|
46
|
+
const dirs = codeDirs(root);
|
|
61
47
|
return {
|
|
62
|
-
source:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
48
|
+
source: dirs.length
|
|
49
|
+
? "the top-level folders that hold code — one role each"
|
|
50
|
+
: "nothing to read — this is a blank start",
|
|
51
|
+
roles: dirs.length
|
|
52
|
+
? dirs.map((d) => ({ name: roleName(d), writes: [`${d}/**`], keys: [] }))
|
|
53
|
+
: [{ name: "agent", writes: [], keys: [] }],
|
|
54
|
+
agents: 0,
|
|
55
|
+
keyDir,
|
|
67
56
|
};
|
|
68
57
|
}
|
|
69
58
|
|
|
59
|
+
/** Where a repo keeps its keys by convention, in the order they are looked for. */
|
|
60
|
+
const KEY_DIRS = [".secrets", "secrets", ".keys"];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One role per owner, with every path that owner has.
|
|
64
|
+
*
|
|
65
|
+
* It was one role per LINE, named after the path, and cut at eight — so a
|
|
66
|
+
* team's ownership came back as eight roles called `apps-web`, `apps-api`… with
|
|
67
|
+
* the rest dropped and nothing said. An owner is the thing a role stands for.
|
|
68
|
+
* A line's first owner takes it: CODEOWNERS lets several share a path, and
|
|
69
|
+
* seisin would rather print one proposal a person corrects than invent an
|
|
70
|
+
* overlap. `*` is skipped — it is the fallback owner, not a territory.
|
|
71
|
+
*/
|
|
72
|
+
export function fromCodeowners(text) {
|
|
73
|
+
const byOwner = new Map();
|
|
74
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
75
|
+
const [glob, owner] = raw.replace(/#.*$/, "").trim().split(/\s+/);
|
|
76
|
+
if (!glob || glob === "*" || !owner) continue;
|
|
77
|
+
// CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
|
|
78
|
+
// are already repo-relative, so a literal "/apps/**" would ask the
|
|
79
|
+
// kernel for the machine's /apps and make `explain` (which reads it as
|
|
80
|
+
// outside the repo) disagree with what the profile grants. Strip the
|
|
81
|
+
// anchor. A trailing "/" means a directory, so it becomes "/**".
|
|
82
|
+
const rel = glob.replace(/^\/+/, "");
|
|
83
|
+
const path = rel.endsWith("/") ? rel + "**" : rel;
|
|
84
|
+
const name = roleName(owner.replace(/^@/, "").replace(/^.*\//, "")) || `role-${byOwner.size + 1}`;
|
|
85
|
+
const role = byOwner.get(name) ?? byOwner.set(name, { name, writes: [], keys: [] }).get(name);
|
|
86
|
+
if (!role.writes.includes(path)) role.writes.push(path);
|
|
87
|
+
}
|
|
88
|
+
return [...byOwner.values()];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const roleName = (s) => String(s).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The top-level folders that hold code, for a repo that says nothing else.
|
|
95
|
+
*
|
|
96
|
+
* It proposed `src/web/**` and `src/api/**` whatever the repo looked like, so
|
|
97
|
+
* the first `check` was about folders that did not exist. Kept simple on
|
|
98
|
+
* purpose: a folder counts when some file under it (a few levels down) has a
|
|
99
|
+
* source extension. Dot-folders, dependencies and build output are skipped,
|
|
100
|
+
* and a folder of only documents or images proposes nothing — it is somebody's
|
|
101
|
+
* territory, but not the first thing a person wants to decide.
|
|
102
|
+
*/
|
|
103
|
+
const CODE = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|sh|sql|vue|svelte|astro|dart|scala|ex|exs|lua|pl|r|m|html|css|scss)$/i;
|
|
104
|
+
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", "venv"]);
|
|
105
|
+
|
|
106
|
+
function codeDirs(root) {
|
|
107
|
+
let top;
|
|
108
|
+
try { top = readdirSync(root, { withFileTypes: true }); } catch { return []; }
|
|
109
|
+
return top
|
|
110
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && roleName(e.name))
|
|
111
|
+
.map((e) => e.name)
|
|
112
|
+
.filter((d) => holdsCode(join(root, d), 4))
|
|
113
|
+
.sort();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function holdsCode(dir, depth) {
|
|
117
|
+
let entries;
|
|
118
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
|
|
119
|
+
if (entries.some((e) => e.isFile() && CODE.test(e.name))) return true;
|
|
120
|
+
if (depth <= 1) return false;
|
|
121
|
+
return entries.some((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && holdsCode(join(dir, e.name), depth - 1));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isDir(p) {
|
|
125
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
126
|
+
}
|
|
127
|
+
|
|
70
128
|
/** The proposed config, as TOML. Pure: takes what `discover` found. */
|
|
71
129
|
export function renderConfig(found) {
|
|
72
130
|
const lines = [
|
|
@@ -75,9 +133,17 @@ export function renderConfig(found) {
|
|
|
75
133
|
`# Proposed from: ${found.source}`,
|
|
76
134
|
"# Nothing here is enforced until you run the agent through `seisin run`.",
|
|
77
135
|
"# Anything not listed is denied. There is no permissive default.",
|
|
136
|
+
"#",
|
|
137
|
+
"# Each role runs as its own process: seisin run <role> -- <agent>",
|
|
138
|
+
"# A Claude Code subagent runs inside its parent's process, so it has its",
|
|
139
|
+
"# parent's role — give it its own by running it as its own `seisin run`.",
|
|
140
|
+
"",
|
|
141
|
+
"# [runtime]",
|
|
142
|
+
'# read = "territory" # opt-in: keep data roots shut; roles declare reads/toolchain/verify',
|
|
78
143
|
"",
|
|
79
|
-
|
|
80
|
-
|
|
144
|
+
...(found.keyDir
|
|
145
|
+
? ["[keys]", `dir = ${tomlString(found.keyDir)} # every key lives here; roles name the files they may read`]
|
|
146
|
+
: ["# [keys]", '# dir = ".secrets" # every key lives here; roles name the files they may read']),
|
|
81
147
|
"",
|
|
82
148
|
"[network]",
|
|
83
149
|
"# The agent's own API comes first. Leave it out and the agent cannot even",
|
|
@@ -95,88 +161,117 @@ export function renderConfig(found) {
|
|
|
95
161
|
"",
|
|
96
162
|
];
|
|
97
163
|
for (const r of found.roles) {
|
|
164
|
+
if (!r.writes.length) lines.push("# writes nothing yet: add the folders this role owns, e.g. \"app/**\"");
|
|
98
165
|
lines.push(`[roles.${tomlName(r.name)}]`);
|
|
99
166
|
lines.push(`writes = [${r.writes.map(tomlString).join(", ")}]`);
|
|
100
167
|
lines.push(`keys = [${r.keys.map(tomlString).join(", ")}]`);
|
|
168
|
+
lines.push('# reads = ["path/**"] # data outside the repo this role must read');
|
|
169
|
+
lines.push('# toolchain = ["~/.venvs/app"] # stack under a home or another data root');
|
|
170
|
+
lines.push('# verify = ["npm", "test"] # seisin check --verify runs this inside the role');
|
|
101
171
|
lines.push("");
|
|
102
172
|
}
|
|
103
173
|
return lines.join("\n");
|
|
104
174
|
}
|
|
105
175
|
|
|
106
|
-
/**
|
|
107
|
-
export function renderObserved(config, entries) {
|
|
176
|
+
/** A proposal that adds observed permissions to the current policy text. */
|
|
177
|
+
export function renderObserved(config, entries, text = readFileSync(config.path, "utf8")) {
|
|
178
|
+
// Start from the policy being proposed for promotion. Rebuilding writes and
|
|
179
|
+
// keys alone discarded isolate, never_writes, providers, per-role network,
|
|
180
|
+
// MCP and every other setting, including those of roles that did not act.
|
|
181
|
+
config = loadConfig(config.path, text);
|
|
108
182
|
const roles = observed(entries);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"# This is what your agents actually did, generalised to directories. Read it",
|
|
113
|
-
"# before you trust it: an agent that touched a file once by mistake asked for",
|
|
114
|
-
"# that directory here, and observation cannot tell intent from accident.",
|
|
115
|
-
"",
|
|
116
|
-
];
|
|
117
|
-
if (config.keyDirs.length)
|
|
118
|
-
lines.push("[keys]", `dir = [${config.keyDirs.map(tomlString).join(", ")}]`, "");
|
|
119
|
-
lines.push("[network]", `allow = [${config.allowedDomains.map(tomlString).join(", ")}]`, "");
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Every declared role appears, whether it acted or not.
|
|
123
|
-
*
|
|
124
|
-
* This used to emit only the roles the log had seen, and the file says "diff
|
|
125
|
-
* it, then move it" — so moving it deleted the territory of every role that
|
|
126
|
-
* happened to be idle during the observation window, silently. A role that
|
|
127
|
-
* did nothing is not a role that needs nothing; it is a role nobody watched.
|
|
128
|
-
*
|
|
129
|
-
* So observation *adds*. What was declared stays, what was seen is appended,
|
|
130
|
-
* and the comment beside each role says which part came from where — because
|
|
131
|
-
* the whole reason to read this file is to tell the two apart.
|
|
132
|
-
*/
|
|
133
|
-
for (const name of new Set([...Object.keys(config.roles), ...roles.keys()])) {
|
|
134
|
-
const declared = config.roles[name];
|
|
183
|
+
let toml = text;
|
|
184
|
+
const notes = [];
|
|
185
|
+
for (const name of Object.keys(config.roles)) {
|
|
135
186
|
const seen = roles.get(name);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
if (!seen) {
|
|
188
|
+
notes.push(`# ${name}: this role did nothing while observing — kept as written`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// A host path is not a repo-relative grant. Unknown roles and outside
|
|
192
|
+
// paths from older logs cannot silently become territory in the proposal.
|
|
193
|
+
const paths = [...seen.writes].map((p) => toRepoRelative(config, resolve(config.root, p)))
|
|
194
|
+
.filter((p) => !isAbsolute(p) && p !== ".." && !p.startsWith("../"));
|
|
195
|
+
const additions = [
|
|
196
|
+
...generalise(paths).map((grant) => ({ action: "write", grant })),
|
|
197
|
+
...[...seen.keys].map((target) => ({ action: "read", grant: keyName(config, target) })),
|
|
142
198
|
];
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
199
|
+
let added = 0;
|
|
200
|
+
for (const request of additions) {
|
|
201
|
+
const field = request.action === "read" ? "keys" : "writes";
|
|
202
|
+
// A valid role can omit either list. Insert only a missing list and
|
|
203
|
+
// retry the same grant; existing values and comments stay untouched.
|
|
204
|
+
let result;
|
|
205
|
+
try {
|
|
206
|
+
result = applyGrant(toml, { ...request, role: name, times: 1 }, "observation proposal");
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (!e.message.includes(`has no ${field} list`)) throw e;
|
|
209
|
+
toml = setKey(toml, `roles.${name}`, field, "[]", "", "from observation");
|
|
210
|
+
result = applyGrant(toml, { ...request, role: name, times: 1 }, "observation proposal");
|
|
211
|
+
}
|
|
212
|
+
toml = result.toml;
|
|
213
|
+
if (result.changed) added++;
|
|
214
|
+
}
|
|
215
|
+
notes.push(`# ${name}: observation added ${added}; all other settings kept as written`);
|
|
157
216
|
}
|
|
158
|
-
|
|
217
|
+
loadConfig(config.path, toml);
|
|
218
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
219
|
+
const heading = [
|
|
220
|
+
`# ${CONFIG_NAME} — proposal from ${entries.length} observed action(s).`,
|
|
221
|
+
"# Observation adds territory and keys; read the diff before promoting this file.",
|
|
222
|
+
...notes, "",
|
|
223
|
+
].join(eol);
|
|
224
|
+
return { toml: heading + eol + toml, roles: [...roles.keys()].filter((name) => config.roles[name]).length };
|
|
159
225
|
}
|
|
160
226
|
|
|
161
|
-
export function init(cwd = process.cwd()) {
|
|
227
|
+
export function init(cwd = process.cwd(), { force = false } = {}) {
|
|
162
228
|
const target = join(cwd, CONFIG_NAME);
|
|
163
|
-
|
|
164
|
-
|
|
229
|
+
const had = existsSync(target);
|
|
230
|
+
if (had && !force)
|
|
231
|
+
throw new Error(`${CONFIG_NAME} already exists here. seisin init --force replaces it, keeping the old one as ${CONFIG_NAME}.bak`);
|
|
165
232
|
|
|
166
233
|
const found = discover(cwd);
|
|
234
|
+
if (had) copyFileSync(target, target + ".bak");
|
|
167
235
|
writeFileSync(target, renderConfig(found));
|
|
236
|
+
const ignored = ignoreState(cwd);
|
|
168
237
|
out(
|
|
169
238
|
`\n wrote ${C.b}${CONFIG_NAME}${C.off} with ${found.roles.length} role(s) from ${found.source}\n` +
|
|
170
|
-
` ${C.dim}
|
|
171
|
-
`
|
|
239
|
+
(had ? ` ${C.dim}the previous one is ${CONFIG_NAME}.bak${C.off}\n` : "") +
|
|
240
|
+
(found.keyDir ? ` ${C.dim}[keys] dir = "${found.keyDir}" — it is there, so it is declared${C.off}\n` : "") +
|
|
241
|
+
(ignored ? ` ${C.dim}added .seisin/ to .gitignore — the log and the request queue belong to this machine${C.off}\n` : "") +
|
|
242
|
+
` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n` +
|
|
243
|
+
// Said once, here, where someone with one agent decides whether to go on.
|
|
244
|
+
(found.agents <= 1
|
|
245
|
+
? ` ${C.dim}one agent? its own sandbox covers most of this. seisin adds a built environment, a key dir no role reads\n` +
|
|
246
|
+
` unless it is declared, and an owner named on every denial — it earns its setup at two roles.${C.off}\n`
|
|
247
|
+
: "") +
|
|
248
|
+
`\n next: seisin check\n\n`
|
|
172
249
|
);
|
|
173
250
|
return found;
|
|
174
251
|
}
|
|
175
252
|
|
|
253
|
+
/**
|
|
254
|
+
* `.seisin/` into the repo's .gitignore, when it has one and it is not there.
|
|
255
|
+
* The state directory is this machine's record; committed, every clone would
|
|
256
|
+
* start with somebody else's log and queue. No .gitignore, nothing written.
|
|
257
|
+
*/
|
|
258
|
+
function ignoreState(cwd) {
|
|
259
|
+
const file = join(cwd, ".gitignore");
|
|
260
|
+
if (!existsSync(file)) return false;
|
|
261
|
+
const text = readFileSync(file, "utf8");
|
|
262
|
+
if (/^\/?\.seisin\/?\s*$/m.test(text)) return false;
|
|
263
|
+
appendFileSync(file, `${text.endsWith("\n") || !text ? "" : "\n"}.seisin/\n`);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
176
267
|
export function initFromObservations(config) {
|
|
177
268
|
const entries = read(logPath(config.root), { verdict: "observed" });
|
|
178
269
|
if (entries.length === 0)
|
|
179
|
-
throw new Error(
|
|
270
|
+
throw new Error(
|
|
271
|
+
"nothing observed yet.\n" +
|
|
272
|
+
" observations come from the hook: run `seisin wire` once, then observe an agent —\n" +
|
|
273
|
+
" seisin run <role> --observe -- claude (or codex, …)\n" +
|
|
274
|
+
" a plain shell command goes through no hook and records nothing. keys stay denied while observing.");
|
|
180
275
|
|
|
181
276
|
const { toml, roles } = renderObserved(config, entries);
|
|
182
277
|
const file = join(config.root, CONFIG_NAME + ".observed");
|
package/src/commands/log.js
CHANGED
|
@@ -18,15 +18,30 @@ const flag = (argv, name) => {
|
|
|
18
18
|
return i === -1 ? undefined : argv[i + 1];
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
const VERDICTS = ["allowed", "denied", "observed"];
|
|
22
|
+
|
|
21
23
|
export function log(config, argv = []) {
|
|
22
24
|
if (argv[0] === "verify") return verify(config);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const role = flag(argv, "--role");
|
|
26
|
+
const verdict = flag(argv, "--verdict");
|
|
27
|
+
const limit = flag(argv, "--limit") ?? "40";
|
|
28
|
+
// Checked, because a filter that matches nothing reads as an empty log.
|
|
29
|
+
if (verdict !== undefined && !VERDICTS.includes(verdict))
|
|
30
|
+
throw new Error(`unknown verdict "${verdict}" — use allowed, denied or observed`);
|
|
31
|
+
if (!/^[1-9]\d*$/.test(limit)) throw new Error(`--limit takes a positive number, got "${limit}"`);
|
|
32
|
+
// A role that is not in the policy is still a fair question: the log keeps
|
|
33
|
+
// the lines of a role that was renamed or removed. The empty answer says so.
|
|
34
|
+
const entries = read(logPath(config.root), { role, verdict, limit: Number(limit) });
|
|
28
35
|
|
|
29
36
|
if (entries.length === 0) {
|
|
37
|
+
// Say which: nothing at all, or nothing for this filter.
|
|
38
|
+
const any = role || verdict ? read(logPath(config.root), { limit: 1000 }) : [];
|
|
39
|
+
if (any.length) {
|
|
40
|
+
const others = [...new Set(any.map((e) => e.role).filter(Boolean))];
|
|
41
|
+
out(`\n ${C.dim}no entries for ${[role && `role ${role}`, verdict && `verdict ${verdict}`].filter(Boolean).join(", ")} — ` +
|
|
42
|
+
`the log has ${any.length === 1000 ? "1000+" : any.length} for ${others.join(", ") || "other filters"}${C.off}\n\n`);
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
30
45
|
out(`\n ${C.dim}nothing recorded yet — run an agent through "seisin run"${C.off}\n\n`);
|
|
31
46
|
return entries;
|
|
32
47
|
}
|
|
@@ -56,14 +71,38 @@ function verify(config) {
|
|
|
56
71
|
const dropped = r.dropped
|
|
57
72
|
? ` ${C.yellow}${r.dropped} entr${r.dropped === 1 ? "y was" : "ies were"} never written${C.off} another writer held the lock too long; see ${LOG_NAME}.dropped\n`
|
|
58
73
|
: "";
|
|
74
|
+
// Where the chain starts is only checked once it has been recorded. Until
|
|
75
|
+
// then a log whose chain was stripped or rewritten whole reads like an
|
|
76
|
+
// intact one — so with chained lines and no record this is a warning, not a
|
|
77
|
+
// footnote. A record made after chained lines already stood pins whatever
|
|
78
|
+
// they said by then, and says so.
|
|
79
|
+
let genesis = "";
|
|
80
|
+
if (r.genesis === "unrecorded" && r.chained)
|
|
81
|
+
genesis = ` ${C.yellow}where the chain starts is not recorded${C.off} — a chain stripped or rewritten from its first line would not show here.\n` +
|
|
82
|
+
` ${C.dim}the next entry records it, marked late: tampering before that cannot be excluded${C.off}\n`;
|
|
83
|
+
else if (r.genesis === "unrecorded" && r.unchained)
|
|
84
|
+
genesis = ` ${C.dim}where the chain starts is not recorded yet — it will be on the next entry${C.off}\n`;
|
|
85
|
+
else if (r.genesis === "pruned")
|
|
86
|
+
genesis = ` ${C.dim}the oldest segments were rotated away; the chain is checked from the oldest one kept${C.off}\n`;
|
|
87
|
+
else if (r.late)
|
|
88
|
+
genesis = ` ${C.yellow}start recorded late${C.off}${r.late.at ? ` (${r.late.at})` : ""}, after ${r.late.after ?? "some"} chained line(s) — earlier tampering cannot be excluded\n`;
|
|
59
89
|
if (!r.breaks.length) {
|
|
60
|
-
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${dropped}\n`);
|
|
90
|
+
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${genesis}${dropped}\n`);
|
|
61
91
|
return r;
|
|
62
92
|
}
|
|
63
93
|
out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
const foreign = r.breaks.find((b) => b.foreign);
|
|
95
|
+
for (const b of r.breaks.slice(0, 10)) {
|
|
96
|
+
if (b.foreign) out(` the genesis record belongs to another log: it pins a different start (${LOG_NAME}.genesis)\n`);
|
|
97
|
+
else out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
|
|
98
|
+
}
|
|
99
|
+
if (foreign)
|
|
100
|
+
out(` ${C.dim}if ${LOG_NAME} was archived or replaced by hand, move ${LOG_NAME}.genesis along with the archived log;\n` +
|
|
101
|
+
` or remove it, and the next entry records this log's start (marked late). If nobody replaced the log,\n` +
|
|
102
|
+
` it was rewritten from its first line.${C.off}\n`);
|
|
103
|
+
if (r.breaks.some((b) => !b.foreign))
|
|
104
|
+
out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n`);
|
|
105
|
+
out(`${genesis}${dropped}\n`);
|
|
67
106
|
process.exitCode = 1;
|
|
68
107
|
return r;
|
|
69
108
|
}
|