Files
AIPass/src/aipass/hooks/tests/test_edit_gate_trinity.py
T
AIOSAIandClaude Opus 4.8 0f5db606a5 feat(hooks): edit_gate non-blocking advisory when todos exceed rollover count limit
- todos don't auto-roll (active work items, pruned by hand) — so when they pile over the per-branch count limit, edit_gate now emits a NON-BLOCKING advisory ('todos over limit (N/M) — prune completed ones') and still allows the save
- reads the count limit from @memory's rollover config (per_branch override -> defaults -> 10); todos-only, local.json-only; char-cap block still takes priority; config-load failure = silent skip
- 11 new tests (522 hooks total), seedgo 100%; verified live (fired 11/10, silent at 10/10)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:29:12 -07:00

1173 lines
46 KiB
Python

# =================== AIPass ====================
# Name: test_edit_gate_trinity.py
# Version: 1.0.0
# Description: Tests for edit_gate .trinity char-limit check (FPLAN-0270 Phase 4)
# Branch: hooks
# Created: 2026-06-13
# Modified: 2026-06-13
# =============================================
"""Tests for edit_gate .trinity character-limit check (Write/Edit/MultiEdit)."""
import importlib
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
_TEST_LIMITS_WARN = {
"enabled": True,
"enforce": False,
"entry_types": {
"key_learnings": {
"file": "local.json",
"container": "key_learnings",
"kind": "dict",
"field": "value",
"max_chars": 200,
},
"sessions": {
"file": "local.json",
"container": "sessions",
"kind": "list",
"field": "summary",
"max_chars": 300,
},
"todos": {
"file": "local.json",
"container": "todos",
"kind": "list",
"field": "task",
"max_chars": 200,
},
"observations": {
"file": "observations.json",
"container": "observations",
"kind": "list",
"field": "note",
"max_chars": 600,
},
},
}
_TEST_LIMITS_ENFORCE = {**_TEST_LIMITS_WARN, "enforce": True}
_TEST_LIMITS_DISABLED = {**_TEST_LIMITS_WARN, "enabled": False}
def _make_trinity_path(tmp_path, branch="hooks", filename="local.json"):
"""Build a .trinity file path with proper src/aipass/<branch>/.trinity/ structure."""
trinity_dir = tmp_path / "src" / "aipass" / branch / ".trinity"
trinity_dir.mkdir(parents=True, exist_ok=True)
return str(trinity_dir / filename)
def _hook_data(file_path, content=None, tool_name="Write", cwd=None, **extra_input):
"""Build a hook_data dict for edit_gate.handle()."""
tool_input = {"file_path": file_path}
if content is not None:
tool_input["content"] = content
tool_input.update(extra_input)
data = {
"tool_name": tool_name,
"tool_input": tool_input,
}
if cwd:
data["cwd"] = cwd
return data
def _mock_entry_limits(limits):
"""Create a mock module with controlled limits and real changed_entries."""
el = importlib.import_module("aipass.memory.apps.handlers.json.entry_limits")
mock_module = MagicMock()
mock_module.load_entry_limits.return_value = limits
mock_module.changed_entries = el.changed_entries
return mock_module
_ROLLOVER_CONFIG_10 = {
"rollover": {
"defaults": {
"local": {
"sessions": {"count": 20},
"key_learnings": {"count": 25},
"todos": {"count": 10},
},
},
"per_branch": {},
},
}
def _mock_importlib_modules(limits, rollover_cfg=None):
"""Return a side_effect for importlib.import_module supporting both modules."""
el_real = importlib.import_module("aipass.memory.apps.handlers.json.entry_limits")
entry_limits_mock = MagicMock()
entry_limits_mock.load_entry_limits.return_value = limits
entry_limits_mock.changed_entries = el_real.changed_entries
config_loader_mock = MagicMock()
config_loader_mock.load.return_value = rollover_cfg if rollover_cfg is not None else _ROLLOVER_CONFIG_10
def side_effect(name):
if "entry_limits" in name:
return entry_limits_mock
if "config_loader" in name:
return config_loader_mock
return importlib.import_module(name)
return side_effect
class TestTrinityWriteClean:
"""Write to .trinity with entries under cap -> allowed."""
def test_clean_write_local_json(self, tmp_path):
"""All entries under cap in local.json -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps(
{
"key_learnings": {"learn_1": "short"},
"sessions": [{"summary": "short session"}],
"todos": [{"task": "short todo"}],
}
)
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_clean_write_observations_json(self, tmp_path):
"""All entries under cap in observations.json -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "observations.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"observations": [{"note": "short observation"}]})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
class TestTrinityWriteOverLimitEnforced:
"""Write with over-limit entry + enforce=True -> blocked."""
def test_block_over_limit_key_learning(self, tmp_path):
"""key_learning value 201 chars vs 200 cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"learn_1": "x" * 201}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "key_learnings" in parsed["reason"]
assert "201" in parsed["reason"]
assert "200" in parsed["reason"]
def test_block_over_limit_session_summary(self, tmp_path):
"""Session summary 301 chars vs 300 cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"sessions": [{"summary": "x" * 301}]})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "sessions" in parsed["reason"]
def test_block_over_limit_todo(self, tmp_path):
"""Todo task 201 chars vs 200 cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": "x" * 201}]})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "todos" in parsed["reason"]
def test_block_over_limit_observation(self, tmp_path):
"""Observation note 601 chars vs 600 cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "observations.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"observations": [{"note": "x" * 601}]})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "observations" in parsed["reason"]
def test_block_reason_includes_over_by(self, tmp_path):
"""Block reason includes the +over_by amount."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "x" * 210}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
parsed = json.loads(result["stdout"])
assert "+10" in parsed["reason"]
class TestTrinityWriteOverLimitWarnOnly:
"""Write with over-limit entry + enforce=False -> allowed + warning logged."""
def test_allow_over_limit_warn_only(self, tmp_path):
"""Over-limit with enforce=False -> exit_code 0, empty stdout."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"learn_1": "x" * 250}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_warn_logs_over_limit_entries(self, tmp_path, caplog):
"""Over-limit with enforce=False -> warning logged with warn-only message."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "x" * 250}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert "warn only" in caplog.text
class TestTrinityWriteNonTrinity:
"""Write to non-.trinity file -> passes through unchanged."""
def test_non_trinity_py_passthrough(self):
"""Write to a .py file -> no .trinity check, passes to diagnostics gate."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
result = handle(
{
"tool_name": "Write",
"tool_input": {
"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py",
"content": "print('hello')",
},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/hooks",
}
)
assert result["exit_code"] == 0
def test_non_trinity_json_passthrough(self):
"""Write to a non-.trinity .json file -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
result = handle(
{
"tool_name": "Write",
"tool_input": {
"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/config.json",
"content": '{"key": "value"}',
},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/hooks",
}
)
assert result["exit_code"] == 0
def test_trinity_passport_passthrough(self, tmp_path):
"""passport.json is in .trinity but NOT in _TRINITY_MEMORY_FILES."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
trinity_dir = tmp_path / "src" / "aipass" / "hooks" / ".trinity"
trinity_dir.mkdir(parents=True, exist_ok=True)
file_path = str(trinity_dir / "passport.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
result = handle(
{
"tool_name": "Write",
"tool_input": {"file_path": file_path, "content": '{"identity": {}}'},
"cwd": cwd,
}
)
assert result["exit_code"] == 0
class TestTrinityWriteFailOpen:
"""Invalid or unparseable content -> fail-open (allowed)."""
def test_invalid_json_content(self, tmp_path):
"""Non-JSON content -> JSONDecodeError caught, fail-open."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, "not valid json {{{", cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_empty_content(self, tmp_path):
"""Empty content string -> JSONDecodeError caught, fail-open."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, "", cwd=cwd))
assert result["exit_code"] == 0
def test_import_failure_fail_open(self, tmp_path):
"""importlib.import_module raises ImportError -> caught, fail-open."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "x" * 500}})
with patch("importlib.import_module", side_effect=ImportError("no module")):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
class TestTrinityWriteCharNotByte:
"""Character vs byte boundary: em-dash is 3 bytes / 1 char."""
def test_em_dash_at_cap_allowed(self, tmp_path):
"""200 em-dashes = 200 chars (600 bytes) = exactly at cap -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "—" * 200}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
def test_em_dash_over_cap_blocked(self, tmp_path):
"""201 em-dashes = 201 chars (603 bytes) = over cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "—" * 201}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
class TestTrinityEditClean:
"""Edit to .trinity with entries under cap -> allowed."""
def test_edit_clean_entry(self, tmp_path):
"""Edit changes a key_learning to a short value -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "old value"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"old value"',
new_string='"new short value"',
)
)
assert result["exit_code"] == 0
assert result["stdout"] == ""
class TestTrinityEditOverLimit:
"""Edit producing over-limit entry -> blocked (enforce) or warned."""
def test_edit_over_limit_enforce_blocks(self, tmp_path):
"""Edit pushes key_learning over 200 cap, enforce=True -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "short"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"short"',
new_string='"' + "x" * 250 + '"',
)
)
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "key_learnings" in parsed["reason"]
def test_edit_over_limit_warn_allows(self, tmp_path, caplog):
"""Edit pushes key_learning over cap, enforce=False -> allowed + warn."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "short"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"short"',
new_string='"' + "x" * 250 + '"',
)
)
assert result["exit_code"] == 0
assert "warn only" in caplog.text
def test_edit_modifies_entry_to_exceed_cap(self, tmp_path):
"""Edit modifies existing entry from under cap to over cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "a" * 100}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"' + "a" * 100 + '"',
new_string='"' + "b" * 250 + '"',
)
)
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
class TestTrinityEditFailOpen:
"""Edit fail-open: old_string not found, invalid JSON result."""
def test_edit_old_string_not_found_fail_open(self, tmp_path):
"""old_string absent from file -> _resolve_after_text returns None -> allow."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "hello"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string="NONEXISTENT",
new_string="x" * 500,
)
)
assert result["exit_code"] == 0
def test_edit_producing_invalid_json_fail_open(self, tmp_path):
"""Edit breaks JSON structure -> JSONDecodeError caught -> allow."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "hello"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"hello"',
new_string='"hello',
)
)
assert result["exit_code"] == 0
def test_edit_nonexistent_file_allows(self, tmp_path):
"""Edit to a .trinity file that doesn't exist yet -> allow."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string="anything",
new_string="x" * 500,
)
)
assert result["exit_code"] == 0
class TestTrinityEditReplaceAll:
"""Edit with replace_all=True vs False."""
def test_replace_all_true(self, tmp_path):
"""replace_all=True replaces all occurrences -> checks result."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "aaa", "k2": "aaa"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"aaa"',
new_string='"' + "x" * 250 + '"',
replace_all=True,
)
)
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
def test_replace_all_false_single(self, tmp_path):
"""replace_all=False replaces first occurrence only -> one entry over."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "aaa", "k2": "bbb"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"aaa"',
new_string='"' + "x" * 250 + '"',
replace_all=False,
)
)
assert result["exit_code"] == 2
class TestTrinityEditCharNotByte:
"""Character vs byte boundary via Edit tool."""
def test_em_dash_edit_at_cap_allowed(self, tmp_path):
"""Edit producing 200 em-dashes (200 chars, 600 bytes) -> at cap -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "short"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"short"',
new_string='"' + "—" * 200 + '"',
)
)
assert result["exit_code"] == 0
def test_em_dash_edit_over_cap_blocked(self, tmp_path):
"""Edit producing 201 em-dashes (201 chars, 603 bytes) -> over cap -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "short"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"short"',
new_string='"' + "—" * 201 + '"',
)
)
assert result["exit_code"] == 2
class TestTrinityMultiEdit:
"""MultiEdit: sequential edits, ordering, over-limit detection."""
def test_multiedit_clean(self, tmp_path):
"""MultiEdit with both edits under cap -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "aaa", "k2": "bbb"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
edits = [
{"old_string": '"aaa"', "new_string": '"new_a"'},
{"old_string": '"bbb"', "new_string": '"new_b"'},
]
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
{
"tool_name": "MultiEdit",
"tool_input": {"file_path": file_path, "edits": edits},
"cwd": cwd,
}
)
assert result["exit_code"] == 0
def test_multiedit_over_limit_blocked(self, tmp_path):
"""MultiEdit where second edit produces over-limit entry -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "aaa", "k2": "bbb"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
edits = [
{"old_string": '"aaa"', "new_string": '"short"'},
{"old_string": '"bbb"', "new_string": '"' + "x" * 250 + '"'},
]
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
{
"tool_name": "MultiEdit",
"tool_input": {"file_path": file_path, "edits": edits},
"cwd": cwd,
}
)
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
def test_multiedit_ordering_dependent(self, tmp_path):
"""MultiEdit where edit 2 depends on edit 1's output -> applied sequentially."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "alpha"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
edits = [
{"old_string": '"alpha"', "new_string": '"beta"'},
{"old_string": '"beta"', "new_string": '"gamma"'},
]
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
{
"tool_name": "MultiEdit",
"tool_input": {"file_path": file_path, "edits": edits},
"cwd": cwd,
}
)
assert result["exit_code"] == 0
def test_multiedit_old_string_not_found_fail_open(self, tmp_path):
"""MultiEdit where an old_string is missing -> fail-open."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "hello"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
edits = [
{"old_string": '"hello"', "new_string": '"world"'},
{"old_string": '"NONEXISTENT"', "new_string": '"' + "x" * 500 + '"'},
]
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
{
"tool_name": "MultiEdit",
"tool_input": {"file_path": file_path, "edits": edits},
"cwd": cwd,
}
)
assert result["exit_code"] == 0
def test_multiedit_replace_all_in_edit(self, tmp_path):
"""MultiEdit with replace_all=True in one edit -> replaces all occurrences."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"k1": "zzz", "k2": "zzz"}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
edits = [
{"old_string": '"zzz"', "new_string": '"' + "x" * 250 + '"', "replace_all": True},
]
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
{
"tool_name": "MultiEdit",
"tool_input": {"file_path": file_path, "edits": edits},
"cwd": cwd,
}
)
assert result["exit_code"] == 2
class TestTrinityEditUnrelatedFieldOnFatFile:
"""THE critical no-false-reject test: unrelated edit on a file with legacy fat entries."""
def test_unrelated_edit_on_fat_file_allowed(self, tmp_path):
"""File has 4000-char sessions + 500-char key_learnings (all legacy).
Edit only touches a small todo. enforce=True. MUST be ALLOWED."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
fat_sessions = [{"summary": "x" * 300} for _ in range(13)]
fat_learnings = {f"k{i}": "y" * 500 for i in range(10)}
existing = {
"key_learnings": fat_learnings,
"sessions": fat_sessions,
"todos": [{"task": "old todo"}],
}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"old todo"',
new_string='"new todo"',
)
)
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_unrelated_edit_plus_new_over_limit_blocked(self, tmp_path):
"""Fat file, but Edit ALSO adds a new over-limit entry -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {
"key_learnings": {"old_fat": "y" * 500},
"todos": [{"task": "old todo"}],
}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"old todo"',
new_string='"' + "z" * 250 + '"',
)
)
assert result["exit_code"] == 2
class TestTrinityEditUnchangedLegacy:
"""Edit that doesn't change legacy over-limit entries -> allowed."""
def test_edit_unchanged_legacy_allowed(self, tmp_path):
"""Legacy over-limit key_learning unchanged by Edit -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {
"key_learnings": {"old_fat": "x" * 500, "k2": "short"},
"todos": [{"task": "my todo"}],
}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string='"short"',
new_string='"still short"',
)
)
assert result["exit_code"] == 0
class TestTrinityWriteDisabled:
"""Feature disabled via enabled:false -> passthrough."""
def test_disabled_allows_over_limit(self, tmp_path):
"""enabled=False -> size check skipped, over-limit entry allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "x" * 500}})
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_DISABLED)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
class TestTrinityWriteUnchangedLegacy:
"""Unchanged legacy over-limit entry in Write -> not blocked (rollover-safe)."""
def test_unchanged_legacy_allowed(self, tmp_path):
"""Legacy over-limit entry unchanged between before/after -> allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"old_fat": "x" * 500}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
after = {"key_learnings": {"old_fat": "x" * 500, "new_clean": "short"}}
content = json.dumps(after)
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
def test_changed_legacy_blocked(self, tmp_path):
"""Legacy entry modified (text changed, still over-limit) -> blocked."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"key_learnings": {"old_fat": "x" * 500}}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
after = {"key_learnings": {"old_fat": "y" * 500}}
content = json.dumps(after)
with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
class TestTrinityTodosCountAdvisory:
"""Non-blocking advisory when todos exceed rollover count limit."""
def test_todos_over_limit_advisory_write(self, tmp_path):
"""Write with 11 todos (limit 10) -> exit_code 0 + advisory stdout."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(11)]})
with patch("importlib.import_module", side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert "todos over limit" in result["stdout"]
assert "11/10" in result["stdout"]
def test_todos_under_limit_no_advisory(self, tmp_path):
"""Write with 5 todos (limit 10) -> exit_code 0, empty stdout."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(5)]})
with patch("importlib.import_module", side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_todos_at_limit_no_advisory(self, tmp_path):
"""Write with exactly 10 todos (limit 10) -> no advisory."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(10)]})
with patch("importlib.import_module", side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN)):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_todos_advisory_via_edit(self, tmp_path):
"""Edit that adds a todo pushing count over limit -> advisory."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
existing = {"todos": [{"task": f"todo {i}"} for i in range(10)]}
Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
new_todos = [{"task": f"todo {i}"} for i in range(11)]
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN),
):
result = handle(
_hook_data(
file_path,
tool_name="Edit",
cwd=cwd,
old_string=json.dumps(existing["todos"]),
new_string=json.dumps(new_todos),
)
)
assert result["exit_code"] == 0
assert "todos over limit" in result["stdout"]
assert "11/10" in result["stdout"]
def test_todos_advisory_never_blocks(self, tmp_path):
"""Even with enforce=True, todos count advisory has exit_code 0."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(15)]})
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_ENFORCE),
):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert "todos over limit" in result["stdout"]
assert "15/10" in result["stdout"]
def test_todos_advisory_observations_json_skip(self, tmp_path):
"""observations.json never triggers todos advisory."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "observations.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps(
{
"observations": [{"note": "obs"}],
"todos": [{"task": f"t{i}"} for i in range(20)],
}
)
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN),
):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_todos_advisory_config_loader_failure_silent(self, tmp_path):
"""config_loader import fails -> no advisory, no crash, save allowed."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(15)]})
el_real = importlib.import_module("aipass.memory.apps.handlers.json.entry_limits")
entry_limits_mock = MagicMock()
entry_limits_mock.load_entry_limits.return_value = _TEST_LIMITS_WARN
entry_limits_mock.changed_entries = el_real.changed_entries
def _side_effect(name):
"""Route importlib calls, failing config_loader."""
if "entry_limits" in name:
return entry_limits_mock
if "config_loader" in name:
raise ImportError("no config_loader")
return importlib.import_module(name)
with patch("importlib.import_module", side_effect=_side_effect):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_char_limit_blocks_before_advisory(self, tmp_path):
"""Char-limit block takes priority over todos advisory."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps(
{
"key_learnings": {"k1": "x" * 250},
"todos": [{"task": f"todo {i}"} for i in range(15)],
}
)
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_ENFORCE),
):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
def test_todos_advisory_logs_warning(self, tmp_path, caplog):
"""Advisory emits a logger.warning with the over-limit message."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(12)]})
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN),
):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert "todos over limit" in caplog.text
assert "12/10" in caplog.text
def test_todos_advisory_per_branch_override(self, tmp_path):
"""per_branch override sets limit to 5 for hooks -> 6 todos triggers advisory."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"todos": [{"task": f"todo {i}"} for i in range(6)]})
rollover_cfg = {
"rollover": {
"defaults": {"local": {"todos": {"count": 10}}},
"per_branch": {"hooks": {"local": {"todos": {"count": 5}}}},
},
}
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN, rollover_cfg),
):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert "6/5" in result["stdout"]
def test_no_todos_container_no_advisory(self, tmp_path):
"""local.json with no todos key -> no advisory."""
from aipass.hooks.apps.handlers.security.edit_gate import handle
file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
cwd = str(tmp_path / "src" / "aipass" / "hooks")
content = json.dumps({"key_learnings": {"k1": "short"}})
with patch(
"importlib.import_module",
side_effect=_mock_importlib_modules(_TEST_LIMITS_WARN),
):
result = handle(_hook_data(file_path, content, cwd=cwd))
assert result["exit_code"] == 0
assert result["stdout"] == ""