@kici-dev/shared 0.6.1 → 0.7.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.
@@ -122,7 +122,10 @@ export declare function defineEnv<TShape extends z.ZodRawShape>(opts: DefineEnvO
122
122
  *
123
123
  * Keep this list small and well-justified. Every addition is a typo we can
124
124
  * no longer catch, so only list things that are (a) actually set in the
125
- * wild by our own tooling and (b) could never be a config typo.
125
+ * wild by our own tooling and (b) could never be a config typo. A variable
126
+ * only one service reads belongs in that service's `extraKnown` instead —
127
+ * `KICI_CONFIG` is the reference case, allowlisted at the orchestrator's own
128
+ * call site so the agent and Platform catchers still reject it.
126
129
  */
127
130
  export declare const RESERVED_NON_SCHEMA_KICI_VARS: readonly string[];
128
131
  /**
@@ -176,6 +179,16 @@ export interface ValidateUnknownKiciVarsOptions {
176
179
  warnOnly?: boolean;
177
180
  /** Logger callback for warn-mode (defaults to `console.warn`). */
178
181
  onWarn?: (msg: string) => void;
182
+ /**
183
+ * Advice text for names the caller recognises but does not accept — a
184
+ * documented variable that belongs to some other layer. A rejected name
185
+ * found here is reported with its advice instead of a Levenshtein guess,
186
+ * which finds nothing for exactly these names because they are not near
187
+ * misses of a schema name.
188
+ *
189
+ * This changes the message only. A name listed here is still rejected.
190
+ */
191
+ notStartupVars?: Record<string, string>;
179
192
  }
180
193
  /**
181
194
  * Inspect `env` for KICI_* keys that are not in `known`. Throws (or warns,
@@ -241,7 +241,10 @@ function suggestClosest(name, candidates) {
241
241
  *
242
242
  * Keep this list small and well-justified. Every addition is a typo we can
243
243
  * no longer catch, so only list things that are (a) actually set in the
244
- * wild by our own tooling and (b) could never be a config typo.
244
+ * wild by our own tooling and (b) could never be a config typo. A variable
245
+ * only one service reads belongs in that service's `extraKnown` instead —
246
+ * `KICI_CONFIG` is the reference case, allowlisted at the orchestrator's own
247
+ * call site so the agent and Platform catchers still reject it.
245
248
  */
246
249
  const RESERVED_NON_SCHEMA_KICI_VARS = [
247
250
  "KICI_CACHE",
@@ -305,21 +308,27 @@ function validateUnknownKiciVars(known, options = {}, env = process.env) {
305
308
  ...options.extraKnown ?? [],
306
309
  ...RESERVED_NON_SCHEMA_KICI_VARS
307
310
  ]);
311
+ const advice = options.notStartupVars ?? {};
308
312
  const unknown = [];
309
313
  for (const key of Object.keys(env)) {
310
314
  if (!key.startsWith("KICI_")) continue;
311
315
  if (knownSet.has(key)) continue;
312
316
  if (RESERVED_NON_SCHEMA_KICI_PREFIXES.some((p) => key.startsWith(p))) continue;
313
317
  if (RESERVED_NON_SCHEMA_KICI_SUFFIXES.some((s) => key.endsWith(s) || key.includes(`${s}_`))) continue;
318
+ const note = advice[key];
314
319
  unknown.push({
315
320
  name: key,
316
- suggestion: suggestClosest(key, [...knownSet])
321
+ note,
322
+ suggestion: note ? void 0 : suggestClosest(key, [...knownSet])
317
323
  });
318
324
  }
319
325
  if (unknown.length === 0) return;
