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.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
package/src/kinds.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * What kind of thing an unowned refused path is.
3
+ *
4
+ * "Owned by nobody" used to be the whole sentence, and the question a person
5
+ * brings to it is not *whose should this be* but *what do I do with it*. The
6
+ * obvious helper — suggest an owner, the role that asked or the owner of the
7
+ * nearest folder — was measured on one deployment's 317 unowned paths before
8
+ * it was built: it was right about 5% of the time, and in the other 95% it was
9
+ * wrong in the one direction seisin does not accept, it widened. A lock file
10
+ * inside `.git` is a mutex, not territory; a directory a test creates with its
11
+ * PID in the name is the test's bug; a credential is never granted a write.
12
+ * Giving any of those to "whoever asked" hands them a folder that is everybody's.
13
+ *
14
+ * So this names the kind and the move that fits it, and only `territory` — a
15
+ * normal path that nobody claims — is left as the decision it looks like.
16
+ *
17
+ * Read from the path alone, plus one fact about the log (`kindsOf`): the same
18
+ * name made unique per run in several sibling places. No disk, no policy, no
19
+ * clock, so it is the same answer on every screen that asks. It never changes
20
+ * what is allowed; it changes what is said about a refusal.
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { CREDENTIAL_HOMES } from "./grants.js";
24
+
25
+ /** Every kind, in the order a reader should deal with them, and the one line each says. */
26
+ export const KINDS = {
27
+ credential:
28
+ "credential-shaped: never grant a write to it; if a role needs to read it, declare it as a key",
29
+ git:
30
+ "git's own bookkeeping, not territory: grant the repository to the role that runs git there, never the file",
31
+ temporary:
32
+ "temporary files a tool or a test makes while it runs: point it at $TMPDIR (already writable through [runtime] writes), or fix it; do not grant it in the repo",
33
+ build:
34
+ "build output or installed dependencies: grant the folder to the role that builds it, or build outside the repo",
35
+ territory:
36
+ "an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there",
37
+ };
38
+
39
+ /** A lock file inside `.git` gets a more precise line than the rest of git's metadata. */
40
+ const GIT_LOCK =
41
+ "git taking a lock in a repo this role does not own: `seisin run` sets GIT_OPTIONAL_LOCKS=0 so status and diff stop taking it; a commit or fetch still needs the repository — grant the repository, never the lock";
42
+
43
+ /**
44
+ * Credential-shaped names.
45
+ *
46
+ * The places `[runtime] isolate` closes in a home directory are the first
47
+ * source (grants.js), by name, because a repository can hold the same files:
48
+ * a `.npmrc` with a token in it is the same secret in either place. `.config`
49
+ * is left out on purpose — it is a container, and inside a repo it is mostly
50
+ * tool settings; `.docker` and `.kube` count only for the file that holds the
51
+ * login (`config`, `config.json`), not for every Dockerfile beside it.
52
+ */
53
+ const HOME_NAMES = CREDENTIAL_HOMES.map((h) => h.replace(/^~\//, ""));
54
+ const CONTAINERS = new Set([".config", ".docker", ".kube"]);
55
+ const SECRET_DIRS = new Set([...HOME_NAMES.filter((n) => !CONTAINERS.has(n) && !/rc$|credentials$/.test(n)), ".secrets", "secrets"]);
56
+ const LOGIN_DIRS = new Set([".docker", ".kube"]);
57
+ const SECRET_FILES = new Set([...HOME_NAMES.filter((n) => /rc$|credentials$/.test(n)), ".pypirc", ".pgpass", ".htpasswd"]);
58
+ const SECRET_EXT = /\.(pem|key|p12|pfx|jks|keystore|kdbx|gpg)$/i;
59
+ const SSH_KEY = /^id_(rsa|dsa|ecdsa|ed25519)(_sk)?$/;
60
+ // `.env`, `.env.local`, `.env.production` — but not the file that exists to be committed.
61
+ const DOTENV = /^\.env(\..+)?$/;
62
+ const DOTENV_TEMPLATE = /\.(example|sample|template|dist|defaults?)$/i;
63
+ /**
64
+ * A token file: the word in the name, and a data extension. The extension is
65
+ * what keeps `src/auth/token.ts` — code about tokens — out of it.
66
+ */
67
+ const TOKEN_FILE = /(^|[-_.])(tokens?|secrets?|passwords?|passwd|credentials?|api[-_]?keys?|private[-_]?key)([-_.]|$)/i;
68
+ const DATA_EXT = /(^[^.]*$)|\.(txt|json|ya?ml|toml|ini|cfg|conf|env|key)$/i;
69
+
70
+ /**
71
+ * Scratch. Directories every toolchain uses for caches, and the shapes an
72
+ * atomic write, an editor or a database leaves beside a file for a moment.
73
+ */
74
+ const CACHE_DIRS = new Set([
75
+ "tmp", "temp", ".tmp", ".temp", ".cache", "_cache", "__pycache__", ".pytest_cache",
76
+ ".mypy_cache", ".ruff_cache", ".hypothesis", ".tox", ".nox", ".parcel-cache", ".turbo",
77
+ ".eslintcache", ".sass-cache", ".gradle", ".dart_tool",
78
+ ]);
79
+ const SCRATCH_FILE = [
80
+ /\.(tmp|temp|swp|swo|swx|bak|orig|rej|pid|sock|log)$/i,
81
+ /\.tmp[.\-_]/i, // `BACKLOG.md.tmp.32029.03c4` — write-then-rename
82
+ /\.py[co](\.\d+)?$/, // bytecode, and its own write-then-rename
83
+ /-(wal|shm|journal)$/, // sqlite's sidecars, beside a database
84
+ /~$/, /^\.#/, /^#.*#$/, // editors
85
+ /^\.!\d+!/, // `.!36165!file` — a copy in flight
86
+ /^\.DS_Store$/,
87
+ /^pytest-cache-files-/,
88
+ /(^|[-_])logs?$/i, // `logs`, `latest_logs`: what a run leaves
89
+ ];
90
+ /**
91
+ * `*.lock` is scratch — a mutex a tool takes and drops — except the files that
92
+ * pin dependencies, which are committed and somebody's to edit.
93
+ */
94
+ const TOOL_LOCK = /\.lock$/i;
95
+ const DEPENDENCY_LOCKS = new Set([
96
+ "yarn.lock", "Cargo.lock", "Gemfile.lock", "poetry.lock", "composer.lock", "flake.lock",
97
+ "Pipfile.lock", "uv.lock", "mix.lock", "pubspec.lock", "Podfile.lock", "bun.lock", "pdm.lock",
98
+ ]);
99
+
100
+ /**
101
+ * Build output and installed dependencies. The same names `seisin scan` skips
102
+ * as machinery nobody wrote (scan.js DEFAULT_IGNORE), minus the caches above.
103
+ */
104
+ const BUILD_DIRS = new Set([
105
+ "node_modules", "dist", "build", ".next", ".nuxt", ".svelte-kit", ".output", ".angular",
106
+ "vendor", ".venv", "venv", "coverage", "target", "out", ".expo", "bower_components",
107
+ ]);
108
+
109
+ const segmentsOf = (p) => String(p).replace(/\\/g, "/").split("/").filter((s) => s && s !== ".");
110
+
111
+ /**
112
+ * The kind of one path, from the path alone.
113
+ *
114
+ * `keyDirs` are the policy's declared key directories: a path under one is a
115
+ * credential whatever it is called. The order is the safe one — a name that is
116
+ * both a credential and scratch (`.env.tmp`) is said as a credential, since
117
+ * that is the reading that never ends in a grant.
118
+ */
119
+ export function kindOf(target, { keyDirs = [] } = {}) {
120
+ const segs = segmentsOf(target);
121
+ const name = segs[segs.length - 1] ?? "";
122
+ const dirs = segs.slice(0, -1);
123
+ const kind = (k, hint = KINDS[k]) => ({ kind: k, hint });
124
+
125
+ const flat = segs.join("/");
126
+ const inKeyDir = keyDirs.some((d) => {
127
+ const k = segmentsOf(d).join("/");
128
+ return k && (flat === k || flat.startsWith(k + "/") || flat.includes("/" + k + "/") || flat.endsWith("/" + k));
129
+ });
130
+ if (inKeyDir || dirs.some((d) => SECRET_DIRS.has(d)) || SECRET_FILES.has(name) ||
131
+ SECRET_EXT.test(name) || SSH_KEY.test(name) ||
132
+ (DOTENV.test(name) && !DOTENV_TEMPLATE.test(name)) ||
133
+ (TOKEN_FILE.test(name) && DATA_EXT.test(name)) ||
134
+ (LOGIN_DIRS.has(dirs[dirs.length - 1]) && /^config(\.json)?$/.test(name)))
135
+ return kind("credential");
136
+
137
+ const git = segs.indexOf(".git");
138
+ if (git !== -1) return git < segs.length - 1 && /\.lock$/.test(name) ? kind("git", GIT_LOCK) : kind("git");
139
+
140
+ if (segs.some((s) => CACHE_DIRS.has(s)) || SCRATCH_FILE.some((re) => re.test(name)) ||
141
+ (TOOL_LOCK.test(name) && !DEPENDENCY_LOCKS.has(name)))
142
+ return kind("temporary");
143
+
144
+ if (segs.some((s) => BUILD_DIRS.has(s))) return kind("build");
145
+
146
+ return kind("territory");
147
+ }
148
+
149
+ /**
150
+ * A name made unique per run: `test-run-57141-2b32ca`, `run_8812`,
151
+ * `out.4419525232`. The stem, and a tail of one or two tokens: the first with a
152
+ * digit, the second with a digit or all hex (`-bfaeec` is a random suffix
153
+ * that happened to draw no digit) — so `report-2026.json` never matches, since
154
+ * an extension is not a run.
155
+ * Only a hint on its own: `run-2026` looks the same as a folder somebody named.
156
+ */
157
+ const RUN_UNIQUE = /^(.+?)[-_.](?=[0-9a-z]*\d)([0-9a-z]{3,})([-_.](?:(?=[0-9a-z]*\d)[0-9a-z]{4,}|[0-9a-f]{6,}))?$/i;
158
+
159
+ /** How many siblings of one stem it takes to call it generated rather than named. */
160
+ export const GENERATED_SIBLINGS = 3;
161
+
162
+ /**
163
+ * The kind of every path in a set, which is what a screen has.
164
+ *
165
+ * One rule needs the set and not the path, and it is the one that mattered
166
+ * most on the log it was measured on: a test that makes a directory with its
167
+ * PID in the name produced 258 of 317 unowned paths, each one different. Alone,
168
+ * `test-run-57141-2b32ca` could be a real folder. The same stem with a
169
+ * different run-unique tail in three or more places under one parent is not a
170
+ * folder anybody named — it is a generator, and granting it to whoever asked
171
+ * would hand over the parent. A repeated name is a fact about this log, not a
172
+ * guess about somebody's toolchain, which is why this is here and a list of
173
+ * test runners is not.
174
+ *
175
+ * Returns a Map from each target to `{ kind, hint }`.
176
+ */
177
+ export function kindsOf(targets, opts = {}) {
178
+ const out = new Map();
179
+ const stems = new Map();
180
+ for (const t of targets) {
181
+ if (out.has(t)) continue;
182
+ out.set(t, kindOf(t, opts));
183
+ const segs = segmentsOf(t);
184
+ segs.forEach((s, i) => {
185
+ const m = RUN_UNIQUE.exec(s);
186
+ if (!m) return;
187
+ const k = `${segs.slice(0, i).join("/")}\u0000${m[1]}`;
188
+ (stems.get(k) ?? stems.set(k, new Set()).get(k)).add(s);
189
+ });
190
+ }
191
+ const generated = new Set();
192
+ for (const [k, names] of stems) {
193
+ if (names.size < GENERATED_SIBLINGS) continue;
194
+ const parent = k.split("\u0000")[0];
195
+ for (const n of names) generated.add(parent ? `${parent}/${n}` : n);
196
+ }
197
+ if (!generated.size) return out;
198
+ for (const [t, k] of out) {
199
+ if (k.kind !== "territory") continue;
200
+ const segs = segmentsOf(t);
201
+ for (let i = 1; i <= segs.length; i++)
202
+ if (generated.has(segs.slice(0, i).join("/"))) {
203
+ out.set(t, { kind: "temporary", hint: KINDS.temporary });
204
+ break;
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * A refusal that is expected, and why — apart from the kinds above.
212
+ *
213
+ * The kinds name a path no role owns and the move that fits it. This is the
214
+ * other case: a path the agent program itself reaches for from inside the
215
+ * sandbox, that the boundary is right to refuse, and that no move fixes because
216
+ * nothing is wrong. Claude Code checks for plugin updates on start and writes
217
+ * the new version into `~/.claude/plugins/cache`; under seisin that write comes
218
+ * from inside the run and the kernel says no. Plugins run outside the sandbox,
219
+ * and they update there, from the Claude Code you start without seisin.
220
+ *
221
+ * Shown as a wall or as "denied repeatedly", it read as a role stuck on
222
+ * something to grant — and the only grant that would quiet it is a write into
223
+ * what Claude Code runs outside the sandbox. So every screen says it apart, as
224
+ * expected, and none counts it as friction. It changes no verdict.
225
+ *
226
+ * Only the cache: the rest of `~/.claude/plugins` (which plugins are installed,
227
+ * which marketplaces are trusted) is not housekeeping, and a write there stays
228
+ * an ordinary protected refusal, said in full.
229
+ */
230
+ export const EXPECTED = {
231
+ plugins:
232
+ "Claude Code updating its plugins from inside the sandbox: plugins run outside it and update there, so the denial is expected — nothing to grant",
233
+ };
234
+
235
+ /**
236
+ * `{ kind, hint }` when the refusal of `target` is expected, else null.
237
+ *
238
+ * `target` as the log keeps it: absolute outside the repository. The home and
239
+ * `CLAUDE_CONFIG_DIR` are parameters so a test does not depend on the machine.
240
+ */
241
+ export function expectedOf(target, { home = homedir(), env = process.env } = {}) {
242
+ const t = String(target ?? "").replace(/\\/g, "/");
243
+ const roots = [
244
+ env.CLAUDE_CONFIG_DIR,
245
+ home && `${home.replace(/\/+$/, "")}/.claude`,
246
+ "~/.claude",
247
+ ].filter(Boolean).map((r) => `${String(r).replace(/\/+$/, "")}/plugins/cache`);
248
+ return roots.some((r) => t === r || t.startsWith(r + "/")) ? { kind: "plugins", hint: EXPECTED.plugins } : null;
249
+ }
package/src/lines.js ADDED
@@ -0,0 +1,26 @@
1
+ /** Bounded newline framing for the audit channel and the stdio MCP server. */
2
+ export function lineReader(consume, { maxBytes = 1_000_000, onTooLong } = {}) {
3
+ let buffer = "", bytes = 0, discarding = false;
4
+ return (chunk) => {
5
+ let from = 0;
6
+ for (;;) {
7
+ const nl = chunk.indexOf("\n", from);
8
+ const part = nl === -1 ? chunk.slice(from) : chunk.slice(from, nl);
9
+ if (!discarding) {
10
+ bytes += Buffer.byteLength(part);
11
+ if (bytes > maxBytes) {
12
+ buffer = ""; bytes = 0; discarding = true;
13
+ onTooLong?.();
14
+ } else buffer += part;
15
+ }
16
+ if (nl === -1) return;
17
+ // Discard the entire oversized frame, including any later chunks, up
18
+ // to its newline. Its suffix is never a fresh request. A burst of many
19
+ // valid frames, however, is not one oversized frame.
20
+ if (!discarding && buffer.trim()) consume(buffer);
21
+ buffer = ""; bytes = 0; discarding = false;
22
+ from = nl + 1;
23
+ if (from === chunk.length) return;
24
+ }
25
+ };
26
+ }