verify-installed-voc-database.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. """Verify the installed database with the already-installed query Skill.
  2. Read-only; no new environment variables, sessions, reports or collection calls.
  3. The only optional write is a new receipt, outside the database and skill folders.
  4. """
  5. import argparse
  6. import hashlib
  7. import json
  8. import os
  9. from pathlib import Path
  10. import subprocess
  11. import sys
  12. import time
  13. sys.dont_write_bytecode = True
  14. def digest(path):
  15. with Path(path).open('rb') as stream:
  16. result = hashlib.sha256()
  17. for block in iter(lambda: stream.read(1024 * 1024), b''):
  18. result.update(block)
  19. return result.hexdigest()
  20. def skill_snapshot(skill):
  21. manifest = skill / 'FILES.sha256'
  22. hashes = {'FILES.sha256': digest(manifest)}
  23. for line in manifest.read_text(encoding='utf-8').splitlines():
  24. sha, name = line.split(' ', 1)
  25. path = (skill / name).resolve()
  26. if not path.is_relative_to(skill.resolve()) or digest(path) != sha:
  27. raise ValueError('SKILL_MANIFEST_MISMATCH')
  28. hashes[name] = sha
  29. return hashes
  30. def verify(package, skill, db):
  31. package, skill, db = [Path(p).absolute() for p in (package, skill, db)]
  32. release = json.loads((package / 'DATABASE.json').read_text(encoding='utf-8'))
  33. expected = release['database']
  34. hashes = skill_snapshot(skill)
  35. if hashes['FILES.sha256'] != release['requiredResearchSkillManifestSha256']:
  36. raise ValueError('EXPECTED_NOV6_QUERY_SKILL_REQUIRED')
  37. before = digest(db)
  38. if before != expected['sha256']:
  39. raise ValueError('INSTALLED_DATABASE_HASH_MISMATCH')
  40. cli = skill / 'scripts/query_voc.py'
  41. calls = []
  42. def call(command, *args, generation=None, success=True):
  43. argv = [sys.executable, '-B', '-X', 'utf8', str(cli), command, '--db', str(db), *args]
  44. if generation:
  45. argv += ['--generation', generation]
  46. start = time.perf_counter()
  47. proc = subprocess.run(argv, text=True, encoding='utf-8', capture_output=True, timeout=60)
  48. if (proc.returncode == 0) != success:
  49. raise ValueError('QUERY_CALL_FAILED:' + command + ':' + proc.stderr[:300])
  50. payload = json.loads(proc.stdout if success else proc.stderr)
  51. if payload.get('readOnly') is not True or payload.get('databaseWrites') != 0:
  52. raise ValueError('READ_ONLY_RECEIPT_REQUIRED')
  53. calls.append(dict(command=command, exitCode=proc.returncode, elapsedMs=round((time.perf_counter()-start)*1000, 3)))
  54. return payload
  55. contract = call('contract')
  56. generation = contract['generation']
  57. if generation != expected['generation'] or contract['retrieval']['agentRulesVersion'] != 'ksf.agent-data-rules.20261106.v1':
  58. raise ValueError('CONTRACT_OR_GENERATION_MISMATCH')
  59. if contract['counts'] != expected['entityCounts']:
  60. raise ValueError('CONTRACT_COUNTS_MISMATCH')
  61. checked = call('verify', generation=generation)
  62. if checked['generation'] != generation:
  63. raise ValueError('VERIFIED_GENERATION_MISMATCH')
  64. scenarios, sampled = [], []
  65. for path in sorted((package / 'examples').glob('*.json')):
  66. command = 'aggregate' if path.name.startswith(('03-', '08-')) else 'search'
  67. result = call(command, '--input', str(path), generation=generation)
  68. if result['generation'] != generation:
  69. raise ValueError('QUERY_GENERATION_MISMATCH')
  70. if 'filters' not in result['effectiveQuery']:
  71. raise ValueError('EFFECTIVE_QUERY_REQUIRED')
  72. if path.name.startswith(('01-', '02-', '06-', '07-', '09-')):
  73. for row in result.get('records', []):
  74. if (row['platform'], row['brand'], row['category']) != ('jd', 'ksf', 'banmian'):
  75. raise ValueError('EXPLICIT_SCOPE_MISMATCH')
  76. if path.name.startswith('02-'):
  77. for row in result['records']:
  78. if row['is_default'] or row['identity_state'] != 'matched' or row['category_state'] != 'consistent':
  79. raise ValueError('REVIEW_ANALYSIS_POLICY_MISMATCH')
  80. if path.name.startswith(('01-', '02-')) and result['records']:
  81. sampled.append(result['records'][0])
  82. scenarios.append(dict(example=path.name, command=command, totalMatched=result.get('totalMatched'),
  83. groups=result.get('groups'), effectiveQuery=result['effectiveQuery'],
  84. firstRefs=[dict(entity=row['entity'], id=row['record_id'], sourceId=row['source_record_id']) for row in result.get('records', [])[:2]]))
  85. citations = []
  86. for row in sampled:
  87. detail = call('get', '--entity', row['entity'], '--id', row['record_id'], generation=generation)['record']
  88. if detail['source_record_id'] != row['source_record_id']:
  89. raise ValueError('SOURCE_RECORD_MISMATCH')
  90. source = call('source', '--id', detail['source_record_id'], generation=generation)
  91. citation = source['citation']
  92. if citation['id'] != row['source_record_id'] or not citation['archiveSha256'] or not citation['payload_sha256']:
  93. raise ValueError('SOURCE_CITATION_INCOMPLETE')
  94. citations.append(dict(entity=row['entity'], id=row['record_id'], sourceId=row['source_record_id'],
  95. archiveSha256=citation['archiveSha256'], asset=citation['asset_path'], line=citation['line_number']))
  96. stale = call('get', '--entity', sampled[0]['entity'], '--id', sampled[0]['record_id'], generation='deliberately-stale', success=False)
  97. if stale['error']['code'] != 'LIBRARY_GENERATION_CHANGED':
  98. raise ValueError('GENERATION_GUARD_NOT_ENFORCED')
  99. if digest(db) != before or skill_snapshot(skill) != hashes:
  100. raise ValueError('DATABASE_OR_SKILL_CHANGED_DURING_READS')
  101. return dict(ok=True, readOnly=True, databaseWrites=0, generation=generation, databaseSha256=before,
  102. databaseLocation=str(db), queryScope='installed static snapshot, not the whole industry or a production board connection',
  103. counts=contract['counts'], scenarios=scenarios, citations=citations, calls=calls,
  104. databaseUnchanged=True, skillUnchanged=True, generationGuardVerified=True,
  105. storageMode='static-snapshot', automaticUpdatesConfigured=False, productionBoardAligned=False,
  106. realMessageVerified=False, browserVerified=False)
  107. if __name__ == '__main__':
  108. parser = argparse.ArgumentParser(description=__doc__)
  109. parser.add_argument('--package', required=True)
  110. parser.add_argument('--skill-dir', default='/opt/data/skills/ksf-voc-library')
  111. parser.add_argument('--db', default='/opt/data/ksf-voc-library/library.sqlite')
  112. parser.add_argument('--out')
  113. args = parser.parse_args()
  114. try:
  115. if args.out:
  116. output = Path(args.out).absolute()
  117. if output.is_relative_to(Path(args.skill_dir).resolve()) or output.is_relative_to(Path(args.db).resolve().parent) or output.is_relative_to(Path(args.package).resolve()):
  118. raise ValueError('RECEIPT_MUST_BE_OUTSIDE_PACKAGE_SKILL_AND_LIBRARY')
  119. result = verify(args.package, args.skill_dir, args.db)
  120. if args.out:
  121. fd = os.open(args.out, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
  122. with os.fdopen(fd, 'w', encoding='utf-8') as stream:
  123. json.dump(result, stream, ensure_ascii=False, indent=2)
  124. stream.write('\n')
  125. print(json.dumps(result, ensure_ascii=False, indent=2))
  126. except (ValueError, OSError, KeyError, subprocess.TimeoutExpired) as error:
  127. print(json.dumps(dict(ok=False, error=dict(code=str(error))), ensure_ascii=False), file=sys.stderr)
  128. raise SystemExit(1)