@xshieldai/agent-kernel 2.0.2

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.
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+ # Copyright (c) 2026 Capt. Anil Sharma (rocketlang). All rights reserved.
4
+ # @rule:KOS-040 cgroup BPF CONNECT4/6 — egress denied before socket established
5
+ # @rule:KOS-041 per-session cgroup under /sys/fs/cgroup/kavachos/{session_id}/
6
+ # @rule:KOS-043 BPF map populated at launch — no runtime expansion permitted
7
+ # @rule:KOS-045 cgroup cleanup mandatory at session end
8
+
9
+ """
10
+ cgroup-egress.py — KavachOS Phase 1E: cgroup BPF egress firewall
11
+
12
+ Architecture:
13
+ 1. Compile BPF program (clang -target bpf) — or use pre-compiled .o
14
+ 2. Create per-session cgroup
15
+ 3. bpftool prog load → pin to /sys/fs/bpf/kavachos/{session_id}/
16
+ 4. bpftool cgroup attach → connect4/connect6 on session cgroup
17
+ 5. bpftool map update → populate IP:port allowlist from egress policy JSON
18
+ 6. Move agent PID into cgroup
19
+ 7. Wait for agent exit → bpftool cgroup detach → cleanup
20
+
21
+ Uses clang + bpftool (available on this host). More stable than bcc for
22
+ cgroup_sock_addr programs due to expected_attach_type API differences across
23
+ bcc versions. (KOS-YK-006)
24
+
25
+ Usage: python3 cgroup-egress.py <session_id> <policy.json> <agent_pid>
26
+ """
27
+
28
+ import json
29
+ import os
30
+ import re
31
+ import shutil
32
+ import socket
33
+ import struct
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from pathlib import Path
39
+ from typing import List, Optional, Tuple
40
+
41
+ CGROUP_ROOT = "/sys/fs/cgroup/kavachos"
42
+ BPF_PIN_ROOT = "/sys/fs/bpf/kavachos"
43
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
44
+
45
+ # ── BPF C source ─────────────────────────────────────────────────────────────
46
+ # Compiled once per session; deterministic for same key schema.
47
+
48
+ _BPF_CONNECT4_C = r"""
49
+ // @rule:KOS-040 cgroup BPF connect4 — deny before socket established
50
+ // @rule:INF-KOS-009 empty map = deny-all
51
+ #include <linux/bpf.h>
52
+ #include <bpf/bpf_helpers.h>
53
+ #include <bpf/bpf_endian.h>
54
+
55
+ // Key: ((ip_be32) << 32) | port_host
56
+ // ip_be32 is as returned by ctx->user_ip4 (big-endian u32)
57
+ // port_host is host-byte-order port number
58
+ struct {
59
+ __uint(type, BPF_MAP_TYPE_HASH);
60
+ __uint(max_entries, 512);
61
+ __type(key, __u64);
62
+ __type(value, __u8);
63
+ } egress_allow_v4 SEC(".maps");
64
+
65
+ SEC("cgroup/connect4")
66
+ int connect4(struct bpf_sock_addr *ctx) {
67
+ // user_ip4 is NBO; bpf_ntohl converts to host order matching Python's key
68
+ __u32 dst_ip = bpf_ntohl(ctx->user_ip4);
69
+ // user_port stores the BE16 port in the *lower* 16 bits (not upper)
70
+ __u16 dst_port = bpf_ntohs((__u16)ctx->user_port);
71
+
72
+ // Exact match: ip + port
73
+ __u64 key = ((__u64)dst_ip << 32) | dst_port;
74
+ __u8 *v = bpf_map_lookup_elem(&egress_allow_v4, &key);
75
+ if (v && *v) return 1;
76
+
77
+ // Wildcard: any port for this ip (port=0 key)
78
+ __u64 wildcard = (__u64)dst_ip << 32;
79
+ v = bpf_map_lookup_elem(&egress_allow_v4, &wildcard);
80
+ if (v && *v) return 1;
81
+
82
+ return 0; // deny
83
+ }
84
+ char _license[] SEC("license") = "GPL";
85
+ """
86
+
87
+ _BPF_CONNECT6_C = r"""
88
+ // @rule:KOS-040 cgroup BPF connect6
89
+ #include <linux/bpf.h>
90
+ #include <bpf/bpf_helpers.h>
91
+ #include <bpf/bpf_endian.h>
92
+
93
+ // Key: last 32 bits of IPv6 address (BE) << 32 | port_host
94
+ // Sufficient for ::1 loopback matching
95
+ struct {
96
+ __uint(type, BPF_MAP_TYPE_HASH);
97
+ __uint(max_entries, 64);
98
+ __type(key, __u64);
99
+ __type(value, __u8);
100
+ } egress_allow_v6 SEC(".maps");
101
+
102
+ SEC("cgroup/connect6")
103
+ int connect6(struct bpf_sock_addr *ctx) {
104
+ // user_ip6[3] is NBO; bpf_ntohl converts to host order matching Python's key
105
+ __u32 addr_last = bpf_ntohl(ctx->user_ip6[3]);
106
+ // user_port stores the BE16 port in the *lower* 16 bits (not upper)
107
+ __u16 dst_port = bpf_ntohs((__u16)ctx->user_port);
108
+
109
+ __u64 key = ((__u64)addr_last << 32) | dst_port;
110
+ __u8 *v = bpf_map_lookup_elem(&egress_allow_v6, &key);
111
+ if (v && *v) return 1;
112
+
113
+ __u64 wildcard = (__u64)addr_last << 32;
114
+ v = bpf_map_lookup_elem(&egress_allow_v6, &wildcard);
115
+ if (v && *v) return 1;
116
+
117
+ return 0;
118
+ }
119
+ char _license[] SEC("license") = "GPL";
120
+ """
121
+
122
+ # ── Availability checks ───────────────────────────────────────────────────────
123
+
124
+ def _has_tool(name: str) -> bool:
125
+ return shutil.which(name) is not None
126
+
127
+ def _has_cgroupv2() -> bool:
128
+ try:
129
+ with open("/proc/mounts") as f:
130
+ return any("cgroup2" in l for l in f)
131
+ except Exception:
132
+ return False
133
+
134
+ def _is_available() -> bool:
135
+ ok = True
136
+ if not _has_tool("clang"):
137
+ sys.stderr.write("[kavachos:egress] clang not found — egress firewall disabled\n")
138
+ ok = False
139
+ if not _has_tool("bpftool"):
140
+ sys.stderr.write("[kavachos:egress] bpftool not found — egress firewall disabled\n")
141
+ ok = False
142
+ if not _has_cgroupv2():
143
+ sys.stderr.write("[kavachos:egress] cgroupv2 not available — egress firewall disabled\n")
144
+ ok = False
145
+ return ok
146
+
147
+ # ── Compilation ────────────────────────────────────────────────────────────────
148
+
149
+ def _compile_bpf(src: str, out_path: str) -> bool:
150
+ """Compile BPF C source to object file via clang."""
151
+ arch = subprocess.check_output(["uname", "-m"], text=True).strip()
152
+ include_dir = f"/usr/include/{arch}-linux-gnu"
153
+ result = subprocess.run(
154
+ ["clang", "-O2", "-target", "bpf", "-g", "-c",
155
+ f"-I{include_dir}", "-I/usr/include/bpf",
156
+ "-x", "c", "-", "-o", out_path],
157
+ input=src.encode(),
158
+ capture_output=True,
159
+ )
160
+ if result.returncode != 0:
161
+ sys.stderr.write(f"[kavachos:egress] BPF compile error:\n{result.stderr.decode()[:400]}\n")
162
+ return False
163
+ return True
164
+
165
+ # ── DNS resolution ────────────────────────────────────────────────────────────
166
+
167
+ def _resolve(host: str) -> List[str]:
168
+ if re.match(r'^\d+\.\d+\.\d+\.\d+$', host) or ":" in host:
169
+ return [host]
170
+ try:
171
+ return list({r[4][0] for r in socket.getaddrinfo(host, None)})
172
+ except socket.gaierror:
173
+ return []
174
+
175
+ def _ip4_be32(ip: str) -> Optional[int]:
176
+ try:
177
+ return struct.unpack(">I", socket.inet_aton(ip))[0]
178
+ except Exception:
179
+ return None
180
+
181
+ def _ip6_last32_be(ip: str) -> Optional[int]:
182
+ try:
183
+ packed = socket.inet_pton(socket.AF_INET6, ip)
184
+ return struct.unpack(">I", packed[12:16])[0]
185
+ except Exception:
186
+ return None
187
+
188
+ # ── bpftool helpers ───────────────────────────────────────────────────────────
189
+
190
+ def _bpftool(*args, check: bool = True) -> subprocess.CompletedProcess:
191
+ return subprocess.run(["bpftool"] + list(args), capture_output=True, text=True, check=check)
192
+
193
+ def _prog_load(obj_path: str, pin_path: str) -> Optional[int]:
194
+ """Load BPF program, pin to bpffs. Returns program ID."""
195
+ r = _bpftool("prog", "load", obj_path, pin_path, "pinmaps",
196
+ os.path.dirname(pin_path), check=False)
197
+ if r.returncode != 0:
198
+ sys.stderr.write(f"[kavachos:egress] prog load failed: {r.stderr[:300]}\n")
199
+ return None
200
+ # Get the prog ID from 'prog show pinned <path>'
201
+ r2 = _bpftool("prog", "show", "pinned", pin_path, "--json", check=False)
202
+ if r2.returncode == 0:
203
+ try:
204
+ data = json.loads(r2.stdout)
205
+ return data[0]["id"] if isinstance(data, list) else data.get("id")
206
+ except Exception:
207
+ pass
208
+ return None
209
+
210
+ def _map_id_for_prog(pin_dir: str, map_name: str) -> Optional[int]:
211
+ """Get the map ID for a pinned map."""
212
+ pin = os.path.join(pin_dir, map_name)
213
+ r = _bpftool("map", "show", "pinned", pin, "--json", check=False)
214
+ if r.returncode == 0:
215
+ try:
216
+ data = json.loads(r.stdout)
217
+ return (data[0] if isinstance(data, list) else data).get("id")
218
+ except Exception:
219
+ pass
220
+ return None
221
+
222
+ def _map_update(map_id: int, key_u64: int, value: int) -> None:
223
+ """Update BPF hash map: key (u64 little-endian bytes) → value (u8)."""
224
+ # bpftool map update id <id> key hex <8 LE bytes> value hex <1 byte>
225
+ key_bytes = " ".join(f"{(key_u64 >> (i*8)) & 0xff:02x}" for i in range(8))
226
+ val_hex = f"{value:02x}"
227
+ r = _bpftool("map", "update", "id", str(map_id), "key", "hex",
228
+ *key_bytes.split(), "value", "hex", val_hex, check=False)
229
+ if r.returncode != 0:
230
+ sys.stderr.write(f"[kavachos:egress] map update failed for key {key_u64:#018x}: {r.stderr[:100]}\n")
231
+
232
+ def _cgroup_attach(cgroup_path: str, prog_id: int, attach_type: str) -> bool:
233
+ r = _bpftool("cgroup", "attach", cgroup_path, attach_type, "id", str(prog_id), check=False)
234
+ if r.returncode != 0:
235
+ sys.stderr.write(f"[kavachos:egress] cgroup attach {attach_type} failed: {r.stderr[:200]}\n")
236
+ return False
237
+ return True
238
+
239
+ def _cgroup_detach(cgroup_path: str, prog_id: int, attach_type: str) -> None:
240
+ _bpftool("cgroup", "detach", cgroup_path, attach_type, "id", str(prog_id), check=False)
241
+
242
+ # ── Cgroup management ─────────────────────────────────────────────────────────
243
+
244
+ def _create_cgroup(session_id: str) -> str:
245
+ path = os.path.join(CGROUP_ROOT, session_id)
246
+ os.makedirs(path, exist_ok=True)
247
+ return path
248
+
249
+ def _move_pid(cgroup_path: str, pid: int) -> None:
250
+ with open(os.path.join(cgroup_path, "cgroup.procs"), "w") as f:
251
+ f.write(str(pid))
252
+
253
+ def _destroy_cgroup(session_id: str) -> None:
254
+ path = os.path.join(CGROUP_ROOT, session_id)
255
+ if not os.path.exists(path):
256
+ return
257
+ # Evacuate any remaining processes to parent cgroup
258
+ try:
259
+ child_procs = os.path.join(path, "cgroup.procs")
260
+ parent_procs = os.path.join(CGROUP_ROOT, "cgroup.procs")
261
+ with open(child_procs) as f:
262
+ pids = f.read().strip().split()
263
+ for p in pids:
264
+ try:
265
+ with open(parent_procs, "w") as f:
266
+ f.write(p)
267
+ except Exception:
268
+ pass
269
+ except Exception:
270
+ pass
271
+ try:
272
+ os.rmdir(path)
273
+ except Exception as e:
274
+ sys.stderr.write(f"[kavachos:egress] cgroup rmdir warning: {e}\n")
275
+
276
+ # ── Main session class ─────────────────────────────────────────────────────────
277
+
278
+ class EgressSession:
279
+ """Lifetime: agent session. Load → attach → populate → wait → cleanup."""
280
+
281
+ def __init__(self, session_id: str):
282
+ self.session_id = session_id
283
+ self.cgroup_path = os.path.join(CGROUP_ROOT, session_id)
284
+ self.pin_dir = os.path.join(BPF_PIN_ROOT, session_id)
285
+ self.prog_id_v4 = -1
286
+ self.prog_id_v6 = -1
287
+ self._tmp = [] # temp files to clean up
288
+
289
+ def setup(self, policy: dict, agent_pid: int) -> bool:
290
+ os.makedirs(CGROUP_ROOT, exist_ok=True)
291
+ os.makedirs(BPF_PIN_ROOT, exist_ok=True)
292
+ os.makedirs(self.pin_dir, exist_ok=True)
293
+ _create_cgroup(self.session_id)
294
+
295
+ # Compile BPF programs to /tmp — bpffs doesn't support regular file writes
296
+ v4_obj = f"/tmp/kavachos-{self.session_id}-connect4.o"
297
+ v6_obj = f"/tmp/kavachos-{self.session_id}-connect6.o"
298
+ self._tmp.extend([v4_obj, v6_obj])
299
+
300
+ if not _compile_bpf(_BPF_CONNECT4_C, v4_obj):
301
+ return False
302
+ _compile_bpf(_BPF_CONNECT6_C, v6_obj) # v6 failure is non-fatal
303
+
304
+ # Load + pin
305
+ v4_pin = os.path.join(self.pin_dir, "connect4")
306
+ self.prog_id_v4 = _prog_load(v4_obj, v4_pin)
307
+ if self.prog_id_v4 is None:
308
+ return False
309
+
310
+ if os.path.exists(v6_obj):
311
+ v6_pin = os.path.join(self.pin_dir, "connect6")
312
+ self.prog_id_v6 = _prog_load(v6_obj, v6_pin) or -1
313
+
314
+ # Attach to cgroup
315
+ if not _cgroup_attach(self.cgroup_path, self.prog_id_v4, "connect4"):
316
+ return False
317
+ if self.prog_id_v6 > 0:
318
+ _cgroup_attach(self.cgroup_path, self.prog_id_v6, "connect6")
319
+
320
+ # Populate allowlist maps
321
+ self._populate(policy)
322
+
323
+ # Move agent into cgroup
324
+ try:
325
+ _move_pid(self.cgroup_path, agent_pid)
326
+ except Exception as e:
327
+ sys.stderr.write(f"[kavachos:egress] move_pid failed: {e}\n")
328
+ return False
329
+
330
+ allow_len = len(policy.get("allow", []))
331
+ sys.stderr.write(
332
+ f"[kavachos:egress] Phase 1E active: session={self.session_id} "
333
+ f"pid={agent_pid} hosts={allow_len}\n"
334
+ )
335
+ return True
336
+
337
+ def _populate(self, policy: dict) -> None:
338
+ """Fill BPF maps with allowlist entries. @rule:KOS-043"""
339
+ map_v4 = _map_id_for_prog(self.pin_dir, "egress_allow_v4")
340
+ map_v6 = _map_id_for_prog(self.pin_dir, "egress_allow_v6") if self.prog_id_v6 > 0 else None
341
+
342
+ for entry in policy.get("allow", []):
343
+ host = entry.get("host", "")
344
+ port = entry.get("port", 0)
345
+ note = entry.get("note", host)
346
+ ips = _resolve(host)
347
+ if not ips:
348
+ sys.stderr.write(f"[kavachos:egress] WARNING: unresolvable host skipped: {host}\n")
349
+ continue
350
+
351
+ for ip in ips:
352
+ be32 = _ip4_be32(ip)
353
+ if be32 is not None and map_v4 is not None:
354
+ # Key: ip_be32 << 32 | port_host (stored little-endian for bpftool)
355
+ key = (be32 << 32) | port
356
+ _map_update(map_v4, key, 1)
357
+ continue
358
+
359
+ last32 = _ip6_last32_be(ip)
360
+ if last32 is not None and map_v6 is not None:
361
+ key = (last32 << 32) | port
362
+ _map_update(map_v6, key, 1)
363
+
364
+ def cleanup(self) -> None:
365
+ """Detach BPF, remove cgroup. @rule:KOS-045"""
366
+ if self.prog_id_v4 > 0:
367
+ _cgroup_detach(self.cgroup_path, self.prog_id_v4, "connect4")
368
+ if self.prog_id_v6 > 0:
369
+ _cgroup_detach(self.cgroup_path, self.prog_id_v6, "connect6")
370
+ _destroy_cgroup(self.session_id)
371
+ # Remove pinned programs from bpffs
372
+ for name in ["connect4", "connect6", "egress_allow_v4", "egress_allow_v6"]:
373
+ p = os.path.join(self.pin_dir, name)
374
+ if os.path.exists(p):
375
+ try:
376
+ os.unlink(p)
377
+ except Exception:
378
+ pass
379
+ try:
380
+ os.rmdir(self.pin_dir)
381
+ except Exception:
382
+ pass
383
+ # Remove tmp .o files
384
+ for tmp in self._tmp:
385
+ if os.path.exists(tmp):
386
+ try:
387
+ os.unlink(tmp)
388
+ except Exception:
389
+ pass
390
+ sys.stderr.write(f"[kavachos:egress] Session {self.session_id} cleaned up\n")
391
+
392
+ # ── Entry point ────────────────────────────────────────────────────────────────
393
+
394
+ def main() -> None:
395
+ if len(sys.argv) < 4:
396
+ sys.stderr.write("Usage: cgroup-egress.py <session_id> <policy.json> <agent_pid>\n")
397
+ sys.exit(1)
398
+
399
+ session_id = sys.argv[1]
400
+ policy_path = sys.argv[2]
401
+ agent_pid = int(sys.argv[3])
402
+
403
+ if not _is_available():
404
+ sys.exit(0) # graceful degradation — log already written above
405
+
406
+ with open(policy_path) as f:
407
+ policy = json.load(f)
408
+
409
+ sess = EgressSession(session_id)
410
+ ok = sess.setup(policy, agent_pid)
411
+ if not ok:
412
+ sess.cleanup()
413
+ sys.exit(0) # graceful degradation
414
+
415
+ # Wait for agent to exit.
416
+ # The agent is typically NOT our direct child (runner.ts spawned it) so
417
+ # waitpid() raises ChildProcessError immediately — fall back to PID-probe loop.
418
+ try:
419
+ os.waitpid(agent_pid, 0)
420
+ except Exception:
421
+ while True:
422
+ try:
423
+ os.kill(agent_pid, 0) # raises ProcessLookupError when PID gone
424
+ time.sleep(0.5)
425
+ except (ProcessLookupError, PermissionError):
426
+ break
427
+
428
+ sess.cleanup()
429
+
430
+
431
+ if __name__ == "__main__":
432
+ main()