Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,19 @@ jobs:
python -c "from fastembed import TextEmbedding; TextEmbedding('sentence-transformers/all-MiniLM-L6-v2')"

- name: Run tests
shell: bash
env:
FASTEMBED_CACHE_PATH: ${{ runner.temp }}/fastembed-cache
run: pytest -n ${{ runner.os == 'Windows' && '0' || '4' }} --cov=context_engine --cov-report=xml --cov-report=term
# Windows GitHub Actions runners send spurious SIGINT during pytest
# teardown, causing exit code 1 even when all tests pass (779/779).
# This is a known runner infrastructure issue, not a code bug.
continue-on-error: ${{ runner.os == 'Windows' }}
# teardown, causing exit code 1 even when all tests pass. Using
# `shell: bash` ensures `|| true` is evaluated by bash on all
# platforms. The follow-up XML parse step then enforces no actual
# test cases failed.
run: pytest -n ${{ runner.os == 'Windows' && '0' || '4' }} --cov=context_engine --cov-report=xml --cov-report=term --junitxml=junit-results.xml || ${{ runner.os == 'Windows' && 'true' || 'false' }}

- name: Verify no test failures (Windows teardown workaround)
if: runner.os == 'Windows'
run: python -c "import xml.etree.ElementTree as ET; r=ET.parse('junit-results.xml').getroot(); f=int(r.get('failures','0'))+int(r.get('errors','0')); print(f'failures={f}'); exit(f)"

- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
Expand Down
13 changes: 10 additions & 3 deletions src/context_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,18 +300,25 @@ def _has_cce_hook(hook_list: list, marker: str) -> bool:
return False


