@rt-tools/agent-kit 0.8.3 → 0.9.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.
- package/README.md +12 -0
- package/assets/checks/board.github.mjs +48 -1
- package/assets/checks/check-board.github.mjs +84 -1
- package/assets/checks/check-lib-layers.mjs +13 -524
- package/assets/checks/check-specs.mjs +11 -782
- package/assets/checks/check-styles.mjs +185 -15
- package/assets/checks/lib-boundaries.mjs +143 -0
- package/assets/checks/lib-common.mjs +149 -0
- package/assets/checks/lib-domains.mjs +205 -0
- package/assets/checks/lib-manifests.mjs +60 -0
- package/assets/checks/lib-reexports.mjs +101 -0
- package/assets/checks/rt-kit-checks.config.mjs +17 -0
- package/assets/checks/spec-anchors.mjs +297 -0
- package/assets/checks/spec-common.mjs +222 -0
- package/assets/checks/spec-contract.mjs +152 -0
- package/assets/checks/spec-scenarios.mjs +201 -0
- package/assets/defaults/project.sh +8 -0
- package/assets/hooks/git-guard-push-tests.sh +8 -4
- package/assets/hooks/skill-gate.sh +1 -1
- package/assets/hooks/sql-guard-parse.sh +187 -0
- package/assets/hooks/sql-guard-request.sh +117 -0
- package/assets/hooks/sql-guard-target.sh +134 -0
- package/assets/hooks/sql-guard-write.sh +212 -0
- package/assets/hooks/sql-guard.sh +26 -596
- package/assets/hooks/waiting-turn-guard.sh +42 -13
- package/assets/laws/delivery.md +7 -0
- package/assets/laws/work-conduct.md +9 -0
- package/assets/patterns/admin-lists-screen.md +25 -14
- package/assets/patterns/admin-nav-item.md +1 -1
- package/assets/patterns/component-structure-new.md +1 -1
- package/assets/patterns/entity-aside.md +4 -2
- package/assets/patterns/observability-record.md +9 -0
- package/assets/patterns/shared-code-new.md +2 -2
- package/assets/patterns/task-flow-close.md +7 -1
- package/assets/rules/git-workflow.azure.md +7 -0
- package/assets/rules/git-workflow.github.md +41 -0
- package/assets/rules/git-workflow.gitlab.md +7 -0
- package/assets/rules/lib-layers.md +4 -0
- package/assets/rules/lists.md +10 -10
- package/assets/rules/shared-code.md +1 -1
- package/assets/rules/task-flow.md +47 -7
- package/assets/rules/testing.md +31 -0
- package/assets/rules/typescript-conventions.md +7 -0
- package/assets/skills/agent-kit.md +36 -0
- package/assets/templates/proposal.md +21 -0
- package/bin/agent-kit.d.ts.map +1 -1
- package/bin/agent-kit.js +115 -87
- package/bin/agent-kit.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/lib/argv.d.ts.map +1 -1
- package/lib/argv.js +6 -4
- package/lib/argv.js.map +1 -1
- package/lib/assets.d.ts.map +1 -1
- package/lib/assets.js +2 -1
- package/lib/assets.js.map +1 -1
- package/lib/cargo.d.ts +20 -0
- package/lib/cargo.d.ts.map +1 -1
- package/lib/cargo.js.map +1 -1
- package/lib/cascade.d.ts +55 -0
- package/lib/cascade.d.ts.map +1 -0
- package/lib/cascade.js +131 -0
- package/lib/cascade.js.map +1 -0
- package/lib/catalog.d.ts +0 -75
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +44 -127
- package/lib/catalog.js.map +1 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +152 -85
- package/lib/commands.js.map +1 -1
- package/lib/companion.d.ts.map +1 -1
- package/lib/companion.js +5 -5
- package/lib/companion.js.map +1 -1
- package/lib/config.d.ts +2 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +7 -5
- package/lib/config.js.map +1 -1
- package/lib/enroll.d.ts +56 -0
- package/lib/enroll.d.ts.map +1 -0
- package/lib/enroll.js +123 -0
- package/lib/enroll.js.map +1 -0
- package/lib/freshness.d.ts.map +1 -1
- package/lib/freshness.js +31 -17
- package/lib/freshness.js.map +1 -1
- package/lib/hooks-map.d.ts +30 -0
- package/lib/hooks-map.d.ts.map +1 -1
- package/lib/hooks-map.js +80 -18
- package/lib/hooks-map.js.map +1 -1
- package/lib/integrity.d.ts +1 -2
- package/lib/integrity.d.ts.map +1 -1
- package/lib/integrity.js +0 -1
- package/lib/integrity.js.map +1 -1
- package/lib/observations.d.ts.map +1 -1
- package/lib/observations.js +25 -12
- package/lib/observations.js.map +1 -1
- package/lib/order.d.ts +10 -0
- package/lib/order.d.ts.map +1 -0
- package/lib/order.js +14 -0
- package/lib/order.js.map +1 -0
- package/lib/picker.d.ts.map +1 -1
- package/lib/picker.js +8 -2
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +1 -1
- package/lib/plan.js.map +1 -1
- package/lib/proposals.d.ts.map +1 -1
- package/lib/proposals.js +25 -8
- package/lib/proposals.js.map +1 -1
- package/lib/sections.js +1 -1
- package/lib/sections.js.map +1 -1
- package/lib/ship.d.ts.map +1 -1
- package/lib/ship.js +9 -1
- package/lib/ship.js.map +1 -1
- package/lib/shipment.d.ts.map +1 -1
- package/lib/shipment.js +14 -10
- package/lib/shipment.js.map +1 -1
- package/lib/snapshot.d.ts.map +1 -1
- package/lib/snapshot.js +2 -1
- package/lib/snapshot.js.map +1 -1
- package/lib/stamp.js +1 -1
- package/lib/stamp.js.map +1 -1
- package/lib/sync.d.ts +12 -2
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +11 -10
- package/lib/sync.js.map +1 -1
- package/lib/vars.d.ts.map +1 -1
- package/lib/vars.js +2 -3
- package/lib/vars.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.9.0.tgz +0 -0
- package/rt-tools-agent-kit-0.8.3.tgz +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Адресат запроса для гарда хранилища: боевая база, одноразовая база проверки или что-то
|
|
3
|
+
# третье — и что на боевой разрешено.
|
|
4
|
+
#
|
|
5
|
+
# Строки `# rt-hook:` здесь нет намеренно: событие и образец вызова объявляет сам гард, а
|
|
6
|
+
# помощник рядом хуком не регистрируется и в одиночку ничего не решает.
|
|
7
|
+
|
|
8
|
+
# --- прод: запись запрещена в любом виде ------------------------------------------------
|
|
9
|
+
# Признак берётся из профиля: порт туннеля, хост, домен — у каждого дерева свои.
|
|
10
|
+
#
|
|
11
|
+
# Ищем его в АДРЕСЕ, а не в данных: домен приложения живёт и в самих строках — в почте
|
|
12
|
+
# владельца, в канонической ссылке объекта. Пока признак брался по всей команде, вставка
|
|
13
|
+
# строки с таким адресом в локальную базу отклонялась как запись в бой, а опт-аута у этой
|
|
14
|
+
# ветки нет по замыслу — команда становилась неисполнимой.
|
|
15
|
+
# Поэтому текст запроса (то, что стоит после `-c`/`--command`) из проверки вырезается.
|
|
16
|
+
#
|
|
17
|
+
# Запрос из редактора адресата в тексте не называет: база выбирается идентификатором
|
|
18
|
+
# подключения, и по одному SQL отличить прод от локальной копии невозможно. Поэтому
|
|
19
|
+
# подключения опознаются в лицо. Список сверяется вызовом list_database_connections;
|
|
20
|
+
# добавили новое — допишите сюда, иначе оно попадёт в «неизвестные» ниже.
|
|
21
|
+
sql_resolve_target() {
|
|
22
|
+
is_prod=""
|
|
23
|
+
PROD_CONNECTIONS="${RT_PROD_CONNECTIONS:-}"
|
|
24
|
+
LOCAL_CONNECTIONS="${RT_LOCAL_CONNECTIONS:-}"
|
|
25
|
+
|
|
26
|
+
conn_known=""
|
|
27
|
+
if [ -n "$conn" ]; then
|
|
28
|
+
case " $PROD_CONNECTIONS " in *" $conn "*) is_prod="yes"; conn_known="prod" ;; esac
|
|
29
|
+
# Опознанное локальное подключение сильнее текстовой догадки: домен приложения живёт в
|
|
30
|
+
# самих данных — в почте владельца, в канонической ссылке объекта. Пока вывод не
|
|
31
|
+
# отменялся, обновление такой строки на локальной базе отклонялось как запись в бой, и
|
|
32
|
+
# обойти это было нечем.
|
|
33
|
+
case " $LOCAL_CONNECTIONS " in *" $conn "*) conn_known="local"; is_prod="" ;; esac
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Для запроса из редактора адрес в тексте не назван вовсе: там решает опознанное
|
|
37
|
+
# подключение, а если оно неизвестно — ветка ниже спросит пользователя.
|
|
38
|
+
is_scratch=""
|
|
39
|
+
addr_other=""
|
|
40
|
+
if [ -z "$conn" ] && [ "$context" != "запрос через подключение редактора (обёртка исполнителя)" ]; then
|
|
41
|
+
while IFS= read -r seg; do
|
|
42
|
+
[ -z "$seg" ] && continue
|
|
43
|
+
if [ -n "$PROD_DSN" ] && printf '%s' "$seg" | grep -qE "$PROD_DSN"; then
|
|
44
|
+
is_prod="yes"
|
|
45
|
+
addr_other="yes"
|
|
46
|
+
continue
|
|
47
|
+
fi
|
|
48
|
+
# Одноразовая база проверки: петлевой адрес и порт из отведённого под них диапазона.
|
|
49
|
+
# Диапазон занимают контейнеры, которые поднимают на время одной проверки —
|
|
50
|
+
# восстановление копии, репетиция миграции на непустой базе — и сносят следом. Данных,
|
|
51
|
+
# которые стоило бы стеречь, там нет по построению, а вопрос на каждую строку такой
|
|
52
|
+
# проверки приучает отвечать «да» не читая и обесценивает тот вопрос, который был важен.
|
|
53
|
+
#
|
|
54
|
+
# Диапазон узкий и петлевой намеренно: под него не должны попадать ни рабочая база
|
|
55
|
+
# дерева, ни туннель к бою, ни базы соседних деревьев на этой машине. Какой он здесь,
|
|
56
|
+
# знает профиль; не назван — исключения нет вовсе, и вопрос задаётся всегда.
|
|
57
|
+
if [ -n "${RT_SCRATCH_PORT_RE:-}" ] \
|
|
58
|
+
&& printf '%s' "$seg" | grep -qE "$RT_SCRATCH_PORT_RE" \
|
|
59
|
+
&& printf '%s' "$seg" | grep -qE '127\.0\.0\.1|localhost|host\.docker\.internal'; then
|
|
60
|
+
is_scratch="yes"
|
|
61
|
+
else
|
|
62
|
+
# Любой другой адресованный вызов снимает исключение целиком: в цепочке
|
|
63
|
+
# `psql -p 19434 -f x.sql && psql -c "delete …"` ранний выход убрал бы проверку
|
|
64
|
+
# со второго звена, а это ровно тот обход, ради которого гард и написан.
|
|
65
|
+
addr_other="yes"
|
|
66
|
+
fi
|
|
67
|
+
done <<EOF
|
|
68
|
+
$(addr_segments)
|
|
69
|
+
EOF
|
|
70
|
+
fi
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Исключение стоит ПОСЛЕ разбора адреса и ПЕРЕД правилами записи, но строго после того, как
|
|
74
|
+
# признак боевой базы уже выставлен: прод перекрывает исключение при любом совпадении, а не
|
|
75
|
+
# наоборот. Условие тройное — одноразовый адрес найден, боевого нет, и других адресованных
|
|
76
|
+
# вызовов в команде нет вовсе.
|
|
77
|
+
sql_pass_scratch() {
|
|
78
|
+
if [ -n "$is_scratch" ] && [ -z "$is_prod" ] && [ -z "$addr_other" ]; then
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# На боевой базе разрешено только то, что гард опознал как чтение — БЕЛЫМ списком, а не
|
|
84
|
+
# перечислением запретов. Чёрный список здесь принципиально не работает: SQL доезжает до
|
|
85
|
+
# сервера файлом, редиректом, `\copy`, `SELECT … INTO`, и каждая заделанная форма оставляет
|
|
86
|
+
# соседнюю. Поэтому вопрос перевёрнут: не «есть ли здесь запись», а «доказано ли чтение».
|
|
87
|
+
#
|
|
88
|
+
# Чтением считаются три формы, и каждая проверяется в СЕГМЕНТЕ своего вызова:
|
|
89
|
+
# pg_dump без --clean/--create — снятие дампа
|
|
90
|
+
# psql -c "<один SELECT>" — запрос без второго стейтмента
|
|
91
|
+
# psql -l / --version / --help — проверка соединения без запроса
|
|
92
|
+
sql_check_prod() {
|
|
93
|
+
if [ -n "$is_prod" ] && [ -n "$is_write" ]; then
|
|
94
|
+
deny "BLOCKED: запись в БОЕВОЕ хранилище (${context}). Адрес ведёт на бой — оттуда данные не восстанавливаются ничем, кроме копии. Схема на бою меняется миграцией через выкатку, данные — через панель владельца. Если правка данных на бою действительно нужна, её делает владелец руками, предварительно сняв копию; гард обойти нельзя."
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
if [ -n "$is_prod" ]; then
|
|
98
|
+
unproven=""
|
|
99
|
+
while IFS= read -r seg; do
|
|
100
|
+
[ -z "$seg" ] && continue
|
|
101
|
+
seg_read=""
|
|
102
|
+
# Аргументы клиента — всё, что стоит ПОСЛЕ его имени. До имени в том же сегменте
|
|
103
|
+
# свободно живут чужие флаги: `docker compose -f docker-compose.prod.yml … psql`,
|
|
104
|
+
# и `-f` от compose однажды отменял доказательство чтения.
|
|
105
|
+
seg_tail="$(printf '%s' "$seg" | perl -0pe 's{^.*?(?<![[:alnum:]_./-])(psql|pg_restore|prisma)(?=\s|$)}{$1}s' 2>/dev/null)"
|
|
106
|
+
[ -z "$seg_tail" ] && seg_tail="$seg"
|
|
107
|
+
|
|
108
|
+
if printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])pg_dump(all)?([^[:alnum:]_.-]|$)'; then
|
|
109
|
+
case "$seg" in
|
|
110
|
+
*--clean*|*--create*) ;;
|
|
111
|
+
*) seg_read="yes" ;;
|
|
112
|
+
esac
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# Один SELECT и ничего кроме: второй стейтмент через `;` уже не чтение.
|
|
116
|
+
if printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_])select([^[:alnum:]_]|$)' \
|
|
117
|
+
&& ! printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_])(delete|update|insert|truncate|drop|alter|grant|revoke|copy)([^[:alnum:]_]|$)|\\copy|into[[:space:]]+[a-z_"]' \
|
|
118
|
+
&& ! printf '%s' "$seg_tail" | grep -qE '(^|[[:space:]])(-f|--file)([[:space:]]|=)'; then
|
|
119
|
+
seg_read="yes"
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
printf '%s' "$seg_tail" | grep -qE '(^|[[:space:]])(-l|--list|--version|--help)([[:space:]]|$)' \
|
|
123
|
+
&& seg_read="yes"
|
|
124
|
+
|
|
125
|
+
[ -z "$seg_read" ] && unproven="yes"
|
|
126
|
+
done <<EOF
|
|
127
|
+
$segments
|
|
128
|
+
EOF
|
|
129
|
+
|
|
130
|
+
if [ -n "$unproven" ]; then
|
|
131
|
+
deny "BLOCKED: вызов клиента БД против БОЕВОЙ базы (${context}). На проде разрешено только доказанное чтение — \`pg_dump\` без \`--clean\`, одиночный SELECT через \`-c\`, либо \`-l\`/\`--version\`. Всё остальное отклоняется, даже если гард просто не разобрал команду: содержимое файлов (\`-f\`, \`< dump.sql\`), \`\\copy\` и восстановление дампа ему не видны. Если нужен разбор данных боевой базы — снимай дамп и работай с локальной копией."
|
|
132
|
+
fi
|
|
133
|
+
fi
|
|
134
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Запись для гарда хранилища: что считается записью, что из неё заведомо разрушительно, куда
|
|
3
|
+
# идут миграции и по чему запрос адресует строки.
|
|
4
|
+
#
|
|
5
|
+
# Строки `# rt-hook:` здесь нет намеренно: событие и образец вызова объявляет сам гард, а
|
|
6
|
+
# помощник рядом хуком не регистрируется и в одиночку ничего не решает.
|
|
7
|
+
|
|
8
|
+
# Глаголы ищутся в сегментах ВЫЗОВА, а не по всей строке: иначе слово из шаблона поиска в
|
|
9
|
+
# соседнем звене цепочки объявляло записью читающую команду.
|
|
10
|
+
sql_detect_write() {
|
|
11
|
+
is_write=""
|
|
12
|
+
write_scope="$segments"
|
|
13
|
+
# `copy … from` и `select … into` — запись, не называющая ни одного привычного глагола.
|
|
14
|
+
printf '%s' "$write_scope" | grep -qE '(^|[^[:alnum:]_])(delete|update|insert|truncate|drop|alter|create|grant|revoke|copy)([^[:alnum:]_]|$)|\\copy|into[[:space:]]+[a-z_"]' \
|
|
15
|
+
&& is_write="yes"
|
|
16
|
+
# Конвейер из источника в клиент — та же доставка файла, только без флага: содержимое
|
|
17
|
+
# `cat fix.sql | psql …` гарду не видно, значит это запись по определению.
|
|
18
|
+
while IFS= read -r seg; do
|
|
19
|
+
[ -z "$seg" ] && continue
|
|
20
|
+
printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])(cat|head|tail|gzcat|zcat|gunzip|echo|printf|curl|wget)([[:space:]]|$)' \
|
|
21
|
+
&& is_write="yes" && break
|
|
22
|
+
done <<PIPE_EOF
|
|
23
|
+
$(client_segments)
|
|
24
|
+
PIPE_EOF
|
|
25
|
+
|
|
26
|
+
# Команды prisma меняют базу, не называя ни одного SQL-глагола: `migrate reset` пересоздаёт
|
|
27
|
+
# её целиком, `db push` подгоняет схему под модель, `db execute` льёт произвольный файл.
|
|
28
|
+
printf '%s' "$write_scope" | grep -qE 'prisma[[:space:]]+(migrate[[:space:]]+(reset|deploy|dev)|db[[:space:]]+(push|execute))' \
|
|
29
|
+
&& is_write="yes"
|
|
30
|
+
|
|
31
|
+
# Глагол в командной строке — не единственный способ довезти SQL до сервера. Файл (`-f`,
|
|
32
|
+
# `--file`, `< dump.sql`) и `pg_restore` не называют ни одного, поэтому раньше проходили
|
|
33
|
+
# мимо всех трёх уровней: доставка файла в клиент на боевой базе завершалась нулём
|
|
34
|
+
# без единого вопроса, а `pg_restore` не мог сработать в принципе, хотя `--clean` сносит
|
|
35
|
+
# содержимое. Содержимое файла гарду недоступно — значит это запись по определению.
|
|
36
|
+
printf '%s' "$flat" | grep -qE '(^|[^[:alnum:]_.-])pg_restore([^[:alnum:]_.-]|$)' \
|
|
37
|
+
&& is_write="yes"
|
|
38
|
+
# У `pg_dump` тот же `-f` означает файл ВЫВОДА: это чтение, и записью его считать нельзя —
|
|
39
|
+
# иначе штатное снятие дампа с боевой базы отклонялось, хотя текст отказа сам его советует.
|
|
40
|
+
#
|
|
41
|
+
# Исключение действует ПОСЕГМЕНТНО. Пока оно проверялось по всей команде, одного упоминания
|
|
42
|
+
# `pg_dump` где угодно в цепочке хватало, чтобы `-f` перестал считаться записью во всех
|
|
43
|
+
# остальных вызовах: `pg_dump … > /dev/null && psql -d app -f /tmp/x.sql` проходил молча.
|
|
44
|
+
while IFS= read -r seg; do
|
|
45
|
+
[ -z "$seg" ] && continue
|
|
46
|
+
# Граница слова обязательна: `-f /tmp/pg_dump-restore.sql` — это заливка дампа, а не
|
|
47
|
+
# его снятие, и подстрочное совпадение снимало с неё обе защиты разом.
|
|
48
|
+
if printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])pg_dump(all)?([^[:alnum:]_.-]|$)'; then
|
|
49
|
+
continue
|
|
50
|
+
fi
|
|
51
|
+
# Хвост сегмента после имени клиента: `-f` у `docker compose` (боевой compose-файл
|
|
52
|
+
# называется нестандартно, без флага не поднимается) стоит ДО `psql` и к запросу
|
|
53
|
+
# отношения не имеет.
|
|
54
|
+
seg_tail="$(printf '%s' "$seg" | perl -0pe 's{^.*?(?<![[:alnum:]_./-])(psql|pg_restore|prisma)(?=\s|$)}{$1}s' 2>/dev/null)"
|
|
55
|
+
[ -z "$seg_tail" ] && seg_tail="$seg"
|
|
56
|
+
if printf '%s' "$seg_tail" | grep -qE '(^|[[:space:]])(-f|--file)([[:space:]]|=)|<[[:space:]]*[^[:space:]|<]+\.(sql|dump)'; then
|
|
57
|
+
is_write="yes"
|
|
58
|
+
break
|
|
59
|
+
fi
|
|
60
|
+
done <<EOF
|
|
61
|
+
$segments
|
|
62
|
+
EOF
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# --- заведомо разрушительное ------------------------------------------------------------
|
|
66
|
+
verdict() {
|
|
67
|
+
if [ -n "$soft" ]; then
|
|
68
|
+
ask "Запрос помечен маркером destructive-ok, но остаётся разрушительным: $1 Подтверди выполнение, если это осознанно."
|
|
69
|
+
fi
|
|
70
|
+
deny "BLOCKED: $1 Адресуй строки по первичному ключу — \`WHERE id IN ('…','…')\`: так затрагивается ровно столько строк, сколько перечислено, и промах виден до выполнения. Удаление по маске (email LIKE '%test%') однажды унесло вместе с тестовыми записями демонстрационные брони владельца. Если адресация по id действительно не подходит — сначала выполни SELECT с тем же условием, покажи пользователю, что попадает под удаление, и помечай запрос маркером destructive-ok с объяснением."
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
sql_check_destructive() {
|
|
74
|
+
soft=""
|
|
75
|
+
case "$flat" in
|
|
76
|
+
*destructive-ok*) soft="yes" ;;
|
|
77
|
+
esac
|
|
78
|
+
|
|
79
|
+
if printf '%s' "$flat" | grep -qE '(^|[^[:alnum:]_])(truncate|drop[[:space:]]+(table|database|schema|column|index)|alter[[:space:]]+table)([^[:alnum:]_]|$)'; then
|
|
80
|
+
verdict "запрос меняет саму схему или очищает таблицу целиком (${context})."
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
if printf '%s' "$flat" | grep -qE 'prisma[[:space:]]+(migrate[[:space:]]+reset|db[[:space:]]+push)'; then
|
|
84
|
+
verdict "\`prisma migrate reset\` / \`db push\` пересоздаёт базу и теряет её содержимое (${context})."
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# `pg_restore --clean` перед загрузкой удаляет существующие объекты — то же очищение
|
|
88
|
+
# таблиц, только чужими руками. Без `--clean` это обычная догрузка, она идёт общим путём.
|
|
89
|
+
if printf '%s' "$flat" | grep -q 'pg_restore' && printf '%s' "$flat" | grep -qE '(^|[[:space:]])(--clean|-c|--create)([[:space:]]|=|$)'; then
|
|
90
|
+
verdict "\`pg_restore --clean\` удаляет объекты базы перед загрузкой дампа (${context})."
|
|
91
|
+
fi
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Штатное применение миграций разрушительным не считается. Раньше здесь стоял безусловный
|
|
95
|
+
# `ask` со словами «убедись, что DATABASE_URL указывает на локальную базу» — гард
|
|
96
|
+
# перекладывал на человека ровно ту работу, которую умеет сделать сам. На локальной базе
|
|
97
|
+
# миграции гоняются постоянно, и вопрос на каждую не добавляет безопасности: гард, который
|
|
98
|
+
# спрашивает по десятому разу, перестают читать вместе с тем единственным вопросом, который
|
|
99
|
+
# был важен.
|
|
100
|
+
#
|
|
101
|
+
# Поэтому адрес РАЗРЕШАЕТСЯ, а не угадывается: сначала inline-префикс самой команды, затем
|
|
102
|
+
# переменная окружения, затем `.env` рабочего каталога.
|
|
103
|
+
unquote() {
|
|
104
|
+
sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
resolve_database_url() {
|
|
108
|
+
# Искать по `$flat` нельзя: он приведён к нижнему регистру, и `DATABASE_URL=` в нём
|
|
109
|
+
# не встречается никогда — правило молча брало бы адрес из `.env`, игнорируя явно
|
|
110
|
+
# указанный в команде. Разбирается исходная строка, а `$flat` остаётся запасным
|
|
111
|
+
# вариантом с регистронезависимым поиском.
|
|
112
|
+
inline="$(printf '%s' "${cmd:-}" | grep -oE '[Dd][Aa][Tt][Aa][Bb][Aa][Ss][Ee]_[Uu][Rr][Ll]=[^[:space:]]+' | tail -n1)"
|
|
113
|
+
[ -z "$inline" ] && inline="$(printf '%s' "$flat" | grep -oiE 'database_url=[^[:space:]]+' | tail -n1)"
|
|
114
|
+
if [ -n "$inline" ]; then
|
|
115
|
+
printf '%s' "${inline#*=}" | unquote
|
|
116
|
+
return
|
|
117
|
+
fi
|
|
118
|
+
if [ -n "${DATABASE_URL:-}" ]; then
|
|
119
|
+
printf '%s' "$DATABASE_URL" | unquote
|
|
120
|
+
return
|
|
121
|
+
fi
|
|
122
|
+
# Подъём по дереву, а не только рабочий каталог: агент работает в git worktree под
|
|
123
|
+
# `.claude/worktrees/<ветка>/`, где своего `.env` нет, а у основного чекаута — есть,
|
|
124
|
+
# и он лежит ровно выше по пути. Без подъёма гард не разрешил бы адрес ни разу
|
|
125
|
+
# именно там, где миграции и гоняются.
|
|
126
|
+
dir="$hook_cwd"
|
|
127
|
+
depth=0
|
|
128
|
+
while [ -n "$dir" ] && [ "$dir" != '/' ] && [ "$depth" -lt 8 ]; do
|
|
129
|
+
for env_file in "$dir/.env" "$dir/.env.local"; do
|
|
130
|
+
[ -f "$env_file" ] || continue
|
|
131
|
+
value="$(grep -m1 -E '^[[:space:]]*DATABASE_URL=' "$env_file" 2>/dev/null | sed -e 's/^[[:space:]]*DATABASE_URL=//' | unquote)"
|
|
132
|
+
if [ -n "$value" ]; then
|
|
133
|
+
printf '%s' "$value"
|
|
134
|
+
return
|
|
135
|
+
fi
|
|
136
|
+
done
|
|
137
|
+
dir="$(dirname "$dir")"
|
|
138
|
+
depth=$((depth + 1))
|
|
139
|
+
done
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
sql_check_migrations() {
|
|
143
|
+
if printf '%s' "$flat" | grep -qE 'prisma[[:space:]]+migrate[[:space:]]+(deploy|dev)'; then
|
|
144
|
+
migrate_target="$(resolve_database_url)"
|
|
145
|
+
# Боевой адрес проверяется ДО разбора по видам: туннель к бою тоже висит на петлевом
|
|
146
|
+
# адресе, только на своём порту, и общее правило «петлевой значит локальный» пропустило бы
|
|
147
|
+
# миграцию на бой.
|
|
148
|
+
if [ -n "$PROD_DSN" ] && printf '%s' "$migrate_target" | grep -qE "$PROD_DSN"; then
|
|
149
|
+
deny "BLOCKED: применение миграций к БОЕВОЙ базе (${context}). Адрес базы ведёт на бой. Схема на бою меняется выкаткой: она сама зовёт применение миграций одноразовым контейнером до старта приложения. Руками этого делать нельзя — гард обойти нельзя."
|
|
150
|
+
fi
|
|
151
|
+
case "$migrate_target" in
|
|
152
|
+
*@localhost:*|*@127.0.0.1:*|*@postgres:*|*@host.docker.internal:*)
|
|
153
|
+
# Локальная база — штатная работа. Разбор завершается здесь, иначе ниже
|
|
154
|
+
# сработает общее правило про запись в базу и вопрос всё равно будет задан:
|
|
155
|
+
# `migrate deploy` помечен записью выше по тексту.
|
|
156
|
+
#
|
|
157
|
+
# Но выйти можно, только если запись в команде ОДНА — сама миграция. В цепочке
|
|
158
|
+
# `prisma migrate deploy && psql -c "delete …"` ранний выход снял бы проверку со
|
|
159
|
+
# второго звена, а это ровно тот обход, ради которого гард и написан.
|
|
160
|
+
other_write=""
|
|
161
|
+
printf '%s' "$write_scope" \
|
|
162
|
+
| grep -qE '(^|[^[:alnum:]_])(delete|update|insert|truncate|drop|alter|create|grant|revoke|copy)([^[:alnum:]_]|$)|\\copy|into[[:space:]]+[a-z_"]' \
|
|
163
|
+
&& other_write="yes"
|
|
164
|
+
while IFS= read -r seg; do
|
|
165
|
+
[ -z "$seg" ] && continue
|
|
166
|
+
printf '%s' "$seg" | grep -qE '(^|[^[:alnum:]_.-])(cat|head|tail|gzcat|zcat|gunzip|echo|printf|curl|wget)([[:space:]]|$)' \
|
|
167
|
+
&& other_write="yes" && break
|
|
168
|
+
done <<MIGRATE_PIPE_EOF
|
|
169
|
+
$(client_segments)
|
|
170
|
+
MIGRATE_PIPE_EOF
|
|
171
|
+
[ -z "$other_write" ] && exit 0
|
|
172
|
+
;;
|
|
173
|
+
'')
|
|
174
|
+
ask "Применение миграций к базе (${context}), но адрес разрешить не удалось: DATABASE_URL нет ни в команде, ни в окружении, ни в \`.env\` рабочего каталога. Проверь, куда пойдёт миграция, и подтверди."
|
|
175
|
+
;;
|
|
176
|
+
*)
|
|
177
|
+
ask "Применение миграций по адресу, НЕИЗВЕСТНОМУ ГАРДУ (${context}). Гард знает локальные адреса и боевые; этот — ни то, ни другое. Убедись, что это не прод, и подтверди."
|
|
178
|
+
;;
|
|
179
|
+
esac
|
|
180
|
+
fi
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# --- delete/update: смотрим на адресацию ------------------------------------------------
|
|
184
|
+
sql_check_addressing() {
|
|
185
|
+
if printf '%s' "$flat" | grep -qE '(^|[^[:alnum:]_])(delete[[:space:]]+from|update)([^[:alnum:]_]|$)'; then
|
|
186
|
+
if ! printf '%s' "$flat" | grep -q 'where'; then
|
|
187
|
+
verdict "DELETE/UPDATE без WHERE затрагивает всю таблицу (${context})."
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# Адресация ищется ТОЛЬКО в хвосте после последнего `where`. Пока смотрели на весь
|
|
191
|
+
# запрос, присваивание в SET засчитывалось за адресацию: `UPDATE bookings SET
|
|
192
|
+
# "propertyId" = 'p1' WHERE source = 'site'` выглядел адресным, хотя задевал все прямые
|
|
193
|
+
# заявки объекта — ровно то, от чего гард и защищает.
|
|
194
|
+
# Регистр здесь сохраняется, в отличие от `flat`: колонки Prisma пишутся в camelCase, и
|
|
195
|
+
# приведённый к нижнему регистру `bookingid` уже не отличить от слова `paid`.
|
|
196
|
+
where_tail="$(printf '%s' "$sql" | tr '\n\t' ' ' | sed -E 's/.*[Ww][Hh][Ee][Rr][Ee]/where/')"
|
|
197
|
+
|
|
198
|
+
# Имя колонки требуется целиком: `id`, `booking_id`, `"bookingId"`. Прежний шаблон
|
|
199
|
+
# `[a-z_]*id` принимал за идентификатор `paid` и `valid`.
|
|
200
|
+
if ! printf '%s' "$where_tail" | grep -qE '(^|[^[:alnum:]_"])"?(id|[A-Za-z_]+_id|[a-zA-Z]+Id)"?[[:space:]]*(=|[Ii][Nn][[:space:]]*\()'; then
|
|
201
|
+
verdict "DELETE/UPDATE адресует строки не по идентификатору (${context}) — условие может совпасть шире, чем задумано."
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# Гард видит имя колонки, но не схему: `propertyId` и `session_id` — внешние ключи, и
|
|
205
|
+
# запрос по ним адресный только на вид. `DELETE … WHERE "propertyId" = 'p1'` сносит все
|
|
206
|
+
# брони объекта. Отличить это от первичного ключа без схемы нельзя, поэтому решение
|
|
207
|
+
# остаётся за пользователем — но предупреждение должно быть прямым, а не общим.
|
|
208
|
+
if ! printf '%s' "$where_tail" | grep -qE '(^|[^[:alnum:]_"])"?id"?[[:space:]]*(=|[Ii][Nn][[:space:]]*\()'; then
|
|
209
|
+
ask "Условие адресует строки по ВНЕШНЕМУ ключу, а не по первичному (${context}): $(printf '%s' "$where_tail" | head -c 200). Под него попадут ВСЕ строки, связанные с этой сущностью, — например \`WHERE \"propertyId\" = …\` заденет все брони объекта, включая демонстрационные. Если нужны конкретные строки, сперва выбери их SELECT-ом и перечисли в \`WHERE id IN (…)\`."
|
|
210
|
+
fi
|
|
211
|
+
fi
|
|
212
|
+
}
|