mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Add the Kimi lifecycle and recovery qualification harness. Preserve original grades and cleanup evidence, strict native ownership audits, bounded startup and cleanup, and early rejection of unsupported local fault execution before credentials or resources. Co-Authored-By: Paperclip <noreply@paperclip.ing>
271 lines
15 KiB
Python
271 lines
15 KiB
Python
import base64
|
|
import hashlib
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import stat
|
|
import sys
|
|
|
|
PACK = '/opt/paperclip-runner/provider-pack'
|
|
ENTRY = 'node_modules/@moonshot-ai/kimi-code/dist/main.mjs'
|
|
NODE = 'node/bin/node'
|
|
GUARD = '.paperclip-native-module-guard.cjs'
|
|
BOOTSTRAP = '.paperclip-native-entry.cjs'
|
|
PLACEHOLDER = '/__paperclip_kimi_fault_snapshot__/distribution'
|
|
|
|
|
|
def require(value, code):
|
|
if not value:
|
|
raise RuntimeError('kimi_provider_fault:' + code)
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def proc(pid, boot):
|
|
raw = Path(f'/proc/{pid}/stat').read_text()
|
|
require(raw.startswith(f'{pid} ('), 'stat_pid')
|
|
fields = raw.rsplit(') ', 1)[1].split()
|
|
require(len(fields) >= 20 and fields[0] != 'Z' and fields[19].isdigit(), 'dead_or_invalid_process')
|
|
return {'pid': pid, 'ppid': int(fields[1]), 'startTicks': fields[19], 'bootId': boot}
|
|
|
|
|
|
def argv(pid):
|
|
raw = Path(f'/proc/{pid}/cmdline').read_bytes()
|
|
require(0 < len(raw) <= 65536, 'argv_bound')
|
|
return raw.decode('utf8').rstrip('\0').split('\0')
|
|
|
|
|
|
def flag(args, name):
|
|
require(args.count(name) == 1 and args.index(name) + 1 < len(args), 'root_flag')
|
|
return args[args.index(name) + 1]
|
|
|
|
|
|
def checked_file(path, maximum, sealed=False):
|
|
require(os.path.realpath(path) == path, 'file_realpath')
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
try:
|
|
before = os.fstat(fd)
|
|
require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 and 0 < before.st_size <= maximum, 'file_shape')
|
|
require(not sealed or before.st_mode & 0o222 == 0, 'file_writable')
|
|
chunks, size = [], 0
|
|
while True:
|
|
chunk = os.read(fd, 1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
size += len(chunk)
|
|
require(size <= maximum, 'file_bound')
|
|
after, named = os.fstat(fd), os.lstat(path)
|
|
identity = lambda value: (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns, value.st_mode, value.st_nlink)
|
|
require(size == before.st_size and identity(before) == identity(after) == identity(named), 'file_changed')
|
|
return b''.join(chunks), (before.st_dev, before.st_ino)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def checked_parent_descriptor(pid):
|
|
path = f'/proc/{pid}/fd/10'
|
|
executable = f'/proc/{pid}/exe'
|
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC)
|
|
identity = lambda value: (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns, value.st_mode, value.st_nlink)
|
|
try:
|
|
before = os.fstat(fd)
|
|
require(stat.S_ISREG(before.st_mode) and before.st_nlink in (0, 1) and 0 < before.st_size <= 256 * 1024 * 1024, 'parent_descriptor_shape')
|
|
require(identity(before) == identity(os.stat(path)) == identity(os.stat(executable)), 'parent_node_descriptor')
|
|
seals = None
|
|
if before.st_nlink == 0:
|
|
require(all(hasattr(fcntl, name) for name in ('F_GET_SEALS', 'F_SEAL_WRITE', 'F_SEAL_GROW', 'F_SEAL_SHRINK', 'F_SEAL_SEAL')), 'parent_memfd_required')
|
|
require(os.readlink(f'/proc/self/fd/{fd}') == '/memfd:paperclip-verified-launch (deleted)' and before.st_mode & 0o222 == 0, 'parent_memfd_required')
|
|
required = fcntl.F_SEAL_WRITE | fcntl.F_SEAL_GROW | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_SEAL
|
|
seals = fcntl.fcntl(fd, fcntl.F_GET_SEALS)
|
|
require(type(seals) is int and seals & required == required, 'parent_memfd_seals')
|
|
chunks, size = [], 0
|
|
while True:
|
|
chunk = os.read(fd, 1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
size += len(chunk)
|
|
require(size <= 256 * 1024 * 1024, 'parent_descriptor_shape')
|
|
require(size == before.st_size and identity(before) == identity(os.fstat(fd)) == identity(os.stat(path)) == identity(os.stat(executable)), 'parent_descriptor_changed')
|
|
if seals is not None:
|
|
require(fcntl.fcntl(fd, fcntl.F_GET_SEALS) == seals, 'parent_memfd_seals')
|
|
return b''.join(chunks), (before.st_dev, before.st_ino)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def checked_runner(path):
|
|
before = os.lstat(path)
|
|
require(stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode), 'runner_shape')
|
|
resolved = os.path.realpath(path)
|
|
content, inode = checked_file(resolved, 256 * 1024 * 1024)
|
|
after = os.lstat(path)
|
|
identity = lambda value: (value.st_dev, value.st_ino, value.st_mode, value.st_mtime_ns, value.st_ctime_ns)
|
|
require(identity(before) == identity(after) and os.path.realpath(path) == resolved, 'runner_changed')
|
|
return content, inode
|
|
|
|
|
|
def parse_closure(content, expected):
|
|
manifest = json.loads(content)
|
|
entries = manifest.get('entries') if isinstance(manifest, dict) else None
|
|
require(isinstance(entries, list) and 0 < len(entries) <= 30000, 'closure_inventory')
|
|
normalized, previous, total = [], '', 0
|
|
for entry in entries:
|
|
require(isinstance(entry, dict) and set(entry) == {'path', 'sha256', 'size', 'executable'}, 'closure_shape')
|
|
path = entry['path']
|
|
require(isinstance(path, str) and 0 < len(path) <= 4096 and not os.path.isabs(path)
|
|
and not re.search(r'[\x00-\x1f\x7f\\]', path) and path > previous
|
|
and all(part not in ('', '.', '..') for part in path.split('/'))
|
|
and path != 'manifest.json' and not path.startswith('.paperclip-'), 'closure_path')
|
|
require(isinstance(entry['sha256'], str) and re.fullmatch(r'[a-f0-9]{64}', entry['sha256'])
|
|
and type(entry['size']) is int and 0 <= entry['size'] <= 384 * 1024 * 1024
|
|
and type(entry['executable']) is bool, 'closure_metadata')
|
|
total += entry['size']
|
|
require(total <= 1024 * 1024 * 1024, 'closure_bound')
|
|
previous = path
|
|
normalized.append({key: entry[key] for key in ('path', 'sha256', 'size', 'executable')})
|
|
require(re.fullmatch(r'[a-f0-9]{64}', expected) and digest(json.dumps(normalized, separators=(',', ':'), ensure_ascii=False).encode()) == expected, 'closure_pin')
|
|
return normalized
|
|
|
|
|
|
def expected_sources(config, distribution, entries):
|
|
bootstrap, guard = config['bootstrapTemplate'], config['guardTemplate']
|
|
require(isinstance(bootstrap, str) and 0 < len(bootstrap) <= 32768 and PLACEHOLDER in bootstrap, 'bootstrap_template')
|
|
require(isinstance(guard, str) and 0 < len(guard) <= 32768 and guard.count('const digests={};') == 1, 'guard_template')
|
|
digests = {distribution + '/' + entry['path']: entry['sha256'] for entry in entries}
|
|
return bootstrap.replace(PLACEHOLDER, distribution).encode(), guard.replace('const digests={};', 'const digests=' + json.dumps(digests, separators=(',', ':'), ensure_ascii=False) + ';').encode()
|
|
|
|
|
|
def inspect(config):
|
|
require(set(config) == {'root', 'binding', 'runtimeEnvironmentLeaseId', 'runnerdSha256', 'closureSha256', 'nodeSha256', 'guardLoaderSha256', 'bootstrapTemplate', 'guardTemplate'}, 'config_keys')
|
|
require(all(isinstance(config[key], str) and re.fullmatch(r'sha256:[a-f0-9]{64}', config[key]) for key in ('runnerdSha256', 'nodeSha256', 'guardLoaderSha256')), 'digest_shape')
|
|
binding, expected = config['binding'], config['root']
|
|
boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
|
require(re.fullmatch(r'[a-f0-9-]{36}', boot) and boot == expected['bootId'], 'boot_changed')
|
|
root = proc(expected['pid'], boot)
|
|
require(root == expected and os.getpgid(root['pid']) == root['pid'], 'root_changed')
|
|
runtime = binding['remoteCwd'] + '/.paperclip-runtime/paperclip-runner'
|
|
runner = runtime + '/bin/paperclip-runnerd'
|
|
root_args = argv(root['pid'])
|
|
require(root_args[0] == runner and flag(root_args, '--run-id') == binding['runId']
|
|
and flag(root_args, '--environment-lease-id') == config['runtimeEnvironmentLeaseId']
|
|
and flag(root_args, '--lifecycle-mode') == 'per_turn', 'root_binding')
|
|
require(re.fullmatch(re.escape(runtime) + r'/sessions/[a-f0-9]{64}/runner', flag(root_args, '--state-dir')), 'root_session')
|
|
runner_bytes, runner_inode = checked_runner(runner)
|
|
root_exe = os.stat(f'/proc/{root["pid"]}/exe')
|
|
require((root_exe.st_dev, root_exe.st_ino) == runner_inode and 'sha256:' + digest(runner_bytes) == config['runnerdSha256'], 'runner_executable')
|
|
metadata, _ = checked_file(PACK + '/provider-assets/kimi/linux-x64/native-closure.json', 4 * 1024 * 1024)
|
|
ordered_entries = parse_closure(metadata, config['closureSha256'])
|
|
entries = {entry['path']: entry for entry in ordered_entries}
|
|
require(NODE in entries and ENTRY in entries and entries[NODE]['executable'] and entries[NODE]['size'] > 0
|
|
and entries[ENTRY]['size'] > 0 and 'sha256:' + entries[NODE]['sha256'] == config['nodeSha256'], 'required_entries')
|
|
pids = [int(pid) for pid in os.listdir('/proc') if pid.isdigit() and int(pid) > 1]
|
|
require(len(pids) <= 4096, 'process_bound')
|
|
table = {}
|
|
for pid in pids:
|
|
try:
|
|
table[pid] = proc(pid, boot)
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
continue
|
|
except RuntimeError as error:
|
|
if str(error).endswith(':dead_or_invalid_process'):
|
|
continue
|
|
raise
|
|
|
|
def ancestry(pid):
|
|
chain = []
|
|
while pid != root['pid']:
|
|
require(pid in table and len(chain) < 64 and pid not in [item['pid'] for item in chain], 'ancestry')
|
|
chain.append(table[pid])
|
|
pid = table[pid]['ppid']
|
|
result = chain + [root]
|
|
require(all(int(child['startTicks']) >= int(parent['startTicks']) for child, parent in zip(result, result[1:])), 'ancestry_birth')
|
|
return result
|
|
|
|
matches = []
|
|
for pid in table:
|
|
try:
|
|
chain = ancestry(pid)
|
|
except RuntimeError:
|
|
continue
|
|
if len(chain) < 3:
|
|
continue
|
|
parent = table[pid]['ppid']
|
|
try:
|
|
args = argv(parent)
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
continue
|
|
if len(args) != 10 or args[1] != '--eval' or args[4] != BOOTSTRAP:
|
|
continue
|
|
distribution = args[3]
|
|
require(re.fullmatch(r'/tmp/paperclip-acpx-native-[A-Za-z0-9_-]+/distribution', distribution), 'snapshot_path')
|
|
require(args[4:] == [BOOTSTRAP, '0', '0', 'commonjs', '[]', '0'] and 'sha256:' + digest(args[2].encode()) == config['guardLoaderSha256'], 'parent_bootstrap')
|
|
require(argv(pid) == ['kimi-code'], 'native_title')
|
|
for directory in (distribution, os.path.dirname(distribution)):
|
|
status = os.lstat(directory)
|
|
require(stat.S_ISDIR(status.st_mode) and not status.st_mode & 0o222 and os.path.realpath(directory) == directory, 'snapshot_seal')
|
|
require(os.readlink(f'/proc/{pid}/cwd') == binding['remoteCwd'] and os.readlink(f'/proc/{parent}/cwd') == binding['remoteCwd'], 'cwd')
|
|
identities = {}
|
|
for name in (NODE, ENTRY):
|
|
content, inode = checked_file(distribution + '/' + name, 256 * 1024 * 1024, True)
|
|
require(len(content) == entries[name]['size'] and digest(content) == entries[name]['sha256'], 'snapshot_digest')
|
|
identities[name] = inode
|
|
bootstrap, guard = expected_sources(config, distribution, ordered_entries)
|
|
require(checked_file(distribution + '/' + BOOTSTRAP, 32768, True)[0] == bootstrap, 'native_bootstrap_bytes')
|
|
require(checked_file(distribution + '/' + GUARD, 4 * 1024 * 1024, True)[0] == guard, 'native_guard_bytes')
|
|
native_exe, held_node = os.stat(f'/proc/{pid}/exe'), os.stat(f'/proc/{pid}/fd/7')
|
|
require((native_exe.st_dev, native_exe.st_ino) == identities[NODE] == (held_node.st_dev, held_node.st_ino), 'native_executable')
|
|
if args[0] == '/proc/self/fd/10':
|
|
parent_bytes, parent_inode = checked_parent_descriptor(parent)
|
|
else:
|
|
parent_exe_path = os.path.realpath(f'/proc/{parent}/exe')
|
|
parent_bytes, parent_inode = checked_file(parent_exe_path, 256 * 1024 * 1024)
|
|
require('sha256:' + digest(parent_bytes) == config['nodeSha256'], 'parent_node')
|
|
if args[0] != '/proc/self/fd/10':
|
|
require(args[0] == PACK + '/node_modules/node/bin/node' and checked_file(args[0], 256 * 1024 * 1024)[1] == parent_inode, 'parent_node_path')
|
|
directory_fd = os.stat(f'/proc/{parent}/fd/4')
|
|
directory = os.lstat(distribution)
|
|
require(stat.S_ISDIR(directory_fd.st_mode) and (directory_fd.st_dev, directory_fd.st_ino) == (directory.st_dev, directory.st_ino), 'parent_directory_descriptor')
|
|
source_mode = os.stat(f'/proc/{parent}/fd/3').st_mode
|
|
require(stat.S_ISFIFO(source_mode) or stat.S_ISSOCK(source_mode), 'parent_source_pipe')
|
|
for identity in chain:
|
|
require(proc(identity['pid'], boot) == identity, 'ancestry_changed')
|
|
matches.append({'target': table[pid], 'ancestry': chain, 'nodeSha256': config['nodeSha256'],
|
|
'entrypointSha256': 'sha256:' + entries[ENTRY]['sha256'], 'closureSha256': config['closureSha256'],
|
|
'guardLoaderSha256': config['guardLoaderSha256'], 'guardSha256': 'sha256:' + digest(guard), 'bootstrapSha256': 'sha256:' + digest(bootstrap),
|
|
'entrypointAttribution': 'pinned_native_bootstrap_parent', 'originalChildArgvAvailable': False, 'observedChildTitle': 'kimi-code'})
|
|
require(len(matches) == 1, 'unique_native_child')
|
|
return matches[0]
|
|
|
|
|
|
def terminate(config):
|
|
require(sys.platform == 'linux' and hasattr(os, 'pidfd_open') and hasattr(signal, 'pidfd_send_signal'), 'pidfd_required')
|
|
before = inspect(config)
|
|
pid = before['target']['pid']
|
|
require(pid not in (os.getpid(), config['root']['pid']), 'not_worker')
|
|
fd = os.pidfd_open(pid)
|
|
try:
|
|
require(inspect(config) == before, 'identity_changed_after_pidfd')
|
|
fields = dict(line.split(':', 1) for line in Path(f'/proc/self/fdinfo/{fd}').read_text().splitlines() if ':' in line)
|
|
require(int(fields.get('Pid', '-1').strip()) == pid, 'pidfd_retired')
|
|
signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0)
|
|
return {'schema': 'paperclip.e2e.kimi-provider-death.v1', 'binding': config['binding'], 'runtimeEnvironmentLeaseId': config['runtimeEnvironmentLeaseId'],
|
|
**before, 'signalled': True, 'signal': 'SIGKILL', 'targetKind': 'kimi_native_child', 'workerSignalled': False}
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
require(len(sys.argv) == 2 and len(sys.argv[1]) <= 131072, 'input_bound')
|
|
print(json.dumps(terminate(json.loads(base64.b64decode(sys.argv[1], validate=True))), separators=(',', ':')))
|
|
except Exception:
|
|
print(json.dumps({'ok': False, 'code': 'kimi_provider_identity_or_signal_failed'}))
|
|
sys.exit(1)
|