@dsh-enhanced/assistant-isolation 0.1.65 → 0.1.67

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.
Files changed (116) hide show
  1. package/README.md +287 -9
  2. package/lib/audit-archive.d.ts +1 -1
  3. package/lib/audit-archive.d.ts.map +1 -1
  4. package/lib/audit-archive.js +31 -13
  5. package/lib/audit-archive.js.map +1 -1
  6. package/lib/cli.d.ts +7 -3
  7. package/lib/cli.d.ts.map +1 -1
  8. package/lib/cli.js +104 -23
  9. package/lib/cli.js.map +1 -1
  10. package/lib/config.d.ts +15 -2
  11. package/lib/config.d.ts.map +1 -1
  12. package/lib/config.js +49 -9
  13. package/lib/config.js.map +1 -1
  14. package/lib/diagnostics.js +2 -2
  15. package/lib/diagnostics.js.map +1 -1
  16. package/lib/index.d.ts +17 -1
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +17 -1
  19. package/lib/index.js.map +1 -1
  20. package/lib/ledger.d.ts +19 -2
  21. package/lib/ledger.d.ts.map +1 -1
  22. package/lib/ledger.js +371 -44
  23. package/lib/ledger.js.map +1 -1
  24. package/lib/linux-cgroups.d.ts +75 -0
  25. package/lib/linux-cgroups.d.ts.map +1 -0
  26. package/lib/linux-cgroups.js +517 -0
  27. package/lib/linux-cgroups.js.map +1 -0
  28. package/lib/linux-execution-contract.d.ts +123 -0
  29. package/lib/linux-execution-contract.d.ts.map +1 -0
  30. package/lib/linux-execution-contract.js +279 -0
  31. package/lib/linux-execution-contract.js.map +1 -0
  32. package/lib/linux-execution.d.ts +123 -0
  33. package/lib/linux-execution.d.ts.map +1 -0
  34. package/lib/linux-execution.js +1264 -0
  35. package/lib/linux-execution.js.map +1 -0
  36. package/lib/linux-job-recovery.d.ts +5 -0
  37. package/lib/linux-job-recovery.d.ts.map +1 -0
  38. package/lib/linux-job-recovery.js +63 -0
  39. package/lib/linux-job-recovery.js.map +1 -0
  40. package/lib/linux-namespace.d.ts +80 -0
  41. package/lib/linux-namespace.d.ts.map +1 -0
  42. package/lib/linux-namespace.js +471 -0
  43. package/lib/linux-namespace.js.map +1 -0
  44. package/lib/linux-repository-admission.d.ts +26 -0
  45. package/lib/linux-repository-admission.d.ts.map +1 -0
  46. package/lib/linux-repository-admission.js +64 -0
  47. package/lib/linux-repository-admission.js.map +1 -0
  48. package/lib/linux-repository-runner.d.ts +54 -0
  49. package/lib/linux-repository-runner.d.ts.map +1 -0
  50. package/lib/linux-repository-runner.js +614 -0
  51. package/lib/linux-repository-runner.js.map +1 -0
  52. package/lib/linux-runner-state-migration.d.ts +94 -0
  53. package/lib/linux-runner-state-migration.d.ts.map +1 -0
  54. package/lib/linux-runner-state-migration.js +463 -0
  55. package/lib/linux-runner-state-migration.js.map +1 -0
  56. package/lib/linux-runner-state.d.ts +53 -0
  57. package/lib/linux-runner-state.d.ts.map +1 -0
  58. package/lib/linux-runner-state.js +332 -0
  59. package/lib/linux-runner-state.js.map +1 -0
  60. package/lib/linux-runtime-source.d.ts +28 -0
  61. package/lib/linux-runtime-source.d.ts.map +1 -0
  62. package/lib/linux-runtime-source.js +237 -0
  63. package/lib/linux-runtime-source.js.map +1 -0
  64. package/lib/linux-runtime.d.ts +37 -0
  65. package/lib/linux-runtime.d.ts.map +1 -0
  66. package/lib/linux-runtime.js +192 -0
  67. package/lib/linux-runtime.js.map +1 -0
  68. package/lib/linux-system-snapshot.d.ts +47 -0
  69. package/lib/linux-system-snapshot.d.ts.map +1 -0
  70. package/lib/linux-system-snapshot.js +704 -0
  71. package/lib/linux-system-snapshot.js.map +1 -0
  72. package/lib/linux-user-namespace.d.ts +79 -0
  73. package/lib/linux-user-namespace.d.ts.map +1 -0
  74. package/lib/linux-user-namespace.js +234 -0
  75. package/lib/linux-user-namespace.js.map +1 -0
  76. package/lib/linux-verifier-runner.d.ts +27 -0
  77. package/lib/linux-verifier-runner.d.ts.map +1 -0
  78. package/lib/linux-verifier-runner.js +346 -0
  79. package/lib/linux-verifier-runner.js.map +1 -0
  80. package/lib/native-cgroup-roots.d.ts +20 -0
  81. package/lib/native-cgroup-roots.d.ts.map +1 -0
  82. package/lib/native-cgroup-roots.js +121 -0
  83. package/lib/native-cgroup-roots.js.map +1 -0
  84. package/lib/native-resources.d.ts +44 -0
  85. package/lib/native-resources.d.ts.map +1 -0
  86. package/lib/native-resources.js +354 -0
  87. package/lib/native-resources.js.map +1 -0
  88. package/lib/probe.d.ts +10 -0
  89. package/lib/probe.d.ts.map +1 -1
  90. package/lib/probe.js +52 -2
  91. package/lib/probe.js.map +1 -1
  92. package/lib/service.d.ts +1 -1
  93. package/lib/service.d.ts.map +1 -1
  94. package/lib/service.js +122 -9
  95. package/lib/service.js.map +1 -1
  96. package/lib/storage.d.ts.map +1 -1
  97. package/lib/storage.js +36 -6
  98. package/lib/storage.js.map +1 -1
  99. package/lib/tools.js +1 -1
  100. package/lib/tools.js.map +1 -1
  101. package/lib/types.d.ts +5 -1
  102. package/lib/types.d.ts.map +1 -1
  103. package/lib/verifier-runner.d.ts +4 -2
  104. package/lib/verifier-runner.d.ts.map +1 -1
  105. package/lib/verifier-runner.js +22 -1
  106. package/lib/verifier-runner.js.map +1 -1
  107. package/lib/version.d.ts +1 -1
  108. package/lib/version.js +1 -1
  109. package/package.json +15 -10
  110. package/runtime/linux-candidate-entry.py +82 -0
  111. package/runtime/linux-namespace-worker.mjs +561 -0
  112. package/runtime/linux-seccomp-policy.LICENSE +202 -0
  113. package/runtime/linux-seccomp-policy.json +886 -0
  114. package/runtime/linux-seccomp.py +106 -0
  115. package/runtime/linux-userns-launcher.py +149 -0
  116. package/runtime/linux-userns-proof.py +299 -0
