@rt-tools/agent-kit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +194 -30
  2. package/assets/agents/business-analyst.md +74 -0
  3. package/assets/agents/project-manager.md +70 -0
  4. package/assets/agents/qa-engineer.md +72 -0
  5. package/assets/agents/skill-curator.md +110 -0
  6. package/assets/agents/spec-critic.md +44 -0
  7. package/assets/agents/spec-writer.md +50 -0
  8. package/assets/checks/board.github.mjs +286 -0
  9. package/assets/checks/check-board.github.mjs +188 -0
  10. package/assets/checks/check-doc-paths.mjs +163 -0
  11. package/assets/checks/check-dupes.mjs +277 -0
  12. package/assets/checks/check-lib-layers.mjs +573 -0
  13. package/assets/checks/check-reuse.mjs +208 -0
  14. package/assets/checks/check-schema-drift.mjs +186 -0
  15. package/assets/checks/check-specs.mjs +1007 -0
  16. package/assets/checks/check-styles.mjs +109 -0
  17. package/assets/checks/rt-kit-checks.config.mjs +134 -0
  18. package/assets/checks/task-new.github.mjs +198 -0
  19. package/assets/commands/skill-curator.md +70 -0
  20. package/assets/defaults/gate-map.sh +100 -0
  21. package/assets/defaults/project.sh +179 -0
  22. package/assets/hooks/browser-device-id.sh +0 -0
  23. package/assets/hooks/browser-guard-device-id.sh +2 -1
  24. package/assets/hooks/browser-guard-no-asking.sh +27 -0
  25. package/assets/hooks/browser-guard-no-listing.sh +2 -1
  26. package/assets/hooks/browser-guard-no-other-drivers.sh +2 -1
  27. package/assets/hooks/browser-guard-require-select.sh +2 -1
  28. package/assets/hooks/commit-msg.sh +1 -1
  29. package/assets/hooks/constitution-index.sh +5 -4
  30. package/assets/hooks/dev-server-guard.sh +8 -6
  31. package/assets/hooks/docs-guard.sh +223 -37
  32. package/assets/hooks/git-guard-delivery.sh +86 -29
  33. package/assets/hooks/git-guard-main.sh +1 -0
  34. package/assets/hooks/git-guard-push-tests.sh +34 -13
  35. package/assets/hooks/glossary-load.sh +23 -0
  36. package/assets/hooks/lint-after-edit.sh +155 -30
  37. package/assets/hooks/qa-dataid-guard.sh +72 -32
  38. package/assets/hooks/reuse-first-guard.sh +105 -34
  39. package/assets/hooks/skill-gate-rearm.sh +1 -0
  40. package/assets/hooks/skill-gate.sh +75 -15
  41. package/assets/hooks/skill-loaded.sh +1 -0
  42. package/assets/hooks/sql-guard.sh +606 -56
  43. package/assets/hooks/task-context-load.sh +100 -0
  44. package/assets/hooks/task-flow-guard.sh +107 -0
  45. package/assets/laws/{access.md → application/access.md} +1 -4
  46. package/assets/laws/{locales.md → application/locales.md} +1 -3
  47. package/assets/laws/application/money.md +41 -0
  48. package/assets/laws/application/ownership.md +32 -0
  49. package/assets/laws/{search-visibility.md → application/search-visibility.md} +1 -1
  50. package/assets/laws/code-structure.md +7 -6
  51. package/assets/laws/delivery.md +53 -3
  52. package/assets/laws/entity-editing.md +49 -55
  53. package/assets/laws/entity-models.md +4 -14
  54. package/assets/laws/frontend-application.md +5 -5
  55. package/assets/laws/lib-imports.md +14 -1
  56. package/assets/laws/lists.md +33 -0
  57. package/assets/laws/navigation.md +40 -0
  58. package/assets/laws/project-documentation.md +17 -8
  59. package/assets/laws/reuse-first.md +26 -21
  60. package/assets/laws/shared-code.md +13 -1
  61. package/assets/laws/verifiability.md +17 -1
  62. package/assets/laws/work-conduct.md +48 -0
  63. package/assets/patterns/admin-lists-screen.md +131 -0
  64. package/assets/patterns/admin-nav-item.md +71 -0
  65. package/assets/patterns/angular-patterns-state.md +29 -22
  66. package/assets/patterns/api-layer-pair.md +40 -30
  67. package/assets/patterns/browser-verification-measure.md +41 -38
  68. package/assets/patterns/browser-verification-stand.md +106 -42
  69. package/assets/patterns/component-structure-new.md +33 -32
  70. package/assets/patterns/dependencies-upgrade.md +65 -0
  71. package/assets/patterns/doc-style-sweep.md +65 -28
  72. package/assets/patterns/doc-style-write.md +36 -33
  73. package/assets/patterns/entity-aside.md +136 -0
  74. package/assets/patterns/entity-models-new.md +124 -0
  75. package/assets/patterns/entity-store.md +91 -0
  76. package/assets/patterns/git-workflow-commit.azure.md +259 -0
  77. package/assets/patterns/git-workflow-commit.github.md +333 -0
  78. package/assets/patterns/git-workflow-commit.gitlab.md +283 -0
  79. package/assets/patterns/git-workflow-merge.md +42 -25
  80. package/assets/patterns/git-workflow-migration.md +61 -31
  81. package/assets/patterns/git-workflow-restart.md +20 -20
  82. package/assets/patterns/lib-layers-move.md +50 -32
  83. package/assets/patterns/lib-layers-new.md +41 -29
  84. package/assets/patterns/ownership-scope-resolve.md +69 -0
  85. package/assets/patterns/permissions-procedure.md +35 -33
  86. package/assets/patterns/platform-access-di.md +39 -25
  87. package/assets/patterns/pricing-quote.md +71 -0
  88. package/assets/patterns/reuse-first-extend.md +22 -22
  89. package/assets/patterns/seo-page.md +52 -40
  90. package/assets/patterns/seo-verify.md +48 -29
  91. package/assets/patterns/shared-code-new.md +37 -31
  92. package/assets/patterns/spec-driven-domain.md +44 -37
  93. package/assets/patterns/spec-driven-rule.md +55 -40
  94. package/assets/patterns/styling-bem-component.md +43 -32
  95. package/assets/patterns/styling-bem-layout.md +30 -24
  96. package/assets/patterns/task-flow-close.md +90 -0
  97. package/assets/patterns/task-flow-resume.md +94 -0
  98. package/assets/patterns/task-flow-start.md +117 -0
  99. package/assets/patterns/testing-e2e.md +53 -51
  100. package/assets/patterns/testing-unit.md +70 -46
  101. package/assets/patterns/translations-key.md +32 -19
  102. package/assets/patterns/ts-procedure.md +24 -25
  103. package/assets/rules/angular-patterns.md +46 -27
  104. package/assets/rules/api-layer.md +46 -28
  105. package/assets/rules/browser-verification.md +66 -48
  106. package/assets/rules/component-structure.md +43 -27
  107. package/assets/rules/dependencies.md +66 -0
  108. package/assets/rules/doc-style.md +81 -39
  109. package/assets/rules/entity-conventions.md +78 -0
  110. package/assets/rules/entity-models.md +70 -0
  111. package/assets/rules/git-workflow.azure.md +116 -0
  112. package/assets/rules/git-workflow.github.md +123 -0
  113. package/assets/rules/git-workflow.gitlab.md +113 -0
  114. package/assets/rules/lib-layers.md +56 -30
  115. package/assets/rules/lists.md +73 -0
  116. package/assets/rules/navigation.md +78 -0
  117. package/assets/rules/ownership-scope.md +63 -0
  118. package/assets/rules/permissions.md +43 -25
  119. package/assets/rules/platform-access.md +57 -29
  120. package/assets/rules/pricing.md +64 -0
  121. package/assets/rules/reuse-first.md +57 -43
  122. package/assets/rules/seo.md +51 -30
  123. package/assets/rules/shared-code.md +51 -26
  124. package/assets/rules/spec-driven.md +96 -50
  125. package/assets/rules/styling-bem.md +54 -39
  126. package/assets/rules/task-flow.md +110 -0
  127. package/assets/rules/testing.md +78 -47
  128. package/assets/rules/translations.md +48 -31
  129. package/assets/rules/typescript-conventions.md +57 -27
  130. package/assets/skills/agent-kit.md +81 -0
  131. package/assets/skills/write-a-skill.md +108 -0
  132. package/assets/templates/gate-map.sh +23 -15
  133. package/assets/templates/implementation.md +14 -8
  134. package/assets/templates/pattern.md +1 -1
  135. package/assets/templates/project.sh +32 -19
  136. package/assets/templates/rule.md +1 -1
  137. package/assets/variants.json +20 -0
  138. package/assets/workflows/feature.js +134 -0
  139. package/assets/workflows/plan.js +150 -0
  140. package/bin/agent-kit.d.ts.map +1 -1
  141. package/bin/agent-kit.js +78 -5
  142. package/bin/agent-kit.js.map +1 -1
  143. package/bin/prompt.d.ts +5 -0
  144. package/bin/prompt.d.ts.map +1 -1
  145. package/bin/prompt.js +19 -7
  146. package/bin/prompt.js.map +1 -1
  147. package/index.d.ts +1 -0
  148. package/index.d.ts.map +1 -1
  149. package/index.js +1 -0
  150. package/index.js.map +1 -1
  151. package/lib/assets.d.ts +8 -3
  152. package/lib/assets.d.ts.map +1 -1
  153. package/lib/assets.js +13 -3
  154. package/lib/assets.js.map +1 -1
  155. package/lib/catalog.d.ts +52 -5
  156. package/lib/catalog.d.ts.map +1 -1
  157. package/lib/catalog.js +104 -16
  158. package/lib/catalog.js.map +1 -1
  159. package/lib/commands.d.ts +22 -1
  160. package/lib/commands.d.ts.map +1 -1
  161. package/lib/commands.js +202 -14
  162. package/lib/commands.js.map +1 -1
  163. package/lib/companion.d.ts +5 -1
  164. package/lib/companion.d.ts.map +1 -1
  165. package/lib/companion.js +29 -2
  166. package/lib/companion.js.map +1 -1
  167. package/lib/config.d.ts +26 -9
  168. package/lib/config.d.ts.map +1 -1
  169. package/lib/config.js +41 -15
  170. package/lib/config.js.map +1 -1
  171. package/lib/freshness.d.ts +14 -0
  172. package/lib/freshness.d.ts.map +1 -0
  173. package/lib/freshness.js +116 -0
  174. package/lib/freshness.js.map +1 -0
  175. package/lib/hooks-map.d.ts +24 -0
  176. package/lib/hooks-map.d.ts.map +1 -0
  177. package/lib/hooks-map.js +72 -0
  178. package/lib/hooks-map.js.map +1 -0
  179. package/lib/integrity.d.ts +36 -0
  180. package/lib/integrity.d.ts.map +1 -0
  181. package/lib/integrity.js +44 -0
  182. package/lib/integrity.js.map +1 -0
  183. package/lib/picker.d.ts +11 -1
  184. package/lib/picker.d.ts.map +1 -1
  185. package/lib/picker.js +44 -6
  186. package/lib/picker.js.map +1 -1
  187. package/lib/sync.d.ts +26 -0
  188. package/lib/sync.d.ts.map +1 -1
  189. package/lib/sync.js +59 -4
  190. package/lib/sync.js.map +1 -1
  191. package/lib/variants.d.ts +44 -0
  192. package/lib/variants.d.ts.map +1 -0
  193. package/lib/variants.js +82 -0
  194. package/lib/variants.js.map +1 -0
  195. package/package.json +1 -1
  196. package/rt-tools-agent-kit-0.4.0.tgz +0 -0
  197. package/assets/laws/admin-lists.md +0 -35
  198. package/assets/laws/admin-navigation.md +0 -38
  199. package/assets/patterns/git-workflow-commit.md +0 -175
  200. package/assets/rules/git-workflow.md +0 -106
  201. package/rt-tools-agent-kit-0.3.0.tgz +0 -0
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bash
2
+ # rt-hook: PreToolUse Bash|mcp__webstorm__execute_sql_query|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
2
3
  # Гард пишущих запросов к хранилищу. PreToolUse.
