Ctrl K
ComunidadGuías…

tests/plugins/test_hooks.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Claude Code's command hooks, end to end with real ``sh`` commands: what
each event receives, adds for the model, and blocks."""

from __future__ import annotations

import asyncio
import json
import os
import signal
from pathlib import Path
from typing import Any

import pytest

from noeta.backends.execenvs.local import LocalExecEnv
from noeta.backends.stores.sqlite import SqliteStore
from noeta.core.agent import Agent
from noeta.core.client import Client
from noeta.core.errors import ContextOverflow
from noeta.core.events import Compacted, ExtensionRecorded, ToolFinished, Waiting
from noeta.core.extension import Extension, TaskView
from noeta.core.loop.state import fold
from noeta.core.runner import Runtime
from noeta.core.tool import Tool, ToolContext, ToolOutput, text_output
from noeta.plugins.compaction.transcript import full_conversation
from noeta.plugins.fs import FileTools
from noeta.plugins.hooks import Hooks, parse_hooks
from noeta.plugins.hooks.config import HookCommand
from noeta.plugins.hooks.runners import OUTPUT_CAP, run_command
from noeta.plugins.markdown_commands import MarkdownCommands
from noeta.plugins.skills import Skills
from noeta.testing import ScriptedProvider, reply


async def _no_sleep(_: float) -> None:
    return None


def _hook(command: str, matcher: str = "") -> list[dict[str, Any]]:
    return [{"matcher": matcher, "hooks": [{"type": "command", "command": command}]}]


async def _run(tmp_path: Path, hooks: dict[str, Any], steps: list[Any],
               prompts: tuple[str, ...] = ("go",)) -> tuple[ScriptedProvider, Client, str]:
    work = tmp_path / "w"
    work.mkdir(exist_ok=True)
    (work / "a.txt").write_text("hello\n")
    store = SqliteStore(str(tmp_path / "t.db"))
    provider = ScriptedProvider(steps)
    agent = Agent(name="a", model="m", extensions=[Hooks(hooks=hooks), FileTools()])
    rt = Runtime(store=store, agents={"a": agent}, provider_for=lambda _m: provider,
                 env_for=lambda _s: LocalExecEnv(str(work)), sleep=_no_sleep)
    client = Client(store)
    tid = await client.create("a", prompts[0], config={"cwd": str(work)})
    await rt.run_until_idle()
    for p in prompts[1:]:
        await client.send(tid, p)
        await rt.run_until_idle()
    return provider, client, tid


def _system(provider: ScriptedProvider, i: int) -> list[str]:
    return [m.text for m in provider.requests[i].messages if m.origin == "system"]


async def test_user_prompt_submit_adds_context_and_sees_the_prompt(tmp_path: Path) -> None:
    seen = tmp_path / "seen.json"
    hooks = {"UserPromptSubmit": _hook(f"cat > {seen}; echo 'The build is green.'")}
    provider, _client, _tid = await _run(tmp_path, hooks, [reply("a"), reply("b")],
                                         prompts=("first", "second"))
    green = "UserPromptSubmit hook success: The build is green."
    assert _system(provider, 0) == [green]
    assert _system(provider, 1) == [green, green]
    payload = json.loads(seen.read_text())
    assert payload["hook_event_name"] == "UserPromptSubmit"
    assert payload["prompt"] == "second"  # only the new message
    assert payload["cwd"].endswith("/w")


async def test_user_prompt_submit_can_block_the_turn(tmp_path: Path) -> None:
    hooks = {"UserPromptSubmit": _hook("echo 'no secrets in prompts' >&2; exit 2")}
    provider, client, tid = await _run(tmp_path, hooks, [])
    assert provider.requests == []
    failed = [r.event for r in await client.events(tid)
              if isinstance(r.event, ExtensionRecorded) and r.event.kind == "turn_failed"]
    assert failed and failed[0].data["detail"] == "no secrets in prompts"


async def test_a_blocked_prompt_is_erased_from_later_requests(tmp_path: Path) -> None:
    hooks = {"UserPromptSubmit": _hook("grep -q SECRET && { echo blocked >&2; exit 2; }; exit 0")}
    provider, client, tid = await _run(tmp_path, hooks, [reply("ok")],
                                       prompts=("my SECRET is 42", "hello"))
    assert len(provider.requests) == 1
    texts = [m.text for m in provider.requests[0].messages]
    assert any("hello" in t for t in texts)
    assert not any("SECRET" in t for t in texts)
    # Nor is it in the whole conversation a compaction's transcript saves.
    whole = await full_conversation(client.events, tid)
    assert any("hello" in m.text for m in whole)
    assert not any("SECRET" in m.text for m in whole)


async def _blocked_expansion(tmp_path: Path, extension: Any, prompt: str) -> list[str]:
    hooks = {"UserPromptSubmit": _hook("grep -q SECRET && { echo blocked >&2; exit 2; }; "
                                       "exit 0")}
    work = tmp_path / "w"
    store = SqliteStore(str(tmp_path / "t.db"))
    provider = ScriptedProvider([reply("ok")])
    agent = Agent(name="a", model="m", extensions=[extension, Hooks(hooks=hooks), FileTools()])
    rt = Runtime(store=store, agents={"a": agent}, provider_for=lambda _m: provider,
                 env_for=lambda _s: LocalExecEnv(str(work)), sleep=_no_sleep)
    client = Client(store)
    tid = await client.create("a", prompt, config={"cwd": str(work)})
    await rt.run_until_idle()
    await client.send(tid, "hello")
    await rt.run_until_idle()
    assert len(provider.requests) == 1
    return [m.text for m in provider.requests[0].messages]


async def test_a_blocked_command_loses_its_expansion_too(tmp_path: Path) -> None:
    (tmp_path / "w").mkdir()
    commands = MarkdownCommands([{"name": "deploy", "body": "Deploy with key $ARGUMENTS now."}])
    texts = await _blocked_expansion(tmp_path, commands, "/deploy SECRET42")
    assert any("hello" in t for t in texts)
    assert not any("SECRET" in t for t in texts)


async def test_a_blocked_skill_command_loses_its_expansion_too(tmp_path: Path) -> None:
    skill = tmp_path / "w" / ".noeta" / "skills" / "deploy"
    skill.mkdir(parents=True)
    (skill / "SKILL.md").write_text("---\nname: deploy\ndescription: Ship it.\n---\n"
                                    "Deploy with key $ARGUMENTS now.\n")
    skills = Skills(global_dir=None, global_agents_dir=None)
    texts = await _blocked_expansion(tmp_path, skills, "/deploy SECRET42")
    assert any("hello" in t for t in texts)
    assert not any("SECRET" in t for t in texts)


class _MakeRoom(Extension):
    """Compacts the whole conversation when the model reports an overflow."""

    name = "room"

    async def on_context_overflow(self, view: TaskView, request: Any) -> bool:
        view.compacted(Compacted(summary="(summary)", through=view.state.last_seq,
                                 lead=await view.residents(lead=True, keeps_tail=False),
                                 after=await view.residents(keeps_tail=False)))
        return True


async def test_a_blocked_skill_command_leaves_no_activation_behind(tmp_path: Path) -> None:
    skill = tmp_path / "w" / ".noeta" / "skills" / "deploy"
    skill.mkdir(parents=True)
    (skill / "SKILL.md").write_text("---\nname: deploy\ndescription: Ship it.\n"
                                    "allowed-tools: Bash(rm:*)\n---\n"
                                    "Deploy with key $ARGUMENTS now.\n")
    hooks = {"UserPromptSubmit": _hook("grep -q SECRET && { echo blocked >&2; exit 2; }; "
                                       "exit 0")}
    skills = Skills(global_dir=None, global_agents_dir=None)
    store = SqliteStore(str(tmp_path / "t.db"))
    provider = ScriptedProvider([
        ContextOverflow("too long"), reply("fits"),
        reply("", ("Skill", {"skill": "deploy", "args": "SECRET42"})), reply("done")])
    agent = Agent(name="a", model="m",
                  extensions=[skills, Hooks(hooks=hooks), FileTools(), _MakeRoom()])
    rt = Runtime(store=store, agents={"a": agent}, provider_for=lambda _m: provider,
                 env_for=lambda _s: LocalExecEnv(str(tmp_path / "w")), sleep=_no_sleep)
    client = Client(store)
    tid = await client.create("a", "/deploy SECRET42", config={"cwd": str(tmp_path / "w")})
    await rt.run_until_idle()
    state = fold(await client.events(tid), {"skills": skills.reduce})
    assert state.ext["skills"]["active"] == {}
    await client.send(tid, "hello")
    await rt.run_until_idle()
    # The request rebuilt after the compaction re-attaches no blocked skill.
    assert not any("SECRET" in m.text for m in provider.requests[1].messages)
    await client.send(tid, "use it")
    await rt.run_until_idle()
    kinds = [r.event.kind for r in await client.events(tid)
             if isinstance(r.event, ExtensionRecorded) and r.event.extension == "skills"]
    # The model's own call with the same arguments loads the skill in full.
    assert "reinvoked" not in kinds and kinds[-1] == "activated"
    loaded = provider.requests[-1].messages[-1].text
    assert "Deploy with key SECRET42 now." in loaded and "earlier" not in loaded


async def test_a_blocked_command_leaves_no_pre_approval_behind(tmp_path: Path) -> None:
    (tmp_path / "w").mkdir()
    commands = MarkdownCommands([{"name": "go", "body": "go $ARGUMENTS",
                                  "allowed_tools": "Bash(rm:*)"}])
    await _blocked_expansion(tmp_path, commands, "/go SECRET99")
    store = SqliteStore(str(tmp_path / "t.db"))
    client = Client(store)
    [tid] = [t.task_id for t in await client.tasks()]
    state = fold(await client.events(tid), {"markdown_commands": commands.reduce})
    assert state.ext["markdown_commands"]["allow_rules"] == []


class _Interject(Extension):
    """A ``Say`` tool: the person's ``input["text"]`` arrives mid-turn."""

    name = "interject"

    def __init__(self, client: Client) -> None:
        self.client = client

    def tools(self, view: TaskView) -> list[Tool]:
        return [_SayTool(self.client)]


