Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1f7b4ed
build(pii): add [readi] and [bench] extras
illeatmyhat Jul 20, 2026
81ecd48
feat(pii): READI semantic redaction as a hook-seam plugin (#275)
illeatmyhat Jul 20, 2026
347cb2e
feat(pii): effectiveness benchmark for regex vs semantic redaction
illeatmyhat Jul 20, 2026
b8c2bcb
test(pii): cover the READI core, shim and payload contract
illeatmyhat Jul 20, 2026
faa5c2f
docs(pii): regex vs semantic guide and YAML switch example
illeatmyhat Jul 20, 2026
8166c6e
fix(pii): drop out-of-range spans after clamping in redact_spans
illeatmyhat Jul 21, 2026
f73ba01
fix(pii): redact PII in tool_calls and other non-content message fields
illeatmyhat Jul 21, 2026
23301c2
refactor(pii)!: rename extras by method (pii-regex/pii-semantic); ali…
illeatmyhat Jul 21, 2026
30267db
docs(pii): note the benchmark value-leak metric is a lower bound
illeatmyhat Jul 21, 2026
24cdb93
feat(hooks)!: always-on seam with fail-closed engine/detector errors
illeatmyhat Jul 21, 2026
1baa641
feat(cli): add `evolve hooks init` + config auto-discovery
illeatmyhat Jul 21, 2026
09e44c0
docs(hooks): document always-on seam, discovery, and `hooks init`
illeatmyhat Jul 21, 2026
274627b
fix(hooks): code-first plugins suppress config discovery
illeatmyhat Jul 22, 2026
7786c3f
merge: bring main (retention #294) into feat/275-readi-semantic-pii; …
illeatmyhat Jul 23, 2026
8607c98
refactor(hooks)!: remove deprecated enabled field
illeatmyhat Jul 23, 2026
e0f50a7
fix(cli): stop rich from mangling extras in hooks init output
illeatmyhat Jul 23, 2026
5af6581
refactor(hooks)!: decouple hook plugins from cpex via native contract…
illeatmyhat Jul 23, 2026
5f75a7b
fix(hooks): default YAML-omitted plugin mode to sequential (fail-closed)
illeatmyhat Jul 23, 2026
73910dc
feat(hooks): add SecretsFilterMemoryPlugin — structured-secrets redac…
illeatmyhat Jul 23, 2026
7833816
refactor(hooks): make SecretsFilterMemoryPlugin native, not raw-cpex
illeatmyhat Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions altk_evolve/cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Evolve CLI for managing entities and namespaces."""

import importlib.resources
import json
import platform
import sys
import zipfile
from pathlib import Path
Expand All @@ -24,13 +26,15 @@
sync_app = typer.Typer(help="Sync commands")
skills_app = typer.Typer(help="Skill management commands")
viz_app = typer.Typer(help="Visualization commands")
hooks_app = typer.Typer(help="Hook seam management commands")
retention_app = typer.Typer(help="Data retention commands")

app.add_typer(namespaces_app, name="namespaces")
app.add_typer(entities_app, name="entities")
app.add_typer(sync_app, name="sync")
app.add_typer(skills_app, name="skills")
app.add_typer(viz_app, name="viz")
app.add_typer(hooks_app, name="hooks")
app.add_typer(retention_app, name="retention")

console = Console()
Expand Down Expand Up @@ -698,5 +702,69 @@ def serve_viz(
serve(evolve_dir=evolve_dir.resolve(), port=port, open_browser=not no_browser)


# =============================================================================
# Hooks Commands
# =============================================================================


def _load_hooks_template() -> str:
"""Read the bundled default hooks config template (READI active, regex
commented). Packaged as data so `evolve hooks init` works from an install."""
return importlib.resources.files("altk_evolve.cli.templates").joinpath("hooks.yaml").read_text(encoding="utf-8")


def hooks_init_platform_note(system: str) -> str:
"""Platform-specific guidance printed after `evolve hooks init`.

``system`` is ``platform.system()`` (e.g. "Darwin", "Linux", "Windows").
Kept as a pure function so the macOS vs non-macOS message can be unit-tested
without spoofing the host OS.
"""
if system == "Darwin":
return (
"macOS note: READI's transformer model runs on Apple-Silicon MPS, which binds to the "
"first thread that touches it. The hook seam dispatches on a worker thread when an event "
"loop is already running, so the model can raise 'Placeholder storage has not been "
"allocated on MPS device!' and — because it is fail-closed (on_error: fail) — BLOCK writes. "
"For local dev on macOS, uncomment the regex block (and comment READI), or run READI on "
"CPU/Linux. See docs/guides/pii-redaction.md 'Known limitations'."
)
return "Once '[pii-semantic]' is installed, READI works out of the box (weights download on first use)."


@hooks_app.command("init")
def hooks_init(
path: Annotated[Path, typer.Option("--path", "-p", help="Where to write the hooks config")] = Path("evolve.hooks.yaml"),
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite an existing file")] = False,
):
"""Scaffold a default hooks config (`./evolve.hooks.yaml`).

The scaffolded file ships the READI SEMANTIC PII plugin ACTIVE and the regex
PII plugin commented out (both `mode: sequential`, `on_error: fail`). Evolve
auto-discovers `./evolve.hooks.yaml`, so no further wiring is needed.
"""
if path.exists() and not force:
console.print(f"[red]Refusing to overwrite existing file:[/red] {path}")
console.print("[yellow]Pass --force to overwrite.[/yellow]")
raise typer.Exit(1)

try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_load_hooks_template(), encoding="utf-8")
except OSError as e:
console.print(f"[red]Could not write {path}: {e}[/red]")
raise typer.Exit(1)

console.print(f"[green]Wrote hooks config:[/green] {path}")
console.print("[dim]Evolve auto-discovers ./evolve.hooks.yaml — no further wiring needed.[/dim]\n")
console.print("[bold]READI semantic PII redaction is enabled by default.[/bold] Install it with:")
# markup=False so the "[pii-semantic]" extra is not parsed as rich markup, and
# highlight=False so rich's bracket *highlighter* does not split the extra with
# ANSI color codes under a TTY — the extras must render literally everywhere.
console.print(" pip install 'altk-evolve[pii-semantic]'", markup=False, highlight=False)
console.print("[dim](the NER model downloads on first use, ~460MB for en_core_web_trf)[/dim]\n")
console.print(hooks_init_platform_note(platform.system()), style="yellow", markup=False, highlight=False)


if __name__ == "__main__":
app()
1 change: 1 addition & 0 deletions altk_evolve/cli/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Bundled CLI templates (packaged data)."""
117 changes: 117 additions & 0 deletions altk_evolve/cli/templates/hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Evolve hooks configuration (CPEX plugin-engine YAML format).
#
# Scaffolded by `evolve hooks init`. Evolve AUTO-DISCOVERS this file: with the
# default name `evolve.hooks.yaml` in your project root, every EvolveClient in
# this project picks it up automatically (search order:
# $EVOLVE_HOOKS_CONFIG -> ./evolve.hooks.yaml -> ~/.config/evolve/hooks.yaml).
#
# The hook seam is always live. Behavior is decided entirely by the plugins
# below: comment a plugin out (or set `mode: disabled`) to turn it off; there is
# no separate on/off switch, and no code change is needed to change posture.
#
# ── Two PII detection methods, not either/or ──────────────────────────────
#
# * SEMANTIC (this file's default, ACTIVE below): NER via IBM READI. Catches
# names and free-form entities a regex cannot — the more powerful method,
# at the cost of a model (weights download on first use). English by
# default; set a language-matched model for other languages.
# * REGEX (COMMENTED OUT below): cpex-pii-filter's Rust engine. Lightweight,
# deterministic, high precision on STRUCTURED identifiers (email, SSN,
# phone, card, IP) — but it has no NER and cannot catch a name.
#
# Defence-in-depth = run BOTH (they chain: regex for identifiers + semantic for
# names). To do that, uncomment the regex block below and leave READI active.
# To use regex only, comment the READI block and uncomment the regex block.
#
# `mode: sequential` + `on_error: fail` are load-bearing on both, not cosmetic:
# CPEX silently downgrades `continue_processing=False` -> `True` in transform/
# audit mode (a redactor there can redact but never BLOCK), and `on_error: fail`
# is what makes a crashing/timing-out redactor halt the write rather than pass
# unredacted content through (fail-closed).

plugins:
# ── Semantic (NER) PII — ACTIVE by default ──────────────────────────────
# Requires: pip install 'altk-evolve[pii-semantic]' (downloads model
# weights, ~460MB for en_core_web_trf, on first use).
- name: readi_semantic_pii
kind: altk_evolve.hooks.plugins.readi.ReadiSemanticPIIPlugin
description: Semantic (NER) PII masking on memory writes and LLM egress
hooks:
- memory_pre_write
- llm_pre_call
mode: sequential # sequential (not transform) so it can BLOCK, not just redact
priority: 10
on_error: fail # fail-closed: a crashing NER model must not pass PII through
config:
# default | spacy | hf | presidio.
# default -> READI's own PII pipeline (spaCy en_core_web_trf). English.
# spacy -> any spaCy pipeline; the multilingual path. Set readi_model.
# hf -> any HF pipeline("ner") model id. Set readi_model.
# presidio -> Microsoft Presidio (READI hardcodes language="en"; see docs).
readi_extractor: default
# A LANGUAGE-MATCHED model is decisive: on Japanese, overall recall goes
# 0.15 (English model) -> 0.92 (ja_core_news_trf).
# readi_extractor: spacy
# readi_model: ja_core_news_trf
# readi_language: ja
redaction_text: "[REDACTED]"
# defaults to true (matches the regex method). Set false only if your
# metadata holds ids/paths/trace keys that redaction would corrupt.
redact_metadata: true

# ── Regex PII — COMMENTED OUT (uncomment to run alongside READI) ─────────
# Requires: pip install 'altk-evolve[pii-regex]' ([pii] is a back-compat alias)
#
# - name: pii_filter_memory
# kind: altk_evolve.hooks.plugins.pii.PIIFilterMemoryPlugin
# description: Regex PII masking on memory writes and LLM egress
# hooks:
# - memory_pre_write
# - llm_pre_call
# mode: sequential # same reason as above: transform mode can never block
# priority: 10 # redact before any normalizer sees content
# on_error: fail # fail-closed: never pass unredacted content through
# config:
# detect_email: true
# detect_ssn: true
# detect_phone: true
# default_mask_strategy: redact
# redaction_text: "[REDACTED]"

# ── Structured secrets — COMMENTED OUT (a third, orthogonal method) ──────
# Requires: pip install 'altk-evolve[secrets]' (cpex-secrets-detection)
#
# Catches CREDENTIALS/TOKENS the PII methods above do not target (AWS keys,
# GitHub/Slack tokens, Stripe secrets, private-key blocks). Regex-based, no
# verification — like the regex PII method, treat it as a high-precision floor,
# not proof of absence. Enable alongside a PII method; it chains the same way.
#
# - name: secrets_filter_memory
# kind: altk_evolve.hooks.plugins.secrets.SecretsFilterMemoryPlugin
# description: Structured secrets (credential/token) masking on memory writes and LLM egress
# hooks:
# - memory_pre_write
# - llm_pre_call
# mode: sequential # same reason as above: transform mode can never block
# priority: 10 # redact before any normalizer sees content
# on_error: fail # fail-closed: never pass content with secrets through
# config:
# redact: true
# redaction_text: "[REDACTED]"
# block_on_detection: false # we redact-and-continue; set true (+redact:false) to hard-block
# enabled:
# # Structured / high-precision detectors — ON.
# aws_access_key_id: true
# aws_secret_access_key: true
# google_api_key: true
# github_token: true
# stripe_secret_key: true
# slack_token: true
# private_key_block: true
# # Entropy / heuristic detectors — present but OFF: on a memory corpus
# # they OVER-REDACT (legit base64 blobs, hex digests, hashes, JWT-shaped
# # ids), so they are opt-in only. Uncomment deliberately.
# # generic_api_key_assignment: true
# # jwt_like: true
# # hex_secret_32: true
# # base64_24: true
73 changes: 69 additions & 4 deletions altk_evolve/config/hooks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,63 @@
"""Configuration models for the memory hook seam."""

from __future__ import annotations

import os
from collections.abc import Mapping
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field, field_validator

#: Basename of the project-local hooks config auto-discovered from the cwd.
DEFAULT_HOOKS_CONFIG_FILENAME = "evolve.hooks.yaml"
#: Environment variable that, when set, points at an explicit hooks config path.
HOOKS_CONFIG_ENV_VAR = "EVOLVE_HOOKS_CONFIG"


def discover_hooks_config_path(
*,
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
user_config_dir: Path | None = None,
) -> str | None:
"""Locate a default hooks config file, searching (first hit wins):

1. ``$EVOLVE_HOOKS_CONFIG`` — an explicit path (an env override always wins).
2. ``./evolve.hooks.yaml`` — project-local, relative to ``cwd``.
3. ``<user_config_dir>/evolve/hooks.yaml`` — a per-user config, where
``user_config_dir`` defaults to ``$XDG_CONFIG_HOME`` or ``~/.config``.

Returns the first existing path as a string, or ``None`` when nothing is
found (the seam then stays a zero-cost no-op). Every input is injectable so
tests can exercise discovery without touching the real home directory.

Note on the env var: an explicit path set via ``$EVOLVE_HOOKS_CONFIG`` is
returned even if the file does not exist, so a typo surfaces as a clear
"file not found" at engine init rather than silently falling through to a
lower-priority location.
"""
env = os.environ if env is None else env
cwd = Path.cwd() if cwd is None else cwd

explicit = env.get(HOOKS_CONFIG_ENV_VAR)
if explicit:
# Explicit path wins unconditionally — do not fall through on a typo.
return explicit

project_local = cwd / DEFAULT_HOOKS_CONFIG_FILENAME
if project_local.is_file():
return str(project_local)

if user_config_dir is None:
xdg = env.get("XDG_CONFIG_HOME")
user_config_dir = Path(xdg) if xdg else Path.home() / ".config"
user_config = user_config_dir / "evolve" / "hooks.yaml"
if user_config.is_file():
return str(user_config)

return None


class HookPluginSpec(BaseModel):
"""Code-first spec for one hook plugin (equivalent of one entry in the
Expand Down Expand Up @@ -46,12 +100,23 @@ def _kind_is_dotted_path(cls, value: str) -> str:
class HooksConfig(BaseModel):
"""Hook seam configuration (``EvolveConfig.hooks``).

``enabled`` defaults to False, guaranteeing zero behavior change for
existing users. When True, the execution engine — the optional ``cpex``
package — must be installed (``pip install 'altk-evolve[hooks]'``).
The hook seam is **always live** — there is no master switch. Behavior is
determined entirely by which plugins are configured:

- **No plugins** (empty ``plugins_yaml`` + empty code-first ``plugins`` +
nothing auto-discovered) → the seam is a zero-cost no-op that requires no
execution engine; importing a backend pulls no ``cpex``.
- **Plugins configured but the engine is missing** → engine initialization
fails **closed** with a clear error (``pip install 'altk-evolve[hooks]'``),
never a silent no-op.

When ``plugins_yaml`` is not set explicitly, a default config file is
auto-discovered via :func:`discover_hooks_config_path` (``$EVOLVE_HOOKS_CONFIG``
→ ``./evolve.hooks.yaml`` → ``~/.config/evolve/hooks.yaml``). Scaffold one
with ``evolve hooks init``. An explicit ``plugins_yaml`` (or code-first
``plugins``) always overrides discovery.
"""

enabled: bool = Field(default=False, description="Master switch. False = the hook seam is a fast no-op.")
plugins_yaml: str | None = Field(
default=None,
description="Path to an engine plugins.yaml (CPEX format). Loaded by the CPEX PluginManager when set.",
Expand Down
17 changes: 10 additions & 7 deletions altk_evolve/frontend/client/evolve_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,20 @@ def __init__(self, config: EvolveConfig | None = None):
else:
raise NotImplementedError(f"Entity backend not implemented: {self.config.backend}")

# Initialize the memory hook seam. The CPEX PluginManager is a
# process-wide singleton, so the seam is process-global, not per-client:
# - Constructing another client with hooks.enabled=True resets the
# Initialize the memory hook seam. The seam is ALWAYS live — there is no
# enable/disable switch; behavior is decided by which plugins resolve
# (config + auto-discovered evolve.hooks.yaml). The CPEX PluginManager is
# a process-wide singleton, so the seam is process-global, not per-client:
# - Constructing another client that resolves plugins resets the
# manager and REPLACES this client's plugins (initialize_hooks warns
# when the reset discards already-registered plugins, e.g. a PII
# redaction plugin an earlier client relied on).
# - A client with hooks.enabled=False shuts the seam DOWN, so it does
# - A client that resolves NO plugins shuts the seam DOWN, so it does
# not inherit another client's process-global hooks — hence this is
# called unconditionally (initialize_hooks(disabled) tears down and
# returns None). The heavy cpex import stays deferred, so the
# disabled path remains a cheap no-op.
# called unconditionally (initialize_hooks(no plugins) tears down and
# returns None). The heavy cpex import stays deferred, so the no-op
# path remains cheap. A configured-but-engine-missing setup instead
# raises here (fail-closed), never a silent no-op.
from altk_evolve.hooks.manager import initialize_hooks

initialize_hooks(self.config.hooks)
Expand Down
7 changes: 5 additions & 2 deletions altk_evolve/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
- :func:`~altk_evolve.hooks.types.engine_available` to probe whether a plugin
execution engine is installed

Everything is a fast no-op unless ``EvolveConfig.hooks.enabled`` is True and
the execution engine is installed (``pip install 'altk-evolve[hooks]'``).
The seam is always live; everything is a fast no-op until at least one plugin
is configured (via ``EvolveConfig.hooks`` or an auto-discovered
``evolve.hooks.yaml``). Once plugins ARE configured, the execution engine must
be installed (``pip install 'altk-evolve[hooks]'``) or initialization fails
closed.
"""

from altk_evolve.hooks.manager import (
Expand Down
Loading
Loading