def _install_memory_hooks(project_dir: Path) -> None:
def _install_memory_hooks(project_dir: Path, config=None) -> None:
"""Install the 5 lifecycle hooks for memory capture (PR 2).

Writes ~/.cce/hooks/cce_hook.sh and wires <project>/.claude/settings.json
entries for SessionStart, UserPromptSubmit, PostToolUse, Stop, SessionEnd.
Idempotent.

When `config` is provided, the hook command is stamped with the
slug-based port file path so two projects with the same basename (e.g.
two repos both called ``api``) each read their own port file.
"""
from context_engine.memory.hook_installer import (
install_hook_script, install_settings,
)
install_hook_script()
summary = install_settings(project_dir)
port_file_path = None
if config is not None:
port_file_path = project_storage_dir(config, project_dir) / "serve.port"
summary = install_settings(project_dir, port_file_path=port_file_path)
if summary["added"]:
_ok(
"Memory hooks installed "
Expand Down Expand Up @@ -956,7 +963,7 @@ def init(ctx: click.Context, agent: str) -> None:
if "claude" in editor_targets:
_ensure_claude_md(project_dir, output_level=output_level)
_ensure_session_hook(project_dir)
_install_memory_hooks(project_dir)
_install_memory_hooks(project_dir, config=config)
_check_memory_capture_reachable(config, project_dir)

# 6. .gitignore — add CCE per-machine entries
Expand Down
11 changes: 10 additions & 1 deletion src/context_engine/indexer/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,16 @@ async def _run_indexing_locked(
if target_path:
target = _resolve_within(project_dir, target_path)
if target.is_file():
file_iter = [target] if target.suffix not in _SKIP_EXTENSIONS else []
from context_engine.indexer.secrets import is_secret_file as _is_secret_file
from context_engine.indexer.ignorefile import matches_any as _ignore_matches
redact = getattr(config, "indexer_redact_secrets", True)
rel = str(target.relative_to(project_dir)).replace("\\", "/")
secret = redact and _is_secret_file(target)
ignored = (
target.name in ignore_set
or (cceignore_patterns and _ignore_matches(rel, False, cceignore_patterns))
)
file_iter = [] if target.suffix in _SKIP_EXTENSIONS or secret or ignored else [target]
elif target.is_dir():
file_iter = list(_iter_project_files(
target, ignore_set, _SKIP_EXTENSIONS,
Expand Down
21 changes: 16 additions & 5 deletions src/context_engine/memory/hook_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def _is_windows() -> bool:
set -u

HOOK_NAME="${1:-unknown}"
PORT_FILE="${HOME}/.cce/projects/$(basename "${PWD}")/serve.port"
PORT_FILE="${CCE_PORT_FILE:-${HOME}/.cce/projects/$(basename "${PWD}")/serve.port}"
[ -r "${PORT_FILE}" ] || exit 0
PORT="$(cat "${PORT_FILE}" 2>/dev/null)"
[ -n "${PORT}" ] || exit 0
Expand Down Expand Up @@ -127,8 +127,12 @@ def _is_windows() -> bool:
set "HOOK_NAME=%~1"
if "%HOOK_NAME%"=="" set "HOOK_NAME=unknown"

for %%I in ("%CD%") do set "PROJECT_NAME=%%~nxI"
set "PORT_FILE=%USERPROFILE%\\.cce\\projects\\%PROJECT_NAME%\\serve.port"
if defined CCE_PORT_FILE (
set "PORT_FILE=%CCE_PORT_FILE%"
) else (
for %%I in ("%CD%") do set "PROJECT_NAME=%%~nxI"
set "PORT_FILE=%USERPROFILE%\\.cce\\projects\\%PROJECT_NAME%\\serve.port"
)
if not exist "%PORT_FILE%" exit /b 0

set /p PORT=<"%PORT_FILE%"
Expand Down Expand Up @@ -201,9 +205,14 @@ def install_hook_script(target: Path = HOOK_PATH) -> bool:
return True


def install_settings(project_dir: Path) -> dict:
def install_settings(project_dir: Path, port_file_path: Path | None = None) -> dict:
"""Wire all 5 lifecycle hooks into <project>/.claude/settings.json.

When `port_file_path` is provided, the hook command is prefixed with
``CCE_PORT_FILE=<path>`` so that two projects sharing the same basename
(e.g. two repos both called ``api``) each read their own port file
instead of colliding at the basename-derived default path.

Idempotent. Preserves any existing user hooks. Returns a summary dict
with `added` (hook names we wrote) and `skipped` (hook names already
present).
Expand All @@ -226,6 +235,8 @@ def install_settings(project_dir: Path) -> dict:
added: list[str] = []
skipped: list[str] = []

port_env = f"CCE_PORT_FILE={_quote_hook_path(port_file_path)} " if port_file_path else ""

for hook_name in LIFECYCLE_HOOKS:
bucket = hooks.setdefault(hook_name, [])
if _has_cce_hook(bucket):
Expand All @@ -241,7 +252,7 @@ def install_settings(project_dir: Path) -> dict:
"matcher": HOOK_MATCHERS.get(hook_name, ""),
"hooks": [{
"type": "command",
"command": f"{_quote_hook_path(HOOK_PATH)} {hook_name}",
"command": f"{port_env}{_quote_hook_path(HOOK_PATH)} {hook_name}",
}],
})
added.append(hook_name)
Expand Down
25 changes: 24 additions & 1 deletion src/context_engine/serve_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,31 @@ async def _auth(request, handler):
return await handler(request)

remote = request.remote or ""
# Loopback requests skip auth regardless of token setting — local dev UX.
# Loopback requests skip bearer-token auth, but are still subject to
# browser-origin defenses to prevent CSRF from malicious web pages
# POSTing to http://127.0.0.1:<port>/ingest.
if remote in _LOOPBACK_HOSTS:
# Safe methods have no write side effects — always allow.
if request.method in ("GET", "HEAD", "OPTIONS"):
return await handler(request)
# Mutating methods: reject cross-origin browser requests.
# Browsers always send Origin on cross-site requests; local
# tool calls (curl, SDK) do not set it.
origin = request.headers.get("Origin", "")
if origin and not any(
origin.startswith(f"http://{h}") or origin.startswith(f"https://{h}")
for h in _LOOPBACK_HOSTS
):
return web.json_response(
{"error": "cross-origin request rejected"}, status=403
)
# Require application/json to block simple-form POSTs (browsers
# can send application/x-www-form-urlencoded without a preflight).
ct = request.headers.get("Content-Type", "")
if request.path not in ("/health",) and "application/json" not in ct:
return web.json_response(
{"error": "Content-Type must be application/json"}, status=415
)
return await handler(request)

if not expected_token:
Expand Down
76 changes: 76 additions & 0 deletions tests/indexer/test_pipeline_secret_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Regression tests for target_path secret/ignore bypass (issue #127).

Ensures that when `run_indexing` is called with `target_path` pointing
to a single file, the same exclusion rules applied to whole-directory
scans are also applied:

· Secret files (e.g. `.env.local`) are not indexed.
· Files matching `.cceignore` patterns are not indexed.
"""
from __future__ import annotations

import pytest

from context_engine.config import load_config
from context_engine.indexer.pipeline import run_indexing
from context_engine.storage.local_backend import LocalBackend


def _storage(config, project_dir):
from context_engine.utils import project_storage_dir
return project_storage_dir(config, project_dir)


def _chunks_for_file(config, project_dir, rel_path: str) -> list:
backend = LocalBackend(base_path=str(_storage(config, project_dir)))
import asyncio
return asyncio.run(backend.fts_search(rel_path, top_k=50))


@pytest.fixture
def simple_project(tmp_path):
project_dir = tmp_path / "proj"
project_dir.mkdir()
storage_dir = tmp_path / "storage"
storage_dir.mkdir()
config = load_config()
config.storage_path = str(storage_dir)
return project_dir, config


@pytest.mark.asyncio
async def test_target_path_secret_file_not_indexed(simple_project):
"""run_indexing with target_path pointing to a secret file stores no chunks."""
project_dir, config = simple_project
# Create a secret file with real content so chunking would succeed if not blocked
secret = project_dir / ".env.local"
secret.write_text("API_KEY=supersecret\nDB_PASSWORD=hunter2\n")

await run_indexing(config, str(project_dir), target_path=".env.local")

# No chunks should have been indexed for the secret file
storage_base = _storage(config, project_dir)
backend = LocalBackend(base_path=str(storage_base))
# Count total chunks — must be zero since nothing else was indexed
assert backend.count_chunks() == 0, (
"Secret file .env.local was indexed despite being a secret file"
)


@pytest.mark.asyncio
async def test_target_path_cceignore_respected(simple_project):
"""run_indexing with target_path matching a .cceignore pattern skips the file."""
project_dir, config = simple_project
# Write a .cceignore that excludes secrets.txt
(project_dir / ".cceignore").write_text("secrets.txt\n")
# Create the target file with real content
target = project_dir / "secrets.txt"
target.write_text("password = 'hunter2'\ntoken = 'abc123'\n")

await run_indexing(config, str(project_dir), target_path="secrets.txt")

storage_base = _storage(config, project_dir)
backend = LocalBackend(base_path=str(storage_base))
assert backend.count_chunks() == 0, (
"File matching .cceignore pattern was indexed despite being ignored"
)
65 changes: 65 additions & 0 deletions tests/memory/test_hook_installer_slug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Regression tests for hook rendezvous collision fix (issue #128).

Two projects with the same directory basename (e.g. both called ``api``)
previously resolved to the same port file path in the hook script, so
whichever project started its hook server last would silently capture
events for both projects.

The fix bakes the slug-based port file path into the hook command at
``cce init`` time via the ``CCE_PORT_FILE`` env var prefix.
"""
from __future__ import annotations

import json
from pathlib import Path

from context_engine.memory import hook_installer as hi


def test_two_same_basename_projects_get_different_port_files(tmp_path: Path):
"""install_settings with port_file_path stamps each project's hook with
its own port file path, so two projects named 'api' at different absolute
paths do not collide.
"""
# Two projects with the same basename but different absolute locations
proj_a = tmp_path / "workspace_a" / "api"
proj_b = tmp_path / "workspace_b" / "api"
proj_a.mkdir(parents=True)
proj_b.mkdir(parents=True)

port_file_a = tmp_path / "storage_a" / "serve.port"
port_file_b = tmp_path / "storage_b" / "serve.port"

hi.install_settings(proj_a, port_file_path=port_file_a)
hi.install_settings(proj_b, port_file_path=port_file_b)

settings_a = json.loads((proj_a / ".claude" / "settings.json").read_text())
settings_b = json.loads((proj_b / ".claude" / "settings.json").read_text())

def _first_cmd(settings: dict) -> str:
return settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]

cmd_a = _first_cmd(settings_a)
cmd_b = _first_cmd(settings_b)

# Each hook command must reference the project-specific port file
assert str(port_file_a) in cmd_a, f"port_file_a not in cmd_a: {cmd_a}"
assert str(port_file_b) in cmd_b, f"port_file_b not in cmd_b: {cmd_b}"

# The two commands must differ (different port file paths)
assert cmd_a != cmd_b, "Hook commands for distinct projects are identical — collision not fixed"


def test_install_settings_without_port_file_path_omits_env_var(tmp_path: Path):
"""Backward compat: calling install_settings without port_file_path
produces a command without CCE_PORT_FILE, falling back to the basename
resolution in the hook script.
"""
proj = tmp_path / "myproject"
proj.mkdir()

hi.install_settings(proj) # no port_file_path

settings = json.loads((proj / ".claude" / "settings.json").read_text())
cmd = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "CCE_PORT_FILE" not in cmd
8 changes: 4 additions & 4 deletions tests/test_cli_init_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def test_init_auto_detects_pi_dir(tmp_path, monkeypatch):
monkeypatch.setattr("context_engine.cli._run_index", _noop_index)
monkeypatch.setattr("context_engine.cli._check_memory_capture_reachable", lambda config, project: None)
monkeypatch.setattr("context_engine.cli._ensure_session_hook", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project, config=None: None)
monkeypatch.chdir(project)

runner = CliRunner()
Expand Down Expand Up @@ -188,7 +188,7 @@ def test_init_claude_does_not_write_other_instruction_files(tmp_path, monkeypatc
monkeypatch.setattr("context_engine.cli._run_index", _noop_index)
monkeypatch.setattr("context_engine.cli._check_memory_capture_reachable", lambda config, project: None)
monkeypatch.setattr("context_engine.cli._ensure_session_hook", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project, config=None: None)
monkeypatch.chdir(project)

runner = CliRunner()
Expand Down Expand Up @@ -242,7 +242,7 @@ def test_init_all_then_uninstall_shared_mcp_json(tmp_path, monkeypatch):
monkeypatch.setattr("context_engine.cli._run_index", _noop_index)
monkeypatch.setattr("context_engine.cli._check_memory_capture_reachable", lambda config, project: None)
monkeypatch.setattr("context_engine.cli._ensure_session_hook", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project, config=None: None)
monkeypatch.chdir(project)

runner = CliRunner()
Expand Down Expand Up @@ -277,7 +277,7 @@ def test_init_all_writes_every_editor_config_and_instruction_file(tmp_path, monk
monkeypatch.setattr("context_engine.cli._run_index", _noop_index)
monkeypatch.setattr("context_engine.cli._check_memory_capture_reachable", lambda config, project: None)
monkeypatch.setattr("context_engine.cli._ensure_session_hook", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project: None)
monkeypatch.setattr("context_engine.cli._install_memory_hooks", lambda project, config=None: None)
monkeypatch.chdir(project)

runner = CliRunner()
Expand Down
Loading
Loading