3
4
  #
4
5
  # Правка данных — единственное действие, которое нельзя откатить правкой кода. Удаление по
@@ -6,8 +7,8 @@
6
7
  # автор запроса, и узнаётся это уже по восстановлению из копии.
7
8
  #
8
9
  # Отсюда правило: строки адресуются по первичному ключу. Перечисление идентификаторов
9
- # затрагивает ровно столько строк, сколько их перечислено, и промах виден до выполнения;
10
- # отбор по подстроке не виден никогда.
10
+ # затрагивает ровно столько строк, сколько их перечислено, и промах виден до выполнения; отбор
11
+ # по подстроке не виден никогда.
11
12
  #
12
13
  # Три уровня:
13
14
  # отказ — снос, очистка, правка схемы, удаление и обновление без условия или с условием
@@ -15,15 +16,23 @@
15
16
  # вопрос — остальная запись: адресная правка и вставка, решение за владельцем;
16
17
  # пропуск — чтение.
17
18
  #
18
- # Боевое хранилище отдельно: его адреса перечисляет профиль проекта ({{projectProfile}},
19
- # переменная RT_PROD_DSN) там любая запись отказывается без опт-аута. Бой правится миграцией
20
- # через выкатку, а не запросом из редактора.
19
+ # Боевое хранилище отдельно: его адреса и подключения перечисляет профиль дерева
20
+ # RT_PROD_DSN образец адреса боевой базы: порт туннеля, хост, домен;
21
+ # RT_PROD_CONNECTIONS — идентификаторы боевых подключений среды разработки;
22
+ # RT_LOCAL_CONNECTIONS — они же у локальных: опознанное локальное подключение сильнее любой
23
+ # текстовой догадки;
24
+ # RT_SCRATCH_PORT_RE — порты одноразовых баз, по которым вопрос не задаётся.
25
+ # По боевому адресу любая запись отказывается без опт-аута: бой правится миграцией через
26
+ # выкатку, а не запросом из редактора.
21
27
  #
22
28
  # Опт-аут для остальных случаев: маркер `destructive-ok` в тексте запроса вместе с объяснением,
23
29
  # почему адресация по идентификатору не подходит, понижает отказ до вопроса. Последнее слово
24
30
  # остаётся за владельцем — гард лишь не пропускает такое молча.
25
31
  #
26
- # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет разборщика, битый ввод, чужой инструмент — пропуск.
32
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет разборщика, битый ввод, чужой инструмент — пропуск. Без `perl`
33
+ # вырезать текст сообщений коммитов нечем, поэтому команды работы с историей в этом случае
34
+ # пропускаются целиком — иначе слова «drop» и «update» в описании коммита читались бы как
35
+ # запрос. Доставки запроса внутри такой команды не бывает, так что цена послабления нулевая.
27
36
 
28
37
  input="$(cat 2>/dev/null)"
29
38
  [ -z "$input" ] && exit 0
@@ -31,99 +40,640 @@ command -v jq >/dev/null 2>&1 || exit 0
31
40
 
32
41
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
33
42
 
43
+ # Рабочий каталог нужен одному правилу — разрешению адреса миграции: `DATABASE_URL`
44
+ # обычно не назван в команде и лежит в `.env` рядом с проектом.
45
+ hook_cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
46
+ [ -z "$hook_cwd" ] && hook_cwd="${CLAUDE_PROJECT_DIR:-.}"
47
+
48
+ # Профиль дерева: сперва умолчание пакета, поверх него — надстройка проекта, если она есть.
49
+ # Умолчание ищется и рядом с самим хуком: уезжают они вместе.
50
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
51
+ for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/project.sh"; do
52
+ # shellcheck disable=SC1090
53
+ [ -f "$profile" ] && . "$profile" 2>/dev/null
54
+ done
55
+
56
+ PROD_DSN="${RT_PROD_DSN:-}"
57
+
34
58
  sql=""
