seisin 0.1.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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +52 -0
- package/src/cli.js +125 -0
- package/src/commands/check.js +9 -0
- package/src/commands/explain.js +87 -0
- package/src/commands/hook.js +42 -0
- package/src/commands/init.js +181 -0
- package/src/commands/log.js +68 -0
- package/src/commands/requests.js +145 -0
- package/src/commands/review.js +94 -0
- package/src/commands/run.js +395 -0
- package/src/commands/scan.js +21 -0
- package/src/commands/ui.js +31 -0
- package/src/commands/whose.js +94 -0
- package/src/commands/wire.js +74 -0
- package/src/config.js +317 -0
- package/src/env.js +61 -0
- package/src/handoff.js +111 -0
- package/src/hook.js +195 -0
- package/src/index.js +52 -0
- package/src/inspect.js +477 -0
- package/src/layout.js +47 -0
- package/src/log.js +124 -0
- package/src/mcp.js +290 -0
- package/src/owners.js +166 -0
- package/src/redact.js +82 -0
- package/src/render.js +150 -0
- package/src/requests.js +259 -0
- package/src/review.js +167 -0
- package/src/scan.js +175 -0
- package/src/serve.js +168 -0
- package/src/spool.js +164 -0
- package/src/srt.js +424 -0
- package/src/violations.js +540 -0
- package/src/worktree.js +216 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +937 -0
package/src/render.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything that decides how output looks, and nothing that decides anything else.
|
|
3
|
+
*
|
|
4
|
+
* This exists because the commands used to do both. A function that computes a
|
|
5
|
+
* verdict and paints it in the same breath can only be tested by running the
|
|
6
|
+
* binary and matching a regular expression against its stdout — which is what
|
|
7
|
+
* the suite was doing eight times, and which tests the renderer as much as the
|
|
8
|
+
* logic. Split apart, the decision returns data and the test reads a field.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here reads config, touches the filesystem, or exits. If a change to
|
|
11
|
+
* this file could alter what seisin allows, it is in the wrong file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ANSI codes, or empty strings when nobody is watching.
|
|
16
|
+
*
|
|
17
|
+
* Resolved once at import: a pipe does not become a terminal halfway through a
|
|
18
|
+
* command. `NO_COLOR` is honoured because this prints into logs and CI.
|
|
19
|
+
*/
|
|
20
|
+
export const C =
|
|
21
|
+
process.stdout.isTTY && !process.env.NO_COLOR
|
|
22
|
+
? {
|
|
23
|
+
dim: "\x1b[2m", b: "\x1b[1m", off: "\x1b[0m",
|
|
24
|
+
red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m",
|
|
25
|
+
}
|
|
26
|
+
: { dim: "", b: "", off: "", red: "", green: "", yellow: "", blue: "" };
|
|
27
|
+
|
|
28
|
+
export const out = (s) => process.stdout.write(s);
|
|
29
|
+
export const err = (s) => process.stderr.write(s);
|
|
30
|
+
|
|
31
|
+
/* ── the map ──────────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
/** The `check` report, as text. Takes the object `inspect()` returns. */
|
|
34
|
+
export function renderReport(report) {
|
|
35
|
+
const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
|
|
36
|
+
const width = Math.max(...report.roles.map((r) => r.name.length), 4);
|
|
37
|
+
|
|
38
|
+
for (const r of report.roles) {
|
|
39
|
+
const writes = r.writes.join(" ") || `${C.dim}nothing${C.off}`;
|
|
40
|
+
const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
|
|
41
|
+
lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
|
|
42
|
+
lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const w of report.warnings) {
|
|
46
|
+
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
47
|
+
if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
|
|
48
|
+
lines.push("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Dim, and last: a standing limit is not an alarm, but it is not a footnote
|
|
52
|
+
// either — it is the part of the map that is blank.
|
|
53
|
+
for (const l of report.limits ?? []) {
|
|
54
|
+
lines.push(` ${C.dim}${l.headline}${C.off}\n`);
|
|
55
|
+
if (l.detail) lines.push(` ${C.dim}${l.detail}${C.off}\n`);
|
|
56
|
+
lines.push("\n");
|
|
57
|
+
}
|
|
58
|
+
return lines.join("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── one verdict ──────────────────────────────────────────────────────── */
|
|
62
|
+
|
|
63
|
+
/** The `explain` answer, as text. Takes what `owners.explain()` returns. */
|
|
64
|
+
export function renderVerdict(role, action, target, verdict) {
|
|
65
|
+
const head = verdict.allowed ? `${C.green}allowed${C.off}` : `${C.yellow}denied${C.off}`;
|
|
66
|
+
return `\n ${head} ${C.b}${role}${C.off} ${action} ${target}\n ${C.dim}${verdict.reason}${C.off}\n\n`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── the log ──────────────────────────────────────────────────────────── */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One log entry, one line, always the same shape.
|
|
73
|
+
*
|
|
74
|
+
* Denials are amber and not red on purpose: a role stopped at its own border is
|
|
75
|
+
* the system working, and painting routine correctness as an error teaches
|
|
76
|
+
* people to stop reading the colour.
|
|
77
|
+
*/
|
|
78
|
+
export function renderEntry(e) {
|
|
79
|
+
const mark =
|
|
80
|
+
e.verdict === "denied" ? `${C.yellow}denied ${C.off}`
|
|
81
|
+
: e.verdict === "observed" ? `${C.dim}seen ${C.off}`
|
|
82
|
+
: `${C.green}allowed${C.off}`;
|
|
83
|
+
const owners =
|
|
84
|
+
e.owners?.length && e.verdict === "denied" ? ` ${C.dim}→ ${e.owners.join(", ")}${C.off}` : "";
|
|
85
|
+
// Which writer saw this. The hook reports an attempt and can be talked
|
|
86
|
+
// around; the kernel reports a refusal and cannot. When the two disagree
|
|
87
|
+
// about a run, a reader needs to know which line is which without going to
|
|
88
|
+
// the file — so the one that carries more weight is the one that is marked.
|
|
89
|
+
const from = e.source === "kernel" ? ` ${C.dim}[kernel]${C.off}` : "";
|
|
90
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${e.target}${owners}${from}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── the scan ─────────────────────────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Two lists, never one.
|
|
97
|
+
*
|
|
98
|
+
* A provider-issued string and a line that merely mentions a password are
|
|
99
|
+
* different claims. Merging them is how a scanner earns a reputation for crying
|
|
100
|
+
* wolf, and the first real run of this one returned 200 findings of which 2
|
|
101
|
+
* mattered. The split is the whole reason the output is readable.
|
|
102
|
+
*/
|
|
103
|
+
export function renderScan(result, keyDirs) {
|
|
104
|
+
const { certain, review, skipped, truncated } = result;
|
|
105
|
+
const links = result.links ?? [];
|
|
106
|
+
const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
|
|
107
|
+
const lines = [`\n ${C.dim}protected: ${where}${C.off}\n\n`];
|
|
108
|
+
|
|
109
|
+
if (certain.length === 0 && review.length === 0 && links.length === 0) {
|
|
110
|
+
lines.push(` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`);
|
|
111
|
+
return lines.join("");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (certain.length) {
|
|
115
|
+
lines.push(` ${C.red}${certain.length} credential(s)${C.off} — these shapes are issued, not written by accident\n\n`);
|
|
116
|
+
for (const h of certain) lines.push(` ${C.b}${h.file}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}\n`);
|
|
117
|
+
lines.push("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (review.length) {
|
|
121
|
+
const byFile = new Map();
|
|
122
|
+
for (const h of review) byFile.set(h.file, (byFile.get(h.file) ?? 0) + 1);
|
|
123
|
+
lines.push(` ${C.yellow}${review.length} line(s) to look at${C.off} in ${byFile.size} file(s) — a secret-shaped name with a literal value\n\n`);
|
|
124
|
+
for (const [file, n] of [...byFile].slice(0, 15))
|
|
125
|
+
lines.push(` ${file}${C.dim}${n > 1 ? ` ×${n}` : ""}${C.off}\n`);
|
|
126
|
+
if (byFile.size > 15) lines.push(` ${C.dim}… and ${byFile.size - 15} more file(s)${C.off}\n`);
|
|
127
|
+
lines.push("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Links are their own finding, not a line with a secret-shaped name in it.
|
|
131
|
+
// What is reported is that the link leaves the repo — the destination is
|
|
132
|
+
// named and never opened, because the point is that it exists.
|
|
133
|
+
if (links.length) {
|
|
134
|
+
lines.push(` ${C.yellow}${links.length} symlink(s) out of the repo${C.off} — present in the tree, stored somewhere else\n\n`);
|
|
135
|
+
for (const h of links.slice(0, 15)) lines.push(` ${C.b}${h.file}${C.off} ${C.dim}→${C.off} ${h.shape}\n`);
|
|
136
|
+
lines.push("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// What was thrown away matters as much as what was kept: it is the only way
|
|
140
|
+
// to tell a quiet scan from a broken one.
|
|
141
|
+
const quiet = [];
|
|
142
|
+
if (skipped.reference) quiet.push(`${skipped.reference} value(s) read from the environment`);
|
|
143
|
+
if (skipped.placeholder) quiet.push(`${skipped.placeholder} placeholder(s)`);
|
|
144
|
+
if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
|
|
145
|
+
if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
|
|
146
|
+
if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
|
|
147
|
+
if (truncated) lines.push(` ${C.yellow}stopped at the limit — there are more${C.off}\n`);
|
|
148
|
+
lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
|
|
149
|
+
return lines.join("");
|
|
150
|
+
}
|
package/src/requests.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The queue of permissions an agent asked for and cannot have yet.
|
|
3
|
+
*
|
|
4
|
+
* ── Two words, kept apart on purpose ──
|
|
5
|
+
* **Refused** is what the boundary does. **Declined** is what a person does to
|
|
6
|
+
* a request. They used to be one word in one command: an entry read `first
|
|
7
|
+
* refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
|
|
8
|
+
* src/api/**`. One means the kernel said no, the other means you did, and a
|
|
9
|
+
* reader had nothing to tell them apart by.
|
|
10
|
+
*
|
|
11
|
+
* The entry says **asked** now, because refused was not always true. Asking for
|
|
12
|
+
* a permission before reaching for it is reasonable, and nothing required a
|
|
13
|
+
* denial to have happened first — so the queue could print a sentence about an
|
|
14
|
+
* event that never occurred, in front of a person about to approve something.
|
|
15
|
+
* `seisin run` drops any request the policy does not actually refuse; what is
|
|
16
|
+
* left is "you cannot have this, and you asked", which holds whether the agent
|
|
17
|
+
* tried or simply asked.
|
|
18
|
+
*
|
|
19
|
+
* A denial already contains everything a request needs — the hook knows the
|
|
20
|
+
* role, the path and the owner — so the refusal leaves something behind that a
|
|
21
|
+
* person can act on in one command instead of in an editor. See
|
|
22
|
+
* `docs/permission-requests.md` for why approving is deliberately not something
|
|
23
|
+
* an agent can do.
|
|
24
|
+
*
|
|
25
|
+
* Append-only, beside the log, for the same reason: a decision that can be
|
|
26
|
+
* rewritten is not evidence. A request that is granted or refused is not
|
|
27
|
+
* deleted — it is followed by a line saying what happened to it.
|
|
28
|
+
*/
|
|
29
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { STATE_DIR, tomlString } from "./layout.js";
|
|
32
|
+
import { send } from "./spool.js";
|
|
33
|
+
|
|
34
|
+
export const REQUESTS_NAME = "requests.jsonl";
|
|
35
|
+
|
|
36
|
+
export function requestsPath(root) {
|
|
37
|
+
return join(root, STATE_DIR, REQUESTS_NAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Identity of a request: the same role wanting the same kind of access to the
|
|
42
|
+
* same place is one request asked twice, not two requests.
|
|
43
|
+
*
|
|
44
|
+
* The path is generalised to its directory on purpose. An agent denied on
|
|
45
|
+
* `src/api/a.ts` and then on `src/api/b.ts` is not asking two questions, and a
|
|
46
|
+
* queue that says it is becomes a queue nobody reads.
|
|
47
|
+
*/
|
|
48
|
+
export function keyOf({ role, action, target }) {
|
|
49
|
+
// A line can arrive malformed — a killed process, or something inside the
|
|
50
|
+
// sandbox writing to the queue on purpose. Reading the queue must not be the
|
|
51
|
+
// thing that fails: `seisin run` prints it on the way out and the console
|
|
52
|
+
// polls it, so a crash here takes down the half of the tool a person uses.
|
|
53
|
+
const t = typeof target === "string" ? target : "";
|
|
54
|
+
const dir = action === "read" ? t : t.split("/").slice(0, -1).join("/") || ".";
|
|
55
|
+
return `${role}:${action}:${dir}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The glob a grant would add, derived from what was asked. */
|
|
59
|
+
export function grantFor({ action, target }) {
|
|
60
|
+
const t = typeof target === "string" ? target : "";
|
|
61
|
+
// A key keeps its directory when it has one. Stripping it turned a request
|
|
62
|
+
// for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
|
|
63
|
+
// resolves against the FIRST key directory — so the person approves one file
|
|
64
|
+
// and another one of the same name is what gets read.
|
|
65
|
+
if (action === "read") return t.includes("/") ? t : t.replace(/^.*\//, "");
|
|
66
|
+
const dir = t.split("/").slice(0, -1).join("/");
|
|
67
|
+
return dir ? `${dir}/**` : t;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function write(file, entry) {
|
|
71
|
+
if (send("requests", entry)) return true;
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
74
|
+
appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
// Same rule as the log: never take the agent down over bookkeeping.
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Records that a role asked for something the policy denies it. Safe to call
|
|
83
|
+
* on every denial; `seisin run` drops anything the policy does not refuse. */
|
|
84
|
+
export function record(file, { role, action, target, owners }) {
|
|
85
|
+
return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Records a decision. `by` is always a person; there is no other caller. */
|
|
89
|
+
export function settle(file, key, decision, reason = "") {
|
|
90
|
+
return write(file, { kind: decision, key, reason });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Records what the execution plane last tried to do about a request, which is a
|
|
95
|
+
* different question from what a person decided about it.
|
|
96
|
+
*
|
|
97
|
+
* `settle` answers "did the authority change?". This answers "what happened to
|
|
98
|
+
* the work?" — handed to the owner, stopped by a cycle, held back by a budget.
|
|
99
|
+
* They are kept apart deliberately: a request whose handoff was throttled is
|
|
100
|
+
* still `pending`, because nobody has decided anything about it. Writing the
|
|
101
|
+
* outcome into `state` would make admission look like a verdict.
|
|
102
|
+
*
|
|
103
|
+
* It exists so that a refusal to run cannot be invisible. Admission may
|
|
104
|
+
* postpone work; it may not make work disappear from the queue a person reads.
|
|
105
|
+
*/
|
|
106
|
+
export function recordHandoff(file, key, decision = {}) {
|
|
107
|
+
const { type, role, limit, max, reason, depth, chainId, revision } = decision;
|
|
108
|
+
return write(file, {
|
|
109
|
+
kind: "handoff", key, outcome: type ?? "unknown",
|
|
110
|
+
...(role !== undefined && { role }),
|
|
111
|
+
...(limit !== undefined && { limit }),
|
|
112
|
+
...(max !== undefined && { max }),
|
|
113
|
+
...(reason !== undefined && { reason }),
|
|
114
|
+
...(depth !== undefined && { depth }),
|
|
115
|
+
...(chainId != null && { chainId }),
|
|
116
|
+
...(revision != null && { revision }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The queue as it stands: one entry per distinct request, with how many times
|
|
122
|
+
* it was asked and what was decided.
|
|
123
|
+
*
|
|
124
|
+
* Reduced from the whole file rather than kept as state, so the file stays the
|
|
125
|
+
* only thing that has to be correct.
|
|
126
|
+
*/
|
|
127
|
+
export function pending(file, { includeSettled = false } = {}) {
|
|
128
|
+
if (!existsSync(file)) return [];
|
|
129
|
+
const byKey = new Map();
|
|
130
|
+
|
|
131
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
132
|
+
if (!line.trim()) continue;
|
|
133
|
+
let e;
|
|
134
|
+
try {
|
|
135
|
+
e = JSON.parse(line);
|
|
136
|
+
} catch {
|
|
137
|
+
continue; // a half-written line from a killed process is not an error
|
|
138
|
+
}
|
|
139
|
+
if (!e.key) continue;
|
|
140
|
+
|
|
141
|
+
if (e.kind === "asked") {
|
|
142
|
+
const seen = byKey.get(e.key);
|
|
143
|
+
if (seen) { seen.times++; seen.last = e.at; continue; }
|
|
144
|
+
byKey.set(e.key, {
|
|
145
|
+
key: e.key, role: e.role, action: e.action, target: e.target,
|
|
146
|
+
owners: e.owners ?? [], grant: grantFor(e),
|
|
147
|
+
times: 1, first: e.at, last: e.at, state: "pending", reason: "",
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const seen = byKey.get(e.key);
|
|
152
|
+
if (!seen) continue;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Two lifecycles in one file, and only one of them is `state`.
|
|
156
|
+
*
|
|
157
|
+
* Without this branch the catch-all below would set state = "handoff" and a
|
|
158
|
+
* throttled attempt would drop out of the pending queue — the exact silent
|
|
159
|
+
* disappearance the attempt is recorded to prevent.
|
|
160
|
+
*/
|
|
161
|
+
if (e.kind === "handoff") {
|
|
162
|
+
seen.handoff = {
|
|
163
|
+
outcome: e.outcome, role: e.role, limit: e.limit, max: e.max,
|
|
164
|
+
reason: e.reason, depth: e.depth, chainId: e.chainId, at: e.at,
|
|
165
|
+
};
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const all = [...byKey.values()];
|
|
173
|
+
return includeSettled ? all : all.filter((r) => r.state === "pending");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Adds a granted glob to a role in the config text, with its provenance.
|
|
178
|
+
*
|
|
179
|
+
* Edits the text rather than re-emitting the file, because a config someone
|
|
180
|
+
* wrote has comments and an order that mean something, and a tool that
|
|
181
|
+
* reformats it on every grant is a tool people stop letting near it.
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* Adds a granted glob to a role in the config text, with its provenance.
|
|
185
|
+
*
|
|
186
|
+
* Edits the text rather than re-emitting the file, because a config someone
|
|
187
|
+
* wrote has comments and an order that mean something, and a tool that
|
|
188
|
+
* reformats it on every grant is a tool people stop letting near it.
|
|
189
|
+
*
|
|
190
|
+
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
191
|
+
* first version searched from the role header to the next `writes =` anywhere
|
|
192
|
+
* in the file, so approving for a role that had no `writes` line wrote the
|
|
193
|
+
* grant into the NEXT role — with a comment saying who it was for. A human
|
|
194
|
+
* approved one thing and the file recorded another. In a permission tool that
|
|
195
|
+
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
196
|
+
* declared is an ordinary config.
|
|
197
|
+
*/
|
|
198
|
+
export function applyGrant(toml, request, note = "") {
|
|
199
|
+
const field = request.action === "read" ? "keys" : "writes";
|
|
200
|
+
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
|
201
|
+
const at = header.exec(toml);
|
|
202
|
+
if (!at) throw new Error(`no [roles.${request.role}] section to grant into`);
|
|
203
|
+
|
|
204
|
+
// The section runs from its header to the next table header, or to the end.
|
|
205
|
+
const from = at.index + at[0].length;
|
|
206
|
+
const next = /^\[[^\]]+\]\s*$/m.exec(toml.slice(from));
|
|
207
|
+
const to = next ? from + next.index : toml.length;
|
|
208
|
+
const section = toml.slice(from, to);
|
|
209
|
+
|
|
210
|
+
const line = new RegExp(`^(${field}\\s*=\\s*)\\[([^\\]]*)\\]`, "m");
|
|
211
|
+
const m = line.exec(section);
|
|
212
|
+
if (!m)
|
|
213
|
+
throw new Error(
|
|
214
|
+
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
215
|
+
`Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// The subset has no escapes, so a value carrying a quote or a newline cannot
|
|
219
|
+
// be written down at all. The strict parser would reject the result rather
|
|
220
|
+
// than widen anything — but leaving someone with a config that no longer
|
|
221
|
+
// loads, after they approved something, is its own kind of broken.
|
|
222
|
+
tomlString(request.grant); // refuses what the format cannot hold
|
|
223
|
+
|
|
224
|
+
const items = (m[2].match(/"[^"]*"/g) ?? []).map((s) => s.slice(1, -1));
|
|
225
|
+
if (items.includes(request.grant)) return { toml, changed: false };
|
|
226
|
+
|
|
227
|
+
const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
|
|
228
|
+
`${note ? ` · "${cleanReason(note)}"` : ""}`;
|
|
229
|
+
const body = [...items, request.grant]
|
|
230
|
+
.map((v) => ` "${v}"${v === request.grant ? `, ${stamp}` : ","}`)
|
|
231
|
+
.join("\n")
|
|
232
|
+
.replace(/,(\s*#[^\n]*)?$/, "$1");
|
|
233
|
+
|
|
234
|
+
const edited = section.replace(line, `$1[\n${body}\n]`);
|
|
235
|
+
return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The approver's own words, made safe to put in a config file.
|
|
240
|
+
*
|
|
241
|
+
* A reason is free text typed by a person, and a person can be talked into
|
|
242
|
+
* typing something — "paste this as the reason" is a plausible thing for an
|
|
243
|
+
* agent to suggest. Newlines in a comment would end the comment, and what
|
|
244
|
+
* follows is parsed as TOML. The strict parser rejects the result rather than
|
|
245
|
+
* widening anything, so the worst case is a config that no longer loads, but a
|
|
246
|
+
* permission file that can be broken by a sentence is still broken.
|
|
247
|
+
*/
|
|
248
|
+
export function cleanReason(s) {
|
|
249
|
+
return String(s ?? "")
|
|
250
|
+
.replace(/[\r\n\t]/g, " ")
|
|
251
|
+
.replace(/["\\]/g, "'")
|
|
252
|
+
.replace(/\s+/g, " ")
|
|
253
|
+
.trim()
|
|
254
|
+
.slice(0, 240);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function escapeRe(s) {
|
|
258
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
259
|
+
}
|
package/src/review.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the log says about the policy.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is arithmetic over `.seisin/log.jsonl` and the config. No
|
|
5
|
+
* model, no network, no heuristics that could be wrong in an interesting way —
|
|
6
|
+
* and that is deliberate rather than modest. This project's whole argument is
|
|
7
|
+
* that interpreting text is the wrong way to decide things; adding something
|
|
8
|
+
* that reads the log and forms an opinion would contradict it on the way in.
|
|
9
|
+
*
|
|
10
|
+
* Three questions, and the second is the one nothing else can answer.
|
|
11
|
+
*
|
|
12
|
+
* 1. What is a role being stopped by, over and over?
|
|
13
|
+
* Forty denials on one directory is not an agent misbehaving. It is a
|
|
14
|
+
* policy that is wrong, and today it reads as forty tidy amber lines
|
|
15
|
+
* nobody adds up.
|
|
16
|
+
*
|
|
17
|
+
* 2. What was granted and never used?
|
|
18
|
+
* Every permission file only ever grows, everywhere, and the reason is
|
|
19
|
+
* always the same: nobody can prove a line is dead, so the safe move is to
|
|
20
|
+
* leave it. Here the log can prove it. This is the only direction that
|
|
21
|
+
* makes a policy smaller.
|
|
22
|
+
*
|
|
23
|
+
* 3. What does nobody own?
|
|
24
|
+
* `explain` says it one path at a time. Added up, it is the shape of the
|
|
25
|
+
* hole in the map.
|
|
26
|
+
*
|
|
27
|
+
* Every answer carries the window it was computed over, because "never used"
|
|
28
|
+
* means nothing without "…in how long".
|
|
29
|
+
*/
|
|
30
|
+
import { read, logPath } from "./log.js";
|
|
31
|
+
import { covers, ownersOf } from "./owners.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One reading of the log against the policy.
|
|
35
|
+
*
|
|
36
|
+
* `minDenials` is the point at which repetition stops being an accident. Three
|
|
37
|
+
* is low on purpose: the cost of mentioning it is a line, and the cost of
|
|
38
|
+
* missing it is a role that quietly cannot do its job.
|
|
39
|
+
*/
|
|
40
|
+
export function review(config, { minDenials = 3 } = {}) {
|
|
41
|
+
const entries = read(logPath(config.root), { limit: 0 });
|
|
42
|
+
|
|
43
|
+
if (entries.length === 0)
|
|
44
|
+
return { window: null, entries: 0, friction: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
|
|
45
|
+
|
|
46
|
+
const at = entries.map((e) => e.at).filter(Boolean).sort();
|
|
47
|
+
const window = { from: at[0], to: at[at.length - 1], entries: entries.length };
|
|
48
|
+
|
|
49
|
+
return { window, entries: entries.length, ...findings(config, entries, minDenials) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findings(config, entries, minDenials) {
|
|
53
|
+
/* ── 1. what keeps stopping a role ──────────────────────────────────── */
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Two piles, because they are two different decisions and only one of them is
|
|
57
|
+
* "grant it".
|
|
58
|
+
*
|
|
59
|
+
* A role stopped at another role's territory is a policy question: either the
|
|
60
|
+
* territory is drawn wrong or the work belongs to somebody else. That is what
|
|
61
|
+
* the advice *grant, or move the territory* is for.
|
|
62
|
+
*
|
|
63
|
+
* A role stopped at a `[keys] dir` is **the policy working**. A key directory
|
|
64
|
+
* is closed to everyone — nobody holds it, so there is nobody to hand the work
|
|
65
|
+
* to — and it is the one place where a grant is never the answer.
|
|
66
|
+
*
|
|
67
|
+
* Counting them together was not cosmetic. Measured against a real repository:
|
|
68
|
+
* six of the eight lines at the top of this report were key directories, so
|
|
69
|
+
* the strongest recommendation this tool made about that repo was to grant two
|
|
70
|
+
* roles the credential directory. The distinction sat in the config the whole
|
|
71
|
+
* time and this report was the only thing not using it.
|
|
72
|
+
*/
|
|
73
|
+
const stopped = new Map(); // role + directory -> how often
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
if (e.verdict !== "denied" || !e.target) continue;
|
|
76
|
+
const dir = e.action === "read" ? e.target : dirOf(e.target);
|
|
77
|
+
const key = `${e.role} ${e.action} ${dir}`;
|
|
78
|
+
const seen = stopped.get(key) ?? {
|
|
79
|
+
role: e.role, action: e.action, where: dir, times: 0,
|
|
80
|
+
owners: e.owners ?? [],
|
|
81
|
+
// Carried by the log from both writers — the hook sets it from the config
|
|
82
|
+
// and so does the parent for a kernel denial. Recomputing it from the path
|
|
83
|
+
// here would be a second opinion on a settled question.
|
|
84
|
+
kind: e.kind === "key" ? "key" : "file",
|
|
85
|
+
};
|
|
86
|
+
seen.times++;
|
|
87
|
+
stopped.set(key, seen);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const repeated = [...stopped.values()]
|
|
91
|
+
.filter((f) => f.times >= minDenials)
|
|
92
|
+
.sort((a, b) => b.times - a.times);
|
|
93
|
+
|
|
94
|
+
const friction = repeated.filter((f) => f.kind !== "key");
|
|
95
|
+
const guarded = repeated.filter((f) => f.kind === "key");
|
|
96
|
+
|
|
97
|
+
/* ── 2. what was granted and never used ─────────────────────────────── */
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* This question refuses to answer when the log cannot support an answer, and
|
|
101
|
+
* that refusal is the whole of the second finding.
|
|
102
|
+
*
|
|
103
|
+
* "Never used" is read off `allowed` lines, and **only the hook writes those**.
|
|
104
|
+
* The kernel reports what it refused; it has nothing to say about what went
|
|
105
|
+
* through. So a log with no hook behind it holds denials and nothing else,
|
|
106
|
+
* every grant falls through as unused, and this section reports that the whole
|
|
107
|
+
* policy is dead — under a heading calling itself the only evidence anyone
|
|
108
|
+
* will ever have for making a permission file smaller.
|
|
109
|
+
*
|
|
110
|
+
* Measured on a real repository with the hook not installed: every write
|
|
111
|
+
* permission of every role listed as never used, while those roles were
|
|
112
|
+
* working.
|
|
113
|
+
*
|
|
114
|
+
* It became reachable the day the kernel started writing here. Before that an
|
|
115
|
+
* unwired repo had an empty log and this section said nothing, which was
|
|
116
|
+
* accidentally right. A partial input is more dangerous than no input, because
|
|
117
|
+
* it looks like an answer.
|
|
118
|
+
*
|
|
119
|
+
* So: no `allowed` line in the window, no answer. `unused` stays empty rather
|
|
120
|
+
* than full — a consumer that has never heard of `unusedKnowable` then reports
|
|
121
|
+
* nothing instead of everything, which is the direction this has to fail in.
|
|
122
|
+
*/
|
|
123
|
+
const knowable = entries.some((e) => e.verdict === "allowed");
|
|
124
|
+
|
|
125
|
+
// Only writes. A key that is declared and not read is ordinary — most roles
|
|
126
|
+
// hold a credential for the one turn a month that needs it — but a folder a
|
|
127
|
+
// role owns and has never written to in the whole log is a line nobody would
|
|
128
|
+
// miss, and the only evidence anyone will ever have for removing it.
|
|
129
|
+
const used = new Set();
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (e.verdict !== "allowed" || e.action !== "write" || !e.target) continue;
|
|
132
|
+
for (const role of Object.values(config.roles))
|
|
133
|
+
for (const glob of role.writes)
|
|
134
|
+
if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const unused = [];
|
|
138
|
+
if (knowable)
|
|
139
|
+
for (const role of Object.values(config.roles))
|
|
140
|
+
for (const glob of role.writes)
|
|
141
|
+
if (!used.has(`${role.name} ${glob}`)) unused.push({ role: role.name, glob });
|
|
142
|
+
|
|
143
|
+
/* ── 3. what nobody owns ────────────────────────────────────────────── */
|
|
144
|
+
|
|
145
|
+
const unowned = new Map();
|
|
146
|
+
for (const e of entries) {
|
|
147
|
+
if (!e.target || e.kind === "key") continue;
|
|
148
|
+
if (ownersOf(config, e.target).length) continue;
|
|
149
|
+
const dir = dirOf(e.target);
|
|
150
|
+
unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
friction,
|
|
155
|
+
guarded,
|
|
156
|
+
unused,
|
|
157
|
+
unusedKnowable: knowable,
|
|
158
|
+
unowned: [...unowned.entries()]
|
|
159
|
+
.map(([where, times]) => ({ where, times }))
|
|
160
|
+
.sort((a, b) => b.times - a.times),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The directory a path sits in, or "." for a bare filename. */
|
|
165
|
+
function dirOf(p) {
|
|
166
|
+
return p.split("/").slice(0, -1).join("/") || ".";
|
|
167
|
+
}
|