diff --git a/pyproject.toml b/pyproject.toml index dbba05ba8..7420c3525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dev = [ # ``pyproject.toml`` declares ``asyncio_mode = "auto"`` (below) which # requires the pytest-asyncio plugin. Without it, async tests fail at # run time with "async def functions are not natively supported." - "pytest-asyncio>=0.23", + "pytest-asyncio>=1.3.0", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 450c77d37..53ed8cfff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ pathspec>=0.11 build>=1.0.0 twine>=5.0.0 pytest>=8.0.0 +pytest-asyncio>=1.3.0 diff --git a/src/hooks/hook_executor.py b/src/hooks/hook_executor.py index 6eaa615a3..8860d0d17 100644 --- a/src/hooks/hook_executor.py +++ b/src/hooks/hook_executor.py @@ -19,6 +19,7 @@ import logging import os import time +import warnings from typing import Any, AsyncGenerator from uuid import uuid4 @@ -28,6 +29,7 @@ HookEvent, HookResult, ) +from src.hooks.trust_gate import should_skip_hook_due_to_trust from src.types.messages import ( create_attachment_message, create_progress_message, @@ -36,38 +38,87 @@ logger = logging.getLogger(__name__) -def _get_hooks_from_settings(tool_use_context: Any) -> dict[str, list[HookConfig]]: +def _get_hooks_from_snapshot(tool_use_context: Any) -> dict[str, list[HookConfig]]: + """Read hooks from the frozen snapshot held on the active HookConfigManager. + + Mirrors typescript/src/utils/hooks/hooksConfigSnapshot.ts:119-124 + (``getHooksConfigFromSnapshot``). settings.json is never re-read implicitly; + snapshot updates flow only through ``HookConfigManager.load()`` (startup) or + ``HookConfigManager.reload_if_changed()`` (explicit /hooks command). + + **Back-compat:** if ``hook_config_manager`` is not set on the context but + ``options.hooks`` is, fall back to the legacy read path with a + ``DeprecationWarning``. This keeps existing callers / tests working during + one CHANGELOG cycle. After the deprecation cycle, the fallback is removed. + """ + manager = getattr(tool_use_context, "hook_config_manager", None) + if manager is not None: + snapshot = getattr(manager, "snapshot", None) + if snapshot is None: + # Manager exists but hasn't been loaded — treat as empty. + return {} + # Defensive copy: callers MUST NOT mutate the returned dict. + return {ev: list(hooks) for ev, hooks in snapshot.hooks.items()} + + # Legacy fallback — emit DeprecationWarning if options.hooks carries data. + return _get_hooks_from_options_legacy(tool_use_context) + + +def _get_hooks_from_options_legacy(tool_use_context: Any) -> dict[str, list[HookConfig]]: + """Legacy read path: ``tool_use_context.options.hooks``. + + Bypasses the snapshot freezing semantic introduced in WI-0.1. Preserved for + one CHANGELOG cycle so existing callers / tests do not break in lockstep + with the rewire. Emits a ``DeprecationWarning`` when it actually returns + data (silent no-op when options.hooks is empty/None). + """ try: options = getattr(tool_use_context, "options", None) if options is None: return {} hooks_config = getattr(options, "hooks", None) - if hooks_config is None: + if hooks_config is None or not hooks_config: + return {} + if not isinstance(hooks_config, dict): return {} - if isinstance(hooks_config, dict): - result: dict[str, list[HookConfig]] = {} - for event_name, hook_list in hooks_config.items(): - if isinstance(hook_list, list): - configs = [] - for h in hook_list: - if isinstance(h, dict): - configs.append(HookConfig( - type=h.get("type", "command"), - command=h.get("command", ""), - timeout=h.get("timeout"), - matcher=h.get("matcher"), - )) - elif isinstance(h, HookConfig): - configs.append(h) - result[event_name] = configs - return result + result: dict[str, list[HookConfig]] = {} + for event_name, hook_list in hooks_config.items(): + if isinstance(hook_list, list): + configs = [] + for h in hook_list: + if isinstance(h, dict): + configs.append(HookConfig( + type=h.get("type", "command"), + command=h.get("command", ""), + timeout=h.get("timeout"), + matcher=h.get("matcher"), + )) + elif isinstance(h, HookConfig): + configs.append(h) + result[event_name] = configs + if result: + warnings.warn( + "Reading hooks from tool_use_context.options.hooks is deprecated; " + "wire a HookConfigManager onto tool_use_context.hook_config_manager " + "and call .load() at bootstrap. The legacy path bypasses the snapshot " + "security model (chapter §'The Snapshot Security Model'). Will be " + "removed two CHANGELOG entries after the rename.", + DeprecationWarning, + stacklevel=3, + ) + return result except Exception: - pass - return {} + return {} + + +# Back-compat alias — preserved for any external test fixtures still importing +# ``_get_hooks_from_settings`` directly. New code uses ``_get_hooks_from_snapshot``. +def _get_hooks_from_settings(tool_use_context: Any) -> dict[str, list[HookConfig]]: + return _get_hooks_from_snapshot(tool_use_context) def has_hook_for_event(event: str, tool_use_context: Any) -> bool: - hooks = _get_hooks_from_settings(tool_use_context) + hooks = _get_hooks_from_snapshot(tool_use_context) return bool(hooks.get(event)) @@ -207,9 +258,25 @@ async def _run_hooks_for_event( abort_signal: Any | None = None, timeout_ms: int = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ) -> AsyncGenerator[dict[str, Any], None]: - hooks = _get_hooks_from_settings(tool_use_context) + # WI-0.2 — workspace-trust gate. Skip non-policy hooks while the workspace + # is untrusted. The per-hook policy check happens below since policy-source + # identification is per-HookConfig. + trust_skip = should_skip_hook_due_to_trust(tool_use_context) + + # WI-0.1 — read from the frozen snapshot, not from options.hooks. The + # snapshot is built once at startup by HookConfigManager.load() and is + # immune to settings.json mutation between trust acceptance and tool calls. + hooks = _get_hooks_from_snapshot(tool_use_context) event_hooks = hooks.get(event, []) + if trust_skip: + # Drop everything that isn't a policy-source hook. ``HookConfig.source`` + # is a ``HookSource`` enum; any non-POLICY value is gated. Imported + # locally to avoid pulling the enum into module-init paths that don't + # need it. + from src.hooks.hook_types import HookSource + event_hooks = [h for h in event_hooks if h.source == HookSource.POLICY] + tool_use_id = stdin_data.get("tool_use_id", str(uuid4())) parent_tool_use_id = "" diff --git a/src/hooks/trust_gate.py b/src/hooks/trust_gate.py new file mode 100644 index 000000000..39cf7711a --- /dev/null +++ b/src/hooks/trust_gate.py @@ -0,0 +1,37 @@ +"""Workspace-trust gate for hook execution. + +Mirrors the TypeScript ``shouldSkipHookDueToTrust`` predicate (referenced from +``typescript/src/utils/hooks/hooksConfigManager.ts``). The chapter +(``ch12-extensibility.md`` §"The Snapshot Security Model") describes this as a +centralized gate at the top of ``executeHooks()`` — introduced after two +vulnerabilities where hooks fired in lifecycle states the user had not consented +to. + +Failure mode without this gate: a project's ``.claude/settings.json`` defines a +hook that fires before every tool call. The user opens the project, declines the +workspace-trust dialog, and the hook still fires once because ``SessionStart`` +runs before the trust check. The gate closes that window. + +Policy hooks (``HookSource.POLICY_SETTINGS``) are NOT subject to this gate per +chapter §"The Snapshot Security Model" final paragraph: "the policy layer +always wins." +""" + +from __future__ import annotations + +from typing import Any + + +def should_skip_hook_due_to_trust(tool_use_context: Any) -> bool: + """Return True if non-policy hooks must be skipped because the workspace + isn't trusted. + + Reads ``tool_use_context.workspace_trusted`` (a bool added on ``ToolContext`` + in WI-0.2). Defaults to ``False`` if the attribute is missing — fail-safe: + unknown trust state is treated as untrusted. + + Returns ``True`` (skip) iff workspace_trusted is False. Callers must still + let policy-source hooks through; the per-hook policy check happens at the + caller, not here, because policy-source identification is per-``HookConfig``. + """ + return not getattr(tool_use_context, "workspace_trusted", False) diff --git a/src/tool_system/context.py b/src/tool_system/context.py index 0134d3e5a..79c6706eb 100644 --- a/src/tool_system/context.py +++ b/src/tool_system/context.py @@ -141,6 +141,25 @@ class ToolContext: # string in that case. session_id: str | None = None + # Chapter-12 / Phase 0 / WI-0.1 — frozen snapshot of hook config. + # The snapshot is built once at startup by ``HookConfigManager.load()`` + # and updated only via explicit channels (the ``/hooks`` command or + # an explicit ``reload_if_changed()`` call). Hook execution reads from + # ``hook_config_manager.snapshot`` instead of ``options.hooks`` so a + # malicious post-trust mutation of ``settings.json`` cannot affect + # in-flight tool calls. + # + # ``options.hooks`` survives as a deprecated fallback for one release + # cycle (see ``_get_hooks_from_snapshot`` in ``src/hooks/hook_executor.py``): + # callers that still pass hooks via options get a ``DeprecationWarning`` + # but their behavior is preserved. + hook_config_manager: Any | None = None + # Chapter-12 / Phase 0 / WI-0.2 — workspace-trust gate. Bootstrap flips + # this to ``True`` after the user accepts the trust dialog. Hooks (other + # than ``HookSource.POLICY``) are skipped while the workspace is + # untrusted, mirroring TS' ``shouldSkipHookDueToTrust`` gate. + workspace_trusted: bool = False + def __post_init__(self) -> None: self.workspace_root = Path(self.workspace_root).resolve() if self.cwd is None: diff --git a/tests/test_snapshot_freezing.py b/tests/test_snapshot_freezing.py new file mode 100644 index 000000000..0fa148a2c --- /dev/null +++ b/tests/test_snapshot_freezing.py @@ -0,0 +1,247 @@ +"""Phase 0 / WI-0.1 — snapshot freezing regression tests. + +The chapter (``ch12-extensibility.md`` §"The Snapshot Security Model") says: + + > captureHooksConfigSnapshot() is called once during startup. From that + > point, executeHooks() reads from the snapshot, never re-reading settings + > files implicitly. + +Before WI-0.1 the Python port had ``_get_hooks_from_settings`` reading +``tool_use_context.options.hooks`` on every call (``hook_executor.py:39-66``) +and ``_run_hooks_for_event`` invoking that function per turn (``:210``). Both +sites bypassed the ``HookConfigSnapshot`` that ``HookConfigManager.load()`` +built. An attacker who edited ``~/.claude/settings.json`` after the trust +dialog could land arbitrary code on the next tool call. + +These tests pin the corrected behavior: + +1. The executor reads from the snapshot, not from ``options.hooks``. +2. Mutating ``settings.json`` after the snapshot is captured does not affect + in-flight tool calls. +3. The legacy ``options.hooks`` fallback emits a ``DeprecationWarning`` when + used. +""" + +from __future__ import annotations + +import asyncio +import json +import warnings +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest + +from src.hooks.config_manager import ( + HookConfigManager, + HookConfigSnapshot, + load_hooks_from_settings, +) +from src.hooks.hook_executor import ( + _get_hooks_from_snapshot, + _run_hooks_for_event, + has_hook_for_event, +) +from src.hooks.hook_types import HookConfig, HookSource +from src.hooks.registry import AsyncHookRegistry + + +@dataclass +class _MockOptions: + hooks: dict[str, Any] | None = None + tools: list[Any] = field(default_factory=list) + + +@dataclass +class _MockContext: + """Minimal stand-in for ToolContext for executor calls.""" + options: _MockOptions = field(default_factory=_MockOptions) + hook_config_manager: Any | None = None + workspace_trusted: bool = True # default True so trust gate doesn't fire + abort_controller: Any | None = None + + +def _build_manager_with_snapshot(hooks: dict[str, list[HookConfig]]) -> HookConfigManager: + """Build a HookConfigManager whose snapshot contains the given hooks. + + Skips disk I/O — directly assigns the snapshot field. The constructor + requires an AsyncHookRegistry for ``self._registry``; we pass a fresh one. + """ + manager = HookConfigManager(registry=AsyncHookRegistry(), settings_path="/dev/null") + manager._snapshot = HookConfigSnapshot(hooks=hooks, timestamp=0.0, source_path=None) + return manager + + +# --------------------------------------------------------------------------- +# WI-0.1 regression — executor reads from snapshot, not from options.hooks +# --------------------------------------------------------------------------- + + +class TestExecutorReadsFromSnapshot: + def test_get_hooks_from_snapshot_when_manager_present(self): + real_hook = HookConfig(type="command", command="echo real", source=HookSource.SETTINGS) + manager = _build_manager_with_snapshot({"PreToolUse": [real_hook]}) + + ctx = _MockContext( + options=_MockOptions(hooks={"PreToolUse": [{"type": "command", "command": "echo BOGUS"}]}), + hook_config_manager=manager, + ) + + # When the manager is present, the snapshot wins. + result = _get_hooks_from_snapshot(ctx) + + assert "PreToolUse" in result + assert len(result["PreToolUse"]) == 1 + assert result["PreToolUse"][0].command == "echo real" + # The bogus options.hooks entry must NOT have leaked through. + assert all("BOGUS" not in (h.command or "") for h in result["PreToolUse"]) + + def test_has_hook_for_event_consults_snapshot(self): + real_hook = HookConfig(type="command", command="echo x", source=HookSource.SETTINGS) + manager = _build_manager_with_snapshot({"PreToolUse": [real_hook]}) + + ctx = _MockContext(hook_config_manager=manager) + + assert has_hook_for_event("PreToolUse", ctx) is True + assert has_hook_for_event("PostToolUse", ctx) is False + + def test_no_manager_no_options_returns_empty(self): + ctx = _MockContext() # both unset + assert _get_hooks_from_snapshot(ctx) == {} + + def test_manager_without_loaded_snapshot_returns_empty(self): + # Manager exists but snapshot was never populated — treat as empty. + manager = HookConfigManager(registry=AsyncHookRegistry(), settings_path="/dev/null") + # _snapshot stays at its default (None per __init__). + ctx = _MockContext(hook_config_manager=manager) + assert _get_hooks_from_snapshot(ctx) == {} + + +# --------------------------------------------------------------------------- +# WI-0.1 regression — _run_hooks_for_event uses snapshot path, not :210 bypass +# --------------------------------------------------------------------------- + + +class TestRunHooksForEventReadsSnapshot: + @pytest.mark.asyncio + async def test_run_hooks_fires_snapshot_hook_not_options_hook(self, tmp_path): + """The headline regression test: settings.json bogus + snapshot real + → only the real hook fires. + """ + # The "real" hook writes to a file we'll inspect. + marker = tmp_path / "real_fired.txt" + real_hook = HookConfig( + type="command", + command=f"echo 'real' > {marker}", + source=HookSource.SETTINGS, + ) + manager = _build_manager_with_snapshot({"PreToolUse": [real_hook]}) + + # Bogus options.hooks would normally fire a different command if the + # bypass were still live. + bogus_marker = tmp_path / "bogus_fired.txt" + ctx = _MockContext( + options=_MockOptions(hooks={ + "PreToolUse": [{ + "type": "command", + "command": f"echo 'bogus' > {bogus_marker}", + }] + }), + hook_config_manager=manager, + ) + + results = [] + async for r in _run_hooks_for_event( + "PreToolUse", "Bash", {"tool_name": "Bash"}, ctx, + ): + results.append(r) + + # Real hook fired → marker file exists with "real" + assert marker.exists() + assert "real" in marker.read_text() + # Bogus hook did NOT fire → marker file does not exist + assert not bogus_marker.exists() + + @pytest.mark.asyncio + async def test_settings_mutation_after_snapshot_does_not_affect_executor(self, tmp_path): + """Write a settings.json with hook A; capture snapshot; mutate to hook B; + execute. Hook A fires (from snapshot), not hook B (mutated on disk). + """ + settings_path = tmp_path / "settings.json" + marker_a = tmp_path / "a_fired.txt" + marker_b = tmp_path / "b_fired.txt" + + settings_path.write_text(json.dumps({ + "hooks": {"PreToolUse": [{ + "type": "command", + "command": f"echo 'A' > {marker_a}", + }]} + })) + + # Capture snapshot (this is what bootstrap would do). + snapshot = load_hooks_from_settings(settings_path) + manager = HookConfigManager(registry=AsyncHookRegistry(), settings_path=settings_path) + manager._snapshot = snapshot + + # Now mutate the settings file on disk to a different hook. + settings_path.write_text(json.dumps({ + "hooks": {"PreToolUse": [{ + "type": "command", + "command": f"echo 'B' > {marker_b}", + }]} + })) + + # Execute — snapshot was captured before the mutation. + ctx = _MockContext(hook_config_manager=manager) + async for _ in _run_hooks_for_event( + "PreToolUse", "Bash", {"tool_name": "Bash"}, ctx, + ): + pass + + # The original (pre-mutation) hook fired. + assert marker_a.exists() + assert "A" in marker_a.read_text() + # The mutated hook did NOT fire — the snapshot froze the original. + assert not marker_b.exists() + + +# --------------------------------------------------------------------------- +# Legacy options.hooks fallback (deprecated, one CHANGELOG cycle) +# --------------------------------------------------------------------------- + + +class TestLegacyOptionsHooksFallback: + def test_legacy_path_emits_deprecation_warning(self): + # No hook_config_manager set; only options.hooks. + ctx = _MockContext( + options=_MockOptions(hooks={ + "PreToolUse": [{"type": "command", "command": "echo legacy"}] + }), + ) + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + result = _get_hooks_from_snapshot(ctx) + + # The legacy path returns the parsed hooks for back-compat. + assert "PreToolUse" in result + assert result["PreToolUse"][0].command == "echo legacy" + + # ...and emits a DeprecationWarning. + deprecation_warnings = [ + w for w in captured if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) >= 1 + msg = str(deprecation_warnings[0].message) + assert "options.hooks" in msg + assert "deprecated" in msg.lower() + + def test_empty_options_hooks_does_not_warn(self): + # Empty options.hooks is the common case; no warning. + ctx = _MockContext(options=_MockOptions(hooks=None)) + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + result = _get_hooks_from_snapshot(ctx) + assert result == {} + assert not any(issubclass(w.category, DeprecationWarning) for w in captured) diff --git a/tests/test_trust_gate.py b/tests/test_trust_gate.py new file mode 100644 index 000000000..27cdf5361 --- /dev/null +++ b/tests/test_trust_gate.py @@ -0,0 +1,168 @@ +"""Phase 0 / WI-0.2 — workspace-trust gate tests. + +The chapter (``ch12-extensibility.md`` §"The Snapshot Security Model") describes +``shouldSkipHookDueToTrust`` as a centralized gate at the top of +``executeHooks()``. Introduced after two CVEs: + - SessionEnd hooks executing when a user *declined* the trust dialog. + - SubagentStop hooks firing before trust was presented. + +Both share the same root cause: hooks firing in lifecycle states where the user +had not consented to workspace code execution. The gate closes that window. + +Policy hooks (``HookSource.POLICY_SETTINGS``) are NOT subject to the gate per +the chapter's "policy layer always wins" semantic. We test all four cells of +the (trusted × policy) matrix. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest + +from src.hooks.config_manager import HookConfigManager, HookConfigSnapshot +from src.hooks.hook_executor import _run_hooks_for_event +from src.hooks.hook_types import HookConfig, HookSource +from src.hooks.registry import AsyncHookRegistry +from src.hooks.trust_gate import should_skip_hook_due_to_trust + + +@dataclass +class _MockOptions: + hooks: dict[str, Any] | None = None + tools: list[Any] = field(default_factory=list) + + +@dataclass +class _MockContext: + options: _MockOptions = field(default_factory=_MockOptions) + hook_config_manager: Any | None = None + workspace_trusted: bool = False + abort_controller: Any | None = None + + +def _manager_with(hooks: dict[str, list[HookConfig]]) -> HookConfigManager: + m = HookConfigManager(registry=AsyncHookRegistry(), settings_path="/dev/null") + m._snapshot = HookConfigSnapshot(hooks=hooks, timestamp=0.0, source_path=None) + return m + + +# --------------------------------------------------------------------------- +# The predicate itself +# --------------------------------------------------------------------------- + + +class TestShouldSkipHookDueToTrust: + def test_untrusted_workspace_skips(self): + ctx = _MockContext(workspace_trusted=False) + assert should_skip_hook_due_to_trust(ctx) is True + + def test_trusted_workspace_does_not_skip(self): + ctx = _MockContext(workspace_trusted=True) + assert should_skip_hook_due_to_trust(ctx) is False + + def test_missing_attribute_treated_as_untrusted(self): + # Bare object without workspace_trusted attribute → fail-safe to True. + class Bare: + pass + assert should_skip_hook_due_to_trust(Bare()) is True + + +# --------------------------------------------------------------------------- +# End-to-end: _run_hooks_for_event respects the gate +# --------------------------------------------------------------------------- + + +class TestExecutorRespectsGate: + @pytest.mark.asyncio + async def test_untrusted_workspace_skips_user_hooks(self, tmp_path): + marker = tmp_path / "user_hook_fired.txt" + user_hook = HookConfig( + type="command", + command=f"echo 'user' > {marker}", + source=HookSource.SETTINGS, + ) + ctx = _MockContext( + workspace_trusted=False, + hook_config_manager=_manager_with({"PreToolUse": [user_hook]}), + ) + + async for _ in _run_hooks_for_event( + "PreToolUse", "Bash", {"tool_name": "Bash"}, ctx, + ): + pass + + # User hook did NOT fire because workspace is untrusted. + assert not marker.exists() + + @pytest.mark.asyncio + async def test_untrusted_workspace_still_runs_policy_hooks(self, tmp_path): + marker = tmp_path / "policy_hook_fired.txt" + policy_hook = HookConfig( + type="command", + command=f"echo 'policy' > {marker}", + source=HookSource.POLICY, # current Python enum value pre-WI-1.2 + ) + ctx = _MockContext( + workspace_trusted=False, + hook_config_manager=_manager_with({"PreToolUse": [policy_hook]}), + ) + + async for _ in _run_hooks_for_event( + "PreToolUse", "Bash", {"tool_name": "Bash"}, ctx, + ): + pass + + # Policy hook DID fire — the policy layer always wins. + assert marker.exists() + assert "policy" in marker.read_text() + + @pytest.mark.asyncio + async def test_trusted_workspace_runs_all_hooks(self, tmp_path): + user_marker = tmp_path / "user.txt" + policy_marker = tmp_path / "policy.txt" + + user_hook = HookConfig( + type="command", + command=f"echo 'u' > {user_marker}", + source=HookSource.SETTINGS, + ) + policy_hook = HookConfig( + type="command", + command=f"echo 'p' > {policy_marker}", + source=HookSource.POLICY, + ) + ctx = _MockContext( + workspace_trusted=True, + hook_config_manager=_manager_with({"PreToolUse": [user_hook, policy_hook]}), + ) + + async for _ in _run_hooks_for_event( + "PreToolUse", "Bash", {"tool_name": "Bash"}, ctx, + ): + pass + + # Both fired. + assert user_marker.exists() + assert policy_marker.exists() + + @pytest.mark.asyncio + async def test_untrusted_workspace_with_only_user_hooks_yields_nothing(self): + """When the gate strips everything, the executor yields no items.""" + user_hook = HookConfig( + type="command", command="echo x", source=HookSource.SETTINGS, + ) + ctx = _MockContext( + workspace_trusted=False, + hook_config_manager=_manager_with({"PreToolUse": [user_hook]}), + ) + + items = [] + async for r in _run_hooks_for_event( + "PreToolUse", "Bash", {"tool_name": "Bash"}, ctx, + ): + items.append(r) + + assert items == [] diff --git a/uv.lock b/uv.lock index fd638611e..68e9a5627 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -257,6 +266,11 @@ dev = [ { name = "twine" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "anthropic" }, @@ -275,6 +289,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "pytest-asyncio", specifier = ">=1.3.0" }] + [[package]] name = "colorama" version = "0.4.6" @@ -952,6 +969,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2"