@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,625 @@
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-011 kavachos run — only approved agent launch path
5
+ # @rule:KOS-006 seccomp filter applied before exec — inherited by children, immutable after
6
+ # @rule:KOS-028 NOTIFY supervisor — kernel pauses syscall, operator decides before ALLOW/DENY
7
+
8
+ """
9
+ apply-seccomp.py — KavachOS libseccomp launcher + NOTIFY supervisor
10
+
11
+ Phase 1A (stable): load profile, exec agent under SCMP_ACT_ERRNO default.
12
+ Phase 1D (this): notify_syscalls in profile → supervisor forks, kernel pauses
13
+ those syscalls, Telegram ALLOW/DENY before proceeding.
14
+
15
+ Architecture (Phase 1D):
16
+ 1. Build libseccomp context with ALLOW + NOTIFY rules (before fork).
17
+ 2. socketpair() for fd transfer.
18
+ 3. fork():
19
+ CHILD → seccomp_load(ctx) → seccomp_notify_fd(ctx) → send fd to parent
20
+ → exec(agent) [seccomp filter now active on agent]
21
+ PARENT → receive notify_fd from child
22
+ → run supervisor loop (no seccomp filter — unrestricted)
23
+ → waitpid(child) → exit with child's status
24
+
25
+ Usage:
26
+ python3 apply-seccomp.py <profile.json> -- <agent_command> [args...]
27
+
28
+ Backward compatible: profiles without notify_syscalls run exactly as before
29
+ (no fork, exec directly).
30
+ """
31
+
32
+ import array
33
+ import ctypes
34
+ import ctypes.util
35
+ import fcntl
36
+ import json
37
+ import os
38
+ import select
39
+ import socket
40
+ import sqlite3
41
+ import struct
42
+ import sys
43
+ import time
44
+ import threading
45
+ import urllib.request
46
+ import urllib.error
47
+ from typing import List, Optional, Tuple
48
+
49
+ # ── libseccomp bindings ────────────────────────────────────────────────────────
50
+
51
+ _LIB_PATH = ctypes.util.find_library("seccomp")
52
+ if not _LIB_PATH:
53
+ _LIB_PATH = "libseccomp.so.2"
54
+
55
+ try:
56
+ _lib = ctypes.CDLL(_LIB_PATH)
57
+ except OSError as e:
58
+ sys.stderr.write(f"[kavachos] FATAL: cannot load libseccomp: {e}\n")
59
+ sys.exit(1)
60
+
61
+ # Action constants
62
+ SCMP_ACT_KILL = ctypes.c_uint32(0x00000000)
63
+ SCMP_ACT_NOTIFY = ctypes.c_uint32(0x7fc00000)
64
+ SCMP_ACT_ERRNO = ctypes.c_uint32(0x00050001) # ERRNO(EPERM=1)
65
+ SCMP_ACT_ALLOW = ctypes.c_uint32(0x7fff0000)
66
+
67
+ _lib.seccomp_init.restype = ctypes.c_void_p
68
+ _lib.seccomp_init.argtypes = [ctypes.c_uint32]
69
+
70
+ _lib.seccomp_rule_add.restype = ctypes.c_int
71
+ _lib.seccomp_rule_add.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int, ctypes.c_uint]
72
+
73
+ _lib.seccomp_load.restype = ctypes.c_int
74
+ _lib.seccomp_load.argtypes = [ctypes.c_void_p]
75
+
76
+ _lib.seccomp_release.restype = None
77
+ _lib.seccomp_release.argtypes = [ctypes.c_void_p]
78
+
79
+ _lib.seccomp_syscall_resolve_name.restype = ctypes.c_int
80
+ _lib.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
81
+
82
+ # @rule:KOS-028 seccomp_notify_fd — get notify fd after seccomp_load
83
+ _lib.seccomp_notify_fd.restype = ctypes.c_int
84
+ _lib.seccomp_notify_fd.argtypes = [ctypes.c_void_p]
85
+
86
+ _libc_path = ctypes.util.find_library("c") or "libc.so.6"
87
+ _libc = ctypes.CDLL(_libc_path)
88
+ _libc.prctl.restype = ctypes.c_int
89
+ _libc.prctl.argtypes = [ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong]
90
+
91
+ # ioctl via libc for unsigned request codes > 2^31
92
+ _libc.ioctl.restype = ctypes.c_int
93
+ _libc.ioctl.argtypes = [ctypes.c_int, ctypes.c_ulong, ctypes.c_void_p]
94
+
95
+ PR_SET_NO_NEW_PRIVS = 38
96
+
97
+ # ── NOTIFY supervisor constants ────────────────────────────────────────────────
98
+
99
+ # IOWR('!', nr, size) on x86_64
100
+ # = (3 << 30) | (ord('!') << 8) | nr | (size << 16)
101
+ _SECCOMP_IOC_MAGIC = ord('!')
102
+
103
+ def _IOWR(nr: int, size: int) -> int:
104
+ return (3 << 30) | (_SECCOMP_IOC_MAGIC << 8) | nr | (size << 16)
105
+
106
+ # struct seccomp_notif — 80 bytes: id(Q) pid(I) flags(I) nr(i) arch(I) ip(Q) args(6Q)
107
+ # struct seccomp_notif_resp — 24 bytes: id(Q) val(q) error(i) flags(I)
108
+ _NOTIF_PACK = "=QIIiIQ6Q"
109
+ _RESP_PACK = "=QqiI"
110
+ _NOTIF_SIZE = struct.calcsize(_NOTIF_PACK) # 80
111
+ _RESP_SIZE = struct.calcsize(_RESP_PACK) # 24
112
+
113
+ SECCOMP_IOCTL_NOTIF_RECV = _IOWR(0, _NOTIF_SIZE) # 0xC0502100
114
+ SECCOMP_IOCTL_NOTIF_SEND = _IOWR(1, _RESP_SIZE) # 0xC0182101
115
+ SECCOMP_USER_NOTIF_FLAG_CONTINUE = 1 # allow syscall to proceed
116
+
117
+ # ── Syscall name lookup ────────────────────────────────────────────────────────
118
+
119
+ # Build a reverse map: syscall_nr → name from /usr/include (fallback: libseccomp)
120
+ def _build_nr_to_name() -> dict:
121
+ mapping: dict = {}
122
+ try:
123
+ import os as _os
124
+ path = "/usr/include/x86_64-linux-gnu/asm/unistd_64.h"
125
+ if not _os.path.exists(path):
126
+ path = "/usr/include/asm/unistd_64.h"
127
+ if _os.path.exists(path):
128
+ with open(path) as f:
129
+ for line in f:
130
+ if line.startswith("#define __NR_"):
131
+ parts = line.split()
132
+ if len(parts) >= 3:
133
+ name = parts[1].replace("__NR_", "")
134
+ try:
135
+ nr = int(parts[2])
136
+ mapping[nr] = name
137
+ except ValueError:
138
+ pass
139
+ except Exception:
140
+ pass
141
+ return mapping
142
+
143
+ _NR_TO_NAME = _build_nr_to_name()
144
+
145
+ def syscall_name(nr: int) -> str:
146
+ return _NR_TO_NAME.get(nr, f"syscall#{nr}")
147
+
148
+ # ── fd passing via SCM_RIGHTS ──────────────────────────────────────────────────
149
+
150
+ def _send_fd(sock: socket.socket, fd: int) -> None:
151
+ """Send a file descriptor over a Unix socket using SCM_RIGHTS."""
152
+ fds = array.array("i", [fd])
153
+ sock.sendmsg([b"\x00"], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds)])
154
+
155
+
156
+ def _recv_fd(sock: socket.socket, timeout: float = 5.0) -> int:
157
+ """Receive a file descriptor from a Unix socket."""
158
+ sock.settimeout(timeout)
159
+ msg, ancdata, _flags, _addr = sock.recvmsg(1, socket.CMSG_LEN(array.array("i", [0]).itemsize))
160
+ for cmsg_level, cmsg_type, cmsg_data in ancdata:
161
+ if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
162
+ fds = array.array("i")
163
+ fds.frombytes(cmsg_data[: fds.itemsize])
164
+ return fds[0]
165
+ raise RuntimeError("[kavachos] No fd received from child")
166
+
167
+ # ── Notify supervisor loop ─────────────────────────────────────────────────────
168
+
169
+ def _read_aegis_config() -> dict:
170
+ """Load ~/.aegis/config.json — returns {} if absent."""
171
+ config_path = os.path.join(os.environ.get("HOME", "/root"), ".aegis", "config.json")
172
+ try:
173
+ with open(config_path) as f:
174
+ return json.load(f)
175
+ except Exception:
176
+ return {}
177
+
178
+
179
+ def _aegis_db_path() -> str:
180
+ return os.path.join(os.environ.get("HOME", "/root"), ".aegis", "aegis.db")
181
+
182
+
183
+ def _create_approval(db_path: str, approval_id: str, session_id: str,
184
+ syscall: str, pid: int, domain: str) -> None:
185
+ """Insert a pending approval record into aegis.db."""
186
+ try:
187
+ conn = sqlite3.connect(db_path, timeout=5)
188
+ conn.execute("""
189
+ INSERT OR IGNORE INTO kavach_approvals
190
+ (id, created_at, command, tool_name, level, consequence, session_id, timeout_ms, status)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
192
+ """, (
193
+ approval_id,
194
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
195
+ f"syscall:{syscall} pid:{pid}",
196
+ f"kavachos:supervisor_ambiguous",
197
+ 3,
198
+ f"Agent syscall {syscall} requires operator approval (KOS-028)",
199
+ session_id,
200
+ 600_000,
201
+ ))
202
+ conn.commit()
203
+ conn.close()
204
+ except Exception as e:
205
+ sys.stderr.write(f"[kavachos:supervisor] DB write failed: {e}\n")
206
+
207
+
208
+ def _poll_approval(db_path: str, approval_id: str,
209
+ timeout_s: float = 600.0) -> bool:
210
+ """
211
+ Poll aegis.db until the approval is decided. Returns True=ALLOW, False=DENY.
212
+ @rule:KOS-026 silence = STOP (DENY)
213
+ """
214
+ deadline = time.time() + timeout_s
215
+ while time.time() < deadline:
216
+ try:
217
+ conn = sqlite3.connect(db_path, timeout=5)
218
+ row = conn.execute(
219
+ "SELECT status FROM kavach_approvals WHERE id = ?", (approval_id,)
220
+ ).fetchone()
221
+ conn.close()
222
+ if row and row[0] == "allowed":
223
+ return True
224
+ if row and row[0] in ("stopped", "timed_out"):
225
+ return False
226
+ except Exception:
227
+ pass
228
+ time.sleep(2)
229
+ sys.stderr.write(f"[kavachos:supervisor] Approval {approval_id} timed out → DENY\n")
230
+ return False
231
+
232
+
233
+ def _send_telegram(config: dict, message: str, approval_id: Optional[str]) -> None:
234
+ """
235
+ POST message to AnkrClaw /api/notify (same as kernel-notifier.ts sendViaAnkrClaw).
236
+ Silent on failure — Telegram is best-effort; the DB poll is authoritative.
237
+ """
238
+ kc = config.get("kavach", {})
239
+ if not kc.get("enabled"):
240
+ return
241
+ url = kc.get("webhook_url") or kc.get("ankrclaw_url", "")
242
+ if not url:
243
+ return
244
+ channel = kc.get("notify_channel", "telegram")
245
+ to = kc.get("notify_telegram_chat_id") if channel == "telegram" else kc.get("notify_phone", "")
246
+ if not to:
247
+ return
248
+ payload = json.dumps({
249
+ "to": to,
250
+ "message": message,
251
+ "service": "KAVACHOS",
252
+ "channel": channel,
253
+ "approval_id": approval_id,
254
+ }).encode()
255
+ try:
256
+ req = urllib.request.Request(
257
+ f"{url.rstrip('/')}/api/notify",
258
+ data=payload,
259
+ headers={"Content-Type": "application/json"},
260
+ method="POST",
261
+ )
262
+ urllib.request.urlopen(req, timeout=10)
263
+ except Exception as e:
264
+ sys.stderr.write(f"[kavachos:supervisor] Telegram send failed: {e}\n")
265
+
266
+
267
+ def _build_notify_message(syscall: str, agent_id: str, session_id: str,
268
+ domain: str, pid: int, approval_id: str) -> str:
269
+ plain_map = {
270
+ "ptrace": "attach to and control another process (debugger)",
271
+ "bpf": "load a BPF kernel program",
272
+ "mount": "mount a filesystem",
273
+ "umount2": "unmount a filesystem",
274
+ "userfaultfd": "register a userspace page-fault handler",
275
+ "perf_event_open": "open a performance monitoring counter",
276
+ "setns": "join a Linux namespace",
277
+ "capset": "modify Linux capability flags",
278
+ }
279
+ plain = plain_map.get(syscall, f'call restricted syscall "{syscall}"')
280
+ return "\n".join([
281
+ "🟠 KavachOS — Syscall Gated (Action Required)",
282
+ "",
283
+ f"Agent: {agent_id} | Domain: {domain}",
284
+ f"Session: {session_id} | Agent PID: {pid}",
285
+ "",
286
+ "What happened:",
287
+ f"The agent tried to {plain}.",
288
+ "This syscall is in the supervised (NOTIFY) tier. The kernel has paused it.",
289
+ "",
290
+ "Technical detail:",
291
+ f"syscall: {syscall} | rule: KOS-028 SCMP_ACT_NOTIFY tier",
292
+ "",
293
+ "Reply with one word:",
294
+ f" ALLOW {approval_id} — permit this call, agent continues",
295
+ f" STOP {approval_id} — deny, agent receives EPERM",
296
+ "",
297
+ "Expires: 10 min (silence = STOP)",
298
+ ])
299
+
300
+
301
+ def _notify_recv(notify_fd: int) -> Optional[Tuple[int, int, int]]:
302
+ """
303
+ Read one pending seccomp notification.
304
+ Returns (notif_id, pid, syscall_nr) or None on error.
305
+ Blocks until a notification arrives (caller should poll first).
306
+ """
307
+ buf = bytearray(_NOTIF_SIZE)
308
+ arr = (ctypes.c_uint8 * _NOTIF_SIZE).from_buffer(buf)
309
+ ret = _libc.ioctl(notify_fd, ctypes.c_ulong(SECCOMP_IOCTL_NOTIF_RECV), arr)
310
+ if ret != 0:
311
+ errno = ctypes.get_errno()
312
+ sys.stderr.write(f"[kavachos:supervisor] NOTIF_RECV failed: errno={errno}\n")
313
+ return None
314
+ fields = struct.unpack(_NOTIF_PACK, bytes(buf))
315
+ notif_id, _pid, _flags, nr, _arch, _ip = fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]
316
+ return notif_id, _pid, nr
317
+
318
+
319
+ def _notify_send(notify_fd: int, notif_id: int, allow: bool) -> None:
320
+ """
321
+ Respond to kernel: ALLOW (continue) or DENY (EPERM).
322
+ @rule:KOS-026 silence = STOP — caller must call this; never let it time out silently.
323
+ """
324
+ if allow:
325
+ resp = struct.pack(_RESP_PACK, notif_id, 0, 0, SECCOMP_USER_NOTIF_FLAG_CONTINUE)
326
+ else:
327
+ resp = struct.pack(_RESP_PACK, notif_id, 0, -1, 0) # error=-EPERM
328
+ arr = (ctypes.c_uint8 * _RESP_SIZE).from_buffer(bytearray(resp))
329
+ ret = _libc.ioctl(notify_fd, ctypes.c_ulong(SECCOMP_IOCTL_NOTIF_SEND), arr)
330
+ if ret != 0:
331
+ errno = ctypes.get_errno()
332
+ # ENOENT means the tracee already died — not an error
333
+ if errno != 2:
334
+ sys.stderr.write(f"[kavachos:supervisor] NOTIF_SEND failed: errno={errno}\n")
335
+
336
+
337
+ def run_supervisor(notify_fd: int, agent_pid: int,
338
+ session_id: str, agent_id: str, domain: str) -> int:
339
+ """
340
+ @rule:KOS-028 supervisor loop — runs in parent process, no seccomp filter.
341
+ Uses poll() so POLLHUP (child exited) is detected cleanly without racing
342
+ against waitpid(WNOHANG) — the root cause of the POLLHUP infinite-loop bug.
343
+ Returns child exit code.
344
+ """
345
+ import random, string
346
+
347
+ config = _read_aegis_config()
348
+ db_path = _aegis_db_path()
349
+
350
+ sys.stderr.write(
351
+ f"[kavachos:supervisor] Phase 1D active — agent_pid={agent_pid} "
352
+ f"session={session_id} domain={domain}\n"
353
+ )
354
+
355
+ poller = select.poll()
356
+ poller.register(notify_fd, select.POLLIN | select.POLLHUP | select.POLLERR)
357
+
358
+ while True:
359
+ try:
360
+ events = poller.poll(200) # 200 ms
361
+ except (ValueError, OSError):
362
+ break # fd closed externally
363
+
364
+ for _fd, event in events:
365
+ if event & (select.POLLHUP | select.POLLERR):
366
+ # Child exited — collect it (blocking is safe; it's already gone)
367
+ try:
368
+ _, wstatus = os.waitpid(agent_pid, 0)
369
+ code = os.WEXITSTATUS(wstatus) if os.WIFEXITED(wstatus) else 1
370
+ except ChildProcessError:
371
+ code = 0
372
+ os.close(notify_fd)
373
+ return code
374
+
375
+ if event & select.POLLIN:
376
+ result = _notify_recv(notify_fd)
377
+ if result is None:
378
+ continue # ioctl failed (e.g. EINTR) — retry
379
+
380
+ notif_id, pid, nr = result
381
+ syscall = syscall_name(nr)
382
+ approval_id = "KOS-" + "".join(random.choices(string.hexdigits.upper(), k=8))
383
+
384
+ sys.stderr.write(
385
+ f"[kavachos:supervisor] GATED syscall={syscall} pid={pid} "
386
+ f"approval={approval_id}\n"
387
+ )
388
+
389
+ # Escalate in a daemon thread so the main loop stays responsive
390
+ # to further notifications or POLLHUP.
391
+ def _escalate(aid=approval_id, sc=syscall, p=pid, nid=notif_id):
392
+ _create_approval(db_path, aid, session_id, sc, p, domain)
393
+ msg = _build_notify_message(sc, agent_id, session_id, domain, p, aid)
394
+ _send_telegram(config, msg, aid)
395
+ allow = _poll_approval(db_path, aid)
396
+ verb = "ALLOW" if allow else "DENY"
397
+ sys.stderr.write(f"[kavachos:supervisor] {verb} {aid} — syscall={sc}\n")
398
+ _notify_send(notify_fd, nid, allow)
399
+
400
+ threading.Thread(target=_escalate, daemon=True).start()
401
+
402
+ if not events:
403
+ # Timeout — backup liveness check in case POLLHUP was missed
404
+ try:
405
+ wpid, wstatus = os.waitpid(agent_pid, os.WNOHANG)
406
+ if wpid == agent_pid:
407
+ os.close(notify_fd)
408
+ return os.WEXITSTATUS(wstatus) if os.WIFEXITED(wstatus) else 1
409
+ except ChildProcessError:
410
+ os.close(notify_fd)
411
+ return 0
412
+
413
+ # Unreachable in normal operation — belt-and-suspenders cleanup
414
+ try:
415
+ os.close(notify_fd)
416
+ except OSError:
417
+ pass
418
+ try:
419
+ _, wstatus = os.waitpid(agent_pid, os.WNOHANG)
420
+ return os.WEXITSTATUS(wstatus) if os.WIFEXITED(wstatus) else 1
421
+ except ChildProcessError:
422
+ return 0
423
+
424
+ # ── Profile context builder ────────────────────────────────────────────────────
425
+
426
+ def resolve_syscall(name: str) -> int:
427
+ return _lib.seccomp_syscall_resolve_name(name.encode())
428
+
429
+
430
+ _NO_FREEZE_REQUIRED = {"exit_group", "exit", "futex", "rt_sigreturn", "restart_syscall"}
431
+
432
+
433
+ def build_ctx(profile: dict):
434
+ """
435
+ Build (but do NOT load) a libseccomp context from a KavachOS profile.
436
+ Returns (ctx, has_notify) — ctx must be loaded in the child after fork.
437
+ """
438
+ if profile.get("defaultAction") == "SCMP_ACT_KILL":
439
+ sys.stderr.write("[kavachos] FATAL: defaultAction SCMP_ACT_KILL is banned — use SCMP_ACT_ERRNO\n")
440
+ sys.exit(1)
441
+
442
+ allowed: List[str] = []
443
+ notify_names: List[str] = []
444
+
445
+ for entry in profile.get("syscalls", []):
446
+ action = entry.get("action", "")
447
+ names = entry.get("names", [])
448
+ if action == "SCMP_ACT_ALLOW":
449
+ allowed.extend(names)
450
+ elif action == "SCMP_ACT_NOTIFY":
451
+ notify_names.extend(names)
452
+
453
+ if not allowed:
454
+ sys.stderr.write("[kavachos] FATAL: profile has no allowed syscalls\n")
455
+ sys.exit(1)
456
+
457
+ allowed_set = set(allowed)
458
+ missing = _NO_FREEZE_REQUIRED - allowed_set
459
+ if missing:
460
+ sys.stderr.write(
461
+ f"[kavachos] FATAL: profile missing no-freeze syscalls: {', '.join(sorted(missing))}\n"
462
+ )
463
+ sys.exit(1)
464
+
465
+ kavachos_meta = profile.get("_kavachos", {})
466
+ sys.stderr.write(
467
+ f"[kavachos] Building seccomp context: trust_mask=0x{kavachos_meta.get('trust_mask', 0):08x} "
468
+ f"domain={kavachos_meta.get('domain', 'unknown')} "
469
+ f"allow={len(allowed)} notify={len(notify_names)}\n"
470
+ )
471
+
472
+ if _libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
473
+ sys.stderr.write("[kavachos] WARNING: PR_SET_NO_NEW_PRIVS failed\n")
474
+
475
+ ctx = _lib.seccomp_init(SCMP_ACT_ERRNO)
476
+ if not ctx:
477
+ sys.stderr.write("[kavachos] FATAL: seccomp_init returned NULL\n")
478
+ sys.exit(1)
479
+
480
+ unknown: List[str] = []
481
+ added = 0
482
+
483
+ for name in allowed:
484
+ nr = resolve_syscall(name)
485
+ if nr < 0:
486
+ unknown.append(name)
487
+ continue
488
+ ret = _lib.seccomp_rule_add(ctx, SCMP_ACT_ALLOW, nr, 0)
489
+ if ret == 0:
490
+ added += 1
491
+
492
+ for name in notify_names:
493
+ nr = resolve_syscall(name)
494
+ if nr < 0:
495
+ continue
496
+ _lib.seccomp_rule_add(ctx, SCMP_ACT_NOTIFY, nr, 0)
497
+
498
+ if unknown:
499
+ sys.stderr.write(f"[kavachos] INFO: {len(unknown)} unknown syscalls skipped: {', '.join(unknown[:10])}\n")
500
+
501
+ return ctx, len(notify_names) > 0
502
+
503
+
504
+ def load_ctx_and_get_notify_fd(ctx) -> int:
505
+ """
506
+ Load the seccomp context (applies filter to calling process).
507
+ Returns notify_fd if NOTIFY rules were added, else -1.
508
+ Must be called in the CHILD after fork.
509
+ """
510
+ ret = _lib.seccomp_load(ctx)
511
+ if ret != 0:
512
+ sys.stderr.write(f"[kavachos] FATAL: seccomp_load failed: errno={-ret}\n")
513
+ _lib.seccomp_release(ctx)
514
+ sys.exit(1)
515
+
516
+ notify_fd = _lib.seccomp_notify_fd(ctx)
517
+ _lib.seccomp_release(ctx)
518
+
519
+ # notify_fd == -22 (EINVAL) means no NOTIFY rules were added
520
+ return notify_fd if notify_fd >= 0 else -1
521
+
522
+ # ── Main ───────────────────────────────────────────────────────────────────────
523
+
524
+ def main() -> None:
525
+ args = sys.argv[1:]
526
+
527
+ try:
528
+ sep_idx = args.index("--")
529
+ except ValueError:
530
+ sys.stderr.write("Usage: apply-seccomp.py <profile.json> -- <command> [args...]\n")
531
+ sys.exit(1)
532
+
533
+ profile_path = args[sep_idx - 1] if sep_idx > 0 else None
534
+ exec_args = args[sep_idx + 1:]
535
+
536
+ if not profile_path or not exec_args:
537
+ sys.stderr.write("Usage: apply-seccomp.py <profile.json> -- <command> [args...]\n")
538
+ sys.exit(1)
539
+
540
+ if not os.path.exists(profile_path):
541
+ sys.stderr.write(f"[kavachos] FATAL: profile not found: {profile_path}\n")
542
+ sys.exit(1)
543
+
544
+ with open(profile_path) as f:
545
+ profile = json.load(f)
546
+
547
+ # Build context before fork (pre-flight checks + libseccomp ctx allocation)
548
+ ctx, has_notify = build_ctx(profile)
549
+
550
+ session_id = os.environ.get("KAVACHOS_SESSION_ID", "unknown")
551
+ agent_id = os.environ.get("KAVACHOS_AGENT_ID", session_id)
552
+ domain = os.environ.get("KAVACHOS_DOMAIN", "unknown")
553
+
554
+ if not has_notify:
555
+ # ── Phase 1A path (no NOTIFY syscalls) — load and exec directly ──────
556
+ ret = _lib.seccomp_load(ctx)
557
+ _lib.seccomp_release(ctx)
558
+ if ret != 0:
559
+ sys.stderr.write(f"[kavachos] FATAL: seccomp_load failed: errno={-ret}\n")
560
+ sys.exit(1)
561
+ sys.stderr.write(f"[kavachos] seccomp active (no notify tier)\n")
562
+ try:
563
+ os.execvp(exec_args[0], exec_args)
564
+ except FileNotFoundError:
565
+ sys.stderr.write(f"[kavachos] FATAL: command not found: {exec_args[0]}\n")
566
+ sys.exit(1)
567
+ except PermissionError as e:
568
+ sys.stderr.write(f"[kavachos] FATAL: permission denied: {e}\n")
569
+ sys.exit(1)
570
+ return # unreachable after execvp
571
+
572
+ # ── Phase 1D path — fork, child loads+execs, parent supervises ───────────
573
+ # socketpair for notify_fd transfer (SCM_RIGHTS)
574
+ sock_recv, sock_send = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)
575
+
576
+ pid = os.fork()
577
+
578
+ if pid == 0:
579
+ # ── CHILD: load seccomp, send notify_fd to parent, exec agent ────────
580
+ sock_recv.close()
581
+
582
+ notify_fd = load_ctx_and_get_notify_fd(ctx)
583
+
584
+ if notify_fd >= 0:
585
+ _send_fd(sock_send, notify_fd)
586
+ os.close(notify_fd)
587
+
588
+ sock_send.close()
589
+
590
+ sys.stderr.write(
591
+ f"[kavachos] seccomp active (Phase 1D — notify tier live, supervisor pid={os.getppid()})\n"
592
+ )
593
+
594
+ try:
595
+ os.execvp(exec_args[0], exec_args)
596
+ except FileNotFoundError:
597
+ sys.stderr.write(f"[kavachos] FATAL: command not found: {exec_args[0]}\n")
598
+ os._exit(1)
599
+ except PermissionError as e:
600
+ sys.stderr.write(f"[kavachos] FATAL: permission denied: {e}\n")
601
+ os._exit(1)
602
+ os._exit(1) # unreachable
603
+
604
+ else:
605
+ # ── PARENT: receive notify_fd, supervise, wait for child ──────────────
606
+ sock_send.close()
607
+ _lib.seccomp_release(ctx)
608
+
609
+ try:
610
+ notify_fd = _recv_fd(sock_recv, timeout=10.0)
611
+ except Exception as e:
612
+ sys.stderr.write(f"[kavachos:supervisor] Could not receive notify_fd: {e}\n")
613
+ # Fall back: just wait for child (no notify supervision)
614
+ sock_recv.close()
615
+ _, wstatus = os.waitpid(pid, 0)
616
+ sys.exit(os.WEXITSTATUS(wstatus))
617
+
618
+ sock_recv.close()
619
+
620
+ exit_code = run_supervisor(notify_fd, pid, session_id, agent_id, domain)
621
+ sys.exit(exit_code)
622
+
623
+
624
+ if __name__ == "__main__":
625
+ main()