Skip to content

Commit a0e6bed

Browse files
refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface (#2077)
* Implement Memory Guard Lite for OSS Allow users to prevent token and secret leakage in agent memory. feat(memory-defense): reject quarantine action in policy parser refactor(retain): drop quarantine branch from orchestrator test(retain): remove quarantine-path tests refactor(memory-defense): remove DefenseAction.QUARANTINE enum value refactor(api): remove include_quarantined query parameter refactor(recall): drop include_quarantined parameter from memory engine test(memory-defense): replace stale parser-reject test with full-union accept test The previous parametrized test asserted parse_policy() should 422 on any detector name other than sensitive_data. That contract was deliberately widened on 2026-06-07 so cloud-style policies pass through api-slim's parser unchanged. The test was stale; the runtime is correct. Replaced with test_parse_policy_accepts_full_detector_union, which proves the actual contract: all 7 detector names are valid in the parser, with dispatch and entitlement enforcement deferred to the loaded extension. Memory defense UI * i18n labels * Fix tests * Client changes to fix breaking tests * Test fixes * chore: regenerate API clients via generate-clients.sh The clients were previously hand-generated in a way that diverged from the project's tooling — including a non-standard hindsight-clients/typescript/client/ directory the generator never produces (the standard output is typescript/generated/), plus ~150 spurious files. Revert the entire hindsight-clients/ tree to main and regenerate from the OpenAPI spec using ./scripts/generate-clients.sh (Rust via progenitor build.rs, Python/Go via openapi-generator, TypeScript via @hey-api/openapi-ts). The spec itself is unchanged (a code-regenerated spec is byte-identical to what was already committed). Net result is the real API delta only: the new nullable MemoryItem.receipt_uri field propagated to the Python, TypeScript and Go models. * refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface Review cleanup of the memory-defense feature: - Rename the OSS extension Lite -> Regex (MemoryDefenseRegexExtension, memory_defense_regex.py). It is pure regex redaction now. - Drop the agent_memory_guard (OWASP) dependency entirely — the SensitiveDataDetector fallback and to_owasp_policy are gone; nothing cloud-tier remains in api-slim. - Trim the policy to what OSS enforces: { enabled, rules:[{on:sensitive_data, action}] }. Removed default_action, protected/immutable namespaces, detector_overrides, min_severity, and the unused memory_defense_enabled_default server default. Per-bank override stays (memory_defense is a configurable field) and the UI writes the trimmed shape. - Fire a memory_defense.triggered webhook on every non-allow decision (redact and block) when one is configured, via the retain orchestrator. Adds WebhookEventType.MEMORY_DEFENSE_TRIGGERED + MemoryDefenseEventData. Replaces the no-op record_violation hook. - Block is now actually enforced (drop item / 422 when all blocked) instead of being silently downgraded to redact. - Remove the unused 'status' lifecycle: the add_status migration + its two merge migrations, the recall quarantine filter, the status column reads in search, and MemoryFact.status. Branch now adds zero migrations (single head). - Remove receipt_uri from the API (MemoryItem) and clients — it was always None and carried no value. Tests updated/renamed accordingly; OWASP smoke + enabled-default tests removed. * fix(memory-defense): address code-review findings - Delete test_migration_status.py (asserted the removed status column/constraint). - Remove receipt_uri from the Rust CLI (memory.rs + integration_test.rs) — the generated client struct no longer has the field, so it wouldn't compile. - Type the blocked-violations as a BlockedViolation dataclass instead of raw dicts (serialized via asdict() in the 422 body); type the webhook helper's decision param as DefenseDecision. - Add an end-to-end test asserting a redact decision queues a memory_defense.triggered webhook delivery. - Drop the unrelated docs/ entry from .gitignore (local scratch, not this PR). * test(memory-defense): consolidate into a single test_memory_defense.py Merge the 10 scattered memory-defense test modules (policy parser, regex engine/screen, redaction benchmark, extension loader, extension-context wiring, bank-config validation, and the three retain e2e files) into one test_memory_defense.py, deduping the overlapping unit screen tests and the duplicated retain redact e2e. 36 tests, same coverage. * docs(memory-defense): document memory_defense.triggered webhook + block action - Add memory_defense.triggered to the control-plane webhook event-type selector (it was firing but wasn't selectable in the UI). - Document the memory_defense.triggered event (payload + data fields) on the webhooks API page, and link it from the Memory Defense page. - Document the block action (the page only described redact) and add a Notifications section. Regenerate the docs skill copies. * docs: remove Memory Defense page from version-0.7 (unreleased feature) The feature was snapshotted into the 0.7 versioned docs by mistake — 0.7 never shipped Memory Defense. Remove the page and its (sole) Security sidebar category. * test(memory-defense): assert webhook payload fields + cover block path - test_retain_fires_webhook_on_redact now parses the queued delivery and asserts the MemoryDefenseEventData payload (action/detector/matched_types/ message + event status), not just that the event type was queued. - Add test_retain_fires_webhook_on_block: a block decision fires the webhook (before the 422 is raised) with action=block. Confirms the delivery persists despite the blocked retain returning 422. - Factor out _memory_defense_webhook_events() helper. * fix(control-plane): render structured API error details as a string A blocked retain returns 422 with detail {violations: [{message, ...}]}; the proxy forwards it as `details` and the client passed that object straight into the sonner toast, crashing with "Objects are not valid as a React child". Add describeErrorDetails() to reduce details to a string — joining violation messages when present (so a Memory Defense block shows e.g. "Sensitive data pattern matched: aws_access_key"), else JSON-stringifying. * docs(webhooks): clarify WebhookEvent.status covers the memory_defense action * feat(memory-defense): record redact/block actions in the audit log Emit a fire-and-forget 'memory_defense' audit entry for each non-allow decision (alongside the webhook), with the action/detector/document_id/matched_types in metadata. Threads the engine's AuditLogger into retain_batch like the webhook manager; gated by the existing audit_log_enabled switch (off by default). - Add the memory_defense option to the audit-logs UI action filter + the actionMemoryDefense i18n key across all locales. - Document it on the Memory Defense page and the audit-logging config section. - Test: a redact retain writes a memory_defense audit row with the expected metadata (audit enabled on the test engine). --------- Co-authored-by: Chris Latimer <chris.latimer@vectorize.io>
1 parent fd848a1 commit a0e6bed

40 files changed

Lines changed: 2301 additions & 36 deletions

File tree

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5718,6 +5718,15 @@ async def api_update_bank_config(
57185718
app.state.memory._operation_validator.validate_bank_write(ctx)
57195719
)
57205720

5721+
# Validate Memory Defense policy shape before persisting.
5722+
if "memory_defense" in request.updates and request.updates["memory_defense"] is not None:
5723+
from hindsight_api.extensions.memory_defense import parse_policy
5724+
5725+
try:
5726+
parse_policy(request.updates["memory_defense"])
5727+
except ValueError as exc:
5728+
raise HTTPException(status_code=422, detail=f"invalid memory_defense policy: {exc}")
5729+
57215730
# Update config via config resolver (validates configurable fields and permissions)
57225731
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
57235732

@@ -6252,6 +6261,15 @@ async def api_retain(
62526261
# client errors, not server faults.
62536262
raise HTTPException(status_code=400, detail=str(e))
62546263
except Exception as e:
6264+
from dataclasses import asdict
6265+
6266+
from hindsight_api.engine.retain.orchestrator import MemoryDefenseAllBlockedError
6267+
6268+
if isinstance(e, MemoryDefenseAllBlockedError):
6269+
raise HTTPException(
6270+
status_code=422,
6271+
detail={"violations": [asdict(v) for v in e.violations]},
6272+
)
62556273
import traceback
62566274

62576275
# Create a summary of the input for debugging

hindsight-api-slim/hindsight_api/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,10 @@ class HindsightConfig:
14811481
# When False: only label entities are extracted (or no entities at all if no labels configured)
14821482
entities_allow_free_form: bool
14831483

1484+
# Memory Defense policy (dict matching DefensePolicy schema — validated on write)
1485+
# None = Memory Defense disabled / not configured for this bank
1486+
memory_defense: dict | None
1487+
14841488
# Reflect agent settings
14851489
reflect_mission: str | None
14861490
reflect_source_facts_max_tokens: int
@@ -1675,6 +1679,8 @@ class HindsightConfig:
16751679
"disposition_empathy",
16761680
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
16771681
"llm_gemini_safety_settings",
1682+
# Memory Defense policy (validated against DefensePolicy schema on write)
1683+
"memory_defense",
16781684
}
16791685

16801686
@property
@@ -2412,6 +2418,7 @@ def from_env(cls) -> "HindsightConfig":
24122418
),
24132419
entity_labels=None,
24142420
entities_allow_free_form=True,
2421+
memory_defense=None,
24152422
# Database migrations
24162423
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
24172424
# Database connection pool

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,35 @@ def __init__(
10501050
tenant_extension = DefaultTenantExtension(config={})
10511051
self._tenant_extension = tenant_extension
10521052

1053+
# Load memory defense extension; default to the regex extension when the
1054+
# env var is unset. Lazy imports avoid a circular dependency:
1055+
# extensions/__init__ imports MCPExtension which imports MemoryEngine at
1056+
# module level.
1057+
from ..extensions.builtin.memory_defense_regex import ( # noqa: PLC0415
1058+
MemoryDefenseRegexExtension,
1059+
)
1060+
from ..extensions.context import DefaultExtensionContext # noqa: PLC0415
1061+
from ..extensions.loader import load_extension # noqa: PLC0415
1062+
from ..extensions.memory_defense import MemoryDefenseExtension # noqa: PLC0415
1063+
1064+
# Build the extension context now; webhook_manager is populated later in
1065+
# initialize() once the pool is ready. current_schema is a per-request
1066+
# value written by _authenticate() and execute_task().
1067+
self._ext_ctx = DefaultExtensionContext(
1068+
database_url=config.database_url or "",
1069+
memory_engine=self,
1070+
webhook_manager=None,
1071+
current_schema=None,
1072+
)
1073+
1074+
loaded = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension, context=self._ext_ctx)
1075+
if loaded is not None:
1076+
self._memory_defense: MemoryDefenseExtension = loaded
1077+
else:
1078+
regex_defense = MemoryDefenseRegexExtension({})
1079+
regex_defense.set_context(self._ext_ctx)
1080+
self._memory_defense = regex_defense
1081+
10531082
# Cache for get_bank_stats — short TTL + concurrent-loader coalescing.
10541083
# The query joins memory_links to memory_units and can be a multi-second
10551084
# parallel scan on large banks; a single polling client used to be able
@@ -1129,6 +1158,7 @@ async def _authenticate_tenant(self, request_context: "RequestContext | None") -
11291158
tenant_context = await self._tenant_extension.authenticate(request_context)
11301159

11311160
_current_schema.set(tenant_context.schema_name)
1161+
self._ext_ctx.current_schema = tenant_context.schema_name
11321162
return tenant_context.schema_name
11331163

11341164
async def _handle_import_documents(self, task_dict: dict[str, Any]):
@@ -1614,6 +1644,7 @@ async def execute_task(self, task_dict: dict[str, Any]):
16141644
schema = task_dict.pop("_schema", None)
16151645
if schema:
16161646
_current_schema.set(schema)
1647+
self._ext_ctx.current_schema = schema
16171648

16181649
# Check if operation was cancelled (only for tasks with operation_id)
16191650
if operation_id:
@@ -2706,6 +2737,9 @@ async def _init_connection(conn: asyncpg.Connection) -> None:
27062737
global_webhooks=webhook_global,
27072738
tenant_extension=self._tenant_extension,
27082739
)
2740+
# Propagate the now-ready webhook manager to the extension context so
2741+
# that the Memory Defense extension can fire webhooks.
2742+
self._ext_ctx.webhook_manager = self._webhook_manager
27092743
logger.debug("Webhook manager initialized")
27102744