class _SayTool:
    name = "Say"
    description = "Say."
    input_schema: dict[str, Any] = {"type": "object"}
    read_only = True
    clearable = False

    def __init__(self, client: Client) -> None:
        self.client = client

    async def run(self, input: dict[str, Any], ctx: ToolContext) -> ToolOutput:
        await self.client.send(ctx.task_id, input["text"], when="now")
        return text_output("said")


async def _mid_turn(tmp_path: Path, steps: list[Any],
                    prompts: tuple[str, ...]) -> ScriptedProvider:
    hooks = {"UserPromptSubmit": _hook("grep -q SECRET && { echo blocked >&2; exit 2; }; "
                                       "exit 0")}
    work = tmp_path / "w"
    work.mkdir()
    store = SqliteStore(str(tmp_path / "t.db"))
    provider = ScriptedProvider(steps)
    client = Client(store)
    agent = Agent(name="a", model="m", extensions=[_Interject(client), Hooks(hooks=hooks)])
    rt = Runtime(store=store, agents={"a": agent}, provider_for=lambda _m: provider,
                 env_for=lambda _s: LocalExecEnv(str(work)), sleep=_no_sleep)
    tid = await client.create("a", prompts[0], config={"cwd": str(work)})
    await rt.run_until_idle()
    for p in prompts[1:]:
        await client.send(tid, p)
        await rt.run_until_idle()
    return provider


