| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111 |
- """Export current workshop instructions and code as inert bytes, never credentials."""
- import sys
- sys.dont_write_bytecode = True
- import argparse
- import datetime
- import hashlib
- import json
- import os
- from pathlib import Path, PurePosixPath
- import re
- import zipfile
- SHA = lambda raw: hashlib.sha256(raw).hexdigest()
- ROOT_FILES = {'SKILL.md', 'FILES.sha256', 'runtime-lock.json'}
- def permitted(name):
- parts = PurePosixPath(name).parts
- if not parts or '..' in parts or name.startswith('/') or '\\' in name or ':' in name:
- return False
- if name in ROOT_FILES:
- return True
- # Instance binding, envelopes, key material, environment and employee state
- # are outside these explicit documentation/code namespaces.
- if any(re.search(r'credential|binding|envelope|private|secret|recipient|decrypt|\.env', p, re.I) for p in parts):
- return False
- if parts[0] == 'references':
- return name.endswith('.md') or name == 'references/business-data-dictionary.json'
- return parts[0] == 'scripts' and Path(name).suffix in ('.py', '.mjs', '.js')
- def export(skill_root, out):
- root = Path(skill_root).absolute()
- target = Path(out).absolute()
- for path in (root, target):
- if any(p.is_symlink() for p in (path, *path.parents)):
- raise ValueError('REDIRECTED_PATH')
- if not root.is_dir() or not (root / 'SKILL.md').is_file():
- raise ValueError('WORKSHOP_SKILL_NOT_FOUND')
- if target.exists() or root == target or root in target.parents:
- raise ValueError('NEW_OUTPUT_OUTSIDE_SKILL_REQUIRED')
- files = {}
- for path in root.rglob('*'):
- name = path.relative_to(root).as_posix()
- if not permitted(name):
- continue
- if any(p.is_symlink() for p in (path, *path.parents)):
- raise ValueError('REDIRECTED_SKILL_FILE')
- if not path.is_file():
- continue
- if path.stat().st_size > 8 * 1024 * 1024:
- raise ValueError('SKILL_FILE_TOO_LARGE')
- files[name] = path.read_bytes()
- if len(files) > 100 or sum(map(len, files.values())) > 20 * 1024 * 1024:
- raise ValueError('SKILL_EXPORT_TOO_LARGE')
- manifest_state = []
- manifest = files.get('FILES.sha256')
- if manifest:
- for line in manifest.decode('utf-8-sig').splitlines():
- digest, sep, name = line.partition(' ')
- if not sep or not re.fullmatch('[a-f0-9]{64}', digest):
- raise ValueError('INVALID_INSTALLED_MANIFEST')
- manifest_state.append({'path': name,
- 'state': ('MATCH' if SHA(files[name]) == digest else 'MISMATCH')
- if name in files else 'NOT_EXPORTED'})
- # Re-read the public files before sealing; do not copy during an upgrade.
- if any((root / name).read_bytes() != raw for name, raw in files.items()):
- raise ValueError('SKILL_CHANGED_DURING_EXPORT')
- receipt = {'schema': 'ksf.workshop.skill-readonly-export.v1',
- 'exportedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
- 'skillName': 'ksf-workshop-runtime', 'skillSha256': SHA(files['SKILL.md']),
- 'skillLines': len(files['SKILL.md'].decode('utf-8-sig').splitlines()),
- 'files': [{'path': n, 'bytes': len(b), 'sha256': SHA(b)} for n, b in sorted(files.items())],
- 'installedManifest': manifest_state,
- 'readOnly': True, 'installedFilesModified': False, 'exportFilesWritten': True,
- 'packageCodeExecuted': False, 'credentialsExported': False,
- 'databaseWrites': 0, 'networkCalls': 0, 'paidCalls': 0,
- 'agentReloadVerified': False, 'actualBusinessWriteVerified': False}
- target.mkdir(parents=True, mode=0o700)
- archive = target / 'ksf-workshop-runtime-current.zip'
- payload = {'ksf-workshop-runtime/' + n: b for n, b in files.items()}
- payload['EXPORT.json'] = (json.dumps(receipt, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
- payload['EXPORT.sha256'] = ''.join(SHA(b) + ' ' + n + '\n' for n, b in sorted(payload.items())).encode('utf-8')
- with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
- for name, raw in payload.items():
- z.writestr(name, raw)
- os.chmod(archive, 0o600)
- with zipfile.ZipFile(archive) as z:
- if z.testzip() is not None or any(z.read(n) != b for n, b in payload.items()):
- raise ValueError('EXPORT_VERIFICATION_FAILED')
- result = {'ok': True, 'archive': str(archive), 'bytes': archive.stat().st_size,
- 'sha256': SHA(archive.read_bytes()), 'skillSha256': receipt['skillSha256'],
- 'skillLines': receipt['skillLines'], 'files': len(files),
- 'installedManifestMismatches': [r['path'] for r in manifest_state if r['state'] == 'MISMATCH'],
- 'installedFilesModified': False, 'databaseWrites': 0, 'paidCalls': 0}
- (target / 'export-receipt.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
- os.chmod(target / 'export-receipt.json', 0o600)
- return result
- if __name__ == '__main__':
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--skill-root', default='/opt/data/skills/ksf-workshop-runtime')
- parser.add_argument('--out', required=True)
- args = parser.parse_args()
- try:
- print(json.dumps(export(args.skill_root, args.out), ensure_ascii=False))
- except (ValueError, OSError, KeyError) as error:
- code = str(error) if re.fullmatch('[A-Z][A-Z0-9_]+', str(error)) else 'EXPORT_FAILED'
- print(json.dumps({'ok': False, 'error': {'code': code}, 'installedFilesModified': False}, ensure_ascii=False))
- sys.exit(1)
|