"""BR-64: managed, immutable standard Skill packages; scripts are never executed.""" from __future__ import annotations import base64 import hashlib import io import json import os import re import shutil import zipfile from pathlib import Path, PurePosixPath import yaml from services import assistant_store as store, component_registry as registry, knowledge_cards from services.assistant_materials import FIELDS from services.lease_case_library import LibraryError ROOT = Path(__file__).resolve().parents[1] / 'xingbang_data' / 'skill_packages' MAX_TOTAL = 10 * 1024 * 1024 def validate_files(files): if not 1 <= len(files) <= 100: raise LibraryError('SKILL_PACKAGE_INVALID', 'Skills 文件夹需要 1–100 个文件') decoded, total = {}, 0 roots = set() for item in files: path = str(item['path']).replace('\\', '/') parts = PurePosixPath(path).parts if not parts or len(parts) < 2 or path.startswith('/') or any( p in ('.', '..') or ':' in p or not p.strip() or p.endswith((' ', '.')) or re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', p, re.I) for p in path.split('/')): raise LibraryError('SKILL_PACKAGE_INVALID', '文件路径不安全,请选择一个标准 Skills 文件夹') roots.add(parts[0]) relative = '/'.join(parts[1:]) if relative in decoded: raise LibraryError('SKILL_PACKAGE_INVALID', '文件夹存在重复路径') try: raw = base64.b64decode(item['content_base64'], validate=True) except (ValueError, TypeError): raise LibraryError('SKILL_PACKAGE_INVALID', '文件内容编码无效') from None total += len(raw) if len(raw) > 2 * 1024 * 1024 or total > MAX_TOTAL: raise LibraryError('SKILL_PACKAGE_INVALID', '文件超过单文件 2MiB 或文件夹 10MiB 上限') decoded[relative] = raw if len(roots) != 1 or 'SKILL.md' not in decoded: raise LibraryError('SKILL_PACKAGE_INVALID', '请选择单个文件夹,根目录必须有 SKILL.md') try: text = decoded['SKILL.md'].decode('utf-8-sig') if len(decoded['SKILL.md']) > 65536: raise ValueError() match = re.match(r'\A---\s*\r?\n(.*?)\r?\n---\s*\r?\n([\s\S]+)\Z', text, re.S) meta = yaml.safe_load(match.group(1)) if match else None if not isinstance(meta, dict): raise ValueError() name, description = meta.get('name'), meta.get('description') if not isinstance(name, str) or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name) or len(name) > 64: raise ValueError() if not isinstance(description, str) or not description.strip() or len(description) > 1024 or name != next(iter(roots)): raise ValueError() except (UnicodeError, ValueError, yaml.YAMLError): raise LibraryError('SKILL_PACKAGE_INVALID', 'SKILL.md 需要标准 YAML name/description,name 与文件夹名一致') from None return name, description, text, decoded def write_bundle(db, component_id, version, name, files, root=None): root = Path(root or ROOT).resolve() root.mkdir(parents=True, exist_ok=True) package_id = store.uid('package') package_root = root / package_id folder = package_root / name if package_root.parent != root: raise ValueError('不安全的托管路径') folder.mkdir(parents=True) try: manifest = [] for relative, raw in sorted(files.items()): target = folder.joinpath(*PurePosixPath(relative).parts) if not target.resolve().is_relative_to(folder.resolve()): raise ValueError('不安全的文件路径') target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(raw) manifest.append({'path': relative, 'size': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()}) body = {'id': package_id, 'component_id': component_id, 'version': version, 'name': name, 'manifest': manifest, 'content_hash': store.digest(manifest), 'created_at': store.now()} store.put(db, 'skill_package', body, immutable=True) return body except Exception: shutil.rmtree(package_root) # Only the fresh UUID folder created above. raise def import_package(db, project, actor, files, required_field=None, root=None): name, description, text, decoded = validate_files(files) if required_field and required_field not in set(FIELDS.values()): raise LibraryError('SKILL_PACKAGE_INVALID', '核查字段不在允许的标准字段中') for kind in ('component_draft', 'component_version'): if any(row.get('package_name') == name and row.get('project_id') == project['id'] for row in store.listing(db, kind)): raise LibraryError('SKILL_PACKAGE_INVALID', '该项目已导入同名 Skill,请修订版本,不覆盖原文件夹') body = {'id': store.uid('skill.folder'), 'kind': 'skill', 'name': description[:80], 'package_name': name, 'scope': project['data_domain'], 'version': '1.0.0', 'engine': 'required' if required_field else 'model', 'inputs': [required_field] if required_field else [], 'code': 'folder.' + name, 'title': description[:120], 'instructions': text, 'description': description, 'import_method': 'folder', 'allowed_tools': [] if required_field else sorted(registry.TOOLS), 'dimensions': [{'id': 'folder.' + name, 'name': description[:20], 'label': description[:20], 'scale_id': 'stage', 'fields': [required_field]}] if required_field else []} draft = knowledge_cards.propose(db, body, actor, project['id'], origin='manual') package = write_bundle(db, draft['component_id'], draft['version'], name, decoded, root) store.event(db, project['id'], 'skill.folder_import', actor, {'draft_id': draft['id'], 'package_id': package['id'], 'content_hash': package['content_hash']}) return dict(draft, package=package) def standard_package(db, component, root=None): component_id = component.get('component_id') or component['id'] version = component['version'] packages = [p for p in store.listing(db, 'skill_package') if p['component_id'] == component_id and p['version'] == version] if packages: return packages[0] name = re.sub('[^a-z0-9-]', '-', component_id.lower()).strip('-')[:64] description = str(component.get('description') or component['name'])[:1024] text = '---\n' + yaml.safe_dump({'name': name, 'description': description}, allow_unicode=True, sort_keys=False) + '---\n\n' text += '# ' + component['name'] + '\n\n' + str(component.get('instructions') or '按 references/executor.json 中的受控执行器、适用字段与规则进行核查。') text += '\n\n## 执行边界\n只读核查;不执行脚本,不扩大工具权限,不替代正式评审会。\n' return write_bundle(db, component_id, version, name, {'SKILL.md': text.encode('utf-8'), 'references/executor.json': json.dumps(component, ensure_ascii=False, indent=2).encode('utf-8')}, root) def content(db, component, root=None): package = standard_package(db, component, root) folder = Path(root or ROOT).resolve() / package['id'] / package['name'] if folder.is_symlink() or not folder.resolve().is_relative_to(Path(root or ROOT).resolve()): raise LibraryError('SKILL_PACKAGE_INVALID', '托管 Skills 文件夹路径无效') files = [] for entry in package['manifest']: path = folder / entry['path'] if path.is_symlink() or not path.resolve().is_relative_to(folder.resolve()): raise LibraryError('SKILL_PACKAGE_INVALID', '托管文件路径无效') raw = path.read_bytes() if hashlib.sha256(raw).hexdigest() != entry['sha256']: raise LibraryError('SKILL_PACKAGE_INVALID', 'Skills 文件已改变,请通过新版本修订') try: text = raw.decode('utf-8-sig') except UnicodeError: text = None files.append({**entry, 'text': text}) return {**package, 'files': files} def archive(db, component, root=None): package = content(db, component, root) folder = Path(root or ROOT).resolve() / package['id'] / package['name'] stream = io.BytesIO() with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as zipped: for entry in package['manifest']: zipped.writestr(package['name'] + '/' + entry['path'], (folder / entry['path']).read_bytes()) return package['name'], stream.getvalue() def open_folder(db, component, root=None): package = content(db, component, root) if os.name != 'nt': raise LibraryError('SKILL_FOLDER_UNAVAILABLE', '当前部署不支持打开本机文件夹,请下载标准包') os.startfile(str(Path(root or ROOT).resolve() / package['id'] / package['name'])) return {'opened': True, 'package_id': package['id']}