async def test_a_message_sent_mid_turn_goes_through_the_hook(tmp_path: Path) -> None:
    provider = await _mid_turn(tmp_path, [reply("", ("Say", {"text": "my SECRET is 42"})),
                                          reply("ok")], ("go", "hello"))
    assert all("SECRET" not in m.text for r in provider.requests for m in r.messages)
    assert any(m.text == "hello" for m in provider.requests[-1].messages)


async def test_a_blocked_prompt_leaves_what_the_model_already_saw(tmp_path: Path) -> None:
    provider = await _mid_turn(tmp_path, [reply("", ("Say", {"text": "also check docs"})),
                                          reply("done"), reply("ok")],
                               ("go", "my SECRET is 42", "hello"))
    assert len(provider.requests) == 3
    texts = [m.text for m in provider.requests[-1].messages]
    assert "also check docs" in texts and "hello" in texts
    assert not any("SECRET" in t for t in texts)


async def test_a_prompt_hook_added_later_checks_only_what_the_model_has_not_seen(
        tmp_path: Path) -> None:
    work = tmp_path / "w"
    work.mkdir()
    store = SqliteStore(str(tmp_path / "t.db"))
    provider = ScriptedProvider([reply("noted"), reply("ok")])
    client = Client(store)

    def runtime(hooks: dict[str, Any]) -> Runtime:
        agent = Agent(name="a", model="m", extensions=[Hooks(hooks=hooks)])
        return Runtime(store=store, agents={"a": agent}, provider_for=lambda _m: provider,
                       env_for=lambda _s: LocalExecEnv(str(work)), sleep=_no_sleep)

    tid = await client.create("a", "my SECRET is 42", config={"cwd": str(work)})
    await runtime({}).run_until_idle()
    seen = tmp_path / "seen.json"
    blocking = {"UserPromptSubmit": _hook(f"tee {seen} | grep -q SECRET && "
                                          "{ echo blocked >&2; exit 2; }; exit 0")}
    await client.send(tid, "hello")
    await runtime(blocking).run_until_idle()
    assert json.loads(seen.read_text())["prompt"] == "hello"
    assert len(provider.requests) == 2
    assert [m.text for m in provider.requests[1].messages
            if m.role == "user"][-2:] == ["my SECRET is 42", "hello"]


