diff --git a/mcp_server/handlers/consolidation/authoring_prompts.py b/mcp_server/handlers/consolidation/authoring_prompts.py index cb7d2aab..fc6cc93e 100644 --- a/mcp_server/handlers/consolidation/authoring_prompts.py +++ b/mcp_server/handlers/consolidation/authoring_prompts.py @@ -46,6 +46,53 @@ def _wrap_untrusted(text: str) -> str: return f"{_UNTRUSTED_OPEN}\n{text}\n{_UNTRUSTED_CLOSE}" +# Page-kind → specialist agent for optional Task delegation in agents mode. +# The agents themselves come from the user's roster (loaded by +# ``--setting-sources user``); this map only suggests a sensible default per +# page kind. Unknown kinds fall back to ``_DEFAULT_SPECIALIST``. +_KIND_SPECIALIST: dict[str, str] = { + "file-doc": "architect", + "reference": "architect", + "architecture": "architect", + "services": "architect", + "api": "engineer", + "ci-cd": "devops-engineer", + "mcp": "engineer", + "adr": "architect", + "decision": "architect", + "spec": "paper-writer", + "note": "architect", +} +_DEFAULT_SPECIALIST = "architect" + + +def _delegation_hint(kind: str) -> str: + """Build the optional Task-delegation paragraph for an authoring prompt. + + Returns a self-contained Markdown section (trailing blank line included) + inviting the model to delegate deep, read-only codebase analysis to a + specialist subagent via the ``Task`` tool, then synthesise the findings + into the page itself. The caller (``headless_authoring._delegation_hint_for``) + decides whether to include it at all — gated on the agents-mode knob — + so this builder stays pure and never reads the environment. + + Pre-condition: ``kind`` is a wiki page kind (may be unknown). + Post-condition: returns a non-empty paragraph; the suggested specialist + is ``_KIND_SPECIALIST[kind]`` or ``_DEFAULT_SPECIALIST``. + """ + agent = _KIND_SPECIALIST.get(kind, _DEFAULT_SPECIALIST) + return ( + "## You may delegate analysis (optional)\n\n" + "A roster of specialist subagents is available through the **Task** " + f"tool. For a `{kind}` page the **{agent}** agent is well-suited to " + "map the structure before you write; spawn one (or several, for " + "independent facets — callers, invariants, failure modes) to gather " + "grounded findings, then SYNTHESISE them into the page YOURSELF. " + "Subagents are read-only (Read/Glob/Grep) and return analysis, not " + "file writes. Delegation is optional — skip it for simple pages.\n\n" + ) + + def _find_gap_marker( body: str, gap_name: str, gap_description: str ) -> tuple[int, int] | None: @@ -79,11 +126,14 @@ def _build_section_prompt( gap_name: str, gap_description: str, source_text: str | None, + delegate_hint: str | None = None, ) -> str: """Construct the LLM prompt for one missing section. Pre-condition: all string parameters are well-typed; ``source_text`` - may be None when the source file is unavailable. + may be None when the source file is unavailable; + ``delegate_hint`` is the optional agents-mode Task + delegation paragraph (None in solo mode). Post-condition: returned prompt includes the security guard header and wraps every untrusted block in the delimiter so the model treats source material as data, not @@ -113,6 +163,7 @@ def _build_section_prompt( f"`{page_path}` (title: {title!r}, project: {domain!r}).\n\n" f"The section to author is **{gap_name}**. The curation gap " f"description states:\n\n{safe_gap_desc}\n\n" + f"{delegate_hint or ''}" f"## What I want from you\n\n" f"Write JUST the body of the `## {gap_name.title()}` section as Markdown. " f"Do NOT include the heading line itself (I'll add it). Do NOT add a " @@ -262,13 +313,15 @@ def _build_page_prompt( page_meta: dict[str, Any], gaps: list[str], source_text: str | None, + delegate_hint: str | None = None, ) -> str: """Construct a single prompt that asks Claude to author every missing section on the page, formatted as a strict heading-delimited block we can parse. Pre-condition: ``gaps`` is a non-empty list of known gap slugs; - ``source_text`` may be None. + ``source_text`` may be None; ``delegate_hint`` is the + optional agents-mode Task delegation paragraph. Post-condition: returned prompt includes the security guard header and wraps every untrusted block (source text, gap descriptions derived from frontmatter) in the @@ -312,6 +365,7 @@ def _build_page_prompt( f"{_UNTRUSTED_GUARD}\n\n" f"You are authoring missing sections for the wiki file-doc " f"of `{source_path}` in project `{domain}`.\n\n" + f"{delegate_hint or ''}" f"## Ground your writing in codebase intelligence FIRST\n\n" f"Before drafting, extract structural facts about the file " f"using whatever tools are available. Try in this order; " diff --git a/mcp_server/handlers/consolidation/claude_cli.py b/mcp_server/handlers/consolidation/claude_cli.py new file mode 100644 index 00000000..d4afee8a --- /dev/null +++ b/mcp_server/handlers/consolidation/claude_cli.py @@ -0,0 +1,154 @@ +"""``claude -p`` argv + child-environment construction. + +Split out of ``headless_authoring`` to keep that module under the 500-line +size limit (Fowler: Extract Function). These are pure builders — no +subprocess, no I/O beyond reading ``os.environ``. The async invocation that +consumes them (``_claude_invoke``) stays in ``headless_authoring`` because it +is the patchable seam tests target. + +Patchability contract: the runtime knobs (``CORTEX_HEADLESS_AGENTS``, +``CORTEX_HEADLESS_AUTH``) and ``_CLAUDE_BIN`` are read off the root module at +CALL time, so ``monkeypatch.setattr(headless_authoring, ...)`` is observed — +matching the scanner/drain sibling pattern documented in +``headless_authoring``'s module docstring. + +Security model (audit B-1) — the argv differs by mode: + +Agents mode (``CORTEX_HEADLESS_AGENTS=1``, the default) — load the user's +zetetic agent roster and let the top-level authoring agent delegate read-only +analysis to specialists. TWO INDEPENDENT ENFORCING CONTROLS keep it safe: + + Control A — ``--setting-sources user`` + Loads ONLY the user's settings/agents/hooks. Project and local sources + are NOT loaded, so a malicious repository cannot inject + ``permissions.allow:["Bash"]`` or a malicious hook — the original B-1 + project-injection vector stays closed. The roster that DOES load is the + user's own, authored by the user, hence trusted: the threat model here + is malicious *source material being documented*, not malicious *user + config*. Verified empirically against claude CLI 2.1.197 (2026-06-30): + paper-writer / architect / feynman / zetetic-team-subagents:* load; + project/local agents do not. + + Control B — ``--disallowedTools "Write,Edit,Bash,NotebookEdit"`` + A HARD DENY ceiling. Verified empirically (2026-06-30) to PROPAGATE to + spawned subagents: a delegated write-capable ``engineer`` received only + Read/Glob/Grep + MCP; a top-level ``Write`` attempt returned "not + enabled" and produced no file. So the roster can analyse but never + write or execute, transitively. ``--tools "Read,Glob,Grep,Task"`` adds + ``Task`` (delegation) to the read-only built-in set. + + Hooks side effect: ``--setting-sources user`` also loads the user's hooks. + They are neutralised by ``CORTEX_HEADLESS_AUTHORING_CHILD=1`` (see + ``_subprocess_env`` and ``mcp_server.hooks._headless_guard``). + + MCP note: ``--setting-sources user`` also loads the user's MCP servers, + which the authoring prompts use for grounding (codebase_context etc.). + ``--disallowedTools`` does not gate MCP tools, but those servers are the + user's own (trusted). A write-capable MCP tool call is possible but is + soft pollution, not a sandbox breach, and the prompts never request it. + +Solo mode (``CORTEX_HEADLESS_AGENTS=0``) — hardened config isolation: + + Control — ``--safe-mode`` + Disables CLAUDE.md, skills, plugins, hooks, MCP servers, custom + commands/agents, and project/user settings. Same malicious-settings and + malicious-hook defence, no roster, no Task tool. Chosen over ``--bare`` + deliberately: ``--bare`` forces API-key billing (OAuth/keychain never + read), whereas ``--safe-mode`` keeps the subscription intact. + ``--tools "Read,Glob,Grep"`` is the read-only built-in set. + +Common to both modes: + + ``--output-format json`` — single JSON object with ``result`` (assistant + text) and ``total_cost_usd`` (client-side spend). ``--print`` / + ``--no-session-persistence`` — one-shot, no session file. + + ``--add-dir `` (when given) — EXTENDS readable scope; it does + NOT confine reads. Residual read-exfiltration risk is unchanged from the + pre-agents design; full FS isolation would need ``--sandbox`` (out of + scope). NOTE: ``--add-dir`` is variadic (````); a + positional prompt placed after it is swallowed as a second directory and + the CLI errors "Input must be provided". The prompt is therefore passed + via STDIN (see ``_claude_invoke``), never as a positional argument — + removing all argv-ordering fragility. + + Advisory (NOT counted as enforcing): the ``_wrap_untrusted`` delimiter in + ``authoring_prompts`` demotes file content to DATA. Defence-in-depth + only — it relies on model instruction-following, not a hard sandbox. + +Sources: https://code.claude.com/docs/en/cli-reference + https://code.claude.com/docs/en/headless +""" + +from __future__ import annotations + +import os + +from . import headless_authoring as _root + +# Anthropic credential keys stripped from the child env in subscription mode. +_API_CREDENTIAL_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN") + +# Stamped into every authoring child's env so the user's Cortex hooks — loaded +# by ``--setting-sources user`` in agents mode — early-exit instead of running +# (recursion + pollution guard). See ``mcp_server.hooks._headless_guard``. Set +# unconditionally: harmless in solo mode (``--safe-mode`` disables hooks), load- +# bearing in agents mode. +_HEADLESS_CHILD_FLAG = "CORTEX_HEADLESS_AUTHORING_CHILD" + +# Hard tool-deny ceiling applied in agents mode. Propagates to subagents. +_AGENTS_DISALLOWED_TOOLS = "Write,Edit,Bash,NotebookEdit" + + +def _subprocess_env() -> dict[str, str]: + """Child env for ``claude -p``: auth method + hook-neutralising flag. + + Default ``CORTEX_HEADLESS_AUTH=subscription`` strips API-key credentials + so the CLI uses the logged-in subscription (no API charge). Setting + ``CORTEX_HEADLESS_AUTH=api`` passes the parent env through unchanged, so a + present ``ANTHROPIC_API_KEY`` bills the API — an explicit user opt-in. + + In both cases ``CORTEX_HEADLESS_AUTHORING_CHILD=1`` is set so the user's + Cortex hooks (loaded by ``--setting-sources user`` in agents mode) no-op + inside the child — preventing the consolidation-recursion and + memory-pollution that running them there would cause. + """ + mode = os.getenv("CORTEX_HEADLESS_AUTH", "subscription").strip().lower() + if mode == "api": + env = dict(os.environ) + else: + env = {k: v for k, v in os.environ.items() if k not in _API_CREDENTIAL_KEYS} + env[_HEADLESS_CHILD_FLAG] = "1" + return env + + +def _build_argv(source_root: str | None) -> list[str]: + """Build the ``claude -p`` argv for the active mode. + + Reads ``_root.CORTEX_HEADLESS_AGENTS`` at call time (patchable). See this + module's docstring for the per-mode security argument. The prompt is NOT + part of the argv — it is fed via STDIN by ``_claude_invoke`` (the variadic + ``--add-dir`` would otherwise swallow a trailing positional prompt). + + Post-condition: returns the option-only argv; the tool surface is + read-only in both modes (no Write/Edit/Bash). + """ + argv = [_root._CLAUDE_BIN, "--print", "--no-session-persistence"] + if _root.CORTEX_HEADLESS_AGENTS: + # Agents mode: roster + Task delegation under a hard write/exec ceiling. + argv += [ + "--setting-sources", + "user", + "--tools", + "Read,Glob,Grep,Task", + "--disallowedTools", + _AGENTS_DISALLOWED_TOOLS, + ] + else: + # Solo mode: hardened config isolation, no roster, no delegation. + argv += ["--safe-mode", "--tools", "Read,Glob,Grep"] + argv += ["--output-format", "json"] + if source_root: + # Extends readable scope to include source_root; does NOT confine. + argv += ["--add-dir", source_root] + return argv diff --git a/mcp_server/handlers/consolidation/drain_operations.py b/mcp_server/handlers/consolidation/drain_operations.py index 0c41eb0f..6e34f748 100644 --- a/mcp_server/handlers/consolidation/drain_operations.py +++ b/mcp_server/handlers/consolidation/drain_operations.py @@ -83,6 +83,7 @@ async def drain_one( gap_name=_gap_heading(gap_name), gap_description=gap_desc, source_text=source_text, + delegate_hint=_root._delegation_hint_for(meta.get("kind") or "file-doc"), ) ir = await invoke(prompt, source_root=src_root) response = ir.text @@ -178,6 +179,7 @@ async def drain_all_gaps_on_page( page_meta=meta, gaps=gaps, source_text=source_text, + delegate_hint=_root._delegation_hint_for(meta.get("kind") or "file-doc"), ) ir = await invoke(prompt, source_root=src_root) base_ms = int((time.monotonic() - start) * 1000) @@ -318,6 +320,7 @@ async def drain_missing_anchors( scope_title=sc.scope.title, scope_description=sc.scope.description, source_root=src_root, + delegate_hint=_root._delegation_hint_for(sc.scope.suggested_kind), ) ir = await invoke(prompt, cwd=src_root, source_root=src_root) response = ir.text diff --git a/mcp_server/handlers/consolidation/headless_authoring.py b/mcp_server/handlers/consolidation/headless_authoring.py index 925ded8e..c57ad239 100644 --- a/mcp_server/handlers/consolidation/headless_authoring.py +++ b/mcp_server/handlers/consolidation/headless_authoring.py @@ -160,6 +160,20 @@ def _env_float(name: str, default: float) -> float: "CORTEX_HEADLESS_MAX_FILE_DRAINS", MAX_DRAINS_PER_CYCLE ) +# Agents mode — selects the ``claude -p`` invocation strategy. +# 1 (default): load the user's zetetic agent ROSTER (--setting-sources user) +# and give the top-level authoring agent the ``Task`` tool so it can +# delegate read-only codebase analysis to specialists (architect, +# engineer, …). A hard ``--disallowedTools`` ceiling (Write/Edit/Bash/ +# NotebookEdit) propagates to every spawned subagent, so the roster can +# analyse but never write or execute. User hooks load too — they are +# neutralised by CORTEX_HEADLESS_AUTHORING_CHILD (see _subprocess_env). +# 0: hardened solo path — ``--safe-mode`` config isolation, no roster, no +# Task tool. Use when you want zero user-config surface in the child. +# Policy knob, not a measured constant. Default 1 reflects the design intent: +# diverse specialist grounding beats a single generalist pass. +CORTEX_HEADLESS_AGENTS: int = _env_int("CORTEX_HEADLESS_AGENTS", 1) + # ── Core data types ─────────────────────────────────────────────────────── @@ -262,23 +276,6 @@ class _AnchorCandidate: suggested_kind: str -_API_CREDENTIAL_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN") - - -def _subprocess_env() -> dict[str, str]: - """Child env for ``claude -p``, choosing the auth method. - - Default ``CORTEX_HEADLESS_AUTH=subscription`` strips API-key credentials - so the CLI uses the logged-in subscription (no API charge). Setting - ``CORTEX_HEADLESS_AUTH=api`` passes the parent env through unchanged, so a - present ``ANTHROPIC_API_KEY`` bills the API — an explicit user opt-in. - """ - mode = os.getenv("CORTEX_HEADLESS_AUTH", "subscription").strip().lower() - if mode == "api": - return dict(os.environ) - return {k: v for k, v in os.environ.items() if k not in _API_CREDENTIAL_KEYS} - - async def _claude_invoke( prompt: str, *, @@ -292,93 +289,33 @@ async def _claude_invoke( the call is non-blocking on the event loop. On timeout the subprocess is killed and an empty InvokeResult is returned. - Security controls (audit B-1) — TWO INDEPENDENT ENFORCING CONTROLS: - - Control 1 — ``--tools "Read,Glob,Grep"`` - Restricts which built-in tools Claude can use: Bash, Edit, and - Write are removed from the model's context entirely. This is - NOT the same as ``--allowedTools``, which auto-approves tools - but does NOT remove them — ``--allowedTools`` would leave Bash - available and is therefore INCORRECT for confinement. - Source: https://code.claude.com/docs/en/cli-reference (--tools flag) - https://code.claude.com/docs/en/headless (headless mode) - - Control 2 — ``--safe-mode`` - Disables CLAUDE.md, skills, plugins, hooks, MCP servers, custom - commands/agents, and project/user settings. This defeats the same - malicious-settings vector (permissions.allow:["Bash"]) and the - malicious-hook vector that ``--bare`` did. We use ``--safe-mode`` - rather than ``--bare`` deliberately: ``--bare`` documents - "Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via - --settings (OAuth and keychain are never read)" — i.e. it forces - API-key *billing* and ignores a logged-in subscription. ``--safe-mode`` - gives the same config isolation while leaving OAuth/keychain intact, - so the drain runs on the user's Claude subscription (no API charge). - Source: https://code.claude.com/docs/en/cli-reference (--safe-mode, --bare) - - Auth — subscription by default, API key opt-in - ``--safe-mode`` imposes no auth method; the CLI picks API-key billing - whenever ``ANTHROPIC_API_KEY`` is in its env, otherwise it falls back - to the keychain/OAuth subscription. ``create_subprocess_exec`` inherits - the parent env by default, so the auth is chosen via the child env: - * ``CORTEX_HEADLESS_AUTH=subscription`` (default) — strip - ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` from the child env - so the call runs on the logged-in subscription (no API charge). - * ``CORTEX_HEADLESS_AUTH=api`` — pass the parent env through, so a - present ``ANTHROPIC_API_KEY`` bills the API (the user opted in). - See ``_subprocess_env``. Verified against claude CLI 2.1.197. - - Control 3 — ``--output-format json`` - Emits a single JSON object. Documented top-level fields we - rely on: ``result`` (str — assistant text) and - ``total_cost_usd`` (float — client-side spend estimate). - ``usage`` and ``is_error`` are NOT guaranteed in the CLI JSON - — we detect errors via subprocess returncode only. - Source: https://code.claude.com/docs/en/headless (output-format) - - Advisory (NOT counted as enforcing): ``_wrap_untrusted`` / ``_UNTRUSTED_GUARD`` - Prompt-level injection defence — demotes untrusted file content - to DATA in the model's context. Defence-in-depth only; it relies - on model instruction-following and is not a hard sandbox. - - Note on ``--add-dir``: when provided it EXTENDS the readable scope - to include ``source_root`` alongside cwd. It does NOT confine - reads — the model can still read files outside that directory. - Residual read-exfiltration risk remains; full FS isolation would - require ``--sandbox`` (out of scope here). - Source: https://code.claude.com/docs/en/cli-reference (--add-dir flag) + The argv and child environment — including the full audit-B-1 security + argument for both agents mode (the default, loading the user's zetetic + roster under a hard write/exec ceiling) and solo ``--safe-mode`` mode — + are built by ``claude_cli._build_argv`` / ``claude_cli._subprocess_env``. + Auth is subscription-by-default, ``CORTEX_HEADLESS_AUTH=api`` opt-in. + + Response parsing relies on ``--output-format json``: ``result`` (assistant + text) and ``total_cost_usd`` (client-side spend). ``usage`` / ``is_error`` + are NOT guaranteed in the CLI JSON — errors are detected via subprocess + returncode only. + + The prompt is fed via STDIN, not as a positional argv element: the + variadic ``--add-dir`` would otherwise swallow a trailing prompt and the + CLI would error "Input must be provided". See ``claude_cli._build_argv``. """ - # Control 1: --tools restricts (removes) Bash/Edit/Write from the model - # context. Control 2: --safe-mode isolates config (no malicious - # settings/hooks) WITHOUT disabling OAuth/keychain, so the call runs on - # the user's Claude subscription. Control 3: --output-format json for - # structured cost telemetry. - # source: https://code.claude.com/docs/en/cli-reference - # https://code.claude.com/docs/en/headless - argv = [ - _CLAUDE_BIN, - "--print", - "--no-session-persistence", - "--safe-mode", - "--tools", - "Read,Glob,Grep", - "--output-format", - "json", - ] - if source_root: - # Extends readable scope to include source_root; does NOT confine. - argv += ["--add-dir", source_root] - argv.append(prompt) + argv = _build_argv(source_root) call_timeout = timeout if timeout is not None else float(CLAUDE_CALL_TIMEOUT_SEC) - # Default to the subscription; pass the API key through only when the - # user opts in via CORTEX_HEADLESS_AUTH=api. See _subprocess_env. + # Subscription by default + hook-neutralising child flag; API key passes + # through only on CORTEX_HEADLESS_AUTH=api opt-in. See claude_cli. child_env = _subprocess_env() try: proc = await asyncio.create_subprocess_exec( *argv, + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, @@ -393,7 +330,7 @@ async def _claude_invoke( try: stdout_bytes, stderr_bytes = await asyncio.wait_for( - proc.communicate(), timeout=call_timeout + proc.communicate(input=prompt.encode("utf-8")), timeout=call_timeout ) except asyncio.TimeoutError: logger.warning( @@ -454,6 +391,22 @@ async def _claude_invoke( return InvokeResult(text=text, cost_usd=cost_usd) +def _delegation_hint_for(kind: str) -> str | None: + """Return the Task-delegation prompt paragraph for ``kind``, or None. + + Gated on ``CORTEX_HEADLESS_AGENTS`` (module global — patchable in tests). + In solo mode the ``claude -p`` call has no ``Task`` tool and no agent + roster, so a delegation hint would point the model at an unavailable + tool; return None to omit it. In agents mode, delegate to + ``authoring_prompts._delegation_hint`` (the pure string builder). + """ + if not CORTEX_HEADLESS_AGENTS: + return None + from .authoring_prompts import _delegation_hint + + return _delegation_hint(kind) + + # ── Re-exports — the public import surface (see module docstring) ───────── # # These siblings import THIS module as ``_root`` and read the patchable @@ -461,6 +414,7 @@ async def _claude_invoke( # constant / type / ``_claude_invoke`` definition above. This is a # deliberate, load-order-sensitive circular import. +from .claude_cli import _build_argv, _subprocess_env # noqa: E402 from .candidate_scan import ( # noqa: E402 _collect_anchor_candidates, _scan_pages_with_gaps, @@ -490,7 +444,11 @@ async def _claude_invoke( "CORTEX_HEADLESS_USD_BUDGET", "CORTEX_HEADLESS_MAX_FILE_DRAINS", "CORTEX_HEADLESS_MAX_ANCHOR_DRAINS", + "CORTEX_HEADLESS_AGENTS", "CLAUDE_CALL_TIMEOUT_SEC", "_CLAUDE_BIN", + "_build_argv", + "_subprocess_env", + "_delegation_hint_for", "MAX_DRAINS_PER_CYCLE", ] diff --git a/mcp_server/handlers/consolidation/page_io.py b/mcp_server/handlers/consolidation/page_io.py index 8682ec85..06e388de 100644 --- a/mcp_server/handlers/consolidation/page_io.py +++ b/mcp_server/handlers/consolidation/page_io.py @@ -220,6 +220,7 @@ def _scope_anchor_prompt( scope_title: str, scope_description: str, source_root: str, + delegate_hint: str | None = None, ) -> str: """Build the prompt that asks Claude to author a project anchor page. @@ -293,6 +294,7 @@ def _scope_anchor_prompt( f"The page you must produce is the **{scope_title}** anchor " f"(scope: `{scope_name}`). The scope description is:\n\n" f"{safe_scope_description}\n\n" + f"{delegate_hint or ''}" f"## Ground your writing in codebase intelligence FIRST\n\n" f"Before drafting the page, use whatever codebase-intelligence " f"tools are available to extract structural facts about the " diff --git a/mcp_server/handlers/consolidation/wiki_maintenance.py b/mcp_server/handlers/consolidation/wiki_maintenance.py index 81da5920..811a76b3 100644 --- a/mcp_server/handlers/consolidation/wiki_maintenance.py +++ b/mcp_server/handlers/consolidation/wiki_maintenance.py @@ -46,6 +46,11 @@ def _headless_authoring_enabled() -> bool: * ``CORTEX_HEADLESS_AUTH`` (default ``subscription``) — ``subscription`` runs on the logged-in Claude session (no API charge); ``api`` bills ``ANTHROPIC_API_KEY`` instead. + * ``CORTEX_HEADLESS_AGENTS`` (default 1) — ``1`` loads the user's zetetic + agent roster (``--setting-sources user``) and lets the authoring agent + delegate read-only analysis via ``Task`` under a hard write/exec + ceiling (richer grounding, higher per-page cost); ``0`` runs the + hardened solo ``--safe-mode`` path with no roster. * ``CORTEX_HEADLESS_CONCURRENCY`` (default 4) — max in-flight calls. * ``CORTEX_HEADLESS_BUDGET_SEC`` (default 300) — wall-clock deadline. * ``CORTEX_HEADLESS_USD_BUDGET`` (default 5.0) — per-cycle cap on the diff --git a/mcp_server/hooks/_headless_guard.py b/mcp_server/hooks/_headless_guard.py new file mode 100644 index 00000000..7b9734c8 --- /dev/null +++ b/mcp_server/hooks/_headless_guard.py @@ -0,0 +1,56 @@ +"""Suppress Cortex hooks inside the headless wiki-authoring subprocess. + +The headless authoring worker (``handlers.consolidation.headless_authoring``) +can spawn ``claude -p --setting-sources user`` so the user's full zetetic +agent roster authors wiki pages. ``--setting-sources user`` loads the user's +*agents* — but it also loads the user's *hooks*. Letting those hooks run +inside the authoring child is wrong on two counts: + +* **Recursion.** The ``SessionEnd`` hook (:mod:`session_lifecycle`) runs the + consolidation "dream" cycle, which re-enters wiki maintenance and can + re-trigger headless authoring → another ``claude -p`` → ``SessionEnd`` → + … an unbounded fork loop. :mod:`consolidate_background` is the same vector. +* **Pollution.** ``PostToolUse`` capture (:mod:`post_tool_capture`) would + record the authoring agent's own ``Read``/``Grep`` calls as memories; + ``UserPromptSubmit``/``SessionStart`` hooks (:mod:`auto_recall`, + :mod:`session_start`) would inject context the authoring agent never asked + for; ``SubagentStart`` briefing (:mod:`agent_briefing`) would fire for every + spawned specialist. + +The worker marks its child environment with +``CORTEX_HEADLESS_AUTHORING_CHILD=1`` (see +``headless_authoring._subprocess_env``). Every Cortex hook calls +:func:`exit_if_headless_authoring_child` at its process entry point; in the +marked child it exits ``0`` immediately — a silent no-op that lets the +``claude -p`` call proceed with no side effects. Everywhere else it does +nothing. + +This module has zero dependencies beyond the standard library, so importing it +from a hook can never fail for a missing third-party package. +""" + +from __future__ import annotations + +import os +import sys + +# The worker stamps this into the authoring child's env. Value "1" means +# "you are the headless authoring subprocess — do no hook work". +_HEADLESS_CHILD_FLAG = "CORTEX_HEADLESS_AUTHORING_CHILD" + + +def is_headless_authoring_child() -> bool: + """True when running inside the headless wiki-authoring subprocess.""" + return os.environ.get(_HEADLESS_CHILD_FLAG) == "1" + + +def exit_if_headless_authoring_child() -> None: + """Exit ``0`` immediately when inside the headless authoring child. + + A no-op in every other context. Called at the top of each Cortex hook's + process entry point so the hook does no work — preventing the recursion + and memory pollution documented in this module's docstring — while the + ``claude -p`` authoring drain runs. + """ + if is_headless_authoring_child(): + sys.exit(0) diff --git a/mcp_server/hooks/agent_briefing.py b/mcp_server/hooks/agent_briefing.py index dfc952bb..da461242 100644 --- a/mcp_server/hooks/agent_briefing.py +++ b/mcp_server/hooks/agent_briefing.py @@ -384,4 +384,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/auto_recall.py b/mcp_server/hooks/auto_recall.py index 08fe04b7..2b0da2e5 100644 --- a/mcp_server/hooks/auto_recall.py +++ b/mcp_server/hooks/auto_recall.py @@ -262,4 +262,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/compaction_checkpoint.py b/mcp_server/hooks/compaction_checkpoint.py index 51978e55..03bf82ce 100644 --- a/mcp_server/hooks/compaction_checkpoint.py +++ b/mcp_server/hooks/compaction_checkpoint.py @@ -109,4 +109,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/consolidate_background.py b/mcp_server/hooks/consolidate_background.py index dc3f87b0..22fc69a6 100644 --- a/mcp_server/hooks/consolidate_background.py +++ b/mcp_server/hooks/consolidate_background.py @@ -121,4 +121,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/ingest_codebase_background.py b/mcp_server/hooks/ingest_codebase_background.py index 5bbbbc1a..7f338f78 100644 --- a/mcp_server/hooks/ingest_codebase_background.py +++ b/mcp_server/hooks/ingest_codebase_background.py @@ -78,4 +78,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/pipeline_impact_bump.py b/mcp_server/hooks/pipeline_impact_bump.py index 634d5973..68914b45 100644 --- a/mcp_server/hooks/pipeline_impact_bump.py +++ b/mcp_server/hooks/pipeline_impact_bump.py @@ -225,4 +225,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/post_commit_reindex.py b/mcp_server/hooks/post_commit_reindex.py index 536246eb..65aec667 100644 --- a/mcp_server/hooks/post_commit_reindex.py +++ b/mcp_server/hooks/post_commit_reindex.py @@ -279,4 +279,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/post_tool_capture.py b/mcp_server/hooks/post_tool_capture.py index 5a66b18f..d50f0cb0 100644 --- a/mcp_server/hooks/post_tool_capture.py +++ b/mcp_server/hooks/post_tool_capture.py @@ -418,4 +418,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/preemptive_context.py b/mcp_server/hooks/preemptive_context.py index 863dbafa..47ccc207 100644 --- a/mcp_server/hooks/preemptive_context.py +++ b/mcp_server/hooks/preemptive_context.py @@ -191,4 +191,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/session_lifecycle.py b/mcp_server/hooks/session_lifecycle.py index 0469f973..3a497788 100644 --- a/mcp_server/hooks/session_lifecycle.py +++ b/mcp_server/hooks/session_lifecycle.py @@ -238,4 +238,12 @@ def main() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/mcp_server/hooks/session_start.py b/mcp_server/hooks/session_start.py index 38e2560a..07c04347 100644 --- a/mcp_server/hooks/session_start.py +++ b/mcp_server/hooks/session_start.py @@ -921,4 +921,12 @@ def _print_external_sources() -> None: if __name__ == "__main__": + # No-op inside the headless wiki-authoring subprocess (see + # _headless_guard): prevents recursion + memory pollution when + # ``claude -p --setting-sources user`` loads the user hooks. + from mcp_server.hooks._headless_guard import ( + exit_if_headless_authoring_child, + ) + + exit_if_headless_authoring_child() main() diff --git a/tests_py/handlers/test_authoring_delegation.py b/tests_py/handlers/test_authoring_delegation.py new file mode 100644 index 00000000..d4da7536 --- /dev/null +++ b/tests_py/handlers/test_authoring_delegation.py @@ -0,0 +1,90 @@ +"""Tests for the agents-mode delegation hint in authoring prompts. + +Validates that ``_delegation_hint`` maps page kinds to specialists and that +each of the three prompt builders splices the hint when given and omits it +(byte-for-byte unchanged shape) when ``delegate_hint=None`` (solo mode). +""" + +from __future__ import annotations + +from mcp_server.handlers.consolidation.authoring_prompts import ( + _DEFAULT_SPECIALIST, + _build_page_prompt, + _build_section_prompt, + _delegation_hint, +) +from mcp_server.handlers.consolidation.page_io import _scope_anchor_prompt + + +def test_delegation_hint_maps_kind_to_specialist() -> None: + assert "engineer" in _delegation_hint("api") + assert "devops-engineer" in _delegation_hint("ci-cd") + assert "architect" in _delegation_hint("architecture") + # Unknown kind falls back to the default specialist. + hint = _delegation_hint("totally-unknown-kind") + assert _DEFAULT_SPECIALIST in hint + assert "totally-unknown-kind" in hint # kind echoed into the paragraph + + +def test_section_prompt_includes_hint_only_when_given() -> None: + meta = {"domain": "d", "title": "T", "source_file_path": "x.py"} + kw = dict( + page_path="reference/d/x.md", + page_meta=meta, + gap_name="Purpose", + gap_description="what it does", + source_text=None, + ) + with_hint = _build_section_prompt(**kw, delegate_hint=_delegation_hint("file-doc")) + without = _build_section_prompt(**kw, delegate_hint=None) + + assert "You may delegate analysis" in with_hint + assert "You may delegate analysis" not in without + # Solo default (no kwarg) matches explicit None. + assert _build_section_prompt(**kw) == without + + +def test_page_prompt_includes_hint_only_when_given() -> None: + meta = {"domain": "d", "source_file_path": "x.py", "language": "python"} + kw = dict( + page_path="reference/d/x.md", page_meta=meta, gaps=["purpose"], source_text=None + ) + with_hint = _build_page_prompt(**kw, delegate_hint=_delegation_hint("file-doc")) + without = _build_page_prompt(**kw, delegate_hint=None) + + assert "You may delegate analysis" in with_hint + assert "You may delegate analysis" not in without + assert _build_page_prompt(**kw) == without + + +def test_anchor_prompt_includes_hint_only_when_given(tmp_path) -> None: + kw = dict( + domain="proj", + scope_name="architecture", + scope_title="Architecture", + scope_description="desc", + source_root=str(tmp_path), + ) + with_hint = _scope_anchor_prompt( + **kw, delegate_hint=_delegation_hint("architecture") + ) + without = _scope_anchor_prompt(**kw, delegate_hint=None) + + assert "You may delegate analysis" in with_hint + assert "You may delegate analysis" not in without + assert _scope_anchor_prompt(**kw) == without + + +def test_hint_preserves_untrusted_guard() -> None: + """The delegation hint must not displace the security guard header.""" + meta = {"domain": "d", "source_file_path": "x.py", "language": "python"} + prompt = _build_page_prompt( + page_path="reference/d/x.md", + page_meta=meta, + gaps=["purpose"], + source_text="print('hi')", + delegate_hint=_delegation_hint("file-doc"), + ) + # Guard still leads; hint sits after the intro, before grounding section. + assert prompt.startswith("SECURITY:") + assert prompt.index("You may delegate") < prompt.index("Ground your writing") diff --git a/tests_py/handlers/test_headless_authoring_throttle.py b/tests_py/handlers/test_headless_authoring_throttle.py index db06eba5..7d14782c 100644 --- a/tests_py/handlers/test_headless_authoring_throttle.py +++ b/tests_py/handlers/test_headless_authoring_throttle.py @@ -561,7 +561,7 @@ def __init__(self) -> None: self.killed = False self.waited = False - async def communicate(self) -> Any: + async def communicate(self, input: Any = None) -> Any: await asyncio.Event().wait() # hang until cancelled def kill(self) -> None: @@ -606,11 +606,13 @@ async def _fake_exec(*_a: Any, **_k: Any) -> _HangingProc: class TestSubscriptionAuth: """The drain runs on the subscription by default, API key only on opt-in. - Enforcing facts: argv carries ``--safe-mode`` (config isolation that keeps - OAuth/keychain) and never ``--bare`` (which forces API-key billing); the - default child env strips ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` - (subscription); and ``CORTEX_HEADLESS_AUTH=api`` passes them through so a - user who wants API billing still can. + Enforcing facts: argv never carries ``--bare`` (which forces API-key + billing); the default child env strips ``ANTHROPIC_API_KEY`` / + ``ANTHROPIC_AUTH_TOKEN`` (subscription); ``CORTEX_HEADLESS_AUTH=api`` passes + them through so a user who wants API billing still can; and the child env + always carries ``CORTEX_HEADLESS_AUTHORING_CHILD=1`` so user hooks no-op. + The auth behaviour is orthogonal to agents/solo mode, so these tests pin + the mode explicitly. """ @staticmethod @@ -618,7 +620,7 @@ def _patch_exec(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> No class _OkProc: returncode = 0 - async def communicate(self) -> Any: + async def communicate(self, input: Any = None) -> Any: return (b'{"result": "OK", "total_cost_usd": 0.0}', b"") async def _fake_exec(*argv: Any, **kwargs: Any) -> _OkProc: @@ -629,7 +631,7 @@ async def _fake_exec(*argv: Any, **kwargs: Any) -> _OkProc: monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) @pytest.mark.asyncio - async def test_default_uses_safe_mode_and_strips_api_key( + async def test_default_strips_api_key_and_marks_child( self, monkeypatch: pytest.MonkeyPatch ) -> None: from mcp_server.handlers.consolidation import headless_authoring as ha @@ -643,12 +645,13 @@ async def test_default_uses_safe_mode_and_strips_api_key( result = await ha._claude_invoke("prompt") assert result.text == "OK" - assert "--safe-mode" in captured["argv"] assert "--bare" not in captured["argv"] env = captured["env"] assert env is not None, "must pass an explicit env to strip credentials" assert "ANTHROPIC_API_KEY" not in env assert "ANTHROPIC_AUTH_TOKEN" not in env + # User hooks (loaded by --setting-sources user) must no-op in the child. + assert env.get("CORTEX_HEADLESS_AUTHORING_CHILD") == "1" @pytest.mark.asyncio async def test_api_mode_passes_api_key_through( @@ -664,7 +667,120 @@ async def test_api_mode_passes_api_key_through( result = await ha._claude_invoke("prompt") assert result.text == "OK" - assert "--safe-mode" in captured["argv"] env = captured["env"] assert env is not None assert env.get("ANTHROPIC_API_KEY") == "sk-user-opted-in" + assert env.get("CORTEX_HEADLESS_AUTHORING_CHILD") == "1" + + +class TestAgentsMode: + """Agents mode (default) loads the roster under a hard write/exec ceiling; + solo mode falls back to ``--safe-mode`` config isolation. + """ + + @staticmethod + def _patch_exec(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> None: + class _OkProc: + returncode = 0 + + async def communicate(self, input: Any = None) -> Any: + return (b'{"result": "OK", "total_cost_usd": 0.0}', b"") + + async def _fake_exec(*argv: Any, **kwargs: Any) -> _OkProc: + captured["argv"] = list(argv) + return _OkProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + + @pytest.mark.asyncio + async def test_agents_mode_loads_roster_with_hard_ceiling( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from mcp_server.handlers.consolidation import headless_authoring as ha + + monkeypatch.setattr(ha, "CORTEX_HEADLESS_AGENTS", 1) + captured: dict[str, Any] = {} + self._patch_exec(monkeypatch, captured) + + await ha._claude_invoke("prompt") + argv = captured["argv"] + + # Roster loads from the user source only (project/local excluded). + assert "--setting-sources" in argv + assert argv[argv.index("--setting-sources") + 1] == "user" + # Task delegation is available; writes/exec are hard-denied. + assert argv[argv.index("--tools") + 1] == "Read,Glob,Grep,Task" + assert "--disallowedTools" in argv + assert ( + argv[argv.index("--disallowedTools") + 1] == "Write,Edit,Bash,NotebookEdit" + ) + # Agents mode does NOT use --safe-mode (that would unload the roster). + assert "--safe-mode" not in argv + + @pytest.mark.asyncio + async def test_solo_mode_uses_safe_mode_without_task( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from mcp_server.handlers.consolidation import headless_authoring as ha + + monkeypatch.setattr(ha, "CORTEX_HEADLESS_AGENTS", 0) + captured: dict[str, Any] = {} + self._patch_exec(monkeypatch, captured) + + await ha._claude_invoke("prompt") + argv = captured["argv"] + + assert "--safe-mode" in argv + assert "--setting-sources" not in argv + assert "--disallowedTools" not in argv + assert argv[argv.index("--tools") + 1] == "Read,Glob,Grep" + + def test_delegation_hint_gated_on_agents_knob( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from mcp_server.handlers.consolidation import headless_authoring as ha + + monkeypatch.setattr(ha, "CORTEX_HEADLESS_AGENTS", 1) + hint = ha._delegation_hint_for("adr") + assert hint is not None + assert "Task" in hint and "architect" in hint + + monkeypatch.setattr(ha, "CORTEX_HEADLESS_AGENTS", 0) + assert ha._delegation_hint_for("adr") is None + + @pytest.mark.asyncio + async def test_prompt_via_stdin_not_argv( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: the variadic ``--add-dir`` must not swallow the prompt. + + With a source_root present, the prompt MUST travel via stdin and be + absent from argv — otherwise the CLI errors "Input must be provided". + """ + from mcp_server.handlers.consolidation import headless_authoring as ha + + captured: dict[str, Any] = {} + + class _OkProc: + returncode = 0 + + async def communicate(self, input: Any = None) -> Any: + captured["input"] = input + return (b'{"result": "OK", "total_cost_usd": 0.0}', b"") + + async def _fake_exec(*argv: Any, **kwargs: Any) -> _OkProc: + captured["argv"] = list(argv) + captured["stdin"] = kwargs.get("stdin") + return _OkProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + + await ha._claude_invoke("MY_UNIQUE_PROMPT", source_root="/some/src") + + assert "--add-dir" in captured["argv"] + assert captured["argv"][captured["argv"].index("--add-dir") + 1] == "/some/src" + # Prompt is NOT a positional argv element … + assert "MY_UNIQUE_PROMPT" not in captured["argv"] + # … it is fed via stdin instead. + assert captured["stdin"] is asyncio.subprocess.PIPE + assert captured["input"] == b"MY_UNIQUE_PROMPT" diff --git a/tests_py/hooks/test_headless_guard.py b/tests_py/hooks/test_headless_guard.py new file mode 100644 index 00000000..bc5f4417 --- /dev/null +++ b/tests_py/hooks/test_headless_guard.py @@ -0,0 +1,88 @@ +"""Tests for the headless-authoring hook guard. + +Validates: + (a) ``is_headless_authoring_child`` reflects the env flag; + (b) ``exit_if_headless_authoring_child`` exits 0 in the child, no-ops else; + (c) integration: a real hook invoked as ``python -m`` short-circuits BEFORE + doing any work when the flag is set (proven by the absence of the + stderr log line the hook emits on its normal no-stdin path), and still + runs normally without the flag. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + + +def test_is_headless_authoring_child_reads_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from mcp_server.hooks import _headless_guard as g + + monkeypatch.delenv("CORTEX_HEADLESS_AUTHORING_CHILD", raising=False) + assert g.is_headless_authoring_child() is False + + monkeypatch.setenv("CORTEX_HEADLESS_AUTHORING_CHILD", "1") + assert g.is_headless_authoring_child() is True + + # Any value other than exactly "1" is NOT a child (defensive). + monkeypatch.setenv("CORTEX_HEADLESS_AUTHORING_CHILD", "0") + assert g.is_headless_authoring_child() is False + + +def test_exit_if_child_raises_systemexit_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from mcp_server.hooks import _headless_guard as g + + monkeypatch.setenv("CORTEX_HEADLESS_AUTHORING_CHILD", "1") + with pytest.raises(SystemExit) as exc: + g.exit_if_headless_authoring_child() + assert exc.value.code == 0 + + +def test_exit_if_child_noops_outside_child( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from mcp_server.hooks import _headless_guard as g + + monkeypatch.delenv("CORTEX_HEADLESS_AUTHORING_CHILD", raising=False) + # Must NOT raise. + g.exit_if_headless_authoring_child() + + +def _run_hook(flag: str | None) -> subprocess.CompletedProcess[str]: + """Run session_lifecycle as ``python -m`` with empty stdin.""" + env = dict(os.environ) + if flag is None: + env.pop("CORTEX_HEADLESS_AUTHORING_CHILD", None) + else: + env["CORTEX_HEADLESS_AUTHORING_CHILD"] = flag + return subprocess.run( + [sys.executable, "-m", "mcp_server.hooks.session_lifecycle"], + input="", # empty stdin → normal path logs "Empty stdin, exiting" + capture_output=True, + text=True, + env=env, + timeout=60, + ) + + +def test_guard_short_circuits_hook_before_work() -> None: + """With the flag set, the hook exits 0 BEFORE its first stderr log line.""" + res = _run_hook(flag="1") + assert res.returncode == 0 + # main() never runs, so the no-stdin log line is absent. + assert "[methodology-hook]" not in res.stderr + + +def test_hook_runs_normally_without_flag() -> None: + """Without the flag, the same invocation reaches main() and logs.""" + res = _run_hook(flag=None) + assert res.returncode == 0 + # main() ran its no-stdin branch — the guard is what suppresses this above. + assert "[methodology-hook]" in res.stderr