probe-agent-library-read.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """Run the installed Agent read path without installing or changing anything.
  2. Only the explicit new output directory is written. No upstream collection,
  3. publication, database writes, shell commands or credentials are exported.
  4. """
  5. import sys
  6. sys.dont_write_bytecode = True
  7. import argparse
  8. import datetime
  9. import hashlib
  10. import importlib
  11. import json
  12. import os
  13. from pathlib import Path
  14. import re
  15. import subprocess
  16. import urllib.request
  17. REVIEW = 'jd:100297147639:106531290235443828'
  18. SOURCE = '554b71fd087b7c0ef33016c736e1b822ff5be66b832ce523d2c96dc926d90735'
  19. FIELDS = ('id', 'product_id', 'platform_family', 'review_date_raw',
  20. 'review_calendar_date', 'review_date_utc', 'rating_raw',
  21. 'rating_scale', 'is_default', 'source_record_id')
  22. SMOKE = {'entities': ['reviews'], 'filters': {'platform': 'jd', 'brand': 'ksf',
  23. 'category': 'banmian'}, 'quality': 'analysis', 'limit': 20}
  24. def sha(raw):
  25. return hashlib.sha256(raw).hexdigest()
  26. def code(value):
  27. # Never export raw traceback, arbitrary messages or stderr.
  28. return value if isinstance(value, str) and re.fullmatch(r'[A-Z][A-Z0-9_]{0,100}', value) else 'UNCLASSIFIED_ERROR'
  29. def summary(result):
  30. out = {k: result[k] for k in ('ok', 'generation', 'readOnly', 'databaseWrites',
  31. 'dataAccessMode', 'totalMatched', 'countsByEntity', 'effectiveQuery') if k in result}
  32. if isinstance(result.get('error'), dict):
  33. out['error'] = {'code': code(result['error'].get('code'))}
  34. diagnostics = result['error'].get('diagnostics', {})
  35. out['error']['diagnostics'] = {k: diagnostics[k] for k in (
  36. 'stage', 'exitCode', 'stdoutBytes', 'stderrBytes',
  37. 'stdoutSha256', 'stderrSha256') if k in diagnostics}
  38. if isinstance(result.get('records'), list):
  39. out['recordRefs'] = [{k: r[k] for k in ('entity', 'record_id', 'source_record_id',
  40. 'review_day', 'rating_value', 'rating_scale', 'is_default') if k in r}
  41. for r in result['records']]
  42. return out
  43. def valid_read(value, generation):
  44. return (value.get('ok') is True and value.get('readOnly') is True and
  45. value.get('databaseWrites') == 0 and value.get('generation') == generation and
  46. ('process' not in value or value['process'].get('exitCode') == 0))
  47. def search_agrees(results, generation, keyword=None):
  48. """Require successful reads with identical scope, counts and ordered IDs."""
  49. if not results or not all(valid_read(r, generation) for r in results):
  50. return False
  51. expected_filters = {k: {'eq': v} for k, v in SMOKE['filters'].items()}
  52. for result in results:
  53. effective = result.get('effectiveQuery', {})
  54. if (effective.get('entities') != SMOKE['entities'] or
  55. effective.get('filters') != expected_filters or
  56. effective.get('quality') != 'analysis' or
  57. effective.get('text', {}).get('groupsAll') != ([[keyword]] if keyword else [])):
  58. return False
  59. if type(result.get('totalMatched')) is not int or result['totalMatched'] <= 0:
  60. return False
  61. refs = result.get('recordRefs', [])
  62. ids = [r.get('record_id') for r in refs]
  63. if not refs or any(not item for item in ids) or len(ids) != len(set(ids)):
  64. return False
  65. if len(refs) != min(result['totalMatched'], SMOKE['limit']):
  66. return False
  67. first = results[0]
  68. # Preview prose and gateway metadata may differ, but result scope and IDs may not.
  69. for result in results[1:]:
  70. if any(result.get(k) != first.get(k) for k in ('totalMatched', 'countsByEntity', 'effectiveQuery', 'recordRefs')):
  71. return False
  72. return True
  73. def probe(args):
  74. skill = Path(args.skill_root).resolve()
  75. instructions = Path(getattr(args, 'instructions_root', None) or args.skill_root).resolve()
  76. scripts = skill / 'scripts'
  77. channel_path = args.channel or os.environ.get('KSF_LIBRARY_CHANNEL')
  78. if not channel_path:
  79. raise ValueError('CHANNEL_NOT_CONFIGURED')
  80. output = Path(args.out).absolute()
  81. if output.exists() or any(p.is_symlink() for p in (output, *output.parents)):
  82. raise ValueError('OUTPUT_ALREADY_EXISTS_OR_REDIRECTED')
  83. expected_state_path = getattr(args, 'expected_installed_state', None)
  84. if expected_state_path:
  85. expected_state = json.loads(Path(expected_state_path).read_text(encoding='utf-8-sig'))
  86. if sha((instructions / 'SKILL.md').read_bytes()) != expected_state['skillFingerprint']:
  87. raise ValueError('INSTALLED_SKILL_BASELINE_CHANGED')
  88. for name in ('query_voc.py', 'library_channel.py', 'voc_db.py', 'retrieval.py'):
  89. if sha((scripts / name).read_bytes()) != expected_state['installedFingerprints'][name]:
  90. raise ValueError('INSTALLED_READ_CODE_BASELINE_CHANGED')
  91. original_raw = Path(args.original_input).read_bytes() if args.original_input else None
  92. original = json.loads(original_raw.decode('utf-8-sig')) if original_raw is not None else None
  93. if original is not None and (not isinstance(original, dict) or set(original) - {
  94. 'entities', 'filters', 'text', 'quality', 'limit', 'cursor', 'groupBy', 'sort'}):
  95. raise ValueError('ORIGINAL_INPUT_NOT_SEARCH_OBJECT')
  96. sys.path.insert(0, str(scripts))
  97. channel = importlib.import_module('library_channel')
  98. transport = []
  99. def runner(command, **kwargs):
  100. try:
  101. run = subprocess.run(command, **kwargs)
  102. except (OSError, subprocess.TimeoutExpired) as error:
  103. transport.append({'stage': 'ssh-execution', 'exceptionType': type(error).__name__})
  104. raise
  105. transport.append({'stage': 'ssh-response', 'exitCode': run.returncode,
  106. 'requestSha256': sha(kwargs['input']), 'stdoutBytes': len(run.stdout),
  107. 'stderrBytes': len(run.stderr), 'stdoutSha256': sha(run.stdout),
  108. 'stderrSha256': sha(run.stderr)})
  109. return run
  110. def remote(command, params):
  111. try:
  112. return channel.send(channel_path, {'action': 'read', 'command': command,
  113. 'params': params}, runner=runner)
  114. except (ValueError, OSError) as error:
  115. return {'ok': False, 'error': {'code': code(str(error))}}
  116. def cli(command, extra, stdin=None):
  117. argv = [sys.executable, '-B', '-X', 'utf8', str(scripts / 'query_voc.py'),
  118. command, '--channel', channel_path, *extra]
  119. try:
  120. run = subprocess.run(argv, input=stdin, stdout=subprocess.PIPE,
  121. stderr=subprocess.PIPE, timeout=330, check=False)
  122. body = run.stdout if run.stdout.strip() else run.stderr
  123. result = json.loads(body.decode('utf-8-sig'))
  124. safe = summary(result)
  125. safe['process'] = {'exitCode': run.returncode, 'stdoutBytes': len(run.stdout),
  126. 'stderrBytes': len(run.stderr), 'stdoutSha256': sha(run.stdout),
  127. 'stderrSha256': sha(run.stderr)}
  128. return safe
  129. except (ValueError, OSError, subprocess.TimeoutExpired):
  130. return {'ok': False, 'error': {'code': 'CLI_RESULT_UNAVAILABLE'}}
  131. receipt = {'schema': 'ksf.agent-library-read-probe.v2',
  132. 'checkedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
  133. 'readOnly': True, 'databaseWrites': 0, 'paidCalls': 0, 'upstreamCalls': 0,
  134. 'installedFilesModified': False, 'originalRequestProvided': original is not None,
  135. 'credentialsExported': False,
  136. 'runtime': {'python': sys.version.split()[0]},
  137. 'installedFingerprints': {name: sha((scripts / name).read_bytes()) for name in (
  138. 'query_voc.py', 'library_channel.py', 'voc_db.py', 'retrieval.py')},
  139. 'skillFingerprint': sha((instructions / 'SKILL.md').read_bytes()),
  140. 'instructionSkillName': instructions.name}
  141. def board(request):
  142. req = urllib.request.Request('https://kangshifu.fmode.cn/api/library',
  143. data=json.dumps(request).encode('utf-8'), headers={'Content-Type': 'application/json'}, method='POST')
  144. with urllib.request.urlopen(req, timeout=40) as response:
  145. return json.load(response)
  146. public = board({'op': 'summary'})
  147. contract = remote('contract', {})
  148. receipt['contract'] = summary(contract)
  149. receipt['board'] = summary(public)
  150. generation = contract.get('generation')
  151. expected = getattr(args, 'expected_generation', None)
  152. if (not valid_read(contract, generation) or not valid_read(public, generation) or
  153. not generation or expected is not None and generation != expected):
  154. receipt['sameGeneration'] = False
  155. receipt['ok'] = False
  156. receipt['stoppedReason'] = 'CONTRACT_OR_BOARD_GENERATION_MISMATCH'
  157. else:
  158. receipt['sameGeneration'] = True
  159. pinned = ['--generation', generation]
  160. query = original if original is not None else SMOKE
  161. accept_current = getattr(args, 'accept_current', False)
  162. receipt['queryKind'] = ('original-input' if original is not None else
  163. 'current-functional-acceptance' if accept_current else 'documented-smoke-only')
  164. receipt['query'] = query
  165. if original_raw is not None:
  166. receipt['input'] = {'sha256': sha(original_raw), 'bytes': len(original_raw),
  167. 'utf8Bom': original_raw.startswith(b'\xef\xbb\xbf')}
  168. receipt['searchOriginalFileCli'] = cli('search', pinned + ['--input', args.original_input])
  169. receipt['searchOriginalStdinCli'] = cli('search', pinned + ['--input', '-'], original_raw)
  170. receipt['searchStructuredChannel'] = summary(remote('search', {'generation': generation, 'input': query}))
  171. receipt['currentCliVerified'] = False
  172. receipt['keywordSearchVerified'] = False
  173. if accept_current:
  174. output.mkdir(mode=0o700, parents=True)
  175. receipt['currentChecks'] = []
  176. for name, keyword in (('scope', None), ('keyword', '味道')):
  177. current_query = {**SMOKE, **({'text': {'all': [keyword]}} if keyword else {})}
  178. raw = (json.dumps(current_query, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  179. path = output / (name + '-request.json')
  180. with path.open('xb') as stream:
  181. stream.write(raw)
  182. os.chmod(path, 0o600)
  183. check = {'name': name, 'query': current_query, 'requestSha256': sha(raw),
  184. 'fileCli': cli('search', pinned + ['--input', str(path)]),
  185. 'stdinCli': cli('search', pinned + ['--input', '-'], raw),
  186. 'structuredChannel': summary(remote('search', {'generation': generation, 'input': current_query}))}
  187. board_request = {'op': 'browse', 'entity': 'reviews', 'filters': SMOKE['filters'],
  188. 'quality': SMOKE['quality'], 'limit': SMOKE['limit'], 'generation': generation}
  189. if keyword: board_request['keyword'] = keyword
  190. try:
  191. check['board'] = summary(board(board_request))
  192. except (ValueError, OSError):
  193. check['board'] = {'ok': False, 'error': {'code': 'BOARD_READ_UNAVAILABLE'}}
  194. check['verified'] = search_agrees([check[k] for k in (
  195. 'fileCli', 'stdinCli', 'structuredChannel', 'board')], generation, keyword)
  196. receipt['currentChecks'].append(check)
  197. receipt['currentCliVerified'] = all(c['verified'] for c in receipt['currentChecks'])
  198. receipt['keywordSearchVerified'] = receipt['currentChecks'][1]['verified']
  199. detail = remote('get', {'generation': generation, 'entity': 'reviews', 'id': REVIEW})
  200. record = detail.get('record', {})
  201. receipt['get'] = summary(detail)
  202. receipt['get']['recordFields'] = {k: {'present': k in record, 'value': record.get(k)} for k in FIELDS}
  203. receipt['get']['unknownFieldsPreserved'] = (
  204. 'is_default' in record and record['is_default'] is None and
  205. 'rating_scale' in record and record['rating_scale'] is None)
  206. source = remote('source', {'generation': generation, 'id': SOURCE})
  207. receipt['source'] = summary(source)
  208. citation = source.get('citation', {})
  209. receipt['source']['citationRef'] = {k: citation[k] for k in (
  210. 'id', 'line_number', 'payload_sha256', 'assetSha256', 'archiveSha256') if k in citation}
  211. receipt['originalConditionVerified'] = original is not None and all(
  212. valid_read(receipt[k], generation) for k in (
  213. 'searchOriginalFileCli', 'searchOriginalStdinCli', 'searchStructuredChannel'))
  214. receipt['fieldReadVerified'] = valid_read(detail, generation) and all(k in record for k in FIELDS) and (
  215. record['review_date_raw'] == '2026-11-06 10:49:00' and record['rating_raw'] == '5' and
  216. record['source_record_id'] == SOURCE and receipt['get']['unknownFieldsPreserved'])
  217. receipt['historicalRootCauseVerified'] = False
  218. receipt['acceptanceScope'] = 'current-functionality' if accept_current else 'original-condition'
  219. search_verified = receipt['currentCliVerified'] if accept_current else receipt['originalConditionVerified']
  220. receipt['ok'] = search_verified and receipt['fieldReadVerified'] and valid_read(source, generation)
  221. receipt['transport'] = transport
  222. receipt['installedFingerprintsAfter'] = {name: sha((scripts / name).read_bytes())
  223. for name in receipt['installedFingerprints']}
  224. receipt['skillFingerprintAfter'] = sha((instructions / 'SKILL.md').read_bytes())
  225. receipt['installedFilesUnchanged'] = (
  226. receipt['installedFingerprintsAfter'] == receipt['installedFingerprints'] and
  227. receipt['skillFingerprintAfter'] == receipt['skillFingerprint'])
  228. if not receipt['installedFilesUnchanged']:
  229. receipt['ok'] = False
  230. receipt['stoppedReason'] = 'INSTALLED_FILES_CHANGED_DURING_PROBE'
  231. output.mkdir(mode=0o700, parents=True, exist_ok=True)
  232. path = output / 'agent-library-read-receipt.json'
  233. path.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  234. os.chmod(path, 0o600)
  235. print(json.dumps({'ok': receipt['ok'], 'receipt': str(path), 'sha256': sha(path.read_bytes()),
  236. 'installedFilesModified': False, 'databaseWrites': 0}, ensure_ascii=False))
  237. return 0 if receipt['ok'] else 1
  238. if __name__ == '__main__':
  239. parser = argparse.ArgumentParser(description=__doc__)
  240. parser.add_argument('--skill-root', default='/opt/data/skills/ksf-voc-library')
  241. parser.add_argument('--instructions-root', help='Unified Skill instruction directory; runtime stays at --skill-root')
  242. parser.add_argument('--channel', help='Existing private config path; never copied to output')
  243. parser.add_argument('--original-input', help='Unchanged JSON input of the failed search')
  244. parser.add_argument('--accept-current', action='store_true', help='Verify current CLI file/stdin/keyword reads; never claims historical reproduction')
  245. parser.add_argument('--expected-generation', help='Stop if the current generation differs from this accepted generation')
  246. parser.add_argument('--expected-installed-state', help='Stop if public Skill/code fingerprints differ from the prior receipt')
  247. parser.add_argument('--out', required=True, help='New private output directory')
  248. options = parser.parse_args()
  249. try:
  250. sys.exit(probe(options))
  251. except (ValueError, OSError, KeyError) as error:
  252. print(json.dumps({'ok': False, 'error': {'code': code(str(error))},
  253. 'readOnly': True, 'databaseWrites': 0}, ensure_ascii=False))
  254. sys.exit(1)