feat(hooks): §1A гард поддержки + суфлёр навыков (node-хуки Claude Code)

Харнес-слой поверх пола §1B: ловит правки мимо навыков-мутаторов.

- support-guard.mjs (PreToolUse Edit|Write|MultiEdit) — §1A: блокирует
  сырую правку объекта поставщика «на замке» / read-only конфы; реакция
  deny|warn|off из .v8-project.json editingAllowedCheck, идентично §1B.
- skill-suggester.mjs (PostToolUse Read|Grep|Glob|Edit|Write|MultiEdit) —
  ненавязчивая подсказка профильного навыка, throttle 1×/сессия/группа,
  не блокирует; флаг skillSuggester (on|off).
- common/: support-state.mjs (порт декодера bin 1:1 из Assert-EditAllowed),
  project.mjs (реакция из .v8-project.json), object-class.mjs (карта
  путь→навык с различением cf/cfe и mxl/скд по нюху корня).
- test/run.mjs: 38 standalone-тестов на корпусе cfsrc + синтетике.
- plugin.json: hooks → ./hooks/hooks.json (авто-загрузка в плагине).

§1C (грубый Bash-гейт) отброшен — дублирует §1B, формат bin заморожен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-20 18:39:05 +03:00
parent 07ea676326
commit ebd620d262
9 changed files with 808 additions and 1 deletions
+2 -1
View File
@@ -27,5 +27,6 @@
"testing",
"test-automation"
],
"skills": "./.claude/skills/"
"skills": "./.claude/skills/",
"hooks": "./hooks/hooks.json"
}
+76
View File
@@ -0,0 +1,76 @@
# Hooks: guardrail поддержки + суфлёр навыков
Харнес-хуки Claude Code, **усиливающие** защиту типовых конфигураций 1С на поддержке. Это **бонус-слой
поверх пола безопасности** (гард §1B внутри навыков-мутаторов + видимость состояния в info-навыках), который
едет всеми каналами установки. Хуки ловят то, что навыки не видят — правки **мимо навыков**.
> Хуки — фича только Claude Code. На других платформах (Cursor/Codex/…) их нет; там работает переносимый
> пол §1B. Поэтому хуки — усиление, а не замена.
## Что внутри
| Файл | Событие | Назначение |
|------|---------|------------|
| `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` + синтетике. |
Рантайм — **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`.
## Установка
### Плагин (рекомендуется) — автоматически
`.claude-plugin/plugin.json` декларирует `"hooks": "./hooks/hooks.json"`. При включении плагина хуки
подключаются сами, пути резолвятся через `${CLAUDE_PLUGIN_ROOT}`. Ничего настраивать не нужно.
### Копия папки / `switch.py` — вручную (опт-ин)
Эти каналы не несут хуки автоматически (копируется только `.claude/skills/`, а `settings.json` не переносится).
Чтобы включить:
1. Скопируйте каталог `hooks/` в проект, например в `<проект>/.claude/hooks/`.
2. Добавьте в `<проект>/.claude/settings.json` (проектный, **не** `settings.local.json`):
```json
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/support-guard.mjs\"" }] }
],
"PostToolUse": [
{ "matcher": "Read|Grep|Glob|Edit|Write|MultiEdit",
"hooks": [{ "type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/skill-suggester.mjs\"" }] }
]
}
}
```
## Тесты
```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/скд.
+108
View File
@@ -0,0 +1,108 @@
// object-class.mjs v1.0 — classify a 1C source path → relevant skill group (suggester)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Conservative path→skill map for the skill-suggester hook. Returns { group, message }
// or null (stay silent) when the path is not a recognizable 1C artifact. Distinguishes
// cf vs cfe (extension) by sniffing <ConfigurationExtensionPurpose> in Configuration.xml,
// and mxl vs skd templates by the root namespace. Never throws.
import { readFileSync, existsSync, statSync } from 'node:fs';
import { basename, dirname } from 'node:path';
// Top-level metadata collections handled by meta-* (Roles handled separately → role-*).
const META_COLLECTIONS = new Set([
'Catalogs', 'Documents', 'Enums', 'Reports', 'DataProcessors', 'InformationRegisters',
'AccumulationRegisters', 'AccountingRegisters', 'CalculationRegisters', 'DocumentJournals',
'ChartsOfCharacteristicTypes', 'ChartsOfAccounts', 'ChartsOfCalculationTypes', 'BusinessProcesses',
'Tasks', 'ExchangePlans', 'Constants', 'CommonModules', 'FilterCriteria', 'SettingsStorages',
'CommonAttributes', 'DefinedTypes', 'SessionParameters', 'CommonForms', 'CommonTemplates',
'CommonCommands', 'CommandGroups', 'CommonPictures', 'WebServices', 'HTTPServices', 'WSReferences',
'ScheduledJobs', 'FunctionalOptions', 'FunctionalOptionsParameters', 'EventSubscriptions',
'Sequences', 'ExternalDataSources', 'IntegrationServices',
]);
const MESSAGES = {
meta: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML), а структурные правки — `meta-edit` (реквизиты/ТЧ/измерения/ресурсы).',
form: 'Для управляемой формы 1С есть `form-info` (анализ элементов/реквизитов/команд) и `form-edit` (точечные правки).',
mxl: 'Это табличный документ 1С: `mxl-info`/`mxl-decompile` дают редактируемое описание, `mxl-compile` собирает обратно.',
skd: 'Это схема компоновки данных (СКД): `skd-info` для анализа, `skd-edit` для точечных правок.',
role: 'Для прав роли 1С есть `role-info` (сводка прав/RLS) и `role-compile` (создание из DSL).',
cf: 'Корень конфигурации 1С: `cf-info` (обзор состава/свойств) и `cf-edit` (правки настроек/состава).',
cfe: 'Это расширение конфигурации (CFE): `cfe-diff` для анализа, а доработку безопаснее вести через `cfe-borrow`/`cfe-patch-method`.',
subsystem: 'Подсистема 1С: `subsystem-info` (состав/дерево) и `subsystem-edit` (правки состава/свойств).',
template: 'Это макет объекта 1С: для табличного документа — навыки `mxl-*`, для СКД — `skd-*`.',
search: 'Для навигации по метаданным 1С есть структурированные навыки `*-info` (meta-info/cf-info/form-info/…) — обычно быстрее сырого поиска по XML.',
};
function segments(p) {
return p.replace(/\\/g, '/').split('/').filter(Boolean);
}
function sniffRoot(path) {
try {
if (!existsSync(path) || !statSync(path).isFile()) return '';
const fd = readFileSync(path, 'utf8');
return fd.slice(0, 600);
} catch {
return '';
}
}
// Classify a concrete file path. Returns { group, message } or null.
export function classifyFile(path) {
try {
const segs = segments(path);
const name = basename(path);
if (!name) return null;
if (name.toLowerCase().endsWith('.bsl')) return null; // module code — no skill, stay silent
// Form.xml under .../Forms/<Name>/Ext/
if (name === 'Form.xml' && segs.includes('Forms')) return mk('form');
// Template.xml under .../Templates/<Name>/Ext/ → sniff root namespace (mxl vs skd)
if (name === 'Template.xml' && segs.includes('Templates')) {
const head = sniffRoot(path);
if (/data\/spreadsheet/.test(head)) return mk('mxl');
if (/DataCompositionSchema|data-composition-schema/i.test(head)) return mk('skd');
return mk('template'); // unreadable / unknown → generic
}
// Roles: Rights.xml or Roles/<Name>.xml
if (name === 'Rights.xml' && segs.includes('Roles')) return mk('role');
// Configuration.xml → cf vs cfe (extension marker)
if (name === 'Configuration.xml') {
const head = sniffRoot(path);
return /ConfigurationExtensionPurpose/.test(head) ? mk('cfe') : mk('cf');
}
const parent = basename(dirname(path));
// Top-level object root: <Collection>/<Name>.xml
if (name.toLowerCase().endsWith('.xml')) {
if (parent === 'Roles') return mk('role');
if (parent === 'Subsystems') return mk('subsystem');
if (META_COLLECTIONS.has(parent)) return mk('meta');
}
return null;
} catch {
return null;
}
}
// Classify a Grep/Glob search target: if it points inside a 1C config tree (or a known
// collection appears in the path/pattern) → suggest the info-skills. Best-effort, lean silent.
export function classifySearch(target) {
try {
if (!target) return null;
const segs = segments(target);
if (segs.some((s) => META_COLLECTIONS.has(s) || s === 'Roles' || s === 'Subsystems')) return mk('search');
return null;
} catch {
return null;
}
}
function mk(group) {
return { group, message: MESSAGES[group] };
}
+67
View File
@@ -0,0 +1,67 @@
// project.mjs v1.0 — read reaction mode from .v8-project.json for Claude Code hooks
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Canonical port of Get-EditMode / _sg_get_edit_mode
// (reference: .claude/skills/meta-edit/scripts/meta-edit.ps1:181-201, meta-edit.py:50-68).
// configSrc is matched here ONLY to fetch a per-database override — identically to the
// in-skill guard §1B, so that a raw Edit and an edit-via-skill behave the same under the
// same databases[].editingAllowedCheck. Never throws — falls back to the default.
import { readFileSync, existsSync, statSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
const WIN = process.platform === 'win32';
function norm(p) {
let s = resolve(p).replace(/[\\/]+$/, '');
return WIN ? s.toLowerCase() : s;
}
function findV8Project(startDir) {
let d = startDir;
for (let i = 0; i < 20 && d; i++) {
const pj = join(d, '.v8-project.json');
if (existsSync(pj)) return pj;
const parent = dirname(d);
if (parent === d) break;
d = parent;
}
return null;
}
// Generic reader: returns databases[].<key> for the matching configSrc, else global
// proj.<key>, else fallback. cwd is the hook's stdin cwd; cfgDir is the resolved config root.
export function getProjectSetting(key, cfgDir, cwd, fallback) {
try {
const pj = findV8Project(cwd) || (cfgDir ? findV8Project(cfgDir) : null);
if (!pj) return fallback;
let raw = readFileSync(pj, 'utf8');
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // strip BOM
const proj = JSON.parse(raw);
if (cfgDir && Array.isArray(proj.databases)) {
const cfgFull = norm(cfgDir);
for (const db of proj.databases) {
if (db && db.configSrc) {
const src = norm(db.configSrc);
if (cfgFull === src || cfgFull.startsWith(src + sep)) {
if (db[key]) return db[key];
}
}
}
}
if (proj[key]) return proj[key];
return fallback;
} catch {
return fallback;
}
}
// Guard reaction: deny (default) | warn | off.
export function getEditMode(cfgDir, cwd) {
return getProjectSetting('editingAllowedCheck', cfgDir, cwd, 'deny');
}
// Suggester switch: on (default) | off.
export function getSuggesterMode(cfgDir, cwd) {
return getProjectSetting('skillSuggester', cfgDir, cwd, 'on');
}
+142
View File
@@ -0,0 +1,142 @@
// support-state.mjs v1.0 — decode 1C support state (Ext/ParentConfigurations.bin) for Claude Code hooks
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Canonical port of the in-skill guard Assert-EditAllowed / assert_edit_allowed
// (reference: .claude/skills/meta-edit/scripts/meta-edit.ps1:160-261, meta-edit.py:22-148).
// See docs/1c-support-state-spec.md. Detects whether a target file lives under a
// vendor configuration on support and whether editing it is blocked. Never throws —
// any decode error degrades to "not blocked" (allow). configSrc / .v8-project.json
// are NOT used here (reaction lookup lives in project.mjs); the config root is found
// purely by walking up to Ext/ParentConfigurations.bin.
import { readFileSync, existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
const GUID_RE = /\buuid="([0-9a-fA-F-]{36})"/;
// First uuid="..." in an object XML == root element uuid (the <MetaDataObject> wrapper
// carries none), matching the reference's "first element child uuid" semantics.
export function rootUuid(xmlPath) {
try {
if (!existsSync(xmlPath) || !statSync(xmlPath).isFile()) return null;
const text = readFileSync(xmlPath, 'utf8');
const m = GUID_RE.exec(text);
return m ? m[1] : null;
} catch {
return null;
}
}
// Walk up from startPath (a file or dir) to find the configuration root: the directory
// that holds Ext/ParentConfigurations.bin or Configuration.xml. Returns
// { cfgDir, binPath, isExtension } or nulls. isExtension is positive recognition via
// <ConfigurationExtensionPurpose> in Configuration.xml (spec §1) — distinguishes an
// extension (no real support) from "support fully removed" (bin also near-empty).
export function findConfigRoot(startPath) {
let cfgDir = null, binPath = null, configXml = null;
let d = startPath;
try {
d = existsSync(startPath) && statSync(startPath).isDirectory() ? startPath : dirname(startPath);
} catch {
d = dirname(startPath);
}
for (let i = 0; i < 12 && d; i++) {
const cand = join(d, 'Ext', 'ParentConfigurations.bin');
const cfgX = join(d, 'Configuration.xml');
if (existsSync(cand) || existsSync(cfgX)) {
cfgDir = d;
binPath = cand;
configXml = existsSync(cfgX) ? cfgX : null;
break;
}
const parent = dirname(d);
if (parent === d) break;
d = parent;
}
let isExtension = false;
if (configXml) {
try {
isExtension = readFileSync(configXml, 'utf8').includes('ConfigurationExtensionPurpose');
} catch { /* ignore */ }
}
return { cfgDir, binPath, isExtension };
}
// 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.
export function decideSupport(targetPath, require = 'editable') {
const result = { blocked: false, reason: '', cfgDir: null, targetPath };
try {
let elemUuid = rootUuid(targetPath);
// Walk up: collect elemUuid (from <dir>.xml of a sub-element) and the config root.
let cfgDir = null, binPath = null;
let d;
try {
d = existsSync(targetPath) && statSync(targetPath).isDirectory() ? targetPath : dirname(targetPath);
} catch {
d = dirname(targetPath);
}
for (let i = 0; i < 12 && d; i++) {
if (!elemUuid) elemUuid = rootUuid(d + '.xml');
if (!cfgDir) {
const cand = join(d, 'Ext', 'ParentConfigurations.bin');
if (existsSync(cand) || existsSync(join(d, 'Configuration.xml'))) {
cfgDir = d;
binPath = cand;
}
}
if (elemUuid && cfgDir) break;
const parent = dirname(d);
if (parent === d) break;
d = parent;
}
result.cfgDir = cfgDir;
// New object (no element file): fall back to config root uuid.
if (!elemUuid && cfgDir) elemUuid = rootUuid(join(cfgDir, 'Configuration.xml'));
if (!binPath || !existsSync(binPath)) return result;
let data = readFileSync(binPath);
if (data.length <= 32) return result;
if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) data = data.subarray(3);
const text = data.toString('utf8');
const h = /^\{6,(\d+),(\d+),/.exec(text);
if (!h) return result;
const G = parseInt(h[1], 10);
const K = parseInt(h[2], 10);
if (K === 0) return result;
let best = null;
if (elemUuid) {
const re = new RegExp('([0-2]),0,' + escapeRe(elemUuid.toLowerCase()), 'g');
let m;
while ((m = re.exec(text)) !== null) {
const f1 = parseInt(m[1], 10);
if (best === null || f1 < best) best = f1;
}
}
if (G === 1) {
result.blocked = true;
result.reason = 'возможность изменения конфигурации выключена (вся конфигурация read-only)';
} else if (require === 'removed') {
if (best !== null && best !== 2) {
result.blocked = true;
result.reason = 'объект на поддержке (не снят с поддержки) — удаление сломает обновления';
}
} else {
if (best !== null && best === 0) {
result.blocked = true;
result.reason = 'объект на замке (поддержка поставщика) — прямая правка сломает обновления';
}
}
return result;
} catch {
return result;
}
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+26
View File
@@ -0,0 +1,26 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/support-guard.mjs\""
}
]
}
],
"PostToolUse": [
{
"matcher": "Read|Grep|Glob|Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-suggester.mjs\""
}
]
}
]
}
}
+83
View File
@@ -0,0 +1,83 @@
// skill-suggester.mjs v1.0 — PostToolUse hook: nudge toward the matching 1C skill when
// the model works the sources with raw tools (forgot a skill, or went manual).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// stdin: PostToolUse JSON { tool_name, tool_input, session_id, cwd, ... }.
// Non-blocking: emits stdout JSON hookSpecificOutput.additionalContext (model-visible).
// Throttled to 1×/session/skill-group via marker files. Switch: skillSuggester (on|off)
// in .v8-project.json. Never throws.
import { classifyFile, classifySearch } from './common/object-class.mjs';
import { findConfigRoot } from './common/support-state.mjs';
import { getSuggesterMode } from './common/project.mjs';
import { resolve, isAbsolute, join } from 'node:path';
import { existsSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
function pickTarget(input, cwd) {
const ti = input.tool_input || {};
const tool = input.tool_name;
let raw = null, kind = 'file';
if (tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
raw = typeof ti.file_path === 'string' ? ti.file_path
: (Array.isArray(ti.file_edits) && ti.file_edits[0]?.file_path) || null;
} else if (tool === 'Grep') {
raw = typeof ti.path === 'string' ? ti.path : null; kind = 'search';
} else if (tool === 'Glob') {
raw = typeof ti.path === 'string' ? ti.path : (typeof ti.pattern === 'string' ? ti.pattern : null); kind = 'search';
}
if (!raw) return null;
const path = isAbsolute(raw) ? raw : resolve(cwd, raw);
return { path, kind };
}
function sanitize(s) {
return String(s || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
}
// Core. opts.throttleDir overrides the marker directory (tests). Returns {stdout,stderr,exitCode}.
export function processInput(input, opts = {}) {
const empty = { stdout: '', stderr: '', exitCode: 0 };
try {
const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd();
const t = pickTarget(input, cwd);
if (!t) return empty;
const hit = t.kind === 'search' ? classifySearch(t.path) : classifyFile(t.path);
if (!hit) return empty;
const { cfgDir } = findConfigRoot(t.path);
if (getSuggesterMode(cfgDir, cwd) === 'off') return empty;
const dir = opts.throttleDir || tmpdir();
const marker = join(dir, `cc-1c-suggest-${sanitize(input.session_id)}-${hit.group}`);
if (existsSync(marker)) return empty; // already nudged this group this session
try { writeFileSync(marker, ''); } catch { /* throttle best-effort */ }
const decision = {
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: `[1c-skills] ${hit.message}`,
},
};
return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 };
} catch {
return empty;
}
}
async function readStdin() {
const chunks = [];
for await (const c of process.stdin) chunks.push(c);
return Buffer.concat(chunks).toString('utf8');
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('skill-suggester.mjs')) {
const raw = await readStdin();
let input = {};
try { input = raw.trim() ? JSON.parse(raw) : {}; } catch { input = {}; }
const { stdout, stderr, exitCode } = processInput(input);
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr + '\n');
process.exit(exitCode);
}
+84
View File
@@ -0,0 +1,84 @@
// support-guard.mjs v1.0 — PreToolUse hook (§1A): block raw Edit/Write/MultiEdit of
// vendor objects "на замке" / read-only configs that bypass the in-skill guard (§1B).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// stdin: PreToolUse JSON { tool_name, tool_input, cwd, ... }.
// Decision via stdout JSON hookSpecificOutput.permissionDecision (deny) — see
// docs/1c-support-state-spec.md. Reaction (deny|warn|off) from .v8-project.json
// editingAllowedCheck, identical to §1B. Never blocks on its own errors.
import { decideSupport } from './common/support-state.mjs';
import { getEditMode } from './common/project.mjs';
import { resolve, isAbsolute } from 'node:path';
// Collect candidate file paths from an Edit/Write/MultiEdit tool_input. Handles the
// single-file form ({ file_path }) and the array form ({ file_edits: [{ file_path }] }).
function candidatePaths(toolInput) {
const out = [];
if (!toolInput || typeof toolInput !== 'object') return out;
if (typeof toolInput.file_path === 'string') out.push(toolInput.file_path);
if (Array.isArray(toolInput.file_edits)) {
for (const e of toolInput.file_edits) {
if (e && typeof e.file_path === 'string') out.push(e.file_path);
}
}
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.`
);
}
// Core decision. Returns { stdout, stderr, exitCode }. Pure (no I/O) for testability.
export function processInput(input) {
const empty = { stdout: '', stderr: '', exitCode: 0 };
try {
const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd();
const paths = candidatePaths(input.tool_input);
for (const p of paths) {
const target = isAbsolute(p) ? p : resolve(cwd, p);
const r = decideSupport(target, 'editable');
if (!r.blocked) continue;
const mode = getEditMode(r.cfgDir, cwd);
if (mode === 'off') continue;
if (mode === 'warn') {
return { stdout: '', stderr: `[support-guard] ПРЕДУПРЕЖДЕНИЕ: ${r.reason}. Цель: ${target}`, exitCode: 0 };
}
// deny (default): structured PreToolUse decision.
const decision = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: diagnostic(r.reason, target),
},
};
return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 };
}
return empty;
} catch {
return empty; // guard errors must never block
}
}
async function readStdin() {
const chunks = [];
for await (const c of process.stdin) chunks.push(c);
return Buffer.concat(chunks).toString('utf8');
}
// Run only when executed directly (not when imported by tests).
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('support-guard.mjs')) {
const raw = await readStdin();
let input = {};
try { input = raw.trim() ? JSON.parse(raw) : {}; } catch { input = {}; }
const { stdout, stderr, exitCode } = processInput(input);
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr + '\n');
process.exit(exitCode);
}
+220
View File
@@ -0,0 +1,220 @@
// run.mjs v1.0 — standalone tests for hook common/ modules against the cfsrc corpus
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// No live hook registration needed: exercises decideSupport / getEditMode / findConfigRoot
// directly. Run: node hooks/test/run.mjs
import { decideSupport, findConfigRoot, rootUuid } from '../common/support-state.mjs';
import { getEditMode, getSuggesterMode } from '../common/project.mjs';
import { processInput as guard } from '../support-guard.mjs';
import { processInput as suggest } from '../skill-suggester.mjs';
import { execFileSync } from 'node:child_process';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
const CORPUS = 'C:/WS/tasks/cfsrc';
const ACC = join(CORPUS, 'acc_8.3.24'); // G=1 — whole config locked
const ERP = join(CORPUS, 'erp_8.3.24'); // K=0 — support removed
const REPO = 'C:/WS/tasks/skills';
let pass = 0, fail = 0;
function check(name, cond, detail = '') {
if (cond) { pass++; console.log(` PASS ${name}`); }
else { fail++; console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`); }
}
console.log('=== support-state: decideSupport ===');
if (existsSync(join(ACC, 'Configuration.xml'))) {
const r = decideSupport(join(ACC, 'Configuration.xml'), 'editable');
check('acc_8.3.24 (G=1) → blocked', r.blocked === true, JSON.stringify(r));
check('acc_8.3.24 reason mentions read-only', /read-only/.test(r.reason));
} else { console.log(' SKIP acc_8.3.24 corpus missing'); }
if (existsSync(join(ERP, 'Configuration.xml'))) {
const r = decideSupport(join(ERP, 'Configuration.xml'), 'editable');
check('erp_8.3.24 (K=0) → NOT blocked', r.blocked === false, JSON.stringify(r));
check('erp_8.3.24 cfgDir resolved', !!r.cfgDir);
} else { console.log(' SKIP erp_8.3.24 corpus missing'); }
{
const r = decideSupport(join(REPO, 'README.md'), 'editable');
check('repo README (no bin) → NOT blocked', r.blocked === false, JSON.stringify(r));
}
console.log('=== support-state: findConfigRoot / rootUuid ===');
if (existsSync(join(ACC, 'Configuration.xml'))) {
const cr = findConfigRoot(join(ACC, 'Ext', 'ParentConfigurations.bin'));
check('findConfigRoot locates cfgDir', !!cr.cfgDir);
check('findConfigRoot base config isExtension=false', cr.isExtension === false);
const u = rootUuid(join(ACC, 'Configuration.xml'));
check('rootUuid(Configuration.xml) is a guid', !!u && /^[0-9a-fA-F-]{36}$/.test(u), String(u));
}
console.log('=== support-state: synthetic per-object f1 (G=0) ===');
{
// Corpus only has G=1 / K=0; synthesize a G=0 single-vendor config with a locked
// (f1=0), an editable (f1=1) and a removed-from-support (f1=2) object to exercise
// the uuid rule + min-f1 fold. Fixtures go to test-tmp (gitignored), never cfsrc.
const ROOT = join(REPO, 'test-tmp', 'hooks-synth');
const U = {
root: '11111111-1111-1111-1111-111111111111',
locked: '22222222-2222-2222-2222-222222222222',
edit: '33333333-3333-3333-3333-333333333333',
removed: '44444444-4444-4444-4444-444444444444',
free: '55555555-5555-5555-5555-555555555555', // not in bin → not on support
};
mkdirSync(join(ROOT, 'Ext'), { recursive: true });
mkdirSync(join(ROOT, 'Catalogs'), { recursive: true });
const rec = (f1, u) => `${f1},0,${u},${u},`;
const binText =
`{6,0,1,aaaaaaaa-0000-0000-0000-000000000000,0,bbbbbbbb-0000-0000-0000-000000000000,` +
`"1.0","Vendor","Name",4,` +
rec(0, U.root) + rec(0, U.locked) + rec(1, U.edit) + rec(2, U.removed).replace(/,$/, '') + `}`;
writeFileSync(join(ROOT, 'Ext', 'ParentConfigurations.bin'),
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(binText, 'utf8')]));
const objXml = (u) => `<?xml version="1.0" encoding="UTF-8"?>\n<MetaDataObject><Catalog uuid="${u}"></Catalog></MetaDataObject>`;
writeFileSync(join(ROOT, 'Configuration.xml'), objXml(U.root));
for (const [k, u] of [['Locked', U.locked], ['Editable', U.edit], ['Removed', U.removed], ['Free', U.free]]) {
writeFileSync(join(ROOT, 'Catalogs', k + '.xml'), objXml(u));
}
const rLocked = decideSupport(join(ROOT, 'Catalogs', 'Locked.xml'), 'editable');
check('synth locked (f1=0) → blocked', rLocked.blocked === true, JSON.stringify(rLocked));
check('synth locked reason mentions замке', /замке/.test(rLocked.reason));
const rEdit = decideSupport(join(ROOT, 'Catalogs', 'Editable.xml'), 'editable');
check('synth editable (f1=1) → NOT blocked', rEdit.blocked === false, JSON.stringify(rEdit));
const rRemoved = decideSupport(join(ROOT, 'Catalogs', 'Removed.xml'), 'editable');
check('synth removed (f1=2) → NOT blocked', rRemoved.blocked === false, JSON.stringify(rRemoved));
const rFree = decideSupport(join(ROOT, 'Catalogs', 'Free.xml'), 'editable');
check('synth free (not in bin) → NOT blocked', rFree.blocked === false, JSON.stringify(rFree));
// meta-remove semantics: deletion needs f1=2 (removed from support).
const rmLocked = decideSupport(join(ROOT, 'Catalogs', 'Locked.xml'), 'removed');
check('synth remove locked (f1=0) → blocked', rmLocked.blocked === true, JSON.stringify(rmLocked));
const rmRemoved = decideSupport(join(ROOT, 'Catalogs', 'Removed.xml'), 'removed');
check('synth remove removed (f1=2) → NOT blocked', rmRemoved.blocked === false, JSON.stringify(rmRemoved));
}
console.log('=== project: reaction modes ===');
{
const m = getEditMode(ACC, REPO);
check('getEditMode default → deny', m === 'deny', m);
const s = getSuggesterMode(ACC, REPO);
check('getSuggesterMode default → on', s === 'on', s);
}
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.
if (existsSync(join(ACC, 'Configuration.xml'))) {
const r = edit(join(ACC, 'Configuration.xml'));
let d = null; try { d = JSON.parse(r.stdout); } catch { /* */ }
check('guard acc (G=1) → deny JSON', d?.hookSpecificOutput?.permissionDecision === 'deny', r.stdout);
}
const rLocked = edit(join(SYNTH, 'Catalogs', 'Locked.xml'));
let dLocked = null; try { dLocked = JSON.parse(rLocked.stdout); } catch { /* */ }
check('guard synth locked → deny JSON', dLocked?.hookSpecificOutput?.permissionDecision === 'deny', rLocked.stdout);
check('guard deny reason has safe paths', /cfe-\*/.test(dLocked?.hookSpecificOutput?.permissionDecisionReason || ''));
// allow: erp + synth editable + non-config file → empty stdout, exit 0.
const rEdit = edit(join(SYNTH, 'Catalogs', 'Editable.xml'));
check('guard synth editable → allow (no stdout)', rEdit.stdout === '' && rEdit.exitCode === 0, JSON.stringify(rEdit));
const rReadme = edit(join(REPO, 'README.md'));
check('guard non-config → allow', rReadme.stdout === '' && rReadme.exitCode === 0);
// MultiEdit array form.
const rMulti = guard({ tool_name: 'MultiEdit', cwd: REPO,
tool_input: { file_edits: [{ file_path: join(SYNTH, 'Catalogs', 'Editable.xml') }, { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') }] } });
let dMulti = null; try { dMulti = JSON.parse(rMulti.stdout); } catch { /* */ }
check('guard MultiEdit (one locked) → deny', dMulti?.hookSpecificOutput?.permissionDecision === 'deny', rMulti.stdout);
// warn / off via local .v8-project.json in the synth root.
writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ editingAllowedCheck: 'warn' }));
const rWarn = edit(join(SYNTH, 'Catalogs', 'Locked.xml'), SYNTH);
check('guard warn → allow + stderr note', rWarn.stdout === '' && /ПРЕДУПРЕЖДЕНИЕ/.test(rWarn.stderr), JSON.stringify(rWarn));
writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ editingAllowedCheck: 'off' }));
const rOff = edit(join(SYNTH, 'Catalogs', 'Locked.xml'), SYNTH);
check('guard off → silent allow', rOff.stdout === '' && rOff.stderr === '', JSON.stringify(rOff));
// Real stdin→stdout wiring through node (deny path, default project).
try {
const payload = JSON.stringify({ tool_name: 'Edit', cwd: REPO, tool_input: { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') } });
// remove the off-mode project file so default deny applies
writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ editingAllowedCheck: 'deny' }));
const out = execFileSync(process.execPath, [join(REPO, 'hooks', 'support-guard.mjs')], { input: payload, encoding: 'utf8' });
const d = JSON.parse(out);
check('guard via stdin subprocess → deny JSON', d?.hookSpecificOutput?.permissionDecision === 'deny', out);
} catch (e) {
check('guard via stdin subprocess → deny JSON', false, String(e));
}
}
console.log('=== skill-suggester: PostToolUse nudge ===');
{
const SYNTH = join(REPO, 'test-tmp', 'hooks-synth');
const THR = join(REPO, 'test-tmp', 'hooks-throttle');
rmSync(THR, { recursive: true, force: true });
mkdirSync(THR, { recursive: true });
// suggester reads skillSuggester from .v8-project.json; clear synth project file → default on
rmSync(join(SYNTH, '.v8-project.json'), { force: true });
// sniff fixtures
mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext'), { recursive: true });
mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext'), { recursive: true });
mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext'), { recursive: true });
mkdirSync(join(SYNTH, 'Roles', 'R', 'Ext'), { recursive: true });
mkdirSync(join(SYNTH, 'ext'), { recursive: true });
writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext', 'Form.xml'), '<?xml version="1.0"?><Form/>');
writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'),
'<?xml version="1.0"?>\n<document xmlns="http://v8.1c.ru/8.2/data/spreadsheet"></document>');
writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'),
'<?xml version="1.0"?>\n<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data/data-composition-system/schema"></DataCompositionSchema>');
writeFileSync(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), '<?xml version="1.0"?><Rights/>');
writeFileSync(join(SYNTH, 'ext', 'Configuration.xml'),
'<?xml version="1.0"?>\n<MetaDataObject><Configuration uuid="x"><Properties><ConfigurationExtensionPurpose>Customization</ConfigurationExtensionPurpose></Properties></Configuration></MetaDataObject>');
const read = (fp, session = 's1', tool = 'Read') => suggest({ tool_name: tool, session_id: session, cwd: REPO, tool_input: { file_path: fp } }, { throttleDir: THR });
const grp = (r) => { try { return JSON.parse(r.stdout)?.hookSpecificOutput?.additionalContext; } catch { return null; } };
const rMeta = read(join(SYNTH, 'Catalogs', 'Locked.xml'), 'A');
check('suggest Catalogs/X.xml → meta nudge', /meta-info/.test(grp(rMeta) || ''), rMeta.stdout);
const rMeta2 = read(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group
check('suggest second meta same session → silent (throttle)', rMeta2.stdout === '', rMeta2.stdout);
const rForm = read(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext', 'Form.xml'), 'A');
check('suggest Form.xml (diff group, same session) → form nudge', /form-info/.test(grp(rForm) || ''), rForm.stdout);
const rMxl = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'), 'B');
check('suggest spreadsheet Template → mxl', /mxl-/.test(grp(rMxl) || ''), rMxl.stdout);
const rSkd = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), 'B');
check('suggest DCS Template → skd', /skd-/.test(grp(rSkd) || ''), rSkd.stdout);
const rRole = read(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), 'B');
check('suggest Rights.xml → role', /role-/.test(grp(rRole) || ''), rRole.stdout);
const rCf = read(join(ACC, 'Configuration.xml'), 'C');
check('suggest base Configuration.xml → cf', /cf-info/.test(grp(rCf) || ''), rCf.stdout);
const rCfe = read(join(SYNTH, 'ext', 'Configuration.xml'), 'C');
check('suggest extension Configuration.xml → cfe', /cfe-/.test(grp(rCfe) || ''), rCfe.stdout);
// blind spots
const rBsl = read(join(SYNTH, 'Catalogs', 'Obj', 'Ext', 'ObjectModule.bsl'), 'D');
check('suggest .bsl → silent', rBsl.stdout === '', rBsl.stdout);
const rReadme = read(join(REPO, 'README.md'), 'D');
check('suggest non-1C file → silent', rReadme.stdout === '', rReadme.stdout);
// Grep/Glob search
const rGrep = suggest({ tool_name: 'Grep', session_id: 'E', cwd: REPO, tool_input: { path: join(ACC, 'Catalogs'), pattern: 'foo' } }, { throttleDir: THR });
check('suggest Grep under Catalogs → search nudge', /\*-info/.test(grp(rGrep) || ''), rGrep.stdout);
// skillSuggester off
writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ skillSuggester: 'off' }));
const rOff = suggest({ tool_name: 'Read', session_id: 'F', cwd: SYNTH, tool_input: { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') } }, { throttleDir: THR });
check('suggest skillSuggester=off → silent', rOff.stdout === '', rOff.stdout);
rmSync(join(SYNTH, '.v8-project.json'), { force: true });
}
console.log(`\n${fail === 0 ? 'ALL OK' : 'FAILURES'}: ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);