59
+ context=""
60
+ conn=""
35
61
  case "$tool" in
36
62
  mcp__webstorm__execute_sql_query)
37
63
  sql="$(printf '%s' "$input" | jq -r '.tool_input.queryText // empty' 2>/dev/null)"
64
+ conn="$(printf '%s' "$input" | jq -r '.tool_input.connectionId // empty' 2>/dev/null)"
65
+ context="запрос через подключение редактора"
38
66
  ;;
67
+ # Терминал IDE исполняет ту же командную строку и кладёт её в то же поле, что и Bash:
68
+ # без этой ветки весь гард обходился сменой инструмента.
39
69
  Bash | mcp__webstorm__execute_terminal_command | mcp__webstorm__execute_tool)
40
70
  cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
41
71
  [ -z "$cmd" ] && exit 0
42
72
 
43
- # Клиент ищется КАК СЛОВО в любом месте команды, а не только в начале: удаление на бою
44
- # выглядит как заход по сети с вложенной командой клиента, и проверка одного лишь
45
- # начала строки проходит мимо него целиком.
73
+ # Универсальный исполнитель зовёт ЛЮБОЙ инструмент редактора по имени, в том числе
74
+ # `execute_sql_query`, и тогда в строке нет ни одного имени клиента, по которому
75
+ # гард себя включает. Сам инструмент закрыт в permissions.deny, но полагаться только
76
+ # на настройку нельзя: её снимут, а гард останется.
77
+ if [ "$tool" = "mcp__webstorm__execute_tool" ] && command -v perl >/dev/null 2>&1; then
78
+ case "$cmd" in
79
+ *execute_sql_query*)
80
+ inner_sql="$(printf '%s' "$cmd" | perl -0ne '
81
+ if (/--queryText(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(.+))/s) {
82
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
83
+ }
84
+ ' 2>/dev/null)"
85
+ inner_conn="$(printf '%s' "$cmd" | perl -0ne '
86
+ if (/--connectionId(?:=|\s+)(?:"([^"]*)"|\x27([^\x27]*)\x27|(\S+))/) {
87
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
88
+ }
89
+ ' 2>/dev/null)"
90
+ ;;
91
+ esac
92
+ # Терминал, завёрнутый в тот же исполнитель: разбираем настоящую команду.
93
+ if [ -z "$inner_sql" ]; then
94
+ inner_cmd="$(printf '%s' "$cmd" | perl -0ne '
95
+ if (/--command(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(.+))/s) {
96
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
97
+ }
98
+ ' 2>/dev/null)"
99
+ [ -n "$inner_cmd" ] && cmd="$inner_cmd"
100
+ fi
101
+ fi
102
+
103
+ # Завёрнутый запрос разбирается как запрос из редактора, а не как команда оболочки:
104
+ # имени клиента в нём нет, зато есть подключение и текст SQL.
105
+ if [ -n "$inner_sql" ]; then
106
+ sql="$inner_sql"
107
+ conn="$inner_conn"
108
+ context="запрос через подключение редактора (обёртка исполнителя)"
109
+ else
110
+
111
+ # Команда, которая ничего никуда не доставляет, разбору не подлежит — даже если имя
112
+ # клиента стоит в её аргументах. `grep -rn "prisma db push" docs/` читает файлы, а не
113
+ # базу; раньше он получал deny, и обойти это можно было только испортив сам шаблон
114
+ # поиска. Тот же приём уже применён в dev-server-guard: решает ГОЛОВА команды, а не
115
+ # упоминание имени где-то внутри.
116
+ # Интересуют команды, которые доносят SQL до сервера. Клиент ищется КАК СЛОВО в любом
117
+ # месте команды, а не только в начале: удаление на бою выглядит как
118
+ # `ssh root@host "docker compose exec postgres psql -c '…'"` — при проверке одного
119
+ # лишь начала строки оно проходило мимо гарда целиком.
46
120
  #
47
- # Порядок принципиален. Отсечка «команда начинается с гита», снимающая ложные
48
- # срабатывания на тексте коммита, открывает обход: в составной команде первое звено
49
- # уносит с собой весь остальной запрос. Поэтому сперва ищется клиент.
50
- printf '%s\n' "$cmd" | grep -qE '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' || exit 0
51
-
52
- # Ложные срабатывания на описаниях снимаются вырезанием текстов сообщений, а не отказом
53
- # от проверки всей команды: в самом сообщении запрос не исполняется, но слова «удалить»
54
- # и «обновить» в нём обычны.
121
+ # Порядок здесь принципиален. Раньше выше стояла отсечка «команда начинается с git/gh»,
122
+ # снимавшая ложные срабатывания на тексте коммита, и она же открывала обход: в
123
+ # `git log && psql … -c "DELETE …"` первое звено уносило с собой весь остальной SQL.
124
+ # Теперь сперва ищется клиент, и только его отсутствие завершает проверку.
125
+ printf '%s\n' "$cmd" | grep -qE \
126
+ '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' \
127
+ || exit 0
128
+
129
+ # Ложные срабатывания на описаниях снимаются иначе — вырезанием текста сообщений
130
+ # (`-m '…'`, `-am "…"`, `--message="…"`, `-F file`), а не отказом от проверки всей
131
+ # команды. В самом сообщении SQL не исполняется, но слова «delete» и «drop» в нём
132
+ # обычны: `git commit -am "chore(api): update prisma schema"` разбирался как
133
+ # UPDATE без WHERE, потому что шаблон требовал `-m` вплотную к дефису.
55
134
  if command -v perl >/dev/null 2>&1; then
56
- # Флага файла в списке быть не должно: под него попадает файл запроса у клиента, и
57
- # признак «запрос приехал файлом» умирает раньше, чем его проверят.
135
+ # `file` в альтернации быть не должно: под него попадал `--file=cleanup.sql`
136
+ # у psql, и признак «SQL приехал файлом» умирал раньше, чем его проверяли.
137
+ # Здесь только флаги, которые несут ТЕКСТ сообщения.
58
138
  cmd="$(printf '%s' "$cmd" | perl -0pe '
59
139
  s/(^|[^[:alnum:]])--?[a-zA-Z]*(m|message|body|title|body-file|F)(=|\s+)("([^"\\]|\\.)*"|\x27[^\x27]*\x27|[^\s;&|]+)/$1/gs
60
140
  ' 2>/dev/null || printf '%s' "$cmd")"
61
- printf '%s\n' "$cmd" | grep -qE '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' || exit 0
141
+ # После вырезания сообщения клиента может уже не остаться — тогда это была
142
+ # git-команда, лишь упоминавшая psql в тексте.
143
+ printf '%s\n' "$cmd" | grep -qE \
144
+ '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' \
145
+ || exit 0
62
146
  else
63
- # Без разборщика вырезать текст сообщения нечем, и разбирать его как запрос нельзя:
64
- # штатный коммит упирался бы в отказ. Доставки запроса внутри команды истории не
65
- # бывает, поэтому здесь дешевле пропустить, чем ломать работу.
147
+ # Без perl вырезать текст сообщения нечем, и разбирать его как SQL нельзя:
148
+ # штатный коммит упирался бы в отказ. Доставки SQL в git-команде не бывает,
149
+ # поэтому здесь дешевле пропустить, чем ломать работу.
66
150
  case "$cmd" in
67
151
  git\ *|*/git\ *|gh\ *|*/gh\ *) exit 0 ;;
68
152
  esac
69
153
  fi
70
154
 
71
155
  sql="$cmd"
156
+ context="команда psql/prisma"
157
+ fi
72
158
  ;;
73
159
  *) exit 0 ;;
74
160
  esac
75
161
 
