BxDolAIToolMysqlWrite.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup UnaCore UNA Core
  7. * @{
  8. */
  9. use NeuronAI\Tools\PropertyType;
  10. use NeuronAI\Tools\ToolProperty;
  11. class BxDolAIToolMysqlWrite extends BxDolAITool
  12. {
  13. protected $_aDenyTables = [
  14. 'sys_accounts',
  15. 'sys_profiles',
  16. 'sys_sessions',
  17. 'sys_cron_jobs',
  18. 'sys_modules',
  19. 'sys_agents_sql_log',
  20. 'sys_agents_chat_history',
  21. 'sys_agents_tools',
  22. 'sys_agents_agents',
  23. 'sys_agents_prompt_feedback',
  24. ];
  25. public function __construct()
  26. {
  27. parent::__construct(
  28. 'mysql_write',
  29. 'INSERT or UPDATE one row. DELETE/DROP/TRUNCATE/ALTER are forbidden. UPDATE WHERE must identify exactly one row (prefer primary key: WHERE id = N). Snapshots are stored automatically. Prefer mysql_schema and mysql_select first. '
  30. . 'Call with dry_run=true first: it returns a plain-language summary (table, row, field: old → new) — show that to the person, never the SQL.',
  31. );
  32. }
  33. protected function properties(): array
  34. {
  35. return [
  36. new ToolProperty(
  37. name: 'query',
  38. type: PropertyType::STRING,
  39. description: 'Single INSERT or UPDATE. UPDATE needs WHERE that matches one row, e.g. WHERE `id` = 12. Table.column and extra AND conditions are ok. No DELETE. No multiple statements.',
  40. required: true
  41. ),
  42. new ToolProperty(
  43. name: 'dry_run',
  44. type: PropertyType::BOOLEAN,
  45. description: 'true = validate and return summary / changes (field: old → new) without writing. Use it before asking the person to confirm.',
  46. required: false
  47. ),
  48. ];
  49. }
  50. public function __invoke(string $query, $dry_run = false): array
  51. {
  52. $oDb = BxDolDb::getInstance();
  53. $sSql = $this->_normalize($query);
  54. $sBare = $this->_stripLiterals($sSql);
  55. $bDry = $dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true';
  56. $this->_assertSafe($sBare);
  57. if (preg_match('/^insert\b/i', $sSql))
  58. $sOp = 'insert';
  59. else if (preg_match('/^update\b/i', $sSql))
  60. $sOp = 'update';
  61. else
  62. throw new Exception('Only INSERT or UPDATE is allowed.');
  63. $sTable = $this->_tableName($sSql, $sOp);
  64. $this->_assertTable($sTable);
  65. $sPk = $this->_primaryKey($oDb, $sTable);
  66. $sBefore = null;
  67. $sPkValue = '';
  68. if ($sOp === 'update') {
  69. $sPkValue = $this->_wherePkValue($sSql, $sPk, $oDb, $sTable);
  70. $aBefore = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
  71. if (!$aBefore)
  72. throw new Exception("Row not found: {$sTable}.{$sPk} = {$sPkValue}");
  73. $sBefore = json_encode($aBefore, JSON_UNESCAPED_UNICODE);
  74. }
  75. if ($bDry) {
  76. $aAssign = $this->_parseAssignments($sSql, $sOp);
  77. $aChanges = $this->_changes($sOp === 'update' ? ($aBefore ?? []) : null, $aAssign, $sOp === 'update');
  78. return [
  79. 'ok' => 1,
  80. 'dry_run' => 1,
  81. 'op' => $sOp,
  82. 'table' => $sTable,
  83. 'pk' => $sPk,
  84. 'pk_value' => $sPkValue,
  85. 'changes' => $aChanges,
  86. 'summary' => $this->_summary($sOp, $sTable, $sPkValue, $aChanges),
  87. ];
  88. }
  89. $mixedRes = $oDb->query($sSql);
  90. if ($mixedRes === false)
  91. throw new Exception('Query failed.');
  92. if ($sOp === 'insert') {
  93. $sPkValue = (string)$oDb->lastId();
  94. if ($sPkValue === '' || $sPkValue === '0')
  95. $sPkValue = $this->_insertedPkFromSql($sSql, $sPk);
  96. if ($sPkValue === '')
  97. throw new Exception('INSERT succeeded but primary key is unknown.');
  98. }
  99. $aAfter = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
  100. $iLogId = $this->_log($sOp, $sTable, $sPk, $sPkValue, $sSql, $sBefore, $aAfter ? json_encode($aAfter, JSON_UNESCAPED_UNICODE) : null);
  101. // exact diff from the snapshots (the dry run only had the parsed SET clause)
  102. $aChanges = $sOp === 'update'
  103. ? $this->_changes($aBefore ?? [], $aAfter ?: [], true)
  104. : $this->_changes(null, $this->_parseAssignments($sSql, $sOp), false);
  105. return [
  106. 'ok' => 1,
  107. 'op' => $sOp,
  108. 'table' => $sTable,
  109. 'pk' => $sPk,
  110. 'pk_value' => $sPkValue,
  111. 'log_id' => $iLogId,
  112. 'affected' => is_numeric($mixedRes) ? (int)$mixedRes : 1,
  113. 'changes' => $aChanges,
  114. 'summary' => $this->_summary($sOp, $sTable, $sPkValue, $aChanges),
  115. ];
  116. }
  117. /**
  118. * column => literal from the SET clause (UPDATE / INSERT ... SET) or the
  119. * (columns) VALUES (...) pair. Expressions that are not plain literals
  120. * (NOW(), col + 1) are kept as their SQL text.
  121. *
  122. * SET and WHERE are located on a copy with quoted literals blanked
  123. * ($sBare, same length as $sSql) so a value cannot end the clause.
  124. */
  125. protected function _parseAssignments(string $sSql, string $sOp): array
  126. {
  127. $aOut = [];
  128. $sBare = $this->_maskLiterals($sSql, true);
  129. if (preg_match('/\bset\b/i', $sBare, $aSet, PREG_OFFSET_CAPTURE)) {
  130. $iFrom = $aSet[0][1] + strlen($aSet[0][0]);
  131. $sClause = substr($sSql, $iFrom);
  132. if (preg_match('/\bwhere\b/i', substr($sBare, $iFrom), $aWhere, PREG_OFFSET_CAPTURE))
  133. $sClause = substr($sSql, $iFrom, $aWhere[0][1]);
  134. foreach ($this->_splitTopLevel($sClause) as $sPair) {
  135. if (preg_match('/^\s*(?:`?[a-zA-Z0-9_]+`?\s*\.\s*)?`?([a-zA-Z0-9_]+)`?\s*=\s*(.+)$/s', trim($sPair), $aP))
  136. $aOut[$aP[1]] = $this->_literal(trim($aP[2]));
  137. }
  138. return $aOut;
  139. }
  140. if ($sOp === 'insert' && preg_match('/^insert\s+into\s+`?[a-zA-Z0-9_]+`?\s*\((.+?)\)\s*values\s*\((.+)\)\s*$/is', $sSql, $aM)) {
  141. $aCols = array_map(fn($c) => trim($c, " `\t\n\r"), $this->_splitTopLevel($aM[1]));
  142. $aVals = $this->_splitTopLevel($aM[2]);
  143. foreach ($aCols as $i => $sCol) {
  144. if ($sCol !== '' && array_key_exists($i, $aVals))
  145. $aOut[$sCol] = $this->_literal(trim($aVals[$i]));
  146. }
  147. }
  148. return $aOut;
  149. }
  150. /**
  151. * Copy of $s with the inside of quoted literals replaced by spaces.
  152. * Length and quote marks stay, so keyword offsets still point into $s.
  153. * Quote rules match _splitTopLevel() (backslash and doubled quotes).
  154. * $bIdents also blanks `identifiers`, so a column named where/set is not a clause boundary.
  155. */
  156. protected function _maskLiterals(string $s, bool $bIdents = false): string
  157. {
  158. $iLen = strlen($s);
  159. $sQuote = '';
  160. for ($i = 0; $i < $iLen; $i++) {
  161. $c = $s[$i];
  162. if ($sQuote !== '') {
  163. if ($sQuote !== '`' && $c === '\\' && $i + 1 < $iLen) {
  164. $s[$i] = ' ';
  165. $s[++$i] = ' ';
  166. } elseif ($c === $sQuote) {
  167. if ($i + 1 < $iLen && $s[$i + 1] === $sQuote) {
  168. $s[$i] = ' ';
  169. $s[++$i] = ' ';
  170. } else {
  171. $sQuote = '';
  172. }
  173. } else {
  174. $s[$i] = ' ';
  175. }
  176. continue;
  177. }
  178. if ($c === "'" || $c === '"' || ($bIdents && $c === '`'))
  179. $sQuote = $c;
  180. }
  181. return $s;
  182. }
  183. /** Split on commas that are outside quotes and parentheses. */
  184. protected function _splitTopLevel(string $s): array
  185. {
  186. $aOut = [];
  187. $sCur = '';
  188. $iDepth = 0;
  189. $sQuote = '';
  190. $iLen = strlen($s);
  191. for ($i = 0; $i < $iLen; $i++) {
  192. $c = $s[$i];
  193. if ($sQuote !== '') {
  194. $sCur .= $c;
  195. if ($c === '\\' && $i + 1 < $iLen) {
  196. $sCur .= $s[++$i];
  197. } elseif ($c === $sQuote) {
  198. if ($i + 1 < $iLen && $s[$i + 1] === $sQuote)
  199. $sCur .= $s[++$i];
  200. else
  201. $sQuote = '';
  202. }
  203. continue;
  204. }
  205. if ($c === "'" || $c === '"') {
  206. $sQuote = $c;
  207. $sCur .= $c;
  208. } elseif ($c === '(') {
  209. $iDepth++;
  210. $sCur .= $c;
  211. } elseif ($c === ')') {
  212. $iDepth--;
  213. $sCur .= $c;
  214. } elseif ($c === ',' && $iDepth === 0) {
  215. $aOut[] = $sCur;
  216. $sCur = '';
  217. } else {
  218. $sCur .= $c;
  219. }
  220. }
  221. if (trim($sCur) !== '')
  222. $aOut[] = $sCur;
  223. return $aOut;
  224. }
  225. /** SQL literal → PHP value; anything else stays as the expression text. */
  226. protected function _literal(string $s)
  227. {
  228. if (preg_match('/^null$/i', $s))
  229. return null;
  230. if (preg_match('/^-?\d+(\.\d+)?$/', $s))
  231. return $s + 0;
  232. $iLen = strlen($s);
  233. if ($iLen >= 2 && ($s[0] === "'" || $s[0] === '"') && $s[$iLen - 1] === $s[0]) {
  234. $q = $s[0];
  235. $sInner = substr($s, 1, -1);
  236. $sInner = str_replace($q . $q, $q, $sInner);
  237. return stripcslashes($sInner);
  238. }
  239. return $s;
  240. }
  241. /**
  242. * [{field, before, after}] — for an update only the fields whose value
  243. * actually changes; for an insert every given field (before = null).
  244. */
  245. protected function _changes(?array $aBefore, array $aAfter, bool $bDiff): array
  246. {
  247. $aOut = [];
  248. foreach ($aAfter as $sField => $mixedAfter) {
  249. $mixedBefore = $aBefore[$sField] ?? null;
  250. if ($bDiff && (string)$mixedBefore === (string)$mixedAfter)
  251. continue;
  252. $aOut[] = [
  253. 'field' => $sField,
  254. 'before' => $bDiff ? $this->_short($mixedBefore) : null,
  255. 'after' => $this->_short($mixedAfter),
  256. ];
  257. }
  258. return $aOut;
  259. }
  260. protected function _short($mixed): ?string
  261. {
  262. if ($mixed === null)
  263. return null;
  264. $s = trim(strip_tags((string)$mixed));
  265. $s = preg_replace('/\s+/u', ' ', $s);
  266. return mb_strlen($s) > 120 ? mb_substr($s, 0, 119) . '…' : $s;
  267. }
  268. /** "sys_menu_items #12: active: 0 → 1; title: «Old» → «New»" */
  269. protected function _summary(string $sOp, string $sTable, string $sPkValue, array $aChanges): string
  270. {
  271. $fQ = fn($v) => $v === null || $v === '' ? '—' : (is_numeric($v) ? (string)$v : '«' . $v . '»');
  272. $aParts = [];
  273. foreach ($aChanges as $aC)
  274. $aParts[] = $aC['field'] . ': ' . ($sOp === 'update' ? $fQ($aC['before']) . ' → ' : '') . $fQ($aC['after']);
  275. $sHead = $sTable . ($sPkValue !== '' ? ' #' . $sPkValue : '');
  276. if (!$aParts)
  277. return $sHead . ($sOp === 'update' ? ': no changes' : ': new row');
  278. return ($sOp === 'insert' ? 'new row in ' : '') . $sHead . ': ' . implode('; ', $aParts);
  279. }
  280. protected function _normalize(string $s): string
  281. {
  282. $s = preg_replace('~/\*.*?\*/~s', ' ', $s);
  283. $s = preg_replace('/--[^\n]*/', ' ', $s);
  284. $s = preg_replace('/#[^\n]*/', ' ', $s);
  285. $s = trim($s);
  286. $s = rtrim($s, "; \t\n\r");
  287. if ($s === '')
  288. throw new Exception('Empty query.');
  289. if (strpos($s, ';') !== false)
  290. throw new Exception('Multiple statements are not allowed.');
  291. return $s;
  292. }
  293. protected function _stripLiterals(string $s): string
  294. {
  295. $s = preg_replace("/'([^'\\\\]|\\\\.)*'/s", "''", $s);
  296. $s = preg_replace('/"([^"\\\\]|\\\\.)*"/s', '""', $s);
  297. return $s;
  298. }
  299. protected function _assertSafe(string $sBare): void
  300. {
  301. if (preg_match('/\b(delete|drop|truncate|alter|replace|create|grant|revoke|call|handler|load|outfile|dumpfile|into\s+outfile|lock\s+tables|unlock\s+tables)\b/i', $sBare))
  302. throw new Exception('Forbidden SQL keyword.');
  303. if (preg_match('/\bon\s+duplicate\s+key\b/i', $sBare))
  304. throw new Exception('ON DUPLICATE KEY is not allowed.');
  305. if (preg_match('/^insert\b/i', $sBare) && preg_match('/\bselect\b/i', $sBare))
  306. throw new Exception('INSERT ... SELECT is not allowed.');
  307. }
  308. protected function _tableName(string $sSql, string $sOp): string
  309. {
  310. if ($sOp === 'insert' && preg_match('/^insert\s+into\s+`?([a-zA-Z0-9_]+)`?/i', $sSql, $aM))
  311. return $aM[1];
  312. if ($sOp === 'update' && preg_match('/^update\s+(?:low_priority\s+|ignore\s+)*`?([a-zA-Z0-9_]+)`?/i', $sSql, $aM))
  313. return $aM[1];
  314. throw new Exception('Cannot parse table name.');
  315. }
  316. protected function _assertTable(string $sTable): void
  317. {
  318. $sTable = strtolower($sTable);
  319. if (in_array($sTable, $this->_aDenyTables, true))
  320. throw new Exception("Table {$sTable} is not writable by this tool.");
  321. if (preg_match('/^(mysql|information_schema|performance_schema|sys)\./', $sTable))
  322. throw new Exception("Table {$sTable} is not writable by this tool.");
  323. }
  324. protected function _primaryKey(BxDolDb $oDb, string $sTable): string
  325. {
  326. $aKeys = $oDb->getAll("SHOW KEYS FROM `" . str_replace('`', '', $sTable) . "` WHERE `Key_name` = 'PRIMARY'");
  327. if (!$aKeys)
  328. throw new Exception("Table {$sTable} has no PRIMARY KEY.");
  329. if (count($aKeys) > 1)
  330. throw new Exception("Composite primary keys are not supported.");
  331. return $aKeys[0]['Column_name'];
  332. }
  333. protected function _wherePkValue(string $sSql, string $sPk, BxDolDb $oDb, string $sTable): string
  334. {
  335. $sBare = $this->_maskLiterals($sSql, true);
  336. if (!preg_match('/\bwhere\b/i', $sBare, $aWhere, PREG_OFFSET_CAPTURE))
  337. throw new Exception('UPDATE requires WHERE primary_key = value.');
  338. $sWhere = substr($sSql, $aWhere[0][1] + strlen($aWhere[0][0]));
  339. $sWhereBare = $this->_maskLiterals($sWhere, true);
  340. $iCut = strlen($sWhere);
  341. if (preg_match('/\s+(?:limit|offset)\b/i', $sWhereBare, $aTail, PREG_OFFSET_CAPTURE) && $aTail[0][1] < $iCut)
  342. $iCut = $aTail[0][1];
  343. if (preg_match('/\s+order\s+by\b/i', $sWhereBare, $aOrder, PREG_OFFSET_CAPTURE) && $aOrder[0][1] < $iCut)
  344. $iCut = $aOrder[0][1];
  345. $sWhere = trim(substr($sWhere, 0, $iCut), " \t\n\r;");
  346. if ($sWhere === '')
  347. throw new Exception('UPDATE requires WHERE primary_key = value.');
  348. $sWhereBare = $this->_stripLiterals($sWhere);
  349. if (preg_match('/\b(or|in\s*\(|between|like|exists|select)\b/i', $sWhereBare))
  350. throw new Exception('UPDATE WHERE must be a simple equality (no OR / IN / LIKE).');
  351. $sPkRe = preg_quote($sPk, '/');
  352. $sTableRe = preg_quote($sTable, '/');
  353. $sIdent = '(?:`?' . $sTableRe . '`?\s*\.\s*)?`?' . $sPkRe . '`?';
  354. $sWhereMask = $this->_maskLiterals($sWhere);
  355. if (preg_match('/(?:^|\band\s+)\s*\(?\s*' . $sIdent . '\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sWhereMask, $aV, PREG_OFFSET_CAPTURE))
  356. return trim(substr($sWhere, $aV[1][1], strlen($aV[1][0])), "\"'");
  357. $sTableSafe = str_replace('`', '', $sTable);
  358. $sPkSafe = str_replace('`', '', $sPk);
  359. $aRows = $oDb->getAll("SELECT `{$sPkSafe}` FROM `{$sTableSafe}` WHERE {$sWhere} LIMIT 2");
  360. if (!$aRows)
  361. throw new Exception("No rows match UPDATE WHERE. Use `{$sPk}` = value.");
  362. if (count($aRows) > 1)
  363. throw new Exception("UPDATE WHERE matched more than one row. Use `{$sPk}` = value.");
  364. return (string)$aRows[0][$sPkSafe];
  365. }
  366. protected function _insertedPkFromSql(string $sSql, string $sPk): string
  367. {
  368. $sPkRe = preg_quote($sPk, '/');
  369. if (preg_match('/`' . $sPkRe . '`\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sSql, $aM))
  370. return trim($aM[1], "\"'");
  371. if (preg_match('/\b' . $sPkRe . '\b\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sSql, $aM))
  372. return trim($aM[1], "\"'");
  373. return '';
  374. }
  375. protected function _fetchRow(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): array|false
  376. {
  377. $sTable = str_replace('`', '', $sTable);
  378. $sPk = str_replace('`', '', $sPk);
  379. if (preg_match('/^\d+$/', $sPkValue))
  380. return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", ['v' => (int)$sPkValue]);
  381. return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", ['v' => $sPkValue]);
  382. }
  383. public static function currentLogContext(): array
  384. {
  385. $oDb = BxDolDb::getInstance();
  386. $oAi = BxDolAi::getInstance();
  387. $iAgentId = 0;
  388. $sThread = '';
  389. $iProfile = is_object($oAi) ? (int)$oAi->getProfileId() : 0;
  390. $iHist = (int)BxDolAiChat::getInstance()->getCurrentChatHistoryId();
  391. if ($iHist > 0) {
  392. $aRow = $oDb->getRow("SELECT `thread_id` FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => $iHist]);
  393. $sThread = $aRow['thread_id'] ?? '';
  394. $aParts = explode(':', $sThread);
  395. $iAgentId = (int)($aParts[1] ?? 0);
  396. }
  397. return [
  398. 'agent_id' => $iAgentId,
  399. 'profile_id' => $iProfile,
  400. 'thread_id' => $sThread,
  401. ];
  402. }
  403. protected function _log(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, ?string $sBefore, ?string $sAfter): int
  404. {
  405. $oDb = BxDolDb::getInstance();
  406. $aCtx = self::currentLogContext();
  407. $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL([
  408. 'agent_id' => $aCtx['agent_id'],
  409. 'profile_id' => $aCtx['profile_id'],
  410. 'thread_id' => $aCtx['thread_id'],
  411. 'op' => $sOp,
  412. 'table_name' => $sTable,
  413. 'pk_name' => $sPk,
  414. 'pk_value' => $sPkValue,
  415. 'sql_text' => $sSql,
  416. 'before_json' => $sBefore,
  417. 'after_json' => $sAfter,
  418. 'added' => time(),
  419. ]));
  420. return (int)$oDb->lastId();
  421. }
  422. }