@@ -0,0 +1,106 @@
1
+ """Compile the fixed, reviewed syscall policy for bubblewrap; never load it here.
2
+
3
+ The policy is the existing vendored Moby-derived source builder allowlist, with
4
+ its three explicit namespace extensions removed for ordinary jobs. libseccomp
5
+ is a system dependency, not a Docker dependency. Its public C ABI is documented
6
+ at https://github.com/seccomp/libseccomp/blob/main/include/seccomp.h.in .
7
+ """
8
+ import ctypes
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import platform
13
+ import sys
14
+
15
+ POLICY_SHA256 = "b1e4b5b709578785bd2aff4a3a344301997571ad0e8ae5747aec176571ddc342"
16
+ ALLOW = 0x7fff0000
17
+ ERRNO = 0x00050000
18
+ ARCHES = {"x86_64": "amd64", "aarch64": "arm64"}
19
+ OPS = {"SCMP_CMP_NE": 1, "SCMP_CMP_EQ": 4, "SCMP_CMP_MASKED_EQ": 7}
20
+
21
+
22
+ class Arg(ctypes.Structure):
23
+ _fields_ = [("arg", ctypes.c_uint), ("op", ctypes.c_int),
24
+ ("datum_a", ctypes.c_uint64), ("datum_b", ctypes.c_uint64)]
25
+
26
+
27
+ def fail(message):
28
+ raise RuntimeError("Linux seccomp: " + message)
29
+
30
+
31
+ def selected(rule, architecture, kernel):
32
+ include = rule.get("includes", {})
33
+ exclude = rule.get("excludes", {})
34
+ # No host capability is granted by this policy. In particular, namespace-
35
+ # local CAP_SYS_ADMIN must not activate the broad host-capability allowlist.
36
+ if include.get("caps"):
37
+ return False
38
+ if "arches" in include and architecture not in include["arches"]:
39
+ return False
40
+ if architecture in exclude.get("arches", []):
41
+ return False
42
+ if "minKernel" in include and kernel < tuple(map(int, include["minKernel"].split("."))):
43
+ return False
44
+ return True
45
+
46
+
47
+ def main():
48
+ if len(sys.argv) != 2 or sys.argv[1] not in ("ordinary", "repository"):
49
+ fail("expected ordinary or repository profile")
50
+ architecture = ARCHES.get(platform.machine())
51
+ if sys.platform != "linux" or architecture is None:
52
+ fail("unsupported platform")
53
+ kernel = tuple(map(int, platform.release().split(".")[:2]))
54
+ if kernel < (5, 4):
55
+ fail("Linux 5.4 or later is required")
56
+ with open(os.path.join(os.path.dirname(__file__), "linux-seccomp-policy.json"), "rb") as source:
57
+ content = source.read(65537)
58
+ if hashlib.sha256(content).hexdigest() != POLICY_SHA256:
59
+ fail("fixed syscall policy changed")
60
+ policy = json.loads(content)
61
+ if policy["defaultAction"] != "SCMP_ACT_ERRNO" or policy.get("defaultErrnoRet", 1) != 1:
62
+ fail("unexpected default action")
63
+ rules = policy["syscalls"]
64
+ if [rule["names"] for rule in rules[-3:]] != [["unshare"], ["clone"], ["mount", "umount2", "pivot_root"]]:
65
+ fail("namespace extension inventory changed")
66
+ if sys.argv[1] == "ordinary":
67
+ rules = rules[:-3]
68
+ lib = ctypes.CDLL("libseccomp.so.2")
69
+ lib.seccomp_init.argtypes = [ctypes.c_uint32]
70
+ lib.seccomp_init.restype = ctypes.c_void_p
71
+ lib.seccomp_release.argtypes = [ctypes.c_void_p]
72
+ lib.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
73
+ lib.seccomp_syscall_resolve_name.restype = ctypes.c_int
74
+ lib.seccomp_rule_add_array.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int,
75
+ ctypes.c_uint, ctypes.POINTER(Arg)]
76
+ lib.seccomp_rule_add_array.restype = ctypes.c_int
77
+ lib.seccomp_export_bpf.argtypes = [ctypes.c_void_p, ctypes.c_int]
78
+ lib.seccomp_export_bpf.restype = ctypes.c_int
79
+ context = lib.seccomp_init(ERRNO | 1)
80
+ if not context:
81
+ fail("could not create filter")
82
+ try:
83
+ for rule in rules:
84
+ if not selected(rule, architecture, kernel):
85
+ continue
86
+ action = ALLOW if rule["action"] == "SCMP_ACT_ALLOW" else ERRNO | rule.get("errnoRet", 1)
87
+ args = rule.get("args", [])
88
+ comparisons = (Arg * len(args))(*(Arg(arg["index"], OPS[arg["op"]],
89
+ arg["value"], arg.get("valueTwo", 0)) for arg in args))
90
+ for name in rule["names"]:
91
+ number = lib.seccomp_syscall_resolve_name(name.encode("ascii"))
92
+ if number == -1:
93
+ # Names for other architectures and future syscalls remain
94
+ # denied by default; they never become unrestricted.
95
+ continue
96
+ result = lib.seccomp_rule_add_array(context, action, number, len(args), comparisons)
97
+ if result != 0:
98
+ fail("cannot encode rule for %s (%d)" % (name, result))
99
+ if lib.seccomp_export_bpf(context, sys.stdout.fileno()) != 0:
100
+ fail("cannot export filter")
101
+ finally:
102
+ lib.seccomp_release(context)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,149 @@
1
+ """Fixed repository keeper launcher. No candidate command executes here."""
2
+ import ctypes
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import select
7
+ import signal
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+
14
+ def main():
15
+ if len(sys.argv) != 5:
16
+ raise RuntimeError("invalid user namespace launcher arguments")
17
+ mapping = json.loads(sys.argv[1])
18
+ deadline = int(sys.argv[2])
19
+ node, worker = sys.argv[3:]
20
+ owner, group = os.getuid(), os.getgid()
21
+ if (owner == 0 or mapping["schemaVersion"] != 1
22
+ or mapping["ownerUid"] != owner or mapping["ownerGid"] != group
23
+ or mapping["candidateUid"] in (0, owner)
24
+ or mapping["candidateGid"] in (0, group)):
25
+ raise RuntimeError("invalid keeper mapping")
26
+ current = os.stat("/proc/self/ns/user")
27
+ if mapping["hostNamespace"] != {"dev": current.st_dev, "ino": current.st_ino}:
28
+ raise RuntimeError("host user namespace changed")
29
+ for kind, path in (("uid", "/usr/bin/newuidmap"), ("gid", "/usr/bin/newgidmap")):
30
+ helper = mapping["helpers"][kind]
31
+ info = os.lstat(path)
32
+ if (helper["path"] != path or not stat.S_ISREG(info.st_mode)
33
+ or info.st_uid != 0 or info.st_nlink != 1
34
+ or info.st_mode & 0o022 or not info.st_mode & stat.S_ISUID
35
+ or info.st_size > 1048576):
36
+ raise RuntimeError("unsafe mapping helper")
37
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
38
+ try:
39
+ content = os.read(fd, 1048577)
40
+ stable = lambda value: (value.st_dev, value.st_ino, value.st_uid, value.st_mode,
41
+ value.st_size, value.st_mtime_ns, value.st_ctime_ns)
42
+ if (len(content) != info.st_size or stable(os.fstat(fd)) != stable(info)
43
+ or hashlib.sha256(content).hexdigest() != helper["sha256"]):
44
+ raise RuntimeError("mapping helper changed")
45
+ finally:
46
+ os.close(fd)
47
+
48
+ libc = ctypes.CDLL(None, use_errno=True)
49
+ def checked(result, operation):
50
+ if result != 0:
51
+ raise OSError(ctypes.get_errno(), operation)
52
+ def remaining():
53
+ value = (deadline - time.time() * 1000) / 1000
54
+ if value <= 0:
55
+ raise TimeoutError("namespace deadline ended")
56
+ return value
57
+ # If the Host disappears, neither this monitor nor the mapped keeper may
58
+ # survive it. The mapping handshake also fails closed on either pipe EOF.
59
+ checked(libc.prctl(1, signal.SIGKILL, 0, 0, 0), "launcher parent-death signal")
60
+ parent_pid = os.getppid()
61
+ if parent_pid == 1:
62
+ raise RuntimeError("Host already exited")
63
+ ready_r, ready_w = os.pipe()
64
+ start_r, start_w = os.pipe()
65
+ child = os.fork()
66
+ if child == 0:
67
+ try:
68
+ os.close(ready_r)
69
+ os.close(start_w)
70
+ checked(libc.prctl(1, signal.SIGKILL, 0, 0, 0), "keeper parent-death signal")
71
+ if os.getppid() == 1:
72
+ raise RuntimeError("launcher exited")
73
+ checked(libc.unshare(0x10000000), "unshare user namespace")
74
+ os.write(ready_w, b"R")
75
+ os.close(ready_w)
76
+ if not select.select([start_r], [], [], remaining())[0] or os.read(start_r, 1) != b"S":
77
+ raise RuntimeError("mapping was not authorized")
78
+ os.close(start_r)
79
+ os.setgroups([])
80
+ os.setresgid(0, 0, 0)
81
+ os.setresuid(0, 0, 0)
82
+ checked(libc.unshare(0x00020000 | 0x40000000 | 0x08000000 | 0x04000000), "keeper namespaces")
83
+ checked(libc.mount(None, b"/", None, 0x4000 | 0x40000, None), "private mounts")
84
+ checked(libc.prctl(1, signal.SIGKILL, 0, 0, 0), "mapped keeper parent-death signal")
85
+ # fd 4/5 are the bounded archive and binary channels. Python makes
86
+ # its own pipes non-inheritable; only these Host channels survive.
87
+ for fd in (0, 1, 2, 4, 5):
88
+ os.set_inheritable(fd, True)
89
+ os.execve(node, [node, worker], {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"})
90
+ except BaseException as error:
91
+ os.write(2, ("linux user namespace child: " + str(error) + "\n").encode())
92
+ os._exit(125)
93
+ os.close(ready_w)
94
+ os.close(start_r)
95
+ pidfd = os.pidfd_open(child)
96
+ try:
97
+ if not select.select([ready_r], [], [], min(5, remaining()))[0] or os.read(ready_r, 1) != b"R":
98
+ raise RuntimeError("keeper did not reach mapping gate")
99
+ for kind, outside in (("uid", mapping["candidateUid"]), ("gid", mapping["candidateGid"])):
100
+ helper = mapping["helpers"][kind]
101
+ subprocess.run([helper["path"], str(child), "0", str(owner if kind == "uid" else group),
102
+ "1", "65534", str(outside), "1"], check=True,
103
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.PIPE, close_fds=True,
105
+ env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
106
+ timeout=min(5, remaining()))
107
+ os.write(start_w, b"S")
108
+ os.close(start_w)
109
+ start_w = None
110
+ # Close monitor copies: EOF on Host disconnect belongs to the keeper,
111
+ # and EOF on fd 5 must not wait for an unrelated monitor descriptor.
112
+ for fd in (0, 4, 5):
113
+ os.close(fd)
114
+ while True:
115
+ waited, status = os.waitpid(child, os.WNOHANG)
116
+ if waited:
117
+ return os.waitstatus_to_exitcode(status)
118
+ if os.getppid() != parent_pid:
119
+ raise RuntimeError("Host disconnected")
120
+ time.sleep(min(0.02, remaining()))
121
+ finally:
122
+ os.close(ready_r)
123
+ if start_w is not None:
124
+ os.close(start_w)
125
+ try:
126
+ signal.pidfd_send_signal(pidfd, signal.SIGKILL)
127
+ except ProcessLookupError:
128
+ pass
129
+ os.close(pidfd)
130
+ # SIGKILL cannot be ignored. This bounded monitor never waits for
131
+ # candidate stdout or an inherited control descriptor during cleanup.
132
+ until = time.monotonic() + 2
133
+ while time.monotonic() < until:
134
+ try:
135
+ if os.waitpid(child, os.WNOHANG)[0]:
136
+ break
137
+ except ChildProcessError:
138
+ break
139
+ time.sleep(0.01)
140
+
141
+
142
+ try:
143
+ code = main()
144
+ sys.exit(code if code >= 0 else 128 - code)
145
+ except BaseException as error:
146
+ if isinstance(error, SystemExit):
147
+ raise
148
+ print("linux user namespace launcher: " + str(error), file=sys.stderr)
149
+ sys.exit(125)
@@ -0,0 +1,299 @@
1
+ """Fixed Host-side kernel witnesses and pidfd cleanup for repository identities.
2
+
3
+ No candidate code is evaluated. Namespace FDs remain open across observation
4
+ and signaling; pidfds and start ticks prevent numeric PID reuse from granting
5
+ authority. The caller additionally owns the frozen cgroup and durable intent.
6
+ """
7
+ import array
8
+ import fcntl
9
+ import json
10
+ import os
11
+ import select
12
+ import signal
13
+ import sys
14
+
15
+ NS_GET_PARENT = 0xB702
16
+ NS_GET_OWNER_UID = 0xB704
17
+
18
+
19
+ def fail(message):
20
+ raise RuntimeError("repository user namespace: " + message)
21
+
22
+
23
+ def identity(fd):
24
+ item = os.fstat(fd)
25
+ return {"dev": item.st_dev, "ino": item.st_ino}
26
+
27
+
28
+ def read(path, maximum=65536):
29
+ with open(path, encoding="ascii") as source:
30
+ value = source.read(maximum + 1)
31
+ if len(value) > maximum:
32
+ fail("kernel observation exceeds bound")
33
+ return value
34
+
35
+
36
+ def boot():
37
+ return read("/proc/sys/kernel/random/boot_id").strip().lower()
38
+
39
+
40
+ def owner(fd):
41
+ value = array.array("I", [0])
42
+ fcntl.ioctl(fd, NS_GET_OWNER_UID, value, True)
43
+ return value[0]
44
+
45
+
46
+ def parent(fd):
47
+ return fcntl.ioctl(fd, NS_GET_PARENT)
48
+
49
+
50
+ def mapping_rows(pid, kind):
51
+ return [[int(item) for item in line.split()]
52
+ for line in read("/proc/%d/%s_map" % (pid, kind)).splitlines()]
53
+
54
+
55
+ def exact_maps(pid, mapping):
56
+ for kind in ("uid", "gid"):
57
+ suffix = "Uid" if kind == "uid" else "Gid"
58
+ expected = [[0, mapping["owner" + suffix], 1],
59
+ [65534, mapping["candidate" + suffix], 1]]
60
+ if mapping_rows(pid, kind) != expected:
61
+ fail("kernel mapping differs")
62
+
63
+
64
+ def status(pid):
65
+ values = {}
66
+ for line in read("/proc/%d/status" % pid).splitlines():
67
+ key, separator, value = line.partition(":")
68
+ if separator:
69
+ values[key] = value.strip()
70
+ return values
71
+
72
+
73
+ def ticks(pid):
74
+ value = read("/proc/%d/stat" % pid)
75
+ fields = value[value.rfind(") ") + 2:].split()
76
+ if len(fields) < 20 or not fields[19].isdigit():
77
+ fail("process start identity unavailable")
78
+ return fields[19]
79
+
80
+
81
+ def alive(fd):
82
+ poll = select.poll()
83
+ poll.register(fd, select.POLLIN)
84
+ return not poll.poll(0)
85
+
86
+
87
+ def checked_process(pid, expected_ticks, pidfd):
88
+ if not alive(pidfd) or ticks(pid) != expected_ticks:
89
+ fail("process identity changed")
90
+ return status(pid)
91
+
92
+
93
+ def same_current_namespace(pid, fd):
94
+ current = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
95
+ try:
96
+ if identity(current) != identity(fd):
97
+ fail("process user namespace changed")
98
+ finally:
99
+ os.close(current)
100
+
101
+
102
+ def ids(values, kind):
103
+ result = [int(item) for item in values[kind].split()]
104
+ if len(result) != 4:
105
+ fail("incomplete process identity")
106
+ return result
107
+
108
+
109
+ def check_host(mapping):
110
+ if os.getuid() != mapping["ownerUid"] or os.getgid() != mapping["ownerGid"]:
111
+ fail("Host owner differs")
112
+ fd = os.open("/proc/self/ns/user", os.O_RDONLY)
113
+ try:
114
+ if identity(fd) != mapping["hostNamespace"]:
115
+ fail("Host namespace differs")
116
+ finally:
117
+ os.close(fd)
118
+
119
+
120
+ def check_anchor_fd(fd, witness):
121
+ if identity(fd) != witness["namespace"] or owner(fd) != witness["ownerUid"]:
122
+ fail("namespace anchor differs")
123
+ up = parent(fd)
124
+ try:
125
+ if identity(up) != witness["parent"] or witness["parent"] != witness["mapping"]["hostNamespace"]:
126
+ fail("namespace parent differs")
127
+ finally:
128
+ os.close(up)
129
+
130
+
131
+ def anchor(mapping, process):
132
+ check_host(mapping)
133
+ current_boot = boot()
134
+ pid = process["pid"]
135
+ pidfd = os.pidfd_open(pid, 0)
136
+ nsfd = None
137
+ try:
138
+ values = checked_process(pid, process["startTicks"], pidfd)
139
+ if ids(values, "Uid") != [mapping["ownerUid"]] * 4 or ids(values, "Gid") != [mapping["ownerGid"]] * 4:
140
+ fail("keeper identity differs")
141
+ exact_maps(pid, mapping)
142
+ nsfd = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
143
+ parentfd = parent(nsfd)
144
+ try:
145
+ result = {"schemaVersion": 1, "bootId": current_boot,
146
+ "mapping": mapping, "namespace": identity(nsfd),
147
+ "parent": identity(parentfd), "ownerUid": owner(nsfd),
148
+ "leader": process}
149
+ finally:
150
+ os.close(parentfd)
151
+ if result["parent"] != mapping["hostNamespace"] or result["ownerUid"] != mapping["ownerUid"]:
152
+ fail("keeper namespace is not owned directly by Host")
153
+ if result["namespace"] == result["parent"]:
154
+ fail("keeper did not enter a new user namespace")
155
+ checked_process(pid, process["startTicks"], pidfd)
156
+ exact_maps(pid, mapping)
157
+ same_current_namespace(pid, nsfd)
158
+ checked_process(pid, process["startTicks"], pidfd)
159
+ if boot() != current_boot:
160
+ fail("boot changed")
161
+ return result
162
+ finally:
163
+ if nsfd is not None:
164
+ os.close(nsfd)
165
+ os.close(pidfd)
166
+
167
+
168
+ def candidate(witness, process):
169
+ mapping = witness["mapping"]
170
+ check_host(mapping)
171
+ if boot() != witness["bootId"]:
172
+ fail("boot changed")
173
+ pid = process["pid"]
174
+ pidfd = os.pidfd_open(pid, 0)
175
+ nsfd = None
176
+ try:
177
+ values = checked_process(pid, process["startTicks"], pidfd)
178
+ if ids(values, "Uid") != [mapping["candidateUid"]] * 4 or ids(values, "Gid") != [mapping["candidateGid"]] * 4:
179
+ fail("candidate did not drop all identities")
180
+ if values.get("Groups") != "" or any(int(values[key], 16) != 0 for key in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb")):
181
+ fail("candidate retained groups or capabilities")
182
+ if values.get("NoNewPrivs") != "1" or values.get("Seccomp") != "2" or int(values.get("Seccomp_filters", "0")) < 1:
183
+ fail("candidate privilege or syscall fence differs")
184
+ if len(values.get("NSpid", "").split()) < 2:
185
+ fail("candidate PID namespace missing")
186
+ exact_maps(pid, mapping)
187
+ nsfd = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
188
+ check_anchor_fd(nsfd, witness)
189
+ result = {"schemaVersion": 1, "pid": pid, "startTicks": process["startTicks"],
190
+ "bootId": witness["bootId"], "namespace": identity(nsfd),
191
+ "uid": mapping["candidateUid"], "gid": mapping["candidateGid"],
192
+ "groups": [], "capabilities": "0", "noNewPrivileges": True, "seccomp": 2}
193
+ after = checked_process(pid, process["startTicks"], pidfd)
194
+ for key in ("Uid", "Gid", "Groups", "CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb", "NoNewPrivs", "Seccomp", "Seccomp_filters", "NSpid"):
195
+ if after.get(key) != values.get(key):
196
+ fail("candidate security state changed")
197
+ exact_maps(pid, mapping)
198
+ same_current_namespace(pid, nsfd)
199
+ checked_process(pid, process["startTicks"], pidfd)
200
+ return result
201
+ finally:
202
+ if nsfd is not None:
203
+ os.close(nsfd)
204
+ os.close(pidfd)
205
+
206
+
207
+ def descendant_anchor(fd, witness, held):
208
+ """Keep each FD while walking; missing/replaced ancestry never grants kill."""
209
+ current = fd
210
+ for _ in range(33):
211
+ if identity(current) == witness["namespace"]:
212
+ check_anchor_fd(current, witness)
213
+ return
214
+ current = parent(current)
215
+ held.append(current)
216
+ fail("namespace ancestry exceeds bound")
217
+
218
+
219
+ def kill_members(request):
220
+ witness = request["witness"]
221
+ mapping = witness["mapping"]
222
+ check_host(mapping)
223
+ if boot() != witness["bootId"]:
224
+ fail("boot changed")
225
+ freezer = request["freezerPath"]
226
+ expected_suffix = request["cgroup"][1]
227
+ if request["cgroup"][0] != "freezer" or not freezer.startswith("/sys/fs/cgroup/") or not freezer.endswith(expected_suffix):
228
+ fail("freezer identity differs")
229
+ if read(freezer + "/freezer.state").strip() != "FROZEN":
230
+ fail("job group is not frozen")
231
+ held = []
232
+ namespaces = []
233
+ try:
234
+ for item in request["processes"]:
235
+ pid, expected_ticks = item
236
+ try:
237
+ pidfd = os.pidfd_open(pid, 0)
238
+ except ProcessLookupError:
239
+ continue
240
+ held.append(pidfd)
241
+ if not alive(pidfd):
242
+ continue
243
+ values = checked_process(pid, expected_ticks, pidfd)
244
+ memberships = [line.split(":", 2)[1:] for line in read("/proc/%d/cgroup" % pid).splitlines()]
245
+ if request["cgroup"] not in memberships:
246
+ fail("process escaped frozen group")
247
+ nsfd = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
248
+ namespaces.append(nsfd)
249
+ outside = any(entry["pid"] == pid and entry["startTicks"] == expected_ticks for entry in request["outsideGates"])
250
+ if outside and identity(nsfd) == mapping["hostNamespace"]:
251
+ if ids(values, "Uid") != [mapping["ownerUid"]] * 4 or ids(values, "Gid") != [mapping["ownerGid"]] * 4:
252
+ fail("original keeper gate changed identity")
253
+ else:
254
+ if any(value not in (mapping["ownerUid"], mapping["candidateUid"]) for value in ids(values, "Uid")):
255
+ fail("foreign UID in job group")
256
+ if any(value not in (mapping["ownerGid"], mapping["candidateGid"]) for value in ids(values, "Gid")):
257
+ fail("foreign GID in job group")
258
+ descendant_anchor(nsfd, witness, namespaces)
259
+ if identity(nsfd) == witness["namespace"]:
260
+ exact_maps(pid, mapping)
261
+ same_current_namespace(pid, nsfd)
262
+ checked_process(pid, expected_ticks, pidfd)
263
+ # All members are independently checked before any signal is sent.
264
+ if read(freezer + "/freezer.state").strip() != "FROZEN":
265
+ fail("freezer barrier changed")
266
+ for fd in held:
267
+ try:
268
+ signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0)
269
+ except ProcessLookupError:
270
+ pass
271
+ return {"signaled": len(held)}
272
+ finally:
273
+ for fd in namespaces:
274
+ os.close(fd)
275
+ for fd in held:
276
+ os.close(fd)
277
+
278
+
279
+ def main():
280
+ raw = sys.stdin.buffer.read(1048577)
281
+ if len(raw) > 1048576:
282
+ fail("request exceeds bound")
283
+ request = json.loads(raw)
284
+ operation = request.get("operation")
285
+ if operation == "anchor":
286
+ result = anchor(request["mapping"], request["identity"])
287
+ elif operation == "candidate":
288
+ result = candidate(request["witness"], request["identity"])
289
+ elif operation == "kill":
290
+ if not isinstance(request["processes"], list) or len(request["processes"]) > 16384 or len(request["outsideGates"]) > 1:
291
+ fail("process inventory exceeds bound")
292
+ result = kill_members(request)
293
+ else:
294
+ fail("unsupported operation")
295
+ print(json.dumps(result, separators=(",", ":")))
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()