feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones

This commit is contained in:
hh
2026-08-30 18:16:15 +02:00
parent 6f8711c1a8
commit b1f3fc93ed
11 changed files with 1186 additions and 47 deletions
+41 -8
View File
@@ -67,6 +67,7 @@ MUTATING = frozenset(
}
)
COPYING = frozenset({"cp", "install", "rsync"})
DELETING = frozenset({"rm", "rmdir", "unlink", "shred", "dd", "truncate"})
INPLACE = frozenset({"sed", "perl"})
REDIRECTS = frozenset({">", ">>", ">|", "&>", "&>>", ">&"})
SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
@@ -74,18 +75,36 @@ SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
@dataclass(frozen=True, slots=True)
class Zones:
"""Зоны vault: ``write`` - полная запись, ``create`` - только новые файлы."""
"""Зоны vault.
``write`` - всё, включая удаление; ``create`` - только новые файлы;
``edit`` - создавать, править, переносить можно, удалять - нет;
``protected`` - не трогать. Приоритет: write > protected > create > edit;
путь в vault вне всех зон - отказ. Vault целиком в ``edit`` - это
«дом» диспетчера (решение h, 2026-08-30): заметки, задачи, люди - его;
удаление чужого и `.obsidian`/`мета` остаются за Бобром.
"""
vault: Path
write: tuple[Path, ...]
create: tuple[Path, ...]
create: tuple[Path, ...] = ()
edit: tuple[Path, ...] = ()
protected: tuple[Path, ...] = ()
def verdict(self, path: Path, *, creating: bool) -> Deny | None:
def verdict(
self, path: Path, *, creating: bool, deleting: bool = False
) -> Deny | None:
if not _under(path, self.vault):
return None
if any(_under(path, zone) for zone in self.write):
return None
rel = path.relative_to(self.vault)
for zone in self.protected:
if _under(path, zone):
return Deny(
reason=f"{rel}: «{zone.relative_to(self.vault)}» не трогаем - "
"это зона Бобра"
)
for zone in self.create:
if _under(path, zone):
if creating and not path.exists():
@@ -94,6 +113,17 @@ class Zones:
reason=f"{rel}: в «{zone.relative_to(self.vault)}» можно только "
"создавать новые файлы, существующие не трогаем"
)
for zone in self.edit:
if _under(path, zone):
if deleting:
zones = ", ".join(
f"«{z.relative_to(self.vault)}»" for z in self.write
)
return Deny(
reason=f"{rel}: удалять можно только в {zones}; чужие "
"заметки не удаляем - скажи Бобру, он сам"
)
return None
zones = ", ".join(f"«{z.relative_to(self.vault)}»" for z in self.write)
return Deny(reason=f"{rel}: vault только на чтение; писать можно в {zones}")
@@ -125,8 +155,10 @@ def bash_zones(zones: Zones) -> PolicyRule:
command = call.input.get("command")
if not isinstance(command, str):
return None
for target, creating in _bash_targets(command):
deny = zones.verdict(call.resolve(target), creating=creating)
for target, creating, deleting in _bash_targets(command):
deny = zones.verdict(
call.resolve(target), creating=creating, deleting=deleting
)
if deny is not None:
return Deny(reason=f"Bash: {deny.reason}")
return None
@@ -134,7 +166,8 @@ def bash_zones(zones: Zones) -> PolicyRule:
return rule
def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
def _bash_targets(command: str) -> Iterator[tuple[str, bool, bool]]:
"""``(путь, создаёт, удаляет)`` для каждого пути, который команда трогает."""
for segment in _segments(command):
plain: list[str] = []
i = 0
@@ -142,7 +175,7 @@ def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
word = segment[i]
if word in REDIRECTS:
if i + 1 < len(segment) and _pathlike(segment[i + 1]):
yield segment[i + 1], True
yield segment[i + 1], True, False
i += 2
continue
plain.append(word)
@@ -156,7 +189,7 @@ def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
if word in COPYING:
args = args[-1:]
for arg in args:
yield arg, False
yield arg, False, word in DELETING
break