From 378b19b59f03bf5fe5f41f884bd4f079a3936552 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 20 Jun 2026 19:36:55 +0300 Subject: [PATCH] =?UTF-8?q?refactor(hooks):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=B8=D0=B0=D0=B3?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B3=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=20+=20README=20=D0=B4=D0=BB=D1=8F=20=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - support-guard: вместо общего списка всех вариантов — текст под конкретную причину отказа (decideSupport.code: capability-off | locked | not-removed), с подставленным реальным путём и точными командами support-edit. Понятно модели вне контекста: что за состояние и что именно сделать. - support-state: decideSupport возвращает code (дискриминатор причины). - README: переписан для читателя — убраны отсылки к внутренней реализации (§-нумерация, декодер, разбор common/); назначение, установка, настройка (.v8-project.json), что делать при отказе, проверка. Co-Authored-By: Claude Opus 4.8 --- hooks/README.md | 89 ++++++++++++++++++---------------- hooks/common/support-state.mjs | 9 +++- hooks/support-guard.mjs | 55 +++++++++++++++++---- hooks/test/run.mjs | 8 ++- 4 files changed, 106 insertions(+), 55 deletions(-) diff --git a/hooks/README.md b/hooks/README.md index 5cee00f2..badacf3d 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -1,52 +1,35 @@ -# Hooks: guardrail поддержки + суфлёр навыков +# Хуки: защита конфигураций на поддержке + подсказка навыков -Харнес-хуки Claude Code, **усиливающие** защиту типовых конфигураций 1С на поддержке. Это **бонус-слой -поверх пола безопасности** (гард §1B внутри навыков-мутаторов + видимость состояния в info-навыках), который -едет всеми каналами установки. Хуки ловят то, что навыки не видят — правки **мимо навыков**. +Два хука Claude Code, которые помогают безопасно дорабатывать типовые конфигурации 1С: -> Хуки — фича только Claude Code. На других платформах (Cursor/Codex/…) их нет; там работает переносимый -> пол §1B. Поэтому хуки — усиление, а не замена. +- **Защита от правки «на замке».** Если модель пытается напрямую (инструментами `Edit`/`Write`) + изменить объект типовой конфигурации, который стоит на поддержке поставщика, правка **блокируется** — + иначе она молча сломает будущие обновления вендора. В отказе сразу даётся, что делать дальше под + конкретный случай (доработать в расширении или явно разрешить правку). +- **Подсказка навыков.** Когда модель работает с исходниками 1С «вручную» (читает сырой XML, ищет по + метаданным), хук ненавязчиво напоминает, что для этой задачи есть профильный навык (`meta-info`, + `form-edit`, `mxl-*`, `skd-*` и т.п.). Не блокирует, подсказывает не чаще одного раза за сессию на группу. -## Что внутри +Это дополнительный слой поверх проверок, которые уже встроены в сами навыки: навыки-мутаторы и так не дадут +испортить объект на поддержке. Хуки добавляют защиту для случаев, когда правят файлы **в обход навыков**. -| Файл | Событие | Назначение | -|------|---------|------------| -| `support-guard.mjs` | **PreToolUse** `Edit\|Write\|MultiEdit` | §1A: блокирует сырую правку объекта поставщика «на замке» / read-only конфигурации мимо навыков. | -| `skill-suggester.mjs` | **PostToolUse** `Read\|Grep\|Glob\|Edit\|Write\|MultiEdit` | Ненавязчиво подсказывает профильный навык 1С, когда модель работает с исходниками напрямую. Не блокирует. | -| `common/support-state.mjs` | — | Декодер `Ext/ParentConfigurations.bin` + правило `G`/`f1` (канон; зеркало гарда §1B). | -| `common/project.mjs` | — | Чтение реакции из `.v8-project.json`. | -| `common/object-class.mjs` | — | Карта путь→навык (с различением cf/cfe и mxl/скд). | -| `test/run.mjs` | — | Standalone-тесты на корпусе `cfsrc` + синтетике. | +> Хуки — возможность только Claude Code. На других платформах их нет; там работают встроенные в навыки проверки. -Рантайм — **Node.js 18+** (как и для `/web-test`). Скрипты рантайм-независимы (не зависят от PS/Python-порта навыков). +## Требования -## Поведение - -**Гард (§1A).** Срабатывает по наличию `Ext/ParentConfigurations.bin` (walk-up от пути правки). Реакция — -из `.v8-project.json`, поле `editingAllowedCheck`: -- `deny` (**по умолчанию**) — блокирует (`permissionDecision: deny`) с диагностикой (безопасные пути: `cfe-*`, - `support-edit`, осознанное снятие с поддержки); -- `warn` — пропускает с предупреждением; -- `off` — выключено. - -Раскладка: глобальный дефолт + переопределение на запись базы (`databases[].editingAllowedCheck`). Читается -**идентично** гарду §1B внутри навыков. - -**Суфлёр.** Поле `skillSuggester` (`on` по умолчанию | `off`). Подсказывает не чаще **1×/сессия/группа навыков**, -мягкой формулировкой, через `additionalContext` (видит модель). Молчит на коде модулей (`*.bsl`), нераспознанных -путях и при `off`. +**Node.js 18+** (тот же, что нужен для `/web-test`). Команда `node` должна быть доступна в PATH. ## Установка -### Плагин (рекомендуется) — автоматически -`.claude-plugin/plugin.json` декларирует `"hooks": "./hooks/hooks.json"`. При включении плагина хуки -подключаются сами, пути резолвятся через `${CLAUDE_PLUGIN_ROOT}`. Ничего настраивать не нужно. +### Через плагин — автоматически +Если навыки установлены как плагин (`/plugin install 1c-skills@cc-1c-skills`), хуки подключаются сами — +настраивать ничего не нужно. + +### Вручную (при установке копированием папки навыков) +Копирование `.claude/skills/` хуки не переносит. Чтобы включить их: -### Копия папки / `switch.py` — вручную (опт-ин) -Эти каналы не несут хуки автоматически (копируется только `.claude/skills/`, а `settings.json` не переносится). -Чтобы включить: 1. Скопируйте каталог `hooks/` в проект, например в `<проект>/.claude/hooks/`. -2. Добавьте в `<проект>/.claude/settings.json` (проектный, **не** `settings.local.json`): +2. Добавьте в `<проект>/.claude/settings.json`: ```json { @@ -65,12 +48,34 @@ } ``` -## Тесты +## Настройка (`.v8-project.json`) + +Поведение настраивается в файле проекта `.v8-project.json` — глобально и/или по конкретной базе +(`databases[].…`, переопределяет глобальное): + +| Поле | Значения | По умолчанию | Что делает | +|------|----------|--------------|------------| +| `editingAllowedCheck` | `deny` / `warn` / `off` | `deny` | Реакция защиты: блокировать правку объекта на замке / только предупреждать / выключить проверку. | +| `skillSuggester` | `on` / `off` | `on` | Включает/выключает подсказки навыков. | + +Источник истины по состоянию поддержки — сама выгрузка конфигурации; `.v8-project.json` лишь настраивает +реакцию. + +## Что делать при отказе защиты + +Текст отказа сам подсказывает варианты под конкретную ситуацию. Кратко: + +- **Безопаснее всего** — вести доработку в расширении (навыки `cfe-borrow` / `cfe-patch-method`): + состояние поддержки менять не нужно, обновления вендора сохраняются. +- **Либо** осознанно разрешить правку через навык `support-edit` (включить редактирование объекта, + снять его с поддержки или включить возможность изменения всей конфигурации). Готовую команду под ваш + случай печатает сам отказ. + +## Проверка ```bash node hooks/test/run.mjs ``` -Прогоняет декодер/гард/суфлёр на корпусе `cfsrc` (bp `G=1` → deny, erp `K=0` → allow) и на синтетических -фикстурах (`test-tmp/`, gitignored; корпус не трогается). Все ветки: per-object `f1=0/1/2`, warn/off, -throttle, слепые пятна, cf/cfe, mxl/скд. +Прогоняет защиту и подсказку на реальных выгрузках и на временных тестовых данных (в `test-tmp/`, не +попадает в git; рабочие выгрузки не затрагиваются). diff --git a/hooks/common/support-state.mjs b/hooks/common/support-state.mjs index 4868019c..f88bd39f 100644 --- a/hooks/common/support-state.mjs +++ b/hooks/common/support-state.mjs @@ -64,9 +64,11 @@ export function findConfigRoot(startPath) { // Decode the bin header + per-object rules and apply the support rule for `require` // ('editable' — blocked if locked f1=0; 'removed' — blocked unless f1=2). -// Returns { blocked, reason, cfgDir, targetPath }. Never throws. +// Returns { blocked, reason, code, cfgDir, targetPath }. `code` discriminates the cause +// ('capability-off' | 'locked' | 'not-removed') so callers can tailor the remedy. +// Never throws. export function decideSupport(targetPath, require = 'editable') { - const result = { blocked: false, reason: '', cfgDir: null, targetPath }; + const result = { blocked: false, reason: '', code: null, cfgDir: null, targetPath }; try { let elemUuid = rootUuid(targetPath); // Walk up: collect elemUuid (from .xml of a sub-element) and the config root. @@ -119,15 +121,18 @@ export function decideSupport(targetPath, require = 'editable') { if (G === 1) { result.blocked = true; + result.code = 'capability-off'; result.reason = 'возможность изменения конфигурации выключена (вся конфигурация read-only)'; } else if (require === 'removed') { if (best !== null && best !== 2) { result.blocked = true; + result.code = 'not-removed'; result.reason = 'объект на поддержке (не снят с поддержки) — удаление сломает обновления'; } } else { if (best !== null && best === 0) { result.blocked = true; + result.code = 'locked'; result.reason = 'объект на замке (поддержка поставщика) — прямая правка сломает обновления'; } } diff --git a/hooks/support-guard.mjs b/hooks/support-guard.mjs index 15ffaebf..2ef20bd3 100644 --- a/hooks/support-guard.mjs +++ b/hooks/support-guard.mjs @@ -25,14 +25,51 @@ function candidatePaths(toolInput) { return out; } -function diagnostic(reason, target) { - return ( - `[support-guard] Операция запрещена: ${reason}.\n` + - ` Цель: ${target}\n` + - ` Безопасные пути: доработка через расширение (cfe-*); либо support-edit -Path <цель> -Set editable ` + - `(включить объект) / -Path <дамп> -Capability on (вся конфа read-only) / -Set off-support (снять с поддержки).\n` + - ` Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.` - ); +// Tailored, model-actionable diagnostic per block cause (see decideSupport `code`). +// Fills in the real target/config paths so the suggested commands are ready to run. +function diagnostic(code, target, cfgDir) { + const head = + '[support-guard] Правка отклонена: это объект типовой конфигурации на поддержке поставщика, ' + + 'прямая правка молча сломает будущие обновления.'; + const cfe = + 'Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — ' + + 'состояние поддержки менять не нужно, обновления вендора сохраняются.'; + const offNote = 'Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json.'; + const root = cfgDir || '<каталог дампа>'; + + if (code === 'capability-off') { + return [ + head, + `Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — ` + + `поэтому объект «${target}» править нельзя.`, + cfe, + `Либо снять защиту явно (навык support-edit, два шага):`, + ` 1. support-edit -Path "${root}" -Capability on — включить возможность изменения (объекты пока остаются на замке);`, + ` 2. support-edit -Path "${target}" -Set editable — открыть этот объект для правки.`, + `Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора.`, + offNote, + ].join('\n'); + } + if (code === 'not-removed') { + return [ + head, + `Состояние: объект «${target}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора.`, + cfe, + `Либо сначала снять объект с поддержки, затем удалять:`, + ` support-edit -Path "${target}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.`, + offNote, + ].join('\n'); + } + // locked (G=0, f1=0) + return [ + head, + `Состояние: объект «${target}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется).`, + cfe, + `Либо разрешить правку этого объекта (навык support-edit, выбрать одно):`, + ` • support-edit -Path "${target}" -Set editable — править и дальше получать обновления вендора (при обновлении возможны конфликты слияния);`, + ` • support-edit -Path "${target}" -Set off-support — снять с поддержки: правки свободны, обновления по объекту больше не приходят.`, + offNote, + ].join('\n'); } // Core decision. Returns { stdout, stderr, exitCode }. Pure (no I/O) for testability. @@ -55,7 +92,7 @@ export function processInput(input) { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: diagnostic(r.reason, target), + permissionDecisionReason: diagnostic(r.code, target, r.cfgDir), }, }; return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 }; diff --git a/hooks/test/run.mjs b/hooks/test/run.mjs index 2cb6a53a..0156d681 100644 --- a/hooks/test/run.mjs +++ b/hooks/test/run.mjs @@ -109,16 +109,20 @@ console.log('=== support-guard: §1A PreToolUse ==='); const SYNTH = join(REPO, 'test-tmp', 'hooks-synth'); const edit = (fp, cwd = REPO) => guard({ tool_name: 'Edit', cwd, tool_input: { file_path: fp } }); - // deny (default): G=1 corpus + synth locked. + // deny (default): G=1 corpus → capability-off remedy; synth locked → editable remedy. if (existsSync(join(ACC, 'Configuration.xml'))) { const r = edit(join(ACC, 'Configuration.xml')); let d = null; try { d = JSON.parse(r.stdout); } catch { /* */ } + const reason = d?.hookSpecificOutput?.permissionDecisionReason || ''; check('guard acc (G=1) → deny JSON', d?.hookSpecificOutput?.permissionDecision === 'deny', r.stdout); + check('guard G=1 reason → -Capability on remedy', /-Capability on/.test(reason) && /возможность изменения/.test(reason), reason); } const rLocked = edit(join(SYNTH, 'Catalogs', 'Locked.xml')); let dLocked = null; try { dLocked = JSON.parse(rLocked.stdout); } catch { /* */ } + const reasonLocked = dLocked?.hookSpecificOutput?.permissionDecisionReason || ''; check('guard synth locked → deny JSON', dLocked?.hookSpecificOutput?.permissionDecision === 'deny', rLocked.stdout); - check('guard deny reason has safe paths', /cfe-\*/.test(dLocked?.hookSpecificOutput?.permissionDecisionReason || '')); + check('guard locked reason → -Set editable with real path', /-Set editable/.test(reasonLocked) && reasonLocked.includes('Locked.xml'), reasonLocked); + check('guard reason offers cfe + support-edit', /cfe-borrow/.test(reasonLocked) && /support-edit/.test(reasonLocked), reasonLocked); // allow: erp + synth editable + non-config file → empty stdout, exit 0. const rEdit = edit(join(SYNTH, 'Catalogs', 'Editable.xml'));