76
162
  [ -z "$sql" ] && exit 0
77
163
 
78
- # Дальше разбираем без учёта регистра: запрос, разбитый на строки, — тот же запрос.
79
- flat="$(printf '%s' "$sql" | tr '\n' ' ' | tr '[:upper:]' '[:lower:]')"
164
+ # Дальше разбираем без учёта регистра и переводов строк: `delete\n from bookings`
165
+ # тот же запрос, что и в одну строку.
166
+ #
167
+ # Перевод строки при этом превращается в `;`, а не в пробел: в командной строке он РАЗДЕЛЯЕТ
168
+ # вызовы ровно как `;`, и склейка его в пробел стирала границу между ними. Многострочная
169
+ # команда становилась одним сегментом, доказательство чтения из первой строки покрывало
170
+ # неразобранный вызов из второй, и запись на боевую базу проходила молча — тот же обход,
171
+ # что чинили для `&&`, только набранный с новой строки.
172
+ normalize() {
173
+ perl -0ne '
174
+ # Продолжение длинной команды обратным слешем — это одна строка, а не две: без
175
+ # склейки адрес из первой уезжал в соседний сегмент, где клиента уже нет.
176
+ s/\\\n/ /g;
177
+ # Перевод строки заменяется на `;` ТОЛЬКО вне кавычек. Внутри `-c "…"` он часть
178
+ # запроса: многострочный SELECT резался на сегменты, и штатное чтение боевой базы
179
+ # переставало быть доказанным.
180
+ my ($out, $quote, $esc) = ("", "", 0);
181
+ for my $ch (split //, $_) {
182
+ if ($esc) { $out .= $ch; $esc = 0; next; }
183
+ if ($ch eq "\\") { $out .= $ch; $esc = 1; next; }
184
+ if ($quote ne "") {
185
+ $out .= ($ch eq "\n" ? " " : $ch);
186
+ $quote = "" if $ch eq $quote;
187
+ } elsif ($ch eq q{"} || $ch eq q{'"'"'}) {
188
+ $quote = $ch; $out .= $ch;
189
+ } elsif ($ch eq "\n" || $ch eq ";") {
190
+ $out .= "\x01"; # граница независимых команд
191
+ } elsif ($ch eq "|") {
192
+ $out .= "\x02"; # граница звена конвейера
193
+ } elsif ($ch eq "&") {
194
+ $out .= "\x01";
195
+ } else {
196
+ $out .= $ch;
197
+ }
198
+ }
199
+ # Соседние маркеры схлопываются: `&&` и `||` дают по два подряд.
200
+ $out =~ s/\x01+/\x01/g;
201
+ $out =~ s/\x02\x01/\x01/g;
202
+ $out =~ s/\x01\x02/\x01/g;
203
+ # Незакрытая кавычка означает, что автомат разъехался: апостроф в комментарии
204
+ # (`# don'"'"'t forget`) оставлял состояние «внутри строки» до конца ввода, и все
205
+ # последующие переводы строк переставали разделять вызовы — сегментация выключалась
206
+ # одним символом. В таком случае честнее ничего не печатать: вызывающий разделит
207
+ # строки грубым способом, и лишнее дробление сыграет в пользу строгости.
208
+ print $out if $quote eq "";
209
+ ' 2>/dev/null
210
+ }
211
+
212
+ # Грубое разделение — запасной путь: все переводы строк становятся разделителями. Оно строже
213
+ # точного (может разрезать многострочный запрос), поэтому годится и как фолбэк без perl,
214
+ # и как ответ на неразобранную команду.
215
+ flat_rough="$(printf '%s' "$sql" | perl -0pe 's/\\\n/ /g' 2>/dev/null | tr '\n;&' '\001\001\001' | tr '|' '\002' | tr '\t' ' ' | tr '[:upper:]' '[:lower:]')"
216
+ [ -z "$flat_rough" ] && flat_rough="$(printf '%s' "$sql" | tr '\n;&' '\001\001\001' | tr '|' '\002' | tr '\t' ' ' | tr '[:upper:]' '[:lower:]')"
217
+
218
+ if command -v perl >/dev/null 2>&1; then
219
+ flat="$(printf '%s' "$sql" | normalize | tr '\t' ' ' | tr '[:upper:]' '[:lower:]')"
220
+ [ -z "$flat" ] && flat="$flat_rough"
221
+ else
222
+ flat="$flat_rough"
223
+ fi
80
224
 
81
225
  deny() {
82
- echo "$1 Адресуй строки по первичному ключу: перечисление идентификаторов затрагивает ровно столько строк, сколько их названо, и промах виден до выполнения. Если адресация по идентификатору здесь не подходит, поставь маркер destructive-ok в текст запроса с объяснением — решение тогда примет владелец." >&2
83
- exit 2
226
+ jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
227
+ || printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Destructive SQL blocked. Address rows by id."}}\n'
228
+ exit 0
84
229
  }
85
230
 
86
231
  ask() {
87
- jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}' 2>/dev/null
232
+ jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}' 2>/dev/null \
233
+ || exit 0
88
234
  exit 0
89
235
  }
90
236
 
91
- # Бой: адреса знает профиль. Там любая запись отказывается, и опт-аут не действует.
92
- profile="${CLAUDE_PROJECT_DIR:-.}/{{projectProfile}}"
93
- if [ -f "$profile" ]; then
94
- # shellcheck disable=SC1090
95
- . "$profile" 2>/dev/null
96
- if [ -n "${RT_PROD_DSN:-}" ] && printf '%s' "$flat" | grep -qF "$RT_PROD_DSN"; then
97
- case "$flat" in
98
- *insert\ *|*update\ *|*delete\ *|*drop\ *|*truncate\ *|*alter\ *)
99
- echo "Запись в боевое хранилище запрещена совсем: схема меняется миграцией через выкатку, данные — через интерфейс." >&2
100
- exit 2 ;;
237
+ # --- прод: запись запрещена в любом виде ------------------------------------------------
238
+ # Признак берётся из профиля: порт туннеля, хост, домен — у каждого дерева свои.
239
+ #
240
+ # Ищем его в АДРЕСЕ, а не в данных: домен приложения живёт и в самих строках — в почте
241
+ # владельца, в канонической ссылке объекта. Пока признак брался по всей команде, вставка
242
+ # строки с таким адресом в локальную базу отклонялась как запись в бой, а опт-аута у этой
243
+ # ветки нет по замыслу — команда становилась неисполнимой.
244
+ # Поэтому текст запроса (то, что стоит после `-c`/`--command`) из проверки вырезается.
245
+ is_prod=""
246
+
247
+ # Запрос из редактора адресата в тексте не называет: база выбирается идентификатором
248
+ # подключения, и по одному SQL отличить прод от локальной копии невозможно. Поэтому
249
+ # подключения опознаются в лицо. Список сверяется вызовом list_database_connections;
250
+ # добавили новое — допишите сюда, иначе оно попадёт в «неизвестные» ниже.
251
+ PROD_CONNECTIONS="${RT_PROD_CONNECTIONS:-}"
252
+ LOCAL_CONNECTIONS="${RT_LOCAL_CONNECTIONS:-}"
253
+
254
+ conn_known=""
255
+ if [ -n "$conn" ]; then
256
+ case " $PROD_CONNECTIONS " in *" $conn "*) is_prod="yes"; conn_known="prod" ;; esac
257
+ # Опознанное локальное подключение сильнее текстовой догадки: домен приложения живёт в
258
+ # самих данных — в почте владельца, в канонической ссылке объекта. Пока вывод не
259
+ # отменялся, обновление такой строки на локальной базе отклонялось как запись в бой, и
260
+ # обойти это было нечем.
261
+ case " $LOCAL_CONNECTIONS " in *" $conn "*) conn_known="local"; is_prod="" ;; esac
262
+ fi
263
+
264
+ # Разбор идёт по сегментам, а не по всей строке: `ls -l /opt && … psql -c "\copy …"` целиком
265
+ # выглядел как чтение, потому что `-l` от `ls` засчитывался за `psql -l`.
266
+ #
267
+ # Нарезка живёт в ЕДИНСТВЕННОМ месте. Пока наборов разделителей было два — один в раннем слое,
268
+ # другой здесь, — расхождение само по себе оказалось дырой: одиночный `|` считался границей
269
+ # тут и не считался там, и доставка файла конвейером в клиент проходила как чтение.
270
+ # Команда — то, что стоит между `;`, `&&`, `||` и переводами строк. Конвейер границей НЕ
271
+ # является: `cat fix.sql | psql …` — не две независимые команды, а одна доставка SQL, где
272
+ # левое звено питает правое. Пока `|` резал команды, глагол из `echo "drop table …"` терялся
273
+ # вместе с отброшенным звеном, и запись проходила молча.
274
+ split_segments() {
275
+ # Перевод строки в конце обязателен: без него `read` не отдаёт последнюю строку в тело
276
+ # цикла, и команда из одного сегмента молча выпадала из разбора целиком.
277
+ printf '%s\n' "$1" | tr '\001' '\n'
278
+ }
279
+
280
+ # Звенья конвейера внутри одной команды.
281
+ split_pipeline() {
282
+ printf '%s' "$1" | tr '\002' '\n'
283
+ }
284
+
285
+ # Команда считается вызовом клиента, только если клиент стоит ГОЛОВОЙ хотя бы одного звена.
286
+ # Имя в аргументе (`grep -rn "prisma db push" docs/`) вызовом не является.
287
+ calls_client() {
288
+ _found=""
289
+ _links="$(printf '%s' "$1" | tr '\002' '\n')"
290
+ while IFS= read -r link; do
291
+ [ -z "$link" ] && continue
292
+ # Голова звена: пропускаем присваивания окружения и обёртки вроде sudo/time/xargs.
293
+ head="$(printf '%s' "$link" | sed -E 's/^[[:space:]]*//; s/^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*//; s/^(sudo|time|env|nice|xargs)[[:space:]]+//')"
294
+ case "$head" in
295
+ psql*|pg_restore*|pg_dump*|pg_dumpall*|prisma*|*/psql*|*/pg_restore*|*/pg_dump*)
296
+ _found="yes"; break ;;
297
+ esac
298
+ # Транспорт до машины: сам вызов стоит внутри строки, которую он исполняет.
299
+ case "$head" in
300
+ ssh\ *|scp\ *|docker\ *|*/ssh\ *|*/docker\ *)
301
+ printf '%s' "$link" | grep -qE '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' \
302
+ && { _found="yes"; break; } ;;
101
303
  esac