27112745
# Long-lived HTTP client for webhook delivery tasks
@@ -3435,6 +3469,9 @@ async def _retain_batch_async_internal(
34353469
# Stream chunk-level "storing N/total" progress to the operation row as
34363470
# the document's chunks commit (more useful than the coarse sub-batch tick).
34373471
progress_callback=self._write_operation_progress,
3472+
webhook_manager=self._webhook_manager,
3473+
memory_defense_extension=self._memory_defense,
3474+
audit_logger=self._audit_logger,
34383475
)
34393476
# Map the created facts onto this retain's trace so the trace view can
34403477
# show which memories the ingestion produced. result[0] is the
@@ -6276,7 +6313,9 @@ async def list_memory_units(
62766313

62776314
units = await conn.fetch(
62786315
f"""
6279-
SELECT id, text, event_date, context, fact_type, mentioned_at, occurred_start, occurred_end, chunk_id, proof_count, tags, consolidated_at, consolidation_failed_at
6316+
SELECT id, text, event_date, context, fact_type, document_id,
6317+
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
6318+
tags, consolidated_at, consolidation_failed_at
62806319
FROM {fq_table("memory_units")}
62816320
{where_clause}
62826321
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
@@ -6323,6 +6362,7 @@ async def list_memory_units(
63236362
"context": row["context"] if row["context"] else "",
63246363
"date": row["event_date"].isoformat() if row["event_date"] else "",
63256364
"fact_type": row["fact_type"],
6365+
"document_id": row["document_id"],
63266366
"mentioned_at": row["mentioned_at"].isoformat() if row["mentioned_at"] else None,
63276367
"occurred_start": row["occurred_start"].isoformat() if row["occurred_start"] else None,
63286368
"occurred_end": row["occurred_end"].isoformat() if row["occurred_end"] else None,

0 commit comments

Comments
 (0)