@descryy/runtime-controller 0.0.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/dist/capability-registry.d.ts +24 -0
- package/dist/capability-registry.d.ts.map +1 -0
- package/dist/capability-registry.js +42 -0
- package/dist/capability-registry.js.map +1 -0
- package/dist/collector-version.d.ts +10 -0
- package/dist/collector-version.d.ts.map +1 -0
- package/dist/collector-version.js +12 -0
- package/dist/collector-version.js.map +1 -0
- package/dist/container-sandbox.d.ts +188 -0
- package/dist/container-sandbox.d.ts.map +1 -0
- package/dist/container-sandbox.js +233 -0
- package/dist/container-sandbox.js.map +1 -0
- package/dist/controller.d.ts +162 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +433 -0
- package/dist/controller.js.map +1 -0
- package/dist/dependency-version-check.d.ts +90 -0
- package/dist/dependency-version-check.d.ts.map +1 -0
- package/dist/dependency-version-check.js +121 -0
- package/dist/dependency-version-check.js.map +1 -0
- package/dist/env.d.ts +16 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +18 -0
- package/dist/env.js.map +1 -0
- package/dist/environment-metadata.d.ts +23 -0
- package/dist/environment-metadata.d.ts.map +1 -0
- package/dist/environment-metadata.js +80 -0
- package/dist/environment-metadata.js.map +1 -0
- package/dist/environment-version-check.d.ts +83 -0
- package/dist/environment-version-check.d.ts.map +1 -0
- package/dist/environment-version-check.js +218 -0
- package/dist/environment-version-check.js.map +1 -0
- package/dist/execution-safety.d.ts +154 -0
- package/dist/execution-safety.d.ts.map +1 -0
- package/dist/execution-safety.js +194 -0
- package/dist/execution-safety.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestration.d.ts +70 -0
- package/dist/orchestration.d.ts.map +1 -0
- package/dist/orchestration.js +255 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/process-collector.d.ts +34 -0
- package/dist/process-collector.d.ts.map +1 -0
- package/dist/process-collector.js +148 -0
- package/dist/process-collector.js.map +1 -0
- package/dist/process-manager.d.ts +133 -0
- package/dist/process-manager.d.ts.map +1 -0
- package/dist/process-manager.js +333 -0
- package/dist/process-manager.js.map +1 -0
- package/dist/readiness.d.ts +120 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +179 -0
- package/dist/readiness.js.map +1 -0
- package/dist/sandbox.d.ts +139 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +271 -0
- package/dist/sandbox.js.map +1 -0
- package/package.json +29 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readiness (plan §6): "Readiness must not equal 'process exists.'" —
|
|
3
|
+
* supported strategies are HTTP health check, TCP/port check, a known log
|
|
4
|
+
* pattern, a real subprocess command, or a caller-supplied hook (which
|
|
5
|
+
* covers the plan's "framework readiness hook" and "user-defined readiness"
|
|
6
|
+
* — two names for the same shape, a caller-provided async predicate,
|
|
7
|
+
* consolidated into one mechanism rather than two near-identical ones).
|
|
8
|
+
*
|
|
9
|
+
* **`command` is not `custom-hook` (RT-193).** An earlier version of this
|
|
10
|
+
* comment folded the plan's "command check" into `custom-hook` too, on the
|
|
11
|
+
* reasoning that both are "a caller-provided check." That collapsed a real
|
|
12
|
+
* distinction: `custom-hook` runs a caller's own in-process predicate,
|
|
13
|
+
* while `command` runs an actual subprocess and reads its actual exit
|
|
14
|
+
* code — the same "declared, not inferred" shape `ServiceConfiguration`'s
|
|
15
|
+
* own `command` field already uses, one layer over. A caller with a real
|
|
16
|
+
* CLI health check (`pg_isready`, a framework's own readiness script) had
|
|
17
|
+
* no way to express that without either wrapping it in a hand-written
|
|
18
|
+
* `custom-hook` predicate that shells out itself, or reimplementing the
|
|
19
|
+
* subprocess plumbing per caller — `command` is that plumbing, built once.
|
|
20
|
+
*
|
|
21
|
+
* The plan requires recording which mechanism succeeded — `ReadinessResult`
|
|
22
|
+
* makes that a field, not something inferred after the fact.
|
|
23
|
+
*
|
|
24
|
+
* **What `ready: true` does and does not claim (RT-024).** All four
|
|
25
|
+
* mechanisms answer one question — is something listening / responding
|
|
26
|
+
* where expected — and none answer whether it is the process *this*
|
|
27
|
+
* `spawnProcess` call produced. A stale listener from an unrelated, already-
|
|
28
|
+
* dead run satisfies `http`/`tcp-port` identically to a correct one; nothing
|
|
29
|
+
* here can tell them apart. `log-pattern` only carries the stronger,
|
|
30
|
+
* this-run-specific guarantee when its `read` is wired to *this* execution's
|
|
31
|
+
* own `ManagedProcess.readOutput()` — the type does not enforce that wiring,
|
|
32
|
+
* it is a caller discipline. `custom-hook` inherits whatever the caller's
|
|
33
|
+
* predicate actually checks.
|
|
34
|
+
*
|
|
35
|
+
* Callers that bind a fixed, guessable host:port are exposed to this gap;
|
|
36
|
+
* an ephemeral port closes it by construction (nothing else can already be
|
|
37
|
+
* bound there) rather than by detection, and is the default recommendation
|
|
38
|
+
* over a startup-nonce scheme, which only earns its extra machinery when a
|
|
39
|
+
* fixed port is genuinely unavoidable (a contract with an external tool).
|
|
40
|
+
* See `descry-runtime`'s `DECISIONS.md` RT-024 for how this was found —
|
|
41
|
+
* a real 30-second collector timeout that looked like a Playwright/DOM bug
|
|
42
|
+
* and was actually a correct wait against an orphaned process from a
|
|
43
|
+
* different, already-dead session.
|
|
44
|
+
*
|
|
45
|
+
* **A single probe attempt cannot outlive `timeoutMs` (RT-030).** `http`
|
|
46
|
+
* and `tcp-port` each bind their I/O to the *remaining* time until
|
|
47
|
+
* `deadline`, not a fresh per-attempt budget — a target that accepts a
|
|
48
|
+
* connection and never responds no longer lets one probe attempt run past
|
|
49
|
+
* the caller's own deadline. Measured, not theoretical: an unbound `fetch`
|
|
50
|
+
* here let `awaitReadiness` run past its own `timeoutMs` (>8s observed
|
|
51
|
+
* against a 3s budget). `command` honors the same discipline, the same
|
|
52
|
+
* way: `execFile`'s own `timeout` option is set to the remaining time until
|
|
53
|
+
* `deadline`, so a command that hangs instead of exiting is killed rather
|
|
54
|
+
* than left to run past the caller's own budget — it owns a real OS
|
|
55
|
+
* resource (a child process) exactly like the http/tcp-port sockets do, so
|
|
56
|
+
* it gets the same treatment. `custom-hook` is not wrapped the same way — a
|
|
57
|
+
* caller-supplied predicate owns its own timeout behavior, same as every
|
|
58
|
+
* other `Collector`-shaped callback contract in this repo (`collector.ts`'s
|
|
59
|
+
* own docs put "must resolve, not hang" on the implementer); imposing a
|
|
60
|
+
* surprise external cancellation on arbitrary caller code would be a
|
|
61
|
+
* different, larger decision than fixing the mechanisms that own a real OS
|
|
62
|
+
* resource this module can actually clean up.
|
|
63
|
+
*/
|
|
64
|
+
import { createConnection } from "node:net";
|
|
65
|
+
import { execFile } from "node:child_process";
|
|
66
|
+
export const READINESS_MECHANISMS = ["http", "tcp-port", "log-pattern", "command", "custom-hook"];
|
|
67
|
+
const DEFAULT_POLL_INTERVAL_MS = 200;
|
|
68
|
+
export async function awaitReadiness(checks, options) {
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
const deadline = startedAt + options.timeoutMs;
|
|
71
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
72
|
+
if (checks.length === 0) {
|
|
73
|
+
return { ready: false, mechanism: null, elapsedMs: 0, reason: "no readiness checks configured" };
|
|
74
|
+
}
|
|
75
|
+
do {
|
|
76
|
+
for (const check of checks) {
|
|
77
|
+
if (await probe(check, deadline)) {
|
|
78
|
+
return { ready: true, mechanism: check.kind, elapsedMs: Date.now() - startedAt, reason: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
await sleep(pollIntervalMs);
|
|
82
|
+
} while (Date.now() < deadline);
|
|
83
|
+
// Names what was actually probed, not just that nothing answered.
|
|
84
|
+
//
|
|
85
|
+
// This is the only cheap thing available against the ephemeral-port race
|
|
86
|
+
// in `allocateEphemeralPort` (bind 0, read, close, spawn): if another
|
|
87
|
+
// process takes the port in the window between release and the child's
|
|
88
|
+
// own bind, the child never comes up and the *only* symptom is this
|
|
89
|
+
// result. "No readiness mechanism succeeded" sends the reader to look at
|
|
90
|
+
// the application; "nothing answered http://127.0.0.1:41237/health"
|
|
91
|
+
// sends them to look at the port, which is where the problem is. The race
|
|
92
|
+
// itself cannot be closed without binding the real service directly to
|
|
93
|
+
// the probe socket, which the child-process model does not support — so
|
|
94
|
+
// making its one symptom legible is the honest remainder.
|
|
95
|
+
return {
|
|
96
|
+
ready: false,
|
|
97
|
+
mechanism: null,
|
|
98
|
+
elapsedMs: Date.now() - startedAt,
|
|
99
|
+
// "timeout" stays in the wording deliberately: the first draft of this
|
|
100
|
+
// message replaced it with "within 1500ms", which reads fine and drops
|
|
101
|
+
// the one word a caller can match on. §41's hung-process test does
|
|
102
|
+
// exactly that, and caught it.
|
|
103
|
+
reason: `no readiness mechanism succeeded within the ${options.timeoutMs}ms timeout — probed ${checks.map(describeCheck).join(", ")}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function describeCheck(check) {
|
|
107
|
+
switch (check.kind) {
|
|
108
|
+
case "http":
|
|
109
|
+
return check.url;
|
|
110
|
+
case "tcp-port":
|
|
111
|
+
return `tcp ${check.host}:${check.port}`;
|
|
112
|
+
case "log-pattern":
|
|
113
|
+
return `log pattern ${String(check.pattern)}`;
|
|
114
|
+
case "command":
|
|
115
|
+
return `command ${[check.command, ...(check.args ?? [])].join(" ")}`;
|
|
116
|
+
case "custom-hook":
|
|
117
|
+
return "custom hook";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function probe(check, deadline) {
|
|
121
|
+
switch (check.kind) {
|
|
122
|
+
case "http":
|
|
123
|
+
return probeHttp(check, deadline);
|
|
124
|
+
case "tcp-port":
|
|
125
|
+
return probeTcpPort(check, deadline);
|
|
126
|
+
case "log-pattern":
|
|
127
|
+
return check.pattern.test(check.read());
|
|
128
|
+
case "command":
|
|
129
|
+
return probeCommand(check, deadline);
|
|
130
|
+
case "custom-hook":
|
|
131
|
+
return check.check();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function probeHttp(check, deadline) {
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(check.url, { signal: AbortSignal.timeout(Math.max(0, deadline - Date.now())) });
|
|
137
|
+
return response.status === (check.expectedStatus ?? 200);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function probeTcpPort(check, deadline) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const socket = createConnection({ host: check.host, port: check.port });
|
|
146
|
+
socket.setTimeout(Math.max(0, deadline - Date.now()));
|
|
147
|
+
const finish = (ok) => {
|
|
148
|
+
socket.destroy();
|
|
149
|
+
resolve(ok);
|
|
150
|
+
};
|
|
151
|
+
socket.once("connect", () => finish(true));
|
|
152
|
+
socket.once("error", () => finish(false));
|
|
153
|
+
socket.once("timeout", () => finish(false));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Ready when `check.command` exits 0 within the remaining time until
|
|
158
|
+
* `deadline` (RT-030 — see this module's own header). A non-zero exit, a
|
|
159
|
+
* spawn failure (e.g. command not found), and a timeout kill are all "not
|
|
160
|
+
* ready yet" here — `awaitReadiness`'s polling loop retries on the next
|
|
161
|
+
* `pollIntervalMs` tick regardless of which one happened, the same as every
|
|
162
|
+
* other mechanism's `false`.
|
|
163
|
+
*/
|
|
164
|
+
function probeCommand(check, deadline) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const remainingMs = Math.max(0, deadline - Date.now());
|
|
167
|
+
if (remainingMs === 0) {
|
|
168
|
+
resolve(false);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
execFile(check.command, check.args ?? [], { cwd: check.cwd, timeout: remainingMs }, (error) => {
|
|
172
|
+
resolve(error === null);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function sleep(ms) {
|
|
177
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=readiness.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readiness.js","sourceRoot":"","sources":["../src/readiness.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §21's execution boundary, real isolation half (the sandbox-isolation
|
|
3
|
+
* lane, superseding the "sandbox platform is a separate architectural
|
|
4
|
+
* decision, not built here" deferral). RT-070 and RT-193 gave this runtime
|
|
5
|
+
* a *named* boundary (trusted-local, resource limits, privilege reporting)
|
|
6
|
+
* but not an *isolating* one -- nothing stopped a spawned process from
|
|
7
|
+
* reading any file the invoking user could read, or reaching any host the
|
|
8
|
+
* invoking user's network could reach. This module closes that gap for
|
|
9
|
+
* real, on Linux, for processes this runtime SPAWNS.
|
|
10
|
+
*
|
|
11
|
+
* **The architectural constraint this module does not try to solve
|
|
12
|
+
* around:** isolation can only wrap a process Descry itself launches.
|
|
13
|
+
* Attach-mode (`attach-to-running-process.ts`, `attach-to-running-node-
|
|
14
|
+
* process.ts`, `attach-to-running-jvm-process.ts`) reaches a process that
|
|
15
|
+
* was already running, unconfined, before Descry touched it -- there is no
|
|
16
|
+
* honest way to retroactively contain it (ptrace-based interception was
|
|
17
|
+
* investigated and rejected elsewhere in this repo as "not honestly
|
|
18
|
+
* generalizable"). Spawn-mode gets real isolation via this module.
|
|
19
|
+
* Attach-mode stays permanently and explicitly unsandboxed --
|
|
20
|
+
* `SANDBOX_BOUNDARY_DISCLOSURE` in `execution-safety.ts` says so in-product,
|
|
21
|
+
* and this module does not (and structurally cannot) change that.
|
|
22
|
+
*
|
|
23
|
+
* **Mechanism: bubblewrap (bwrap), not raw `unshare`/`clone`.** bwrap is
|
|
24
|
+
* the unprivileged sandboxing helper behind Flatpak and used by many
|
|
25
|
+
* developer tools directly -- a small (tens of KB), auditable, actively
|
|
26
|
+
* maintained binary, not a bespoke native module this repo would need to
|
|
27
|
+
* build and ship per-platform. It runs setuid-free on any kernel with
|
|
28
|
+
* unprivileged user namespaces enabled (checked below, not assumed) and
|
|
29
|
+
* gives one process a new mount namespace (real filesystem visibility
|
|
30
|
+
* control) and network namespace (real network denial) in one exec,
|
|
31
|
+
* composing cleanly with `applyResourceLimits`'s `prlimit` wrap and with
|
|
32
|
+
* this package's existing process-group kill mechanism (`detached: true` +
|
|
33
|
+
* `process.kill(-pid, …)`) -- verified directly: bwrap does not create a
|
|
34
|
+
* new process group unless asked to, so the group-kill in
|
|
35
|
+
* `process-manager.ts` reaches it and everything it started, unchanged.
|
|
36
|
+
* The one dependency this buys: `bwrap` must be on `PATH`. Reported as a
|
|
37
|
+
* capability, exactly like `prlimit` -- refused, never silently
|
|
38
|
+
* unconstrained, when requested and absent.
|
|
39
|
+
*
|
|
40
|
+
* **What this module does NOT attempt, and where that gap actually closes:**
|
|
41
|
+
* a *native* macOS or Windows sandbox for this mechanism (`sandbox-exec`/
|
|
42
|
+
* Seatbelt -- deprecated public API; the Endpoint Security Framework --
|
|
43
|
+
* needs a special Apple entitlement this project does not have; Windows Job
|
|
44
|
+
* Objects + restricted tokens/AppContainer + WFP network enforcement -- a
|
|
45
|
+
* second and third bespoke native sandbox to build and maintain). That is
|
|
46
|
+
* not "researched but not built here, someday" -- it is an explicit
|
|
47
|
+
* architecture decision NOT to build native sandboxes for those two
|
|
48
|
+
* platforms at all. `../container-sandbox.ts` closes the gap instead, by
|
|
49
|
+
* routing macOS and Windows through a real Docker container that reuses
|
|
50
|
+
* THIS module's own proven Linux mechanism underneath (Docker Desktop's
|
|
51
|
+
* Linux VM on macOS, WSL2/Docker Desktop's Windows backend on Windows) --
|
|
52
|
+
* see that module's own doc comment for exactly what is real-tested there
|
|
53
|
+
* versus unverified on real macOS/Windows hardware. `filesystemIsolationCapability`/
|
|
54
|
+
* `networkIsolationCapability` below still report `unavailable` with a
|
|
55
|
+
* reason on non-Linux platforms -- this module never silently claims to
|
|
56
|
+
* cover them; `container-sandbox.ts` is the module a non-Linux caller
|
|
57
|
+
* actually wants.
|
|
58
|
+
*/
|
|
59
|
+
import type { CapabilityStatus, FilesystemPolicy, NetworkPolicy } from "@descryy/runtime-contracts";
|
|
60
|
+
/**
|
|
61
|
+
* Real capability check, matching `resourceLimitCapability`'s own shape and
|
|
62
|
+
* precedent exactly: existence on `PATH` first (fast, matches
|
|
63
|
+
* `prlimit --version`'s pattern), then the one Linux-specific precondition
|
|
64
|
+
* that existence alone doesn't prove -- unprivileged user namespaces
|
|
65
|
+
* actually being usable, not just bwrap being installed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function filesystemIsolationCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): CapabilityStatus;
|
|
68
|
+
/**
|
|
69
|
+
* The same underlying mechanism as `filesystemIsolationCapability` --
|
|
70
|
+
* bwrap building one namespace set covers both a restricted mount view and
|
|
71
|
+
* a network namespace in the same exec -- so this delegates rather than
|
|
72
|
+
* duplicating the check. Kept as its own named function because the two
|
|
73
|
+
* policies are declared, refused, and reasoned about independently
|
|
74
|
+
* (`NetworkPolicy` only supports one shape; see `unsupportedNetworkPolicyReason`),
|
|
75
|
+
* even though today they share one mechanism underneath.
|
|
76
|
+
*/
|
|
77
|
+
export declare function networkIsolationCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): CapabilityStatus;
|
|
78
|
+
/**
|
|
79
|
+
* `NetworkPolicy`'s general shape (arbitrary allow/deny host lists) is not
|
|
80
|
+
* what this iteration's mechanism enforces -- only full denial. Returns the
|
|
81
|
+
* reason a given policy cannot be applied, or `null` if it is the one
|
|
82
|
+
* shape that can. Checked independently of `networkIsolationCapability`:
|
|
83
|
+
* a policy can be shape-unsupported on a platform where the mechanism
|
|
84
|
+
* itself is otherwise available, and the two refusals are different
|
|
85
|
+
* claims ("nothing here can enforce any network policy" vs. "this specific
|
|
86
|
+
* policy asks for something the real mechanism doesn't do yet").
|
|
87
|
+
*/
|
|
88
|
+
export declare function unsupportedNetworkPolicyReason(policy: NetworkPolicy): string | null;
|
|
89
|
+
/**
|
|
90
|
+
* Resolves the real, symlink-followed directory containing the executable
|
|
91
|
+
* `command` would run as -- the one piece of host filesystem a sandboxed
|
|
92
|
+
* process needs beyond the standard OS directories and whatever the caller
|
|
93
|
+
* declared, and the one this function computes generically rather than by
|
|
94
|
+
* naming a language: an interpreter installed outside `/usr` (nvm, rbenv,
|
|
95
|
+
* pyenv, sdkman -- all common, all under a user's home directory) would
|
|
96
|
+
* otherwise be invisible inside the sandbox no matter which language it
|
|
97
|
+
* is, which is exactly the kind of per-language special case §IR-boundary
|
|
98
|
+
* rules out. Returns `null` when `command` cannot be resolved (already
|
|
99
|
+
* absolute-looking and missing, or not found on `PATH`) -- the caller
|
|
100
|
+
* proceeds without an extra bind in that case; the exec itself will fail
|
|
101
|
+
* inside the sandbox the same honest way it would outside one.
|
|
102
|
+
*/
|
|
103
|
+
export declare function resolveExecutableDirectory(command: string, cwd: string, env: NodeJS.ProcessEnv): string | null;
|
|
104
|
+
export interface SandboxOptions {
|
|
105
|
+
/** The original, un-resource-limit-wrapped command (e.g. `"node"`) -- used only to resolve which extra directory the interpreter itself needs bound in. */
|
|
106
|
+
readonly interpreterCommand: string;
|
|
107
|
+
/** The command to actually exec inside the sandbox -- already passed through `applyResourceLimits`, so this may be `"prlimit"` with the real command in `args`. */
|
|
108
|
+
readonly command: string;
|
|
109
|
+
readonly args: readonly string[];
|
|
110
|
+
readonly cwd: string;
|
|
111
|
+
readonly filesystemPolicy?: FilesystemPolicy;
|
|
112
|
+
readonly networkPolicy?: NetworkPolicy;
|
|
113
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
114
|
+
readonly platform?: NodeJS.Platform;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Wraps `command`/`args` with `bwrap` so the OS enforces `filesystemPolicy`
|
|
118
|
+
* and/or `networkPolicy`. Neither declared returns `command`/`args`
|
|
119
|
+
* unchanged -- every execution before this lane keeps behaving exactly as
|
|
120
|
+
* before unless it opts in, the same non-regression contract
|
|
121
|
+
* `applyResourceLimits` already established for resource limits.
|
|
122
|
+
*
|
|
123
|
+
* **Throws rather than silently spawning unconstrained** -- same
|
|
124
|
+
* `tokenizeCommand`/`applyResourceLimits` precedent -- when a policy is
|
|
125
|
+
* requested and the real mechanism cannot back it, whether because the
|
|
126
|
+
* platform has no mechanism at all or because the declared shape is one
|
|
127
|
+
* this mechanism doesn't implement (`unsupportedNetworkPolicyReason`).
|
|
128
|
+
*
|
|
129
|
+
* Composes with `applyResourceLimits`: this function wraps *outside* it
|
|
130
|
+
* (`bwrap … -- prlimit … -- realCommand`), because the mount namespace must
|
|
131
|
+
* exist before `prlimit` or the real command run inside it, and `prlimit`
|
|
132
|
+
* itself (in `/usr/bin`) is reachable through the same base OS binding
|
|
133
|
+
* every sandboxed process gets.
|
|
134
|
+
*/
|
|
135
|
+
export declare function applySandbox(options: SandboxOptions): {
|
|
136
|
+
readonly command: string;
|
|
137
|
+
readonly args: readonly string[];
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AA+BpG;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,gBAAgB,CAoBlJ;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,gBAAgB,CAE/I;AAED;;;;;;;;;GASG;AACH,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CAOnF;AAOD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,GAAG,IAAI,CAwB9G;AAED,MAAM,WAAW,cAAc;IAC7B,2JAA2J;IAC3J,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,mKAAmK;IACnK,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAC7C,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,cAAc,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAmFpH"}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §21's execution boundary, real isolation half (the sandbox-isolation
|
|
3
|
+
* lane, superseding the "sandbox platform is a separate architectural
|
|
4
|
+
* decision, not built here" deferral). RT-070 and RT-193 gave this runtime
|
|
5
|
+
* a *named* boundary (trusted-local, resource limits, privilege reporting)
|
|
6
|
+
* but not an *isolating* one -- nothing stopped a spawned process from
|
|
7
|
+
* reading any file the invoking user could read, or reaching any host the
|
|
8
|
+
* invoking user's network could reach. This module closes that gap for
|
|
9
|
+
* real, on Linux, for processes this runtime SPAWNS.
|
|
10
|
+
*
|
|
11
|
+
* **The architectural constraint this module does not try to solve
|
|
12
|
+
* around:** isolation can only wrap a process Descry itself launches.
|
|
13
|
+
* Attach-mode (`attach-to-running-process.ts`, `attach-to-running-node-
|
|
14
|
+
* process.ts`, `attach-to-running-jvm-process.ts`) reaches a process that
|
|
15
|
+
* was already running, unconfined, before Descry touched it -- there is no
|
|
16
|
+
* honest way to retroactively contain it (ptrace-based interception was
|
|
17
|
+
* investigated and rejected elsewhere in this repo as "not honestly
|
|
18
|
+
* generalizable"). Spawn-mode gets real isolation via this module.
|
|
19
|
+
* Attach-mode stays permanently and explicitly unsandboxed --
|
|
20
|
+
* `SANDBOX_BOUNDARY_DISCLOSURE` in `execution-safety.ts` says so in-product,
|
|
21
|
+
* and this module does not (and structurally cannot) change that.
|
|
22
|
+
*
|
|
23
|
+
* **Mechanism: bubblewrap (bwrap), not raw `unshare`/`clone`.** bwrap is
|
|
24
|
+
* the unprivileged sandboxing helper behind Flatpak and used by many
|
|
25
|
+
* developer tools directly -- a small (tens of KB), auditable, actively
|
|
26
|
+
* maintained binary, not a bespoke native module this repo would need to
|
|
27
|
+
* build and ship per-platform. It runs setuid-free on any kernel with
|
|
28
|
+
* unprivileged user namespaces enabled (checked below, not assumed) and
|
|
29
|
+
* gives one process a new mount namespace (real filesystem visibility
|
|
30
|
+
* control) and network namespace (real network denial) in one exec,
|
|
31
|
+
* composing cleanly with `applyResourceLimits`'s `prlimit` wrap and with
|
|
32
|
+
* this package's existing process-group kill mechanism (`detached: true` +
|
|
33
|
+
* `process.kill(-pid, …)`) -- verified directly: bwrap does not create a
|
|
34
|
+
* new process group unless asked to, so the group-kill in
|
|
35
|
+
* `process-manager.ts` reaches it and everything it started, unchanged.
|
|
36
|
+
* The one dependency this buys: `bwrap` must be on `PATH`. Reported as a
|
|
37
|
+
* capability, exactly like `prlimit` -- refused, never silently
|
|
38
|
+
* unconstrained, when requested and absent.
|
|
39
|
+
*
|
|
40
|
+
* **What this module does NOT attempt, and where that gap actually closes:**
|
|
41
|
+
* a *native* macOS or Windows sandbox for this mechanism (`sandbox-exec`/
|
|
42
|
+
* Seatbelt -- deprecated public API; the Endpoint Security Framework --
|
|
43
|
+
* needs a special Apple entitlement this project does not have; Windows Job
|
|
44
|
+
* Objects + restricted tokens/AppContainer + WFP network enforcement -- a
|
|
45
|
+
* second and third bespoke native sandbox to build and maintain). That is
|
|
46
|
+
* not "researched but not built here, someday" -- it is an explicit
|
|
47
|
+
* architecture decision NOT to build native sandboxes for those two
|
|
48
|
+
* platforms at all. `../container-sandbox.ts` closes the gap instead, by
|
|
49
|
+
* routing macOS and Windows through a real Docker container that reuses
|
|
50
|
+
* THIS module's own proven Linux mechanism underneath (Docker Desktop's
|
|
51
|
+
* Linux VM on macOS, WSL2/Docker Desktop's Windows backend on Windows) --
|
|
52
|
+
* see that module's own doc comment for exactly what is real-tested there
|
|
53
|
+
* versus unverified on real macOS/Windows hardware. `filesystemIsolationCapability`/
|
|
54
|
+
* `networkIsolationCapability` below still report `unavailable` with a
|
|
55
|
+
* reason on non-Linux platforms -- this module never silently claims to
|
|
56
|
+
* cover them; `container-sandbox.ts` is the module a non-Linux caller
|
|
57
|
+
* actually wants.
|
|
58
|
+
*/
|
|
59
|
+
import { execFileSync } from "node:child_process";
|
|
60
|
+
import { accessSync, constants as fsConstants, readFileSync, realpathSync } from "node:fs";
|
|
61
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
62
|
+
/**
|
|
63
|
+
* Whether unprivileged user namespaces -- the kernel feature bwrap needs to
|
|
64
|
+
* build a mount/network namespace without running setuid-root -- are
|
|
65
|
+
* actually enabled on this host, not merely assumed from "the kernel
|
|
66
|
+
* supports namespaces in general." Two independent sysctls can each
|
|
67
|
+
* disable it; neither file existing at all is itself evidence of a
|
|
68
|
+
* restriction (most non-Debian-derived distros never gate this at all).
|
|
69
|
+
*/
|
|
70
|
+
function unprivilegedUserNamespaceStatus() {
|
|
71
|
+
try {
|
|
72
|
+
const clone = readFileSync("/proc/sys/kernel/unprivileged_userns_clone", "utf8").trim();
|
|
73
|
+
if (clone === "0") {
|
|
74
|
+
return { ok: false, reason: "kernel.unprivileged_userns_clone=0 -- unprivileged user namespaces are disabled on this host" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Sysctl doesn't exist on this kernel -- not evidence of a restriction,
|
|
79
|
+
// only Debian/Ubuntu-derived kernels gate it through this specific file.
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const max = readFileSync("/proc/sys/user/max_user_namespaces", "utf8").trim();
|
|
83
|
+
if (max === "0") {
|
|
84
|
+
return { ok: false, reason: "user.max_user_namespaces=0 -- unprivileged user namespaces are disabled on this host" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Same reasoning.
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, reason: null };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Real capability check, matching `resourceLimitCapability`'s own shape and
|
|
94
|
+
* precedent exactly: existence on `PATH` first (fast, matches
|
|
95
|
+
* `prlimit --version`'s pattern), then the one Linux-specific precondition
|
|
96
|
+
* that existence alone doesn't prove -- unprivileged user namespaces
|
|
97
|
+
* actually being usable, not just bwrap being installed.
|
|
98
|
+
*/
|
|
99
|
+
export function filesystemIsolationCapability(env = process.env, platform = process.platform) {
|
|
100
|
+
if (platform !== "linux") {
|
|
101
|
+
return {
|
|
102
|
+
availability: "unavailable",
|
|
103
|
+
reason: `no filesystem isolation mechanism is wired up on "${platform}" for bwrap -- by design, not by omission (native sandbox-exec/Seatbelt and Job Objects/AppContainer were researched and rejected); see container-sandbox.ts, which routes "${platform}" through a real Docker container instead`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
execFileSync("bwrap", ["--version"], { env, stdio: "ignore" });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {
|
|
111
|
+
availability: "unavailable",
|
|
112
|
+
reason: "bubblewrap (bwrap) is not on PATH -- filesystem isolation cannot be enforced without it",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const userns = unprivilegedUserNamespaceStatus();
|
|
116
|
+
if (!userns.ok) {
|
|
117
|
+
return { availability: "unavailable", reason: `bubblewrap is installed but unprivileged user namespaces are unavailable: ${userns.reason}` };
|
|
118
|
+
}
|
|
119
|
+
return { availability: "available", reason: null };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The same underlying mechanism as `filesystemIsolationCapability` --
|
|
123
|
+
* bwrap building one namespace set covers both a restricted mount view and
|
|
124
|
+
* a network namespace in the same exec -- so this delegates rather than
|
|
125
|
+
* duplicating the check. Kept as its own named function because the two
|
|
126
|
+
* policies are declared, refused, and reasoned about independently
|
|
127
|
+
* (`NetworkPolicy` only supports one shape; see `unsupportedNetworkPolicyReason`),
|
|
128
|
+
* even though today they share one mechanism underneath.
|
|
129
|
+
*/
|
|
130
|
+
export function networkIsolationCapability(env = process.env, platform = process.platform) {
|
|
131
|
+
return filesystemIsolationCapability(env, platform);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* `NetworkPolicy`'s general shape (arbitrary allow/deny host lists) is not
|
|
135
|
+
* what this iteration's mechanism enforces -- only full denial. Returns the
|
|
136
|
+
* reason a given policy cannot be applied, or `null` if it is the one
|
|
137
|
+
* shape that can. Checked independently of `networkIsolationCapability`:
|
|
138
|
+
* a policy can be shape-unsupported on a platform where the mechanism
|
|
139
|
+
* itself is otherwise available, and the two refusals are different
|
|
140
|
+
* claims ("nothing here can enforce any network policy" vs. "this specific
|
|
141
|
+
* policy asks for something the real mechanism doesn't do yet").
|
|
142
|
+
*/
|
|
143
|
+
export function unsupportedNetworkPolicyReason(policy) {
|
|
144
|
+
if (policy.mode === "allow" && policy.hosts.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
return ('only full network denial ({ mode: "allow", hosts: [] }) is enforced -- selective allow- or deny-listing of ' +
|
|
147
|
+
"specific hosts needs DNS interception and IP filtering inside the sandbox's network namespace (a veth pair, " +
|
|
148
|
+
"NAT, iptables/nftables rules), not built in this iteration");
|
|
149
|
+
}
|
|
150
|
+
function isWithin(root, target) {
|
|
151
|
+
const rel = relative(root, target);
|
|
152
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolves the real, symlink-followed directory containing the executable
|
|
156
|
+
* `command` would run as -- the one piece of host filesystem a sandboxed
|
|
157
|
+
* process needs beyond the standard OS directories and whatever the caller
|
|
158
|
+
* declared, and the one this function computes generically rather than by
|
|
159
|
+
* naming a language: an interpreter installed outside `/usr` (nvm, rbenv,
|
|
160
|
+
* pyenv, sdkman -- all common, all under a user's home directory) would
|
|
161
|
+
* otherwise be invisible inside the sandbox no matter which language it
|
|
162
|
+
* is, which is exactly the kind of per-language special case §IR-boundary
|
|
163
|
+
* rules out. Returns `null` when `command` cannot be resolved (already
|
|
164
|
+
* absolute-looking and missing, or not found on `PATH`) -- the caller
|
|
165
|
+
* proceeds without an extra bind in that case; the exec itself will fail
|
|
166
|
+
* inside the sandbox the same honest way it would outside one.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveExecutableDirectory(command, cwd, env) {
|
|
169
|
+
let candidate = null;
|
|
170
|
+
if (command.includes("/")) {
|
|
171
|
+
candidate = isAbsolute(command) ? command : join(cwd, command);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const pathVar = env.PATH ?? "";
|
|
175
|
+
for (const dir of pathVar.split(":")) {
|
|
176
|
+
if (dir === "")
|
|
177
|
+
continue;
|
|
178
|
+
const full = join(dir, command);
|
|
179
|
+
try {
|
|
180
|
+
accessSync(full, fsConstants.X_OK);
|
|
181
|
+
candidate = full;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (candidate === null)
|
|
190
|
+
return null;
|
|
191
|
+
try {
|
|
192
|
+
return dirname(realpathSync(candidate));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Wraps `command`/`args` with `bwrap` so the OS enforces `filesystemPolicy`
|
|
200
|
+
* and/or `networkPolicy`. Neither declared returns `command`/`args`
|
|
201
|
+
* unchanged -- every execution before this lane keeps behaving exactly as
|
|
202
|
+
* before unless it opts in, the same non-regression contract
|
|
203
|
+
* `applyResourceLimits` already established for resource limits.
|
|
204
|
+
*
|
|
205
|
+
* **Throws rather than silently spawning unconstrained** -- same
|
|
206
|
+
* `tokenizeCommand`/`applyResourceLimits` precedent -- when a policy is
|
|
207
|
+
* requested and the real mechanism cannot back it, whether because the
|
|
208
|
+
* platform has no mechanism at all or because the declared shape is one
|
|
209
|
+
* this mechanism doesn't implement (`unsupportedNetworkPolicyReason`).
|
|
210
|
+
*
|
|
211
|
+
* Composes with `applyResourceLimits`: this function wraps *outside* it
|
|
212
|
+
* (`bwrap … -- prlimit … -- realCommand`), because the mount namespace must
|
|
213
|
+
* exist before `prlimit` or the real command run inside it, and `prlimit`
|
|
214
|
+
* itself (in `/usr/bin`) is reachable through the same base OS binding
|
|
215
|
+
* every sandboxed process gets.
|
|
216
|
+
*/
|
|
217
|
+
export function applySandbox(options) {
|
|
218
|
+
const { filesystemPolicy, networkPolicy } = options;
|
|
219
|
+
if (filesystemPolicy === undefined && networkPolicy === undefined) {
|
|
220
|
+
return { command: options.command, args: options.args };
|
|
221
|
+
}
|
|
222
|
+
const env = options.env ?? process.env;
|
|
223
|
+
const platform = options.platform ?? process.platform;
|
|
224
|
+
if (networkPolicy !== undefined) {
|
|
225
|
+
const shapeReason = unsupportedNetworkPolicyReason(networkPolicy);
|
|
226
|
+
if (shapeReason !== null) {
|
|
227
|
+
throw new Error(`networkPolicy was configured but cannot be enforced: ${shapeReason}`);
|
|
228
|
+
}
|
|
229
|
+
const capability = networkIsolationCapability(env, platform);
|
|
230
|
+
if (capability.availability !== "available") {
|
|
231
|
+
throw new Error(`networkPolicy was configured but cannot be enforced on this platform: ${capability.reason}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (filesystemPolicy !== undefined) {
|
|
235
|
+
const capability = filesystemIsolationCapability(env, platform);
|
|
236
|
+
if (capability.availability !== "available") {
|
|
237
|
+
throw new Error(`filesystemPolicy was configured but cannot be enforced on this platform: ${capability.reason}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const bwrapArgs = [];
|
|
241
|
+
if (filesystemPolicy !== undefined) {
|
|
242
|
+
bwrapArgs.push("--ro-bind", "/usr", "/usr", "--symlink", "/usr/bin", "/bin", "--symlink", "/usr/lib", "/lib", "--symlink", "/usr/lib64", "/lib64", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--bind", options.cwd, options.cwd);
|
|
243
|
+
const boundRoots = ["/usr", options.cwd, ...filesystemPolicy.allowedRoots];
|
|
244
|
+
for (const root of filesystemPolicy.allowedRoots) {
|
|
245
|
+
bwrapArgs.push("--bind", root, root);
|
|
246
|
+
}
|
|
247
|
+
const interpreterDir = resolveExecutableDirectory(options.interpreterCommand, options.cwd, env);
|
|
248
|
+
if (interpreterDir !== null && !boundRoots.some((root) => isWithin(root, interpreterDir))) {
|
|
249
|
+
bwrapArgs.push("--ro-bind", interpreterDir, interpreterDir);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// No filesystem restriction declared -- mirror the host root exactly,
|
|
254
|
+
// matching FilesystemPolicy's own "absent means unconstrained" default.
|
|
255
|
+
// A mount namespace is still created here (bwrap always builds one, and
|
|
256
|
+
// --unshare-net below needs it), so /proc, /dev and /tmp are remounted
|
|
257
|
+
// explicitly rather than inherited: a plain `--bind / /` is not
|
|
258
|
+
// recursive and would otherwise leave them looking empty inside.
|
|
259
|
+
bwrapArgs.push("--bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--bind", "/tmp", "/tmp");
|
|
260
|
+
}
|
|
261
|
+
bwrapArgs.push("--chdir", options.cwd);
|
|
262
|
+
if (networkPolicy !== undefined) {
|
|
263
|
+
bwrapArgs.push("--unshare-net");
|
|
264
|
+
}
|
|
265
|
+
// Kills the sandboxed tree with the sandbox itself if bwrap's own parent
|
|
266
|
+
// (this Node process) dies unexpectedly -- a second, independent backstop
|
|
267
|
+
// alongside process-manager.ts's own group-kill, not a replacement for it.
|
|
268
|
+
bwrapArgs.push("--die-with-parent", "--", options.command, ...options.args);
|
|
269
|
+
return { command: "bwrap", args: bwrapArgs };
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=sandbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@descryy/runtime-controller",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Runtime controller: process manager, readiness, timeout/cancellation, execution IDs, cleanup. Deliverable 2 of the runtime phase — depends on @descryy/runtime-contracts, builds no collectors.",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.5"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org",
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -b"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@descryy/runtime-contracts": "0.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|