304
+ # Раннер пакетов перед клиентом: `npx prisma migrate deploy`.
305
+ case "$head" in
306
+ npx\ *|pnpm\ *|yarn\ *|bun\ *|npm\ *)
307
+ printf '%s' "$head" | grep -qE '^(npx|pnpm|yarn|bun|npm)([[:space:]]+(exec|run|dlx))?[[:space:]]+(psql|pg_restore|pg_dump|prisma)([[:space:]]|$)' \
308
+ && { _found="yes"; break; } ;;
309
+ esac
310
+ done <<CALLS_EOF
311
+ $_links
312
+ CALLS_EOF
313
+ [ -n "$_found" ]
314
+ }
315
+
316
+ # Голова сегмента — читающий инструмент: имя клиента стоит в его аргументе, то есть это поиск
317
+ # или просмотр файла, а не доставка SQL. `grep -rn "prisma db push" docs/` читает документацию.
318
+ # Решает именно ГОЛОВА: тот же приём применён в dev-server-guard якорем BOUND.
319
+ is_reader() {
320
+ printf '%s' "$1" | grep -qE '^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*((sudo|time|env|nice|xargs)[[:space:]]+)*([^[:space:]]*/)?(grep|rg|ag|ack|find|awk|sed|cat|less|more|head|tail|wc|echo|printf|jq|diff|comm|sort|uniq|column)([[:space:]]|$)'
321
+ }
322
+
323
+ # Сегменты с настоящим вызовом клиента: читающие головы отсеиваются здесь же, а не отдельным
324
+ # слоем с собственным выходом.
325
+ client_segments() {
326
+ split_segments "$flat" | while IFS= read -r seg; do
327
+ [ -z "$seg" ] && continue
328
+ calls_client "$seg" || continue
329
+ # Маркер звена больше не нужен: дальше команда разбирается как одна строка.
330
+ printf '%s\n' "$(printf '%s' "$seg" | tr '\002' ' ')"
331
+ done
332
+ }
333
+
334
+
335
+ segments="$(client_segments)"
336
+ if [ -z "$segments" ]; then
337
+ # У запроса из редактора сегментов нет вовсе — там весь текст и есть запрос.
338
+ if [ -n "$conn" ] || [ "$context" != "команда psql/prisma" ]; then
339
+ segments="$flat"
340
+ else
341
+ # Команда, где имя клиента встретилось только в аргументах читающих инструментов,
342
+ # ничего никуда не доставляет. Выход здесь безопасен: он стоит ПОСЛЕ разбора, а не
343
+ # вместо него, и срабатывает лишь когда настоящего вызова в команде нет.
344
+ exit 0
102
345
  fi
103
346
  fi
104
347
 
105
- opt_out=0
106
- case "$flat" in *destructive-ok*) opt_out=1 ;; esac
348
+ # Адресат берётся ТОЛЬКО из сегментов вызова клиента и только из аргументов, а не из данных.
349
+ # Домен сайта живёт в самих строках — в почте владельца, в canonical и og:url объекта, — а
350
+ # рядом в цепочке его дёргают по HTTP. Пока признак искался по всей команде, `curl
351
+ # https://<домен приложения>/... && psql -p 5432 -c "update …"` отклонялся как запись в
352
+ # боевую базу, и опт-аута у этой ветки нет по замыслу: команда становилась неисполнимой.
353
+ # Текст запроса вырезается ДО нарезки на сегменты. Внутри `-c "…"` свободно живут `;` и `||`
354
+ # — по ним сегменты и режутся, поэтому кусок запроса с доменом в данных становился отдельным
355
+ # сегментом, и почта на домене приложения снова читалась как адрес боевой базы.
356
+ addr_flat="$flat"
357
+ if command -v perl >/dev/null 2>&1; then
358
+ addr_flat="$(printf '%s' "$flat" | perl -0pe '
359
+ s/(^|[^[:alnum:]])--?(c|command|querytext)(=|\s+)("([^"\\]|\\.)*"|\x27[^\x27]*\x27)/$1/gs
360
+ ' 2>/dev/null || printf '%s' "$flat")"
361
+ fi
362
+ addr_segments() {
363
+ split_segments "$addr_flat" | while IFS= read -r seg; do
364
+ [ -z "$seg" ] && continue
365
+ flat_seg="$(printf '%s' "$seg" | tr '\002' ' ')"
366
+ # Адрес берётся из команд с вызовом клиента и из транспорта до машины: `ssh root@…`
367
+ # называет боевой хост, а сам вызов стоит дальше по цепочке.
368
+ if calls_client "$seg"; then
369
+ printf '%s\n' "$flat_seg"
370
+ elif printf '%s' "$flat_seg" | grep -qE '(^|[^[:alnum:]_.-])(ssh|scp|docker)([^[:alnum:]_.-]|$)|(pghost|pgport|pgdatabase|pguser|database_url|postgres_url)='; then
371
+ is_reader "$flat_seg" && continue
372
+ printf '%s\n' "$flat_seg"
373
+ fi
374
+ done
375
+ }
107
376
 
