@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.
- package/LICENSE +31 -0
- package/README.md +130 -0
- package/bin/kavachos +5 -0
- package/dist/apply-seccomp.py +625 -0
- package/dist/cgroup-egress.py +432 -0
- package/dist/kavachos.js +2687 -0
- package/package.json +52 -0
|
@@ -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()
|