seisin 0.1.1 → 0.2.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 +225 -339
- package/package.json +2 -2
- package/src/cli.js +15 -2
- package/src/commands/init.js +1 -1
- package/src/commands/requests.js +1 -1
- package/src/commands/run.js +69 -1
- package/src/commands/walls.js +24 -0
- package/src/config.js +160 -3
- package/src/hook.js +33 -2
- package/src/index.js +12 -0
- package/src/inspect.js +154 -4
- package/src/keys.js +483 -0
- package/src/mcp.js +59 -1
- package/src/nested.js +86 -0
- package/src/render.js +34 -1
- package/src/serve.js +137 -3
- package/src/srt.js +48 -1
- package/src/walls.js +170 -0
- package/ui/index.html +367 -31
package/src/mcp.js
CHANGED
|
@@ -32,6 +32,8 @@ import { explain, ownersOf } from "./owners.js";
|
|
|
32
32
|
import { settingsFor } from "./srt.js";
|
|
33
33
|
import { pending, requestsPath, grantFor } from "./requests.js";
|
|
34
34
|
import { read, logPath } from "./log.js";
|
|
35
|
+
import { causesOf } from "./serve.js";
|
|
36
|
+
import { walls, wasted } from "./walls.js";
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Versions this server will agree to.
|
|
@@ -93,13 +95,35 @@ const TOOLS = [
|
|
|
93
95
|
},
|
|
94
96
|
},
|
|
95
97
|
},
|
|
98
|
+
{
|
|
99
|
+
name: "seisin_causes",
|
|
100
|
+
description:
|
|
101
|
+
"Denials grouped by what was denied — by path, and one level coarser by the name at " +
|
|
102
|
+
"the end of it — with how many are on paths no role owns. This is the question " +
|
|
103
|
+
"`seisin_activity` cannot answer: the raw log has the events, this has them counted " +
|
|
104
|
+
"against the policy as it stands, so a cause that has been granted since stops " +
|
|
105
|
+
"counting. Reach for it before proposing a change: a day that is three quarters one " +
|
|
106
|
+
"filename is a tooling problem, and the same volume spread across unrelated paths is " +
|
|
107
|
+
"a question about who owns what. Read-only.",
|
|
108
|
+
inputSchema: { type: "object", properties: { limit: { type: "number" } } },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "seisin_walls",
|
|
112
|
+
description:
|
|
113
|
+
"What a role keeps being denied AND would still be denied today, with the calls it " +
|
|
114
|
+
"spent retrying. Recomputed against the policy rather than read out of the log, so " +
|
|
115
|
+
"something granted since is not listed. Use it to find out whether an agent is stuck " +
|
|
116
|
+
"rather than slow. Omit `role` for every role that has one. Read-only.",
|
|
117
|
+
inputSchema: { type: "object", properties: { role: { type: "string" } } },
|
|
118
|
+
},
|
|
96
119
|
{
|
|
97
120
|
name: "seisin_draft_grant",
|
|
98
121
|
description:
|
|
99
122
|
"Draft the change a pending request would make, as text, plus the command a person " +
|
|
100
123
|
"runs to apply it. Reach for this after a refusal: the denial is already in " +
|
|
101
124
|
"seisin_requests, and this turns it into a concrete proposal someone can approve. " +
|
|
102
|
-
"
|
|
125
|
+
"Read-only, and it is the tool where that matters most: the name says grant and it " +
|
|
126
|
+
"does not grant. The approval is a human action in another channel.",
|
|
103
127
|
inputSchema: {
|
|
104
128
|
type: "object",
|
|
105
129
|
properties: { number: { type: "number", description: "position in seisin_requests" } },
|
|
@@ -166,6 +190,40 @@ const HANDLERS = {
|
|
|
166
190
|
};
|
|
167
191
|
},
|
|
168
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The same arithmetic the console's front page does.
|
|
195
|
+
*
|
|
196
|
+
* It lives here as well as there because the two readers are different and
|
|
197
|
+
* neither can do the other's work: a person opens the console, an agent
|
|
198
|
+
* calls this. Giving the agent only `seisin_activity` hands it the raw log
|
|
199
|
+
* and asks it to re-derive the grouping — without the policy, which is the
|
|
200
|
+
* half that makes the grouping mean anything.
|
|
201
|
+
*/
|
|
202
|
+
seisin_causes({ limit }) {
|
|
203
|
+
const cfg = config();
|
|
204
|
+
const c = causesOf(cfg, read(logPath(cfg.root), { limit: 4000 }));
|
|
205
|
+
return {
|
|
206
|
+
total: c.total,
|
|
207
|
+
distinct: c.distinct,
|
|
208
|
+
unowned: c.unowned,
|
|
209
|
+
families: c.families,
|
|
210
|
+
causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
seisin_walls({ role }) {
|
|
215
|
+
const cfg = config();
|
|
216
|
+
const file = logPath(cfg.root);
|
|
217
|
+
const names = role ? [role] : Object.keys(cfg.roles);
|
|
218
|
+
if (role && !cfg.roles[role]) throw new Error(`unknown role "${role}"`);
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const r of names) {
|
|
221
|
+
const w = walls(cfg, r, { file });
|
|
222
|
+
if (w.length) out[r] = { walls: w, spentRetrying: wasted(w) };
|
|
223
|
+
}
|
|
224
|
+
return { roles: out, spentRetrying: Object.values(out).reduce((n, x) => n + x.spentRetrying, 0) };
|
|
225
|
+
},
|
|
226
|
+
|
|
169
227
|
seisin_activity({ role, verdict, limit }) {
|
|
170
228
|
if (verdict !== undefined && !["allowed", "denied", "observed"].includes(verdict))
|
|
171
229
|
throw new Error(`verdict must be allowed, denied or observed, got ${JSON.stringify(verdict)}`);
|
package/src/nested.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents that sandbox themselves, and why seisin has to say so first.
|
|
3
|
+
*
|
|
4
|
+
* `codex` confines every command it runs with its own `sandbox-exec` profile.
|
|
5
|
+
* macOS will not apply a second Seatbelt profile to a process that already has
|
|
6
|
+
* one, so inside `seisin run` it fails with:
|
|
7
|
+
*
|
|
8
|
+
* sandbox-exec: sandbox_apply: Operation not permitted
|
|
9
|
+
*
|
|
10
|
+
* Measured, twice: the README has it from the original run and the lab
|
|
11
|
+
* reproduced it — exit 71, and the inner command never produced a byte.
|
|
12
|
+
*
|
|
13
|
+
* Two things make it a special case in a tool that otherwise has none.
|
|
14
|
+
*
|
|
15
|
+
* **It does not fail at the start.** The agent launches, reads, thinks, and
|
|
16
|
+
* dies on the first command it tries to confine. So the operator sees a turn
|
|
17
|
+
* that did nothing, which reads exactly like an agent with nothing to do.
|
|
18
|
+
*
|
|
19
|
+
* **And the error explains nothing.** It names neither seisin, nor the agent,
|
|
20
|
+
* nor the fix. For a tool whose whole claim is that a refusal tells you what
|
|
21
|
+
* happened, shipping the one message that says nothing is the worst available
|
|
22
|
+
* outcome — so this is checked before the spawn and said in words.
|
|
23
|
+
*
|
|
24
|
+
* It is a closed list, not a detector. Guessing which binaries sandbox
|
|
25
|
+
* themselves from their name would be wrong in both directions, and being
|
|
26
|
+
* wrong here means printing a warning about a thing that is not happening.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What each agent does by default, and what turns it off.
|
|
31
|
+
*
|
|
32
|
+
* `on: true` means the agent confines by default, so the warning is the
|
|
33
|
+
* normal case and silence needs a flag. codex is that. gemini is the other
|
|
34
|
+
* way round: off unless asked. Getting this backwards means warning about a
|
|
35
|
+
* thing that is not happening, which trains people to skip the line.
|
|
36
|
+
*
|
|
37
|
+
* `opencode` is absent. It has been run under seisin and the
|
|
38
|
+
* README describes its behaviour, but nothing here has measured whether it
|
|
39
|
+
* confines its own commands, and a closed list holds only what was checked.
|
|
40
|
+
*/
|
|
41
|
+
const SELF_SANDBOXING = {
|
|
42
|
+
codex: {
|
|
43
|
+
on: true,
|
|
44
|
+
off: ["--dangerously-bypass-approvals-and-sandbox"],
|
|
45
|
+
// The subcommand whose entire job is to confine. No flag turns that off.
|
|
46
|
+
never: ["sandbox"],
|
|
47
|
+
},
|
|
48
|
+
gemini: { on: false, onWith: ["-s", "--sandbox"], off: ["--sandbox=false"] },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The basename of a command, without its path. */
|
|
52
|
+
const bare = (c) => String(c ?? "").split("/").pop();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The warning for this command line, or null.
|
|
56
|
+
*
|
|
57
|
+
* Takes the argv `run` is about to spawn, and looks only at the first token
|
|
58
|
+
* and its flags. Anything cleverer would be guessing at a shell it cannot
|
|
59
|
+
* parse, and a wrong warning is worse than no warning.
|
|
60
|
+
*/
|
|
61
|
+
export function nestedSandboxWarning(cmd = []) {
|
|
62
|
+
const name = bare(cmd[0]);
|
|
63
|
+
const spec = Object.prototype.hasOwnProperty.call(SELF_SANDBOXING, name) ? SELF_SANDBOXING[name] : null;
|
|
64
|
+
if (!spec) return null;
|
|
65
|
+
|
|
66
|
+
const rest = cmd.slice(1);
|
|
67
|
+
if ((spec.never ?? []).includes(rest[0]))
|
|
68
|
+
return (
|
|
69
|
+
`\`${name} ${rest[0]}\` exists to run a command inside a sandbox, so it cannot run inside ` +
|
|
70
|
+
`one. The OS will not apply a second profile to a process that already has one.\n` +
|
|
71
|
+
` Run the command directly — seisin is already the boundary here.`
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "=")));
|
|
75
|
+
if (turnedOff) return null;
|
|
76
|
+
const confining = spec.on || (spec.onWith ?? []).some((f) => rest.includes(f));
|
|
77
|
+
if (!confining) return null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
`${name} confines the commands it runs with its own sandbox, and the OS will not apply a ` +
|
|
81
|
+
`second profile to a process that already has one.\n` +
|
|
82
|
+
` This does not fail here — it fails on the first command ${name} tries to run, which ` +
|
|
83
|
+
`looks like an agent that did nothing.\n` +
|
|
84
|
+
` Run it with its own sandbox off: \`${name} … ${(spec.off ?? ["--no-sandbox"])[0]}\`.`
|
|
85
|
+
);
|
|
86
|
+
}
|
package/src/render.js
CHANGED
|
@@ -30,18 +30,51 @@ export const err = (s) => process.stderr.write(s);
|
|
|
30
30
|
|
|
31
31
|
/* ── the map ──────────────────────────────────────────────────────────── */
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A database's sidecars, shown as the database.
|
|
35
|
+
*
|
|
36
|
+
* `writes` carries the expansion because the grant and the sentence have to
|
|
37
|
+
* agree — see config.js. Printing all four is then honest and unreadable: on a
|
|
38
|
+
* real policy it turned eight databases into thirty-two rows of the same name
|
|
39
|
+
* with three suffixes, which is what the expansion existed to remove.
|
|
40
|
+
*
|
|
41
|
+
* So the list is collapsed back for DISPLAY only, and only where the base is
|
|
42
|
+
* present, and it says the sidecars are there rather than hiding them. A
|
|
43
|
+
* sidecar declared without its database still prints on its own line — that is
|
|
44
|
+
* unusual enough to be worth seeing.
|
|
45
|
+
*/
|
|
46
|
+
const SIDECAR_RE = /-(wal|shm|journal)$/;
|
|
47
|
+
|
|
48
|
+
export function collapseSidecars(paths) {
|
|
49
|
+
const have = new Set(paths);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const p of paths) {
|
|
52
|
+
const base = p.replace(SIDECAR_RE, "");
|
|
53
|
+
if (SIDECAR_RE.test(p) && have.has(base)) continue; // shown on its base
|
|
54
|
+
out.push(SIDECAR_RE.test(p) || !have.has(p + "-wal") ? p : `${p}${C.dim}+wal+shm+journal${C.off}`);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/** The `check` report, as text. Takes the object `inspect()` returns. */
|
|
34
60
|
export function renderReport(report) {
|
|
35
61
|
const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
|
|
36
62
|
const width = Math.max(...report.roles.map((r) => r.name.length), 4);
|
|
37
63
|
|
|
38
64
|
for (const r of report.roles) {
|
|
39
|
-
const writes = r.writes.join(" ") || `${C.dim}nothing${C.off}`;
|
|
65
|
+
const writes = collapseSidecars(r.writes).join(" ") || `${C.dim}nothing${C.off}`;
|
|
40
66
|
const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
|
|
41
67
|
lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
|
|
42
68
|
lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
if (report.providers?.length) {
|
|
72
|
+
lines.push(` ${C.b}key providers${C.off} ${C.dim}— seisin runs these itself, outside the sandbox, as you${C.off}\n`);
|
|
73
|
+
for (const p of report.providers)
|
|
74
|
+
lines.push(` ${C.green}${p.name}${C.off} ${p.command.join(" ")}${p.mode ? ` ${C.dim}(${p.mode})${C.off}` : ""}\n`);
|
|
75
|
+
lines.push("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
for (const w of report.warnings) {
|
|
46
79
|
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
47
80
|
if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
|
package/src/serve.js
CHANGED
|
@@ -19,6 +19,8 @@ import { loadConfig } from "./config.js";
|
|
|
19
19
|
import { read, logPath } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
21
|
import { pending, requestsPath, settle, applyGrant } from "./requests.js";
|
|
22
|
+
import { walls } from "./walls.js";
|
|
23
|
+
import { explain } from "./owners.js";
|
|
22
24
|
|
|
23
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
@@ -29,9 +31,124 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
29
31
|
* page is a view of it — someone editing seisin.toml in an editor should see
|
|
30
32
|
* the console change, not wonder why it disagrees.
|
|
31
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* Today's denials, grouped by what was denied rather than by who asked.
|
|
36
|
+
*
|
|
37
|
+
* Called `causes` and not `friction`: `seisin review` already has a `friction`
|
|
38
|
+
* that counts something narrower — repeated denials that are not about keys —
|
|
39
|
+
* and two screens of one product reporting different numbers under one label
|
|
40
|
+
* is the kind of thing nobody notices until they are compared.
|
|
41
|
+
*
|
|
42
|
+
* The console used to show them per role, which is the same list a hundred
|
|
43
|
+
* times over: measured on one deployment, 1,080 of 1,422 refusals were a
|
|
44
|
+
* single lock file across six repositories, and per-role that reads as six
|
|
45
|
+
* hundred identical rows instead of one sentence. Grouped, the shape of the
|
|
46
|
+
* day is visible at a glance and it is almost never a territory dispute.
|
|
47
|
+
*
|
|
48
|
+
* `stillRefused` is recomputed against the policy as it stands, not read out
|
|
49
|
+
* of the log — a cause that has been granted since is history, not friction,
|
|
50
|
+
* and leaving it on the page sends somebody to fix what is already fixed.
|
|
51
|
+
*/
|
|
52
|
+
export function causesOf(cfg, entries) {
|
|
53
|
+
const by = new Map();
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
56
|
+
const k = `${e.action}\u0000${e.target}`;
|
|
57
|
+
const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(), owners: e.owners ?? [] };
|
|
58
|
+
g.times++;
|
|
59
|
+
g.roles.add(e.role);
|
|
60
|
+
by.set(k, g);
|
|
61
|
+
}
|
|
62
|
+
const total = [...by.values()].reduce((n, g) => n + g.times, 0);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The same grouping again, one level coarser: by the NAME at the end of the
|
|
66
|
+
* path rather than the path.
|
|
67
|
+
*
|
|
68
|
+
* Grouping by path alone gets the most important reading backwards. Measured
|
|
69
|
+
* here: six of the top seven causes were `.git/index.lock` in six different
|
|
70
|
+
* repositories, no single one above 17% — so "no cause dominates" is what the
|
|
71
|
+
* arithmetic says and the opposite of what is true. Three quarters of the
|
|
72
|
+
* window was one *kind* of thing, and that is a tooling problem with a
|
|
73
|
+
* mechanical fix, not a territory question anybody needs to rule on.
|
|
74
|
+
*
|
|
75
|
+
* By last segment, and nothing cleverer. A regex that recognised lock files,
|
|
76
|
+
* caches and build outputs would be a list of guesses about other people's
|
|
77
|
+
* toolchains that quietly goes stale; a repeated filename is a fact about
|
|
78
|
+
* this log.
|
|
79
|
+
*/
|
|
80
|
+
const fams = new Map();
|
|
81
|
+
for (const g of by.values()) {
|
|
82
|
+
const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
|
|
83
|
+
const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
|
|
84
|
+
f.times += g.times;
|
|
85
|
+
f.paths++;
|
|
86
|
+
// The places, so the console can show what a name is made of instead of
|
|
87
|
+
// asserting a percentage the reader has to take on faith.
|
|
88
|
+
f.where.push({ target: g.target, times: g.times });
|
|
89
|
+
for (const r of g.roles) f.roles.add(r);
|
|
90
|
+
fams.set(name, f);
|
|
91
|
+
}
|
|
92
|
+
const families = [...fams.values()]
|
|
93
|
+
.sort((a, b) => b.times - a.times)
|
|
94
|
+
.map((f) => ({
|
|
95
|
+
name: f.name, times: f.times, paths: f.paths,
|
|
96
|
+
share: total ? f.times / total : 0,
|
|
97
|
+
roles: [...f.roles].sort(),
|
|
98
|
+
where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What a `grep` over the log cannot tell you.
|
|
103
|
+
*
|
|
104
|
+
* The headline used to be "74% of it is one name", and a field review
|
|
105
|
+
* applied this project's own test to it: *did the number tell you something
|
|
106
|
+
* you did not know?* For somebody who reads the raw log, no — they had
|
|
107
|
+
* already counted that. The console was repeating the log back.
|
|
108
|
+
*
|
|
109
|
+
* These two are different, because they need the **policy** and the log does
|
|
110
|
+
* not contain it. `unowned` is the count of causes on paths no role claims:
|
|
111
|
+
* no grant resolves those until somebody decides who owns them, which is a
|
|
112
|
+
* decision rather than a number. `settled` is friction that has since been
|
|
113
|
+
* granted — real yesterday, noise today.
|
|
114
|
+
*/
|
|
115
|
+
const live = [...by.values()];
|
|
116
|
+
const unowned = live.filter((g) => !(g.owners ?? []).length).length;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
total,
|
|
120
|
+
unowned,
|
|
121
|
+
// The real number of distinct causes, not the length of the list below.
|
|
122
|
+
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
123
|
+
// so taking N from the list reported the cap as if it were the count —
|
|
124
|
+
// a wrong number stated confidently, which is worse than no number.
|
|
125
|
+
distinct: by.size,
|
|
126
|
+
families: families.slice(0, 8),
|
|
127
|
+
causes: [...by.values()]
|
|
128
|
+
.sort((a, b) => b.times - a.times)
|
|
129
|
+
.slice(0, 12)
|
|
130
|
+
.map((g) => ({
|
|
131
|
+
action: g.action,
|
|
132
|
+
target: g.target,
|
|
133
|
+
times: g.times,
|
|
134
|
+
share: total ? g.times / total : 0,
|
|
135
|
+
roles: [...g.roles].sort(),
|
|
136
|
+
owners: g.owners,
|
|
137
|
+
// How many of the roles that hit this would still hit it. `some` and
|
|
138
|
+
// not `every`: two roles out of three still blocked is still friction,
|
|
139
|
+
// and requiring all of them would quietly retire a live cause the day
|
|
140
|
+
// one role got a grant.
|
|
141
|
+
stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !explain(cfg, r, g.action, g.target).allowed).length,
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
32
146
|
function state(configPath) {
|
|
33
147
|
const cfg = loadConfig(configPath);
|
|
34
148
|
const entries = read(logPath(cfg.root), { limit: 200 });
|
|
149
|
+
// A wider window than the activity tail, because grouping by cause is an
|
|
150
|
+
// arithmetic question and 200 lines of a busy day is one role's morning.
|
|
151
|
+
const forShape = read(logPath(cfg.root), { limit: 4000 });
|
|
35
152
|
|
|
36
153
|
const roles = Object.values(cfg.roles).map((r) => ({
|
|
37
154
|
name: r.name,
|
|
@@ -54,6 +171,13 @@ function state(configPath) {
|
|
|
54
171
|
keyDirs: cfg.keyDirs,
|
|
55
172
|
allowedDomains: cfg.allowedDomains,
|
|
56
173
|
roles,
|
|
174
|
+
causes: causesOf(cfg, forShape),
|
|
175
|
+
// What each role keeps being refused AND would still be refused today.
|
|
176
|
+
// Empty for a role that has hit nothing twice, which is most of them.
|
|
177
|
+
walls: Object.fromEntries(
|
|
178
|
+
Object.keys(cfg.roles)
|
|
179
|
+
.map((r) => [r, walls(cfg, r, { file: logPath(cfg.root) })])
|
|
180
|
+
.filter(([, w]) => w.length)),
|
|
57
181
|
log: entries.slice(-60).reverse(),
|
|
58
182
|
live: true,
|
|
59
183
|
};
|
|
@@ -114,8 +238,18 @@ export function serve(configPath, port = 4178) {
|
|
|
114
238
|
*/
|
|
115
239
|
const token = randomBytes(24).toString("hex");
|
|
116
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The path, without whatever came after `?`.
|
|
243
|
+
*
|
|
244
|
+
* `req.url` is the raw request target, so a query string made every route
|
|
245
|
+
* miss: `http://localhost:4178/?anything` answered 404 on the one page this
|
|
246
|
+
* server has. Harmless until a launcher appends a parameter or a link is
|
|
247
|
+
* shared with a suffix on it, and then the console just looks broken.
|
|
248
|
+
*/
|
|
249
|
+
const pathOf = (u) => (u ?? "/").split("?")[0];
|
|
250
|
+
|
|
117
251
|
const server = createServer(async (req, res) => {
|
|
118
|
-
if (req.method === "POST" && req.url === "/api/decide") {
|
|
252
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decide") {
|
|
119
253
|
if (req.headers["x-seisin-token"] !== token) {
|
|
120
254
|
res.writeHead(403, { "content-type": "application/json" });
|
|
121
255
|
return res.end(JSON.stringify({ error: "bad or missing token" }));
|
|
@@ -133,7 +267,7 @@ export function serve(configPath, port = 4178) {
|
|
|
133
267
|
res.writeHead(405).end("method not allowed");
|
|
134
268
|
return;
|
|
135
269
|
}
|
|
136
|
-
if (req.url === "/api/state") {
|
|
270
|
+
if (pathOf(req.url) === "/api/state") {
|
|
137
271
|
let body;
|
|
138
272
|
try {
|
|
139
273
|
body = JSON.stringify(state(configPath));
|
|
@@ -145,7 +279,7 @@ export function serve(configPath, port = 4178) {
|
|
|
145
279
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
146
280
|
return res.end(body);
|
|
147
281
|
}
|
|
148
|
-
if (req.url === "/" || req.url === "/index.html") {
|
|
282
|
+
if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
|
|
149
283
|
// Inlined rather than put in the URL: a fragment survives in history, in
|
|
150
284
|
// a screenshot, and in whatever the operator pastes into a chat.
|
|
151
285
|
const html = readFileSync(page, "utf8")
|
package/src/srt.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
18
18
|
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { entriesOf } from "./keys.js";
|
|
19
20
|
import { realpathSync, lstatSync } from "node:fs";
|
|
20
21
|
import { createHash } from "node:crypto";
|
|
21
22
|
|
|
@@ -283,7 +284,17 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
283
284
|
denyRead.push(realOrSelf(roleHomeRoot(config)));
|
|
284
285
|
allowRead.push(realOrSelf(home));
|
|
285
286
|
}
|
|
286
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Only a key that IS a file becomes a read grant.
|
|
289
|
+
*
|
|
290
|
+
* A reference has no path, so there is nothing here to allow: it is resolved
|
|
291
|
+
* by the parent before the sandbox starts and handed over as an environment
|
|
292
|
+
* variable or as a scratch file. See keys.js. Filtering here rather than
|
|
293
|
+
* upstream keeps the two kinds from having to know about each other — the
|
|
294
|
+
* settings this function emits are still, entirely, about the filesystem.
|
|
295
|
+
*/
|
|
296
|
+
for (const entry of entriesOf(role).filter((e) => e.kind === "file")) {
|
|
297
|
+
const key = entry.raw;
|
|
287
298
|
const path = key.includes("/") ? abs(key) : abs(join(dirs[0] ?? ".", key));
|
|
288
299
|
// A key has to live in a declared key directory. Without this check a
|
|
289
300
|
// slash in the name made the path relative to the repo root instead, so
|
|
@@ -373,11 +384,47 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
373
384
|
// Connecting is `network-outbound` and both of those are
|
|
374
385
|
// `file-write-unlink`, so denying them leaves the first working.
|
|
375
386
|
...(spool ? [spool, dirname(spool)] : []),
|
|
387
|
+
/**
|
|
388
|
+
* Whatever the key providers execute.
|
|
389
|
+
*
|
|
390
|
+
* `seisin.toml` is denied because a role that can rewrite the policy
|
|
391
|
+
* has no policy. A provider command is the same thing one level out:
|
|
392
|
+
* the parent runs it, unsandboxed, as you — so a role that can rewrite
|
|
393
|
+
* `bin/open-vault.sh` decides what runs outside the box. That is not a
|
|
394
|
+
* wider boundary, it is no boundary, and it arrives disguised as an
|
|
395
|
+
* ordinary file in somebody's territory.
|
|
396
|
+
*
|
|
397
|
+
* Found by writing the documentation: every worked example ended up
|
|
398
|
+
* using a script, because the config language has no escapes and a
|
|
399
|
+
* one-line shell pipeline cannot be spelled. So the shape this protects
|
|
400
|
+
* is not a corner case — it is the shape the tool pushes you into.
|
|
401
|
+
*
|
|
402
|
+
* Only a command that resolves to a path. A bare name like `security`
|
|
403
|
+
* or `op` is found on PATH, which is not this policy's to reason about.
|
|
404
|
+
*/
|
|
405
|
+
...providerPaths(config),
|
|
376
406
|
],
|
|
377
407
|
},
|
|
378
408
|
};
|
|
379
409
|
}
|
|
380
410
|
|
|
411
|
+
/**
|
|
412
|
+
* The provider executables that live at a path, absolute.
|
|
413
|
+
*
|
|
414
|
+
* A command with no separator (`security`, `op`, `gpg`) is resolved by the OS
|
|
415
|
+
* from PATH and is deliberately left alone: denying "wherever gpg happens to
|
|
416
|
+
* be" would mean writing a rule about a machine rather than about a repo.
|
|
417
|
+
*/
|
|
418
|
+
export function providerPaths(config) {
|
|
419
|
+
const out = [];
|
|
420
|
+
for (const p of Object.values(config.keyProviders ?? {})) {
|
|
421
|
+
const cmd = p.command?.[0];
|
|
422
|
+
if (!cmd || !cmd.includes("/")) continue;
|
|
423
|
+
out.push(cmd.startsWith("/") ? cmd : join(config.root ?? ".", cmd));
|
|
424
|
+
}
|
|
425
|
+
return [...new Set(out)];
|
|
426
|
+
}
|
|
427
|
+
|
|
381
428
|
/**
|
|
382
429
|
* `allowWrite` takes directories, not globs: the OS grants a subtree, it does
|
|
383
430
|
* not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
|
package/src/walls.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The walls a role has already hit.
|
|
3
|
+
*
|
|
4
|
+
* seisin answers *whose is this* at the moment of the refusal, once, in the
|
|
5
|
+
* middle of a turn, and then the sentence is gone. Nothing carries it forward,
|
|
6
|
+
* so the agent tries again — and the measurement that made this module exist
|
|
7
|
+
* says how often: of 345 denials on a real team, **88 (25%) were a repeat of
|
|
8
|
+
* something that same role had already been refused**. One role spent 37 calls
|
|
9
|
+
* on two walls, hitting the same one nineteen times.
|
|
10
|
+
*
|
|
11
|
+
* A block that repeats nineteen times is not a boundary working. It is a
|
|
12
|
+
* boundary that failed to communicate. The refusal was correct every time and
|
|
13
|
+
* still cost thirty-seven calls, because *correct* and *heard* are different
|
|
14
|
+
* properties and only one of them was being measured.
|
|
15
|
+
*
|
|
16
|
+
* So this reads what the role has already been told and hands it back, with
|
|
17
|
+
* the reason — the reason is the part that lets it look for the other way in.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it recomputes instead of trusting the log
|
|
20
|
+
*
|
|
21
|
+
* The obvious version of this is a time window: only count the last two days,
|
|
22
|
+
* because an old wall may have been granted since and telling an agent not to
|
|
23
|
+
* do something it can now do is worse than saying nothing. That is what the
|
|
24
|
+
* prior art does, and the window is a proxy for the thing you actually want to
|
|
25
|
+
* know.
|
|
26
|
+
*
|
|
27
|
+
* seisin can ask the real question instead. The policy is right here, so every
|
|
28
|
+
* candidate is checked against it **now**: if the role would be allowed today,
|
|
29
|
+
* it is not a wall, whatever the log says. Same move as `owners` and the
|
|
30
|
+
* request queue — recompute rather than believe — and it means a grant makes
|
|
31
|
+
* its wall disappear on the next turn instead of two days later.
|
|
32
|
+
*
|
|
33
|
+
* A window is still accepted, and still useful for a different reason: a
|
|
34
|
+
* refusal from three weeks ago is noise even if it is still refused today.
|
|
35
|
+
*/
|
|
36
|
+
import { openSync, readSync, closeSync, statSync } from "node:fs";
|
|
37
|
+
import { read } from "./log.js";
|
|
38
|
+
import { explain } from "./owners.js";
|
|
39
|
+
|
|
40
|
+
/** A wall is something you hit more than once. Once is information; twice is a pattern. */
|
|
41
|
+
export const MIN_HITS = 2;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What `role` keeps being denied, most-repeated first.
|
|
45
|
+
*
|
|
46
|
+
* Each entry is `{ action, target, times, reason, owners, firstAt, lastAt }`.
|
|
47
|
+
* The grain is action+target, not the directory above it: two writes into two
|
|
48
|
+
* different repositories are two walls, and merging them hides which one was
|
|
49
|
+
* hit.
|
|
50
|
+
*/
|
|
51
|
+
export function walls(config, role, { file, min = MIN_HITS, since = null, limit = 6 } = {}) {
|
|
52
|
+
const denied = read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
|
|
53
|
+
|
|
54
|
+
const byKey = new Map();
|
|
55
|
+
for (const e of denied) {
|
|
56
|
+
if (!e.target || !e.action) continue;
|
|
57
|
+
const key = `${e.action}\u0000${e.target}`;
|
|
58
|
+
const seen = byKey.get(key);
|
|
59
|
+
if (seen) { seen.times++; seen.lastAt = e.at; continue; }
|
|
60
|
+
byKey.set(key, { action: e.action, target: e.target, times: 1, firstAt: e.at, lastAt: e.at });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const w of byKey.values()) {
|
|
65
|
+
if (w.times < min) continue;
|
|
66
|
+
// The policy as it stands, not as it stood. A granted wall is not a wall,
|
|
67
|
+
// and the whole point of saying this to an agent is that it is true now.
|
|
68
|
+
const verdict = explain(config, role, w.action, w.target);
|
|
69
|
+
if (verdict.allowed) continue;
|
|
70
|
+
out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
|
|
71
|
+
}
|
|
72
|
+
out.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
|
|
73
|
+
return limit > 0 ? out.slice(0, limit) : out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How much of the log `timesHit` will look at.
|
|
78
|
+
*
|
|
79
|
+
* The log is append-only and never rotates — that is the storage design and it
|
|
80
|
+
* is the right one — so "read the file" is O(everything you have ever done),
|
|
81
|
+
* on a path that runs inside the hook. Measured on a 372 KB log: 3 ms per
|
|
82
|
+
* call, which is nothing, and 300 ms at 37 MB, which is not, on every refusal
|
|
83
|
+
* of every turn forever.
|
|
84
|
+
*
|
|
85
|
+
* So it reads the tail. That makes the count **recent repetition** rather than
|
|
86
|
+
* lifetime repetition, and recent is the one the sentence is about anyway: an
|
|
87
|
+
* agent being told "you have hit this three times" means this session, not
|
|
88
|
+
* last month. 4 MB is thousands of entries — far more than a turn produces,
|
|
89
|
+
* and bounded.
|
|
90
|
+
*/
|
|
91
|
+
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How many times this exact denial is already in the recent log for this role.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately NOT the same question as `walls()`: no policy recomputation, no
|
|
97
|
+
* threshold. The hook calls this while deciding, and the only thing it needs is
|
|
98
|
+
* a count — the policy has just been consulted, so asking it again would be
|
|
99
|
+
* asking a question already answered, in the one place that runs on every tool
|
|
100
|
+
* call.
|
|
101
|
+
*/
|
|
102
|
+
export function timesHit(file, role, action, target) {
|
|
103
|
+
let text;
|
|
104
|
+
try {
|
|
105
|
+
const { size } = statSync(file);
|
|
106
|
+
const from = Math.max(0, size - TAIL_BYTES);
|
|
107
|
+
const fd = openSync(file, "r");
|
|
108
|
+
try {
|
|
109
|
+
const buf = Buffer.alloc(size - from);
|
|
110
|
+
readSync(fd, buf, 0, buf.length, from);
|
|
111
|
+
text = buf.toString("utf8");
|
|
112
|
+
} finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return 0; // no log yet is not an error; it is a first turn
|
|
117
|
+
}
|
|
118
|
+
const lines = text.split("\n");
|
|
119
|
+
// The first line of a tail read is usually half an entry. Dropping it costs
|
|
120
|
+
// one count at most and keeps a truncated line from being parsed as if it
|
|
121
|
+
// were whole.
|
|
122
|
+
if (from0(text, file)) lines.shift();
|
|
123
|
+
let n = 0;
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (!line.trim()) continue;
|
|
126
|
+
let e;
|
|
127
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
128
|
+
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
|
|
129
|
+
}
|
|
130
|
+
return n;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Did we start mid-file? Then the first line is a fragment. */
|
|
134
|
+
function from0(text, file) {
|
|
135
|
+
try { return statSync(file).size > text.length; } catch { return false; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The calls that went into hitting the same wall twice or more.
|
|
140
|
+
*
|
|
141
|
+
* `times - 1` per wall, because the first one is the agent finding out. Every
|
|
142
|
+
* one after that is the cost of not having been able to remember.
|
|
143
|
+
*/
|
|
144
|
+
export function wasted(list) {
|
|
145
|
+
return list.reduce((n, w) => n + w.times - 1, 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The block an agent reads, or `""` when there is nothing to say.
|
|
150
|
+
*
|
|
151
|
+
* Empty rather than "no walls found", because this goes into a prompt: a line
|
|
152
|
+
* that says nothing happened, every turn, is how an agent learns to skip the
|
|
153
|
+
* whole section — and then the one turn it matters, it is skipped too.
|
|
154
|
+
*/
|
|
155
|
+
export function render(list) {
|
|
156
|
+
if (!list.length) return "";
|
|
157
|
+
const lines = [
|
|
158
|
+
"WALLS — you have already been denied these, and the policy still denies them.",
|
|
159
|
+
"Do not retry; the reason is where the other way in is.",
|
|
160
|
+
];
|
|
161
|
+
for (const w of list) {
|
|
162
|
+
lines.push(` ${w.times}× ${w.action} ${w.target}`);
|
|
163
|
+
lines.push(` ${w.reason}`);
|
|
164
|
+
}
|
|
165
|
+
const n = wasted(list);
|
|
166
|
+
// Said to the agent, not about it. One that can see what retrying cost has a
|
|
167
|
+
// reason to stop; one told "do not retry" has an instruction.
|
|
168
|
+
if (n > 0) lines.push(` (${n} of your calls went into retrying these.)`);
|
|
169
|
+
return lines.join("\n") + "\n";
|
|
170
|
+
}
|