108
- case "$flat" in
109
- *drop\ *|*truncate\ *|*alter\ table*)
110
- [ "$opt_out" = 1 ] && ask "Схемная команда с маркером destructive-ok. Решение за тобой."
111
- deny "Снос, очистка или правка схемы запросом." ;;
112
- esac
377
+ # Для запроса из редактора адрес в тексте не назван вовсе: там решает опознанное подключение,
378
+ # а если оно неизвестно — ветка ниже спросит пользователя.
379
+ is_scratch=""
380
+ addr_other=""
381
+ if [ -z "$conn" ] && [ "$context" != "запрос через подключение редактора (обёртка исполнителя)" ]; then
382
+ while IFS= read -r seg; do
383
+ [ -z "$seg" ] && continue
384
+ if [ -n "$PROD_DSN" ] && printf '%s' "$seg" | grep -qE "$PROD_DSN"; then
385
+ is_prod="yes"
386
+ addr_other="yes"
387
+ continue
388
+ fi
389
+ # Одноразовая база проверки: петлевой адрес и порт из отведённого под них диапазона.
390
+ # Диапазон занимают контейнеры, которые поднимают на время одной проверки —
391
+ # восстановление копии, репетиция миграции на непустой базе — и сносят следом. Данных,
392
+ # которые стоило бы стеречь, там нет по построению, а вопрос на каждую строку такой
393
+ # проверки приучает отвечать «да» не читая и обесценивает тот вопрос, который был важен.
394
+ #
395
+ # Диапазон узкий и петлевой намеренно: под него не должны попадать ни рабочая база
396
+ # дерева, ни туннель к бою, ни базы соседних деревьев на этой машине. Какой он здесь,
397
+ # знает профиль; не назван — исключения нет вовсе, и вопрос задаётся всегда.
398
+ if [ -n "${RT_SCRATCH_PORT_RE:-}" ] \
399
+ && printf '%s' "$seg" | grep -qE "$RT_SCRATCH_PORT_RE" \
400
+ && printf '%s' "$seg" | grep -qE '127\.0\.0\.1|localhost|host\.docker\.internal'; then
401
+ is_scratch="yes"
402
+ else
403
+ # Любой другой адресованный вызов снимает исключение целиком: в цепочке
404
+ # `psql -p 19434 -f x.sql && psql -c "delete …"` ранний выход убрал бы проверку
405
+ # со второго звена, а это ровно тот обход, ради которого гард и написан.
406
+ addr_other="yes"
407
+ fi
408
+ done <<EOF
409
+ $(addr_segments)
410
+ EOF
411
+ fi
113
412
 
114
- case "$flat" in
115
- *delete\ from*|*update\ *set\ *)
116
- # Условие по идентификатору единственная форма, где число задетых строк известно
117
- # заранее. Отбор по подстроке им не является, даже когда сегодня совпадает точно.
118
- if printf '%s' "$flat" | grep -qE 'where[^;]*\b(id|uuid)\b[[:space:]]*(=|in[[:space:]]*\()'; then
119
- ask "Адресная правка данных. Решение за тобой."
413
+ is_write=""
414
+ # Глаголы ищутся в сегментах ВЫЗОВА, а не по всей строке: иначе слово из шаблона поиска в
415
+ # соседнем звене цепочки объявляло записью читающую команду.
416
+ write_scope="$segments"
417
+ # `copy from` и `select into` — запись, не называющая ни одного привычного глагола.
418
+ printf '%s' "$write_scope" | grep -qE '(^|[^[:alnum:]_])(delete|update|insert|truncate|drop|alter|create|grant|revoke|copy)([^[:alnum:]_]|$)|\\copy|into[[:space:]]+[a-z_"]' \
419
+ && is_write="yes"
420
+ # Конвейер из источника в клиент — та же доставка файла, только без флага: содержимое
421
+ # `cat fix.sql | psql …` гарду не видно, значит это запись по определению.
422
+ while IFS= read -r seg; do
423
+ [ -z "$seg" ] && continue
424
+ printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])(cat|head|tail|gzcat|zcat|gunzip|echo|printf|curl|wget)([[:space:]]|$)' \
425
+ && is_write="yes" && break
426
+ done <<PIPE_EOF
427
+ $(client_segments)
428
+ PIPE_EOF
429
+
430
+ # Команды prisma меняют базу, не называя ни одного SQL-глагола: `migrate reset` пересоздаёт
431
+ # её целиком, `db push` подгоняет схему под модель, `db execute` льёт произвольный файл.
432
+ printf '%s' "$write_scope" | grep -qE 'prisma[[:space:]]+(migrate[[:space:]]+(reset|deploy|dev)|db[[:space:]]+(push|execute))' \
433
+ && is_write="yes"
434
+
435
+ # Глагол в командной строке — не единственный способ довезти SQL до сервера. Файл (`-f`,
436
+ # `--file`, `< dump.sql`) и `pg_restore` не называют ни одного, поэтому раньше проходили
437
+ # мимо всех трёх уровней: доставка файла в клиент на боевой базе завершалась нулём
438
+ # без единого вопроса, а `pg_restore` не мог сработать в принципе, хотя `--clean` сносит
439
+ # содержимое. Содержимое файла гарду недоступно — значит это запись по определению.
440
+ printf '%s' "$flat" | grep -qE '(^|[^[:alnum:]_.-])pg_restore([^[:alnum:]_.-]|$)' \
441
+ && is_write="yes"
442
+ # У `pg_dump` тот же `-f` означает файл ВЫВОДА: это чтение, и записью его считать нельзя —
443
+ # иначе штатное снятие дампа с боевой базы отклонялось, хотя текст отказа сам его советует.
444
+ #
445
+ # Исключение действует ПОСЕГМЕНТНО. Пока оно проверялось по всей команде, одного упоминания
446
+ # `pg_dump` где угодно в цепочке хватало, чтобы `-f` перестал считаться записью во всех
447
+ # остальных вызовах: `pg_dump … > /dev/null && psql -d app -f /tmp/x.sql` проходил молча.
448
+ while IFS= read -r seg; do
449
+ [ -z "$seg" ] && continue
450
+ # Граница слова обязательна: `-f /tmp/pg_dump-restore.sql` — это заливка дампа, а не
451
+ # его снятие, и подстрочное совпадение снимало с неё обе защиты разом.
452
+ if printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])pg_dump(all)?([^[:alnum:]_.-]|$)'; then
453
+ continue
454
+ fi
455
+ # Хвост сегмента после имени клиента: `-f` у `docker compose` (боевой compose-файл
456
+ # называется нестандартно, без флага не поднимается) стоит ДО `psql` и к запросу
457
+ # отношения не имеет.
458
+ seg_tail="$(printf '%s' "$seg" | perl -0pe 's{^.*?(?<![[:alnum:]_./-])(psql|pg_restore|prisma)(?=\s|$)}{$1}s' 2>/dev/null)"
459
+ [ -z "$seg_tail" ] && seg_tail="$seg"
460
+ if printf '%s' "$seg_tail" | grep -qE '(^|[[:space:]])(-f|--file)([[:space:]]|=)|<[[:space:]]*[^[:space:]|<]+\.(sql|dump)'; then
461
+ is_write="yes"
462
+ break
463
+ fi
464
+ done <<EOF
465
+ $segments
466
+ EOF
467
+
468
+ # Исключение стоит ПОСЛЕ разбора адреса и ПЕРЕД правилами записи, но строго после того, как
469
+ # признак боевой базы уже выставлен: прод перекрывает исключение при любом совпадении, а не
470
+ # наоборот. Условие тройное — одноразовый адрес найден, боевого нет, и других адресованных
471
+ # вызовов в команде нет вовсе.
472
+ if [ -n "$is_scratch" ] && [ -z "$is_prod" ] && [ -z "$addr_other" ]; then
473
+ exit 0
474
+ fi
475
+
476
+ if [ -n "$is_prod" ] && [ -n "$is_write" ]; then
477
+ deny "BLOCKED: запись в БОЕВОЕ хранилище (${context}). Адрес ведёт на бой — оттуда данные не восстанавливаются ничем, кроме копии. Схема на бою меняется миграцией через выкатку, данные — через панель владельца. Если правка данных на бою действительно нужна, её делает владелец руками, предварительно сняв копию; гард обойти нельзя."
478
+ fi
479
+
480
+ # На боевой базе разрешено только то, что гард опознал как чтение — БЕЛЫМ списком, а не
481
+ # перечислением запретов. Чёрный список здесь принципиально не работает: SQL доезжает до
482
+ # сервера файлом, редиректом, `\copy`, `SELECT … INTO`, и каждая заделанная форма оставляет
483
+ # соседнюю. Поэтому вопрос перевёрнут: не «есть ли здесь запись», а «доказано ли чтение».
484
+ #
485
+ # Чтением считаются три формы, и каждая проверяется в СЕГМЕНТЕ своего вызова:
486
+ # pg_dump без --clean/--create — снятие дампа
487
+ # psql -c "<один SELECT>" — запрос без второго стейтмента
488
+ # psql -l / --version / --help — проверка соединения без запроса
489
+ if [ -n "$is_prod" ]; then
490
+ unproven=""
491
+ while IFS= read -r seg; do
492
+ [ -z "$seg" ] && continue
493
+ seg_read=""
494
+ # Аргументы клиента — всё, что стоит ПОСЛЕ его имени. До имени в том же сегменте
495
+ # свободно живут чужие флаги: `docker compose -f docker-compose.prod.yml … psql`,
496
+ # и `-f` от compose однажды отменял доказательство чтения.
497
+ seg_tail="$(printf '%s' "$seg" | perl -0pe 's{^.*?(?<![[:alnum:]_./-])(psql|pg_restore|prisma)(?=\s|$)}{$1}s' 2>/dev/null)"
498
+ [ -z "$seg_tail" ] && seg_tail="$seg"
499
+
500
+ if printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])pg_dump(all)?([^[:alnum:]_.-]|$)'; then
501
+ case "$seg" in
502
+ *--clean*|*--create*) ;;
503
+ *) seg_read="yes" ;;
504
+ esac
120
505
  fi
