seisin 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/src/index.js
CHANGED
|
@@ -18,12 +18,11 @@ export { findConfig, loadConfig, parseToml } from "./config.js";
|
|
|
18
18
|
/** Where seisin's own files live. */
|
|
19
19
|
export { CONFIG_NAME, STATE_DIR, LOG_NAME } from "./layout.js";
|
|
20
20
|
|
|
21
|
-
/** Who owns a path, and the sentence to hand a
|
|
22
|
-
export { covers, ownersOf, keyHolders, explain } from "./owners.js";
|
|
21
|
+
/** Who owns a path, and the sentence to hand a denied agent. */
|
|
22
|
+
export { covers, ownersOf, keyHolders, explain, explainFileRead, readTarget } from "./owners.js";
|
|
23
23
|
|
|
24
|
-
/** A policy, resolved into sandbox settings
|
|
25
|
-
export { settingsFor
|
|
26
|
-
export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
24
|
+
/** A policy, resolved into sandbox settings. */
|
|
25
|
+
export { settingsFor } from "./srt.js";
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Keys that are references, and how a resolved value reaches a role.
|
|
@@ -32,33 +31,42 @@ export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
|
32
31
|
* knowing whether an entry is a path or a reference; `resolveRef` is not,
|
|
33
32
|
* because resolving is something seisin does and not something you ask it.
|
|
34
33
|
*/
|
|
35
|
-
export { parseKey, defaultName, entriesOf, MODES
|
|
34
|
+
export { parseKey, defaultName, entriesOf, MODES } from "./keys.js";
|
|
36
35
|
|
|
37
|
-
/** What a role keeps being
|
|
38
|
-
export { walls, wasted
|
|
36
|
+
/** What a role keeps being denied, recomputed against the policy as it stands. */
|
|
37
|
+
export { walls, wasted } from "./walls.js";
|
|
39
38
|
|
|
40
39
|
/** The report `check` prints, without the printing. */
|
|
41
40
|
export { inspect, sharedPaths } from "./inspect.js";
|
|
42
41
|
|
|
43
42
|
/** Credential-shaped content outside the declared key directories. */
|
|
44
|
-
export { scan
|
|
43
|
+
export { scan } from "./scan.js";
|
|
45
44
|
|
|
46
45
|
/** The append-only record, and what can be learned from it. */
|
|
47
46
|
export { read as readLog, logPath, observed, generalise } from "./log.js";
|
|
48
47
|
|
|
49
48
|
/**
|
|
50
|
-
* The queue of
|
|
49
|
+
* The queue of requests waiting on a person.
|
|
51
50
|
*
|
|
52
51
|
* `pending` and `grantFor` are public because reading the queue and drafting a
|
|
53
52
|
* proposal are things a caller — including an MCP server — legitimately does.
|
|
54
|
-
* `settle` and `applyGrant` are NOT exported:
|
|
53
|
+
* `settle` and `applyGrant` are NOT exported: granting is not a tool call, and
|
|
55
54
|
* the surface should make that hard to get wrong rather than merely document
|
|
56
|
-
* it. See docs/
|
|
55
|
+
* it. See docs/requests.md.
|
|
57
56
|
*/
|
|
58
|
-
export { pending as pendingRequests, requestsPath, grantFor } from "./requests.js";
|
|
57
|
+
export { pending as pendingRequests, requestsPath, grantFor, queueOf as requestQueue, recordHandoff } from "./requests.js";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where a denied write goes — decided, never executed.
|
|
61
|
+
*
|
|
62
|
+
* `requestQueue(config)` is the pending queue with each request's `handoff`
|
|
63
|
+
* decision against the policy as it stands. Something that starts roles reads
|
|
64
|
+
* it, starts `seisin run <handoff.role> --handoff <id>`, and may record with
|
|
65
|
+
* `recordHandoff` that it held one back; `decideHandoff` is there for the
|
|
66
|
+
* limits only that caller knows (how many chains a role has open, how many of
|
|
67
|
+
* the receiver are running). None of them grants anything.
|
|
68
|
+
*/
|
|
69
|
+
export { decideHandoff, MAX_DEPTH } from "./handoff.js";
|
|
59
70
|
|
|
60
71
|
/** One PreToolUse decision. */
|
|
61
72
|
export { decide, targetsOf } from "./hook.js";
|
|
62
|
-
|
|
63
|
-
/** Masking a role's own secrets on the way out. */
|
|
64
|
-
export { secretsOf, redactor } from "./redact.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { join, isAbsolute } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How Codex is signed in on this machine: "chatgpt", "apikey", or null.
|
|
7
|
+
*
|
|
8
|
+
* Read from `auth.json` in `CODEX_HOME` or `~/.codex`, by the parent, which
|
|
9
|
+
* `check` is. Only the shape is looked at — which fields are set — and
|
|
10
|
+
* nothing from the file is kept or printed.
|
|
11
|
+
*/
|
|
12
|
+
export function codexSignIn(env = process.env) {
|
|
13
|
+
const home = env.CODEX_HOME && isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : join(homedir(), ".codex");
|
|
14
|
+
let auth;
|
|
15
|
+
try { auth = JSON.parse(readFileSync(join(home, "auth.json"), "utf8")); } catch { return null; }
|
|
16
|
+
if (!auth || typeof auth !== "object") return null;
|
|
17
|
+
if (typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) return "apikey";
|
|
18
|
+
if (auth.tokens && typeof auth.tokens === "object" && auth.tokens.refresh_token) return "chatgpt";
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A role that looks set up for OpenAI and would leave Codex hanging.
|
|
24
|
+
*
|
|
25
|
+
* Signed in with ChatGPT, Codex talks to `chatgpt.com` for the model and to
|
|
26
|
+
* `auth.openai.com` to renew the sign-in — never to `api.openai.com` (measured
|
|
27
|
+
* 2026-09-30, codex 0.150.1: 0 connections). Without `chatgpt.com` it does not
|
|
28
|
+
* fail: it retries without end and prints nothing, which reads as an agent
|
|
29
|
+
* thinking. And the proxy's refusal does not reach seisin's log.
|
|
30
|
+
*
|
|
31
|
+
* `check` does not know which CLI a role runs, so the signal is the two facts
|
|
32
|
+
* it can see: this machine's Codex is signed in with ChatGPT, and the role's
|
|
33
|
+
* list names something under `openai.com` — a list written for OpenAI — but
|
|
34
|
+
* not `chatgpt.com`. A role whose `env` passes `OPENAI_API_KEY` is left out:
|
|
35
|
+
* it is set up for the API, where `api.openai.com` is right. Wrong costs a
|
|
36
|
+
* sentence, never a run, the same trade as the model-endpoint warning above.
|
|
37
|
+
*/
|
|
38
|
+
export function codexSignInWarnings(config, roles, env = process.env) {
|
|
39
|
+
if (codexSignIn(env) !== "chatgpt") return [];
|
|
40
|
+
const reaches = (list, host) => list.some((d) => d === "*" || d === host ||
|
|
41
|
+
(d.startsWith("*.") && host.endsWith(d.slice(1))));
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const r of roles) {
|
|
44
|
+
const list = r.network ?? config.allowedDomains ?? [];
|
|
45
|
+
if (!list.some((d) => /(^|\.)openai\.com$/i.test(d))) continue;
|
|
46
|
+
if ((r.env ?? []).includes("OPENAI_API_KEY")) continue;
|
|
47
|
+
const missing = ["chatgpt.com", "auth.openai.com"].filter((h) => !reaches(list, h));
|
|
48
|
+
if (!missing.length) continue;
|
|
49
|
+
out.push({
|
|
50
|
+
kind: "codex-chatgpt-endpoint",
|
|
51
|
+
headline: `${r.name} allows ${list.filter((d) => /openai\.com$/i.test(d)).join(" ")} but not ${missing.join(" ")}`,
|
|
52
|
+
detail:
|
|
53
|
+
"Codex on this machine is signed in with ChatGPT, and with that sign-in it talks to " +
|
|
54
|
+
"chatgpt.com for the model and to auth.openai.com to renew the sign-in, never to " +
|
|
55
|
+
"api.openai.com. " +
|
|
56
|
+
(missing.includes("chatgpt.com")
|
|
57
|
+
? "Without chatgpt.com a Codex run does not fail: it retries without end and prints " +
|
|
58
|
+
"nothing, and the proxy's denial does not reach seisin's log. "
|
|
59
|
+
: "Without auth.openai.com a Codex run works until the sign-in has to be renewed. ") +
|
|
60
|
+
`If ${r.name} runs Codex, add ${missing.join(" and ")}; if it does not, this is not about it.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|