export-workshop-skill-readonly.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """Export current workshop instructions and code as inert bytes, never credentials."""
  2. import sys
  3. sys.dont_write_bytecode = True
  4. import argparse
  5. import datetime
  6. import hashlib
  7. import json
  8. import os
  9. from pathlib import Path, PurePosixPath
  10. import re
  11. import zipfile
  12. SHA = lambda raw: hashlib.sha256(raw).hexdigest()
  13. ROOT_FILES = {'SKILL.md', 'FILES.sha256', 'runtime-lock.json'}
  14. def permitted(name):
  15. parts = PurePosixPath(name).parts
  16. if not parts or '..' in parts or name.startswith('/') or '\\' in name or ':' in name:
  17. return False
  18. if name in ROOT_FILES:
  19. return True
  20. # Instance binding, envelopes, key material, environment and employee state
  21. # are outside these explicit documentation/code namespaces.
  22. if any(re.search(r'credential|binding|envelope|private|secret|recipient|decrypt|\.env', p, re.I) for p in parts):
  23. return False
  24. if parts[0] == 'references':
  25. return name.endswith('.md') or name == 'references/business-data-dictionary.json'
  26. return parts[0] == 'scripts' and Path(name).suffix in ('.py', '.mjs', '.js')
  27. def export(skill_root, out):
  28. root = Path(skill_root).absolute()
  29. target = Path(out).absolute()
  30. for path in (root, target):
  31. if any(p.is_symlink() for p in (path, *path.parents)):
  32. raise ValueError('REDIRECTED_PATH')
  33. if not root.is_dir() or not (root / 'SKILL.md').is_file():
  34. raise ValueError('WORKSHOP_SKILL_NOT_FOUND')
  35. if target.exists() or root == target or root in target.parents:
  36. raise ValueError('NEW_OUTPUT_OUTSIDE_SKILL_REQUIRED')
  37. files = {}
  38. for path in root.rglob('*'):
  39. name = path.relative_to(root).as_posix()
  40. if not permitted(name):
  41. continue
  42. if any(p.is_symlink() for p in (path, *path.parents)):
  43. raise ValueError('REDIRECTED_SKILL_FILE')
  44. if not path.is_file():
  45. continue
  46. if path.stat().st_size > 8 * 1024 * 1024:
  47. raise ValueError('SKILL_FILE_TOO_LARGE')
  48. files[name] = path.read_bytes()
  49. if len(files) > 100 or sum(map(len, files.values())) > 20 * 1024 * 1024:
  50. raise ValueError('SKILL_EXPORT_TOO_LARGE')
  51. manifest_state = []
  52. manifest = files.get('FILES.sha256')
  53. if manifest:
  54. for line in manifest.decode('utf-8-sig').splitlines():
  55. digest, sep, name = line.partition(' ')
  56. if not sep or not re.fullmatch('[a-f0-9]{64}', digest):
  57. raise ValueError('INVALID_INSTALLED_MANIFEST')
  58. manifest_state.append({'path': name,
  59. 'state': ('MATCH' if SHA(files[name]) == digest else 'MISMATCH')
  60. if name in files else 'NOT_EXPORTED'})
  61. # Re-read the public files before sealing; do not copy during an upgrade.
  62. if any((root / name).read_bytes() != raw for name, raw in files.items()):
  63. raise ValueError('SKILL_CHANGED_DURING_EXPORT')
  64. receipt = {'schema': 'ksf.workshop.skill-readonly-export.v1',
  65. 'exportedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
  66. 'skillName': 'ksf-workshop-runtime', 'skillSha256': SHA(files['SKILL.md']),
  67. 'skillLines': len(files['SKILL.md'].decode('utf-8-sig').splitlines()),
  68. 'files': [{'path': n, 'bytes': len(b), 'sha256': SHA(b)} for n, b in sorted(files.items())],
  69. 'installedManifest': manifest_state,
  70. 'readOnly': True, 'installedFilesModified': False, 'exportFilesWritten': True,
  71. 'packageCodeExecuted': False, 'credentialsExported': False,
  72. 'databaseWrites': 0, 'networkCalls': 0, 'paidCalls': 0,
  73. 'agentReloadVerified': False, 'actualBusinessWriteVerified': False}
  74. target.mkdir(parents=True, mode=0o700)
  75. archive = target / 'ksf-workshop-runtime-current.zip'
  76. payload = {'ksf-workshop-runtime/' + n: b for n, b in files.items()}
  77. payload['EXPORT.json'] = (json.dumps(receipt, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  78. payload['EXPORT.sha256'] = ''.join(SHA(b) + ' ' + n + '\n' for n, b in sorted(payload.items())).encode('utf-8')
  79. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
  80. for name, raw in payload.items():
  81. z.writestr(name, raw)
  82. os.chmod(archive, 0o600)
  83. with zipfile.ZipFile(archive) as z:
  84. if z.testzip() is not None or any(z.read(n) != b for n, b in payload.items()):
  85. raise ValueError('EXPORT_VERIFICATION_FAILED')
  86. result = {'ok': True, 'archive': str(archive), 'bytes': archive.stat().st_size,
  87. 'sha256': SHA(archive.read_bytes()), 'skillSha256': receipt['skillSha256'],
  88. 'skillLines': receipt['skillLines'], 'files': len(files),
  89. 'installedManifestMismatches': [r['path'] for r in manifest_state if r['state'] == 'MISMATCH'],
  90. 'installedFilesModified': False, 'databaseWrites': 0, 'paidCalls': 0}
  91. (target / 'export-receipt.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  92. os.chmod(target / 'export-receipt.json', 0o600)
  93. return result
  94. if __name__ == '__main__':
  95. parser = argparse.ArgumentParser(description=__doc__)
  96. parser.add_argument('--skill-root', default='/opt/data/skills/ksf-workshop-runtime')
  97. parser.add_argument('--out', required=True)
  98. args = parser.parse_args()
  99. try:
  100. print(json.dumps(export(args.skill_root, args.out), ensure_ascii=False))
  101. except (ValueError, OSError, KeyError) as error:
  102. code = str(error) if re.fullmatch('[A-Z][A-Z0-9_]+', str(error)) else 'EXPORT_FAILED'
  103. print(json.dumps({'ok': False, 'error': {'code': code}, 'installedFilesModified': False}, ensure_ascii=False))
  104. sys.exit(1)