From b17dd5d04cfa862a99a534f0df1716acd1ee69a2 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 20 Jun 2026 15:22:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(mutators):=20=D1=80=D0=B0=D0=B7=D0=BC?= =?UTF-8?q?=D0=BD=D0=BE=D0=B6=D0=B8=D1=82=D1=8C=20support-guard=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B2=D1=8B=D0=BA=D0=B8-=D0=BC=D1=83=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Контракт Assert-EditAllowed (дословная копия из meta-edit, оба порта) добавлен в 13 навыков-мутаторов. Всем единый вызов require=editable — «кого проверять» решает не навык, а walk-up по файловой системе от пути цели: - целевой файл имеет root-uuid → его f1 (правка существующего элемента); - иначе ближайший вверх .xml с uuid → f1 владельца (добавление дочернего: форма/реквизит/макет к объекту); - иначе корень Configuration.xml → f1 корня (новый объект верхнего уровня). Это воспроизводит семантику поддержки 1С автоматически, поэтому один и тот же навык проверяет разное по состоянию дампа: skd-compile/mxl-compile/ form-compile в существующий макет/форму → f1 этого элемента (modify), в несуществующий → f1 владельца. Проверено обоими сценариями. Навыки: form-edit, form-add, form-compile, skd-edit, skd-compile, cf-edit, subsystem-edit, subsystem-compile, interface-edit, template-add, help-add, mxl-compile, role-compile. Диагностика через [Console]::Error.WriteLine + exit 1 (как в эталоне). Версии подняты в обоих портах. Проверено: deny на обоих портах (крафт-фикстуры на копиях, корпус не тронут); все 16 навыков-мутаторов зелёные на PowerShell и Python (264 кейса). BOM сохранён везде. Follow-up (в upload-плане): пред-существующий баг help-add Detect-FormatVersion (Substring на кириллице, до гарда, не связан); авторезолв пути в meta-edit; per-skill committed deny-тесты. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/cf-edit/scripts/cf-edit.ps1 | 110 +++++++++++++- .claude/skills/cf-edit/scripts/cf-edit.py | 142 ++++++++++++++++- .claude/skills/form-add/scripts/form-add.ps1 | 109 ++++++++++++- .claude/skills/form-add/scripts/form-add.py | 141 ++++++++++++++++- .../form-compile/scripts/form-compile.ps1 | 109 ++++++++++++- .../form-compile/scripts/form-compile.py | 141 ++++++++++++++++- .../skills/form-edit/scripts/form-edit.ps1 | 109 ++++++++++++- .claude/skills/form-edit/scripts/form-edit.py | 139 ++++++++++++++++- .claude/skills/help-add/scripts/add-help.ps1 | 110 +++++++++++++- .claude/skills/help-add/scripts/add-help.py | 141 ++++++++++++++++- .../interface-edit/scripts/interface-edit.ps1 | 111 +++++++++++++- .../interface-edit/scripts/interface-edit.py | 141 ++++++++++++++++- .../mxl-compile/scripts/mxl-compile.ps1 | 109 ++++++++++++- .../skills/mxl-compile/scripts/mxl-compile.py | 142 ++++++++++++++++- .../role-compile/scripts/role-compile.ps1 | 109 ++++++++++++- .../role-compile/scripts/role-compile.py | 141 ++++++++++++++++- .../skd-compile/scripts/skd-compile.ps1 | 110 +++++++++++++- .../skills/skd-compile/scripts/skd-compile.py | 142 ++++++++++++++++- .claude/skills/skd-edit/scripts/skd-edit.ps1 | 110 +++++++++++++- .claude/skills/skd-edit/scripts/skd-edit.py | 143 +++++++++++++++++- .../scripts/subsystem-compile.ps1 | 110 +++++++++++++- .../scripts/subsystem-compile.py | 141 ++++++++++++++++- .../subsystem-edit/scripts/subsystem-edit.ps1 | 110 +++++++++++++- .../subsystem-edit/scripts/subsystem-edit.py | 141 ++++++++++++++++- .../template-add/scripts/add-template.ps1 | 110 +++++++++++++- .../template-add/scripts/add-template.py | 141 ++++++++++++++++- 26 files changed, 3236 insertions(+), 26 deletions(-) diff --git a/.claude/skills/cf-edit/scripts/cf-edit.ps1 b/.claude/skills/cf-edit/scripts/cf-edit.ps1 index 5da5fc4f..879dff73 100644 --- a/.claude/skills/cf-edit/scripts/cf-edit.ps1 +++ b/.claude/skills/cf-edit/scripts/cf-edit.ps1 @@ -1,4 +1,4 @@ -# cf-edit v1.4 — Edit 1C configuration root (Configuration.xml) +# cf-edit v1.5 — Edit 1C configuration root (Configuration.xml) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)][Alias('Path')][string]$ConfigPath, @@ -29,6 +29,114 @@ if (-not (Test-Path $ConfigPath)) { Write-Error "File not found: $ConfigPath"; e $resolvedPath = (Resolve-Path $ConfigPath).Path $script:configDir = [System.IO.Path]::GetDirectoryName($resolvedPath) +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + +Assert-EditAllowed $resolvedPath 'editable' + # --- Load XML with PreserveWhitespace --- $script:xmlDoc = New-Object System.Xml.XmlDocument $script:xmlDoc.PreserveWhitespace = $true diff --git a/.claude/skills/cf-edit/scripts/cf-edit.py b/.claude/skills/cf-edit/scripts/cf-edit.py index ccd98c92..40f3b5a7 100644 --- a/.claude/skills/cf-edit/scripts/cf-edit.py +++ b/.claude/skills/cf-edit/scripts/cf-edit.py @@ -1,16 +1,154 @@ #!/usr/bin/env python3 -# cf-edit v1.4 — Edit 1C configuration root (Configuration.xml) +# cf-edit v1.5 — Edit 1C configuration root (Configuration.xml) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json import os +import re import subprocess import sys import uuid as _uuid from html import escape as html_escape from lxml import etree + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + MD_NS = "http://v8.1c.ru/8.3/MDClasses" XR_NS = "http://v8.1c.ru/8.3/xcf/readable" XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" @@ -190,6 +328,8 @@ def main(): resolved_path = os.path.abspath(config_path) config_dir = os.path.dirname(resolved_path) + assert_edit_allowed(resolved_path, "editable") + xml_parser = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, xml_parser) xml_root = tree.getroot() diff --git a/.claude/skills/form-add/scripts/form-add.ps1 b/.claude/skills/form-add/scripts/form-add.ps1 index 8a1a47ff..06358f3f 100644 --- a/.claude/skills/form-add/scripts/form-add.ps1 +++ b/.claude/skills/form-add/scripts/form-add.ps1 @@ -1,4 +1,4 @@ -# form-add v1.5 — Add managed form to 1C config object +# form-add v1.6 — Add managed form to 1C config object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -18,6 +18,112 @@ $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::InputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # --- Detect XML format version --- function Detect-FormatVersion([string]$dir) { @@ -55,6 +161,7 @@ if (-not (Test-Path $ObjectPath)) { } $objectXmlFull = Resolve-Path $ObjectPath +Assert-EditAllowed $objectXmlFull.Path 'editable' $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) $xmlDoc = New-Object System.Xml.XmlDocument diff --git a/.claude/skills/form-add/scripts/form-add.py b/.claude/skills/form-add/scripts/form-add.py index 9d8a1ced..6b025b00 100644 --- a/.claude/skills/form-add/scripts/form-add.py +++ b/.claude/skills/form-add/scripts/form-add.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -# form-add v1.5 — Add managed form to 1C config object +# form-add v1.6 — Add managed form to 1C config object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse +import json import os import re import sys @@ -10,6 +11,143 @@ import uuid from lxml import etree + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + NSMAP = { "md": "http://v8.1c.ru/8.3/MDClasses", "v8": "http://v8.1c.ru/8.1/data/core", @@ -84,6 +222,7 @@ def main(): sys.exit(1) object_xml_full = os.path.abspath(object_path) + assert_edit_allowed(object_xml_full, "editable") format_version = detect_format_version(os.path.dirname(object_xml_full)) parser_xml = etree.XMLParser(remove_blank_text=False) diff --git a/.claude/skills/form-compile/scripts/form-compile.ps1 b/.claude/skills/form-compile/scripts/form-compile.ps1 index 7a1b3c1d..4baa498c 100644 --- a/.claude/skills/form-compile/scripts/form-compile.ps1 +++ b/.claude/skills/form-compile/scripts/form-compile.ps1 @@ -1,4 +1,4 @@ -# form-compile v1.172 — Compile 1C managed form from JSON or object metadata +# form-compile v1.173 — Compile 1C managed form from JSON or object metadata # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [string]$JsonPath, @@ -1347,7 +1347,114 @@ function Detect-FormatVersion([string]$dir) { return "2.17" } +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + $script:outPathResolved = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path (Get-Location) $OutputPath } +Assert-EditAllowed $script:outPathResolved 'editable' $script:formatVersion = Detect-FormatVersion ([System.IO.Path]::GetDirectoryName($script:outPathResolved)) # --- 0. Path normalization and mode dispatch --- diff --git a/.claude/skills/form-compile/scripts/form-compile.py b/.claude/skills/form-compile/scripts/form-compile.py index 8b4ec95c..679e023b 100644 --- a/.claude/skills/form-compile/scripts/form-compile.py +++ b/.claude/skills/form-compile/scripts/form-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# form-compile v1.172 — Compile 1C managed form from JSON or object metadata +# form-compile v1.173 — Compile 1C managed form from JSON or object metadata # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import copy @@ -11,6 +11,144 @@ import uuid import xml.etree.ElementTree as ET from collections import OrderedDict +from lxml import etree + + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + # ═══════════════════════════════════════════════════════════════════════════ # FROM-OBJECT MODE: functions for metadata parsing, presets, DSL generation # ═══════════════════════════════════════════════════════════════════════════ @@ -6033,6 +6171,7 @@ def main(): # --- Detect XML format version --- out_path_resolved = args.OutputPath if os.path.isabs(args.OutputPath) else os.path.join(os.getcwd(), args.OutputPath) + assert_edit_allowed(out_path_resolved, "editable") format_version = detect_format_version(os.path.dirname(out_path_resolved)) # --- 0. From-object mode --- diff --git a/.claude/skills/form-edit/scripts/form-edit.ps1 b/.claude/skills/form-edit/scripts/form-edit.ps1 index a38ceb1e..aacc70f6 100644 --- a/.claude/skills/form-edit/scripts/form-edit.ps1 +++ b/.claude/skills/form-edit/scripts/form-edit.ps1 @@ -1,4 +1,4 @@ -# form-edit v1.1 — Edit 1C managed form elements +# form-edit v1.2 — Edit 1C managed form elements # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -12,6 +12,112 @@ param( $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # === 1. Load Form.xml === if (-not (Test-Path $FormPath)) { @@ -24,6 +130,7 @@ if (-not (Test-Path $JsonPath)) { } $resolvedFormPath = (Resolve-Path $FormPath).Path +Assert-EditAllowed $resolvedFormPath 'editable' $xmlDoc = New-Object System.Xml.XmlDocument $xmlDoc.PreserveWhitespace = $true try { diff --git a/.claude/skills/form-edit/scripts/form-edit.py b/.claude/skills/form-edit/scripts/form-edit.py index 474c6cfb..2b3178ed 100644 --- a/.claude/skills/form-edit/scripts/form-edit.py +++ b/.claude/skills/form-edit/scripts/form-edit.py @@ -1,4 +1,4 @@ -# form-edit v1.1 — Edit 1C managed form elements (Python port) +# form-edit v1.2 — Edit 1C managed form elements (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -11,6 +11,142 @@ from lxml import etree sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + # ── arg parsing ────────────────────────────────────────────── parser = argparse.ArgumentParser(allow_abbrev=False) @@ -63,6 +199,7 @@ if not os.path.exists(json_path): sys.exit(1) resolved_form_path = os.path.abspath(form_path) +assert_edit_allowed(resolved_form_path, "editable") xml_parser = etree.XMLParser(remove_blank_text=False) try: tree = etree.parse(resolved_form_path, xml_parser) diff --git a/.claude/skills/help-add/scripts/add-help.ps1 b/.claude/skills/help-add/scripts/add-help.ps1 index e848968a..d8885330 100644 --- a/.claude/skills/help-add/scripts/add-help.ps1 +++ b/.claude/skills/help-add/scripts/add-help.ps1 @@ -1,4 +1,4 @@ -# help-add v1.4 — Add built-in help to 1C object +# help-add v1.5 — Add built-in help to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -13,6 +13,112 @@ $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::InputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # --- Detect format version --- function Detect-FormatVersion([string]$dir) { @@ -48,6 +154,8 @@ if (Test-Path $helpXmlPath) { exit 1 } +Assert-EditAllowed $objectDir 'editable' + # --- Кодировка --- $encBom = New-Object System.Text.UTF8Encoding($true) diff --git a/.claude/skills/help-add/scripts/add-help.py b/.claude/skills/help-add/scripts/add-help.py index d99cc4f8..f9ef108d 100644 --- a/.claude/skills/help-add/scripts/add-help.py +++ b/.claude/skills/help-add/scripts/add-help.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -# add-help v1.4 — Add built-in help to 1C object +# add-help v1.5 — Add built-in help to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse +import json import os import re import sys @@ -12,6 +13,142 @@ from lxml import etree NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"} +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + def detect_format_version(d): while d: cfg_path = os.path.join(d, "Configuration.xml") @@ -74,6 +211,8 @@ def main(): print(f"Справка уже существует: {help_xml_path}", file=sys.stderr) sys.exit(1) + assert_edit_allowed(object_dir, "editable") + # --- 1. Help.xml --- help_xml = ( diff --git a/.claude/skills/interface-edit/scripts/interface-edit.ps1 b/.claude/skills/interface-edit/scripts/interface-edit.ps1 index 8a24551f..3807390d 100644 --- a/.claude/skills/interface-edit/scripts/interface-edit.ps1 +++ b/.claude/skills/interface-edit/scripts/interface-edit.ps1 @@ -1,4 +1,4 @@ -# interface-edit v1.3 — Edit 1C CommandInterface.xml +# interface-edit v1.4 — Edit 1C CommandInterface.xml # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)][Alias('Path')][string]$CIPath, @@ -23,6 +23,115 @@ if (-not [System.IO.Path]::IsPathRooted($CIPath)) { } $resolvedPath = $CIPath +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. Walk-up from Subsystems/X/Ext/ +# CommandInterface.xml reaches Subsystems/X.xml (owning subsystem uuid). +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + +Assert-EditAllowed $CIPath 'editable' + # --- Detect format version --- function Detect-FormatVersion([string]$dir) { diff --git a/.claude/skills/interface-edit/scripts/interface-edit.py b/.claude/skills/interface-edit/scripts/interface-edit.py index 23a279c0..92068f59 100644 --- a/.claude/skills/interface-edit/scripts/interface-edit.py +++ b/.claude/skills/interface-edit/scripts/interface-edit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# interface-edit v1.3 — Edit 1C CommandInterface.xml +# interface-edit v1.4 — Edit 1C CommandInterface.xml # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -10,6 +10,143 @@ import subprocess import sys from lxml import etree + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + def detect_format_version(d): while d: cfg_path = os.path.join(d, "Configuration.xml") @@ -208,6 +345,8 @@ def main(): ci_path = os.path.join(os.getcwd(), ci_path) resolved_path = ci_path + assert_edit_allowed(ci_path, "editable") + # --- Create if missing --- if not os.path.isfile(ci_path): if args.CreateIfMissing: diff --git a/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 b/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 index a5338345..f3e3ab83 100644 --- a/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 +++ b/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 @@ -1,4 +1,4 @@ -# mxl-compile v1.1 — Compile 1C spreadsheet from JSON +# mxl-compile v1.2 — Compile 1C spreadsheet from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -11,6 +11,112 @@ param( $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # --- 1. Load and validate JSON --- if (-not (Test-Path $JsonPath)) { @@ -720,6 +826,7 @@ X '' $enc = New-Object System.Text.UTF8Encoding($true) $resolvedPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path (Get-Location) $OutputPath } +Assert-EditAllowed $resolvedPath 'editable' [System.IO.File]::WriteAllText($resolvedPath, $xml.ToString(), $enc) # --- 9. Summary --- diff --git a/.claude/skills/mxl-compile/scripts/mxl-compile.py b/.claude/skills/mxl-compile/scripts/mxl-compile.py index 9253cfb0..a3a40924 100644 --- a/.claude/skills/mxl-compile/scripts/mxl-compile.py +++ b/.claude/skills/mxl-compile/scripts/mxl-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# mxl-compile v1.1 — Compile 1C spreadsheet from JSON +# mxl-compile v1.2 — Compile 1C spreadsheet from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -8,6 +8,144 @@ import os import re import sys +from lxml import etree + + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + def esc_xml(s): return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') @@ -616,6 +754,8 @@ def main(): if not os.path.isabs(out_path): out_path = os.path.join(os.getcwd(), out_path) + assert_edit_allowed(out_path, "editable") + out_dir = os.path.dirname(out_path) if out_dir and not os.path.exists(out_dir): os.makedirs(out_dir, exist_ok=True) diff --git a/.claude/skills/role-compile/scripts/role-compile.ps1 b/.claude/skills/role-compile/scripts/role-compile.ps1 index c193a267..d655820d 100644 --- a/.claude/skills/role-compile/scripts/role-compile.ps1 +++ b/.claude/skills/role-compile/scripts/role-compile.ps1 @@ -1,4 +1,4 @@ -# role-compile v1.5 — Compile 1C role from JSON +# role-compile v1.6 — Compile 1C role from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -11,6 +11,112 @@ param( $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # --- 1. Load and validate JSON --- if (-not (Test-Path $JsonPath)) { @@ -523,6 +629,7 @@ function Detect-FormatVersion([string]$dir) { } $resolvedOutputDir = if ([System.IO.Path]::IsPathRooted($OutputDir)) { $OutputDir } else { Join-Path (Get-Location) $OutputDir } +Assert-EditAllowed $resolvedOutputDir 'editable' $formatVersion = Detect-FormatVersion $resolvedOutputDir # --- 8. Generate UUID --- diff --git a/.claude/skills/role-compile/scripts/role-compile.py b/.claude/skills/role-compile/scripts/role-compile.py index 826fb2c9..1993aef2 100644 --- a/.claude/skills/role-compile/scripts/role-compile.py +++ b/.claude/skills/role-compile/scripts/role-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# role-compile v1.4 — Compile 1C role from JSON +# role-compile v1.5 — Compile 1C role from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -8,6 +8,144 @@ import re import sys import uuid +from lxml import etree + + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + def detect_format_version(d): while d: @@ -476,6 +614,7 @@ def main(): defn['objects'] = defn['rights'] out_dir_resolved = args.OutputDir if os.path.isabs(args.OutputDir) else os.path.join(os.getcwd(), args.OutputDir) + assert_edit_allowed(out_dir_resolved, "editable") format_version = detect_format_version(out_dir_resolved) # --- 2. Parse all object entries --- diff --git a/.claude/skills/skd-compile/scripts/skd-compile.ps1 b/.claude/skills/skd-compile/scripts/skd-compile.ps1 index 3e33c149..6240e4b2 100644 --- a/.claude/skills/skd-compile/scripts/skd-compile.ps1 +++ b/.claude/skills/skd-compile/scripts/skd-compile.ps1 @@ -1,4 +1,4 @@ -# skd-compile v1.105 — Compile 1C DCS from JSON +# skd-compile v1.106 — Compile 1C DCS from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [string]$DefinitionFile, @@ -10,6 +10,112 @@ param( $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # --- 1. Load and validate JSON --- if ($DefinitionFile -and $Value) { @@ -3539,6 +3645,8 @@ X '' # --- 13. Write output --- +Assert-EditAllowed $OutputPath 'editable' + $parentDir = [System.IO.Path]::GetDirectoryName($OutputPath) if ($parentDir -and -not (Test-Path $parentDir)) { New-Item -ItemType Directory -Force $parentDir | Out-Null diff --git a/.claude/skills/skd-compile/scripts/skd-compile.py b/.claude/skills/skd-compile/scripts/skd-compile.py index 09da5e66..a90bdbb1 100644 --- a/.claude/skills/skd-compile/scripts/skd-compile.py +++ b/.claude/skills/skd-compile/scripts/skd-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# skd-compile v1.105 — Compile 1C DCS from JSON +# skd-compile v1.106 — Compile 1C DCS from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -8,6 +8,144 @@ import re import sys import uuid +from lxml import etree + + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + def esc_xml(s): return s.replace('&', '&').replace('<', '<').replace('>', '>') @@ -2878,6 +3016,8 @@ def main(): if not os.path.isabs(output_path): output_path = os.path.join(os.getcwd(), output_path) + assert_edit_allowed(output_path, "editable") + parent_dir = os.path.dirname(output_path) if parent_dir and not os.path.exists(parent_dir): os.makedirs(parent_dir, exist_ok=True) diff --git a/.claude/skills/skd-edit/scripts/skd-edit.ps1 b/.claude/skills/skd-edit/scripts/skd-edit.ps1 index ff4d9086..4cd00cbf 100644 --- a/.claude/skills/skd-edit/scripts/skd-edit.ps1 +++ b/.claude/skills/skd-edit/scripts/skd-edit.ps1 @@ -1,4 +1,4 @@ -# skd-edit v1.25 — Atomic 1C DCS editor +# skd-edit v1.26 — Atomic 1C DCS editor # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -48,6 +48,114 @@ if (-not (Test-Path $TemplatePath)) { $resolvedPath = (Resolve-Path $TemplatePath).Path +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + +Assert-EditAllowed $resolvedPath 'editable' + function Esc-Xml { param([string]$s) return $s.Replace('&','&').Replace('<','<').Replace('>','>') diff --git a/.claude/skills/skd-edit/scripts/skd-edit.py b/.claude/skills/skd-edit/scripts/skd-edit.py index 6f574b4c..7b81b0a5 100644 --- a/.claude/skills/skd-edit/scripts/skd-edit.py +++ b/.claude/skills/skd-edit/scripts/skd-edit.py @@ -1,6 +1,7 @@ -# skd-edit v1.25 — Atomic 1C DCS editor (Python port) +# skd-edit v1.26 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse +import json import os import re import sys @@ -118,6 +119,146 @@ if not os.path.exists(template_path): sys.exit(1) resolved_path = os.path.abspath(template_path) + + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + +assert_edit_allowed(resolved_path, "editable") + query_base_dir = os.path.dirname(resolved_path) # ── 2. Type system ────────────────────────────────────────── diff --git a/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 b/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 index 071aacaa..375e0de3 100644 --- a/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 +++ b/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 @@ -1,4 +1,4 @@ -# subsystem-compile v1.5 — Create 1C subsystem from JSON definition +# subsystem-compile v1.6 — Create 1C subsystem from JSON definition # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [string]$DefinitionFile, @@ -48,6 +48,114 @@ if (-not [System.IO.Path]::IsPathRooted($OutputDir)) { $OutputDir = Join-Path (Get-Location).Path $OutputDir } +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + +Assert-EditAllowed $OutputDir 'editable' + # --- 2. XML helpers --- $script:xml = New-Object System.Text.StringBuilder 8192 diff --git a/.claude/skills/subsystem-compile/scripts/subsystem-compile.py b/.claude/skills/subsystem-compile/scripts/subsystem-compile.py index bdabb4e1..15ba05a7 100644 --- a/.claude/skills/subsystem-compile/scripts/subsystem-compile.py +++ b/.claude/skills/subsystem-compile/scripts/subsystem-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# subsystem-compile v1.5 — Create 1C subsystem from JSON definition +# subsystem-compile v1.6 — Create 1C subsystem from JSON definition # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -8,6 +8,143 @@ import re import sys import uuid import xml.etree.ElementTree as ET +from lxml import etree + + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return def detect_format_version(d): @@ -146,6 +283,8 @@ def main(): if not os.path.isabs(output_dir): output_dir = os.path.join(os.getcwd(), output_dir) + assert_edit_allowed(output_dir, "editable") + # --- 2. Content type normalization (plural→singular, Russian→English) --- CONTENT_TYPE_MAP = { # Plural English → Singular diff --git a/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 b/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 index 0f29762a..2e3be34b 100644 --- a/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 +++ b/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 @@ -1,4 +1,4 @@ -# subsystem-edit v1.2 — Edit existing 1C subsystem XML +# subsystem-edit v1.3 — Edit existing 1C subsystem XML # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)][Alias('Path')][string]$SubsystemPath, @@ -121,6 +121,114 @@ if (-not (Test-Path $SubsystemPath)) { Write-Error "File not found: $SubsystemPa $resolvedPath = (Resolve-Path $SubsystemPath).Path $script:resolvedPath = $resolvedPath +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + +Assert-EditAllowed $resolvedPath 'editable' + # --- Load XML with PreserveWhitespace --- $script:xmlDoc = New-Object System.Xml.XmlDocument $script:xmlDoc.PreserveWhitespace = $true diff --git a/.claude/skills/subsystem-edit/scripts/subsystem-edit.py b/.claude/skills/subsystem-edit/scripts/subsystem-edit.py index abe3776f..a9f5242f 100644 --- a/.claude/skills/subsystem-edit/scripts/subsystem-edit.py +++ b/.claude/skills/subsystem-edit/scripts/subsystem-edit.py @@ -1,16 +1,153 @@ #!/usr/bin/env python3 -# subsystem-edit v1.2 — Edit existing 1C subsystem XML +# subsystem-edit v1.3 — Edit existing 1C subsystem XML # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json import os +import re import subprocess import sys import uuid from lxml import etree +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + + def new_uuid(): return str(uuid.uuid4()) @@ -316,6 +453,8 @@ def main(): resolved_path = os.path.abspath(subsystem_path) + assert_edit_allowed(resolved_path, "editable") + # --- Load XML --- xml_parser = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, xml_parser) diff --git a/.claude/skills/template-add/scripts/add-template.ps1 b/.claude/skills/template-add/scripts/add-template.ps1 index 5e1534e9..2557db0c 100644 --- a/.claude/skills/template-add/scripts/add-template.ps1 +++ b/.claude/skills/template-add/scripts/add-template.ps1 @@ -1,4 +1,4 @@ -# template-add v1.5 — Add template to 1C object +# template-add v1.6 — Add template to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -23,6 +23,112 @@ $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::InputEncoding = [System.Text.Encoding]::UTF8 +# --- Support guard (Ext/ParentConfigurations.bin) --- +# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / +# read-only configs unless allowed. Trigger = bin present; reaction from +# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never +# throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $reason = "" + if ($G -eq 1) { $blocked = $true; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + $msg = "[support-guard] Операция запрещена: $reason.`n Цель: $rp`n Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.`n Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json." + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + [Console]::Error.WriteLine($msg) + exit 1 + } catch { return } +} + # --- Маппинг типов --- $typeMap = @{ @@ -73,6 +179,8 @@ if (Test-Path $templateMetaPath) { exit 1 } +Assert-EditAllowed $rootXmlPath 'editable' + # --- Создание каталогов --- $templateExtDir = Join-Path (Join-Path $templatesDir $TemplateName) "Ext" diff --git a/.claude/skills/template-add/scripts/add-template.py b/.claude/skills/template-add/scripts/add-template.py index 1c636047..88607277 100644 --- a/.claude/skills/template-add/scripts/add-template.py +++ b/.claude/skills/template-add/scripts/add-template.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -# add-template v1.5 — Add template to 1C object +# add-template v1.6 — Add template to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse +import json import os import re import sys @@ -12,6 +13,142 @@ from lxml import etree NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"} + +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def _sg_get_edit_mode(cfg_dir): + try: + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" + + +def assert_edit_allowed(target_path, require): + try: + rp = os.path.abspath(target_path) + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + reason = "" + if g == 1: + blocked = True + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + reason = "объект на поддержке (не снят с поддержки) — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + reason = "объект на замке (поддержка поставщика) — прямая правка сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + sys.stderr.write( + f"[support-guard] Операция запрещена: {reason}.\n" + f" Цель: {rp}\n" + f" Безопасные пути: доработка через расширение (cfe-*); включить редактирование объекта/корня в конфигураторе; снять с поддержки.\n" + f" Отключить проверку: editingAllowedCheck = warn|off в .v8-project.json.\n" + ) + sys.exit(1) + except SystemExit: + raise + except Exception: + return + TYPE_MAP = { "HTML": {"TemplateType": "HTMLDocument", "Ext": ".html"}, "Text": {"TemplateType": "TextDocument", "Ext": ".txt"}, @@ -116,6 +253,8 @@ def main(): print(f"Макет уже существует: {template_meta_path}", file=sys.stderr) sys.exit(1) + assert_edit_allowed(root_xml_path, "editable") + # --- Create directories --- template_ext_dir = os.path.join(templates_dir, template_name, "Ext")