121
- [ "$opt_out" = 1 ] && ask "Правка без адресации по идентификатору, с маркером destructive-ok. Решение за тобой."
122
- deny "Удаление или обновление без условия по идентификатору." ;;
123
- esac
124
506
 
507
+ # Один SELECT и ничего кроме: второй стейтмент через `;` уже не чтение.
508
+ if printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_])select([^[:alnum:]_]|$)' \
509
+ && ! printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_])(delete|update|insert|truncate|drop|alter|grant|revoke|copy)([^[:alnum:]_]|$)|\\copy|into[[:space:]]+[a-z_"]' \
510
+ && ! printf '%s' "$seg_tail" | grep -qE '(^|[[:space:]])(-f|--file)([[:space:]]|=)'; then
511
+ seg_read="yes"
512
+ fi
513
+
514
+ printf '%s' "$seg_tail" | grep -qE '(^|[[:space:]])(-l|--list|--version|--help)([[:space:]]|$)' \
515
+ && seg_read="yes"
516
+
517
+ [ -z "$seg_read" ] && unproven="yes"
518
+ done <<EOF
519
+ $segments
520
+ EOF
521
+
522
+ if [ -n "$unproven" ]; then
523
+ deny "BLOCKED: вызов клиента БД против БОЕВОЙ базы (${context}). На проде разрешено только доказанное чтение — \`pg_dump\` без \`--clean\`, одиночный SELECT через \`-c\`, либо \`-l\`/\`--version\`. Всё остальное отклоняется, даже если гард просто не разобрал команду: содержимое файлов (\`-f\`, \`< dump.sql\`), \`\\copy\` и восстановление дампа ему не видны. Если нужен разбор данных боевой базы — снимай дамп и работай с локальной копией."
524
+ fi
525
+ fi
526
+
527
+ [ -z "$is_write" ] && exit 0
528
+
529
+ # --- заведомо разрушительное ------------------------------------------------------------
530
+ soft=""
125
531
  case "$flat" in
126
- *insert\ into*) ask "Вставка данных. Решение за тобой." ;;
532
+ *destructive-ok*) soft="yes" ;;
127
533
  esac
128
534
 
