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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `srt`: @anthropic-ai/sandbox-runtime, the sandbox seisin was built on.
3
+ *
4
+ * The default, and the reference every other provider is measured against:
5
+ * the settings seisin generates (srt.js, one level up) ARE this runtime's
6
+ * settings file, so it enforces all of them by construction and `unmet` is
7
+ * always empty. What a run hands it is the file and the command, nothing else.
8
+ */
9
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
10
+ import { join, dirname, delimiter } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+
15
+ /**
16
+ * The runtime seisin depends on first, one on PATH second, and nothing third.
17
+ *
18
+ * "The one seisin depends on" is found the way Node finds a dependency — up
19
+ * from this file, through every `node_modules` — not at one spelled-out path.
20
+ * `<seisin>/node_modules/.bin/srt` is only where it lands in a checkout. In a
21
+ * project that installs seisin as a dependency, npm hoists the runtime to the
22
+ * project's own `node_modules`, and that path does not exist. Measured with
23
+ * 0.5.0 packed and installed into a project: `npx seisin` still worked, because
24
+ * npx puts the project's `.bin` on PATH; `./node_modules/.bin/seisin` and
25
+ * `node node_modules/seisin/src/cli.js` did not — they said "not found", or,
26
+ * with a global srt 0.0.75 installed, ran under THAT one instead of the 0.0.78
27
+ * this version pins, with none of its fixes.
28
+ *
29
+ * There is deliberately no fallback to running unsandboxed. A permission tool
30
+ * that quietly becomes a no-op when its enforcer is missing is worse than one
31
+ * that refuses, because you keep trusting it.
32
+ */
33
+ export function resolveSrt({ from = HERE, path = process.env.PATH } = {}) {
34
+ const local = dependencySrt(from);
35
+ if (local) return local;
36
+
37
+ // Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
38
+ // prints a deprecation warning on every single run — Node's DEP0190 — which
39
+ // is a line of noise in front of every user for the sake of finding one file.
40
+ // It is also the concatenation hazard the warning is about.
41
+ for (const dir of (path ?? "").split(delimiter)) {
42
+ if (!dir) continue;
43
+ const candidate = join(dir, "srt");
44
+ if (existsSync(candidate)) return candidate;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const RUNTIME = "@anthropic-ai/sandbox-runtime";
50
+
51
+ /**
52
+ * The `srt` of the sandbox-runtime that resolves from `from`, or null.
53
+ *
54
+ * The `.bin/srt` npm linked beside the package when there is one — that is the
55
+ * file npm made executable — and otherwise the package's own `bin` entry.
56
+ * The package's `package.json` is found by walking up rather than through
57
+ * `require.resolve`, which a package's `exports` map is free to refuse.
58
+ */
59
+ export function dependencySrt(from = HERE) {
60
+ let dir = from;
61
+ for (;;) {
62
+ const nm = join(dir, "node_modules");
63
+ const pkg = join(nm, RUNTIME, "package.json");
64
+ if (existsSync(pkg)) {
65
+ const linked = join(nm, ".bin", "srt");
66
+ if (existsSync(linked)) return linked;
67
+ try {
68
+ const bin = JSON.parse(readFileSync(pkg, "utf8")).bin;
69
+ const rel = typeof bin === "string" ? bin : bin?.srt;
70
+ if (rel) {
71
+ const target = join(nm, RUNTIME, rel);
72
+ if (existsSync(target)) return target;
73
+ }
74
+ } catch { /* an unreadable package.json is not a runtime */ }
75
+ }
76
+ const up = dirname(dir);
77
+ if (up === dir) return null;
78
+ dir = up;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The directory of the runtime's own `apply-seccomp`, or null.
84
+ *
85
+ * On Linux the runtime runs that binary INSIDE the sandbox, from wherever the
86
+ * package is installed, before the command. With `read = "territory"` a home
87
+ * directory is shut, and seisin installed under it — nvm, a user npm prefix,
88
+ * a CI checkout in /home/runner — left the binary unmountable: every run died
89
+ * with `apply-seccomp: No such file or directory` before the canary (measured
90
+ * on ubuntu-latest and in Debian with the checkout in /root, srt 0.0.81).
91
+ * Found from the srt that runs (its real path, up to the package), so a
92
+ * global srt keeps its own.
93
+ */
94
+ export function seccompDir(bin = resolveSrt()) {
95
+ if (!bin) return null;
96
+ let dir;
97
+ try { dir = dirname(realpathSync(bin)); } catch { return null; }
98
+ for (;;) {
99
+ const pkg = join(dir, "package.json");
100
+ if (existsSync(pkg)) {
101
+ try {
102
+ if (JSON.parse(readFileSync(pkg, "utf8")).name === RUNTIME) {
103
+ const vendor = join(dir, "vendor", "seccomp");
104
+ return existsSync(vendor) ? vendor : null;
105
+ }
106
+ } catch { /* not the runtime's */ }
107
+ }
108
+ const up = dirname(dir);
109
+ if (up === dir) return null;
110
+ dir = up;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * How to put the runtime back, for the way this seisin was installed.
116
+ *
117
+ * It is a dependency, so it is missing only from a broken install — and the
118
+ * fix differs: a project's own node_modules, a global install, or a checkout.
119
+ * `npm i -g` was suggested to everyone, which for a project-local seisin
120
+ * installs a second runtime somewhere seisin does not look first.
121
+ */
122
+ export function installHint(from = HERE) {
123
+ const at = from.split("/node_modules/seisin/")[0];
124
+ if (at === from) return `Install it in this checkout with: npm install (in ${dirname(dirname(from))})`;
125
+ // npm's global layout is <prefix>/lib/node_modules on unix, …/npm/node_modules on Windows.
126
+ if (/\/lib$/.test(at) || /[\\/]npm$/.test(at))
127
+ return "seisin is installed globally; reinstall it with: npm install -g seisin";
128
+ return `seisin is installed in ${at}; reinstall it there with: npm install seisin`;
129
+ }
130
+
131
+ const MACH_PRELOAD = join(HERE, "srt-mach.mjs");
132
+
133
+ /**
134
+ * srt with srt-mach.mjs loaded into it, for a role whose browsers register a
135
+ * Mach service. Run by this node, so the preload is in the process: the runtime
136
+ * is a node script, and one that is not (a binary on PATH) cannot take it —
137
+ * refused here rather than run without the rule the role asked for, which
138
+ * would start the browser just to watch it die.
139
+ */
140
+ export function withMachPreload(bin, file, inner) {
141
+ const cli = realpathSync(bin);
142
+ if (!/\.(c|m)?js$/.test(cli))
143
+ throw new Error(`the sandbox runtime at ${bin} is not a node script, so the browser's Mach rule cannot be added to it ` +
144
+ `(browsers in seisin.toml). Use the runtime seisin installs with it.`);
145
+ return [process.execPath, "--import", MACH_PRELOAD, cli, "--settings", file, "--", ...inner];
146
+ }
147
+
148
+ export const srt = {
149
+ name: "srt",
150
+ locate: () => resolveSrt(),
151
+ missing: () => `sandbox runtime not found. ${installHint()}`,
152
+ unmet: () => [],
153
+ // `--` before the command, and it is not cosmetic. Without it the runtime's
154
+ // own argument parser reaches into what the agent was invoked with and eats
155
+ // anything resembling one of its flags: `claude --settings`, `claude -c`.
156
+ wrap: (bin, file, _settings, inner, { machRegister = [] } = {}) =>
157
+ machRegister.length ? withMachPreload(bin, file, inner) : [bin, "--settings", file, "--", ...inner],
158
+ // Takes the `browsers` Mach rule, through srt-mach.mjs.
159
+ machRegister: true,
160
+ // Each Seatbelt denial carries this runtime's tag with the wrapped command,
161
+ // which is how violations.js tells this run's denials from another's.
162
+ tagsDenials: true,
163
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `tartaros`: the Seatbelt / Landlock sandbox, driven by its own CLI.
3
+ *
4
+ * tartaros reads seisin's settings file itself (`--settings`, the schema of
5
+ * sandbox-runtime) and holds itself to the contract in docs/sandboxes.md:
6
+ * every field enforced, or it refuses to start. So seisin does not translate
7
+ * anything and does not keep its own list of what tartaros can do — a list
8
+ * like that is out of date the day tartaros learns something. It asks:
9
+ *
10
+ * tartaros explain --settings <file>
11
+ *
12
+ * reads the same file through the same code as `run` and exits 78, with the
13
+ * reason, on a field it cannot guarantee (2 before 0.1.0-alpha.6, which keeps
14
+ * 2 for a bad command line). seisin takes any exit but 0 as that no, so the
15
+ * code is not read. That answer is what `unmet` returns,
16
+ * so a role tartaros cannot hold is passed over before it starts (or `seisin
17
+ * check` says so), and the canary still checks every run that it can.
18
+ */
19
+ import { existsSync } from "node:fs";
20
+ import { join, delimiter } from "node:path";
21
+ import { assessCheck } from "./command.js";
22
+
23
+ /** `tartaros` on PATH, or null. */
24
+ export function resolveTartaros(path = process.env.PATH) {
25
+ for (const dir of (path ?? "").split(delimiter)) {
26
+ if (!dir) continue;
27
+ const candidate = join(dir, "tartaros");
28
+ if (existsSync(candidate)) return candidate;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** The command line for one run: the settings as they are, the role as a label. */
34
+ export function tartarosArgv(bin, file, inner, { role = null, machRegister = [] } = {}) {
35
+ // A browser role gets tartaros's browser grants — the shared memory, IOKit
36
+ // classes and sysctls a headless Chromium dies without (f7edae1) — and a
37
+ // run-private TMPDIR, the only place it may open a unix socket (bcb0c69):
38
+ // where @playwright/mcp publishes its browser.
39
+ const browser = machRegister.length ? ["--browser", "--private-tmp"] : [];
40
+ // --inherit-env: the environment seisin hands tartaros is already the one
41
+ // the role gets — built from the role's own list (env.js), with the hook's
42
+ // socket, the role's name and its env-mode keys. tartaros's own scrub
43
+ // (7f9925f) would drop those, and the hook would go silent.
44
+ return [bin, "run", ...(role ? ["--role", role] : []), "--inherit-env", ...browser, "--settings", file, "--", ...inner];
45
+ }
46
+
47
+ /** What tartaros says of this file: what it cannot hold, and its warnings. Only asked of a binary that was found. */
48
+ export function tartarosAssess(bin, file, { platform = process.platform } = {}) {
49
+ if (platform !== "darwin" && platform !== "linux") return { unmet: [`tartaros runs on macOS and Linux, not ${platform}`], warnings: [] };
50
+ return assessCheck([bin, "explain", "--settings", "{policy}"], file, "tartaros");
51
+ }
52
+
53
+ export const tartarosUnmet = (bin, file, ctx) => tartarosAssess(bin, file, ctx).unmet;
54
+
55
+ /**
56
+ * `browsers`: tartaros takes the Mach names to register in its settings, as
57
+ * network.allowMachRegister with sandbox-runtime's trailing `*` for a prefix
58
+ * (tartaros 99b0117). Only when there is one: an unchanged role is handed the
59
+ * same file as before, and srt, whose schema is strict, never sees the field.
60
+ */
61
+ export function tartarosSettings(settings, { machRegister = [] } = {}) {
62
+ if (!machRegister.length) return settings;
63
+ return { ...settings, network: { ...settings.network, allowMachRegister: machRegister.map((p) => `${p}*`) } };
64
+ }
65
+
66
+ export const tartaros = {
67
+ name: "tartaros",
68
+ locate: () => resolveTartaros(),
69
+ missing: () => "tartaros not found on PATH. Build it (cargo build --release -p tartaros-cli) and put its target directory on PATH.",
70
+ asksFile: true,
71
+ adapt: tartarosSettings,
72
+ machRegister: true,
73
+ assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),
74
+ unmet: (_settings, { bin, file, platform } = {}) => tartarosUnmet(bin, file, { platform }),
75
+ wrap: (bin, file, _settings, inner, ctx) => tartarosArgv(bin, file, inner, ctx),
76
+ // Every deny in its Seatbelt profile carries sandbox-runtime's tag
77
+ // (`CMD64_<command>_END_<suffix>_SBX`), so violations.js attributes them
78
+ // exactly as it does srt's. Measured 2026-10-13 with tartaros ad85515.
79
+ tagsDenials: true,
80
+ };
package/src/scan.js CHANGED
@@ -17,9 +17,10 @@
17
17
  * than no scanner: it converts a real risk into a wall of noise someone learns
18
18
  * to skip. Three things were wrong and all three are handled below.
19
19
  */
20
- import { readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
21
- import { join, relative, sep } from "node:path";
20
+ import { readdirSync, readFileSync, statSync, realpathSync, existsSync } from "node:fs";
21
+ import { join, resolve, relative, sep, dirname } from "node:path";
22
22
  import { covers } from "./owners.js";
23
+ import { toWritePath } from "./grants.js";
23
24
 
24
25
  /**
25
26
  * A match is `certain` when the shape alone is enough — these strings are
@@ -65,6 +66,29 @@ const PLACEHOLDER = /^(?:changeme|change_me|xxx+|your[-_a-z]*|example|placeholde
65
66
  * finding repeated five times, and three keys belonging to a company that is
66
67
  * not the one running the scan.
67
68
  */
69
+ /**
70
+ * A password inside a URL — `postgres://app:hunter2@db/prod`.
71
+ *
72
+ * The commonest way a database credential sits in a repo, and no issued shape
73
+ * covers it: `.env` with a DATABASE_URL scanned clean. Certain, because a URL
74
+ * with a password in it is a credential whatever the password is, unless the
75
+ * password is plainly a stand-in (`password`, `${DB_PASS}`, `<pass>`, `xxx`).
76
+ */
77
+ export const URL_CREDENTIAL = /\b[a-z][a-z0-9+.-]*:\/\/([^\s:@\/'"]+):([^\s@\/'"]+)@[^\s\/'"]+/i;
78
+ const PASSWORD_STANDIN = /^(?:pass(?:word)?|passwd|pwd|pw|secret|user|username)$|[{}$<>%]/i;
79
+
80
+ /**
81
+ * Files that usually hold secrets, by their name alone.
82
+ *
83
+ * Listed, never read for this — a name is not proof, and the list says so.
84
+ * A `.env` of `DEBUG=1` is a false alarm; a `.env` nobody declared that holds
85
+ * the production database is the most common credential there is, and a scan
86
+ * that only knows issued shapes calls it clean. `.example`, `.sample`,
87
+ * `.template` and `.dist` are the checked-in stand-ins and are left out.
88
+ */
89
+ export const SECRET_NAMES = /^(?:\.env(?:\..+)?|.+\.(?:pem|key|p12|pfx|jks|keystore)|id_(?:rsa|dsa|ecdsa|ed25519)|\.netrc|\.pgpass|credentials\.json|service-account.*\.json)$/i;
90
+ const STAND_IN_FILE = /\.(?:example|sample|template|dist|tmpl)$|\.(?:example|sample|template)\./i;
91
+
68
92
  export const DEFAULT_IGNORE = [
69
93
  "**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**",
70
94
  "**/vendor/**", "**/__pycache__/**", "**/.venv/**", "**/venv/**", "**/coverage/**",
@@ -74,7 +98,7 @@ export const DEFAULT_IGNORE = [
74
98
  "**/*.min.js", "**/*.min.css", "**/*-lock.json", "**/*.lock",
75
99
  ];
76
100
 
77
- const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|bz2|xz|mp[34]|mov|wav|woff2?|ttf|otf|eot|so|dylib|a|o|class|jar|wasm|sqlite3?|db)$/i;
101
+ const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|bz2|xz|mp[34]|mov|wav|woff2?|ttf|otf|eot|so|dylib|a|o|class|jar|wasm|sqlite3?|db|pyc|pyo)$/i;
78
102
  const MAX_BYTES = 512 * 1024;
79
103
  // Written as an escape, not as the byte itself. A literal NUL in the source
80
104
  // makes git classify this file as binary, and a file with no readable diff
@@ -88,17 +112,71 @@ const NUL = "\u0000";
88
112
  * `protectedDirs` are the declared key directories: a hit inside one is the
89
113
  * system working, not a finding. `ignore` adds to DEFAULT_IGNORE rather than
90
114
  * replacing it, so a user's list stays short.
115
+ *
116
+ * `limit` caps the `review` lines and the links, never the `certain` ones.
117
+ * It used to cap all of them together, and the walk stopped at the cap: 500
118
+ * ordinary `PASSWORD =` lines early in the tree hid a `ghp_` token after them,
119
+ * `certain` came back empty and a CI job gated on it exited 0. A certain hit is
120
+ * the finding the command exists for, so the walk always finishes and every
121
+ * one of them is kept; what the cap drops is counted in `omitted`.
122
+ *
123
+ * ── Nested checkouts: pruned inside a repo, walked outside one ──
124
+ * When the root is inside a git repository, a directory below it with its own
125
+ * `.git` (a clone, a submodule, a worktree, an agent's work copy) is somebody
126
+ * else's repository, and is skipped whole: counted in `skipped.nested`, named
127
+ * in `nestedPaths`. Measured on a real tree: two such directories held 207k
128
+ * files, and scanning them took 145 s and 413 MB for findings that belong to
129
+ * somebody else's policy.
130
+ *
131
+ * When the root is NOT a repository, the same rule made the command blind: a
132
+ * folder that holds projects (a workspace) is nothing BUT nested
133
+ * checkouts, so every one of them was pruned, the scan read almost nothing
134
+ * and exited 0 with "nothing credential-shaped". There is no "somebody else"
135
+ * there — the checkouts are the tree — so they are walked. A tree of work
136
+ * copies that is too big to walk goes in `[scan] ignore`.
137
+ *
138
+ * Either way a pruned checkout is still readable by every role; the renderer
139
+ * says so in yellow and says how to scan it, so a pruned tree never reads as
140
+ * a clean one.
141
+ *
142
+ * `.gitignore` is deliberately NOT honoured (no `git ls-files`): the files a
143
+ * repository ignores are precisely where credentials sit — `.env`, a local
144
+ * config — and every role can read them all the same. Skipping them would
145
+ * make the command quiet about the case it was written for.
91
146
  */
92
- export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
93
- const safe = protectedDirs.map((d) => join(root, d));
147
+ export function scan(root, protectedDirs = [], ignore = [], limit = 500, { roots = null } = {}) {
148
+ // The real root, so a symlink's destination is compared like with like: on
149
+ // macOS a tree under /tmp is really under /private/tmp, and every link
150
+ // inside it pointed "out of the repo" until both sides were resolved.
151
+ try { root = realpathSync.native(root); } catch { /* scanned as written */ }
152
+ // `.secrets/` and `.secrets` are one directory. With the slash kept, the
153
+ // prefix test below never matched and the protected directory was scanned.
154
+ const safe = protectedDirs.map((d) => {
155
+ const path = resolve(root, d);
156
+ // The root was canonicalised above. Match the same spelling for absolute
157
+ // directories, including macOS's /var -> /private/var ancestor.
158
+ try { return realpathSync.native(path); } catch { return path; }
159
+ });
94
160
  const patterns = [...DEFAULT_IGNORE, ...ignore];
95
161
  const hits = [];
96
- const skipped = { ignored: 0, protectedDirs: 0, reference: 0, placeholder: 0 };
162
+ const skipped = { ignored: 0, protectedDirs: 0, reference: 0, placeholder: 0, nested: 0 };
163
+ const nestedPaths = [];
164
+ // With `roots`, only those directories are walked, and a checkout inside one
165
+ // is another repository — pruned, unless it is itself one of the roots.
166
+ const walked = roots ? roots.map((r) => { try { return realpathSync.native(r); } catch { return r; } }) : null;
167
+ const prune = walked ? true : insideRepo(root);
168
+ let capped = 0; // review lines and links counted against `limit`
169
+ let omitted = 0; // … and the ones dropped because of it
170
+ const keep = (hit) => {
171
+ if (hit.level === "certain") return hits.push(hit);
172
+ if (capped >= limit) return omitted++;
173
+ capped++;
174
+ hits.push(hit);
175
+ };
97
176
 
98
177
  const isIgnored = (rel) => patterns.some((p) => covers(p, rel));
99
178
 
100
- (function walk(dir) {
101
- if (hits.length >= limit) return;
179
+ function walk(dir) {
102
180
  let entries;
103
181
  try {
104
182
  entries = readdirSync(dir, { withFileTypes: true });
@@ -106,7 +184,6 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
106
184
  return; // an unreadable directory is a permission, not a finding
107
185
  }
108
186
  for (const e of entries) {
109
- if (hits.length >= limit) return;
110
187
  const full = join(dir, e.name);
111
188
  const rel = relative(root, full);
112
189
 
@@ -129,19 +206,27 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
129
206
  if (e.isSymbolicLink()) {
130
207
  let real;
131
208
  try {
132
- real = realpathSync(full);
209
+ real = realpathSync.native(full);
133
210
  } catch {
134
211
  continue; // a broken link has nothing to leak
135
212
  }
136
213
  if (!real.startsWith(root + sep) && real !== root)
137
- hits.push({
138
- file: rel, line: 0, level: "link", shape: real,
139
- });
214
+ keep({ file: rel, line: 0, level: "link", shape: real });
140
215
  continue;
141
216
  }
142
217
 
143
- if (e.isDirectory()) { walk(full); continue; }
144
- if (!e.isFile() || SKIP_EXT.test(e.name)) continue;
218
+ if (e.isDirectory()) {
219
+ if (prune && existsSync(join(full, ".git"))) {
220
+ if (walked?.includes(full)) continue; // walked on its own
221
+ skipped.nested++; nestedPaths.push(rel); continue;
222
+ }
223
+ walk(full);
224
+ continue;
225
+ }
226
+ if (!e.isFile()) continue;
227
+ if (SECRET_NAMES.test(e.name) && !STAND_IN_FILE.test(e.name))
228
+ keep({ file: rel, line: 0, level: "named", shape: "usually holds secrets, by its name" });
229
+ if (SKIP_EXT.test(e.name)) continue;
145
230
 
146
231
  let text;
147
232
  try {
@@ -153,8 +238,14 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
153
238
  if (text.includes(NUL)) continue; // binary
154
239
 
155
240
  const lines = text.split(/\r?\n/);
156
- for (let i = 0; i < lines.length && hits.length < limit; i++) {
241
+ for (let i = 0; i < lines.length; i++) {
157
242
  const line = lines[i];
243
+ const url = URL_CREDENTIAL.exec(line);
244
+ if (url) {
245
+ const pw = url[2];
246
+ if (PASSWORD_STANDIN.test(pw) || PLACEHOLDER.test(pw) || /^[x*]+$/i.test(pw)) skipped.placeholder++;
247
+ else { keep({ file: rel, line: i + 1, shape: "password in a URL", level: "certain" }); continue; }
248
+ }
158
249
  for (const [level, shape, re] of SHAPES) {
159
250
  const m = re.exec(line);
160
251
  if (!m) continue;
@@ -164,12 +255,83 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
164
255
  if (REFERENCE.test(line)) { skipped.reference++; break; }
165
256
  if (m[1] && PLACEHOLDER.test(m[1])) { skipped.placeholder++; break; }
166
257
  }
167
- hits.push({ file: rel, line: i + 1, shape, level });
258
+ keep({ file: rel, line: i + 1, shape, level });
168
259
  break; // one finding per line; the first shape is the most specific
169
260
  }
170
261
  }
171
262
  }
172
- })(root);
263
+ }
264
+ for (const r of walked ?? [root]) walk(r);
265
+
266
+ // A file with a certain finding is already listed with its line; naming it
267
+ // again as "usually holds secrets" says less about the same file.
268
+ const sure = new Set(hits.filter((h) => h.level === "certain").map((h) => h.file));
269
+ for (let i = hits.length - 1; i >= 0; i--) if (hits[i].level === "named" && sure.has(hits[i].file)) hits.splice(i, 1);
270
+
271
+ return { hits, skipped, nestedPaths, truncated: omitted > 0, omitted };
272
+ }
273
+
274
+ /**
275
+ * Whether `dir` is inside a git checkout: a `.git` (directory, or the file a
276
+ * worktree or submodule has) at `dir` or at any directory above it. Read from
277
+ * the filesystem, not from `git`, so a machine without git scans the same way.
278
+ */
279
+ export function insideRepo(dir) {
280
+ for (let d = dir; ; d = dirname(d)) {
281
+ if (existsSync(join(d, ".git"))) return true;
282
+ if (dirname(d) === d) return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * What `seisin scan` walks when the policy sits in a folder that is not a
288
+ * repository — the repositories its territories live in, whole.
289
+ *
290
+ * A policy at the root of one repository scans that repository, and that is
291
+ * right: a `.env` at its root is outside every territory and every role reads
292
+ * it. A policy at the root of a folder that holds many repositories (a
293
+ * workspace, a monorepo of clones) is a different shape. Walking the whole
294
+ * folder read everything the person running it ever kept there — archives, experiments,
295
+ * copies — measured at 155 s and 1,137 certain hits on one such folder, most of
296
+ * them in directories no agent works in. The question the command answers is
297
+ * "which credentials will an agent come across", and the agents work in their
298
+ * territories: so the scan follows the policy, and moves with it when the
299
+ * folder is reorganised. `--all` still walks everything.
300
+ *
301
+ * Each territory is widened to the repository that contains it, because a
302
+ * credential at a repository's root is read by an agent working anywhere in it.
303
+ * A territory outside any repository is walked as itself. Territories outside
304
+ * the policy's folder are left out: they are another folder's to scan.
305
+ */
306
+ export function territoryRoots(config) {
307
+ const root = config.root;
308
+ const inside = (p) => p === root || p.startsWith(root + sep);
309
+ const found = new Set();
310
+ for (const role of Object.values(config.roles)) {
311
+ for (const glob of role.writes ?? []) {
312
+ let p;
313
+ try { p = join(root, toWritePath(glob)); } catch { continue; }
314
+ if (!inside(p)) continue;
315
+ found.add(repositoryOf(p, root));
316
+ }
317
+ }
318
+ // A plain folder inside another root is walked already; a checkout is not
319
+ // (the walk prunes checkouts), so it stays a root of its own.
320
+ const all = [...found];
321
+ return all.filter((r) => r === root || existsSync(join(r, ".git")) ||
322
+ !all.some((o) => o !== r && (o === root || r.startsWith(o + sep))));
323
+ }
324
+
325
+ /** The nearest checkout at or above `p`, stopping below `root`; else the territory itself. */
326
+ function repositoryOf(p, root) {
327
+ let d = p;
328
+ while (d !== root && !isDir(d)) d = dirname(d); // a file, or not created yet
329
+ const territory = d;
330
+ for (let at = d; at !== root && at.startsWith(root + sep); at = dirname(at))
331
+ if (existsSync(join(at, ".git"))) return at;
332
+ return territory;
333
+ }
173
334
 
174
- return { hits, skipped, truncated: hits.length >= limit };
335
+ function isDir(p) {
336
+ try { return statSync(p).isDirectory(); } catch { return false; }
175
337
  }