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", "")