async def test_pre_tool_use_blocks_matching_calls_only(tmp_path: Path) -> None:
    hooks = {"PreToolUse": _hook("echo 'reads are off today' >&2; exit 2", matcher="Read|Glob")}
    steps = [reply("", ("Read", {"file_path": "a.txt"}), ("Write", {"file_path": "b.txt", "content": "x"})),
             reply("done")]
    _provider, client, tid = await _run(tmp_path, hooks, steps)
    results = {r.event.result.call_id: r.event.result for r in await client.events(tid)
               if isinstance(r.event, ToolFinished)}
    read, write = results.values()
    assert read.is_error and "reads are off today" in read.content[0].text  # type: ignore[union-attr]
    assert not write.is_error


async def test_pre_tool_use_json_can_ask(tmp_path: Path) -> None:
    out = json.dumps({"hookSpecificOutput": {"permissionDecision": "ask",
                                             "permissionDecisionReason": "Read a.txt?"}})
    told = tmp_path / "told.json"
    hooks = {"PreToolUse": _hook(f"echo '{out}'"), "Notification": _hook(f"cat > {told}")}
    _provider, client, tid = await _run(tmp_path, hooks,
                                        [reply("", ("Read", {"file_path": "a.txt"}))])
    waits = [r.event for r in await client.events(tid) if isinstance(r.event, Waiting)]
    assert waits[-1].on == "approval"
    for _ in range(100):  # the Notification hook is not waited for
        if told.exists() and told.read_text():
            break
        await asyncio.sleep(0.02)
    told_json = json.loads(told.read_text())
    assert told_json["message"] == "Noeta needs your permission to use Read"
    assert told_json["notification_type"] == "permission_prompt"


async def test_post_tool_use_feedback_follows_the_result(tmp_path: Path) -> None:
    hooks = {"PostToolUse": _hook("echo 'lint: 2 warnings' >&2; exit 2", matcher="Read")}
    provider, _c, _t = await _run(tmp_path, hooks,
                                  [reply("", ("Read", {"file_path": "a.txt"})), reply("ok")])
    second = provider.requests[1].messages
    assert second[-1].origin == "system"
    assert second[-1].text == ("PostToolUse:Read hook blocking error from command: "
                               "\"echo 'lint: 2 warnings' >&2; exit 2\": lint: 2 warnings")
    assert second[-2].role == "user" and second[-2].content[0].call_id  # type: ignore[union-attr]


async def test_stop_hook_keeps_the_model_going_then_lets_it_stop(tmp_path: Path) -> None:
    marker = tmp_path / "ran_once"
    script = (f"if [ -f {marker} ]; then exit 0; fi; touch {marker}; "
              "echo 'Run the tests before you stop.' >&2; exit 2")
    hooks = {"Stop": _hook(script)}
    provider, client, tid = await _run(tmp_path, hooks, [reply("done"), reply("tests pass")])
    assert len(provider.requests) == 2
    assert provider.requests[1].messages[-1].text == (
        f'Stop hook blocking error from command: "{script}": Run the tests before you stop.')
    assert (await client.get(tid)).status == "waiting"


