Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb32aa9907 | |||
| 88ae0ac348 | |||
| 618519c7e8 |
@@ -31,9 +31,14 @@ paths:
|
||||
keyset (cursor) — O(1) глубины; offset-based — backward-совместимость.
|
||||
При count_only=true возвращает только {"total": N} без строк.
|
||||
parameters:
|
||||
- name: status_in[]
|
||||
- name: status_in
|
||||
in: query
|
||||
description: Фильтр по статусам (можно несколько)
|
||||
description: >
|
||||
Фильтр по статусам (можно несколько). На проводе сериализуется
|
||||
Laravel array-binding: status_in[]=NEW&status_in[]=WON. Имя параметра
|
||||
в спецификации — без скобок: ключи свойств MCP-инструмента обязаны
|
||||
матчить ^[a-zA-Z0-9_.-]{1,64}$ (скобки запрещены, иначе Anthropic
|
||||
tools-схема падает с 400).
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Router-gate dev/prod re-scope — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Разрешить локальную разработку (composer/npm/git/worktree) через контроллера, сохранив блок боевого/опасного и дисциплины.
|
||||
|
||||
**Architecture:** Точечно расширить whitelist Bash-гейта (`enforce-router-gate.mjs`) дев-инструментами + разрешить dev-safe git в общем `shell-content-rules.mjs` (`classifyGitCommand`) с «стражем main» для push. Философия default-deny сохраняется; hard-blacklist опасного и дисциплинарные хуки не трогаются.
|
||||
|
||||
**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`, root `app`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md`
|
||||
|
||||
**Verify-команда (вся регрессия tools):**
|
||||
`npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Узкий прогон файла: добавить хвост `<имя>.test` (например `enforce-router-gate.test`).
|
||||
|
||||
**Bootstrap-нюанс (важно):** до того как Task 3 (git dev-allow) применится, `git commit` ещё
|
||||
заблокирован самим гейтом. Поэтому коммиты НЕ делаем по ходу — все правки складываем в рабочее
|
||||
дерево, гоняем тесты, и **один раз** коммитим в конце (Task 5), когда git уже разрешён. Реализация —
|
||||
в основной копии (worktree пока недоступен; это и есть bootstrap-исключение из спеки).
|
||||
|
||||
---
|
||||
|
||||
## Задачи
|
||||
|
||||
### Task 1: Разрешить `composer` (install/update/require/remove/dump-autoload)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 59; SAFE_EXACT ~line 124)
|
||||
- Test: `tools/enforce-router-gate.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить в конец `enforce-router-gate.test.mjs`:
|
||||
|
||||
```js
|
||||
import { matchBashHardBlacklist as mhb2, classifyBashCommand as cbc2 } from './enforce-router-gate.mjs';
|
||||
|
||||
describe('composer dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
it('allows composer install', () => {
|
||||
expect(mhb2('composer install')).toBe(null);
|
||||
expect(cbc2('composer install', {}).result).toBe('allow');
|
||||
});
|
||||
it('allows composer require / update / dump-autoload', () => {
|
||||
expect(cbc2('composer require monolog/monolog', {}).result).toBe('allow');
|
||||
expect(cbc2('composer update', {}).result).toBe('allow');
|
||||
expect(cbc2('composer dump-autoload', {}).result).toBe('allow');
|
||||
});
|
||||
it('still allows composer install with -d working-dir', () => {
|
||||
expect(cbc2('composer install -d app --no-interaction', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: FAIL (composer install currently hard-blacklisted → matchBashHardBlacklist truthy, classify 'block').
|
||||
|
||||
- [ ] **Step 3: Remove composer from hard-blacklist** — в `tools/enforce-router-gate.mjs` удалить строку:
|
||||
|
||||
```js
|
||||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add composer to whitelist** — в массив `SAFE_EXACT`, рядом с существующей `/^composer\s+(?:show|outdated)\b/`, добавить:
|
||||
|
||||
```js
|
||||
/^composer\s+(?:install|update|require|remove|dump-autoload|dump)\b/, // dev-allow 2026-06-02
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: PASS (включая новый describe).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Разрешить `npm` (install/ci/run-скрипты)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 60; SAFE_EXACT ~line 122)
|
||||
- Test: `tools/enforce-router-gate.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить describe:
|
||||
|
||||
```js
|
||||
describe('npm dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
it('allows npm install / i / ci', () => {
|
||||
expect(mhb2('npm install')).toBe(null);
|
||||
expect(cbc2('npm install', {}).result).toBe('allow');
|
||||
expect(cbc2('npm ci', {}).result).toBe('allow');
|
||||
});
|
||||
it('allows npm run <script>', () => {
|
||||
expect(cbc2('npm run build', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: FAIL (npm install hard-blacklisted).
|
||||
|
||||
- [ ] **Step 3: Remove npm from hard-blacklist** — удалить строку:
|
||||
|
||||
```js
|
||||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add npm to whitelist** — в `SAFE_EXACT`, рядом с существующей `/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/`, добавить:
|
||||
|
||||
```js
|
||||
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02
|
||||
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 (любой script)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Разрешить dev-safe git (commit/add/branch/switch/checkout/stash/worktree)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/shell-content-rules.mjs` (GIT_CONDITIONAL_SUB ~line 167; classifyGitCommand ~line 215)
|
||||
- Test: `tools/shell-content-rules.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить в `shell-content-rules.test.mjs`:
|
||||
|
||||
```js
|
||||
import { classifyGitCommand as cgc2 } from './shell-content-rules.mjs';
|
||||
|
||||
describe('git dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
const noApproval = { approvedGitOps: [], now: 0 };
|
||||
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
|
||||
for (const c of [
|
||||
'git commit -m "x"', 'git add .', 'git branch feature-x',
|
||||
'git switch -c feature-x', 'git checkout -b feature-x',
|
||||
'git stash push -m wip', 'git worktree add ../wt -b feat origin/main',
|
||||
]) {
|
||||
expect(cgc2(c, noApproval).result).toBe('allow');
|
||||
}
|
||||
});
|
||||
it('STILL blocks commit --no-verify and add -f (hard patterns)', () => {
|
||||
expect(cgc2('git commit --no-verify -m x', noApproval).result).toBe('block');
|
||||
expect(cgc2('git add -f ignored.txt', noApproval).result).toBe('block');
|
||||
});
|
||||
it('keeps merge/rebase/reset conditional (needs approval)', () => {
|
||||
expect(cgc2('git reset --hard HEAD~1', noApproval).result).toBe('block');
|
||||
expect(cgc2('git merge feature', noApproval).result).toBe('block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: FAIL (commit/branch/... currently conditional → block без approval; worktree → default-deny).
|
||||
|
||||
- [ ] **Step 3: Add GIT_DEV_SUB + trim GIT_CONDITIONAL_SUB** — в `tools/shell-content-rules.mjs`:
|
||||
|
||||
Заменить блок `GIT_CONDITIONAL_SUB`:
|
||||
|
||||
```js
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
||||
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
|
||||
]);
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```js
|
||||
// dev-safe (owner-authorized 2026-06-02): allow без approval. GIT_HARD_PATTERNS
|
||||
// (--no-verify / add -f / -c / force / --output) пре-фильтруют опасное ВЫШЕ.
|
||||
const GIT_DEV_SUB = new Set([
|
||||
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
|
||||
]);
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Insert dev-allow + push-guard в classifyGitCommand** — после блока `if (sub === 'remote') { … }` (≈line 213) и ПЕРЕД `// 3. conditional → approve check`, вставить:
|
||||
|
||||
```js
|
||||
// dev-safe git (owner-authorized 2026-06-02): hard-patterns уже отсеяли опасное выше.
|
||||
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
|
||||
|
||||
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
|
||||
if (sub === 'push') {
|
||||
if (/\b(?:main|master)\b/.test(norm)) {
|
||||
return { result: 'block', reason: 'git push в main/master — клик владельца' };
|
||||
}
|
||||
return { result: 'allow', reason: 'git push в фичевую ветку' };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: «Страж main» для push — отдельные явные тесты
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tools/shell-content-rules.test.mjs` (логика уже добавлена в Task 3 Step 4 — тут только тесты-замок)
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```js
|
||||
describe('git push main-guard (owner-authorized 2026-06-02)', () => {
|
||||
const na = { approvedGitOps: [], now: 0 };
|
||||
it('allows push to a feature branch', () => {
|
||||
expect(cgc2('git push origin worktree-lead-region-tails', na).result).toBe('allow');
|
||||
expect(cgc2('git push', na).result).toBe('allow');
|
||||
expect(cgc2('git push -u origin feature-x', na).result).toBe('allow');
|
||||
});
|
||||
it('blocks push to main/master', () => {
|
||||
expect(cgc2('git push origin main', na).result).toBe('block');
|
||||
expect(cgc2('git push origin HEAD:main', na).result).toBe('block');
|
||||
expect(cgc2('git push origin master', na).result).toBe('block');
|
||||
});
|
||||
it('blocks force-push (hard pattern, unchanged)', () => {
|
||||
expect(cgc2('git push --force origin feature-x', na).result).toBe('block');
|
||||
expect(cgc2('git push origin feature-x --force-with-lease', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify PASS** (логика из Task 3 уже на месте)
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Полная регрессия + коммит в фичевую ветку + PR
|
||||
|
||||
- [ ] **Step 1: Полная регрессия tools**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Expected: всё GREEN (baseline ~1989 + новые). 0 падений.
|
||||
|
||||
- [ ] **Step 2: Дымовая проверка живьём** — после правок гейт читается заново; проверить, что
|
||||
ранее блокированное теперь проходит (а опасное — нет). Прогнать через Bash:
|
||||
|
||||
```
|
||||
composer --version
|
||||
```
|
||||
|
||||
Expected: проходит (раньше любой `composer install` блокировался; `--version` и так был ок — проверка, что не сломали). Затем убедиться, что `git worktree list` (readonly) и `git status` работают.
|
||||
|
||||
- [ ] **Step 3: Создать фичевую ветку + worktree (теперь разрешено) и закоммитить**
|
||||
|
||||
```bash
|
||||
git worktree add "../worktree-gate-rescope" -b feat/gate-dev-prod-rescope origin/main
|
||||
```
|
||||
|
||||
(или коммит в основной копии на новой ветке — на усмотрение исполнителя; main НЕ трогать)
|
||||
|
||||
```bash
|
||||
git add tools/enforce-router-gate.mjs tools/shell-content-rules.mjs \
|
||||
tools/enforce-router-gate.test.mjs tools/shell-content-rules.test.mjs \
|
||||
docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md \
|
||||
docs/superpowers/plans/2026-06-02-router-gate-dev-prod-rescope.md
|
||||
git commit -m "feat(gate): re-scope router-gate — allow local dev (composer/npm/git/worktree), keep prod+discipline blocks"
|
||||
git push origin feat/gate-dev-prod-rescope
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Открыть PR (клик владельца)** — дать владельцу ссылку из вывода `git push`; слияние в main — его клик.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** composer (Task 1) ✓ / npm (Task 2) ✓ / git dev-subs + worktree (Task 3) ✓ /
|
||||
push main-guard (Task 4) ✓ / discipline+prod untouched (явно не трогаем в Task 1-4) ✓ /
|
||||
«main = owner» (push-guard + PR в Task 5) ✓.
|
||||
- **Placeholders:** нет — весь код приведён дословно.
|
||||
- **Type/имена:** `GIT_DEV_SUB` / `GIT_CONDITIONAL_SUB` согласованы Task 3↔4; `classifyGitCommand`,
|
||||
`matchBashHardBlacklist`, `classifyBashCommand` — реальные экспортируемые имена (проверено по коду).
|
||||
- **Bootstrap:** коммит батчем в Task 5 (git разрешается только после применения Task 3) — учтено.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Router-gate re-scope: «боевое блокируем, локальную разработку разрешаем»
|
||||
|
||||
**Дата:** 2026-06-02
|
||||
**Статус:** design (утверждён владельцем; реализация — отдельным планом)
|
||||
**Автор контекста:** сессия lead-region-tails
|
||||
|
||||
## Проблема
|
||||
|
||||
Router-gate v4 (`tools/enforce-router-gate.mjs`) работает в режиме «по умолчанию запрещено»
|
||||
(whitelist для Bash + hard-blacklist + MCP-классификатор + дисциплинарные хуки). Он задумывался
|
||||
как защита **боевого** контура (выкат на liderra.ru, изменение боевой БД, секреты, запуск
|
||||
воркфлоу), но по факту блокирует и **весь локальный инструмент разработки**: `composer install`,
|
||||
`npm install`, `git worktree`, `git commit`/`push`, и даже правку тест-файлов (через
|
||||
`enforce-tdd-real-test-verifier`). Это делает обычную разработку через контроллера непрактичной —
|
||||
любая PHP/JS-задача с тестами упирается в стену (подтверждено в сессии 2026-06-02: попытка сделать
|
||||
fix реестра Россвязи провалилась на цепочке взаимно-охраняющих замков).
|
||||
|
||||
## Цель
|
||||
|
||||
Перенастроить замок так, чтобы он блокировал **только боевое и опасное**, а **локальную
|
||||
разработку разрешал** — сохранив при этом дисциплину работы контроллера и защиту боевого контура.
|
||||
|
||||
## Решения (утверждены владельцем 2026-06-02)
|
||||
|
||||
1. **Дисциплину оставляем.** Хуки качества (TDD-gate, tdd-real-test-verifier, chain-recommendation,
|
||||
graph-first, override-limit, llm-judge, coverage-verify, memory-coverage и пр.) — **не трогаем**.
|
||||
Контроллер продолжает писать тесты до кода и не срезать углы.
|
||||
2. **Защиту боевого оставляем железно.** Выкат/боевая БД/секреты/запуск воркфлоу/защищённые
|
||||
пути — без изменений.
|
||||
3. **Инструменты разработки разрешаем.** composer/npm/pest/git/worktree.
|
||||
4. **Граница git:** ветки — контроллер сам (commit/push в не-главную ветку + подготовка PR);
|
||||
слияние в main, push в main, force-push, выкат — **клик владельца**.
|
||||
|
||||
## Подход
|
||||
|
||||
**Approach A (выбран):** точечно расширить whitelist дев-инструментами, сохранив философию
|
||||
«по умолчанию запрещено». Правим **два файла** — `tools/enforce-router-gate.mjs` (composer/npm) и
|
||||
`tools/shell-content-rules.mjs` (git; там общий `classifyGitCommand`). MCP-классификатор
|
||||
(`tools/mcp-tool-classifier.mjs`) и дисциплинарные хуки — без изменений.
|
||||
|
||||
Отвергнут **Approach B** (перевернуть в default-allow + blacklist опасного): любой пропуск в
|
||||
перечне опасного = дыра; ломает безопасную философию default-deny.
|
||||
|
||||
## Матрица: что блокируем / что разрешаем
|
||||
|
||||
### Остаётся ЗАБЛОКИРОВАННЫМ
|
||||
|
||||
| Категория | Примеры | Где |
|
||||
|---|---|---|
|
||||
| Боевой контур | выкат на сайт, изменение боевой БД, секреты/`.env`, защищённые пути (CLAUDE.md, memory/, transcripts, `~/.claude/runtime`) | без изменений |
|
||||
| GitHub на запись | `create_*`/`update_*`/`merge_*`/`push_files`/`actions_run_trigger` | MCP-классификатор без изменений (read-only, открытый 2026-06-02, остаётся) |
|
||||
| Опасные команды | `rm`/`mv`/`cp`/`chmod`/`chown`, `curl -X POST/PUT/DELETE`, `wget`, `nc`/`ncat`/`socat`, `node -e` с `fs.*`, `eval`, `bash -c`/`sh -c`, `python -c`, redirects в protected | hard-blacklist без изменений |
|
||||
| Дисциплина | TDD-gate, tdd-real-test-verifier, override-limit, chain-recommendation, graph-first, llm-judge, coverage | хуки без изменений |
|
||||
| Главная ветка | `git push` в main, `git push --force`, слияние в main | новый «страж main» |
|
||||
|
||||
### Становится РАЗРЕШЁННЫМ (локальная разработка)
|
||||
|
||||
| Инструмент | Команды |
|
||||
|---|---|
|
||||
| Composer | `composer install`, `composer dump-autoload`, `composer require`, `composer update` |
|
||||
| NPM | `npm install`, `npm ci`, `npm run <script>` |
|
||||
| Тесты | `pest`, `vendor/bin/pest`, `php artisan test` (уже частично в whitelist) |
|
||||
| Git (ветки) | `git commit`, `git add`, `git branch`, `git switch`/`checkout`, `git worktree`, `git stash`, `git push` **в не-главную ветку** |
|
||||
|
||||
## Изменения в коде (два файла)
|
||||
|
||||
Git-логика живёт не в самом router-gate, а в общем модуле `shell-content-rules.mjs`
|
||||
(`classifyGitCommand`, используется и Bash-, и PowerShell-гейтом). Поэтому правок — два файла.
|
||||
|
||||
### `tools/enforce-router-gate.mjs` (composer / npm)
|
||||
|
||||
1. **Из hard-blacklist (`BASH_HARD_BLACKLIST`) убрать** строки про `composer install/update/require/remove`
|
||||
и `npm install/i/update/remove/uninstall`. `yarn`/`pnpm` остаются заблокированными (проект на npm,
|
||||
не нужны). Истинно-опасные fs/сеть/exec (`rm/mv/cp/chmod`, `curl POST`, `wget`, `nc`, `node -e fs`,
|
||||
`eval`, `bash -c`, `python -c`, redirects) — **без изменений**.
|
||||
2. **В whitelist (`SAFE_EXACT`) добавить:** `composer (install|update|require|remove|dump-autoload|dump)`,
|
||||
`npm (install|i|ci)`, `npm run <script>` (любой скрипт). Существующие `composer show/outdated/test/...`
|
||||
и `npm test/run test/run lint` — остаются.
|
||||
|
||||
### `tools/shell-content-rules.mjs` (git)
|
||||
|
||||
1. **Новый `GIT_DEV_SUB`** = `{add, commit, branch, switch, checkout, stash, worktree}` → в
|
||||
`classifyGitCommand` после hard-pattern-проверки возвращать `allow`. Эти подкоманды **убрать** из
|
||||
`GIT_CONDITIONAL_SUB`. (`worktree` сейчас падает в default-deny — попадёт в dev-allow.)
|
||||
2. **`GIT_HARD_PATTERNS` не трогаем** — `--no-verify`, `git add -f`, `git -c`, force-push, `--output`/`-o`
|
||||
и т.п. по-прежнему блокируются ПЕРВЫМИ, до dev-allow. То есть `git commit --no-verify` и `git add -f`
|
||||
остаются заблокированы даже как «dev».
|
||||
3. **Страж main для `push`** (`mainPushGuard`, чистая функция): `push` остаётся, но —
|
||||
если в аргументах фигурирует `main`/`master` как ref (`git push origin main`, `HEAD:main`, `:main`)
|
||||
→ **block** (клик владельца); force-push уже заблокирован `GIT_HARD_PATTERNS`. Иначе (`git push origin <feature>`,
|
||||
bare `git push`) → allow. Допущение: bare `git push` считаем пушем не-главной ветки (контроллер по модели
|
||||
всегда на не-главной ветке); пуш в main возможен только явным `origin main` → пойман.
|
||||
4. **Conditional остаётся** для `merge, rebase, reset, cherry-pick, revert, pull, clean` (require approval) —
|
||||
риск потери работы / слияние в main = клик владельца.
|
||||
|
||||
**Не меняем:** `tools/mcp-tool-classifier.mjs`, `tools/bash-tokenizer.mjs` (`isMutatingSegment` — чейн-правило
|
||||
C13 «цепочка с мутацией → блок» сохраняется), любые `enforce-*` дисциплинарные хуки, `.claude/settings.json`.
|
||||
|
||||
## Тестирование (TDD)
|
||||
|
||||
Через `tools/enforce-router-gate.test.mjs` (vitest, работает в основной копии):
|
||||
|
||||
- `composer install` / `composer require x` → allow; `composer` (без подкоманды) → как раньше.
|
||||
- `npm install` → allow; `npm run build` → allow.
|
||||
- `git commit -m x` / `git worktree add ...` / `git push origin feature-x` → allow.
|
||||
- `git push origin main` / `git push --force` → **block** (страж main).
|
||||
- Регресс: опасное по-прежнему блокируется — `rm -rf x`, `curl -X POST`, `node -e "...fs..."`,
|
||||
`eval`, `python -c` → block.
|
||||
- Полная регрессия tools-тестов (`npx vitest run --root app --config vitest.config.tools.mjs`).
|
||||
|
||||
## Граница реализации (bootstrap-нюанс)
|
||||
|
||||
Сам этот re-scope — bootstrap-исключение: его нельзя делать в worktree (worktree пока заблокирован).
|
||||
Реализуется в основной копии (там активен живой замок и работает vitest). После правки замка
|
||||
`git`/`worktree`/`composer` становятся разрешены — дальнейшие задачи (например, fix реестра)
|
||||
пойдут уже по модели «ветка + PR».
|
||||
|
||||
## Остаточные риски (приняты)
|
||||
|
||||
- Разрешён `composer require`/`npm install` → теоретический supply-chain (установка пакета).
|
||||
Принято: это собственный проект владельца; дисциплина и code-review остаются.
|
||||
- `rm`/`mv`/`cp` остаются заблокированы — если реально мешают разработке, пересматриваем отдельно
|
||||
(файловые правки покрываются инструментами Write/Edit).
|
||||
- «Страж main» опирается на парсинг аргументов `git push`; экзотические формы (push по URL,
|
||||
refspec-трюки) при сомнении → block (fail-safe в сторону защиты main).
|
||||
|
||||
## Что НЕ входит (YAGNI)
|
||||
|
||||
- Не инвертируем модель замка (default-deny остаётся).
|
||||
- Не трогаем боевые воркфлоу, секреты, MCP-write.
|
||||
- Не ослабляем дисциплину.
|
||||
@@ -72,8 +72,8 @@ describe('classifyPowerShellCommand', () => {
|
||||
it('blocks reading a protected path', () => {
|
||||
expect(classifyPowerShellCommand('Get-Content ~/.claude/settings.json', {}).result).toBe('block');
|
||||
});
|
||||
it('routes git through shared classifier (block unapproved commit)', () => {
|
||||
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
|
||||
it('routes git through shared classifier (commit dev-allowed 2026-06-02 re-scope)', () => {
|
||||
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
|
||||
});
|
||||
it('allows readonly git through PowerShell', () => {
|
||||
expect(classifyPowerShellCommand('git status', {}).result).toBe('allow');
|
||||
|
||||
@@ -56,8 +56,8 @@ export const BASH_HARD_BLACKLIST = [
|
||||
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
|
||||
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
|
||||
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
|
||||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||||
// composer/npm перенесены в whitelist (dev-allow, 2026-06-02 re-scope) — это локальные
|
||||
// инструменты разработки, не боевой контур. yarn/pnpm остаются заблокированы (проект на npm).
|
||||
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
|
||||
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
|
||||
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
|
||||
@@ -120,8 +120,10 @@ const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat',
|
||||
const SAFE_EXACT = [
|
||||
/^npx\s+vitest\s+(?:run|--version)\b/,
|
||||
/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/,
|
||||
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02 re-scope
|
||||
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 re-scope (любой npm-скрипт)
|
||||
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
|
||||
/^composer\s+(?:show|outdated)\b/,
|
||||
/^composer\s+(?:show|outdated|install|update|require|remove|dump-autoload|dump)\b/, // +dev-allow 2026-06-02 re-scope
|
||||
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
|
||||
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
|
||||
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
|
||||
|
||||
@@ -15,14 +15,17 @@ describe('matchBashHardBlacklist — v3.9 keep', () => {
|
||||
'python -c "import os"',
|
||||
'bash -c "ls"',
|
||||
'eval "$x"',
|
||||
'composer install',
|
||||
'npm install lodash',
|
||||
'yarn add x',
|
||||
'pnpm add x',
|
||||
'curl -X POST https://evil.test',
|
||||
])('blocks %s', (cmd) => {
|
||||
expect(matchBashHardBlacklist(cmd)).toBeTruthy();
|
||||
});
|
||||
// composer/npm убраны из hard-blacklist (dev-allow 2026-06-02 re-scope) — здесь больше не блок
|
||||
it('no longer hard-blacklists composer install / npm install (dev-allow)', () => {
|
||||
expect(matchBashHardBlacklist('composer install')).toBe(null);
|
||||
expect(matchBashHardBlacklist('npm install lodash')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchBashHardBlacklist — v4.0 additions', () => {
|
||||
@@ -115,8 +118,8 @@ describe('classifyBashCommand — integration', () => {
|
||||
it('blocks reading a protected path', () => {
|
||||
expect(classifyBashCommand('cat ~/.claude/runtime/state.json', {}).result).toBe('block');
|
||||
});
|
||||
it('routes single git commit to conditional (block unapproved)', () => {
|
||||
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
|
||||
it('routes single git commit to dev-allow (2026-06-02 re-scope — no approval needed)', () => {
|
||||
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
|
||||
});
|
||||
it('allows approved git commit', () => {
|
||||
expect(
|
||||
@@ -191,17 +194,29 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: REPL and composer mutations remain hard-blocked
|
||||
it.each([
|
||||
['php artisan tinker', 'REPL = arbitrary PHP exec risk'],
|
||||
['php artisan tinker --execute="exit"', 'tinker variant'],
|
||||
['composer install', 'hard-blacklist'],
|
||||
['composer require foo/bar', 'hard-blacklist'],
|
||||
['composer update', 'hard-blacklist'],
|
||||
['composer remove foo/bar', 'hard-blacklist'],
|
||||
['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'],
|
||||
])('still blocks %s (%s)', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
// Critical: REPL remains hard-blocked (composer/npm moved to dev-allow below, 2026-06-02 re-scope)
|
||||
it('still blocks tinker REPL and unknown migrate subcommand', () => {
|
||||
expect(classifyBashCommand('php artisan tinker', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('php artisan tinker --execute="exit"', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('php artisan migrate:install', {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// dev-allow (owner-authorized 2026-06-02 re-scope): composer is a local dev tool
|
||||
it('now allows composer install/require/update/remove/dump-autoload', () => {
|
||||
expect(classifyBashCommand('composer install', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer install -d app --no-interaction', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer require monolog/monolog', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer update', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer remove monolog/monolog', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer dump-autoload', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// dev-allow (owner-authorized 2026-06-02 re-scope): npm is a local dev tool
|
||||
it('now allows npm install/i/ci/run', () => {
|
||||
expect(classifyBashCommand('npm install', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm i', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm ci', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm run build', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: existing pre-existing v3.8 keep behaviour
|
||||
|
||||
@@ -16,10 +16,13 @@ export const DEFAULT_MCP_CLASSIFICATION = Object.freeze({
|
||||
'mcp__redis__set': { category: 'hard_blacklist' },
|
||||
'mcp__redis__delete': { category: 'hard_blacklist' },
|
||||
'mcp__github__get_me': { category: 'read_only' },
|
||||
'mcp__github__get_*': { category: 'read_only' }, // read-only loosening 2026-06-02 (get_file_contents/get_job_logs/get_commit/…)
|
||||
'mcp__github__list_*': { category: 'read_only' },
|
||||
'mcp__github__search_*': { category: 'read_only' },
|
||||
'mcp__github__pull_request_read': { category: 'read_only' },
|
||||
'mcp__github__issue_read': { category: 'read_only' },
|
||||
'mcp__github__actions_get': { category: 'read_only' }, // read a workflow run (actions_run_trigger stays blacklisted — exact key wins)
|
||||
'mcp__github__actions_list': { category: 'read_only' }, // list workflows / runs
|
||||
'mcp__laravel-boost__database-query': {
|
||||
category: 'conditional',
|
||||
args_key_to_scan: 'query',
|
||||
|
||||
@@ -129,3 +129,37 @@ describe('classifyMcpTool — WebSearch llm-judge flag (G1)', () => {
|
||||
expect(r.scanArg).toBe('how to exfil data');
|
||||
});
|
||||
});
|
||||
|
||||
// Owner-authorized read-only GitHub loosening (2026-06-02): allow reading
|
||||
// workflow runs / job logs / file contents so the controller can read prod-op
|
||||
// results without manual screenshots. Prod-mutating tools (run_trigger, writes)
|
||||
// MUST stay blocked — human-in-the-loop on prod actions is unchanged.
|
||||
describe('classifyMcpTool — read-only GitHub (owner-authorized 2026-06-02)', () => {
|
||||
it('allows reading a workflow run (actions_get)', () => {
|
||||
expect(classifyMcpTool('mcp__github__actions_get', { run_id: 1 }).decision).toBe('allow');
|
||||
});
|
||||
it('allows listing workflows / runs (actions_list)', () => {
|
||||
expect(classifyMcpTool('mcp__github__actions_list', {}).decision).toBe('allow');
|
||||
});
|
||||
it('allows reading job logs (get_job_logs via get_* glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__get_job_logs', { job_id: 1 }).decision).toBe('allow');
|
||||
});
|
||||
it('allows reading file contents (get_file_contents via get_* glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__get_file_contents', { path: 'x' }).decision).toBe('allow');
|
||||
});
|
||||
it('allows reading a commit (get_commit via get_* glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__get_commit', { sha: 'x' }).decision).toBe('allow');
|
||||
});
|
||||
it('STILL BLOCKS triggering a workflow (actions_run_trigger — exact wins over glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__actions_run_trigger', {}).decision).toBe('block');
|
||||
});
|
||||
it('STILL BLOCKS writing a file (create_or_update_file)', () => {
|
||||
expect(classifyMcpTool('mcp__github__create_or_update_file', { path: 'x' }).decision).toBe('block');
|
||||
});
|
||||
it('STILL BLOCKS push_files', () => {
|
||||
expect(classifyMcpTool('mcp__github__push_files', {}).decision).toBe('block');
|
||||
});
|
||||
it('STILL BLOCKS update_pull_request (write)', () => {
|
||||
expect(classifyMcpTool('mcp__github__update_pull_request', {}).decision).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,9 +164,13 @@ const GIT_READONLY_SUB = new Set([
|
||||
'rev-parse', 'merge-base', 'remote', 'stash', // stash list/show resolved below
|
||||
'fetch', 'ls-remote', // ref-only, no working-tree mutation — Stream H pre-flight requires §15.2 sync
|
||||
]);
|
||||
// dev-safe (owner-authorized 2026-06-02 re-scope): allow без approval. GIT_HARD_PATTERNS
|
||||
// (--no-verify / add -f / -c / force / --output / -o) пре-фильтруют опасные варианты ВЫШЕ.
|
||||
const GIT_DEV_SUB = new Set([
|
||||
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
|
||||
]);
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
||||
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
|
||||
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
|
||||
]);
|
||||
|
||||
// G5/G6 + force-push + add -f → always block (даже если "approved").
|
||||
@@ -212,6 +216,18 @@ export function classifyGitCommand(command, ctx = {}) {
|
||||
return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' };
|
||||
}
|
||||
|
||||
// dev-safe git (owner-authorized 2026-06-02 re-scope): GIT_HARD_PATTERNS уже отсеяли
|
||||
// опасные варианты (--no-verify / add -f / -c / force / --output / -o) на шаге 1.
|
||||
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
|
||||
|
||||
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
|
||||
if (sub === 'push') {
|
||||
if (/\b(?:main|master)\b/.test(norm)) {
|
||||
return { result: 'block', reason: 'git push в main/master — клик владельца' };
|
||||
}
|
||||
return { result: 'allow', reason: 'git push в фичевую ветку' };
|
||||
}
|
||||
|
||||
// 3. conditional → approve check
|
||||
if (GIT_CONDITIONAL_SUB.has(sub)) {
|
||||
const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now());
|
||||
|
||||
@@ -167,40 +167,59 @@ describe('classifyGitCommand — readonly', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — conditional after approve', () => {
|
||||
describe('classifyGitCommand — conditional (still needs approval after 2026-06-02 re-scope)', () => {
|
||||
const now = 2_000_000;
|
||||
it('blocks unapproved git commit', () => {
|
||||
const r = classifyGitCommand('git commit -m "x"', { approvedGitOps: [], now });
|
||||
expect(r.result).toBe('block');
|
||||
expect(r.reason).toMatch(/approve/i);
|
||||
});
|
||||
it('allows approved git commit', () => {
|
||||
const r = classifyGitCommand('git commit -m "x"', {
|
||||
approvedGitOps: [{ command: 'git commit -m "x"', ts: now }],
|
||||
now,
|
||||
});
|
||||
expect(r.result).toBe('allow');
|
||||
});
|
||||
it.each(['git rebase main', 'git reset --hard', 'git switch main', 'git stash pop', 'git push origin feat'])(
|
||||
'blocks unapproved %s',
|
||||
(cmd) => {
|
||||
it('blocks unapproved rebase/reset/merge/cherry-pick/revert/pull/clean', () => {
|
||||
for (const cmd of ['git rebase main', 'git reset --hard', 'git merge feat',
|
||||
'git cherry-pick abc', 'git revert abc', 'git pull', 'git clean -fd']) {
|
||||
expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block');
|
||||
},
|
||||
);
|
||||
it('blocks unapproved git add (v4 Stream G addition)', () => {
|
||||
const r = classifyGitCommand('git add .claude/settings.json', { approvedGitOps: [], now });
|
||||
expect(r.result).toBe('block');
|
||||
expect(r.reason).toMatch(/approve/i);
|
||||
}
|
||||
});
|
||||
it('allows approved git add', () => {
|
||||
const r = classifyGitCommand('git add .claude/settings.json', {
|
||||
approvedGitOps: [{ command: 'git add .claude/settings.json', ts: now }],
|
||||
it('allows approved git merge', () => {
|
||||
const r = classifyGitCommand('git merge feat', {
|
||||
approvedGitOps: [{ command: 'git merge feat', ts: now }],
|
||||
now,
|
||||
});
|
||||
expect(r.result).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — dev-allow (owner-authorized 2026-06-02 re-scope)', () => {
|
||||
const na = { approvedGitOps: [], now: 2_000_000 };
|
||||
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
|
||||
for (const cmd of [
|
||||
'git commit -m "x"', 'git add .', 'git branch feature-x',
|
||||
'git switch -c feature-x', 'git switch feature-x', 'git checkout -b feature-x',
|
||||
'git stash push -m wip', 'git stash pop',
|
||||
'git worktree add ../wt -b feat origin/main',
|
||||
]) {
|
||||
expect(classifyGitCommand(cmd, na).result).toBe('allow');
|
||||
}
|
||||
});
|
||||
it('still blocks commit --no-verify and add -f (hard patterns survive dev-allow)', () => {
|
||||
expect(classifyGitCommand('git commit --no-verify -m x', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git add -f ignored.txt', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — push main-guard (owner-authorized 2026-06-02 re-scope)', () => {
|
||||
const na = { approvedGitOps: [], now: 2_000_000 };
|
||||
it('allows push to a feature branch / bare push', () => {
|
||||
expect(classifyGitCommand('git push origin worktree-lead-region-tails', na).result).toBe('allow');
|
||||
expect(classifyGitCommand('git push', na).result).toBe('allow');
|
||||
expect(classifyGitCommand('git push -u origin feature-x', na).result).toBe('allow');
|
||||
});
|
||||
it('blocks push to main/master (owner click)', () => {
|
||||
expect(classifyGitCommand('git push origin main', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git push origin HEAD:main', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git push origin master', na).result).toBe('block');
|
||||
});
|
||||
it('blocks force-push (hard pattern unchanged)', () => {
|
||||
expect(classifyGitCommand('git push --force origin feature-x', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git push origin feature-x --force-with-lease', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — git-hard (always block)', () => {
|
||||
it.each([
|
||||
'git push --force origin main',
|
||||
|
||||
Reference in New Issue
Block a user