skill_bundle.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """Fixed skill transfer operations; installation never executes package code."""
  2. import base64
  3. import hashlib
  4. import json
  5. import os
  6. from pathlib import Path, PurePosixPath
  7. import re
  8. import shutil
  9. import stat
  10. import sys
  11. import tempfile
  12. MAX_FILE = 1024 * 1024
  13. MAX_PACKAGE = 4 * MAX_FILE
  14. MAX_FILES = 128
  15. ROOT = Path('/opt/yaoyao-skills')
  16. def path_name(value):
  17. if not isinstance(value, str) or len(value) > 512:
  18. raise ValueError('技能文件路径无效')
  19. path = PurePosixPath(value)
  20. if not value or path.is_absolute() or str(path) != value or any(part.startswith('.') or re.search(r'[\x00-\x1f\x7f\\:*?"<>|]', part) for part in path.parts):
  21. raise ValueError('技能文件必须使用包内相对路径')
  22. return value
  23. def fingerprint(files):
  24. digest = hashlib.sha256()
  25. for name in sorted(files):
  26. digest.update(name.encode() + b'\0' + hashlib.sha256(files[name]).digest())
  27. return digest.hexdigest()
  28. def install(body):
  29. namespace, revision = body.get('namespace'), body.get('revision')
  30. if any(not isinstance(value, str) or not re.fullmatch(r'[a-f0-9]{64}', value) for value in (namespace, revision)):
  31. raise ValueError('技能版本无效')
  32. encoded = body.get('files')
  33. if not isinstance(encoded, dict) or not 0 < len(encoded) <= MAX_FILES:
  34. raise ValueError('技能文件数量超过限制')
  35. files = {}
  36. for name, data in encoded.items():
  37. path_name(name)
  38. if not isinstance(data, str) or len(data) > MAX_FILE * 4 // 3 + 4:
  39. raise ValueError('技能文件超过限制')
  40. files[name] = base64.b64decode(data, validate=True)
  41. if 'SKILL.md' not in files or sum(map(len, files.values())) > MAX_PACKAGE or any(len(data) > MAX_FILE for data in files.values()) or fingerprint(files) != revision:
  42. raise ValueError('技能包不完整或版本不匹配')
  43. # ROOT and all descendants are root-owned. The cua user cannot substitute
  44. # parents, change modes or overwrite a previously installed revision.
  45. for directory in (ROOT, ROOT / namespace):
  46. if directory.is_symlink():
  47. raise ValueError('技能存储路径不能重定向')
  48. directory.mkdir(exist_ok=True, mode=0o755)
  49. if directory.stat().st_uid != 0 or directory.stat().st_mode & 0o022:
  50. raise ValueError('技能目录权限无效')
  51. target = ROOT / namespace / revision
  52. if target.is_symlink():
  53. raise ValueError('技能版本路径不能重定向')
  54. if not target.exists():
  55. stage = Path(tempfile.mkdtemp(prefix='.install-', dir=target.parent))
  56. try:
  57. for name, data in files.items():
  58. path = stage / name
  59. path.parent.mkdir(parents=True, exist_ok=True)
  60. path.write_bytes(data)
  61. path.chmod(0o444)
  62. for root, dirs, _ in os.walk(stage):
  63. Path(root).chmod(0o555)
  64. os.replace(stage, target)
  65. finally:
  66. if stage.exists():
  67. for root, _, _ in os.walk(stage):
  68. Path(root).chmod(0o755)
  69. shutil.rmtree(stage)
  70. return {'path': str(target), 'revision': revision}
  71. def collect(directory):
  72. root = Path(directory)
  73. if not root.is_absolute() or root.is_symlink() or root.resolve() != root or not root.is_dir():
  74. raise ValueError('草稿必须是隔离环境中的真实绝对目录')
  75. files = {}
  76. for parent, dirs, names in os.walk(root, followlinks=False):
  77. if any((Path(parent) / name).is_symlink() for name in dirs):
  78. raise ValueError('技能草稿不能包含符号链接')
  79. for name in sorted(names):
  80. path = Path(parent) / name
  81. relative = path_name(path.relative_to(root).as_posix())
  82. fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
  83. with os.fdopen(fd, 'rb') as stream:
  84. info = os.fstat(stream.fileno())
  85. if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_FILE:
  86. raise ValueError('技能文件必须为限额内的普通文件')
  87. files[relative] = stream.read(MAX_FILE + 1)
  88. if len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_PACKAGE:
  89. raise ValueError('技能包超过限制')
  90. if 'SKILL.md' not in files:
  91. raise ValueError('草稿缺少 SKILL.md')
  92. return {'files': {name: base64.b64encode(data).decode() for name, data in files.items()}, 'revision': fingerprint(files)}
  93. if __name__ == '__main__':
  94. if sys.argv[1] == 'install':
  95. result = install(json.load(sys.stdin))
  96. elif sys.argv[1] == 'collect':
  97. result = collect(sys.argv[2])
  98. else:
  99. raise ValueError('未知技能传输操作')
  100. print(json.dumps(result))