seisin 0.4.0 → 0.5.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 +151 -67
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +261 -27
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1366 -529
package/src/intake.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* The parent's intake: what arrives during a run, checked, and written down.
|
|
3
3
|
*
|
|
4
4
|
* Two sources, and they are not equally trustworthy. The hook runs inside the
|
|
5
|
-
*
|
|
6
|
-
* kernel is the thing that actually
|
|
5
|
+
* sandbox, so everything it sends is a claim by the process being recorded. The
|
|
6
|
+
* kernel is the thing that actually denied. Both end up as log lines and,
|
|
7
7
|
* sometimes, as requests in front of a person; this module is where each is
|
|
8
8
|
* recomputed from the policy rather than believed.
|
|
9
9
|
*
|
|
@@ -26,7 +26,7 @@ import { createHash } from "node:crypto";
|
|
|
26
26
|
import { readFileSync } from "node:fs";
|
|
27
27
|
import { join } from "node:path";
|
|
28
28
|
import { toRepoRelative } from "./paths.js";
|
|
29
|
-
import { explain, ownersOf } from "./owners.js";
|
|
29
|
+
import { explain, ownersOf, keyHolders, inKeyDir } from "./owners.js";
|
|
30
30
|
import { append, logPath } from "./log.js";
|
|
31
31
|
import { record, requestsPath } from "./requests.js";
|
|
32
32
|
import { inScope, scopeOf, reachedForContent } from "./violations.js";
|
|
@@ -50,6 +50,10 @@ export function policyId(config) {
|
|
|
50
50
|
*/
|
|
51
51
|
export function intake({ config, role, runId, observe = false, settings, notify }) {
|
|
52
52
|
const stamp = { run: runId.slice(0, 8), policy: policyId(config) };
|
|
53
|
+
// No `at` from here or from the sandbox: append() stamps the parent's clock and
|
|
54
|
+
// ignores any other. The confined process chose the time of its own lines —
|
|
55
|
+
// it could date them into or out of every `since` window, or make its
|
|
56
|
+
// requests look old enough to be passed over.
|
|
53
57
|
const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
|
|
54
58
|
const ask = (asked) => { record(requestsPath(config.root), asked); notify?.maybe(asked); };
|
|
55
59
|
|
|
@@ -59,12 +63,12 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
59
63
|
const stats = { offPolicy: 0, walks: 0 };
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
|
-
* Nothing from inside the
|
|
66
|
+
* Nothing from inside the sandbox is taken at its word.
|
|
63
67
|
*
|
|
64
68
|
* The sender is the process being recorded, so every field it supplies is
|
|
65
69
|
* a claim. Two of them matter. `role` decides whose request this is — left
|
|
66
|
-
* alone, a frontend agent could file one as backend and wait for a
|
|
67
|
-
*
|
|
70
|
+
* alone, a frontend agent could file one as backend and wait for a person to
|
|
71
|
+
* grant it. `owners` decides who the queue says it belongs to, and the
|
|
68
72
|
* parent can work that out itself from the policy.
|
|
69
73
|
*
|
|
70
74
|
* So the role is overwritten with the role of this run, the owners are
|
|
@@ -80,7 +84,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
80
84
|
* never queued — which servers a role may load is a change to its `mcp`
|
|
81
85
|
* list, not a grant a person approves, the same shape as never_writes. The
|
|
82
86
|
* verdict is recomputed here from the same list rather than believed from
|
|
83
|
-
* inside the
|
|
87
|
+
* inside the sandbox: the server is parsed from the tool name and asked of the
|
|
84
88
|
* policy, so a line claiming "allowed" for a server the role may not load
|
|
85
89
|
* comes back marked `disputed`.
|
|
86
90
|
*/
|
|
@@ -91,7 +95,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
91
95
|
const v = server ? explain(config, role, "mcp", server) : { allowed: false };
|
|
92
96
|
const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
|
|
93
97
|
return void log({
|
|
94
|
-
|
|
98
|
+
role,
|
|
95
99
|
tool: String(entry.tool ?? "").slice(0, 40),
|
|
96
100
|
action: "use", kind: "tool",
|
|
97
101
|
target: entry.target.slice(0, 1000),
|
|
@@ -106,7 +110,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
106
110
|
|
|
107
111
|
if (to === "log") {
|
|
108
112
|
// The same treatment the queue already got. The role was overwritten but
|
|
109
|
-
// everything else was passed through, so a process inside the
|
|
113
|
+
// everything else was passed through, so a process inside the sandbox could
|
|
110
114
|
// write "allowed" lines for paths it never touched — which would not move
|
|
111
115
|
// the boundary, but would move `review`, and `init --from-observations`
|
|
112
116
|
// builds a policy out of exactly this.
|
|
@@ -123,7 +127,6 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
123
127
|
? "observed"
|
|
124
128
|
: explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
|
|
125
129
|
return void log({
|
|
126
|
-
at: entry.at,
|
|
127
130
|
role,
|
|
128
131
|
tool: String(entry.tool ?? "").slice(0, 40),
|
|
129
132
|
action: entry.action,
|
|
@@ -139,7 +142,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
139
142
|
/**
|
|
140
143
|
* The policy has to actually refuse it, or it does not belong in a queue.
|
|
141
144
|
*
|
|
142
|
-
* Nothing checked. A process inside the
|
|
145
|
+
* Nothing checked. A process inside the sandbox could file a request for a path
|
|
143
146
|
* its own role already owns, and the queue would print *first refused on
|
|
144
147
|
* …* about something that was never refused — a sentence placed in front of
|
|
145
148
|
* a person for approval, describing an event that did not happen.
|
|
@@ -161,7 +164,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
161
164
|
// The owners explain found: for a key that is who declares it, not who
|
|
162
165
|
// writes the directory it sits in — the queue said "owned by dev" about
|
|
163
166
|
// backend's key because dev writes `**`.
|
|
164
|
-
|
|
167
|
+
// Capped like the log line above: a request's target is a path, and an
|
|
168
|
+
// uncapped one (5k+ chars were accepted) is a way to bloat the queue a
|
|
169
|
+
// person reads. 1000 is more than any real path and matches the log.
|
|
170
|
+
ask({ role, action: entry.action, target: entry.target.slice(0, 1000), owners: v.owners });
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
function fromKernel(d) {
|
|
@@ -179,7 +185,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
179
185
|
connects.add(d.path);
|
|
180
186
|
const v = explain(config, role, "connect", d.path);
|
|
181
187
|
log({
|
|
182
|
-
|
|
188
|
+
role, tool: "kernel", source: "kernel",
|
|
183
189
|
action: "connect", kind: "network", target: d.path, verdict: "denied",
|
|
184
190
|
owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
|
|
185
191
|
});
|
|
@@ -197,7 +203,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
197
203
|
*
|
|
198
204
|
* Without this the two halves of the record disagree in the worst
|
|
199
205
|
* direction: `review` would show a role stopped repeatedly on a directory
|
|
200
|
-
* while the queue held nothing to
|
|
206
|
+
* while the queue held nothing to grant, so the one refusal a person most
|
|
201
207
|
* needed to see — the one that escaped the hook — would be the one with no
|
|
202
208
|
* way to act on it.
|
|
203
209
|
*
|
|
@@ -218,11 +224,13 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
218
224
|
// path has always had this check; the kernel path did not, which is how a
|
|
219
225
|
// role ended up asking for its own territory.
|
|
220
226
|
const granted = verdict?.allowed === true;
|
|
227
|
+
// A key is declared, not owned: who a write lookup finds for `.secrets/x`
|
|
228
|
+
// is always nobody, and the queue said "(unowned)" over another role's key.
|
|
229
|
+
const whose = d.action === "read" && inKeyDir(config, rel) ? keyHolders(config, rel) : ownersOf(config, rel);
|
|
221
230
|
if (rel !== d.path && !barred && !unaskable && !granted)
|
|
222
|
-
ask({ role, action: d.action, target: rel, owners:
|
|
231
|
+
ask({ role, action: d.action, target: rel, owners: whose });
|
|
223
232
|
|
|
224
233
|
log({
|
|
225
|
-
at: new Date().toISOString(),
|
|
226
234
|
role,
|
|
227
235
|
tool: "kernel",
|
|
228
236
|
source: "kernel",
|
package/src/keys.js
CHANGED
|
@@ -352,7 +352,7 @@ export function readFileRef(entry, root = ".") {
|
|
|
352
352
|
* of these:
|
|
353
353
|
*
|
|
354
354
|
* - **What seisin itself injects.** `SEISIN_ROLE` is how the hook inside the
|
|
355
|
-
*
|
|
355
|
+
* sandbox learns which role it is. A key called that would let a policy tell
|
|
356
356
|
* the hook it is somebody else — privilege confusion written in the one
|
|
357
357
|
* file that is supposed to prevent it. Same for the config path and the
|
|
358
358
|
* audit socket.
|
|
@@ -427,8 +427,10 @@ export function entriesOf(role) {
|
|
|
427
427
|
* runtime. A mode called `file` sitting ten centimetres from a runtime field
|
|
428
428
|
* called `files` that fails that way is a trap with a date on it.
|
|
429
429
|
*
|
|
430
|
-
* `scratch`
|
|
431
|
-
*
|
|
430
|
+
* `scratch` said what happened when it was named: the value landed in scratch
|
|
431
|
+
* space and was removed after the run, which `file` does not say at all. Since
|
|
432
|
+
* the 2026-09-23 fix it lands in the run's private directory instead (rundir.js),
|
|
433
|
+
* and the name is a breaking rename listed in docs/glossary.md, not made here.
|
|
432
434
|
*/
|
|
433
435
|
export const MODES = ["env", "scratch", "inject"];
|
|
434
436
|
|
|
@@ -442,8 +444,8 @@ export const MODES = ["env", "scratch", "inject"];
|
|
|
442
444
|
* tables — and growing the parser to hold one field is the trade this repo has
|
|
443
445
|
* already decided against out loud.
|
|
444
446
|
*
|
|
445
|
-
* The important half survives that limit. The
|
|
446
|
-
* the mode beside the
|
|
447
|
+
* The important half survives that limit. The argument for declaring
|
|
448
|
+
* the mode beside the key is that a reader of `seisin.toml` can say
|
|
447
449
|
* "this role sees the value" without opening any code, and a per-role setting
|
|
448
450
|
* answers exactly that question.
|
|
449
451
|
*
|
|
@@ -465,9 +467,9 @@ export function modeOf(config, role, entry) {
|
|
|
465
467
|
`[keys.providers.${entry.scheme}] mode = "env".`;
|
|
466
468
|
throw new Error(
|
|
467
469
|
`roles.${role.name}: key "${entry.raw}" has no delivery mode.\n` +
|
|
468
|
-
` Declare one beside the
|
|
469
|
-
` "env" passes the value as ${entry.name}; "scratch" writes it to a file
|
|
470
|
-
`
|
|
470
|
+
` Declare one beside the key: [roles.${role.name}] key_mode = "env".${second}\n` +
|
|
471
|
+
` "env" passes the value as ${entry.name}; "scratch" writes it to a file in the ` +
|
|
472
|
+
`run's private directory, grants that one path, and removes it when the run ends.`);
|
|
471
473
|
}
|
|
472
474
|
if (mode === "inject")
|
|
473
475
|
throw new Error(
|
|
@@ -497,8 +499,8 @@ export function modeOf(config, role, entry) {
|
|
|
497
499
|
* Three properties, each of which was a requirement before it was code:
|
|
498
500
|
*
|
|
499
501
|
* - **The parent runs it, never the confined process.** Same reason `owners`
|
|
500
|
-
* recomputes instead of believing what it is told: the inside of the
|
|
501
|
-
* cannot be allowed to reach the thing that decides what the
|
|
502
|
+
* recomputes instead of believing what it is told: the inside of the sandbox
|
|
503
|
+
* cannot be allowed to reach the thing that decides what the sandbox contains.
|
|
502
504
|
* A provider command spawned from within the sandbox would need the vault's
|
|
503
505
|
* own credential in there with it, which is the problem this feature exists
|
|
504
506
|
* to remove.
|
|
@@ -521,7 +523,7 @@ export function resolveRef(entry, provider, { run = spawnSync, root = ".", confi
|
|
|
521
523
|
*
|
|
522
524
|
* `spawnSync("fakeprov")` searched the parent's PATH, and a PATH directory
|
|
523
525
|
* inside a territory made the next run execute whatever that role had put
|
|
524
|
-
* there — outside the
|
|
526
|
+
* there — outside the sandbox, as you. Measured on 2026-09-23 with a marker file.
|
|
525
527
|
* The provider's own PATH is narrowed the same way, because a provider that
|
|
526
528
|
* is a script starts with `#!/usr/bin/env bash` and looks its interpreter up
|
|
527
529
|
* again. Without a config (a caller embedding seisin), or with a `run` of the
|
package/src/kinds.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
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 { CREDENTIAL_HOMES } from "./grants.js";
|
|
23
|
+
|
|
24
|
+
/** Every kind, in the order a reader should deal with them, and the one line each says. */
|
|
25
|
+
export const KINDS = {
|
|
26
|
+
credential:
|
|
27
|
+
"credential-shaped: never grant a write to it; if a role needs to read it, declare it as a key",
|
|
28
|
+
git:
|
|
29
|
+
"git's own bookkeeping, not territory: grant the repository to the role that runs git there, never the file",
|
|
30
|
+
temporary:
|
|
31
|
+
"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",
|
|
32
|
+
build:
|
|
33
|
+
"build output or installed dependencies: grant the folder to the role that builds it, or build outside the repo",
|
|
34
|
+
territory:
|
|
35
|
+
"an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A lock file inside `.git` gets a more precise line than the rest of git's metadata. */
|
|
39
|
+
const GIT_LOCK =
|
|
40
|
+
"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";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Credential-shaped names.
|
|
44
|
+
*
|
|
45
|
+
* The places `[runtime] isolate` closes in a home directory are the first
|
|
46
|
+
* source (grants.js), by name, because a repository can hold the same files:
|
|
47
|
+
* a `.npmrc` with a token in it is the same secret in either place. `.config`
|
|
48
|
+
* is left out on purpose — it is a container, and inside a repo it is mostly
|
|
49
|
+
* tool settings; `.docker` and `.kube` count only for the file that holds the
|
|
50
|
+
* login (`config`, `config.json`), not for every Dockerfile beside it.
|
|
51
|
+
*/
|
|
52
|
+
const HOME_NAMES = CREDENTIAL_HOMES.map((h) => h.replace(/^~\//, ""));
|
|
53
|
+
const CONTAINERS = new Set([".config", ".docker", ".kube"]);
|
|
54
|
+
const SECRET_DIRS = new Set([...HOME_NAMES.filter((n) => !CONTAINERS.has(n) && !/rc$|credentials$/.test(n)), ".secrets", "secrets"]);
|
|
55
|
+
const LOGIN_DIRS = new Set([".docker", ".kube"]);
|
|
56
|
+
const SECRET_FILES = new Set([...HOME_NAMES.filter((n) => /rc$|credentials$/.test(n)), ".pypirc", ".pgpass", ".htpasswd"]);
|
|
57
|
+
const SECRET_EXT = /\.(pem|key|p12|pfx|jks|keystore|kdbx|gpg)$/i;
|
|
58
|
+
const SSH_KEY = /^id_(rsa|dsa|ecdsa|ed25519)(_sk)?$/;
|
|
59
|
+
// `.env`, `.env.local`, `.env.production` — but not the file that exists to be committed.
|
|
60
|
+
const DOTENV = /^\.env(\..+)?$/;
|
|
61
|
+
const DOTENV_TEMPLATE = /\.(example|sample|template|dist|defaults?)$/i;
|
|
62
|
+
/**
|
|
63
|
+
* A token file: the word in the name, and a data extension. The extension is
|
|
64
|
+
* what keeps `src/auth/token.ts` — code about tokens — out of it.
|
|
65
|
+
*/
|
|
66
|
+
const TOKEN_FILE = /(^|[-_.])(tokens?|secrets?|passwords?|passwd|credentials?|api[-_]?keys?|private[-_]?key)([-_.]|$)/i;
|
|
67
|
+
const DATA_EXT = /(^[^.]*$)|\.(txt|json|ya?ml|toml|ini|cfg|conf|env|key)$/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Scratch. Directories every toolchain uses for caches, and the shapes an
|
|
71
|
+
* atomic write, an editor or a database leaves beside a file for a moment.
|
|
72
|
+
*/
|
|
73
|
+
const CACHE_DIRS = new Set([
|
|
74
|
+
"tmp", "temp", ".tmp", ".temp", ".cache", "_cache", "__pycache__", ".pytest_cache",
|
|
75
|
+
".mypy_cache", ".ruff_cache", ".hypothesis", ".tox", ".nox", ".parcel-cache", ".turbo",
|
|
76
|
+
".eslintcache", ".sass-cache", ".gradle", ".dart_tool",
|
|
77
|
+
]);
|
|
78
|
+
const SCRATCH_FILE = [
|
|
79
|
+
/\.(tmp|temp|swp|swo|swx|bak|orig|rej|pid|sock|log)$/i,
|
|
80
|
+
/\.tmp[.\-_]/i, // `BACKLOG.md.tmp.32029.03c4` — write-then-rename
|
|
81
|
+
/\.py[co](\.\d+)?$/, // bytecode, and its own write-then-rename
|
|
82
|
+
/-(wal|shm|journal)$/, // sqlite's sidecars, beside a database
|
|
83
|
+
/~$/, /^\.#/, /^#.*#$/, // editors
|
|
84
|
+
/^\.!\d+!/, // `.!36165!file` — a copy in flight
|
|
85
|
+
/^\.DS_Store$/,
|
|
86
|
+
/^pytest-cache-files-/,
|
|
87
|
+
/(^|[-_])logs?$/i, // `logs`, `latest_logs`: what a run leaves
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* `*.lock` is scratch — a mutex a tool takes and drops — except the files that
|
|
91
|
+
* pin dependencies, which are committed and somebody's to edit.
|
|
92
|
+
*/
|
|
93
|
+
const TOOL_LOCK = /\.lock$/i;
|
|
94
|
+
const DEPENDENCY_LOCKS = new Set([
|
|
95
|
+
"yarn.lock", "Cargo.lock", "Gemfile.lock", "poetry.lock", "composer.lock", "flake.lock",
|
|
96
|
+
"Pipfile.lock", "uv.lock", "mix.lock", "pubspec.lock", "Podfile.lock", "bun.lock", "pdm.lock",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build output and installed dependencies. The same names `seisin scan` skips
|
|
101
|
+
* as machinery nobody wrote (scan.js DEFAULT_IGNORE), minus the caches above.
|
|
102
|
+
*/
|
|
103
|
+
const BUILD_DIRS = new Set([
|
|
104
|
+
"node_modules", "dist", "build", ".next", ".nuxt", ".svelte-kit", ".output", ".angular",
|
|
105
|
+
"vendor", ".venv", "venv", "coverage", "target", "out", ".expo", "bower_components",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const segmentsOf = (p) => String(p).replace(/\\/g, "/").split("/").filter((s) => s && s !== ".");
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The kind of one path, from the path alone.
|
|
112
|
+
*
|
|
113
|
+
* `keyDirs` are the policy's declared key directories: a path under one is a
|
|
114
|
+
* credential whatever it is called. The order is the safe one — a name that is
|
|
115
|
+
* both a credential and scratch (`.env.tmp`) is said as a credential, since
|
|
116
|
+
* that is the reading that never ends in a grant.
|
|
117
|
+
*/
|
|
118
|
+
export function kindOf(target, { keyDirs = [] } = {}) {
|
|
119
|
+
const segs = segmentsOf(target);
|
|
120
|
+
const name = segs[segs.length - 1] ?? "";
|
|
121
|
+
const dirs = segs.slice(0, -1);
|
|
122
|
+
const kind = (k, hint = KINDS[k]) => ({ kind: k, hint });
|
|
123
|
+
|
|
124
|
+
const flat = segs.join("/");
|
|
125
|
+
const inKeyDir = keyDirs.some((d) => {
|
|
126
|
+
const k = segmentsOf(d).join("/");
|
|
127
|
+
return k && (flat === k || flat.startsWith(k + "/") || flat.includes("/" + k + "/") || flat.endsWith("/" + k));
|
|
128
|
+
});
|
|
129
|
+
if (inKeyDir || dirs.some((d) => SECRET_DIRS.has(d)) || SECRET_FILES.has(name) ||
|
|
130
|
+
SECRET_EXT.test(name) || SSH_KEY.test(name) ||
|
|
131
|
+
(DOTENV.test(name) && !DOTENV_TEMPLATE.test(name)) ||
|
|
132
|
+
(TOKEN_FILE.test(name) && DATA_EXT.test(name)) ||
|
|
133
|
+
(LOGIN_DIRS.has(dirs[dirs.length - 1]) && /^config(\.json)?$/.test(name)))
|
|
134
|
+
return kind("credential");
|
|
135
|
+
|
|
136
|
+
const git = segs.indexOf(".git");
|
|
137
|
+
if (git !== -1) return git < segs.length - 1 && /\.lock$/.test(name) ? kind("git", GIT_LOCK) : kind("git");
|
|
138
|
+
|
|
139
|
+
if (segs.some((s) => CACHE_DIRS.has(s)) || SCRATCH_FILE.some((re) => re.test(name)) ||
|
|
140
|
+
(TOOL_LOCK.test(name) && !DEPENDENCY_LOCKS.has(name)))
|
|
141
|
+
return kind("temporary");
|
|
142
|
+
|
|
143
|
+
if (segs.some((s) => BUILD_DIRS.has(s))) return kind("build");
|
|
144
|
+
|
|
145
|
+
return kind("territory");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A name made unique per run: `test-run-57141-2b32ca`, `run_8812`,
|
|
150
|
+
* `out.4419525232`. The stem, and a tail of one or two tokens: the first with a
|
|
151
|
+
* digit, the second with a digit or all hex (`-bfaeec` is a random suffix
|
|
152
|
+
* that happened to draw no digit) — so `report-2026.json` never matches, since
|
|
153
|
+
* an extension is not a run.
|
|
154
|
+
* Only a hint on its own: `run-2026` looks the same as a folder somebody named.
|
|
155
|
+
*/
|
|
156
|
+
const RUN_UNIQUE = /^(.+?)[-_.](?=[0-9a-z]*\d)([0-9a-z]{3,})([-_.](?:(?=[0-9a-z]*\d)[0-9a-z]{4,}|[0-9a-f]{6,}))?$/i;
|
|
157
|
+
|
|
158
|
+
/** How many siblings of one stem it takes to call it generated rather than named. */
|
|
159
|
+
export const GENERATED_SIBLINGS = 3;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The kind of every path in a set, which is what a screen has.
|
|
163
|
+
*
|
|
164
|
+
* One rule needs the set and not the path, and it is the one that mattered
|
|
165
|
+
* most on the log it was measured on: a test that makes a directory with its
|
|
166
|
+
* PID in the name produced 258 of 317 unowned paths, each one different. Alone,
|
|
167
|
+
* `test-run-57141-2b32ca` could be a real folder. The same stem with a
|
|
168
|
+
* different run-unique tail in three or more places under one parent is not a
|
|
169
|
+
* folder anybody named — it is a generator, and granting it to whoever asked
|
|
170
|
+
* would hand over the parent. A repeated name is a fact about this log, not a
|
|
171
|
+
* guess about somebody's toolchain, which is why this is here and a list of
|
|
172
|
+
* test runners is not.
|
|
173
|
+
*
|
|
174
|
+
* Returns a Map from each target to `{ kind, hint }`.
|
|
175
|
+
*/
|
|
176
|
+
export function kindsOf(targets, opts = {}) {
|
|
177
|
+
const out = new Map();
|
|
178
|
+
const stems = new Map();
|
|
179
|
+
for (const t of targets) {
|
|
180
|
+
if (out.has(t)) continue;
|
|
181
|
+
out.set(t, kindOf(t, opts));
|
|
182
|
+
const segs = segmentsOf(t);
|
|
183
|
+
segs.forEach((s, i) => {
|
|
184
|
+
const m = RUN_UNIQUE.exec(s);
|
|
185
|
+
if (!m) return;
|
|
186
|
+
const k = `${segs.slice(0, i).join("/")}\u0000${m[1]}`;
|
|
187
|
+
(stems.get(k) ?? stems.set(k, new Set()).get(k)).add(s);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const generated = new Set();
|
|
191
|
+
for (const [k, names] of stems) {
|
|
192
|
+
if (names.size < GENERATED_SIBLINGS) continue;
|
|
193
|
+
const parent = k.split("\u0000")[0];
|
|
194
|
+
for (const n of names) generated.add(parent ? `${parent}/${n}` : n);
|
|
195
|
+
}
|
|
196
|
+
if (!generated.size) return out;
|
|
197
|
+
for (const [t, k] of out) {
|
|
198
|
+
if (k.kind !== "territory") continue;
|
|
199
|
+
const segs = segmentsOf(t);
|
|
200
|
+
for (let i = 1; i <= segs.length; i++)
|
|
201
|
+
if (generated.has(segs.slice(0, i).join("/"))) {
|
|
202
|
+
out.set(t, { kind: "temporary", hint: KINDS.temporary });
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|