129
- exit 0
535
+ verdict() {
536
+ if [ -n "$soft" ]; then
537
+ ask "Запрос помечен маркером destructive-ok, но остаётся разрушительным: $1 Подтверди выполнение, если это осознанно."
538
+ fi
539
+ deny "BLOCKED: $1 Адресуй строки по первичному ключу — \`WHERE id IN ('…','…')\`: так затрагивается ровно столько строк, сколько перечислено, и промах виден до выполнения. Удаление по маске (email LIKE '%test%') однажды унесло вместе с тестовыми записями демонстрационные брони владельца. Если адресация по id действительно не подходит — сначала выполни SELECT с тем же условием, покажи пользователю, что попадает под удаление, и помечай запрос маркером destructive-ok с объяснением."
540
+ }
541
+
542
+ if printf '%s' "$flat" | grep -qE '(^|[^[:alnum:]_])(truncate|drop[[:space:]]+(table|database|schema|column|index)|alter[[:space:]]+table)([^[:alnum:]_]|$)'; then
543
+ verdict "запрос меняет саму схему или очищает таблицу целиком (${context})."
544
+ fi
545
+
546
+ if printf '%s' "$flat" | grep -qE 'prisma[[:space:]]+(migrate[[:space:]]+reset|db[[:space:]]+push)'; then
547
+ verdict "\`prisma migrate reset\` / \`db push\` пересоздаёт базу и теряет её содержимое (${context})."
548
+ fi
549
+
550
+ # `pg_restore --clean` перед загрузкой удаляет существующие объекты — то же очищение
551
+ # таблиц, только чужими руками. Без `--clean` это обычная догрузка, она идёт общим путём.
552
+ if printf '%s' "$flat" | grep -q 'pg_restore' && printf '%s' "$flat" | grep -qE '(^|[[:space:]])(--clean|-c|--create)([[:space:]]|=|$)'; then
553
+ verdict "\`pg_restore --clean\` удаляет объекты базы перед загрузкой дампа (${context})."
554
+ fi
555
+
556
+ # Штатное применение миграций разрушительным не считается. Раньше здесь стоял безусловный
557
+ # `ask` со словами «убедись, что DATABASE_URL указывает на локальную базу» — гард
558
+ # перекладывал на человека ровно ту работу, которую умеет сделать сам. На локальной базе
559
+ # миграции гоняются постоянно, и вопрос на каждую не добавляет безопасности: гард, который
560
+ # спрашивает по десятому разу, перестают читать вместе с тем единственным вопросом, который
561
+ # был важен.
562
+ #
563
+ # Поэтому адрес РАЗРЕШАЕТСЯ, а не угадывается: сначала inline-префикс самой команды, затем
564
+ # переменная окружения, затем `.env` рабочего каталога.
565
+ unquote() {
566
+ sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//"
567
+ }
568
+
569
+ resolve_database_url() {
570
+ # Искать по `$flat` нельзя: он приведён к нижнему регистру, и `DATABASE_URL=` в нём
571
+ # не встречается никогда — правило молча брало бы адрес из `.env`, игнорируя явно
572
+ # указанный в команде. Разбирается исходная строка, а `$flat` остаётся запасным
573
+ # вариантом с регистронезависимым поиском.
574
+ inline="$(printf '%s' "${cmd:-}" | grep -oE '[Dd][Aa][Tt][Aa][Bb][Aa][Ss][Ee]_[Uu][Rr][Ll]=[^[:space:]]+' | tail -n1)"
575
+ [ -z "$inline" ] && inline="$(printf '%s' "$flat" | grep -oiE 'database_url=[^[:space:]]+' | tail -n1)"
576
+ if [ -n "$inline" ]; then
577
+ printf '%s' "${inline#*=}" | unquote
578
+ return
579
+ fi
580
+ if [ -n "${DATABASE_URL:-}" ]; then
581
+ printf '%s' "$DATABASE_URL" | unquote
582
+ return
583
+ fi
584
+ # Подъём по дереву, а не только рабочий каталог: агент работает в git worktree под
585
+ # `.claude/worktrees/<ветка>/`, где своего `.env` нет, а у основного чекаута — есть,
586
+ # и он лежит ровно выше по пути. Без подъёма гард не разрешил бы адрес ни разу
587
+ # именно там, где миграции и гоняются.
588
+ dir="$hook_cwd"
589
+ depth=0
590
+ while [ -n "$dir" ] && [ "$dir" != '/' ] && [ "$depth" -lt 8 ]; do
591
+ for env_file in "$dir/.env" "$dir/.env.local"; do
592
+ [ -f "$env_file" ] || continue
593
+ value="$(grep -m1 -E '^[[:space:]]*DATABASE_URL=' "$env_file" 2>/dev/null | sed -e 's/^[[:space:]]*DATABASE_URL=//' | unquote)"
594
+ if [ -n "$value" ]; then
595
+ printf '%s' "$value"
596
+ return
597
+ fi
598
+ done
599
+ dir="$(dirname "$dir")"
600
+ depth=$((depth + 1))
601
+ done
602
+ }
603
+
604
+ if printf '%s' "$flat" | grep -qE 'prisma[[:space:]]+migrate[[:space:]]+(deploy|dev)'; then
605
+ migrate_target="$(resolve_database_url)"
606
+ # Боевой адрес проверяется ДО разбора по видам: туннель к бою тоже висит на петлевом
607
+ # адресе, только на своём порту, и общее правило «петлевой значит локальный» пропустило бы
608
+ # миграцию на бой.
609
+ if [ -n "$PROD_DSN" ] && printf '%s' "$migrate_target" | grep -qE "$PROD_DSN"; then
610
+ deny "BLOCKED: применение миграций к БОЕВОЙ базе (${context}). Адрес базы ведёт на бой. Схема на бою меняется выкаткой: она сама зовёт применение миграций одноразовым контейнером до старта приложения. Руками этого делать нельзя — гард обойти нельзя."
611
+ fi
612
+ case "$migrate_target" in
613
+ *@localhost:*|*@127.0.0.1:*|*@postgres:*|*@host.docker.internal:*)
614
+ # Локальная база — штатная работа. Разбор завершается здесь, иначе ниже
615
+ # сработает общее правило про запись в базу и вопрос всё равно будет задан:
616
+ # `migrate deploy` помечен записью выше по тексту.
617
+ #
618
+ # Но выйти можно, только если запись в команде ОДНА — сама миграция. В цепочке
619
+ # `prisma migrate deploy && psql -c "delete …"` ранний выход снял бы проверку со
620
+ # второго звена, а это ровно тот обход, ради которого гард и написан.
621
+ other_write=""
622
+ printf '%s' "$write_scope" \
623
+ | grep -qE '(^|[^[:alnum:]_])(delete|update|insert|truncate|drop|alter|create|grant|revoke|copy)([^[:alnum:]_]|$)|\\copy|into[[:space:]]+[a-z_"]' \
624
+ && other_write="yes"
625
+ while IFS= read -r seg; do
626
+ [ -z "$seg" ] && continue
627
+ printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])(cat|head|tail|gzcat|zcat|gunzip|echo|printf|curl|wget)([[:space:]]|$)' \
628
+ && other_write="yes" && break
629
+ done <<MIGRATE_PIPE_EOF
630
+ $(client_segments)
631
+ MIGRATE_PIPE_EOF
632
+ [ -z "$other_write" ] && exit 0
633
+ ;;
634
+ '')
635
+ ask "Применение миграций к базе (${context}), но адрес разрешить не удалось: DATABASE_URL нет ни в команде, ни в окружении, ни в \`.env\` рабочего каталога. Проверь, куда пойдёт миграция, и подтверди."
636
+ ;;
637
+ *)
638
+ ask "Применение миграций по адресу, НЕИЗВЕСТНОМУ ГАРДУ (${context}). Гард знает локальные адреса и боевые; этот — ни то, ни другое. Убедись, что это не прод, и подтверди."
639
+ ;;
640
+ esac
641
+ fi
642
+
643
+ # --- delete/update: смотрим на адресацию ------------------------------------------------
644
+ if printf '%s' "$flat" | grep -qE '(^|[^[:alnum:]_])(delete[[:space:]]+from|update)([^[:alnum:]_]|$)'; then
645
+ if ! printf '%s' "$flat" | grep -q 'where'; then
646
+ verdict "DELETE/UPDATE без WHERE затрагивает всю таблицу (${context})."
647
+ fi
648
+
649
+ # Адресация ищется ТОЛЬКО в хвосте после последнего `where`. Пока смотрели на весь
650
+ # запрос, присваивание в SET засчитывалось за адресацию: `UPDATE bookings SET
651
+ # "propertyId" = 'p1' WHERE source = 'site'` выглядел адресным, хотя задевал все прямые
652
+ # заявки объекта — ровно то, от чего гард и защищает.
653
+ # Регистр здесь сохраняется, в отличие от `flat`: колонки Prisma пишутся в camelCase, и
654
+ # приведённый к нижнему регистру `bookingid` уже не отличить от слова `paid`.
655
+ where_tail="$(printf '%s' "$sql" | tr '\n\t' ' ' | sed -E 's/.*[Ww][Hh][Ee][Rr][Ee]/where/')"
656
+
657
+ # Имя колонки требуется целиком: `id`, `booking_id`, `"bookingId"`. Прежний шаблон
658
+ # `[a-z_]*id` принимал за идентификатор `paid` и `valid`.
659
+ if ! printf '%s' "$where_tail" | grep -qE '(^|[^[:alnum:]_"])"?(id|[A-Za-z_]+_id|[a-zA-Z]+Id)"?[[:space:]]*(=|[Ii][Nn][[:space:]]*\()'; then
660
+ verdict "DELETE/UPDATE адресует строки не по идентификатору (${context}) — условие может совпасть шире, чем задумано."
661
+ fi
662
+
663
+ # Гард видит имя колонки, но не схему: `propertyId` и `session_id` — внешние ключи, и
664
+ # запрос по ним адресный только на вид. `DELETE … WHERE "propertyId" = 'p1'` сносит все
665
+ # брони объекта. Отличить это от первичного ключа без схемы нельзя, поэтому решение
666
+ # остаётся за пользователем — но предупреждение должно быть прямым, а не общим.
667
+ if ! printf '%s' "$where_tail" | grep -qE '(^|[^[:alnum:]_"])"?id"?[[:space:]]*(=|[Ii][Nn][[:space:]]*\()'; then
668
+ ask "Условие адресует строки по ВНЕШНЕМУ ключу, а не по первичному (${context}): $(printf '%s' "$where_tail" | head -c 200). Под него попадут ВСЕ строки, связанные с этой сущностью, — например \`WHERE \"propertyId\" = …\` заденет все брони объекта, включая демонстрационные. Если нужны конкретные строки, сперва выбери их SELECT-ом и перечисли в \`WHERE id IN (…)\`."
669
+ fi
670
+ fi
671
+
672
+ # --- остальная запись: решает пользователь ----------------------------------------------
673
+ # Неизвестное подключение — не повод пропустить молча: адресат запроса не опознан, и им
674
+ # может оказаться боевая база под другим идентификатором.
675
+ if [ "$conn_known" = "" ] && [ -n "$conn" ]; then
676
+ ask "Запись в базу через ПОДКЛЮЧЕНИЕ, НЕИЗВЕСТНОЕ ГАРДУ (id ${conn}). Гард знает локальное подключение и боевое; это — ни то, ни другое, поэтому убедись, что запрос уходит не на прод. Если подключение постоянное, впиши его id в PROD_CONNECTIONS или LOCAL_CONNECTIONS в .claude/hooks/sql-guard.sh."
677
+ fi
678
+
679
+ ask "Запись в базу (${context}). Проверь, что затронуты только ожидаемые строки, и подтверди."