seisin 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { SHAPES } from "../scan.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Credential-shaped names at the top of the repository, and nothing deeper.
|
|
7
|
+
*
|
|
8
|
+
* One `readdir`, no recursion, conventional names only. It exists to answer
|
|
9
|
+
* "does this repo plausibly have secrets" fast enough to run on every `check`,
|
|
10
|
+
* not to find them — `seisin scan` is the one that looks properly.
|
|
11
|
+
*/
|
|
12
|
+
const CREDENTIAL_NAMES = /^(\.env(\..+)?|\.secrets|secrets|\.netrc|.*\.(pem|key|p12|pfx))$/i;
|
|
13
|
+
|
|
14
|
+
export function credentialish(root) {
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const found = new Set(entries.filter((e) => CREDENTIAL_NAMES.test(e.name)).map((e) => e.name));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* And the ones whose name gives nothing away.
|
|
25
|
+
*
|
|
26
|
+
* The name test misses `credentials.txt`, `tokens.conf`, `config.local` —
|
|
27
|
+
* which a field review found by writing a test file this did not catch, and
|
|
28
|
+
* then blaming the test. The test was fine; the detector was name-only.
|
|
29
|
+
*
|
|
30
|
+
* So the root's small text files are also read, with the same shapes
|
|
31
|
+
* `seisin scan` uses. Root only, small only, and it stops at the first hit
|
|
32
|
+
* per file: this runs on every `check` and the full walk is `scan`'s job.
|
|
33
|
+
*/
|
|
34
|
+
const CERTAIN = SHAPES.filter(([c]) => c === "certain").map(([, , re]) => re);
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
if (found.size >= 4) break;
|
|
37
|
+
if (!e.isFile() || found.has(e.name)) continue;
|
|
38
|
+
try {
|
|
39
|
+
const p = join(root, e.name);
|
|
40
|
+
if (statSync(p).size > 64 * 1024) continue;
|
|
41
|
+
const text = readFileSync(p, "utf8");
|
|
42
|
+
if (CERTAIN.some((re) => re.test(text))) found.add(e.name);
|
|
43
|
+
} catch {
|
|
44
|
+
/* unreadable, or not text: not our business here */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...found].sort().slice(0, 4);
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { settingsFor } from "../srt.js";
|
|
2
|
+
|
|
3
|
+
export function enforceWarnings(config, roles) {
|
|
4
|
+
const warnings = [];
|
|
5
|
+
// A pattern the kernel cannot be given is not a policy, it is a sentence that
|
|
6
|
+
// reads like one. `run` refuses it; `check` exists precisely so you find that
|
|
7
|
+
// out while reading the map rather than mid-run.
|
|
8
|
+
for (const r of roles) {
|
|
9
|
+
try {
|
|
10
|
+
settingsFor(config, r.name);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// Whatever settingsFor refuses, `run` will refuse too. The label used to
|
|
13
|
+
// say "unenforceable-glob" for every one of them, which mislabelled the
|
|
14
|
+
// key-outside-its-directory error as a glob problem — a warning that
|
|
15
|
+
// names the wrong cause sends the reader to the wrong line.
|
|
16
|
+
warnings.push({
|
|
17
|
+
kind: "cannot-be-enforced",
|
|
18
|
+
headline: `${r.name}: ${e.message.split("\n")[0]}`,
|
|
19
|
+
detail: e.message.split("\n").slice(1).map((s) => s.trim()).filter(Boolean).join(" "),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return warnings;
|
|
24
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A role's territory, sorted by what the kernel will do with each line.
|
|
6
|
+
*
|
|
7
|
+
* Three shapes get through `settingsFor` (see EXPRESSIBLE in srt.js): the whole
|
|
8
|
+
* repo, a subtree `x/**`, and a literal path. The literal path is the one the
|
|
9
|
+
* text cannot settle on its own — `data/base.sqlite` and `data` are both
|
|
10
|
+
* literal, and to the kernel the second is a subtree because it is a directory.
|
|
11
|
+
* So the disk is asked, once per line: a regular file is a file, a directory is
|
|
12
|
+
* a subtree, and anything else — absent, a symlink, a socket — goes in neither
|
|
13
|
+
* count rather than being guessed at. That is one `lstat` per declared path,
|
|
14
|
+
* bounded by the length of the policy and not the size of the repo, which is
|
|
15
|
+
* the line `check` stays on.
|
|
16
|
+
*
|
|
17
|
+
* Subtrees come back as the absolute prefix the kernel will be handed, so that
|
|
18
|
+
* "is this file's folder inside that grant" is the same comparison the kernel
|
|
19
|
+
* makes — a prefix — and not a second reading of the glob.
|
|
20
|
+
*/
|
|
21
|
+
export function territoryOf(config, role) {
|
|
22
|
+
const root = resolve(config.root);
|
|
23
|
+
const abs = (p) => resolve(root, p);
|
|
24
|
+
const files = [];
|
|
25
|
+
const subtrees = [];
|
|
26
|
+
for (const glob of role.writes) {
|
|
27
|
+
if (glob === "**" || glob.endsWith("/**")) {
|
|
28
|
+
subtrees.push(abs(glob === "**" ? "." : glob.slice(0, -3)));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (/[*?[\]]/.test(glob)) continue; // already refused above as cannot-be-enforced
|
|
32
|
+
if (implicitSidecar(role, glob)) continue; // part of its database, not a file of its own
|
|
33
|
+
const s = lstatOrNull(abs(glob));
|
|
34
|
+
if (s?.isFile()) files.push({ glob, abs: abs(glob) });
|
|
35
|
+
else if (s?.isDirectory()) subtrees.push(abs(glob));
|
|
36
|
+
}
|
|
37
|
+
return { files, subtrees };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A `-wal`, `-shm` or `-journal` the role has because it writes the database
|
|
42
|
+
* beside it — the expansion in config.js, which `writes` carries. It is the
|
|
43
|
+
* same resource as the database, so it is not counted or reported as another
|
|
44
|
+
* file. One declared WITHOUT its database is the role's own, and counts.
|
|
45
|
+
*/
|
|
46
|
+
export function implicitSidecar(role, glob) {
|
|
47
|
+
const m = /^(.*\.sqlite3?)-(wal|shm|journal)$/.exec(glob);
|
|
48
|
+
return !!m && role.writes.includes(m[1]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Is `p` the prefix, or somewhere beneath it. The kernel's own test, in one line. */
|
|
52
|
+
export function under(p, prefix) {
|
|
53
|
+
return p === prefix || p.startsWith(prefix + "/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The regular files directly inside `dir`, by name. Not folders, not links, not deeper. */
|
|
57
|
+
export function regularFilesIn(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name).sort();
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function lstatOrNull(p) {
|
|
66
|
+
try {
|
|
67
|
+
return lstatSync(p);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CREDENTIAL_HOMES } from "../srt.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a role can still read in YOUR home, which is the limit people are most
|
|
5
|
+
* surprised by.
|
|
6
|
+
*
|
|
7
|
+
* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` and `~/.config/gh/hosts.yml` are
|
|
8
|
+
* ordinary readable files to every role unless `[runtime] isolate` says
|
|
9
|
+
* otherwise, and nothing on screen said so. Reads outside the key directories
|
|
10
|
+
* being open is a deliberate trade - an agent that cannot read the machine
|
|
11
|
+
* cannot work - but a trade nobody was shown is not a trade they made.
|
|
12
|
+
*
|
|
13
|
+
* It reports the level rather than pushing one. `credentials` closes those four
|
|
14
|
+
* and leaves the agent logged in; `home` closes the agent's own directories too
|
|
15
|
+
* and costs the session. Which of the two you want is the threat model, which
|
|
16
|
+
* is why there is no default beyond off.
|
|
17
|
+
*/
|
|
18
|
+
export function homeReachWarning(config) {
|
|
19
|
+
if (config.read === "territory") return null;
|
|
20
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
21
|
+
if (level === "home") return null; // nothing left to say
|
|
22
|
+
|
|
23
|
+
const where = CREDENTIAL_HOMES.slice(0, 4).join(" ");
|
|
24
|
+
return level === "credentials"
|
|
25
|
+
? {
|
|
26
|
+
kind: "home-partly-open",
|
|
27
|
+
headline: "your credentials are closed to every role; the agent's own directories are not",
|
|
28
|
+
detail:
|
|
29
|
+
`isolate = "credentials" denies ${where} and the rest. What stays open is ` +
|
|
30
|
+
"~/.claude to runs of claude and ~/.codex to runs of codex, which is what keeps the " +
|
|
31
|
+
"agent signed in, so the runs of one agent share its session and can read each other's. " +
|
|
32
|
+
'`isolate = "home"` closes those ' +
|
|
33
|
+
"too, and then every CLI in the sandbox will ask you to log in again.",
|
|
34
|
+
}
|
|
35
|
+
: {
|
|
36
|
+
kind: "home-open",
|
|
37
|
+
headline: `every role can read ${where} and the rest of your home`,
|
|
38
|
+
detail:
|
|
39
|
+
"reads outside the [keys] dir are open on purpose - an agent that cannot read the " +
|
|
40
|
+
"machine cannot work - but that includes the places credentials live. " +
|
|
41
|
+
'`[runtime] isolate = "credentials"` closes them, and the agent stays logged in.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warnings for `key_mode = "inject"`: the MITM it costs, the traffic it
|
|
3
|
+
* widens, the one thing it does not close, and the platform it does not run on.
|
|
4
|
+
*
|
|
5
|
+
* Masking is the only mode where the agent never sees the value, and it is the
|
|
6
|
+
* only one that reads the role's encrypted traffic to do it. A reader of
|
|
7
|
+
* `check` should see, per masked credential: which hosts are intercepted (and
|
|
8
|
+
* when a wildcard allow drags more than the one host under interception), that
|
|
9
|
+
* the value is still recoverable by reflection, and — off Linux — that the role
|
|
10
|
+
* will not run here. None of this is a reason not to use inject; it is the
|
|
11
|
+
* price printed next to the thing it buys.
|
|
12
|
+
*/
|
|
13
|
+
import { injectKeysOf } from "../keys.js";
|
|
14
|
+
import { coversHost as covers } from "../srt.js";
|
|
15
|
+
|
|
16
|
+
export function injectWarnings(config, roles) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const r of roles) {
|
|
19
|
+
let inject;
|
|
20
|
+
try {
|
|
21
|
+
inject = injectKeysOf(config, r);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
const [headline, ...rest] = e.message.split("\n");
|
|
24
|
+
out.push({ kind: "cannot-be-enforced", role: r.name, headline, detail: rest.join("\n").trim() });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (inject.length === 0) continue;
|
|
28
|
+
const allowed = r.network ?? config.allowedDomains ?? [];
|
|
29
|
+
|
|
30
|
+
if (process.platform !== "linux")
|
|
31
|
+
out.push({
|
|
32
|
+
kind: "inject-platform",
|
|
33
|
+
role: r.name,
|
|
34
|
+
headline: `${r.name}: key_mode = "inject" is not available on ${process.platform} — it is Linux-only for now`,
|
|
35
|
+
detail: `On this machine the role will not run. Masked injection on macOS is tracked upstream ` +
|
|
36
|
+
`(anthropics/sandbox-runtime#593) and enables once that lands. Run this role on Linux, or ` +
|
|
37
|
+
`use "env"/"run-file" here.`,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const c of inject) {
|
|
41
|
+
const terminated = new Set();
|
|
42
|
+
const wholesale = [];
|
|
43
|
+
for (const h of c.hosts)
|
|
44
|
+
for (const p of allowed)
|
|
45
|
+
if (covers(p, h)) {
|
|
46
|
+
terminated.add(p);
|
|
47
|
+
if (p.startsWith("*.")) wholesale.push({ pattern: p, host: h });
|
|
48
|
+
}
|
|
49
|
+
const under = [...terminated].join(", ") || "(none reachable — check network)";
|
|
50
|
+
const wildcardNote = wholesale.length
|
|
51
|
+
? ` The wildcard ${wholesale.map((w) => w.pattern).join(", ")} is terminated whole, not just ` +
|
|
52
|
+
`${wholesale.map((w) => w.host).join(", ")}: srt cannot intercept one host out of a wildcard.`
|
|
53
|
+
: "";
|
|
54
|
+
out.push({
|
|
55
|
+
kind: "inject-mitm",
|
|
56
|
+
role: r.name,
|
|
57
|
+
headline: `${r.name}: ${c.name} is masked (agent sees a fake); TLS terminated for ${under}`,
|
|
58
|
+
detail:
|
|
59
|
+
`seisin reads this role's HTTPS to ${under} with a CA of its own, to substitute the real token ` +
|
|
60
|
+
`on the way out.${wildcardNote} The token is still recoverable by reflection: if ${c.hosts.join("/")} ` +
|
|
61
|
+
`stores what the agent sends and returns it later (an issue, a gist, a comment, a note), the agent ` +
|
|
62
|
+
`can POST the sentinel, have the host keep the real value, and read it back. inject stops the agent ` +
|
|
63
|
+
`from seeing the token directly and from sending it anywhere else — not this. Use a least-scope token.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { entriesOf } from "../keys.js";
|
|
2
|
+
import { credentialish } from "./credentials.js";
|
|
3
|
+
|
|
4
|
+
export function envWarnings(roles) {
|
|
5
|
+
const warnings = [];
|
|
6
|
+
// `env` is a hole in the [keys] model, on purpose: some tools only take a
|
|
7
|
+
// credential through the environment. It should still be loud, because it is
|
|
8
|
+
// the one place a secret reaches a role without being a declared key.
|
|
9
|
+
const SECRETISH = /(TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTH|COOKIE|SESSION|PRIVATE|CREDENTIAL)/i;
|
|
10
|
+
for (const r of roles) {
|
|
11
|
+
const risky = (r.env ?? []).filter((n) => SECRETISH.test(n));
|
|
12
|
+
if (risky.length)
|
|
13
|
+
warnings.push({
|
|
14
|
+
kind: "secret-through-env",
|
|
15
|
+
headline: `${r.name} receives ${risky.join(", ")} through the environment`,
|
|
16
|
+
detail:
|
|
17
|
+
"that bypasses [keys] entirely: it is not scoped, not redacted by name, and not " +
|
|
18
|
+
"visible in the key map. Declare it as a key file where the tool allows one.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return warnings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function keyFloorWarnings(config) {
|
|
25
|
+
const warnings = [];
|
|
26
|
+
/**
|
|
27
|
+
* A policy with no credential floor at all, in a repo that has credentials.
|
|
28
|
+
*
|
|
29
|
+
* `[keys] dir` is what produces `denyRead`. Without it the emitted settings
|
|
30
|
+
* carry an empty deny list, so every role reads every secret in the
|
|
31
|
+
* repository — and until this warning, nothing said so. The existing
|
|
32
|
+
* `keys-unscoped` only fires when a role *lists* keys, which is the case
|
|
33
|
+
* where somebody already thought about it.
|
|
34
|
+
*
|
|
35
|
+
* The dangerous shape is the other one, and it is not hypothetical: a policy
|
|
36
|
+
* generated by a script. The deployment this was built against generates its
|
|
37
|
+
* `seisin.toml` from a Python program, and declares `[keys] dir` mostly so
|
|
38
|
+
* `scan` skips those directories. A generator that dropped that one line
|
|
39
|
+
* would hand every role the credential tree and produce a clean `check`.
|
|
40
|
+
*
|
|
41
|
+
* Credit where it is due: this is `L-01` of a code review of another
|
|
42
|
+
* project's contract type, which makes its credential floor impossible to
|
|
43
|
+
* construct empty. That is the right shape and it is a bigger change than a
|
|
44
|
+
* warning. This is the cheap half — and the review's claim that the file
|
|
45
|
+
* path was safe turned out to be generous: it was not.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately NOT a full `seisin scan`: that walks the tree and `check` has
|
|
48
|
+
* to stay instant. A shallow look at the repository root catches the
|
|
49
|
+
* conventional places and costs one `readdir`.
|
|
50
|
+
*/
|
|
51
|
+
if ((config.keyDirs ?? []).length === 0) {
|
|
52
|
+
const found = credentialish(config.root);
|
|
53
|
+
if (found.length)
|
|
54
|
+
warnings.push({
|
|
55
|
+
kind: "no-key-floor",
|
|
56
|
+
headline: `no [keys] dir is declared, and this repo has ${found.join(", ")} — every role can read them`,
|
|
57
|
+
detail:
|
|
58
|
+
"Without a key directory the emitted policy denies no reads at all. Declare the " +
|
|
59
|
+
"directory your secrets live in, and the roles that may read each one.",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Only a key that is a PATH needs a key directory. A config whose secrets all
|
|
64
|
+
// live in a vault has no `[keys] dir` and nothing wrong with it, and warning
|
|
65
|
+
// there would be the tool insisting on the arrangement it was built for.
|
|
66
|
+
if (config.keyDirs.length === 0 &&
|
|
67
|
+
Object.values(config.roles).some((r) => entriesOf(r).some((k) => k.kind === "file")))
|
|
68
|
+
warnings.push({
|
|
69
|
+
kind: "keys-unscoped",
|
|
70
|
+
headline: "keys are listed but [keys] dir is unset — nothing will be scoped",
|
|
71
|
+
detail: null,
|
|
72
|
+
});
|
|
73
|
+
return warnings;
|
|
74
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { resolve, dirname, basename, relative, join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { territoryOf, implicitSidecar, under, regularFilesIn } from "./files.js";
|
|
4
|
+
import { covers } from "../owners.js";
|
|
5
|
+
|
|
6
|
+
export function layoutWarnings(config, roles, scoped) {
|
|
7
|
+
const warnings = [];
|
|
8
|
+
// A territory that leaves the repo is supported — a role can own its handover
|
|
9
|
+
// note one level up — but it is a different promise from "this repo, divided",
|
|
10
|
+
// and it should be a sentence somebody chose rather than a line that drifted.
|
|
11
|
+
const loose = []; // roles granting individual files
|
|
12
|
+
const listed = []; // role + folder pairs that enumerate
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A private folder inside another role's territory. Allowed — it is how a
|
|
16
|
+
* folder is taken out of a wide `writes = ["**"]` — but that role loses it,
|
|
17
|
+
* reading included, and nothing in its own table says so.
|
|
18
|
+
*/
|
|
19
|
+
for (const r of roles)
|
|
20
|
+
for (const p of r.private ?? []) {
|
|
21
|
+
const dir = p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
|
|
22
|
+
for (const o of Object.values(config.roles)) {
|
|
23
|
+
if (o.name === r.name) continue;
|
|
24
|
+
const g = (o.writesDeclared ?? o.writes).find((w) => covers(w, dir));
|
|
25
|
+
if (g)
|
|
26
|
+
warnings.push({
|
|
27
|
+
kind: "private-inside-territory",
|
|
28
|
+
headline: `${r.name}: private "${p}" is inside ${o.name}'s writes ("${g}") — ${o.name} can neither read nor write it`,
|
|
29
|
+
detail: `Every role but ${r.name} is denied the folder, and the deny wins over ${o.name}'s territory. ` +
|
|
30
|
+
`If ${o.name} needs what is in it, it asks ${r.name}; if it should keep it, the folder is not private.`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const r of roles) {
|
|
36
|
+
const out = r.writes.filter((w) => w.startsWith("/") || w.startsWith("../"));
|
|
37
|
+
if (out.length)
|
|
38
|
+
warnings.push({
|
|
39
|
+
kind: "territory-outside-repo",
|
|
40
|
+
headline: `${r.name} writes outside this repo: ${out.join(" ")}`,
|
|
41
|
+
detail: "the sandbox will grant it. Nothing here checks what lives there.",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A territory that is not on disk.
|
|
47
|
+
*
|
|
48
|
+
* A proposal written for another repo — or one typo — reads as a working
|
|
49
|
+
* policy: `check` printed the map and the first sign of anything wrong was a
|
|
50
|
+
* denial inside the agent. Said per glob, as a warning and never a failure:
|
|
51
|
+
* a role can own a folder it is about to create.
|
|
52
|
+
*/
|
|
53
|
+
for (const r of roles) {
|
|
54
|
+
for (const g of r.writes) {
|
|
55
|
+
if (g === "**" || g.startsWith("/") || g.startsWith("../")) continue;
|
|
56
|
+
// A database's sidecars come with it (config.js) and exist only while it
|
|
57
|
+
// is open: absent on disk is their normal state, not a typo to report.
|
|
58
|
+
if (implicitSidecar(r, g)) continue;
|
|
59
|
+
const fixed = [];
|
|
60
|
+
for (const part of g.split("/")) {
|
|
61
|
+
if (/[*?[\]]/.test(part)) break;
|
|
62
|
+
fixed.push(part);
|
|
63
|
+
}
|
|
64
|
+
if (!fixed.length || existsSync(join(config.root, ...fixed))) continue;
|
|
65
|
+
warnings.push({
|
|
66
|
+
kind: "writes-match-nothing",
|
|
67
|
+
headline: `${r.name} writes ${g} — matches nothing in this repo`,
|
|
68
|
+
detail: "fine if the role is about to create it; otherwise the territory points at a folder that is not there.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A territory inside a key directory.
|
|
75
|
+
*
|
|
76
|
+
* Seen in use: a role's whole territory sat under the folder declared as
|
|
77
|
+
* `[keys] dir`, which is closed to every role, so the role could write
|
|
78
|
+
* nothing at all — and `check` printed its territory as if it held. A key
|
|
79
|
+
* directory is read by the roles that declare a key in it and written by
|
|
80
|
+
* none; a folder a role must write belongs somewhere else.
|
|
81
|
+
*/
|
|
82
|
+
const keyRoots = (config.keyDirs ?? []).map((d) => d.replace(/\/+$/, "")).filter(Boolean);
|
|
83
|
+
for (const r of roles) {
|
|
84
|
+
for (const g of r.writes) {
|
|
85
|
+
const fixed = [];
|
|
86
|
+
for (const part of g.split("/")) {
|
|
87
|
+
if (/[*?[\]]/.test(part)) break;
|
|
88
|
+
fixed.push(part);
|
|
89
|
+
}
|
|
90
|
+
const at = fixed.join("/");
|
|
91
|
+
const dir = at && keyRoots.find((d) => at === d || at.startsWith(d + "/"));
|
|
92
|
+
if (!dir) continue;
|
|
93
|
+
warnings.push({
|
|
94
|
+
kind: "territory-in-key-dir",
|
|
95
|
+
headline: `${r.name} writes ${g} — inside the key directory ${dir}, which no role can write`,
|
|
96
|
+
detail: "a key directory is read by the roles that declare its keys and written by none. Move what this role writes out of it, or drop the territory.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A file granted by name does not come with its neighbours.
|
|
103
|
+
*
|
|
104
|
+
* Measured, in production, and it survived two reviews of the policy by people:
|
|
105
|
+
* a role was granted its database file and still could not write the
|
|
106
|
+
* database. The database opens a second file beside the first — a journal —
|
|
107
|
+
* and the sibling was outside the grant, so the write failed in the
|
|
108
|
+
* database's own words ("readonly") rather than as a permission error.
|
|
109
|
+
*
|
|
110
|
+
* What is deliberately NOT here: a list of which programs write which
|
|
111
|
+
* siblings. That would ask seisin to know about databases, and the next
|
|
112
|
+
* question is why it does not know about a lock file, the temporary file of
|
|
113
|
+
* an atomic write, or an editor's backup. The list has no end and every entry
|
|
114
|
+
* is a guess about a repo nobody foresaw. The general form needs no list: the
|
|
115
|
+
* kernel grants a path and everything under it, so a grant on a file covers
|
|
116
|
+
* the file and nothing else. Whether a tool will want a neighbour is the
|
|
117
|
+
* reader's to know; that the neighbour is not granted is arithmetic.
|
|
118
|
+
*/
|
|
119
|
+
for (const r of roles) {
|
|
120
|
+
const { files, subtrees } = territoryOf(config, r);
|
|
121
|
+
// A file inside one of the role's own subtrees has its siblings covered by
|
|
122
|
+
// that subtree, so naming it changes nothing and warns about nothing.
|
|
123
|
+
const alone = files.filter((f) => !subtrees.some((s) => under(dirname(f.abs), s)));
|
|
124
|
+
if (alone.length)
|
|
125
|
+
loose.push({ role: r.name, n: alone.length, of: r.writes.filter((g) => !implicitSidecar(r, g)).length,
|
|
126
|
+
globs: alone.map((f) => f.glob) });
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A territory written as a list of files is a photograph of the folder.
|
|
131
|
+
*
|
|
132
|
+
* A sandbox cannot say "the source files in this folder" — one level down
|
|
133
|
+
* is not a prefix, and `src/*` is refused above for exactly that reason,
|
|
134
|
+
* correctly. So a territory that means that has to be written as the files
|
|
135
|
+
* that exist today, and the day a file is added the policy is one behind:
|
|
136
|
+
* the owning role cannot write its own new file, and nothing fails loudly.
|
|
137
|
+
*
|
|
138
|
+
* Reported as a count, never as a verdict. "Comparable" here means only
|
|
139
|
+
* "a regular file in the same folder" — not the same extension, not the
|
|
140
|
+
* same shape of name, because either of those is a guess about what the
|
|
141
|
+
* territory *meant*, and a guess about intent is the thing this check
|
|
142
|
+
* exists to avoid. The unnamed files are listed by name instead, so the
|
|
143
|
+
* reader can see in one glance whether the gap is an oversight or a
|
|
144
|
+
* decision. seisin cannot tell, and does not pretend to.
|
|
145
|
+
*
|
|
146
|
+
* Silent when a folder has fewer than two named files (one file is a
|
|
147
|
+
* grant, not an enumeration) and when the folder is also granted as a
|
|
148
|
+
* subtree (then the list is decorative).
|
|
149
|
+
*/
|
|
150
|
+
const byDir = new Map();
|
|
151
|
+
for (const f of alone) {
|
|
152
|
+
const dir = dirname(f.abs);
|
|
153
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
154
|
+
byDir.get(dir).push(basename(f.abs));
|
|
155
|
+
}
|
|
156
|
+
for (const [dir, named] of byDir) {
|
|
157
|
+
if (named.length < 2) continue;
|
|
158
|
+
const onDisk = regularFilesIn(dir);
|
|
159
|
+
const unnamed = onDisk.filter((n) => !named.includes(n));
|
|
160
|
+
if (!unnamed.length) continue;
|
|
161
|
+
const shown = relative(resolve(config.root), dir);
|
|
162
|
+
listed.push({ role: r.name, named: named.length, total: onDisk.length,
|
|
163
|
+
where: shown ? shown + "/" : "the repo root", unnamed });
|
|
164
|
+
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One warning per finding, not one per role.
|
|
170
|
+
*
|
|
171
|
+
* These were emitted inside the role loop, which reads fine on the two-role
|
|
172
|
+
* fixture they were written against and falls apart on a real policy: 30
|
|
173
|
+
* roles produced 39 of these lines and took `seisin check` from 30 lines to
|
|
174
|
+
* **297**. That is the defect the detail text below is about — a check nobody
|
|
175
|
+
* finishes reading protects nobody — reached from the other side.
|
|
176
|
+
*
|
|
177
|
+
* So the count leads and a few examples follow. Whoever wants the full list
|
|
178
|
+
* has `seisin check <role>`, which is the question they were asking anyway.
|
|
179
|
+
*/
|
|
180
|
+
if (loose.length) {
|
|
181
|
+
const shown = loose.slice(0, 3).map((l) => `${l.role} (${l.n} of ${l.of})`).join(", ");
|
|
182
|
+
const more = loose.length > 3 ? `, and ${loose.length - 3} more` : "";
|
|
183
|
+
warnings.push({
|
|
184
|
+
kind: "siblings-uncovered",
|
|
185
|
+
headline:
|
|
186
|
+
`${loose.length} role(s) grant individual files rather than folders: ${shown}${more}`,
|
|
187
|
+
detail:
|
|
188
|
+
"the kernel grants exactly that path. Anything a tool creates beside it — a " +
|
|
189
|
+
"database's journal, a lock file, the temporary file of an atomic write — is a " +
|
|
190
|
+
"sibling, and a sibling is outside the grant; the failure arrives in the tool's " +
|
|
191
|
+
"own words, not as a permission error. Grant the folder where the tool needs " +
|
|
192
|
+
"neighbours. " +
|
|
193
|
+
(scoped
|
|
194
|
+
? `The files: ${loose[0].globs.join(" ")}.`
|
|
195
|
+
: "`seisin check <role>` names the files for one role."),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The one sibling that is not a guess: Claude Code's own.
|
|
201
|
+
*
|
|
202
|
+
* Claude Code's Edit and Write do not write the file. They write
|
|
203
|
+
* `<file>.tmp.<pid>.<12 hex>` beside it and rename it over, so a role that
|
|
204
|
+
* holds a file by name and not its folder is refused at the temporary file
|
|
205
|
+
* on every edit — EPERM, and the agent goes around it with a shell (measured:
|
|
206
|
+
* 4 EPERM in one real run). No grant can name that file: its name changes on
|
|
207
|
+
* every edit, and a write grant takes no glob (the runtime drops them; seisin
|
|
208
|
+
* refuses one above). So the choice is the folder, or the shell — said per
|
|
209
|
+
* role, which is where the decision is made.
|
|
210
|
+
*
|
|
211
|
+
* Said whatever agent the role runs: check cannot know, and a role that runs
|
|
212
|
+
* something else reads one line it can skip. Three roles by name and the rest
|
|
213
|
+
* as a count, for the reason the warning above gives.
|
|
214
|
+
*/
|
|
215
|
+
for (const l of loose.slice(0, 3)) {
|
|
216
|
+
// A file at the root has the whole repo for a folder, which is not a
|
|
217
|
+
// suggestion: then the move is to put it in a folder of its own.
|
|
218
|
+
const dirs = [...new Set(l.globs.map((g) => dirname(g)).filter((d) => d !== "."))].map((d) => `${d}/**`);
|
|
219
|
+
const fix = dirs.length
|
|
220
|
+
? `Either grant the folder (${dirs.join(", ")})`
|
|
221
|
+
: "Its folder is the repo root, so either move it into a folder the role can own whole and grant that";
|
|
222
|
+
warnings.push({
|
|
223
|
+
kind: "claude-atomic-write",
|
|
224
|
+
headline: `${l.role}: writes ${l.globs.slice(0, 3).join(" ")}${l.globs.length > 3 ? ` +${l.globs.length - 3}` : ""} by name — ` +
|
|
225
|
+
"Claude Code's Edit and Write on it fail (EPERM)",
|
|
226
|
+
detail:
|
|
227
|
+
"Claude Code edits a file by writing <file>.tmp.<pid>.<random> beside it and renaming it over; " +
|
|
228
|
+
"the temporary file is outside a grant on the file, and no grant can name it — its name changes " +
|
|
229
|
+
`on every edit and a write grant takes no glob. ${fix}, ` +
|
|
230
|
+
"or accept that the agent writes it with its shell (a redirect writes the file itself) instead of Edit. " +
|
|
231
|
+
"A role that does not run Claude Code can ignore this.",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (loose.length > 3)
|
|
235
|
+
warnings.push({
|
|
236
|
+
kind: "claude-atomic-write",
|
|
237
|
+
headline: `${loose.length - 3} more role(s) write files by name, where Claude Code's Edit and Write fail: seisin check <role>`,
|
|
238
|
+
detail: "",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (listed.length) {
|
|
242
|
+
const shown = listed.slice(0, 3)
|
|
243
|
+
.map((l) => `${l.role} names ${l.named} of ${l.total} in ${l.where}`).join("; ");
|
|
244
|
+
const more = listed.length > 3 ? `; and ${listed.length - 3} more` : "";
|
|
245
|
+
warnings.push({
|
|
246
|
+
kind: "enumerated-territory",
|
|
247
|
+
headline: `${listed.length} territory(ies) list some files of a folder: ${shown}${more}`,
|
|
248
|
+
detail:
|
|
249
|
+
`not named, in the first of them: ${listed[0].unnamed.slice(0, 8).join(" ")}` +
|
|
250
|
+
`${listed[0].unnamed.length > 8 ? ` … and ${listed[0].unnamed.length - 8} more` : ""}` +
|
|
251
|
+
`. A sandbox grants a path, ` +
|
|
252
|
+
`not "the files of this kind here", so a territory that means that is the list of files ` +
|
|
253
|
+
`that existed when it was written — the next one added lands outside it, and nothing ` +
|
|
254
|
+
`fails loudly. Whether these are left out on purpose is not something a count can tell.`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return warnings;
|
|
258
|
+
}
|