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/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
|
*
|
|
@@ -22,24 +22,19 @@
|
|
|
22
22
|
* Pulled out of run.js, which held both handlers inline between the spawn and
|
|
23
23
|
* the signal handling — 180 lines of policy arithmetic inside the launcher.
|
|
24
24
|
*/
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
|
-
import { readFileSync } from "node:fs";
|
|
27
25
|
import { join } from "node:path";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
26
|
+
import { browserFor } from "./browsers.js";
|
|
27
|
+
import { toRepoRelative, samePath } from "./paths.js";
|
|
28
|
+
import { explain, ownersOf, keyHolders, inKeyDir, privateOwner } from "./owners.js";
|
|
30
29
|
import { append, logPath } from "./log.js";
|
|
31
30
|
import { record, requestsPath } from "./requests.js";
|
|
32
31
|
import { inScope, scopeOf, reachedForContent } from "./violations.js";
|
|
33
32
|
import { mcpServer } from "./hook.js";
|
|
33
|
+
import { policyId } from "./config.js";
|
|
34
|
+
import { expectedOf, kindOf } from "./kinds.js";
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
export
|
|
37
|
-
try {
|
|
38
|
-
return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
|
|
39
|
-
} catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
36
|
+
/** Moved to config.js, where the handoff decisions read it too; kept here for callers. */
|
|
37
|
+
export { policyId };
|
|
43
38
|
|
|
44
39
|
/**
|
|
45
40
|
* The handlers for one run.
|
|
@@ -47,24 +42,55 @@ export function policyId(config) {
|
|
|
47
42
|
* `fromHook(to, entry)` is the spool's sink; `fromKernel(denial)` is
|
|
48
43
|
* watchDenials' callback; `stats` counts what the kernel said that was left
|
|
49
44
|
* out on purpose, for the line at the end of the run.
|
|
45
|
+
*
|
|
46
|
+
* `expectDenied` is the paths the caller said it expects to be denied
|
|
47
|
+
* (`seisin run --expect-denied`): taken from the parent's own argv, never from
|
|
48
|
+
* anything the sandbox can reach. See expectedByCaller below.
|
|
50
49
|
*/
|
|
51
|
-
export function intake({ config, role, runId, observe = false, settings, notify }) {
|
|
52
|
-
|
|
50
|
+
export function intake({ config, role, runId, observe = false, settings, notify, chain = null, expectDenied = [], trace = {} }) {
|
|
51
|
+
// `trace` is the launcher's (trace.js), read by the parent: in the stamp, so a
|
|
52
|
+
// line the sandbox sends cannot claim another run's trace.
|
|
53
|
+
const stamp = { run: runId.slice(0, 8), policy: policyId(config), ...trace };
|
|
54
|
+
// No `at` from here or from the sandbox: append() stamps the parent's clock and
|
|
55
|
+
// ignores any other. The confined process chose the time of its own lines —
|
|
56
|
+
// it could date them into or out of every `since` window, or make its
|
|
57
|
+
// requests look old enough to be passed over.
|
|
53
58
|
const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
|
|
54
|
-
|
|
59
|
+
// The chain is this run's (run.js, --handoff), the same for every request it
|
|
60
|
+
// files; nothing the sandbox sends can set or change it.
|
|
61
|
+
const ask = (asked) => { record(requestsPath(config.root), { ...asked, chain }); notify?.maybe(asked); };
|
|
55
62
|
|
|
56
63
|
const scope = scopeOf(settings, config.root);
|
|
57
64
|
const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
|
|
58
65
|
const connects = new Set(); // one line per refused target per run
|
|
59
|
-
const
|
|
66
|
+
const systems = new Set(); // one line per operation and service per run
|
|
67
|
+
const stats = { offPolicy: 0, walks: 0, expected: 0, probes: 0, atomic: [], browsers: [] };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A denial the caller asked for is recorded, and asked about by nobody.
|
|
71
|
+
*
|
|
72
|
+
* An orchestrator that probes the sandbox with a write it expects to be
|
|
73
|
+
* denied — its own canary, beside seisin's — got a request for it: a line in
|
|
74
|
+
* the queue, a notification, and a handoff decision that routed the probe to
|
|
75
|
+
* whichever role owns the repo root, which an orchestrator obeying the
|
|
76
|
+
* decision would then have started. The denial is the answer the caller
|
|
77
|
+
* wanted, so it stays in the log, marked `expected`, and files nothing.
|
|
78
|
+
*
|
|
79
|
+
* Exact paths, compared in one spelling (paths.js, samePath): a denial on any
|
|
80
|
+
* other path, the directory around it included, is still a request. The
|
|
81
|
+
* list is the parent's, from its own argv; the hook inside never suppresses
|
|
82
|
+
* anything, and nothing it sends can add to the list (docs/decisions.md).
|
|
83
|
+
*/
|
|
84
|
+
const probes = new Set(expectDenied.map((p) => samePath(config, p)).filter(Boolean));
|
|
85
|
+
const expectedByCaller = (target) => probes.size > 0 && probes.has(samePath(config, target));
|
|
60
86
|
|
|
61
87
|
/**
|
|
62
|
-
* Nothing from inside the
|
|
88
|
+
* Nothing from inside the sandbox is taken at its word.
|
|
63
89
|
*
|
|
64
90
|
* The sender is the process being recorded, so every field it supplies is
|
|
65
91
|
* 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
|
-
*
|
|
92
|
+
* alone, a frontend agent could file one as backend and wait for a person to
|
|
93
|
+
* grant it. `owners` decides who the queue says it belongs to, and the
|
|
68
94
|
* parent can work that out itself from the policy.
|
|
69
95
|
*
|
|
70
96
|
* So the role is overwritten with the role of this run, the owners are
|
|
@@ -80,7 +106,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
80
106
|
* never queued — which servers a role may load is a change to its `mcp`
|
|
81
107
|
* list, not a grant a person approves, the same shape as never_writes. The
|
|
82
108
|
* verdict is recomputed here from the same list rather than believed from
|
|
83
|
-
* inside the
|
|
109
|
+
* inside the sandbox: the server is parsed from the tool name and asked of the
|
|
84
110
|
* policy, so a line claiming "allowed" for a server the role may not load
|
|
85
111
|
* comes back marked `disputed`.
|
|
86
112
|
*/
|
|
@@ -91,7 +117,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
91
117
|
const v = server ? explain(config, role, "mcp", server) : { allowed: false };
|
|
92
118
|
const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
|
|
93
119
|
return void log({
|
|
94
|
-
|
|
120
|
+
role,
|
|
95
121
|
tool: String(entry.tool ?? "").slice(0, 40),
|
|
96
122
|
action: "use", kind: "tool",
|
|
97
123
|
target: entry.target.slice(0, 1000),
|
|
@@ -106,7 +132,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
106
132
|
|
|
107
133
|
if (to === "log") {
|
|
108
134
|
// The same treatment the queue already got. The role was overwritten but
|
|
109
|
-
// everything else was passed through, so a process inside the
|
|
135
|
+
// everything else was passed through, so a process inside the sandbox could
|
|
110
136
|
// write "allowed" lines for paths it never touched — which would not move
|
|
111
137
|
// the boundary, but would move `review`, and `init --from-observations`
|
|
112
138
|
// builds a policy out of exactly this.
|
|
@@ -122,8 +148,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
122
148
|
const expected = observe
|
|
123
149
|
? "observed"
|
|
124
150
|
: explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
|
|
151
|
+
// Marked only when the hook and the parent agree it was a denial: the
|
|
152
|
+
// hook's word alone does not make a line expected.
|
|
153
|
+
const asked = entry.verdict === "denied" && expected === "denied" && expectedByCaller(entry.target);
|
|
125
154
|
return void log({
|
|
126
|
-
at: entry.at,
|
|
127
155
|
role,
|
|
128
156
|
tool: String(entry.tool ?? "").slice(0, 40),
|
|
129
157
|
action: entry.action,
|
|
@@ -133,13 +161,14 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
133
161
|
owners: ownersOf(config, entry.target), // recomputed, never taken
|
|
134
162
|
reason: String(entry.reason ?? "").slice(0, 500),
|
|
135
163
|
...(expected !== entry.verdict ? { disputed: expected } : {}),
|
|
164
|
+
...(asked ? { expected: true } : {}),
|
|
136
165
|
});
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
/**
|
|
140
169
|
* The policy has to actually refuse it, or it does not belong in a queue.
|
|
141
170
|
*
|
|
142
|
-
* Nothing checked. A process inside the
|
|
171
|
+
* Nothing checked. A process inside the sandbox could file a request for a path
|
|
143
172
|
* its own role already owns, and the queue would print *first refused on
|
|
144
173
|
* …* about something that was never refused — a sentence placed in front of
|
|
145
174
|
* a person for approval, describing an event that did not happen.
|
|
@@ -157,7 +186,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
157
186
|
* grant can open.
|
|
158
187
|
*/
|
|
159
188
|
const v = explain(config, role, entry.action, entry.target);
|
|
160
|
-
if (v.allowed || v.neverWrites || v.gitMetadata || v.protected) return;
|
|
189
|
+
if (v.allowed || v.neverWrites || v.gitMetadata || v.protected || v.privateTo) return;
|
|
190
|
+
if (temporary(entry.action, entry.target)) return;
|
|
191
|
+
// The caller's probe: its log line above says it was expected.
|
|
192
|
+
if (expectedByCaller(entry.target)) return;
|
|
161
193
|
// The owners explain found: for a key that is who declares it, not who
|
|
162
194
|
// writes the directory it sits in — the queue said "owned by dev" about
|
|
163
195
|
// backend's key because dev writes `**`.
|
|
@@ -177,12 +209,36 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
177
209
|
* is not a territory anyone owns, so there is nobody to hand it to — the
|
|
178
210
|
* sentence says what the policy would have to change instead.
|
|
179
211
|
*/
|
|
212
|
+
/**
|
|
213
|
+
* A refused Mach service, sysctl or the like: logged by operation and name,
|
|
214
|
+
* never queued — see SYSTEM in violations.js.
|
|
215
|
+
*
|
|
216
|
+
* Once per operation and name per run, and a trailing pid is folded into
|
|
217
|
+
* the name for that count: a browser whose children cannot find its
|
|
218
|
+
* rendezvous service ask for it a thousand times in forty seconds, each
|
|
219
|
+
* under the parent's pid, and the second line says nothing the first did not.
|
|
220
|
+
*/
|
|
221
|
+
if (d.action === "system") {
|
|
222
|
+
const key = `${d.operation} ${d.path.replace(/\.\d+$/, ".<pid>")}`;
|
|
223
|
+
if (systems.has(key)) return;
|
|
224
|
+
systems.add(key);
|
|
225
|
+
// The one that kills a browser at start, and that a preset answers: named
|
|
226
|
+
// at the end of the run, because the agent saw only a crash.
|
|
227
|
+
const preset = d.operation === "mach-register" ? browserFor(d.path) : null;
|
|
228
|
+
if (preset && !stats.browsers.includes(preset)) stats.browsers.push(preset);
|
|
229
|
+
log({
|
|
230
|
+
role, tool: "kernel", source: "kernel",
|
|
231
|
+
action: d.operation, kind: "system", target: d.path.slice(0, 1000), verdict: "denied",
|
|
232
|
+
owners: [], reason: d.operation,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
180
236
|
if (d.action === "connect") {
|
|
181
237
|
if (connects.has(d.path)) return;
|
|
182
238
|
connects.add(d.path);
|
|
183
239
|
const v = explain(config, role, "connect", d.path);
|
|
184
240
|
log({
|
|
185
|
-
|
|
241
|
+
role, tool: "kernel", source: "kernel",
|
|
186
242
|
action: "connect", kind: "network", target: d.path, verdict: "denied",
|
|
187
243
|
owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
|
|
188
244
|
});
|
|
@@ -194,13 +250,18 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
194
250
|
// ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
|
|
195
251
|
// answer to "what was refused" than the path itself.
|
|
196
252
|
const rel = toRepoRelative(config, d.path);
|
|
253
|
+
// Recorded like any other, and counted apart, so the end of the run can
|
|
254
|
+
// say it was expected instead of leaving it to read as a failure.
|
|
255
|
+
if (expectedOf(rel)) stats.expected++;
|
|
256
|
+
const asked = expectedByCaller(rel);
|
|
257
|
+
if (asked) stats.probes++;
|
|
197
258
|
|
|
198
259
|
/**
|
|
199
260
|
* A refusal the hook never saw still leaves a request behind.
|
|
200
261
|
*
|
|
201
262
|
* Without this the two halves of the record disagree in the worst
|
|
202
263
|
* direction: `review` would show a role stopped repeatedly on a directory
|
|
203
|
-
* while the queue held nothing to
|
|
264
|
+
* while the queue held nothing to grant, so the one refusal a person most
|
|
204
265
|
* needed to see — the one that escaped the hook — would be the one with no
|
|
205
266
|
* way to act on it.
|
|
206
267
|
*
|
|
@@ -210,22 +271,45 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
210
271
|
* reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
|
|
211
272
|
* under `isolate` and is not a territory anyone is meant to ask for.
|
|
212
273
|
*/
|
|
213
|
-
|
|
214
|
-
|
|
274
|
+
// Reads are asked too when they land in another role's private folder:
|
|
275
|
+
// that refusal is the policy, not a missing grant, so it is not queued.
|
|
276
|
+
const verdict = rel !== d.path && (d.action === "write" || (d.action === "read" && privateOwner(config, rel)))
|
|
277
|
+
? explain(config, role, d.action === "write" ? "write" : "read", rel)
|
|
215
278
|
: null;
|
|
216
279
|
const barred = verdict?.neverWrites ?? null;
|
|
217
|
-
const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
|
|
280
|
+
const unaskable = verdict?.gitMetadata === true || verdict?.protected != null || verdict?.privateTo != null;
|
|
218
281
|
// A request for something the policy already grants cannot be approved into
|
|
219
282
|
// anything: it is misattribution or a kernel/policy mismatch, and either way
|
|
220
283
|
// the line in the log is the evidence, not a question for a person. The hook
|
|
221
284
|
// path has always had this check; the kernel path did not, which is how a
|
|
222
285
|
// role ended up asking for its own territory.
|
|
223
286
|
const granted = verdict?.allowed === true;
|
|
224
|
-
|
|
225
|
-
|
|
287
|
+
// A key is declared, not owned: who a write lookup finds for `.secrets/x`
|
|
288
|
+
// is always nobody, and the queue said "(unowned)" over another role's key.
|
|
289
|
+
const whose = d.action === "read" && inKeyDir(config, rel) ? keyHolders(config, rel) : ownersOf(config, rel);
|
|
290
|
+
const scratch = rel !== d.path && temporary(d.action, rel);
|
|
291
|
+
if (rel !== d.path && !barred && !unaskable && !granted && !scratch && !asked)
|
|
292
|
+
ask({ role, action: d.action, target: rel, owners: whose });
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* An atomic save's temporary file stands for the file it replaces.
|
|
296
|
+
*
|
|
297
|
+
* Not queued as itself (temporary(), below), and until now not at all —
|
|
298
|
+
* measured: Write was refused at `health.ts.tmp.<pid>`, nothing was queued,
|
|
299
|
+
* and the agent concluded the directory was missing. The question a person
|
|
300
|
+
* can answer is the destination: another role's file is asked for like any
|
|
301
|
+
* write; a file this role may write is the runtime refusing the temporary
|
|
302
|
+
* file beside it, which no grant can name, and the end of the run says so.
|
|
303
|
+
*/
|
|
304
|
+
const destination = scratch && !asked ? destinationOf(rel) : null;
|
|
305
|
+
if (destination) {
|
|
306
|
+
const v = explain(config, role, "write", destination);
|
|
307
|
+
if (v.allowed) stats.atomic.push(destination);
|
|
308
|
+
else if (!v.neverWrites && !v.gitMetadata && v.protected == null && v.privateTo == null)
|
|
309
|
+
ask({ role, action: "write", target: destination, owners: ownersOf(config, destination) });
|
|
310
|
+
}
|
|
226
311
|
|
|
227
312
|
log({
|
|
228
|
-
at: new Date().toISOString(),
|
|
229
313
|
role,
|
|
230
314
|
tool: "kernel",
|
|
231
315
|
source: "kernel",
|
|
@@ -239,8 +323,39 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
239
323
|
// and a protected file from a territory somebody could be asked for.
|
|
240
324
|
...(barred ? { neverWrites: barred } : {}),
|
|
241
325
|
...(verdict?.protected ? { protected: verdict.protected } : {}),
|
|
326
|
+
...(verdict?.privateTo ? { privateTo: verdict.privateTo } : {}),
|
|
327
|
+
// Why no request was queued for it: a temporary file is not territory.
|
|
328
|
+
...(scratch ? { nature: "temporary" } : {}),
|
|
329
|
+
// The file an atomic save was replacing, which is what was asked for.
|
|
330
|
+
...(destination ? { destination } : {}),
|
|
331
|
+
// Why no request was queued for it: the caller said it would be denied.
|
|
332
|
+
...(asked ? { expected: true } : {}),
|
|
242
333
|
});
|
|
243
334
|
}
|
|
244
335
|
|
|
245
336
|
return { fromHook, fromKernel, stats };
|
|
246
337
|
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A write to a temporary file (kinds.js) is recorded and never queued.
|
|
341
|
+
*
|
|
342
|
+
* Its name changes on every run — `BACKLOG.md.tmp.32029.03c4…`, the atomic
|
|
343
|
+
* write of an editor or of Claude Code — so a grant of it opens nothing the
|
|
344
|
+
* next run will use, and the request's id is its directory: at the root of the
|
|
345
|
+
* repo, `dev:write:.`, which reads as asking for the whole repository. The
|
|
346
|
+
* answer is the folder of the file it replaces, or $TMPDIR, and `check` and
|
|
347
|
+
* `review` say so; a request in front of a person says the wrong thing.
|
|
348
|
+
*/
|
|
349
|
+
function temporary(action, target) {
|
|
350
|
+
return action === "write" && kindOf(target).kind === "temporary";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The file a write-then-rename was replacing, from its temporary file's name:
|
|
355
|
+
* `src/health.ts.tmp.48213.3fa9c1d2e0b4` → `src/health.ts`. Claude Code's form,
|
|
356
|
+
* and the general `<file>.tmp.<anything>`; null for any other temporary file.
|
|
357
|
+
*/
|
|
358
|
+
export function destinationOf(target) {
|
|
359
|
+
const m = /^(.+[^/])\.tmp\.[^/]+$/.exec(target);
|
|
360
|
+
return m ? m[1] : null;
|
|
361
|
+
}
|