async def test_stop_hook_that_always_blocks_is_capped(tmp_path: Path) -> None:
    hooks = {"Stop": _hook("echo again >&2; exit 2")}
    provider, _c, _t = await _run(tmp_path, hooks, [reply(str(i)) for i in range(20)])
    assert len(provider.requests) == 9  # the first answer plus eight continuations


def test_parse_and_settings_files(tmp_path: Path) -> None:
    settings = tmp_path / "settings.json"
    settings.write_text(json.dumps({"hooks": {"Stop": _hook("true"), "Bogus": _hook("x")}}))
    hooks = Hooks(settings_files=[settings, tmp_path / "missing.json"],
                  hooks={"Stop": _hook("false")})
    assert [h.command for m in hooks.matchers["Stop"] for h in m.hooks] == ["true", "false"]
    with pytest.raises(ValueError):
        parse_hooks({"Stop": [{"hooks": [{"type": "command"}]}]})
    [m] = parse_hooks({"PreToolUse": _hook("x", matcher="Edit|Write")})["PreToolUse"]
    assert m.matches("Edit") and m.matches("Write") and not m.matches("EditX")


async def test_a_cancelled_command_hook_kills_its_process_group(tmp_path: Path) -> None:
    pid_file = tmp_path / "pid"
    hook = HookCommand(command=f"sleep 30 & echo $! > {pid_file}; wait", timeout=60)
    run = asyncio.create_task(run_command(hook, {}, str(tmp_path)))
    for _ in range(500):
        if pid_file.exists() and pid_file.read_text().strip():
            break
        await asyncio.sleep(0.01)
    pid = int(pid_file.read_text())
    run.cancel()
    with pytest.raises(asyncio.CancelledError):
        await run
    for _ in range(200):
        try:
            os.kill(pid, 0)
        except ProcessLookupError:
            break
        await asyncio.sleep(0.01)
    else:
        os.kill(pid, signal.SIGKILL)
        pytest.fail("the hook's child outlived the cancelled hook")


async def test_a_command_hook_keeps_only_the_head_of_a_flood(tmp_path: Path) -> None:
    hook = HookCommand(command="head -c 3000000 /dev/zero | tr '\\0' x; echo oops >&2")
    result = await run_command(hook, {"big": "x" * 200_000}, str(tmp_path))
    assert result.exit_code == 0
    assert result.stdout == "x" * OUTPUT_CAP and result.stderr == "oops\n"


async def test_a_command_hook_inherits_the_environment_but_noetas_credentials(
        tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("JAVA_HOME", "/opt/jdk")
    monkeypatch.setenv("NOETA_SERVER_TOKEN", "t")
    hook = HookCommand(command='printf "%s|%s" "$JAVA_HOME" "${NOETA_SERVER_TOKEN:-unset}"')
    result = await run_command(hook, {}, str(tmp_path))
    assert result.stdout == "/opt/jdk|unset"


@pytest.mark.parametrize("turns", [0, 3])
async def test_a_prompt_hook_checks_a_continued_tasks_prompts(tmp_path: Path,
                                                              turns: int) -> None:
    """A task that continues a conversation (``history_from``) runs its
    blocking prompt hooks on its own prompts: the source's checked position
    is a seq of the source's log, past the new log's first prompts."""
    work = tmp_path / "w"
    work.mkdir()
    hooks = {"UserPromptSubmit": _hook("grep -q SECRET && { echo blocked >&2; exit 2; }; exit 0")}
    store = SqliteStore(str(tmp_path / "t.db"))
    provider = ScriptedProvider([reply(f"r{i}") for i in range(10)])
    agent = Agent(name="a", model="m", extensions=[Hooks(hooks=hooks), FileTools()])
    rt = Runtime(store=store, agents={"a": agent}, provider_for=lambda _m: provider,
                 env_for=lambda _s: LocalExecEnv(str(work)), sleep=_no_sleep)
    client = Client(store)
    tid = await client.create("a", "hello", config={"cwd": str(work)})
    await rt.run_until_idle()
    for i in range(turns):
        await client.send(tid, f"more {i}")
        await rt.run_until_idle()
    n = len(provider.requests)
    await client.create("a", "my SECRET is 42", config={"cwd": str(work)}, history_from=tid)
    await rt.run_until_idle()
    assert not [r for r in provider.requests[n:] if any("SECRET" in m.text for m in r.messages)]