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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"author": "Carlos Tapia",
|
|
20
|
-
"homepage": "https://
|
|
20
|
+
"homepage": "https://carlostapiaolguin3-stack.github.io/seisin/",
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
23
|
"url": "git+https://github.com/carlostapiaolguin3-stack/seisin.git"
|
package/src/cli.js
CHANGED
|
@@ -30,6 +30,7 @@ import { hook } from "./commands/hook.js";
|
|
|
30
30
|
import { ui } from "./commands/ui.js";
|
|
31
31
|
import { requests, grant, deny } from "./commands/requests.js";
|
|
32
32
|
import { whose } from "./commands/whose.js";
|
|
33
|
+
import { wallsCommand } from "./commands/walls.js";
|
|
33
34
|
import { reviewCommand } from "./commands/review.js";
|
|
34
35
|
import { wire } from "./commands/wire.js";
|
|
35
36
|
import { serveMcp } from "./mcp.js";
|
|
@@ -47,10 +48,11 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
47
48
|
seisin whose <path> who owns it — safe to call from inside the box
|
|
48
49
|
seisin scan find secrets outside the declared key dirs
|
|
49
50
|
seisin review what the log says about the policy
|
|
51
|
+
seisin walls <role> what that role keeps being denied, and what it cost
|
|
50
52
|
seisin wire let the agent record what it does
|
|
51
|
-
seisin requests what the agents asked for and
|
|
53
|
+
seisin requests what the agents asked for and cannot have
|
|
52
54
|
seisin grant <n> [--reason "…"] approve one, with its provenance
|
|
53
|
-
seisin
|
|
55
|
+
seisin decline <n> [--reason "…"] turn one down, and record why
|
|
54
56
|
seisin log [--role r] [--verdict denied]
|
|
55
57
|
seisin watch follow the log live
|
|
56
58
|
seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
|
|
@@ -90,8 +92,19 @@ const COMMANDS = {
|
|
|
90
92
|
whose: () => void whose(config(), argv),
|
|
91
93
|
wire: () => void wire(config()),
|
|
92
94
|
review: () => (reviewCommand(config(), argv).friction.length ? 1 : 0),
|
|
95
|
+
walls: () => (wallsCommand(config(), argv).length ? 1 : 0),
|
|
93
96
|
requests: () => (requests(config()).length ? 1 : 0),
|
|
94
97
|
grant: () => void grant(config(), argv),
|
|
98
|
+
/**
|
|
99
|
+
* A person DECLINES a request; the boundary DENIES a write. One word each,
|
|
100
|
+
* because they are opposite events and `deny` used to name both — a queue
|
|
101
|
+
* entry said `first refused on …` (the kernel) and the answer said
|
|
102
|
+
* `refused frontend ✕ …` (you).
|
|
103
|
+
*
|
|
104
|
+
* `deny` stays and is not documented. Renaming a published command over a
|
|
105
|
+
* word is not worth breaking somebody's script; teaching the new one is.
|
|
106
|
+
*/
|
|
107
|
+
decline: () => void deny(config(), argv),
|
|
95
108
|
deny: () => void deny(config(), argv),
|
|
96
109
|
mcp: async () => {
|
|
97
110
|
await serveMcp(version());
|
package/src/commands/init.js
CHANGED
|
@@ -126,7 +126,7 @@ export function renderObserved(config, entries) {
|
|
|
126
126
|
const declared = config.roles[name];
|
|
127
127
|
const seen = roles.get(name);
|
|
128
128
|
|
|
129
|
-
const from = declared ? declared.writes : [];
|
|
129
|
+
const from = declared ? declared.writesDeclared ?? declared.writes : [];
|
|
130
130
|
const found = seen ? generalise([...seen.writes]).filter((w) => !from.includes(w)) : [];
|
|
131
131
|
const keys = [
|
|
132
132
|
...(declared ? declared.keys : []),
|
package/src/commands/requests.js
CHANGED
|
@@ -73,7 +73,7 @@ export function renderQueue(queue) {
|
|
|
73
73
|
const note = handoffNote(r.handoff);
|
|
74
74
|
if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
|
|
75
75
|
});
|
|
76
|
-
lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin
|
|
76
|
+
lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]${C.off}\n\n`);
|
|
77
77
|
return lines.join("");
|
|
78
78
|
}
|
|
79
79
|
|
package/src/commands/run.js
CHANGED
|
@@ -13,6 +13,8 @@ import { join, dirname, delimiter } from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { settingsFor, roleHome } from "../srt.js";
|
|
15
15
|
import { buildEnv } from "../env.js";
|
|
16
|
+
import { resolveKeys } from "../keys.js";
|
|
17
|
+
import { nestedSandboxWarning } from "../nested.js";
|
|
16
18
|
import { spool, spoolPath, SOCK_ENV } from "../spool.js";
|
|
17
19
|
import { secretsOf, redactor } from "../redact.js";
|
|
18
20
|
import { STATE_DIR } from "../layout.js";
|
|
@@ -202,6 +204,54 @@ export async function run(config, argv) {
|
|
|
202
204
|
env.SEISIN_CONFIG = config.path;
|
|
203
205
|
env[SOCK_ENV] = sockPath;
|
|
204
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Reference keys: resolved here, by the parent, before anything is spawned.
|
|
209
|
+
*
|
|
210
|
+
* The ordering is the security property, not a convenience. The provider
|
|
211
|
+
* command holds the vault's own credential — the keychain prompt, the
|
|
212
|
+
* 1Password session — and running it inside the box would mean putting that
|
|
213
|
+
* credential in there too, which is the thing this feature exists to avoid.
|
|
214
|
+
* The confined side cannot reach the parent; the parent can reach the vault;
|
|
215
|
+
* so the parent resolves and hands over the result.
|
|
216
|
+
*
|
|
217
|
+
* A failure here stops the run. Not an empty value, not the file of the same
|
|
218
|
+
* name, not a skipped key: a role that starts without a credential it
|
|
219
|
+
* declared fails later, somewhere else, looking like something it is not.
|
|
220
|
+
*/
|
|
221
|
+
const resolved = resolveKeys(config, config.roles[role]);
|
|
222
|
+
const scratchKeys = join(dirname(sockPath), "keys");
|
|
223
|
+
if (resolved.some((r) => r.mode === "scratch")) mkdirSync(scratchKeys, { recursive: true, mode: 0o700 });
|
|
224
|
+
for (const { entry, mode, value } of resolved) {
|
|
225
|
+
if (mode === "env") { env[entry.name] = value; continue; }
|
|
226
|
+
// `scratch`: the value lands in the per-run directory this process already
|
|
227
|
+
// makes for the spool and already removes on exit, so its lifetime is the
|
|
228
|
+
// turn's by construction rather than by a cleanup somebody has to remember.
|
|
229
|
+
// The variable carries the PATH, which is the convention every tool that
|
|
230
|
+
// wants a secret from a file already reads (`_FILE`), and it means the
|
|
231
|
+
// value itself is not in the environment of a process that may print it.
|
|
232
|
+
const file = join(scratchKeys, entry.name);
|
|
233
|
+
writeFileSync(file, value, { mode: 0o600 });
|
|
234
|
+
/**
|
|
235
|
+
* Both names, because the world has two conventions and neither is ours.
|
|
236
|
+
*
|
|
237
|
+
* `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
|
|
238
|
+
* take the value in `<NAME>`. But a large family of tools already expects
|
|
239
|
+
* a PATH in the plain variable — `KUBECONFIG`,
|
|
240
|
+
* `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
|
|
241
|
+
* for those, `KUBECONFIG_FILE` is a name nothing reads.
|
|
242
|
+
*
|
|
243
|
+
* Found by running `kubectl` against it rather than reasoning about it:
|
|
244
|
+
* the file was there and correct, and the variable it was announced under
|
|
245
|
+
* was one kubectl has never heard of.
|
|
246
|
+
*
|
|
247
|
+
* Setting both costs nothing and leaks nothing: in `scratch` mode the
|
|
248
|
+
* value is deliberately not in the environment, so both variables hold a
|
|
249
|
+
* path and neither holds a secret.
|
|
250
|
+
*/
|
|
251
|
+
env[entry.name] = file;
|
|
252
|
+
env[`${entry.name}_FILE`] = file;
|
|
253
|
+
}
|
|
254
|
+
|
|
205
255
|
/**
|
|
206
256
|
* Isolated mode: point the toolchain at a home of this role's own.
|
|
207
257
|
*
|
|
@@ -247,7 +297,15 @@ export async function run(config, argv) {
|
|
|
247
297
|
const canRedact = wantRedact && !process.stdout.isTTY;
|
|
248
298
|
if (wantRedact && !canRedact) err(`${C.dim}seisin: redaction off — interactive terminal${C.off}\n`);
|
|
249
299
|
|
|
250
|
-
|
|
300
|
+
// Resolved references are masked alongside the key files. They never touched
|
|
301
|
+
// the disk under a path `secretsOf` could find, so without this the one kind
|
|
302
|
+
// of key that is supposed to be better looked after would be the one kind
|
|
303
|
+
// that sails through the redactor into a log.
|
|
304
|
+
const secrets = canRedact
|
|
305
|
+
? [...secretsOf(settings, readFileSync), ...resolved.map((r) => r.value)]
|
|
306
|
+
.filter((v) => v.length >= 8)
|
|
307
|
+
.sort((a, b) => b.length - a.length)
|
|
308
|
+
: [];
|
|
251
309
|
const outStream = secrets.length ? redactor(secrets) : null;
|
|
252
310
|
const errStream = secrets.length ? redactor(secrets) : null;
|
|
253
311
|
if (outStream) { outStream.pipe(process.stdout); errStream.pipe(process.stderr); }
|
|
@@ -327,6 +385,16 @@ export async function run(config, argv) {
|
|
|
327
385
|
// platform, so it belongs with the other standing limits, in `seisin check`.
|
|
328
386
|
void denials.available;
|
|
329
387
|
|
|
388
|
+
/**
|
|
389
|
+
* An agent that sandboxes itself has to be told about, before it starts.
|
|
390
|
+
*
|
|
391
|
+
* Said here and not by `check`, because `check` never sees the command. And
|
|
392
|
+
* said rather than fixed: passing somebody's bypass flag for them is exactly
|
|
393
|
+
* the kind of quiet widening this tool exists to refuse.
|
|
394
|
+
*/
|
|
395
|
+
const nested = nestedSandboxWarning(cmd);
|
|
396
|
+
if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
|
|
397
|
+
|
|
330
398
|
const child = spawn(srt, ["--settings", file, "--", ...cmd], {
|
|
331
399
|
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
332
400
|
env,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** `seisin walls <role>` — what that role keeps being refused, and what it cost. */
|
|
2
|
+
import { walls, render } from "../walls.js";
|
|
3
|
+
import { logPath } from "../log.js";
|
|
4
|
+
import { out } from "../render.js";
|
|
5
|
+
|
|
6
|
+
export function wallsCommand(config, argv = []) {
|
|
7
|
+
const role = argv[0];
|
|
8
|
+
if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>]");
|
|
9
|
+
if (!config.roles[role])
|
|
10
|
+
throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
|
|
11
|
+
const at = (flag, fallback) => {
|
|
12
|
+
const i = argv.indexOf(flag);
|
|
13
|
+
return i === -1 ? fallback : argv[i + 1];
|
|
14
|
+
};
|
|
15
|
+
const list = walls(config, role, {
|
|
16
|
+
file: logPath(config.root),
|
|
17
|
+
min: Number(at("--min", 2)),
|
|
18
|
+
since: at("--since", null),
|
|
19
|
+
});
|
|
20
|
+
// Nothing to say prints nothing, the way `scan` does when a repo is clean.
|
|
21
|
+
// A command that always speaks is one whose output stops being read.
|
|
22
|
+
if (list.length) out(render(list));
|
|
23
|
+
return list;
|
|
24
|
+
}
|
package/src/config.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { readFileSync, existsSync } from "node:fs";
|
|
12
12
|
import { resolve, dirname, join } from "node:path";
|
|
13
13
|
import { CONFIG_NAME } from "./layout.js";
|
|
14
|
+
import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
|
|
14
15
|
|
|
15
16
|
export { CONFIG_NAME } from "./layout.js";
|
|
16
17
|
|
|
@@ -174,6 +175,17 @@ function readValue(value, lineNo) {
|
|
|
174
175
|
|
|
175
176
|
const end = body.indexOf('"', i + 1);
|
|
176
177
|
if (end === -1) bad("unterminated string inside the array");
|
|
178
|
+
// A backslash before the closing quote means somebody wrote `\"` and
|
|
179
|
+
// expected an escape. There are none — the quote just ended the string,
|
|
180
|
+
// and the error they get otherwise is about a missing comma somewhere in
|
|
181
|
+
// the middle of a shell pipeline, which names the symptom and hides the
|
|
182
|
+
// cause. Measured while writing the docs: every attempt at a one-line
|
|
183
|
+
// provider command landed here.
|
|
184
|
+
if (body[end - 1] === "\\")
|
|
185
|
+
bad(`there are no escapes in this format, so the \\" in ` +
|
|
186
|
+
`${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
|
|
187
|
+
` A value cannot contain a double quote. If this is a shell command, put it in a ` +
|
|
188
|
+
`script and name the script instead — which is also how it stops being unreadable.`);
|
|
177
189
|
items.push(body.slice(i + 1, end));
|
|
178
190
|
i = end + 1;
|
|
179
191
|
expectItem = false; // a trailing comma is fine; a trailing item is not
|
|
@@ -273,6 +285,7 @@ export function loadConfig(path) {
|
|
|
273
285
|
// is how a tool gets kept out.
|
|
274
286
|
const keyDir = own(own(parsed, "keys"), "dir");
|
|
275
287
|
const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
|
|
288
|
+
const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
|
|
276
289
|
// `undefined` means "use the defaults"; an explicit empty array means "none".
|
|
277
290
|
// The difference matters: one is a user who has not thought about it, the
|
|
278
291
|
// other is a user who has.
|
|
@@ -280,7 +293,7 @@ export function loadConfig(path) {
|
|
|
280
293
|
const runtimeWrites = own(runtime, "writes");
|
|
281
294
|
|
|
282
295
|
const out = {
|
|
283
|
-
root: dirname(path), path, keyDirs,
|
|
296
|
+
root: dirname(path), path, keyDirs, keyProviders,
|
|
284
297
|
allowedDomains: own(own(parsed, "network"), "allow") ?? [],
|
|
285
298
|
runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
|
|
286
299
|
// `[runtime] isolate` — how much of your home a role stops reaching. See
|
|
@@ -296,12 +309,69 @@ export function loadConfig(path) {
|
|
|
296
309
|
const r = own(roles, name);
|
|
297
310
|
const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
|
|
298
311
|
const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
|
|
299
|
-
|
|
312
|
+
// Read once, here, so every consumer sees the same answer about what an
|
|
313
|
+
// entry is. `keys` itself stays the array of strings that was written: it
|
|
314
|
+
// is what `render`, `serve` and `init` print, and the whole point of a
|
|
315
|
+
// reference is that the written form is the reviewable one.
|
|
316
|
+
const keyEntries = keys.map((k) => {
|
|
317
|
+
try {
|
|
318
|
+
return parseKey(k);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
throw new Error(`${path}: roles.${name}: ${e.message}`);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
// Only a path needs somewhere to live. A reference does not, and demanding
|
|
324
|
+
// `[keys] dir` from a config whose secrets are all in a vault would be the
|
|
325
|
+
// tool insisting on the arrangement it was built for.
|
|
326
|
+
if (keyEntries.some((k) => k.kind === "file") && keyDirs.length === 0)
|
|
300
327
|
throw new Error(`${path}: roles.${name} lists keys, but [keys] dir is not set`);
|
|
328
|
+
/**
|
|
329
|
+
* An unknown scheme is refused, not ignored.
|
|
330
|
+
*
|
|
331
|
+
* Same rule as everywhere else here: a `keys = ["vault://x"]` with no
|
|
332
|
+
* `[keys.providers.vault]` is a configuration error, and the alternative —
|
|
333
|
+
* treating it as a filename, or as a key that quietly never arrives — is a
|
|
334
|
+
* policy that reads as protecting something it is not.
|
|
335
|
+
*/
|
|
336
|
+
for (const k of keyEntries) {
|
|
337
|
+
// `file://` ships with seisin, so it needs no declaration. It is the one
|
|
338
|
+
// builtin, and the reason is in keys.js: a secret in a plain file is the
|
|
339
|
+
// case this exists for, and making that case declare a provider that
|
|
340
|
+
// runs `cat` is a papercut on the only path most people take.
|
|
341
|
+
if (k.kind !== "ref" || BUILTIN.has(k.scheme) || own(keyProviders, k.scheme)) continue;
|
|
342
|
+
const known = Object.keys(keyProviders);
|
|
343
|
+
throw new Error(
|
|
344
|
+
`${path}: roles.${name}: key "${k.raw}" uses the "${k.scheme}" scheme, and no ` +
|
|
345
|
+
`[keys.providers.${k.scheme}] is declared.\n` +
|
|
346
|
+
` ${known.length ? `Declared providers: ${known.join(", ")}.` : "No providers are declared."}\n` +
|
|
347
|
+
` A provider is a command with a placeholder:\n` +
|
|
348
|
+
` [keys.providers.${k.scheme}]\n` +
|
|
349
|
+
` command = ["your-cli", "read", "{ref}"]`);
|
|
350
|
+
}
|
|
351
|
+
checkNames({ name, keyEntries });
|
|
352
|
+
const keyMode = own(r, "key_mode");
|
|
353
|
+
if (keyMode !== undefined && !MODES.includes(keyMode))
|
|
354
|
+
throw new Error(
|
|
355
|
+
`${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
|
|
356
|
+
`Known: ${MODES.join(", ")}.`);
|
|
301
357
|
out.roles[name] = {
|
|
302
358
|
name,
|
|
303
|
-
|
|
359
|
+
/**
|
|
360
|
+
* `writes` is what is GRANTED; `writesDeclared` is what was written.
|
|
361
|
+
*
|
|
362
|
+
* Expanded here and not at emit time, because two answers is how the
|
|
363
|
+
* document ends up looser or tighter than the boundary. Everything
|
|
364
|
+
* downstream — `ownersOf`, `explain`, `whose`, the settings, the console
|
|
365
|
+
* — reads `writes`, so the owner of a database is the owner of its `-wal`
|
|
366
|
+
* everywhere, and the kernel and the sentence agree. The declared form is
|
|
367
|
+
* kept so that regenerating a config writes back what a person typed
|
|
368
|
+
* rather than the expansion.
|
|
369
|
+
*/
|
|
370
|
+
writes: withSidecars(writes),
|
|
371
|
+
writesDeclared: writes,
|
|
304
372
|
keys,
|
|
373
|
+
keyEntries,
|
|
374
|
+
keyMode: keyMode ?? null,
|
|
305
375
|
env: asArray(own(r, "env"), `roles.${name}.env`),
|
|
306
376
|
network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
|
|
307
377
|
};
|
|
@@ -309,6 +379,93 @@ export function loadConfig(path) {
|
|
|
309
379
|
return out;
|
|
310
380
|
}
|
|
311
381
|
|
|
382
|
+
/**
|
|
383
|
+
* `[keys.providers.<name>]` — the commands that turn a reference into a value.
|
|
384
|
+
*
|
|
385
|
+
* Validated here and not at resolution time, because the whole argument for
|
|
386
|
+
* references is that a broken policy should be visible in `seisin check`,
|
|
387
|
+
* without asking anybody's keychain for a password to find out.
|
|
388
|
+
*
|
|
389
|
+
* `{ref}` is required. A command without it resolves every reference to the
|
|
390
|
+
* same thing, which is the kind of mistake that looks like it works: the first
|
|
391
|
+
* key comes back, so does the second, and they are both the first one.
|
|
392
|
+
*/
|
|
393
|
+
function readProviders(table, path) {
|
|
394
|
+
const out = Object.create(null);
|
|
395
|
+
if (table === undefined) return out;
|
|
396
|
+
for (const name of Object.keys(table)) {
|
|
397
|
+
const p = own(table, name);
|
|
398
|
+
// Redefining `file://` is refused rather than honoured. Two configs where
|
|
399
|
+
// the same scheme means different things is the failure this whole feature
|
|
400
|
+
// is supposed to remove — and it would be invisible, because the policy
|
|
401
|
+
// reads identically in both.
|
|
402
|
+
if (BUILTIN.has(name))
|
|
403
|
+
throw new Error(
|
|
404
|
+
`${path}: [keys.providers.${name}] — "${name}://" is built in and cannot be redefined.\n` +
|
|
405
|
+
` It reads a file, optionally one KEY out of it: "file://.secrets/all.env#TOKEN".\n` +
|
|
406
|
+
` For different behaviour, give your provider another name.`);
|
|
407
|
+
const command = own(p, "command");
|
|
408
|
+
if (!Array.isArray(command) || command.length === 0 || command.some((c) => typeof c !== "string"))
|
|
409
|
+
throw new Error(
|
|
410
|
+
`${path}: [keys.providers.${name}] needs a command, as an array of strings.\n` +
|
|
411
|
+
` e.g. command = ["security", "find-generic-password", "-w", "-s", "{ref}"]`);
|
|
412
|
+
if (!command.some((c) => c.includes("{ref}")))
|
|
413
|
+
throw new Error(
|
|
414
|
+
`${path}: [keys.providers.${name}] has no {ref} in its command, so every key of this ` +
|
|
415
|
+
`provider would resolve to the same value.\n` +
|
|
416
|
+
` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
|
|
417
|
+
const mode = own(p, "mode");
|
|
418
|
+
if (mode !== undefined && !MODES.includes(mode))
|
|
419
|
+
throw new Error(
|
|
420
|
+
`${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
|
|
421
|
+
`Known: ${MODES.join(", ")}.`);
|
|
422
|
+
out[name] = { name, command, mode: mode ?? null };
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A SQLite database is four files, and a policy should say it once.
|
|
429
|
+
*
|
|
430
|
+
* `bitacora/x/equipo.sqlite` is not the database — it is one of the files the
|
|
431
|
+
* database is made of. Writing to it in WAL mode also writes `-wal` and
|
|
432
|
+
* `-shm`; in rollback mode, `-journal`. So "this role writes this database"
|
|
433
|
+
* takes four lines, and a policy with eight databases and thirty-one roles
|
|
434
|
+
* takes nine hundred and ninety-two of them. Measured on a real one: 744 of
|
|
435
|
+
* 1,248 write lines — 60% — were sidecars, and every single one had its
|
|
436
|
+
* `.sqlite` declared beside it. Not one case wanted the `-wal` without the
|
|
437
|
+
* database.
|
|
438
|
+
*
|
|
439
|
+
* **This grants no authority that was not already there**, which is the whole
|
|
440
|
+
* reason it is safe to do implicitly. The `-wal` holds that database's pending
|
|
441
|
+
* transactions: whoever can write the database can already empty it, replace
|
|
442
|
+
* it, or corrupt it. The sidecar is not a second resource — it is the same one.
|
|
443
|
+
*
|
|
444
|
+
* It is also not a glob. The kernel grants a path and everything under it, so
|
|
445
|
+
* `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
|
|
446
|
+
* emitted as literal paths, exactly as if they had been typed.
|
|
447
|
+
*
|
|
448
|
+
* **The suffix list is closed on purpose** and this is the line that keeps the
|
|
449
|
+
* idea from becoming the exception list it could be: three suffixes, and only
|
|
450
|
+
* after a name that says SQLite. `.db` is deliberately not in it — plenty of
|
|
451
|
+
* things are called `.db` and a grant to create `whatever.db-wal` inside a
|
|
452
|
+
* directory somebody else owns would be new authority, arriving quietly. A
|
|
453
|
+
* database with another name declares its sidecars by hand, as before.
|
|
454
|
+
*/
|
|
455
|
+
const SIDECARS = ["-wal", "-shm", "-journal"];
|
|
456
|
+
const IS_SQLITE = /\.sqlite3?$/;
|
|
457
|
+
|
|
458
|
+
export function withSidecars(paths) {
|
|
459
|
+
const out = [];
|
|
460
|
+
for (const p of paths) {
|
|
461
|
+
out.push(p);
|
|
462
|
+
if (IS_SQLITE.test(p)) for (const s of SIDECARS) out.push(p + s);
|
|
463
|
+
}
|
|
464
|
+
// A policy written before this existed already lists them. Keeping the first
|
|
465
|
+
// occurrence keeps the order somebody chose.
|
|
466
|
+
return [...new Set(out)];
|
|
467
|
+
}
|
|
468
|
+
|
|
312
469
|
function asArray(value, where) {
|
|
313
470
|
if (value === undefined) return [];
|
|
314
471
|
if (typeof value === "string") return [value];
|
package/src/hook.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { explain, normalize } from "./owners.js";
|
|
20
20
|
import { append, logPath } from "./log.js";
|
|
21
|
+
import { timesHit } from "./walls.js";
|
|
21
22
|
import { record, requestsPath } from "./requests.js";
|
|
22
23
|
|
|
23
24
|
/** Tools whose input names a file directly. */
|
|
@@ -118,6 +119,12 @@ function kindOf(config, path) {
|
|
|
118
119
|
*/
|
|
119
120
|
const QUEUED = "Already queued for a person to answer — retrying or waiting will not move it.";
|
|
120
121
|
|
|
122
|
+
/** 4 -> "fourth". Past a handful the numeral reads better than the word. */
|
|
123
|
+
function ordinal(n) {
|
|
124
|
+
const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
|
|
125
|
+
return words[n] ?? `${n}th`;
|
|
126
|
+
}
|
|
127
|
+
|
|
121
128
|
/**
|
|
122
129
|
* Decides and records one tool call.
|
|
123
130
|
*
|
|
@@ -164,7 +171,12 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
164
171
|
if (!observe && !v.allowed)
|
|
165
172
|
ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
|
|
166
173
|
|
|
167
|
-
|
|
174
|
+
// The action as it was RECORDED, not as the tool named it: a read of a key
|
|
175
|
+
// is logged as "read" and a write is "write", and the counter below looks
|
|
176
|
+
// the entry up by that pair. Carrying the tool's word here instead would
|
|
177
|
+
// make every lookup miss, silently — the refusal would simply never
|
|
178
|
+
// mention that it had been given before.
|
|
179
|
+
verdicts.push({ ...v, target: rel, kind, action: kind === "key" ? "read" : t.action });
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
if (observe) return { decision: null, logged: verdicts.length };
|
|
@@ -173,6 +185,25 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
173
185
|
if (!denied) return { decision: null, logged: verdicts.length };
|
|
174
186
|
const wasRead = denied.kind === "key";
|
|
175
187
|
|
|
188
|
+
/**
|
|
189
|
+
* How many times this exact denial has already been handed to this role.
|
|
190
|
+
*
|
|
191
|
+
* The sentence below is correct every time and that has not been enough:
|
|
192
|
+
* measured on a real team, 25% of all denials were a repeat of something the
|
|
193
|
+
* same role had already been refused, one of them nineteen times. Correct
|
|
194
|
+
* and *heard* are different properties, and only the first was being
|
|
195
|
+
* measured. So the refusal carries its own history: the second time, it says
|
|
196
|
+
* it is the second time.
|
|
197
|
+
*
|
|
198
|
+
* Counted from the log that was just written, so the count includes this
|
|
199
|
+
* attempt. Silent on the first — a counter that says "1×" on every first
|
|
200
|
+
* refusal is noise on the turn where the sentence is already doing its job.
|
|
201
|
+
*/
|
|
202
|
+
const before = timesHit(logPath(config.root), role, denied.action, denied.target);
|
|
203
|
+
const again = before > 1
|
|
204
|
+
? ` You have been denied this ${before} times now; it is not going to work on the ${ordinal(before + 1)} try.`
|
|
205
|
+
: "";
|
|
206
|
+
|
|
176
207
|
// The hook advises; it does not enforce. Returning `deny` here stops the call
|
|
177
208
|
// early and — the part that matters — hands the agent a sentence it can act
|
|
178
209
|
// on, instead of an errno it can only retry.
|
|
@@ -189,7 +220,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
189
220
|
? wasRead
|
|
190
221
|
? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
|
|
191
222
|
: `${denied.target} belongs to ${denied.owners.join(", ")}. It is not ${role}'s to change — hand it over rather than working around it.`
|
|
192
|
-
: `${denied.reason} (seisin)`) + " " + QUEUED,
|
|
223
|
+
: `${denied.reason} (seisin)`) + again + " " + QUEUED,
|
|
193
224
|
},
|
|
194
225
|
};
|
|
195
226
|
}
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,18 @@ export { covers, ownersOf, keyHolders, explain } from "./owners.js";
|
|
|
25
25
|
export { settingsFor, RUNTIME_WRITES, expand } from "./srt.js";
|
|
26
26
|
export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Keys that are references, and how a resolved value reaches a role.
|
|
30
|
+
*
|
|
31
|
+
* `parseKey` and `MODES` are here because reading somebody's policy means
|
|
32
|
+
* knowing whether an entry is a path or a reference; `resolveRef` is not,
|
|
33
|
+
* because resolving is something seisin does and not something you ask it.
|
|
34
|
+
*/
|
|
35
|
+
export { parseKey, defaultName, entriesOf, MODES, RESERVED_ENV } from "./keys.js";
|
|
36
|
+
|
|
37
|
+
/** What a role keeps being refused, recomputed against the policy as it stands. */
|
|
38
|
+
export { walls, wasted, MIN_HITS } from "./walls.js";
|
|
39
|
+
|
|
28
40
|
/** The report `check` prints, without the printing. */
|
|
29
41
|
export { inspect, sharedPaths } from "./inspect.js";
|
|
30
42
|
|