workshop_registration.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. """No-argument in-process registration, composed with the reviewed aibot bridge.
  2. No identity JSON is accepted from model tool arguments. The Node worker reuses
  3. the existing Skill key/profile code; its stdin is private transport, not proof.
  4. This does not isolate arbitrary same-UID terminal code from local state.
  5. """
  6. from __future__ import annotations
  7. import hashlib
  8. import json
  9. import os
  10. from pathlib import Path
  11. import re
  12. import shutil
  13. import stat
  14. import subprocess
  15. TOOL_NAME = 'ksf_register_current_employee'
  16. WORKER = Path('/opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity-bridge.mjs')
  17. TOOL_SCHEMA = {
  18. 'name': TOOL_NAME,
  19. 'description': '登记当前企微消息发送者,返回员工档案键。无需参数;从本回合可信上下文读取,失败停止,不接受身份转述。只写本地档案,不建会话或写业务库。',
  20. 'parameters': {'type': 'object', 'properties': {}, 'additionalProperties': False},
  21. }
  22. class RegistrationError(Exception):
  23. pass
  24. def _check_private_file(path):
  25. # Permissions are defensive checks, not a same-UID provenance guarantee.
  26. for parent in (path, *path.parents):
  27. if parent.is_symlink():
  28. raise RegistrationError('REGISTRATION_CONFIG_UNSAFE')
  29. s = path.lstat()
  30. if not stat.S_ISREG(s.st_mode):
  31. raise RegistrationError('REGISTRATION_CONFIG_UNSAFE')
  32. if os.name == 'posix' and (s.st_uid != os.geteuid() or stat.S_IMODE(s.st_mode) != 0o600):
  33. raise RegistrationError('REGISTRATION_CONFIG_UNSAFE')
  34. def _read_maintenance_config():
  35. home = Path(os.environ.get('HERMES_HOME') or Path.home())
  36. path = home / 'plugins' / 'wecom-identity' / 'registration.json'
  37. _check_private_file(path)
  38. if path.stat().st_size > 4096:
  39. raise RegistrationError('REGISTRATION_CONFIG_INVALID')
  40. data = json.loads(path.read_text(encoding='utf-8'))
  41. if not isinstance(data, dict) or set(data) != {'version', 'agentProfileKey', 'botIdSha256'}:
  42. raise RegistrationError('REGISTRATION_CONFIG_INVALID')
  43. if data['version'] != 'ksf.current-employee-registration.v1':
  44. raise RegistrationError('REGISTRATION_CONFIG_INVALID')
  45. if not isinstance(data['agentProfileKey'], str) or not re.fullmatch(r'agent-account:[a-f0-9]{64}', data['agentProfileKey']):
  46. raise RegistrationError('REGISTRATION_CONFIG_INVALID')
  47. if not isinstance(data['botIdSha256'], str) or not re.fullmatch(r'[a-f0-9]{64}', data['botIdSha256']):
  48. raise RegistrationError('REGISTRATION_CONFIG_INVALID')
  49. return data
  50. def _current_identity():
  51. from plugins.platforms.wecom.identity_bridge import current_identity
  52. return current_identity() # No model args, reader override, or message-id override.
  53. def _receipt(result, agent_key):
  54. # Reconstruct the public receipt; do not echo worker stderr or raw account fields.
  55. if not isinstance(result, dict) or result.get('ok') is not True or result.get('agentProfileKey') != agent_key:
  56. raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
  57. p = result.get('profile')
  58. if not isinstance(p, dict) or not re.fullmatch(r'participant-account:[a-f0-9]{64}', str(p.get('key', ''))):
  59. raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
  60. if p.get('identityBasis') != 'channel_context' or not isinstance(p.get('name'), str) or not p['name'].strip():
  61. raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
  62. if not isinstance(result.get('changed'), bool) or not isinstance(result.get('needsNameBinding'), bool):
  63. raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
  64. iid = result.get('invocationId', '')
  65. if not isinstance(iid, str) or not re.fullmatch(r'[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}', iid):
  66. raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
  67. return {
  68. 'ok': True, 'operation': 'current-employee-registered', 'invocationId': iid,
  69. 'changed': result['changed'], 'agentProfileKey': agent_key,
  70. 'profile': {'key': p['key'], 'name': p['name'], 'identityBasis': 'channel_context'},
  71. 'needsNameBinding': result['needsNameBinding'], 'currentMessageChecked': True,
  72. 'accountAuthenticated': False, 'displayNameAuthenticated': False,
  73. 'networkRequests': 0, 'databaseWrites': 0,
  74. }
  75. def _register_current_employee_result(args=None, **_kw):
  76. if args is not None and (not isinstance(args, dict) or args):
  77. return {'ok': False, 'error': 'NO_ARGUMENTS_ALLOWED'}
  78. try:
  79. checked = _current_identity()
  80. if not isinstance(checked, dict) or checked.get('ok') is not True:
  81. return {'ok': False, 'error': 'IDENTITY_CONTEXT_UNAVAILABLE'}
  82. identity = checked.get('identity')
  83. if not isinstance(identity, dict) or not isinstance(identity.get('botId'), str):
  84. raise RegistrationError('INVALID_CHANNEL_CONTEXT')
  85. config = _read_maintenance_config()
  86. if hashlib.sha256(identity['botId'].encode('utf-8')).hexdigest() != config['botIdSha256']:
  87. raise RegistrationError('REGISTRATION_INSTANCE_MISMATCH')
  88. node = shutil.which('node')
  89. if not node or not WORKER.is_file() or WORKER.is_symlink():
  90. raise RegistrationError('REGISTRATION_WORKER_UNAVAILABLE')
  91. request = {'version': config['version'], 'agentProfileKey': config['agentProfileKey'], 'identity': identity}
  92. proc = subprocess.run([node, str(WORKER)], input=json.dumps(request, ensure_ascii=False),
  93. capture_output=True, text=True, encoding='utf-8', timeout=20, check=False)
  94. if proc.returncode != 0:
  95. raise RegistrationError('REGISTRATION_WORKER_FAILED')
  96. if len(proc.stdout) > 16384:
  97. raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
  98. return _receipt(json.loads(proc.stdout), config['agentProfileKey'])
  99. except RegistrationError as exc:
  100. return {'ok': False, 'error': str(exc)}
  101. except Exception:
  102. # Exceptions can contain config/worker output; no raw exception logging.
  103. return {'ok': False, 'error': 'IDENTITY_REGISTRATION_FAILED'}
  104. def register_current_employee(args=None, **_kw):
  105. """Hermes tool boundary accepts text; internal callers keep structured data."""
  106. return json.dumps(_register_current_employee_result(args, **_kw), ensure_ascii=False)
  107. def register(ctx):
  108. ctx.register_tool(TOOL_NAME, 'wecom', TOOL_SCHEMA, register_current_employee,
  109. description=TOOL_SCHEMA['description'], emoji='🪪')