320
326
  const header = `Unknown KICI_* env var(s) detected — refusing to start.
321
327
  Set KICI_DEV=true to downgrade this check to a warning.
322
- Unknown vars:\n${unknown.map(({ name, suggestion }) => suggestion ? ` - ${name} (did you mean ${suggestion}?)` : ` - ${name} (no close match in the schema)`).join("\n")}`;
328
+ Unknown vars:\n${unknown.map(({ name, suggestion, note }) => {
329
+ if (note) return ` - ${name} (${note})`;
330
+ return suggestion ? ` - ${name} (did you mean ${suggestion}?)` : ` - ${name} (no close match in the schema)`;
331
+ }).join("\n")}`;
323
332
  if (options.warnOnly ?? env.KICI_DEV === "true") {
324
333
  (options.onWarn ?? console.warn)(header);
325
334
  return;
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Programmatic nftables rule management for agent network isolation.
3
+ *
4
+ * Manages RFC1918 + cloud metadata blocking rules per-interface (Firecracker/container)
5
+ * or per-UID (bare-metal). All operations use `nft` CLI via child_process.execFile.
6
+ *
7
+ * Table layout:
8
+ * table ip kici {
9
+ * chain forward { type filter hook forward priority 0; policy accept; }
10
+ * chain input { type filter hook input priority 0; policy accept; }
11
+ * chain output { type filter hook output priority 0; policy accept; }
12
+ * }
13
+ *
14
+ * `forward` and `input` answer different questions and both are needed. A
15
+ * packet a sandbox sends to one of the host's OWN addresses — a bridge gateway,
16
+ * the host's LAN address — is delivered on the input hook and never traverses
17
+ * forward, so a forward rule cannot see it. `forward` governs what a sandbox
18
+ * reaches THROUGH the host; `input` governs what it reaches ON the host.
19
+ */
20
+ /**
21
+ * Network policy controlling RFC1918 and internet access for the agents or job
22
+ * containers in one label set.
23
+ *
24
+ * Lives here rather than beside the scaler's own types because both the
25
+ * orchestrator's scaler (agent containers and Firecracker VMs) and the agent's
26
+ * container backend (nested job containers) build rules from it. The
27
+ * orchestrator re-exports it from `scaler/types.ts` so its call sites are
28
+ * unchanged.
29
+ */
30
+ export interface NetworkPolicy {
31
+ /** CIDR ranges allowed as exceptions to the default RFC1918 block */
32
+ allowlist?: string[];
33
+ /** Block all outbound traffic except allowlisted ranges */
34
+ denyAll?: boolean;
35
+ /**
36
+ * What this source class may reach on the HOST itself, in the
37
+ * `<cidr|address|*>[:<port|*>]` vocabulary {@link parseHostAccess} reads.
38
+ * Everything else host-destined is dropped.
39
+ *
40
+ * Distinct from {@link allowlist}, which governs the `forward` hook and so
41
+ * answers what a sandbox reaches *through* the host. Leaving this undefined
42
+ * means the caller supplies its own class default; an empty array means
43
+ * "reach nothing on the host".
44
+ */
45
+ hostAccess?: string[];
46
+ }
47
+ /**
48
+ * One parsed {@link NetworkPolicy.hostAccess} entry.
49
+ *
50
+ * `null` means "unconstrained" on both fields: a `daddr` of `null` matches
51
+ * every host address (nft omits the `ip daddr` clause), and a `port` of
52
+ * `null` matches every port.
53
+ */
54
+ export interface HostAccessRule {
55
+ daddr: string | null;
56
+ port: number | null;
57
+ }
58
+ /**
59
+ * Parse one host-access entry.
60
+ *
61
+ * Grammar: `<cidr|address|*>` optionally followed by `:<port|*>`. A bare number
62
+ * is a port on any host address, which is the common case — an operator naming
63
+ * a host-local registry mirror knows its port, not the host's dynamic
64
+ * addresses.
65
+ *
66
+ * Hostnames are rejected on purpose: nftables matches addresses, so resolving a
67
+ * name at rule-build time produces a rule that goes stale silently the next
68
+ * time the name moves.
69
+ *
70
+ * @throws Error naming the entry and what is wrong with it
71
+ */
72
+ export declare function parseHostAccess(entry: string): HostAccessRule;
73
+ /**
74
+ * Build the host-access rules for one identifier, in final head-to-tail order:
75
+ * the conntrack exception, every accept, then one terminal drop.
76
+ *
77
+ * The conntrack rule leads because the chain is keyed on the sandbox as the
78
+ * SOURCE, so it also sees the reply leg of a connection the HOST opened toward
79
+ * the sandbox — a readiness probe, a metrics scrape. Without the exception
80
+ * those replies fall through to the terminal drop and the host's own connection
81
+ * dies as an opaque timeout. It widens nothing a sandbox can initiate:
82
+ * `established` is reached only by a flow whose first packet was already
83
+ * accepted, so a sandbox connecting to a port with no accept still has its SYN
84
+ * dropped and never reaches that state.
85
+ *
86
+ * A port-scoped entry emits an accept per protocol in
87
+ * {@link HOST_ACCESS_PROTOCOLS}. UDP is not optional: a container whose
88
+ * resolver is the bridge gateway — which is what rootful podman with
89
+ * aardvark-dns gives it — resolves over UDP, so a tcp-only accept on port 53
90
+ * leaves it unable to resolve any name.
91
+ *
92
+ * @returns one token list per rule, in final head-to-tail order
93
+ */
94
+ export declare function buildHostAccessRuleOps(matchClause: string[], hostAccess: string[]): string[][];
95
+ /**
96
+ * Match mode for nftables isolation rules.
97
+ * - 'iifname': Match on input interface name (Firecracker TAP devices)
98
+ * - 'saddr': Match on source IP address (container backends)
99
+ */
100
+ export type NftMatchMode = 'iifname' | 'saddr';
101
+ /** RFC1918 private address ranges. */
102
+ export declare const RFC1918_RANGES: string[];
103
+ /** Cloud metadata service range (AWS/GCP/Azure link-local). */
104
+ export declare const METADATA_RANGE = "169.254.0.0/16";
105
+ /**
106
+ * Subnet of the agent's `kici-jobs` bridge, on which the agent keys ONE drop set
107
+ * covering every nested job container — including one that does not exist yet.
108
+ *
109
+ * It lives here rather than beside the agent's own network constants because
110
+ * the Firecracker host provisioner has to recognise those rules to leave them
111
+ * alone, and the orchestrator carries `@kici-dev/agent` only as a devDependency.
112
+ * The agent module re-exports it, so its own call sites are unchanged.
113
+ */
114
+ export declare const JOB_NETWORK_SUBNET = "172.31.0.0/16";
115
+ /**
116
+ * Options for nft command execution.
117
+ */
118
+ interface NftOptions {
119
+ /**
120
+ * Wrap the `nft` invocation with `sudo -n` so non-root orchestrators (e.g.
121
+ * Pi user-mode systemd) can manage rules. Operators must have a NOPASSWD
122
+ * sudoers entry for /usr/sbin/nft. Default false.
123
+ */
124
+ requireSudo?: boolean;
125
+ /**
126
+ * nftables table these rules live in. Defaults to {@link DEFAULT_NFT_TABLE}.
127
+ *
128
+ * The Firecracker backend exposes this as an operator knob so two
129
+ * coordinators on one host get disjoint tables; the per-VM rules must follow
130
+ * the same knob as the bridge baseline, or coordinator B's rules land in
131
+ * coordinator A's table and A's next provision wipes them.
132
+ */
133
+ table?: string;
134
+ }
135
+ /** Table used when {@link NftOptions.table} is not set. */
136
+ export declare const DEFAULT_NFT_TABLE = "kici";
137
+ /**
138
+ * Validate that nftables is available and the process has NET_ADMIN capability.
139
+ * Attempts `nft list tables` -- if it fails:
140
+ * - ENOENT: nft binary not installed
141
+ * - EPERM: nft binary present but NET_ADMIN capability missing
142
+ * Throws with a clear error message in both cases.
143
+ */
144
+ export declare function validateNftablesAvailability(opts?: NftOptions): Promise<void>;
145
+ /**
146
+ * Ensure the nftables table and the chains this module writes to exist.
147
+ * Idempotent -- safe to call multiple times.
148
+ *
149
+ * Every chain is verified individually. A bare "does the table exist?" check is
150
+ * not enough: the Firecracker host provisioner creates the table before this
151
+ * module ever runs, so a table with no `forward` chain satisfied the old early
152
+ * return — and then every `addIsolationRules` failed with nft's "No such file
153
+ * or directory", leaving every VM on that host with no isolation rules at all.
154
+ *
155
+ * @param opts - `table` selects the table; `requireBaselineChain` additionally
156
+ * ensures the regular {@link BASELINE_CHAIN} exists (Firecracker hosts, whose
157
+ * `forward` chain ends in a jump to it).
158
+ */
159
+ export declare function ensureKiciTable(opts?: NftOptions & {
160
+ requireBaselineChain?: boolean;
161
+ }): Promise<void>;
162
+ /** Chain in the kici table that filters host-destined sandbox traffic. */
163
+ export declare const INPUT_CHAIN = "input";
164
+ /**
165
+ * Ensure the kici table and its `input` chain exist. Idempotent — `nft add` is
166
+ * a create-or-noop for both, so there is no check-then-create window.
167
+ *
168
+ * The chain's policy is `accept` because it is a base chain on the host's own
169
+ * input hook: everything the host itself receives passes through it, and a
170
+ * default-deny there would take the machine off the network. The deny lives in
171
+ * the per-identifier terminal drop {@link buildHostAccessRuleOps} emits.
172
+ */
173
+ export declare function ensureKiciInputChain(opts?: NftOptions): Promise<void>;
174
+ /**
175
+ * Apply one identifier's host-access rules to the `input` chain, replacing
176
+ * whatever it had.
177
+ *
178
+ * The pre-clean is not an optimisation. Bridge networks recycle addresses, so a
179
+ * crash or a `kill -9` leaves the previous holder's accepts behind for the next
180
+ * container on that IP to inherit — which is the boundary this chain exists to
181
+ * hold. Applying without removing first would also stack a second terminal drop
182
+ * above the first run's accepts, shadowing every one of them.
183
+ *
184
+ * Rules are `insert`ed in reverse so the block lands at the chain head in the
185
+ * order {@link buildHostAccessRuleOps} returns: accepts first, terminal drop
186
+ * last. Inserting forwards would put the drop above the accepts and deny
187
+ * everything.
188
+ */
189
+ export declare function addHostIsolationRules(identifier: string, hostAccess: string[], matchMode?: NftMatchMode, opts?: NftOptions): Promise<void>;
190
+ /**
191
+ * A table's `input` chain, as `nft -a list chain` prints it, or `null` when the
192
+ * table or the chain does not exist yet.
193
+ *
194
+ * Returning `null` rather than throwing keeps "the chain is not there" distinct
195
+ * from "the chain is there and holds nothing", which a caller deciding whether
196
+ * a rule set is already installed has to be able to tell apart.
197
+ */
198
+ export declare function readInputChain(opts?: NftOptions): Promise<string | null>;
199
+ /**
200
+ * Remove one identifier's rules from the `input` chain.
201
+ *
202
+ * Best-effort: a missing chain, or a rule another path already deleted, must
203
+ * not abort a teardown.
204
+ */
205
+ export declare function removeHostIsolationRules(identifier: string, opts?: NftOptions): Promise<void>;
206
+ /**
207
+ * Name of the regular (non-hooked) chain holding the host baseline rules.
208
+ *
209
+ * The Firecracker host provisioner puts its six source-scoped baseline rules
210
+ * here and reaches them with a `jump` appended as the `forward` chain's LAST
211
+ * rule. Two properties follow, and both are load-bearing:
212
+ *
213
+ * - **Per-VM rules always win.** They are inserted at the `forward` head, so
214
+ * every one of them is evaluated before the jump. A per-VM `accept`
215
+ * terminates the hook before the baseline can re-drop an allowlisted
216
+ * destination, and a per-VM `denyAll` drop is terminal before the
217
+ * baseline's blanket internet `accept` can let the packet out.
218
+ * - **A self-heal can rebuild the baseline without touching live VMs.** The
219
+ * provisioner flushes and refills only this chain, so it never has to
220
+ * `delete table` — which used to drop every running VM's isolation rules
221
+ * fail-open.
222
+ *
223
+ * A regular chain reached by `jump` rather than a second base chain at a lower
224
+ * priority: in netfilter an `accept` ends only the current base chain, so an
225
+ * allowlist accept in an earlier base chain would still be re-evaluated — and
226
+ * dropped — by the baseline's 10.0.0.0/8 rule in the later one.
227
+ */
228
+ export declare const BASELINE_CHAIN = "baseline";
229
+ /**
230
+ * Build the per-identifier isolation rules in their final head-to-tail order.
231
+ *
232
+ * nftables is first-match-wins within a chain and `accept` is terminal, so the
233
+ * order below is the whole security property:
234
+ *
235
+ * 1. gateway accept
236
+ * 2. allowlisted CIDR accepts — ahead of the drops, so an allowlisted
237
+ * destination inside a dropped range (a 10.x registry endpoint behind the
238
+ * 10.0.0.0/8 drop) is accepted before the drop is evaluated
239
+ * 3. RFC1918 drops
240
+ * 4. cloud-metadata drop
241
+ * 5. `denyAll` drop
242
+ *
243
+ * {@link addIsolationRules} lands them in exactly this order by applying the
244
+ * list in REVERSE with `insert`, which puts the whole block at the chain head —
245
+ * ahead of the tail `jump` to {@link BASELINE_CHAIN}.
246
+ *
247
+ * @returns one token list per rule, in final head-to-tail order
248
+ */
249
+ export declare function buildIsolationRuleOps(matchClause: string[], gatewayIp: string, networkPolicy?: NetworkPolicy): string[][];
250
+ /**
251
+ * Add network isolation rules for one identifier (a TAP interface name, or a
252
+ * container's source IP).
253
+ *
254
+ * Every rule is `insert`ed, applying {@link buildIsolationRuleOps} in reverse,
255
+ * so the block lands at the chain head in its documented order — ahead of any
256
+ * host baseline reached by a tail `jump`.
257
+ *
258
+ * @param identifier - Network interface name or source IP to match (e.g., "veth-abc123" or "172.30.0.5")
259
+ * @param gatewayIp - Gateway IP that must remain accessible (e.g., "10.0.0.1")
260
+ * @param networkPolicy - Optional policy with allowlist and denyAll settings
261
+ * @param matchMode - How to match traffic: 'iifname' for interface name (default), 'saddr' for source IP
262
+ */
263
+ export declare function addIsolationRules(identifier: string, gatewayIp: string, networkPolicy?: NetworkPolicy, matchMode?: NftMatchMode, opts?: NftOptions): Promise<void>;
264
+ /**
265
+ * A table's `forward` chain, as `nft -a list chain` prints it, or `null` when
266
+ * the table or the chain does not exist yet.
267
+ *
268
+ * The text form is what {@link parseRuleHandles} reads, and it is also what a
269
+ * caller asking whether a rule set is ALREADY installed — rather than deleting
270
+ * it — compares against the rule text {@link buildIsolationRuleOps} emits.
271
+ * {@link listForwardRules} cannot answer for a subnet identifier: nft reports a
272
+ * subnet source as a prefix object, which its classifier treats as "not a
273
+ * per-agent rule" on purpose.
274
+ *
275
+ * Returning `null` rather than throwing keeps "the chain is not there" distinct
276
+ * from "the chain is there and holds nothing", exactly as {@link readInputChain}
277
+ * does for the sibling chain. The distinction is load-bearing in the install
278
+ * direction: a caller checking whether a rule set is present must read a missing
279
+ * chain as NOT installed and go install it, and a throw instead aborts the whole
280
+ * ensure path — which for the agent's job network degrades to a container on an
281
+ * unfiltered bridge.
282
+ */
283
+ export declare function readForwardChain(opts?: NftOptions): Promise<string | null>;
284
+ /**
285
+ * Remove every isolation rule this identifier owns, in both the `forward` and
286
+ * the `input` chain. Called during agent cleanup, and as the pre-clean before a
287
+ * re-add.
288
+ *
289
+ * Both chains are swept because they are one boundary: an identifier is a
290
+ * container IP or a TAP name, addresses are recycled, and leaving the input
291
+ * chain's accepts behind hands the next tenant on that address the previous
292
+ * one's host reachability.
293
+ *
294
+ * Best-effort: logs errors but does not throw (cleanup must not block
295
+ * destruction).
296
+ *
297
+ * @param interfaceName - Network interface name or source IP whose rules should be removed
298
+ */
299
+ export declare function removeIsolationRules(interfaceName: string, opts?: NftOptions): Promise<void>;
300
+ /**
301
+ * Re-export NftOptions type for use by callers (Firecracker / container backends)
302
+ * that need to pass the requireSudo flag through to these functions.
303
+ */
304
+ export type { NftOptions };
305
+ /** One rule in the forward chain, as reported by `nft -j -a list chain`. */
306
+ export interface NftForwardRule {
307
+ /** nft rule handle, for `nft delete rule … handle N`. */
308
+ handle: number;
309
+ /**
310
+ * The per-identifier value this rule matches on — a concrete source IP or a
311
+ * concrete interface name — or `null` for a rule that is not per-identifier
312
+ * (a host baseline rule matching a whole subnet or a `kici-*` wildcard, or a
313
+ * `jump`).
314
+ */
315
+ identifier: string | null;
316
+ }
317
+ /**
318
+ * Read every rule in a table's forward chain, with its handle and the per-agent
319
+ * identifier it matches on.
320
+ *
321
+ * This is the enumerate-what-is-there half of rule management; the
322
+ * identifier-known delete path is {@link parseRuleHandles}. Reconciliation
323
+ * needs this one: rules are removed only on the synchronous teardown paths this
324
+ * process drives, so an orchestrator crash, a `kill -9`, or a VM that dies
325
+ * while the orchestrator is down strands `ip saddr <ip> …` rules in the shared
326
+ * chain forever. The allocator then hands that IP to another tenant, who
327
+ * inherits the dead job's allowlist.
328
+ *
329
+ * Returns an empty list rather than throwing when the chain cannot be read —
330
+ * the caller is a best-effort sweep, and a failed read must not be mistaken
331
+ * for "nothing is there".
332
+ */
333
+ export declare function listForwardRules(opts?: NftOptions): Promise<NftForwardRule[]>;
334
+ /**
335
+ * Every per-agent identifier currently present in the forward chain, mapped to
336
+ * the handles of the rules that carry it.
337
+ */
338
+ export declare function listIsolationRules(opts?: NftOptions): Promise<Map<string, number[]>>;
339
+ /**
340
+ * Delete rules from a table's forward chain by handle, highest first so earlier
341
+ * deletions cannot shift the handles still to come.
342
+ *
343
+ * Best-effort per handle: a rule another path already removed must not abort
344
+ * the rest of the sweep.
345
+ */
346
+ export declare function deleteForwardRules(handles: number[], opts?: NftOptions): Promise<number>;
347
+ /**
348
+ * Parse nft rule listing output and extract handles for rules matching a given identifier.
349
+ * Handles lines like: ` iifname "veth-abc" ip daddr 10.0.0.0/8 drop # handle 42`
350
+ *
351
+ * @param nftOutput - Raw output from `nft -a list chain ip kici <chain>`
352
+ * @param identifier - String to search for in each rule line (interface name or UID)
353
+ * @returns Array of numeric rule handles
354
+ */
355
+ export declare function parseRuleHandles(nftOutput: string, identifier: string): number[];
356
+ //# sourceMappingURL=nftables.d.ts.map
@@ -0,0 +1,587 @@
1
+ import "../rolldown-runtime-ClRpJifh.js";
2
+ import { createLogger, toErrorMessage } from "@kici-dev/core";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ //#region src/net/nftables.ts
6
+ /**
7
+ * Programmatic nftables rule management for agent network isolation.
8
+ *
9
+ * Manages RFC1918 + cloud metadata blocking rules per-interface (Firecracker/container)
10
+ * or per-UID (bare-metal). All operations use `nft` CLI via child_process.execFile.
11
+ *
12
+ * Table layout:
13
+ * table ip kici {
14
+ * chain forward { type filter hook forward priority 0; policy accept; }
15
+ * chain input { type filter hook input priority 0; policy accept; }
16
+ * chain output { type filter hook output priority 0; policy accept; }
17
+ * }
18
+ *
19
+ * `forward` and `input` answer different questions and both are needed. A
20
+ * packet a sandbox sends to one of the host's OWN addresses — a bridge gateway,
21
+ * the host's LAN address — is delivered on the input hook and never traverses
22
+ * forward, so a forward rule cannot see it. `forward` governs what a sandbox
23
+ * reaches THROUGH the host; `input` governs what it reaches ON the host.
24
+ */
25
+ /** Transport protocols a port-scoped host-access accept is emitted for. */
26
+ const HOST_ACCESS_PROTOCOLS = ["tcp", "udp"];
27
+ /** An IPv4 dotted quad, optionally with a CIDR prefix length. */
28
+ const IPV4_OR_CIDR_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/;
29
+ /** True when every octet is in range and the prefix length, if any, is 0-32. */
30
+ function isIpv4OrCidr(value) {
31
+ const match = IPV4_OR_CIDR_RE.exec(value);
32
+ if (!match) return false;
33
+ for (let i = 1; i <= 4; i++) {
34
+ const octet = Number(match[i]);
35
+ if (!Number.isInteger(octet) || octet > 255) return false;
36
+ }
37
+ if (match[5] !== void 0) {
38
+ const prefix = Number(match[5]);
39
+ if (!Number.isInteger(prefix) || prefix > 32) return false;
40
+ }
41
+ return true;
42
+ }
43
+ /**
44
+ * Parse one host-access entry.
45
+ *
46
+ * Grammar: `<cidr|address|*>` optionally followed by `:<port|*>`. A bare number
47
+ * is a port on any host address, which is the common case — an operator naming
48
+ * a host-local registry mirror knows its port, not the host's dynamic
49
+ * addresses.
50
+ *
51
+ * Hostnames are rejected on purpose: nftables matches addresses, so resolving a
52
+ * name at rule-build time produces a rule that goes stale silently the next
53
+ * time the name moves.
54
+ *
55
+ * @throws Error naming the entry and what is wrong with it
56
+ */
57
+ function parseHostAccess(entry) {
58
+ const trimmed = entry.trim();
59
+ if (trimmed.length === 0) throw new Error("hostAccess entry is empty");
60
+ if (/^\d+$/.test(trimmed)) return {
61
+ daddr: null,
62
+ port: parseHostAccessPort(trimmed, entry)
63
+ };
64
+ const colon = trimmed.lastIndexOf(":");
65
+ const addressPart = colon === -1 ? trimmed : trimmed.slice(0, colon);
66
+ const portPart = colon === -1 ? null : trimmed.slice(colon + 1);
67
+ if (addressPart !== "*" && !isIpv4OrCidr(addressPart)) throw new Error(`hostAccess entry "${entry}" names "${addressPart}", which is not an IPv4 address, an IPv4 CIDR, or "*". Hostnames are not accepted — nftables matches addresses, and a name resolved at rule-build time goes stale without warning.`);
68
+ const port = portPart === null || portPart === "*" ? null : parseHostAccessPort(portPart, entry);
69
+ return {
70
+ daddr: addressPart === "*" ? null : addressPart,
71
+ port
72
+ };
73
+ }
74
+ /** Parse and range-check the port half of a host-access entry. */
75
+ function parseHostAccessPort(value, entry) {
76
+ if (!/^\d+$/.test(value)) throw new Error(`hostAccess entry "${entry}" has a non-numeric port "${value}"`);
77
+ const port = Number(value);
78
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`hostAccess entry "${entry}" has port ${value}, outside 1-65535`);
79
+ return port;
80
+ }
81
+ /**
82
+ * Build the host-access rules for one identifier, in final head-to-tail order:
83
+ * the conntrack exception, every accept, then one terminal drop.
84
+ *
85
+ * The conntrack rule leads because the chain is keyed on the sandbox as the
86
+ * SOURCE, so it also sees the reply leg of a connection the HOST opened toward
87
+ * the sandbox — a readiness probe, a metrics scrape. Without the exception
88
+ * those replies fall through to the terminal drop and the host's own connection
89
+ * dies as an opaque timeout. It widens nothing a sandbox can initiate:
90
+ * `established` is reached only by a flow whose first packet was already
91
+ * accepted, so a sandbox connecting to a port with no accept still has its SYN
92
+ * dropped and never reaches that state.
93
+ *
94
+ * A port-scoped entry emits an accept per protocol in
95
+ * {@link HOST_ACCESS_PROTOCOLS}. UDP is not optional: a container whose
96
+ * resolver is the bridge gateway — which is what rootful podman with
97
+ * aardvark-dns gives it — resolves over UDP, so a tcp-only accept on port 53
98
+ * leaves it unable to resolve any name.
99
+ *
100
+ * @returns one token list per rule, in final head-to-tail order
101
+ */
102
+ function buildHostAccessRuleOps(matchClause, hostAccess) {
103
+ const rules = [[
104
+ ...matchClause,
105
+ "ct",
106
+ "state",
107
+ "established,related",
108
+ "accept"
109
+ ]];
110
+ for (const entry of hostAccess) {
111
+ const { daddr, port } = parseHostAccess(entry);
112
+ const scoped = daddr === null ? matchClause : [
113
+ ...matchClause,
114
+ "ip",
115
+ "daddr",
116
+ daddr
117
+ ];
118
+ if (port === null) {
119
+ rules.push([...scoped, "accept"]);
120
+ continue;
121
+ }
122
+ for (const protocol of HOST_ACCESS_PROTOCOLS) rules.push([
123
+ ...scoped,
124
+ protocol,
125
+ "dport",
126
+ String(port),
127
+ "accept"
128
+ ]);
129
+ }
130
+ rules.push([...matchClause, "drop"]);
131
+ return rules;
132
+ }
133
+ const execFile$1 = promisify(execFile);
134
+ const logger = createLogger({ prefix: "nftables" });
135
+ /** Timeout for nft commands in milliseconds. */
136
+ const NFT_TIMEOUT_MS = 1e4;
137
+ /** RFC1918 private address ranges. */
138
+ const RFC1918_RANGES = [
139
+ "10.0.0.0/8",
140
+ "172.16.0.0/12",
141
+ "192.168.0.0/16"
142
+ ];
143
+ /** Cloud metadata service range (AWS/GCP/Azure link-local). */
144
+ const METADATA_RANGE = "169.254.0.0/16";
145
+ /**
146
+ * Subnet of the agent's `kici-jobs` bridge, on which the agent keys ONE drop set
147
+ * covering every nested job container — including one that does not exist yet.
148
+ *
149
+ * It lives here rather than beside the agent's own network constants because
150
+ * the Firecracker host provisioner has to recognise those rules to leave them
151
+ * alone, and the orchestrator carries `@kici-dev/agent` only as a devDependency.
152
+ * The agent module re-exports it, so its own call sites are unchanged.
153
+ */
154
+ const JOB_NETWORK_SUBNET = "172.31.0.0/16";
155
+ /** Table used when {@link NftOptions.table} is not set. */
156
+ const DEFAULT_NFT_TABLE = "kici";
157
+ /** The configured table, or the default. */
158
+ function tableOf(opts) {
159
+ return opts.table ?? "kici";
160
+ }
161
+ /**
162
+ * Execute an nft command with timeout.
163
+ * @returns stdout from the command
164
+ * @throws Error on non-zero exit or timeout
165
+ */
166
+ async function nft(opts, ...args) {
167
+ const { stdout } = opts.requireSudo === true ? await execFile$1("sudo", [
168
+ "-n",
169
+ "nft",
170
+ ...args
171
+ ], { timeout: NFT_TIMEOUT_MS }) : await execFile$1("nft", args, { timeout: NFT_TIMEOUT_MS });
172
+ return stdout;
173
+ }
174
+ /**
175
+ * Validate that nftables is available and the process has NET_ADMIN capability.
176
+ * Attempts `nft list tables` -- if it fails:
177
+ * - ENOENT: nft binary not installed
178
+ * - EPERM: nft binary present but NET_ADMIN capability missing
179
+ * Throws with a clear error message in both cases.
180
+ */
181
+ async function validateNftablesAvailability(opts = {}) {
182
+ try {
183
+ await nft(opts, "list", "tables");
184
+ } catch (err) {
185
+ const message = toErrorMessage(err);
186
+ if (message.includes("ENOENT") || message.includes("not found")) throw new Error("nftables binary not found at /usr/sbin/nft. The orchestrator container image must include nftables (apk add nftables). Network isolation for agents cannot be established without nftables.");
187
+ if (message.includes("EPERM") || message.includes("Operation not permitted")) throw new Error("nftables operation denied -- missing NET_ADMIN capability. Start the orchestrator container with --cap-add=NET_ADMIN. Network isolation for agents requires this capability.");
188
+ throw new Error(`nftables validation failed: ${message}`);
189
+ }
190
+ }
191
+ /**
192
+ * Ensure the nftables table and the chains this module writes to exist.
193
+ * Idempotent -- safe to call multiple times.
194
+ *
195
+ * Every chain is verified individually. A bare "does the table exist?" check is
196
+ * not enough: the Firecracker host provisioner creates the table before this
197
+ * module ever runs, so a table with no `forward` chain satisfied the old early
198
+ * return — and then every `addIsolationRules` failed with nft's "No such file
199
+ * or directory", leaving every VM on that host with no isolation rules at all.
200
+ *
201
+ * @param opts - `table` selects the table; `requireBaselineChain` additionally
202
+ * ensures the regular {@link BASELINE_CHAIN} exists (Firecracker hosts, whose
203
+ * `forward` chain ends in a jump to it).
204
+ */
205
+ async function ensureKiciTable(opts = {}) {
206
+ const table = tableOf(opts);
207
+ await nft(opts, "add", "table", "ip", table);
208
+ await nft(opts, "add", "chain", "ip", table, "forward", "{ type filter hook forward priority 0; policy accept; }");
209
+ await ensureKiciInputChain(opts);
210
+ await nft(opts, "add", "chain", "ip", table, "output", "{ type filter hook output priority 0; policy accept; }");
211
+ if (opts.requireBaselineChain === true) await nft(opts, "add", "chain", "ip", table, BASELINE_CHAIN);
212
+ logger.debug(`nftables table ip ${table} and chains ready`);
213
+ }
214
+ /** Chain in the kici table that filters host-destined sandbox traffic. */
215
+ const INPUT_CHAIN = "input";
216
+ /**
217
+ * Ensure the kici table and its `input` chain exist. Idempotent — `nft add` is
218
+ * a create-or-noop for both, so there is no check-then-create window.
219
+ *
220
+ * The chain's policy is `accept` because it is a base chain on the host's own
221
+ * input hook: everything the host itself receives passes through it, and a
222
+ * default-deny there would take the machine off the network. The deny lives in
223
+ * the per-identifier terminal drop {@link buildHostAccessRuleOps} emits.
224
+ */
225
+ async function ensureKiciInputChain(opts = {}) {
226
+ const table = tableOf(opts);
227
+ await nft(opts, "add", "table", "ip", table);
228
+ await nft(opts, "add", "chain", "ip", table, INPUT_CHAIN, "{ type filter hook input priority 0; policy accept; }");
229
+ }
230
+ /**
231
+ * Apply one identifier's host-access rules to the `input` chain, replacing
232
+ * whatever it had.
233
+ *
234
+ * The pre-clean is not an optimisation. Bridge networks recycle addresses, so a
235
+ * crash or a `kill -9` leaves the previous holder's accepts behind for the next
236
+ * container on that IP to inherit — which is the boundary this chain exists to
237
+ * hold. Applying without removing first would also stack a second terminal drop
238
+ * above the first run's accepts, shadowing every one of them.
239
+ *
240
+ * Rules are `insert`ed in reverse so the block lands at the chain head in the
241
+ * order {@link buildHostAccessRuleOps} returns: accepts first, terminal drop
242
+ * last. Inserting forwards would put the drop above the accepts and deny
243
+ * everything.
244
+ */
245
+ async function addHostIsolationRules(identifier, hostAccess, matchMode = "saddr", opts = {}) {
246
+ await ensureKiciInputChain(opts);
247
+ await removeHostIsolationRules(identifier, opts);
248
+ const rules = buildHostAccessRuleOps(matchMode === "iifname" ? ["iifname", identifier] : [
249
+ "ip",
250
+ "saddr",
251
+ identifier
252
+ ], hostAccess);
253
+ for (const tokens of [...rules].reverse()) await nft(opts, "insert", "rule", "ip", tableOf(opts), INPUT_CHAIN, ...tokens);
254
+ logger.info(`Host-access rules applied for ${identifier}: ${hostAccess.length > 0 ? hostAccess.join(", ") : "nothing (host fully denied)"}`);
255
+ }
256
+ /**
257
+ * A table's `input` chain, as `nft -a list chain` prints it, or `null` when the
258
+ * table or the chain does not exist yet.
259
+ *
260
+ * Returning `null` rather than throwing keeps "the chain is not there" distinct
261
+ * from "the chain is there and holds nothing", which a caller deciding whether
262
+ * a rule set is already installed has to be able to tell apart.
263
+ */
264
+ async function readInputChain(opts = {}) {
265
+ try {
266
+ return await nft(opts, "-a", "list", "chain", "ip", tableOf(opts), INPUT_CHAIN);
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+ /**
272
+ * Remove one identifier's rules from the `input` chain.
273
+ *
274
+ * Best-effort: a missing chain, or a rule another path already deleted, must
275
+ * not abort a teardown.
276
+ */
277
+ async function removeHostIsolationRules(identifier, opts = {}) {
278
+ const table = tableOf(opts);
279
+ const output = await readInputChain(opts);
280
+ if (output === null) return;
281
+ const handles = parseRuleHandles(output, identifier);
282
+ if (handles.length === 0) return;
283
+ for (const handle of handles.sort((a, b) => b - a)) try {
284
+ await nft(opts, "delete", "rule", "ip", table, INPUT_CHAIN, "handle", String(handle));
285
+ } catch (err) {
286
+ logger.warn(`Failed to delete input rule handle ${handle}: ${toErrorMessage(err)}`);
287
+ }
288
+ logger.info(`Removed ${handles.length} host-access rules for ${identifier}`);
289
+ }
290
+ /**
291
+ * Name of the regular (non-hooked) chain holding the host baseline rules.
292
+ *
293
+ * The Firecracker host provisioner puts its six source-scoped baseline rules
294
+ * here and reaches them with a `jump` appended as the `forward` chain's LAST
295
+ * rule. Two properties follow, and both are load-bearing:
296
+ *
297
+ * - **Per-VM rules always win.** They are inserted at the `forward` head, so
298
+ * every one of them is evaluated before the jump. A per-VM `accept`
299
+ * terminates the hook before the baseline can re-drop an allowlisted
300
+ * destination, and a per-VM `denyAll` drop is terminal before the
301
+ * baseline's blanket internet `accept` can let the packet out.
302
+ * - **A self-heal can rebuild the baseline without touching live VMs.** The
303
+ * provisioner flushes and refills only this chain, so it never has to
304
+ * `delete table` — which used to drop every running VM's isolation rules
305
+ * fail-open.
306
+ *
307
+ * A regular chain reached by `jump` rather than a second base chain at a lower
308
+ * priority: in netfilter an `accept` ends only the current base chain, so an
309
+ * allowlist accept in an earlier base chain would still be re-evaluated — and
310
+ * dropped — by the baseline's 10.0.0.0/8 rule in the later one.
311
+ */
312
+ const BASELINE_CHAIN = "baseline";
313
+ /**
314
+ * Build the per-identifier isolation rules in their final head-to-tail order.
315
+ *
316
+ * nftables is first-match-wins within a chain and `accept` is terminal, so the
317
+ * order below is the whole security property:
318
+ *
319
+ * 1. gateway accept
320
+ * 2. allowlisted CIDR accepts — ahead of the drops, so an allowlisted
321
+ * destination inside a dropped range (a 10.x registry endpoint behind the
322
+ * 10.0.0.0/8 drop) is accepted before the drop is evaluated
323
+ * 3. RFC1918 drops
324
+ * 4. cloud-metadata drop
325
+ * 5. `denyAll` drop
326
+ *
327
+ * {@link addIsolationRules} lands them in exactly this order by applying the
328
+ * list in REVERSE with `insert`, which puts the whole block at the chain head —
329
+ * ahead of the tail `jump` to {@link BASELINE_CHAIN}.
330
+ *
331
+ * @returns one token list per rule, in final head-to-tail order
332
+ */
333
+ function buildIsolationRuleOps(matchClause, gatewayIp, networkPolicy) {
334
+ const rules = [];
335
+ rules.push([
336
+ ...matchClause,
337
+ "ip",
338
+ "daddr",
339
+ gatewayIp,
340
+ "accept"
341
+ ]);
342
+ for (const cidr of networkPolicy?.allowlist ?? []) rules.push([
343
+ ...matchClause,
344
+ "ip",
345
+ "daddr",
346
+ cidr,
347
+ "accept"
348
+ ]);
349
+ for (const range of RFC1918_RANGES) rules.push([
350
+ ...matchClause,
351
+ "ip",
352
+ "daddr",
353
+ range,
354
+ "drop"
355
+ ]);
356
+ rules.push([
357
+ ...matchClause,
358
+ "ip",
359
+ "daddr",
360
+ METADATA_RANGE,
361
+ "drop"
362
+ ]);
363
+ if (networkPolicy?.denyAll) rules.push([...matchClause, "drop"]);
364
+ return rules;
365
+ }
366
+ /**
367
+ * Add network isolation rules for one identifier (a TAP interface name, or a
368
+ * container's source IP).
369
+ *
370
+ * Every rule is `insert`ed, applying {@link buildIsolationRuleOps} in reverse,
371
+ * so the block lands at the chain head in its documented order — ahead of any
372
+ * host baseline reached by a tail `jump`.
373
+ *
374
+ * @param identifier - Network interface name or source IP to match (e.g., "veth-abc123" or "172.30.0.5")
375
+ * @param gatewayIp - Gateway IP that must remain accessible (e.g., "10.0.0.1")
376
+ * @param networkPolicy - Optional policy with allowlist and denyAll settings
377
+ * @param matchMode - How to match traffic: 'iifname' for interface name (default), 'saddr' for source IP
378
+ */
379
+ async function addIsolationRules(identifier, gatewayIp, networkPolicy, matchMode = "iifname", opts = {}) {
380
+ const matchLabel = matchMode === "iifname" ? `interface ${identifier}` : `source IP ${identifier}`;
381
+ logger.info(`Adding isolation rules for ${matchLabel} (gateway: ${gatewayIp})`);
382
+ const matchClause = matchMode === "iifname" ? ["iifname", identifier] : [
383
+ "ip",
384
+ "saddr",
385
+ identifier
386
+ ];
387
+ if (networkPolicy?.allowlist) for (const cidr of networkPolicy.allowlist) logger.debug(`Allowlisting ${cidr} for ${matchLabel}`);
388
+ if (networkPolicy?.denyAll) logger.info(`Blocking all outbound traffic for ${matchLabel} (denyAll)`);
389
+ const rules = buildIsolationRuleOps(matchClause, gatewayIp, networkPolicy);
390
+ for (const tokens of [...rules].reverse()) await nft(opts, "insert", "rule", "ip", tableOf(opts), "forward", ...tokens);
391
+ logger.info(`Isolation rules applied for ${matchLabel}`);
392
+ }
393
+ /**
394
+ * A table's `forward` chain, as `nft -a list chain` prints it, or `null` when
395
+ * the table or the chain does not exist yet.
396
+ *
397
+ * The text form is what {@link parseRuleHandles} reads, and it is also what a
398
+ * caller asking whether a rule set is ALREADY installed — rather than deleting
399
+ * it — compares against the rule text {@link buildIsolationRuleOps} emits.
400
+ * {@link listForwardRules} cannot answer for a subnet identifier: nft reports a
401
+ * subnet source as a prefix object, which its classifier treats as "not a
402
+ * per-agent rule" on purpose.
403
+ *
404
+ * Returning `null` rather than throwing keeps "the chain is not there" distinct
405
+ * from "the chain is there and holds nothing", exactly as {@link readInputChain}
406
+ * does for the sibling chain. The distinction is load-bearing in the install
407
+ * direction: a caller checking whether a rule set is present must read a missing
408
+ * chain as NOT installed and go install it, and a throw instead aborts the whole
409
+ * ensure path — which for the agent's job network degrades to a container on an
410
+ * unfiltered bridge.
411
+ */
412
+ async function readForwardChain(opts = {}) {
413
+ try {
414
+ return await nft(opts, "-a", "list", "chain", "ip", tableOf(opts), "forward");
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+ /**
420
+ * Remove every isolation rule this identifier owns, in both the `forward` and
421
+ * the `input` chain. Called during agent cleanup, and as the pre-clean before a
422
+ * re-add.
423
+ *
424
+ * Both chains are swept because they are one boundary: an identifier is a
425
+ * container IP or a TAP name, addresses are recycled, and leaving the input
426
+ * chain's accepts behind hands the next tenant on that address the previous
427
+ * one's host reachability.
428
+ *
429
+ * Best-effort: logs errors but does not throw (cleanup must not block
430
+ * destruction).
431
+ *
432
+ * @param interfaceName - Network interface name or source IP whose rules should be removed
433
+ */
434
+ async function removeIsolationRules(interfaceName, opts = {}) {
435
+ logger.info(`Removing isolation rules for interface ${interfaceName}`);
436
+ await removeHostIsolationRules(interfaceName, opts);
437
+ try {
438
+ const table = tableOf(opts);
439
+ const output = await readForwardChain(opts);
440
+ if (output === null) return;
441
+ const handles = parseRuleHandles(output, interfaceName);
442
+ if (handles.length === 0) {
443
+ logger.debug(`No rules found for interface ${interfaceName}`);
444
+ return;
445
+ }
446
+ for (const handle of handles.sort((a, b) => b - a)) try {
447
+ await nft(opts, "delete", "rule", "ip", table, "forward", "handle", String(handle));
448
+ } catch (err) {
449
+ logger.warn(`Failed to delete rule handle ${handle}: ${err}`);
450
+ }
451
+ logger.info(`Removed ${handles.length} rules for interface ${interfaceName}`);
452
+ } catch (err) {
453
+ logger.warn(`Failed to list/remove rules for ${interfaceName}: ${err}`);
454
+ }
455
+ }
456
+ /**
457
+ * Read every rule in a table's forward chain, with its handle and the per-agent
458
+ * identifier it matches on.
459
+ *
460
+ * This is the enumerate-what-is-there half of rule management; the
461
+ * identifier-known delete path is {@link parseRuleHandles}. Reconciliation
462
+ * needs this one: rules are removed only on the synchronous teardown paths this
463
+ * process drives, so an orchestrator crash, a `kill -9`, or a VM that dies
464
+ * while the orchestrator is down strands `ip saddr <ip> …` rules in the shared
465
+ * chain forever. The allocator then hands that IP to another tenant, who
466
+ * inherits the dead job's allowlist.
467
+ *
468
+ * Returns an empty list rather than throwing when the chain cannot be read —
469
+ * the caller is a best-effort sweep, and a failed read must not be mistaken
470
+ * for "nothing is there".
471
+ */
472
+ async function listForwardRules(opts = {}) {
473
+ const table = tableOf(opts);
474
+ let parsed;
475
+ try {
476
+ const output = await nft(opts, "-j", "-a", "list", "chain", "ip", table, "forward");
477
+ parsed = JSON.parse(output);
478
+ } catch (err) {
479
+ logger.warn(`Failed to list forward rules in ip ${table}: ${toErrorMessage(err)}`);
480
+ return [];
481
+ }
482
+ const entries = parsed?.nftables;
483
+ if (!Array.isArray(entries)) return [];
484
+ const rules = [];
485
+ for (const entry of entries) {
486
+ const rule = entry.rule;
487
+ if (!rule || typeof rule.handle !== "number") continue;
488
+ rules.push({
489
+ handle: rule.handle,
490
+ identifier: identifierOf(rule.expr)
491
+ });
492
+ }
493
+ return rules;
494
+ }
495
+ /**
496
+ * The concrete per-agent identifier a rule's expression list matches on.
497
+ *
498
+ * A per-agent rule matches a single host — `ip saddr 10.0.0.5` — or one exact
499
+ * interface — `iifname "kici-a1b2c3d4"`. A host baseline rule matches a whole
500
+ * subnet, which nft reports as a `{ prefix: … }` object, and every one of them
501
+ * also names the `kici-*` interface wildcard. That difference is the only thing
502
+ * separating "a live agent owns this" from "the host installed this".
503
+ *
504
+ * The whole rule is scanned before answering, and a wildcard anywhere in it
505
+ * settles the question. Position is why: the host's inbound established/related
506
+ * rule LEADS with `iifname "<egress iface>"` — a concrete name carrying no `*`
507
+ * — and names the wildcard only later, in `oifname`. Returning on the first
508
+ * concrete interface therefore claimed that rule for a nonexistent agent named
509
+ * after the host NIC, so the provisioning sweep spared it and every re-provision
510
+ * left another stale copy behind.
511
+ */
512
+ function identifierOf(expr) {
513
+ if (!Array.isArray(expr)) return null;
514
+ let candidate = null;
515
+ for (const node of expr) {
516
+ const match = node.match;
517
+ if (!match) continue;
518
+ const left = match.left;
519
+ const right = match.right;
520
+ if (typeof right !== "string") {
521
+ if (left?.payload?.field === "saddr") return null;
522
+ continue;
523
+ }
524
+ if (right.includes("*")) return null;
525
+ if (candidate !== null) continue;
526
+ if (left?.payload?.field === "saddr") candidate = right;
527
+ else if (left?.meta?.key === "iifname") candidate = right;
528
+ }
529
+ return candidate;
530
+ }
531
+ /**
532
+ * Every per-agent identifier currently present in the forward chain, mapped to
533
+ * the handles of the rules that carry it.
534
+ */
535
+ async function listIsolationRules(opts = {}) {
536
+ const byIdentifier = /* @__PURE__ */ new Map();
537
+ for (const rule of await listForwardRules(opts)) {
538
+ if (rule.identifier === null) continue;
539
+ const handles = byIdentifier.get(rule.identifier);
540
+ if (handles) handles.push(rule.handle);
541
+ else byIdentifier.set(rule.identifier, [rule.handle]);
542
+ }
543
+ return byIdentifier;
544
+ }
545
+ /**
546
+ * Delete rules from a table's forward chain by handle, highest first so earlier
547
+ * deletions cannot shift the handles still to come.
548
+ *
549
+ * Best-effort per handle: a rule another path already removed must not abort
550
+ * the rest of the sweep.
551
+ */
552
+ async function deleteForwardRules(handles, opts = {}) {
553
+ const table = tableOf(opts);
554
+ let deleted = 0;
555
+ for (const handle of [...handles].sort((a, b) => b - a)) try {
556
+ await nft(opts, "delete", "rule", "ip", table, "forward", "handle", String(handle));
557
+ deleted++;
558
+ } catch (err) {
559
+ logger.warn(`Failed to delete rule handle ${handle} in ip ${table}: ${toErrorMessage(err)}`);
560
+ }
561
+ return deleted;
562
+ }
563
+ /**
564
+ * Parse nft rule listing output and extract handles for rules matching a given identifier.
565
+ * Handles lines like: ` iifname "veth-abc" ip daddr 10.0.0.0/8 drop # handle 42`
566
+ *
567
+ * @param nftOutput - Raw output from `nft -a list chain ip kici <chain>`
568
+ * @param identifier - String to search for in each rule line (interface name or UID)
569
+ * @returns Array of numeric rule handles
570
+ */
571
+ function parseRuleHandles(nftOutput, identifier) {
572
+ const handles = [];
573
+ const lines = nftOutput.split("\n");
574
+ const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
575
+ const boundaryRe = new RegExp(`(^|[\\s"])${escaped}([\\s"]|$)`);
576
+ for (const line of lines) {
577
+ if (!line.includes("# handle")) continue;
578
+ if (!boundaryRe.test(line)) continue;
579
+ const match = line.match(/# handle (\d+)/);
580
+ if (match) handles.push(parseInt(match[1], 10));
581
+ }
582
+ return handles;
583
+ }
584
+ //#endregion
585
+ export { BASELINE_CHAIN, DEFAULT_NFT_TABLE, INPUT_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, deleteForwardRules, ensureKiciInputChain, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, readInputChain, removeHostIsolationRules, removeIsolationRules, validateNftablesAvailability };
586
+
587
+ //# sourceMappingURL=nftables.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=nftables.test.d.ts.map
package/dist/net.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Node-only network primitives, kept off the main barrel.
3
+ *
4
+ * `nftables.ts` promisifies `child_process.execFile` at module scope, so
5
+ * exporting it from `index.ts` would pull `node:child_process` into the module
6
+ * graph of every `@kici-dev/shared` consumer — including ones that only wanted
7
+ * a logger. Importers ask for it explicitly: `@kici-dev/shared/net`.
8
+ */
9
+ export * from './net/nftables.js';
10
+ //# sourceMappingURL=net.d.ts.map
package/dist/net.js ADDED
@@ -0,0 +1,3 @@
1
+ import "./rolldown-runtime-ClRpJifh.js";
2
+ import { BASELINE_CHAIN, DEFAULT_NFT_TABLE, INPUT_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, deleteForwardRules, ensureKiciInputChain, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, readInputChain, removeHostIsolationRules, removeIsolationRules, validateNftablesAvailability } from "./net/nftables.js";
3
+ export { BASELINE_CHAIN, DEFAULT_NFT_TABLE, INPUT_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, deleteForwardRules, ensureKiciInputChain, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, readInputChain, removeHostIsolationRules, removeIsolationRules, validateNftablesAvailability };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kici-dev/shared",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Shared utilities for the KiCI CI/CD stack — logging, zx setup, crypto, telemetry, health and metrics routes. No business logic.",
5
5
  "keywords": [
6
6
  "ci",
@@ -93,6 +93,10 @@
93
93
  "./container-runtime": {
94
94
  "import": "./dist/container-runtime.js",
95
95
  "types": "./dist/container-runtime.d.ts"
96
+ },
97
+ "./net": {
98
+ "import": "./dist/net.js",
99
+ "types": "./dist/net.d.ts"
96
100
  }
97
101
  },
98
102
  "dependencies": {
@@ -117,8 +121,8 @@
117
121
  "yaml": "^2.9.0",
118
122
  "zod": "^4.4.3",
119
123
  "zx": "^8.8.5",
120
- "@kici-dev/core": "0.6.1",
121
- "@kici-dev/engine": "0.6.1"
124
+ "@kici-dev/core": "0.7.0",
125
+ "@kici-dev/engine": "0.7.0"
122
126
  },
123
127
  "devDependencies": {
124
128
  "@opentelemetry/sdk-trace-base": "^2.10.0",
package/sbom.spdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@kici-dev/shared@0.6.1",
6
- "documentNamespace": "https://kici.dev/sbom/%40kici-dev%2Fshared/0.6.1/db48b18d-53cd-4d58-81a0-e1d8e98d6c91",
5
+ "name": "@kici-dev/shared@0.7.0",
6
+ "documentNamespace": "https://kici.dev/sbom/%40kici-dev%2Fshared/0.7.0/b2f87154-22b3-4a53-b9e7-67876075dc20",
7
7
  "creationInfo": {
8
- "created": "2026-09-02T03:01:10Z",
8
+ "created": "2026-09-11T07:37:23Z",
9
9
  "creators": [
10
10
  "Tool: kici-sbom-generator"
11
11
  ]
@@ -564,9 +564,9 @@
564
564
  "homepage": "https://ericsmekens.github.io/jsep/tree/master/packages/regex#readme"
565
565
  },
566
566
  {
567
- "SPDXID": "SPDXRef-Package--kici-dev-core-0.6.1",
567
+ "SPDXID": "SPDXRef-Package--kici-dev-core-0.7.0",
568
568
  "name": "@kici-dev/core",
569
- "versionInfo": "0.6.1",
569
+ "versionInfo": "0.7.0",
570
570
  "downloadLocation": "NOASSERTION",
571
571
  "filesAnalyzed": false,
572
572
  "licenseConcluded": "NOASSERTION",
@@ -577,16 +577,16 @@
577
577
  {
578
578
  "referenceCategory": "PACKAGE-MANAGER",
579
579
  "referenceType": "purl",
580
- "referenceLocator": "pkg:npm/%40kici-dev/core@0.6.1"
580
+ "referenceLocator": "pkg:npm/%40kici-dev/core@0.7.0"
581
581
  }
582
582
  ],
583
583
  "description": "Light shared utilities for the KiCI stack (logging, errors, formatting, crypto, zx init, the TypeScript ESM loader hook). No server-side dependencies.",
584
584
  "homepage": "https://kici.dev"
585
585
  },
586
586
  {
587
- "SPDXID": "SPDXRef-Package--kici-dev-engine-0.6.1",
587
+ "SPDXID": "SPDXRef-Package--kici-dev-engine-0.7.0",
588
588
  "name": "@kici-dev/engine",
589
- "versionInfo": "0.6.1",
589
+ "versionInfo": "0.7.0",
590
590
  "downloadLocation": "NOASSERTION",
591
591
  "filesAnalyzed": false,
592
592
  "licenseConcluded": "NOASSERTION",
@@ -597,7 +597,7 @@
597
597
  {
598
598
  "referenceCategory": "PACKAGE-MANAGER",
599
599
  "referenceType": "purl",
600
- "referenceLocator": "pkg:npm/%40kici-dev/engine@0.6.1"
600
+ "referenceLocator": "pkg:npm/%40kici-dev/engine@0.7.0"
601
601
  }
602
602
  ],
603
603
  "description": "Shared business logic for the KiCI CI/CD stack: protocol, triggers, state machine, and provider interfaces used by the Platform relay, orchestrator, and compiler.",
@@ -606,7 +606,7 @@
606
606
  {
607
607
  "SPDXID": "SPDXRef-RootPackage",
608
608
  "name": "@kici-dev/shared",
609
- "versionInfo": "0.6.1",
609
+ "versionInfo": "0.7.0",
610
610
  "downloadLocation": "NOASSERTION",
611
611
  "filesAnalyzed": false,
612
612
  "licenseConcluded": "NOASSERTION",
@@ -617,7 +617,7 @@
617
617
  {
618
618
  "referenceCategory": "PACKAGE-MANAGER",
619
619
  "referenceType": "purl",
620
- "referenceLocator": "pkg:npm/%40kici-dev/shared@0.6.1"
620
+ "referenceLocator": "pkg:npm/%40kici-dev/shared@0.7.0"
621
621
  }
622
622
  ],
623
623
  "description": "Shared utilities for the KiCI CI/CD stack — logging, zx setup, crypto, telemetry, health and metrics routes. No business logic.",
@@ -4896,62 +4896,62 @@
4896
4896
  "relationshipType": "DEPENDS_ON"
4897
4897
  },
4898
4898
  {
4899
- "spdxElementId": "SPDXRef-Package--kici-dev-core-0.6.1",
4899
+ "spdxElementId": "SPDXRef-Package--kici-dev-core-0.7.0",
4900
4900
  "relatedSpdxElement": "SPDXRef-Package-oxc-transform-0.140.0",
4901
4901
  "relationshipType": "DEPENDS_ON"
4902
4902
  },
4903
4903
  {
4904
- "spdxElementId": "SPDXRef-Package--kici-dev-core-0.6.1",
4904
+ "spdxElementId": "SPDXRef-Package--kici-dev-core-0.7.0",
4905
4905
  "relatedSpdxElement": "SPDXRef-Package-picocolors-1.1.1",
4906
4906
  "relationshipType": "DEPENDS_ON"
4907
4907
  },
4908
4908
  {
4909
- "spdxElementId": "SPDXRef-Package--kici-dev-core-0.6.1",
4909
+ "spdxElementId": "SPDXRef-Package--kici-dev-core-0.7.0",
4910
4910
  "relatedSpdxElement": "SPDXRef-Package-winston-daily-rotate-file-5.0.0",
4911
4911
  "relationshipType": "DEPENDS_ON"
4912
4912
  },
4913
4913
  {
4914
- "spdxElementId": "SPDXRef-Package--kici-dev-core-0.6.1",
4914
+ "spdxElementId": "SPDXRef-Package--kici-dev-core-0.7.0",
4915
4915
  "relatedSpdxElement": "SPDXRef-Package-winston-3.19.0",
4916
4916
  "relationshipType": "DEPENDS_ON"
4917
4917
  },
4918
4918
  {
4919
- "spdxElementId": "SPDXRef-Package--kici-dev-core-0.6.1",
4919
+ "spdxElementId": "SPDXRef-Package--kici-dev-core-0.7.0",
4920
4920
  "relatedSpdxElement": "SPDXRef-Package-zod-4.4.3",
4921
4921
  "relationshipType": "DEPENDS_ON"
4922
4922
  },
4923
4923
  {
4924
- "spdxElementId": "SPDXRef-Package--kici-dev-core-0.6.1",
4924
+ "spdxElementId": "SPDXRef-Package--kici-dev-core-0.7.0",
4925
4925
  "relatedSpdxElement": "SPDXRef-Package-zx-8.8.5",
4926
4926
  "relationshipType": "DEPENDS_ON"
4927
4927
  },
4928
4928
  {
4929
- "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.6.1",
4929
+ "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.7.0",
4930
4930
  "relatedSpdxElement": "SPDXRef-Package-jose-6.2.10",
4931
4931
  "relationshipType": "DEPENDS_ON"
4932
4932
  },
4933
4933
  {
4934
- "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.6.1",
4934
+ "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.7.0",
4935
4935
  "relatedSpdxElement": "SPDXRef-Package-jsonpath-plus-10.4.0",
4936
4936
  "relationshipType": "DEPENDS_ON"
4937
4937
  },
4938
4938
  {
4939
- "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.6.1",
4939
+ "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.7.0",
4940
4940
  "relatedSpdxElement": "SPDXRef-Package-picomatch-4.0.7",
4941
4941
  "relationshipType": "DEPENDS_ON"
4942
4942
  },
4943
4943
  {
4944
- "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.6.1",
4944
+ "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.7.0",
4945
4945
  "relatedSpdxElement": "SPDXRef-Package-safe-regex-2.1.1",
4946
4946
  "relationshipType": "DEPENDS_ON"
4947
4947
  },
4948
4948
  {
4949
- "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.6.1",
4949
+ "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.7.0",
4950
4950
  "relatedSpdxElement": "SPDXRef-Package-yaml-2.9.0",
4951
4951
  "relationshipType": "DEPENDS_ON"
4952
4952
  },
4953
4953
  {
4954
- "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.6.1",
4954
+ "spdxElementId": "SPDXRef-Package--kici-dev-engine-0.7.0",
4955
4955
  "relatedSpdxElement": "SPDXRef-Package-zod-4.4.3",
4956
4956
  "relationshipType": "DEPENDS_ON"
4957
4957
  },
@@ -4962,12 +4962,12 @@
4962
4962
  },
4963
4963
  {
4964
4964
  "spdxElementId": "SPDXRef-RootPackage",
4965
- "relatedSpdxElement": "SPDXRef-Package--kici-dev-core-0.6.1",
4965
+ "relatedSpdxElement": "SPDXRef-Package--kici-dev-core-0.7.0",
4966
4966
  "relationshipType": "DEPENDS_ON"
4967
4967
  },
4968
4968
  {
4969
4969
  "spdxElementId": "SPDXRef-RootPackage",
4970
- "relatedSpdxElement": "SPDXRef-Package--kici-dev-engine-0.6.1",
4970
+ "relatedSpdxElement": "SPDXRef-Package--kici-dev-engine-0.7.0",
4971
4971
  "relationshipType": "DEPENDS_ON"
4972
4972
  },
4973
4973
  {