diff --git a/README.md b/README.md
index 03cf3dc..8ddfd5a 100644
--- a/README.md
+++ b/README.md
@@ -275,7 +275,7 @@ This means a single relatum can carry multiple policies with different callbacks
#### Demo example
-The demo ships with a `protected_file_delete` callback (`demo/policies.py`) that inspects `rm` commands across three detection modes: literal filename match, glob expansion against the filesystem, and recursive directory inspection. It demonstrates how a callback can make nuanced, context-aware decisions by inspecting both the command arguments and the actual filesystem state:
+The demo ships with a `protected_file_delete` callback (`examples/langchain_ollama/policies.py`) that inspects `rm` commands across three detection modes: literal filename match, glob expansion against the filesystem, and recursive directory inspection. It demonstrates how a callback can make nuanced, context-aware decisions by inspecting both the command arguments and the actual filesystem state:
```python
# Registered on the delete → file APPLIES_TO relatum via seed_all.py
@@ -283,7 +283,7 @@ Policy(
name="protected_file_delete",
requires_confirmation=False,
trigger_cardinality=None,
- callback="demo.policies.protected_file_delete",
+ callback="examples.langchain_ollama.policies.protected_file_delete",
confirmation_message="Deletion of {action} blocked — protected files at risk.",
)
```
@@ -337,7 +337,7 @@ vre_guard(
**`concepts`** can be static or dynamic. Static is appropriate when a function always touches the same concept domain. Dynamic is appropriate when the concepts depend on the actual arguments — for example, a shell tool that must inspect the command string to know what it touches. Any callable that accepts the same arguments as the decorated function and returns `list[str]` works:
```python
-concepts = ConceptExtractor() # LLM-based — see demo/callbacks.py
+concepts = ConceptExtractor() # LLM-based — see examples/langchain_ollama/callbacks.py
@vre_guard(vre, concepts=concepts)
def shell_tool(command: str) -> str:
@@ -541,13 +541,13 @@ The demo's `DemoLearner` uses ChatOllama structured output to fill templates and
The demo ships a complete LangChain + Ollama agent that exercises all of VRE's enforcement layers against a sandboxed filesystem.
```bash
-poetry run python -m demo.main \
+poetry run python -m examples.langchain_ollama.main \
--neo4j-uri neo4j://localhost:7687 \
--neo4j-user neo4j \
--neo4j-password password \
--model qwen3:8b \
--concepts-model qwen2.5-coder:7b \
- --sandbox demo/workspace
+ --sandbox examples/langchain_ollama/workspace
```
The agent exposes a single `shell_tool` — a sandboxed subprocess executor — guarded by `vre_guard`. Every shell command the LLM decides to run is intercepted before execution:
@@ -562,7 +562,7 @@ The agent exposes a single `shell_tool` — a sandboxed subprocess executor —
### Concept extraction
-The demo uses `ConceptExtractor` (`demo/callbacks.py`) — a callable class that sends each command segment to a local Ollama model and collects the conceptual primitives it identifies. The prompt includes few-shot flag-to-concept examples (e.g. `rm -rf dir/` → delete + directory + file) and an explicit instruction to never return flag names as primitives.
+The demo uses `ConceptExtractor` (`examples/langchain_ollama/callbacks.py`) — a callable class that sends each command segment to a local Ollama model and collects the conceptual primitives it identifies. The prompt includes few-shot flag-to-concept examples (e.g. `rm -rf dir/` → delete + directory + file) and an explicit instruction to never return flag names as primitives.
`ConceptExtractor` is constructed once at startup and reused across calls. It splits compound commands (pipes, `&&`, `;`) into segments and extracts concepts from each independently. The model is configurable via `--concepts-model` (default `qwen2.5-coder:7b`).
@@ -571,7 +571,7 @@ The demo uses `ConceptExtractor` (`demo/callbacks.py`) — a callable class that
### Wiring it together
```python
-# demo/tools.py
+# examples/langchain_ollama/tools.py
from vre.guard import vre_guard
concepts = ConceptExtractor() # LLM-based concept extraction (Ollama)
@@ -617,10 +617,9 @@ VRE ships with a [PreToolUse hook](https://docs.anthropic.com/en/docs/claude-cod
### Install the hook
-```python
-from vre.integrations.claude_code import install
-
-install("neo4j://localhost:7687", "neo4j", "password")
+```bash
+python examples/claude-code/claude_code.py install \
+ --uri neo4j://localhost:7687 --user neo4j --password password
```
This does two things:
@@ -659,10 +658,8 @@ The hook fails open when the VRE config file is absent. Empty commands are allow
### Remove the hook
-```python
-from vre.integrations.claude_code import uninstall
-
-uninstall()
+```bash
+python examples/claude-code/claude_code.py uninstall
```
This removes the VRE hook entry from `~/.claude/settings.json` and leaves `~/.vre/config.json` in place.
@@ -753,22 +750,23 @@ src/vre/
models.py # Candidate models, CandidateDecision, LearningResult
templates.py # TemplateFactory — gap → structured candidate template
engine.py # LearningEngine — template → callback → validate → persist
- integrations/
- claude_code.py # Claude Code PreToolUse hook — two-pass concept protocol
scripts/
clear_graph.py # Clear all primitives from the Neo4j graph
seed_all.py # Seed fully grounded graph (16 primitives)
seed_gaps.py # Seed gap-demonstration graph (10 primitives)
-demo/
- main.py # Entry point — argparse + agent setup
- agent.py # ToolAgent — LangChain + Ollama streaming loop
- tools.py # shell_tool with vre_guard applied
- callbacks.py # ConceptExtractor, on_trace, on_policy, get_cardinality, make_on_learn
- policies.py # Demo PolicyCallback — protected file deletion guard
- learner.py # DemoLearner — ChatOllama structured output + Rich UI
- repl.py # Streaming REPL with Rich Live display
+examples/
+ claude-code/
+ claude_code.py # Claude Code PreToolUse hook — two-pass concept protocol
+ langchain_ollama/
+ main.py # Entry point — argparse + agent setup
+ agent.py # ToolAgent — LangChain + Ollama streaming loop
+ tools.py # shell_tool with vre_guard applied
+ callbacks.py # ConceptExtractor, on_trace, on_policy, get_cardinality, make_on_learn
+ policies.py # Demo PolicyCallback — protected file deletion guard
+ learner.py # DemoLearner — ChatOllama structured output + Rich UI
+ repl.py # Streaming REPL with Rich Live display
```
---
diff --git a/docs/index.html b/docs/index.html
index 0c6360d..6fb2f82 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1029,27 +1029,11 @@
✓ Grounded — EPISTEMIC PERMISSION GRANTED
-
-
- CLAUDE CODE INTEGRATION
-
-
-
- VRE ships with a PreToolUse hook for Claude Code that
- uses a two-pass protocol: the first call blocks and asks Claude to
- identify the conceptual primitives; the second call carries a
- # vre:concept1,concept2 prefix that the hook extracts,
- grounds, and strips via updatedInput before execution.
-
-
- from vre.integrations.claude_code import install
- install("neo4j://localhost:7687", "neo4j", "password")
-
-
- Claude proposes the concepts itself — no static mapping needed.
- When blocked, it reads the epistemic trace, identifies exact gap types,
- and adjusts. A frontier model, structurally constrained by a graph
- it cannot modify.
+
+ VRE does not own concept extraction, cardinality detection, or trace rendering.
+ The integrator decides how to bridge their agent's actions to VRE's epistemic
+ contract. The examples/ directory ships working integrations for
+ Claude Code and LangChain + Ollama.
`
}
diff --git a/demo/__init__.py b/examples/__init__.py
similarity index 100%
rename from demo/__init__.py
rename to examples/__init__.py
diff --git a/src/vre/integrations/claude_code.py b/examples/claude-code/claude_code.py
similarity index 87%
rename from src/vre/integrations/claude_code.py
rename to examples/claude-code/claude_code.py
index cdcc699..e73c535 100644
--- a/src/vre/integrations/claude_code.py
+++ b/examples/claude-code/claude_code.py
@@ -19,13 +19,12 @@
Setup::
- from vre.integrations.claude_code import install
- install("neo4j://localhost:7687", "neo4j", "password")
+ python examples/claude-code/claude_code.py install \
+ --uri neo4j://localhost:7687 --user neo4j --password password
Removal::
- from vre.integrations.claude_code import uninstall
- uninstall()
+ python examples/claude-code/claude_code.py uninstall
Hook protocol::
@@ -45,7 +44,7 @@
_VRE_CONFIG_PATH = Path.home() / ".vre" / "config.json"
-_MODULE = "vre.integrations.claude_code"
+_HOOK_MARKER = "examples/claude-code/claude_code.py"
# Matches a leading `# vre:concept1,concept2` comment line.
_VRE_PREFIX_RE = re.compile(r"^#\s*vre:\s*(.+)")
@@ -65,19 +64,21 @@
def _hook_command() -> str:
"""
- Build the hook command string using the current interpreter's absolute path.
+ Build the hook command string using the current interpreter and this script's
+ absolute path.
This ensures the hook runs in the same virtualenv where VRE is installed,
regardless of what `python` resolves to in Claude Code's shell.
"""
- return f"{shlex.quote(sys.executable)} -m {_MODULE}"
+ script = Path(__file__).resolve()
+ return f"{shlex.quote(sys.executable)} {shlex.quote(str(script))}"
def _is_vre_hook(hook_entry: dict) -> bool:
"""
Check whether a hook entry belongs to VRE, regardless of interpreter path.
"""
- return _MODULE in json.dumps(hook_entry)
+ return _HOOK_MARKER in json.dumps(hook_entry)
_EXIT_ALLOW = 0
@@ -278,4 +279,25 @@ def _run_hook() -> None:
if __name__ == "__main__":
- _run_hook()
+ import argparse
+
+ parser = argparse.ArgumentParser(description="VRE Claude Code hook")
+ sub = parser.add_subparsers(dest="command")
+
+ inst = sub.add_parser("install", help="Install the VRE PreToolUse hook")
+ inst.add_argument("--uri", required=True)
+ inst.add_argument("--user", required=True)
+ inst.add_argument("--password", required=True)
+ inst.add_argument("--database", default="neo4j")
+
+ sub.add_parser("uninstall", help="Remove the VRE PreToolUse hook")
+
+ args = parser.parse_args()
+
+ if args.command == "install":
+ install(args.uri, args.user, args.password, args.database)
+ elif args.command == "uninstall":
+ uninstall()
+ else:
+ # No subcommand — invoked as hook by Claude Code
+ _run_hook()
diff --git a/examples/langchain_ollama/__init__.py b/examples/langchain_ollama/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/demo/agent.py b/examples/langchain_ollama/agent.py
similarity index 100%
rename from demo/agent.py
rename to examples/langchain_ollama/agent.py
diff --git a/demo/callbacks.py b/examples/langchain_ollama/callbacks.py
similarity index 98%
rename from demo/callbacks.py
rename to examples/langchain_ollama/callbacks.py
index 17a69c0..5b51a68 100644
--- a/demo/callbacks.py
+++ b/examples/langchain_ollama/callbacks.py
@@ -14,8 +14,8 @@
from rich.prompt import Confirm
from rich.tree import Tree
-from demo.learner import DemoLearner
-from demo.repl import console
+from examples.langchain_ollama.learner import DemoLearner
+from examples.langchain_ollama.repl import console
from vre.core.policy.models import PolicyViolation
if TYPE_CHECKING:
@@ -43,7 +43,7 @@ class ConceptExtractor:
Mirrors the DemoLearner pattern: ChatOllama chain constructed once in
__init__ and reused across calls. Callable via __call__ so it can be
- passed directly as vre_guard's ``concepts`` parameter.
+ passed directly as vre_guard's `concepts` parameter.
"""
def __init__(self, model: str = "qwen2.5-coder:7b") -> None:
diff --git a/demo/learner.py b/examples/langchain_ollama/learner.py
similarity index 99%
rename from demo/learner.py
rename to examples/langchain_ollama/learner.py
index 220ddc2..ae58b64 100644
--- a/demo/learner.py
+++ b/examples/langchain_ollama/learner.py
@@ -14,7 +14,7 @@
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
-from demo.repl import console
+from examples.langchain_ollama.repl import console
from vre.learning.callback import LearningCallback
from vre.learning.models import (
CandidateDecision,
diff --git a/demo/main.py b/examples/langchain_ollama/main.py
similarity index 75%
rename from demo/main.py
rename to examples/langchain_ollama/main.py
index 901fce5..192f8c3 100644
--- a/demo/main.py
+++ b/examples/langchain_ollama/main.py
@@ -2,7 +2,7 @@
VRE Demo Agent — entry point.
Usage:
- python -m demo.main [--neo4j-uri ...] [--model ...] [--sandbox ...]
+ python -m examples.langchain_ollama.main [--neo4j-uri ...] [--model ...] [--sandbox ...]
"""
from __future__ import annotations
@@ -15,10 +15,10 @@
from vre import VRE
from vre.core.graph import PrimitiveRepository
-from demo.agent import make_agent
-from demo.callbacks import ConceptExtractor, get_cardinality, make_on_learn, on_policy, on_trace
-from demo.repl import run
-from demo.tools import init_tools
+from examples.langchain_ollama.agent import make_agent
+from examples.langchain_ollama.callbacks import ConceptExtractor, get_cardinality, make_on_learn, on_policy, on_trace
+from examples.langchain_ollama.repl import run
+from examples.langchain_ollama.tools import init_tools
def main() -> None:
@@ -27,7 +27,7 @@ def main() -> None:
parser.add_argument("--neo4j-user", default="neo4j")
parser.add_argument("--neo4j-password", default="password")
parser.add_argument("--model", default="qwen3.5:latest")
- parser.add_argument("--sandbox", default="demo/workspace")
+ parser.add_argument("--sandbox", default="examples/langchain_ollama/workspace")
parser.add_argument("--concepts-model", default="qwen2.5-coder:7b")
args = parser.parse_args()
diff --git a/demo/policies.py b/examples/langchain_ollama/policies.py
similarity index 100%
rename from demo/policies.py
rename to examples/langchain_ollama/policies.py
diff --git a/demo/repl.py b/examples/langchain_ollama/repl.py
similarity index 100%
rename from demo/repl.py
rename to examples/langchain_ollama/repl.py
diff --git a/demo/tools.py b/examples/langchain_ollama/tools.py
similarity index 100%
rename from demo/tools.py
rename to examples/langchain_ollama/tools.py
diff --git a/pyproject.toml b/pyproject.toml
index 6435a0e..2684768 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "vre"
-version = "0.3.1"
+version = "0.3.3"
description = "Volute Reasoning Engine — decorator-based epistemic enforcement"
authors = [
{name = "Andrew Greene",email = "anormang@gmail.com"}
@@ -14,7 +14,7 @@ dependencies = [
]
[project.optional-dependencies]
-demo = [
+examples = [
"rich>=14.3.2",
"langchain-core>=1.2.16",
"langchain-ollama>=1.0.1",
diff --git a/scripts/seed_all.py b/scripts/seed_all.py
index 81248e5..a59f6a7 100644
--- a/scripts/seed_all.py
+++ b/scripts/seed_all.py
@@ -881,7 +881,7 @@ def seed_delete(repo: PrimitiveRepository, file: Primitive, directory: Primitive
Policy(
name="ProtectedFileDeletePolicy",
requires_confirmation=True,
- callback="demo.policies.protected_file_delete",
+ callback="examples.langchain_ollama.policies.protected_file_delete",
confirmation_message="Deletion may affect protected files. Proceed?",
),
],
diff --git a/src/vre/integrations/__init__.py b/src/vre/integrations/__init__.py
deleted file mode 100644
index dea66f2..0000000
--- a/src/vre/integrations/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# Copyright 2026 Andrew Greene
-# Licensed under the Apache License, Version 2.0
diff --git a/tests/vre/test_claude_code.py b/tests/vre/test_claude_code.py
deleted file mode 100644
index a59fa4f..0000000
--- a/tests/vre/test_claude_code.py
+++ /dev/null
@@ -1,339 +0,0 @@
-# Copyright 2026 Andrew Greene
-# Licensed under the Apache License, Version 2.0
-
-"""
-Unit tests for vre.integrations.claude_code — install, uninstall, and _run_hook.
-"""
-
-import io
-import json
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from vre.core.grounding import GroundingResult
-from vre.core.policy import PolicyAction, PolicyResult
-from vre.core.policy.models import Policy, PolicyViolation
-
-
-# ── helpers ──────────────────────────────────────────────────────────────────
-
-
-def _stdin(payload: dict) -> io.StringIO:
- return io.StringIO(json.dumps(payload))
-
-
-def _tool_payload(command: str = "") -> dict:
- return {"tool_input": {"command": command}}
-
-
-def _prefixed_command(concepts: str, command: str) -> str:
- return f"# vre:{concepts}\n{command}"
-
-
-# ── install ──────────────────────────────────────────────────────────────────
-
-
-class TestInstall:
-
- def test_creates_config_with_restricted_permissions(self, tmp_path, monkeypatch):
- settings = tmp_path / ".claude" / "settings.json"
- config = tmp_path / ".vre" / "config.json"
- monkeypatch.setattr(
- "vre.integrations.claude_code._SETTINGS_PATH", settings
- )
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
-
- from vre.integrations.claude_code import install
-
- install("neo4j://localhost:7687", "neo4j", "pass")
-
- assert config.exists()
- mode = config.stat().st_mode & 0o777
- assert mode == 0o600
-
- def test_creates_claude_parent_directory(self, tmp_path, monkeypatch):
- settings = tmp_path / ".claude" / "settings.json"
- config = tmp_path / ".vre" / "config.json"
- monkeypatch.setattr(
- "vre.integrations.claude_code._SETTINGS_PATH", settings
- )
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
-
- from vre.integrations.claude_code import install
-
- install("neo4j://localhost:7687", "neo4j", "pass")
-
- assert settings.parent.is_dir()
- assert settings.exists()
-
- def test_idempotent_no_duplicate_hooks(self, tmp_path, monkeypatch):
- settings = tmp_path / ".claude" / "settings.json"
- config = tmp_path / ".vre" / "config.json"
- monkeypatch.setattr(
- "vre.integrations.claude_code._SETTINGS_PATH", settings
- )
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
-
- from vre.integrations.claude_code import install
-
- install("neo4j://localhost:7687", "neo4j", "pass")
- install("neo4j://localhost:7687", "neo4j", "pass")
-
- data = json.loads(settings.read_text())
- hooks = data["hooks"]["PreToolUse"]
- assert len(hooks) == 1
-
-
-# ── uninstall ────────────────────────────────────────────────────────────────
-
-
-class TestUninstall:
-
- def test_removes_hook_entry(self, tmp_path, monkeypatch):
- settings = tmp_path / ".claude" / "settings.json"
- config = tmp_path / ".vre" / "config.json"
- monkeypatch.setattr(
- "vre.integrations.claude_code._SETTINGS_PATH", settings
- )
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
-
- from vre.integrations.claude_code import install, uninstall
-
- install("neo4j://localhost:7687", "neo4j", "pass")
- uninstall()
-
- data = json.loads(settings.read_text())
- assert data["hooks"]["PreToolUse"] == []
-
- def test_safe_when_no_settings_file(self, tmp_path, monkeypatch):
- settings = tmp_path / ".claude" / "settings.json"
- monkeypatch.setattr(
- "vre.integrations.claude_code._SETTINGS_PATH", settings
- )
-
- from vre.integrations.claude_code import uninstall
-
- # Should not raise
- uninstall()
-
-
-# ── _parse_vre_prefix ───────────────────────────────────────────────────────
-
-
-class TestParseVrePrefix:
-
- def test_extracts_concepts_and_clean_command(self):
- from vre.integrations.claude_code import _parse_vre_prefix
-
- result = _parse_vre_prefix("# vre:delete,file\nrm -rf foo/")
- assert result is not None
- concepts, clean = result
- assert set(concepts) == {"delete", "file"}
- assert clean == "rm -rf foo/"
-
- def test_returns_none_without_prefix(self):
- from vre.integrations.claude_code import _parse_vre_prefix
-
- assert _parse_vre_prefix("rm -rf foo/") is None
-
- def test_normalizes_to_lowercase(self):
- from vre.integrations.claude_code import _parse_vre_prefix
-
- result = _parse_vre_prefix("# vre:Delete,File\nrm foo")
- assert result is not None
- concepts, _ = result
- assert concepts == ["delete", "file"]
-
- def test_handles_spaces_around_concepts(self):
- from vre.integrations.claude_code import _parse_vre_prefix
-
- result = _parse_vre_prefix("# vre: delete , file \nrm foo")
- assert result is not None
- concepts, _ = result
- assert set(concepts) == {"delete", "file"}
-
- def test_handles_no_newline(self):
- from vre.integrations.claude_code import _parse_vre_prefix
-
- result = _parse_vre_prefix("# vre:read")
- assert result is not None
- concepts, clean = result
- assert concepts == ["read"]
- assert clean == ""
-
-
-# ── _run_hook ────────────────────────────────────────────────────────────────
-
-
-class TestRunHook:
-
- def test_allows_empty_command(self, monkeypatch, capsys):
- monkeypatch.setattr("sys.stdin", _stdin(_tool_payload("")))
-
- from vre.integrations.claude_code import _run_hook
-
- with pytest.raises(SystemExit) as exc:
- _run_hook()
-
- assert exc.value.code == 0
- out = json.loads(capsys.readouterr().out)
- assert out["hookSpecificOutput"]["permissionDecision"] == "allow"
-
- def test_blocks_command_without_prefix(self, monkeypatch, capsys):
- monkeypatch.setattr(
- "sys.stdin", _stdin(_tool_payload("rm -rf /"))
- )
-
- from vre.integrations.claude_code import _run_hook
-
- with pytest.raises(SystemExit) as exc:
- _run_hook()
-
- assert exc.value.code == 2
- err = capsys.readouterr().err
- assert "# vre:" in err
- assert "conceptual primitives" in err
-
- def test_blocks_ungrounded_concepts(self, tmp_path, monkeypatch, capsys):
- config = tmp_path / ".vre" / "config.json"
- config.parent.mkdir(parents=True)
- config.write_text(json.dumps({
- "uri": "neo4j://localhost:7687",
- "user": "neo4j",
- "password": "pass",
- "database": "neo4j",
- }))
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
- monkeypatch.setattr(
- "sys.stdin",
- _stdin(_tool_payload(_prefixed_command("delete,file", "rm -rf /"))),
- )
-
- grounding = GroundingResult(grounded=False, resolved=["Delete"], gaps=[])
- mock_repo = MagicMock()
- mock_repo.__enter__ = MagicMock(return_value=mock_repo)
- mock_repo.__exit__ = MagicMock(return_value=False)
-
- mock_vre = MagicMock()
- mock_vre.check.return_value = grounding
-
- with patch("vre.core.graph.PrimitiveRepository", return_value=mock_repo), \
- patch("vre.VRE", return_value=mock_vre):
- from vre.integrations.claude_code import _run_hook
-
- with pytest.raises(SystemExit) as exc:
- _run_hook()
-
- assert exc.value.code == 2
-
- def test_allows_grounded_with_updated_input(self, tmp_path, monkeypatch, capsys):
- config = tmp_path / ".vre" / "config.json"
- config.parent.mkdir(parents=True)
- config.write_text(json.dumps({
- "uri": "neo4j://localhost:7687",
- "user": "neo4j",
- "password": "pass",
- "database": "neo4j",
- }))
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
- monkeypatch.setattr(
- "sys.stdin",
- _stdin(_tool_payload(_prefixed_command("read,file", "cat foo.txt"))),
- )
-
- grounding = GroundingResult(grounded=True, resolved=["Read", "File"], gaps=[])
- policy = PolicyResult(action=PolicyAction.PASS, reason="No violations", violations=[])
-
- mock_repo = MagicMock()
- mock_repo.__enter__ = MagicMock(return_value=mock_repo)
- mock_repo.__exit__ = MagicMock(return_value=False)
-
- mock_vre = MagicMock()
- mock_vre.check.return_value = grounding
- mock_vre.check_policy.return_value = policy
-
- with patch("vre.core.graph.PrimitiveRepository", return_value=mock_repo), \
- patch("vre.VRE", return_value=mock_vre):
- from vre.integrations.claude_code import _run_hook
-
- with pytest.raises(SystemExit) as exc:
- _run_hook()
-
- assert exc.value.code == 0
- out = json.loads(capsys.readouterr().out)
- assert out["hookSpecificOutput"]["permissionDecision"] == "allow"
- assert out["hookSpecificOutput"]["updatedInput"]["command"] == "cat foo.txt"
-
- def test_defers_confirmation_required_policy_to_tui(self, tmp_path, monkeypatch, capsys):
- config = tmp_path / ".vre" / "config.json"
- config.parent.mkdir(parents=True)
- config.write_text(json.dumps({
- "uri": "neo4j://localhost:7687",
- "user": "neo4j",
- "password": "pass",
- "database": "neo4j",
- }))
- monkeypatch.setattr(
- "vre.integrations.claude_code._VRE_CONFIG_PATH", config
- )
- monkeypatch.setattr(
- "sys.stdin",
- _stdin(_tool_payload(_prefixed_command("read", "ls /etc"))),
- )
-
- grounding = GroundingResult(grounded=True, resolved=["Read"], gaps=[])
- violation = PolicyViolation(
- policy=Policy(name="ReadPolicy", requires_confirmation=True),
- message="Read access requires confirmation.",
- )
- policy = PolicyResult(
- action=PolicyAction.BLOCK,
- reason="Confirmation required, no handler",
- violations=[violation],
- )
-
- mock_repo = MagicMock()
- mock_repo.__enter__ = MagicMock(return_value=mock_repo)
- mock_repo.__exit__ = MagicMock(return_value=False)
-
- mock_vre = MagicMock()
- mock_vre.check.return_value = grounding
- mock_vre.check_policy.return_value = policy
-
- with patch("vre.core.graph.PrimitiveRepository", return_value=mock_repo), \
- patch("vre.VRE", return_value=mock_vre):
- from vre.integrations.claude_code import _run_hook
-
- with pytest.raises(SystemExit) as exc:
- _run_hook()
-
- assert exc.value.code == 0
- out = json.loads(capsys.readouterr().out)
- assert out["hookSpecificOutput"]["permissionDecision"] == "ask"
- assert "updatedInput" not in out["hookSpecificOutput"]
-
- def test_fails_open_on_unexpected_exception(self, monkeypatch, capsys):
- monkeypatch.setattr("sys.stdin", io.StringIO("NOT JSON{{{"))
-
- from vre.integrations.claude_code import _run_hook
-
- with pytest.raises(SystemExit) as exc:
- _run_hook()
-
- assert exc.value.code == 0
- out = json.loads(capsys.readouterr().out)
- assert out["hookSpecificOutput"]["permissionDecision"] == "allow"
- assert "failing open" in out["hookSpecificOutput"].get("permissionDecisionReason", "")