From b1401da0f80d373b8b139ab92d37bc921c2bc708 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:07:27 +1000 Subject: [PATCH 01/17] docs: design spec for wardline install command (agent enablement + sibling bindings) Closes the push-mechanism gap: a single `wardline install` injects a lightweight hash-fenced block into CLAUDE.md/AGENTS.md, ships the wardline-gate skill, merges a wardline MCP entry into .mcp.json, and detects Clarion/Filigree to set up bindings. Promotes clarion.url/ filigree.url from inert config placeholders to runtime-read fields. No SessionStart hook (deliberate "inject half" of filigree's pipeline). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-05-31-wardline-install-command-design.md | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-wardline-install-command-design.md diff --git a/docs/superpowers/specs/2026-05-31-wardline-install-command-design.md b/docs/superpowers/specs/2026-05-31-wardline-install-command-design.md new file mode 100644 index 00000000..0dfa1c34 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-wardline-install-command-design.md @@ -0,0 +1,291 @@ +# Wardline `install` command — design + +**Date:** 2026-05-31 +**Status:** Approved (brainstorming → spec) +**Branch:** `feat/wardline-install-command` + +## Problem + +Wardline teaches agents how to use it only through **pull-based, MCP-bound** +channels: the MCP server's tool descriptions, the `wardline:loop` prompt, and +the four `wardline://…` resources — all of which reach an agent only if the +consuming project has wired up `wardline mcp` — plus `docs/agents.md`, which a +human has to find and act on. + +There is **no push mechanism**. An agent dropped into a repo that merely *uses* +wardline learns nothing about it automatically. By contrast, a sibling tool +(filigree) installs a hashed instruction block into `CLAUDE.md`/`AGENTS.md`, a +skill into `.claude/skills/`, and a SessionStart snapshot — so its agents are +taught at session start without any per-project wiring. + +This spec closes that gap with a single `wardline install` command — the +**"inject" half of filigree's pipeline**, deliberately **without** the +SessionStart hook. The hook is where the mechanism tips into the +session-infrastructure "enterprise weight" the wardline rebuild dropped; wardline +is a gate, not a workflow orchestrator, so it does not own the session lifecycle. + +A secondary goal: when `wardline install` runs, it should detect the two sibling +integrations it can compose with — the Clarion taint store and the Filigree +emitter — and set up those bindings where it can. + +## Non-goals + +- **No SessionStart hook**, no dashboard, no freshness daemon. Freshness is + enforced only when a human re-runs `wardline install`. +- **No interactive prompting** in the default path. `wardline install` must be + safe to run non-interactively (CI, scripted setup). +- **No magic URL discovery.** Sibling services live at runtime/deployment URLs + that are not recorded in any project marker; the command never guesses a URL + it cannot verify from an explicit source (env var). + +## Design overview + +`wardline install` performs five steps, all enabled by default, each +individually opt-out-able: + +| Step | Writes | Opt-out flag | +|------|--------|--------------| +| 1. Config plumbing (prerequisite, always active) | reads `clarion.url`/`filigree.url` at runtime | — | +| 2. Lightweight instruction block | `CLAUDE.md`, `AGENTS.md` | `--no-claude-md`, `--no-agents-md` | +| 3. `wardline-gate` skill | `.claude/skills/`, `.agents/skills/` | `--no-skill` | +| 4. MCP wiring | `.mcp.json` (merge) | `--no-mcp` | +| 5. Binding detection | `wardline.yaml` (`clarion:`/`filigree:`) | `--no-bindings` | + +The command is idempotent end-to-end and prints a summary of what it +wrote / skipped / detected. + +--- + +## Component 1 — Config plumbing (prerequisite) + +Today `clarion.url` and `filigree.url` do not exist as usable config: the +`clarion:` and `filigree:` sections are accepted by `WARDLINE_SCHEMA` as inert +`{"type": "object"}` placeholders, and the loader exposes them as opaque dicts +that no code reads. The URLs flow **only** through the `--clarion-url` / +`--filigree-url` CLI flags. A binding written to `wardline.yaml` would therefore +be dead unless the runtime reads it. + +**Change:** promote `clarion.url` and `filigree.url` to real, schema'd fields and +have the runtime read them. + +- Extend `WARDLINE_SCHEMA` (`core/config_schema.py`): `clarion` and `filigree` + become `{"type": "object", "additionalProperties": False, + "properties": {"url": {"type": "string"}}}`. +- The config loader (`core/config.py`) continues to expose `clarion` / + `filigree` mappings; add typed accessors `config.clarion_url` / + `config.filigree_url` returning `str | None`. +- Resolution precedence, applied at the CLI/MCP entry points: + **CLI flag > env var > `wardline.yaml`**. + - Both URL env vars are **new**: `WARDLINE_CLARION_URL` and + `WARDLINE_FILIGREE_URL`. (Today only the token env var + `WARDLINE_CLARION_TOKEN` exists; the Clarion *URL* is flag-only and no + URL env var is read.) +- `wardline scan` reads both resolved URLs; `wardline mcp` reads the resolved + `clarion.url`. Net effect: the `.mcp.json` entry can be a bare + `wardline mcp --root .` with **no URL in its args** — the server picks up + Clarion from config. One source of truth; the URL is never duplicated into + `.mcp.json`. + +This change is in scope for this feature (no deferral): the placeholders were +reserved for exactly this, and without it the binding-detection step writes +something the runtime ignores. + +**Behavior preserved:** the `--clarion-url` / `--filigree-url` flags keep their +current meaning and still win over env/config. + +--- + +## Component 2 — The lightweight instruction block + +A short, hash-fenced block injected into `CLAUDE.md` and `AGENTS.md` (both by +default; each created if absent). It is **always-loaded** context, so it must +stay small — it makes the agent *aware* of the gate and points at the deeper +material; it does not reproduce it. + +Fence and shape: + +``` + +This project uses **wardline** as its trust-boundary gate. Before handing back +code that touches external input, run `wardline scan . --fail-on ERROR` +(exit 0 = clean, 1 = gate tripped, 2 = wardline error) and fix findings at the +boundary, not the sink. The full scan → explain → fix → rescan loop and the +baseline-vs-waiver discipline live in the `wardline-gate` skill and in +`docs/agents.md`. + +``` + +**Injection (text-level):** +- If the open/close fence pair is present, replace the content between them. +- Else append the block (with a leading blank line) to the file. +- The `` is computed over the rendered block text. Re-running replaces the + block **only if** the bundled hash differs from the one in the fence; + otherwise the file is left byte-identical. +- The `` segment is the block-template version (bumped when the template + text changes), mirroring filigree's `vX.Y.Z:hash` marker convention. + +--- + +## Component 3 — The `wardline-gate` skill + +Shipped as **package data** at `src/wardline/skills/wardline-gate/SKILL.md` +(force-included into the wheel alongside the existing YAML data files). Copied +into both `.claude/skills/wardline-gate/` and `.agents/skills/wardline-gate/` +(Codex) as an idempotent overwrite. + +Frontmatter `description` triggers on the gate use cases — e.g. "scan for +trust-boundary / taint findings", "fix a wardline finding", "wire wardline into +the agent loop". Body is assembled from material that already exists, so the +skill is authored content, not new doctrine: + +- the `wardline:loop` four-step cycle (scan → explain_taint → fix-at-boundary → + rescan); +- the exit-code contract and how the agent self-corrects on a trip; +- baseline-vs-waiver discipline ("prefer fixing; a waiver is an audited, + time-boxed exception"); +- `judge` is opt-in and network-fenced, fails loud; +- the MCP path (tools/resources/prompt) vs. the CLI path. + +The skill carries the substance the always-loaded block deliberately omits. + +--- + +## Component 4 — MCP wiring + +**Merge** a `wardline` server into the existing `.mcp.json` `mcpServers` object, +preserving any entries already present (the repo already ships a `filigree` +stdio entry): + +```json +{ + "mcpServers": { + "filigree": { "...": "preserved verbatim" }, + "wardline": { "type": "stdio", "command": "wardline", "args": ["mcp", "--root", "."] } + } +} +``` + +- If `.mcp.json` is absent, create it with just the `wardline` entry. +- If present, parse JSON, add/replace **only** the `wardline` key under + `mcpServers`, and write back. Never overwrite the file wholesale; never touch + sibling entries. +- No `--clarion-url` in the args — the server resolves Clarion from config + (Component 1). +- `command`: resolve the wardline executable (prefer the running + `sys.executable -m wardline` invocation form if a bare `wardline` is not on a + predictable PATH; final form decided in the plan, but it must be invocable by + the MCP client). + +--- + +## Component 5 — Binding detection + +On install (unless `--no-bindings`), detect the two siblings and record what is +found into `wardline.yaml`. + +**Detection signals (presence only):** +- **Filigree in use:** `.filigree.conf` exists in the project root. (Confirmed + reliable: it is a small JSON file with `project_name`/`prefix`/`db`.) +- **Clarion available:** `clarion` resolvable on `PATH`, **or** + `WARDLINE_CLARION_URL` set. + +**URL resolution (honest, never guesses):** +- If the integration's env var is set (`WARDLINE_CLARION_URL` / + `WARDLINE_FILIGREE_URL`), write a **live** stanza with that URL. +- Else (presence detected, no URL known) write a **commented** stanza noting the + detected presence, with the URL left for the user to fill: + + ```yaml + # Clarion taint store detected (clarion on PATH) but no URL configured. + # Set the taint-store URL to enable per-entity taint-fact enrichment: + # clarion: + # url: "http://localhost:PORT" + ``` + + ```yaml + # Filigree detected (.filigree.conf present) but no Loom URL configured. + # Set the Loom scan-results URL to POST findings into Filigree: + # filigree: + # url: "http://localhost:PORT/api/loom/scan-results" + ``` + +**Writing into `wardline.yaml` (text-append, guarded):** +- pyyaml cannot round-trip comments, so the command does **not** parse-and- + rewrite an existing `wardline.yaml`. +- Cheap parse to check whether a top-level `clarion:` / `filigree:` key already + exists. If it does, leave it untouched (the user owns it). +- If absent, **append** the stanza (live or commented) as text, preserving all + existing content and comments. +- If `wardline.yaml` does not exist, create it containing only the detected + stanza(s). + +--- + +## Command surface + +``` +wardline install [--no-claude-md] [--no-agents-md] [--no-skill] + [--no-mcp] [--no-bindings] [--root PATH] +``` + +- All five steps run by default. Each flag skips its step. +- `--root` (default `.`) sets the project root all paths are resolved under. +- Non-interactive; idempotent; CI-safe. +- Exits `0` on success (including "nothing to update"); non-zero only on a real + IO/parse error it cannot fail-soft past. +- Prints a per-step summary: for each artifact, one of + `created` / `updated` / `unchanged` / `skipped`, plus the detection result + (`clarion: detected (commented)`, `filigree: wired (env URL)`, etc.). + +--- + +## Packaging + +- `src/wardline/skills/wardline-gate/SKILL.md` and the instruction-block template + ship as package data, force-included into the wheel the same way + `stdlib_taint.yaml` and `vocabulary.yaml` already are + (`[tool.hatch.build.targets.wheel.force-include]`). +- Read at install time via `Path(__file__).parent`. +- No new runtime dependency — the zero-dependency analysis core is preserved; + the install command uses only the stdlib plus the already-present CLI deps + (`click`, `pyyaml`). + +## Module layout (provisional, finalized in the plan) + +- `src/wardline/cli/install.py` — the `install` click command + summary output. +- `src/wardline/install/` — the mechanics, each unit independently testable: + - `block.py` — render + hash-fence inject/replace for `CLAUDE.md`/`AGENTS.md`. + - `skill.py` — copy the bundled skill into `.claude`/`.agents`. + - `mcp_json.py` — merge the `wardline` entry into `.mcp.json`. + - `detect.py` — presence detection + `wardline.yaml` stanza append. +- `src/wardline/skills/wardline-gate/SKILL.md` — bundled skill (package data). +- Config plumbing edits in `core/config.py` + `core/config_schema.py` and the + `scan` / `mcp` entry points. + +## Testing strategy + +- **Block injection:** fresh file (append), existing file with no fence (append), + existing file with stale-hash fence (replace), existing file with current-hash + fence (unchanged/byte-identical), idempotent re-run. +- **Skill copy:** fresh dir, overwrite of an older copy, both `.claude` and + `.agents` targets. +- **`.mcp.json` merge:** absent file (create), present file with a `filigree` + entry (preserved, `wardline` added), present file with a stale `wardline` + entry (replaced), malformed JSON (fail with a clear error, do not clobber). +- **Detection:** Filigree present/absent (`.filigree.conf`), Clarion present via + PATH, present via env var (live stanza), present without URL (commented + stanza), `wardline.yaml` absent (create) vs. existing key present (untouched) + vs. existing file without the key (append, comments preserved). +- **Config plumbing:** precedence flag > env > config for both `scan` and `mcp`; + a `clarion.url` in config reaches the MCP server with a bare + `wardline mcp --root .`. +- **End-to-end:** `wardline install` in a temp project, then assert every + artifact, then re-run and assert all-`unchanged`. + +## Risks / open points for the plan + +- Final form of the `command` string in `.mcp.json` (bare `wardline` vs. + `python -m wardline`) — must be reliably invocable by the MCP client. +- Whether the instruction block should also be removable (`wardline install + --uninstall` / an `uninstall` verb) — deferred unless the plan surfaces a need; + the fenced block is already safe to delete by hand. From 5ec9f663770c4e5822d351a3918f86ea02e5a648 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:12:56 +1000 Subject: [PATCH 02/17] docs: implementation plan for wardline install command Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-31-wardline-install-command.md | 1187 +++++++++++++++++ 1 file changed, 1187 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-wardline-install-command.md diff --git a/docs/superpowers/plans/2026-05-31-wardline-install-command.md b/docs/superpowers/plans/2026-05-31-wardline-install-command.md new file mode 100644 index 00000000..4165574e --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-wardline-install-command.md @@ -0,0 +1,1187 @@ +# Wardline `install` Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `wardline install` command that pushes agent-enablement artifacts (a hash-fenced CLAUDE.md/AGENTS.md block, a `wardline-gate` skill, a merged `.mcp.json` entry) into a consuming project and detects/records Clarion & Filigree bindings. + +**Architecture:** A prerequisite config change promotes `clarion.url`/`filigree.url` to runtime-read fields (precedence: CLI flag > env var > `wardline.yaml`). The command itself is a thin `click` orchestrator over four single-responsibility helper modules under `src/wardline/install/`, plus a bundled skill shipped as package data. No SessionStart hook — freshness is re-run-on-demand only. + +**Tech Stack:** Python 3 stdlib (`hashlib`, `re`, `json`, `shutil`, `os`), `click`, `pyyaml`/`jsonschema` (already present via the `scanner` extra). No new runtime dependency. + +**Spec:** `docs/superpowers/specs/2026-05-31-wardline-install-command-design.md` + +--- + +## File Structure + +**Create:** +- `src/wardline/install/__init__.py` — package marker (empty). +- `src/wardline/install/block.py` — render + hash-fence inject/replace for `CLAUDE.md`/`AGENTS.md`. +- `src/wardline/install/skill.py` — copy the bundled skill into `.claude`/`.agents`. +- `src/wardline/install/mcp_json.py` — merge the `wardline` entry into `.mcp.json`. +- `src/wardline/install/detect.py` — presence detection + `wardline.yaml` stanza append. +- `src/wardline/cli/install.py` — the `install` click command + summary output. +- `src/wardline/skills/wardline-gate/SKILL.md` — bundled skill (package data). +- Tests: `tests/unit/install/test_block.py`, `test_skill.py`, `test_mcp_json.py`, `test_detect.py`; `tests/unit/cli/test_install.py`; additions to `tests/unit/core/test_config.py`. + +**Modify:** +- `src/wardline/core/config_schema.py` — schema `url` properties for `clarion`/`filigree`. +- `src/wardline/core/config.py` — `clarion_url`/`filigree_url` properties + `resolve_*` helpers. +- `src/wardline/cli/scan.py` — resolve both URLs via the helpers. +- `src/wardline/cli/mcp.py` — resolve `clarion_url` via the helper. +- `src/wardline/cli/main.py` — register the `install` command. +- `pyproject.toml` — force-include the bundled skill in the wheel. +- `docs/agents.md` + `CHANGELOG.md` — document the command. + +--- + +## Task 1: Config — `clarion.url` / `filigree.url` become real fields + +**Files:** +- Modify: `src/wardline/core/config_schema.py:41-42` +- Modify: `src/wardline/core/config.py:17-28` (dataclass) and add module-level helpers +- Test: `tests/unit/core/test_config.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/core/test_config.py`: + +```python +from pathlib import Path + +from wardline.core import config as config_mod +from wardline.core.config import ( + WardlineConfig, + load, + resolve_clarion_url, + resolve_filigree_url, +) + + +def test_clarion_and_filigree_url_read_from_config(tmp_path: Path) -> None: + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://clarion.local:9100"\n' + 'filigree:\n url: "http://filigree.local/api/loom/scan-results"\n', + encoding="utf-8", + ) + cfg = load(tmp_path / "wardline.yaml") + assert cfg.clarion_url == "http://clarion.local:9100" + assert cfg.filigree_url == "http://filigree.local/api/loom/scan-results" + + +def test_urls_default_to_none() -> None: + cfg = WardlineConfig() + assert cfg.clarion_url is None + assert cfg.filigree_url is None + + +def test_unknown_clarion_key_is_rejected(tmp_path: Path) -> None: + (tmp_path / "wardline.yaml").write_text( + "clarion:\n bogus: 1\n", encoding="utf-8" + ) + import pytest + + from wardline.core.errors import ConfigError + + with pytest.raises(ConfigError): + load(tmp_path / "wardline.yaml") + + +def test_resolve_precedence_flag_beats_env_beats_config(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://from-config"\n', encoding="utf-8" + ) + # config only + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + assert resolve_clarion_url(None, tmp_path, None) == "http://from-config" + # env beats config + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://from-env") + assert resolve_clarion_url(None, tmp_path, None) == "http://from-env" + # flag beats env + assert resolve_clarion_url("http://from-flag", tmp_path, None) == "http://from-flag" + + +def test_resolve_filigree_env(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://fil-env") + assert resolve_filigree_url(None, tmp_path, None) == "http://fil-env" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/unit/core/test_config.py -k "clarion_url or filigree_url or resolve or unknown_clarion" -v` +Expected: FAIL — `resolve_clarion_url` / `clarion_url` do not exist; `test_unknown_clarion_key_is_rejected` fails because the schema currently allows any property under `clarion`. + +- [ ] **Step 3: Tighten the schema** + +In `src/wardline/core/config_schema.py`, replace lines 41-42: + +```python + "filigree": { + "type": "object", + "additionalProperties": False, + "properties": {"url": {"type": "string"}}, + }, + "clarion": { + "type": "object", + "additionalProperties": False, + "properties": {"url": {"type": "string"}}, + }, +``` + +- [ ] **Step 4: Add properties + resolution helpers** + +In `src/wardline/core/config.py`, add these two properties to the `WardlineConfig` dataclass (after line 28, inside the class body — properties are class-level so they coexist with `slots=True`): + +```python + @property + def clarion_url(self) -> str | None: + value = self.clarion.get("url") + return value if isinstance(value, str) else None + + @property + def filigree_url(self) -> str | None: + value = self.filigree.get("url") + return value if isinstance(value, str) else None +``` + +Add `import os` to the top imports, and append these module-level helpers after the `load` function: + +```python +_CLARION_URL_ENV = "WARDLINE_CLARION_URL" +_FILIGREE_URL_ENV = "WARDLINE_FILIGREE_URL" + + +def _config_for(root: Path, config_path: Path | None) -> WardlineConfig: + return load(config_path if config_path is not None else root / "wardline.yaml") + + +def resolve_clarion_url( + flag: str | None, root: Path, config_path: Path | None = None +) -> str | None: + """Clarion URL by precedence: explicit flag > env var > wardline.yaml.""" + if flag is not None: + return flag + env = os.environ.get(_CLARION_URL_ENV) + if env: + return env + return _config_for(root, config_path).clarion_url + + +def resolve_filigree_url( + flag: str | None, root: Path, config_path: Path | None = None +) -> str | None: + """Filigree Loom URL by precedence: explicit flag > env var > wardline.yaml.""" + if flag is not None: + return flag + env = os.environ.get(_FILIGREE_URL_ENV) + if env: + return env + return _config_for(root, config_path).filigree_url +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/unit/core/test_config.py -v` +Expected: PASS (all, including the new cases). + +- [ ] **Step 6: Commit** + +```bash +git add src/wardline/core/config_schema.py src/wardline/core/config.py tests/unit/core/test_config.py +git commit -m "feat(config): clarion.url/filigree.url become runtime-read fields with flag>env>config resolution + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Wire URL resolution into `scan` and `mcp` + +**Files:** +- Modify: `src/wardline/cli/scan.py:52-56` (top of `scan` body) +- Modify: `src/wardline/cli/mcp.py:18-20` +- Test: `tests/unit/cli/test_install.py` (new file; holds CLI-wiring tests too) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/cli/test_install.py`: + +```python +from pathlib import Path + +from click.testing import CliRunner + +from wardline.cli.main import cli + + +def test_scan_reads_filigree_url_from_config(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "wardline.yaml").write_text( + 'filigree:\n url: "http://configured-filigree"\n', encoding="utf-8" + ) + (tmp_path / "m.py").write_text("x = 1\n", encoding="utf-8") + captured: dict[str, object] = {} + + class _FakeEmitter: + def __init__(self, url: str) -> None: + captured["url"] = url + + def emit(self, findings): # noqa: ANN001 + from wardline.core.filigree_emit import EmitResult + + return EmitResult(reachable=False) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _FakeEmitter) + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert captured["url"] == "http://configured-filigree" + + +def test_mcp_resolves_clarion_url_from_config(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://configured-clarion"\n', encoding="utf-8" + ) + captured: dict[str, object] = {} + + class _FakeServer: + def __init__(self, *, root: Path, clarion_url: str | None = None) -> None: + captured["clarion_url"] = clarion_url + self.rpc = self + + def run_stdio(self) -> None: + captured["ran"] = True + + monkeypatch.setattr("wardline.cli.mcp.WardlineMCPServer", _FakeServer) + result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert captured["clarion_url"] == "http://configured-clarion" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/unit/cli/test_install.py -k "scan_reads or mcp_resolves" -v` +Expected: FAIL — `scan` ignores config `filigree.url`; `mcp` passes the raw `None` flag through. + +- [ ] **Step 3: Wire `scan`** + +In `src/wardline/cli/scan.py`, add the import (next to the other `wardline.core.run` import): + +```python +from wardline.core.config import resolve_clarion_url, resolve_filigree_url +``` + +Then at the very start of the `scan` body (immediately after the docstring on line 52), insert: + +```python + filigree_url = resolve_filigree_url(filigree_url, path, config_path) + clarion_url = resolve_clarion_url(clarion_url, path, config_path) +``` + +- [ ] **Step 4: Wire `mcp`** + +Replace the body of `src/wardline/cli/mcp.py`'s `mcp` function (lines 19-20) with: + +```python + """Run the Wardline MCP server over stdio (JSON-RPC 2.0).""" + from wardline.core.config import resolve_clarion_url + + clarion_url = resolve_clarion_url(clarion_url, root, None) + WardlineMCPServer(root=root, clarion_url=clarion_url).rpc.run_stdio() +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/unit/cli/test_install.py -v` +Expected: PASS. + +- [ ] **Step 6: Run the full CLI/scan suite for regressions** + +Run: `.venv/bin/pytest tests/unit/cli -v` +Expected: PASS (no behavior change when no config/env/flag URL is set). + +- [ ] **Step 7: Commit** + +```bash +git add src/wardline/cli/scan.py src/wardline/cli/mcp.py tests/unit/cli/test_install.py +git commit -m "feat(cli): scan/mcp resolve clarion+filigree URLs from config/env + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: `block.py` — hash-fenced instruction block + +**Files:** +- Create: `src/wardline/install/__init__.py` (empty) +- Create: `src/wardline/install/block.py` +- Test: `tests/unit/install/test_block.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/install/test_block.py`: + +```python +from pathlib import Path + +from wardline.install.block import inject_block, render_block + + +def test_render_block_is_fenced_and_mentions_the_gate() -> None: + block = render_block() + assert block.startswith("") + assert "wardline scan" in block + assert "wardline-gate" in block + + +def test_inject_into_absent_file_creates_it(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + assert inject_block(f) == "created" + assert f.read_text(encoding="utf-8").count("wardline:instructions") == 1 + + +def test_inject_appends_when_no_fence_present(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + f.write_text("# My project\n\nExisting content.\n", encoding="utf-8") + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert "Existing content." in text + assert "wardline:instructions" in text + + +def test_reinject_same_version_is_unchanged(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + inject_block(f) + before = f.read_text(encoding="utf-8") + assert inject_block(f) == "unchanged" + assert f.read_text(encoding="utf-8") == before + + +def test_inject_replaces_a_stale_fenced_block(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + f.write_text( + "intro\n\n\nOLD BODY\n" + "\n\noutro\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert "OLD BODY" not in text + assert "intro" in text and "outro" in text + assert text.count("wardline:instructions") == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_block.py -v` +Expected: FAIL — `wardline.install.block` does not exist. + +- [ ] **Step 3: Implement `block.py`** + +Create `src/wardline/install/__init__.py` (empty file), then `src/wardline/install/block.py`: + +```python +"""Render + idempotently inject the hash-fenced wardline instruction block.""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +_BLOCK_VERSION = "1" + +_BODY = ( + "This project uses **wardline** as its trust-boundary gate. Before handing " + "back code that touches external input, run `wardline scan . --fail-on ERROR` " + "(exit 0 = clean, 1 = gate tripped, 2 = wardline error) and fix findings at " + "the boundary, not the sink. The full scan -> explain -> fix -> rescan loop " + "and the baseline-vs-waiver discipline live in the `wardline-gate` skill and " + "in `docs/agents.md`." +) + +_FENCE_RE = re.compile( + r".*?", + re.DOTALL, +) + + +def _body_hash() -> str: + return hashlib.sha256(_BODY.encode("utf-8")).hexdigest()[:8] + + +def render_block() -> str: + return ( + f"\n" + f"{_BODY}\n" + "" + ) + + +def inject_block(file_path: Path) -> str: + """Create / append / replace the block. Returns created|updated|unchanged.""" + block = render_block() + if not file_path.exists(): + file_path.write_text(block + "\n", encoding="utf-8") + return "created" + text = file_path.read_text(encoding="utf-8") + match = _FENCE_RE.search(text) + if match is None: + sep = "" if text.endswith("\n") else "\n" + file_path.write_text(f"{text}{sep}\n{block}\n", encoding="utf-8") + return "updated" + if match.group(0) == block: + return "unchanged" + new = text[: match.start()] + block + text[match.end() :] + file_path.write_text(new, encoding="utf-8") + return "updated" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_block.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/__init__.py src/wardline/install/block.py tests/unit/install/test_block.py +git commit -m "feat(install): hash-fenced instruction block render+inject + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Bundled `wardline-gate` skill + `skill.py` copier + packaging + +**Files:** +- Create: `src/wardline/skills/wardline-gate/SKILL.md` +- Create: `src/wardline/install/skill.py` +- Modify: `pyproject.toml:55-57` (force-include) +- Test: `tests/unit/install/test_skill.py` + +- [ ] **Step 1: Write the bundled skill** + +Create `src/wardline/skills/wardline-gate/SKILL.md`: + +```markdown +--- +name: wardline-gate +description: > + Use when scanning for or fixing trust-boundary / taint findings, when a + `wardline scan` reports a defect, or when wiring wardline into an agent's + edit-verify loop. Explains the scan -> explain -> fix-at-the-boundary -> + rescan cycle and the baseline-vs-waiver discipline. +--- + +# Wardline: the trust-boundary gate + +Wardline is a deterministic, whole-program static taint analyzer. It marks trust +boundaries with two decorators from `wardline.decorators`: `@external_boundary` +(untrusted data arriving from outside) and `@trusted` (a producer that must only +receive validated data). When untrusted data reaches a trusted producer it raises +`PY-WL-101` at `ERROR`. + +## The loop + +1. **Scan.** Run `wardline scan . --fail-on ERROR` (or call the `scan` MCP tool). + Read the gate verdict and the active (non-suppressed) findings — `active` is + the population the gate enforces on. +2. **Explain.** For each active defect, call `explain_taint` with the finding's + `fingerprint`, `path`+`line`, and its `qualname` as `sink_qualname`. Do this + right after the scan and before editing — a stale fingerprint returns an error. + With a Clarion store configured, pass `chain: true` to walk the full taint + chain back to the originating boundary. +3. **Fix at the BOUNDARY, not the sink.** Add validation or rejection at the hop + where untrusted data should have been checked — not a band-aid at the sink. +4. **Re-scan.** Confirm the finding is gone. + +## Exit codes (CLI path) + +- `0` — clean (or gate not requested). +- `1` — the gate tripped: a non-suppressed defect at/above `--fail-on`. +- `2` — a wardline error (bad config, unreadable path). Not a finding. + +Branch on the code. On a trip, read the structured report wardline just wrote — +the finding names the function, file, and lines, which is enough to locate the +leak. + +## Suppression discipline + +Prefer FIXING a finding. Suppress only a finding you have judged a true +non-issue, always with a reason: + +- `baseline_create` / `baseline_update` — snapshot current defects so only NEW + findings surface. A coarse, whole-set tool; requires a reason. +- `waiver_add` — waive ONE finding by fingerprint with a mandatory reason and an + expiry date. An audited, time-boxed exception. +- `wardline judge` (opt-in, network) — an LLM pass that labels each defect + TRUE/FALSE positive. Never runs automatically, never folded into scan; fails + loud with no API key so "couldn't triage" is never mistaken for "nothing to + triage". Above-floor false positives can be recorded as audited suppressions. + +## CLI vs MCP + +- **CLI:** `wardline scan`, `wardline judge`, `wardline baseline create/update`. + Branch on the exit code; read the findings file it writes. +- **MCP:** `wardline mcp` exposes `scan`, `explain_taint`, `judge` (network), + `baseline_create`, `baseline_update`, `waiver_add`; resources + `wardline://vocab|rules|config|config-schema`; and the `wardline:loop` prompt. + The server is stateless — the read-only tools are pure functions of your code + on disk and your config. +``` + +- [ ] **Step 2: Write the failing test** + +Create `tests/unit/install/test_skill.py`: + +```python +from pathlib import Path + +from wardline.install.skill import install_skill + + +def test_install_skill_creates_both_targets(tmp_path: Path) -> None: + results = install_skill(tmp_path) + assert results == {".claude": "created", ".agents": "created"} + for base in (".claude", ".agents"): + skill = tmp_path / base / "skills" / "wardline-gate" / "SKILL.md" + assert skill.is_file() + assert "name: wardline-gate" in skill.read_text(encoding="utf-8") + + +def test_reinstall_overwrites(tmp_path: Path) -> None: + install_skill(tmp_path) + stale = tmp_path / ".claude" / "skills" / "wardline-gate" / "SKILL.md" + stale.write_text("STALE", encoding="utf-8") + results = install_skill(tmp_path) + assert results[".claude"] == "overwritten" + assert "name: wardline-gate" in stale.read_text(encoding="utf-8") +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_skill.py -v` +Expected: FAIL — `wardline.install.skill` does not exist. + +- [ ] **Step 4: Implement `skill.py`** + +Create `src/wardline/install/skill.py`: + +```python +"""Copy the bundled wardline-gate skill into a project's .claude / .agents.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + + +def _skill_source() -> Path: + # src/wardline/install/skill.py -> src/wardline/skills/wardline-gate + return Path(__file__).resolve().parent.parent / "skills" / "wardline-gate" + + +def install_skill(root: Path) -> dict[str, str]: + """Copy the skill into .claude/skills and .agents/skills (idempotent overwrite). + + Returns a per-target status: created | overwritten. + """ + src = _skill_source() + results: dict[str, str] = {} + for base in (".claude", ".agents"): + dest = root / base / "skills" / "wardline-gate" + existed = dest.exists() + if existed: + shutil.rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dest) + results[base] = "overwritten" if existed else "created" + return results +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_skill.py -v` +Expected: PASS. + +- [ ] **Step 6: Force-include the skill in the wheel** + +In `pyproject.toml`, under `[tool.hatch.build.targets.wheel.force-include]` (after line 57), add: + +```toml +"src/wardline/skills/wardline-gate/SKILL.md" = "wardline/skills/wardline-gate/SKILL.md" +``` + +- [ ] **Step 7: Verify the wheel includes the skill** + +Run: `.venv/bin/python -m build --wheel 2>/dev/null && .venv/bin/python -c "import zipfile,glob; z=zipfile.ZipFile(sorted(glob.glob('dist/*.whl'))[-1]); print([n for n in z.namelist() if 'wardline-gate' in n])"` +Expected: prints a list containing `wardline/skills/wardline-gate/SKILL.md`. (If `build` is unavailable, instead confirm `Path` resolution with: `.venv/bin/python -c "from wardline.install.skill import _skill_source; print((_skill_source()/'SKILL.md').is_file())"` → `True`.) + +- [ ] **Step 8: Commit** + +```bash +git add src/wardline/skills/wardline-gate/SKILL.md src/wardline/install/skill.py pyproject.toml tests/unit/install/test_skill.py +git commit -m "feat(install): bundle wardline-gate skill + copier, ship in wheel + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: `mcp_json.py` — merge the wardline MCP entry + +**Files:** +- Create: `src/wardline/install/mcp_json.py` +- Test: `tests/unit/install/test_mcp_json.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/install/test_mcp_json.py`: + +```python +import json +from pathlib import Path + +import pytest + +from wardline.core.errors import WardlineError +from wardline.install.mcp_json import merge_mcp_entry + +_WARDLINE_ENTRY = {"type": "stdio", "command": "wardline", "args": ["mcp", "--root", "."]} + + +def test_create_when_absent(tmp_path: Path) -> None: + assert merge_mcp_entry(tmp_path) == "created" + data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY + + +def test_merge_preserves_siblings(tmp_path: Path) -> None: + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"filigree": {"type": "stdio", "command": "filigree-mcp", "args": []}}}), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "updated" + data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["filigree"]["command"] == "filigree-mcp" + assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY + + +def test_idempotent_when_entry_matches(tmp_path: Path) -> None: + merge_mcp_entry(tmp_path) + assert merge_mcp_entry(tmp_path) == "unchanged" + + +def test_malformed_json_raises_without_clobbering(tmp_path: Path) -> None: + bad = tmp_path / ".mcp.json" + bad.write_text("{not json", encoding="utf-8") + with pytest.raises(WardlineError): + merge_mcp_entry(tmp_path) + assert bad.read_text(encoding="utf-8") == "{not json" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_mcp_json.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `mcp_json.py`** + +Create `src/wardline/install/mcp_json.py`: + +```python +"""Merge a `wardline` stdio server into a project's .mcp.json, preserving siblings.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from wardline.core.errors import WardlineError + +_ENTRY = {"type": "stdio", "command": "wardline", "args": ["mcp", "--root", "."]} + + +def merge_mcp_entry(root: Path) -> str: + """Add/replace the `wardline` entry under mcpServers. Returns created|updated|unchanged.""" + path = root / ".mcp.json" + if not path.exists(): + payload = {"mcpServers": {"wardline": dict(_ENTRY)}} + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return "created" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise WardlineError(f"malformed .mcp.json: {exc}") from exc + if not isinstance(data, dict): + raise WardlineError(".mcp.json must be a JSON object") + servers = data.get("mcpServers") + if servers is None: + servers = {} + data["mcpServers"] = servers + if not isinstance(servers, dict): + raise WardlineError(".mcp.json mcpServers must be an object") + if servers.get("wardline") == _ENTRY: + return "unchanged" + servers["wardline"] = dict(_ENTRY) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return "updated" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_mcp_json.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/mcp_json.py tests/unit/install/test_mcp_json.py +git commit -m "feat(install): merge wardline entry into .mcp.json (preserve siblings) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: `detect.py` — Clarion/Filigree detection + wardline.yaml stanzas + +**Files:** +- Create: `src/wardline/install/detect.py` +- Test: `tests/unit/install/test_detect.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/install/test_detect.py`: + +```python +from pathlib import Path + +from wardline.install.detect import record_bindings + + +def test_no_siblings_writes_nothing(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + results = record_bindings(tmp_path) + assert results == {"clarion": "absent", "filigree": "absent"} + assert not (tmp_path / "wardline.yaml").exists() + + +def test_filigree_marker_writes_commented_stanza(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / ".filigree.conf").write_text("{}", encoding="utf-8") + results = record_bindings(tmp_path) + assert results["filigree"] == "detected (commented)" + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert "wardline-install:filigree" in text + assert "# filigree:" in text + + +def test_env_url_writes_live_stanza(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://clar:9100") + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + results = record_bindings(tmp_path) + assert results["clarion"] == "wired (env URL)" + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert 'clarion:\n url: "http://clar:9100"' in text + + +def test_existing_key_left_untouched(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://new") + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://existing"\n', encoding="utf-8" + ) + results = record_bindings(tmp_path) + assert results["clarion"] == "present (left untouched)" + assert (tmp_path / "wardline.yaml").read_text(encoding="utf-8").count("clarion:") == 1 + assert "http://new" not in (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + + +def test_rerun_does_not_duplicate_commented_stanza(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / ".filigree.conf").write_text("{}", encoding="utf-8") + record_bindings(tmp_path) + record_bindings(tmp_path) + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert text.count("wardline-install:filigree") == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_detect.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `detect.py`** + +Create `src/wardline/install/detect.py`: + +```python +"""Detect sibling tools (Clarion, Filigree) and record bindings in wardline.yaml. + +Presence is detectable (a marker file or a binary on PATH / an env URL); a service +URL is not discoverable, so we write a live stanza only when an env URL is set, +otherwise a commented stanza for the user to fill. Writes are text-appends guarded +by a key/sentinel check, so re-running never duplicates or clobbers. +""" + +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path + + +def _detect_clarion() -> tuple[bool, str | None]: + url = os.environ.get("WARDLINE_CLARION_URL") or None + present = bool(url) or shutil.which("clarion") is not None + return present, url + + +def _detect_filigree(root: Path) -> tuple[bool, str | None]: + url = os.environ.get("WARDLINE_FILIGREE_URL") or None + present = bool(url) or (root / ".filigree.conf").is_file() + return present, url + + +def _live_stanza(key: str, url: str) -> str: + return f'{key}:\n url: "{url}" # wardline-install:{key} (from env at install time)\n' + + +_COMMENTED = { + "clarion": ( + "# wardline-install:clarion — Clarion taint store detected, no URL configured.\n" + "# Set the taint-store URL to enable per-entity taint-fact enrichment:\n" + "# clarion:\n" + '# url: "http://localhost:PORT"\n' + ), + "filigree": ( + "# wardline-install:filigree — Filigree detected (.filigree.conf), no URL configured.\n" + "# Set the Loom scan-results URL to POST findings into Filigree:\n" + "# filigree:\n" + '# url: "http://localhost:PORT/api/loom/scan-results"\n' + ), +} + + +def _already_recorded(text: str, key: str) -> bool: + # Live key at column 0, or our sentinel from a previous commented write. + return bool(re.search(rf"(?m)^{key}:", text)) or f"wardline-install:{key}" in text + + +def record_bindings(root: Path) -> dict[str, str]: + """Detect siblings and append stanzas to wardline.yaml. Returns per-key status.""" + cfg = root / "wardline.yaml" + text = cfg.read_text(encoding="utf-8") if cfg.exists() else "" + detections = {"clarion": _detect_clarion(), "filigree": _detect_filigree(root)} + additions: list[str] = [] + results: dict[str, str] = {} + for key, (present, url) in detections.items(): + if not present: + results[key] = "absent" + continue + if _already_recorded(text + "".join(additions), key): + results[key] = "present (left untouched)" + continue + if url: + additions.append(_live_stanza(key, url)) + results[key] = "wired (env URL)" + else: + additions.append(_COMMENTED[key]) + results[key] = "detected (commented)" + if additions: + sep = "" if (not text or text.endswith("\n")) else "\n" + lead = "\n" if text else "" + cfg.write_text(text + sep + lead + "\n".join(additions), encoding="utf-8") + return results +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_detect.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/detect.py tests/unit/install/test_detect.py +git commit -m "feat(install): detect Clarion/Filigree and record wardline.yaml bindings + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: `install` CLI command + registration + +**Files:** +- Create: `src/wardline/cli/install.py` +- Modify: `src/wardline/cli/main.py:13-14,29` (import + register) +- Test: `tests/unit/cli/test_install.py` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/cli/test_install.py`: + +```python +def test_install_writes_all_artifacts(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert (tmp_path / "CLAUDE.md").is_file() + assert (tmp_path / "AGENTS.md").is_file() + assert (tmp_path / ".claude" / "skills" / "wardline-gate" / "SKILL.md").is_file() + assert (tmp_path / ".agents" / "skills" / "wardline-gate" / "SKILL.md").is_file() + assert (tmp_path / ".mcp.json").is_file() + assert "CLAUDE.md" in result.output + + +def test_install_is_idempotent(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "unchanged" in result.output + + +def test_install_opt_outs(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + result = CliRunner().invoke( + cli, + ["install", "--root", str(tmp_path), "--no-agents-md", "--no-skill", + "--no-mcp", "--no-bindings"], + ) + assert result.exit_code == 0, result.output + assert (tmp_path / "CLAUDE.md").is_file() + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / ".claude").exists() + assert not (tmp_path / ".mcp.json").exists() + + +def test_install_fails_2_on_malformed_mcp_json(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / ".mcp.json").write_text("{bad", encoding="utf-8") + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 2 + assert "malformed .mcp.json" in result.output +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/unit/cli/test_install.py -k install_writes -v` +Expected: FAIL — `install` command is not registered (`No such command 'install'`). + +- [ ] **Step 3: Implement `cli/install.py`** + +Create `src/wardline/cli/install.py`: + +```python +# src/wardline/cli/install.py +"""`wardline install` — push agent-enablement artifacts into a project.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from wardline.core.errors import WardlineError +from wardline.install.block import inject_block +from wardline.install.detect import record_bindings +from wardline.install.mcp_json import merge_mcp_entry +from wardline.install.skill import install_skill + + +@click.command() +@click.option("--root", type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", help="Project root to install into (default: cwd).") +@click.option("--no-claude-md", is_flag=True, help="Skip the CLAUDE.md instruction block.") +@click.option("--no-agents-md", is_flag=True, help="Skip the AGENTS.md instruction block.") +@click.option("--no-skill", is_flag=True, help="Skip the wardline-gate skill.") +@click.option("--no-mcp", is_flag=True, help="Skip wiring .mcp.json.") +@click.option("--no-bindings", is_flag=True, help="Skip Clarion/Filigree detection.") +def install( + root: Path, + no_claude_md: bool, + no_agents_md: bool, + no_skill: bool, + no_mcp: bool, + no_bindings: bool, +) -> None: + """Install wardline's agent-facing guidance and sibling bindings into ROOT.""" + lines: list[str] = [] + try: + if not no_claude_md: + lines.append(f"CLAUDE.md: {inject_block(root / 'CLAUDE.md')}") + if not no_agents_md: + lines.append(f"AGENTS.md: {inject_block(root / 'AGENTS.md')}") + if not no_skill: + for base, status in install_skill(root).items(): + lines.append(f"skill {base}/skills/wardline-gate: {status}") + if not no_mcp: + lines.append(f".mcp.json (wardline entry): {merge_mcp_entry(root)}") + if not no_bindings: + for name, status in record_bindings(root).items(): + lines.append(f"{name}: {status}") + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc + click.echo("wardline install:") + for line in lines: + click.echo(f" {line}") +``` + +- [ ] **Step 4: Register the command** + +In `src/wardline/cli/main.py`, add after line 13 (`from wardline.cli.mcp import mcp`): + +```python +from wardline.cli.install import install +``` + +and after line 29 (`cli.add_command(mcp)`): + +```python +cli.add_command(install) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/unit/cli/test_install.py -v` +Expected: PASS (all install tests). + +- [ ] **Step 6: Run the full suite** + +Run: `.venv/bin/pytest -q` +Expected: PASS (no regressions across the suite). + +- [ ] **Step 7: Lint + type-check** + +Run: `.venv/bin/ruff check src/wardline/install src/wardline/cli/install.py && .venv/bin/mypy src/wardline/install src/wardline/cli/install.py` +Expected: clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/wardline/cli/install.py src/wardline/cli/main.py tests/unit/cli/test_install.py +git commit -m "feat(cli): wardline install command (block + skill + mcp + bindings) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Docs + changelog + +**Files:** +- Modify: `docs/agents.md` (add an install section near the top) +- Modify: `CHANGELOG.md` ([Unreleased] Added) + +- [ ] **Step 1: Add the install section to `docs/agents.md`** + +Insert after the intro (before "## Gate the agent's work"): + +```markdown +## One-command setup: `wardline install` + +`wardline install` wires wardline into a project's agent context in one step: + +- injects a small, hash-fenced block into `CLAUDE.md` and `AGENTS.md` pointing + the agent at the gate and the loop; +- installs the `wardline-gate` skill into `.claude/skills/` and `.agents/skills/`; +- merges a `wardline` entry into `.mcp.json` (preserving any existing servers); +- detects a Clarion taint store (`clarion` on `PATH` or `WARDLINE_CLARION_URL`) + and a Filigree project (`.filigree.conf`), recording a `clarion:`/`filigree:` + binding in `wardline.yaml` — live when a URL env var is set, otherwise a + commented stanza for you to fill. + +```console +$ wardline install +wardline install: + CLAUDE.md: created + AGENTS.md: created + skill .claude/skills/wardline-gate: created + skill .agents/skills/wardline-gate: created + .mcp.json (wardline entry): created + clarion: detected (commented) + filigree: detected (commented) +``` + +It is idempotent (re-run to refresh after upgrading wardline) and non-interactive +(safe in CI). Opt out of any piece with `--no-claude-md`, `--no-agents-md`, +`--no-skill`, `--no-mcp`, or `--no-bindings`. There is no SessionStart hook — +freshness is enforced only when you re-run `wardline install`. + +Once installed, the MCP server resolves the Clarion URL from `wardline.yaml`, so +the `.mcp.json` entry stays a bare `wardline mcp --root .` with no URL in its args. +``` + +- [ ] **Step 2: Add the changelog entry** + +Under `## [Unreleased]` → `### Added` in `CHANGELOG.md`: + +```markdown +- `wardline install`: one-command agent enablement — injects a hash-fenced + instruction block into `CLAUDE.md`/`AGENTS.md`, installs the `wardline-gate` + skill, merges a `wardline` entry into `.mcp.json`, and detects Clarion/Filigree + to record bindings in `wardline.yaml`. `clarion.url`/`filigree.url` are now + runtime-read config fields (precedence: CLI flag > env var > `wardline.yaml`). +``` + +- [ ] **Step 3: Build the docs strictly (catches broken markdown/nav)** + +Run: `.venv/bin/mkdocs build --strict 2>&1 | tail -5` (if the `docs` extra is installed; otherwise skip and eyeball the file). +Expected: build succeeds, no warnings. + +- [ ] **Step 4: Commit** + +```bash +git add docs/agents.md CHANGELOG.md +git commit -m "docs: document wardline install (agents.md + changelog) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification + +- [ ] **Full suite + lint + types:** `.venv/bin/pytest -q && .venv/bin/ruff check src tests && .venv/bin/mypy src` +- [ ] **Live smoke test in a temp dir:** + +```bash +tmp=$(mktemp -d); .venv/bin/wardline install --root "$tmp"; \ + ls -R "$tmp"; cat "$tmp/CLAUDE.md"; \ + .venv/bin/wardline install --root "$tmp" | grep -q unchanged && echo "IDEMPOTENT OK"; \ + rm -rf "$tmp" +``` + +Expected: all artifacts present on the first run; the second run reports `unchanged` for the block/mcp entries. + +--- + +## Notes / resolved open points + +- **`.mcp.json` command string:** resolved to bare `wardline` — `pyproject.toml` + declares `[project.scripts] wardline = "wardline.cli.main:cli"`, so the console + script is on PATH wherever the package is installed. +- **Uninstall path:** out of scope. The fenced block, the skill directory, and the + `.mcp.json` entry are all safe to remove by hand; add an `uninstall` verb later + only if usage demands it. +- **No SessionStart hook:** deliberate (see spec non-goals). This is the "inject + half" of filigree's pipeline. From a8a6d3975818f4c50f59f504237a54485e49ea89 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:19:40 +1000 Subject: [PATCH 03/17] feat(config): clarion.url/filigree.url become runtime-read fields with flag>env>config resolution Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/core/config.py | 43 ++++++++++++++++++++++++ src/wardline/core/config_schema.py | 12 +++++-- tests/unit/core/test_config.py | 52 ++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/wardline/core/config.py b/src/wardline/core/config.py index 77771350..d816615a 100644 --- a/src/wardline/core/config.py +++ b/src/wardline/core/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -27,6 +28,16 @@ class WardlineConfig: filigree: Mapping[str, Any] = field(default_factory=dict) clarion: Mapping[str, Any] = field(default_factory=dict) + @property + def clarion_url(self) -> str | None: + value = self.clarion.get("url") + return value if isinstance(value, str) else None + + @property + def filigree_url(self) -> str | None: + value = self.filigree.get("url") + return value if isinstance(value, str) else None + def load(path: Path | None) -> WardlineConfig: if path is None or not path.exists(): @@ -55,6 +66,38 @@ def load(path: Path | None) -> WardlineConfig: ) +_CLARION_URL_ENV = "WARDLINE_CLARION_URL" +_FILIGREE_URL_ENV = "WARDLINE_FILIGREE_URL" + + +def _config_for(root: Path, config_path: Path | None) -> WardlineConfig: + return load(config_path if config_path is not None else root / "wardline.yaml") + + +def resolve_clarion_url( + flag: str | None, root: Path, config_path: Path | None = None +) -> str | None: + """Clarion URL by precedence: explicit flag > env var > wardline.yaml.""" + if flag is not None: + return flag + env = os.environ.get(_CLARION_URL_ENV) + if env: + return env + return _config_for(root, config_path).clarion_url + + +def resolve_filigree_url( + flag: str | None, root: Path, config_path: Path | None = None +) -> str | None: + """Filigree Loom URL by precedence: explicit flag > env var > wardline.yaml.""" + if flag is not None: + return flag + env = os.environ.get(_FILIGREE_URL_ENV) + if env: + return env + return _config_for(root, config_path).filigree_url + + @dataclass(frozen=True, slots=True) class JudgeSettings: model: str = "anthropic/claude-opus-4-8" diff --git a/src/wardline/core/config_schema.py b/src/wardline/core/config_schema.py index 09e33dee..0fe20cea 100644 --- a/src/wardline/core/config_schema.py +++ b/src/wardline/core/config_schema.py @@ -38,7 +38,15 @@ "write_confidence_floor": {"type": "number", "minimum": 0.0, "maximum": 1.0}, }, }, - "filigree": {"type": "object"}, - "clarion": {"type": "object"}, + "filigree": { + "type": "object", + "additionalProperties": False, + "properties": {"url": {"type": "string"}}, + }, + "clarion": { + "type": "object", + "additionalProperties": False, + "properties": {"url": {"type": "string"}}, + }, }, } diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 31fbc4fa..3f7bf461 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -1,6 +1,13 @@ +from pathlib import Path + import pytest -from wardline.core.config import load +from wardline.core.config import ( + WardlineConfig, + load, + resolve_clarion_url, + resolve_filigree_url, +) from wardline.core.errors import ConfigError @@ -124,7 +131,7 @@ def test_full_valid_config_passes(tmp_path) -> None: "judge:\n model: anthropic/claude-opus-4-8\n context_lines: 10\n" " max_findings: 50\n write_confidence_floor: 0.7\n" "filigree: {url: http://x}\n" - "clarion: {db: .clarion/clarion.db}\n", + "clarion: {url: http://clarion.local:9100}\n", encoding="utf-8", ) cfg = load(p) @@ -164,3 +171,44 @@ def test_unknown_judge_key_raises(tmp_path) -> None: p.write_text("judge:\n bogus_setting: 1\n", encoding="utf-8") with pytest.raises(ConfigError): load(p) + + +def test_clarion_and_filigree_url_read_from_config(tmp_path: Path) -> None: + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://clarion.local:9100"\n' + 'filigree:\n url: "http://filigree.local/api/loom/scan-results"\n', + encoding="utf-8", + ) + cfg = load(tmp_path / "wardline.yaml") + assert cfg.clarion_url == "http://clarion.local:9100" + assert cfg.filigree_url == "http://filigree.local/api/loom/scan-results" + + +def test_urls_default_to_none() -> None: + cfg = WardlineConfig() + assert cfg.clarion_url is None + assert cfg.filigree_url is None + + +def test_unknown_clarion_key_is_rejected(tmp_path: Path) -> None: + (tmp_path / "wardline.yaml").write_text( + "clarion:\n bogus: 1\n", encoding="utf-8" + ) + with pytest.raises(ConfigError): + load(tmp_path / "wardline.yaml") + + +def test_resolve_precedence_flag_beats_env_beats_config(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://from-config"\n', encoding="utf-8" + ) + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + assert resolve_clarion_url(None, tmp_path, None) == "http://from-config" + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://from-env") + assert resolve_clarion_url(None, tmp_path, None) == "http://from-env" + assert resolve_clarion_url("http://from-flag", tmp_path, None) == "http://from-flag" + + +def test_resolve_filigree_env(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://fil-env") + assert resolve_filigree_url(None, tmp_path, None) == "http://fil-env" From 26f3224b32a4b8272d42c73dab83338356c4eb1b Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:24:20 +1000 Subject: [PATCH 04/17] feat(cli): scan/mcp resolve clarion+filigree URLs from config/env Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/cli/mcp.py | 3 +++ src/wardline/cli/scan.py | 3 +++ tests/unit/cli/test_install.py | 47 ++++++++++++++++++++++++++++++++++ tests/unit/core/test_config.py | 5 ++++ 4 files changed, 58 insertions(+) create mode 100644 tests/unit/cli/test_install.py diff --git a/src/wardline/cli/mcp.py b/src/wardline/cli/mcp.py index 93935ce1..d1f016d4 100644 --- a/src/wardline/cli/mcp.py +++ b/src/wardline/cli/mcp.py @@ -17,4 +17,7 @@ help="Clarion taint-store URL: `scan` writes facts; `explain_taint` queries it.") def mcp(root: Path, clarion_url: str | None) -> None: """Run the Wardline MCP server over stdio (JSON-RPC 2.0).""" + from wardline.core.config import resolve_clarion_url + + clarion_url = resolve_clarion_url(clarion_url, root, None) WardlineMCPServer(root=root, clarion_url=clarion_url).rpc.run_stdio() diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index 84d85f2e..56da2521 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -7,6 +7,7 @@ import click +from wardline.core.config import resolve_clarion_url, resolve_filigree_url from wardline.core.emit import JsonlSink from wardline.core.errors import WardlineError from wardline.core.filigree_emit import EmitResult, FiligreeEmitter @@ -55,6 +56,8 @@ def scan( emit_result: EmitResult | None = None clarion_result = None try: + filigree_url = resolve_filigree_url(filigree_url, path, config_path) + clarion_url = resolve_clarion_url(clarion_url, path, config_path) result = run_scan(path, config_path=config_path, cache_dir=cache_dir) findings = result.findings sink = SarifSink(output) if fmt == "sarif" else JsonlSink(output) diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py new file mode 100644 index 00000000..f6e73015 --- /dev/null +++ b/tests/unit/cli/test_install.py @@ -0,0 +1,47 @@ +from pathlib import Path + +from click.testing import CliRunner + +from wardline.cli.main import cli + + +def test_scan_reads_filigree_url_from_config(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "wardline.yaml").write_text( + 'filigree:\n url: "http://configured-filigree"\n', encoding="utf-8" + ) + (tmp_path / "m.py").write_text("x = 1\n", encoding="utf-8") + captured: dict[str, object] = {} + + class _FakeEmitter: + def __init__(self, url: str) -> None: + captured["url"] = url + + def emit(self, findings): # noqa: ANN001 + from wardline.core.filigree_emit import EmitResult + + return EmitResult(reachable=False) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _FakeEmitter) + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert captured["url"] == "http://configured-filigree" + + +def test_mcp_resolves_clarion_url_from_config(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://configured-clarion"\n', encoding="utf-8" + ) + captured: dict[str, object] = {} + + class _FakeServer: + def __init__(self, *, root: Path, clarion_url: str | None = None) -> None: + captured["clarion_url"] = clarion_url + self.rpc = self + + def run_stdio(self) -> None: + captured["ran"] = True + + monkeypatch.setattr("wardline.cli.mcp.WardlineMCPServer", _FakeServer) + result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert captured["clarion_url"] == "http://configured-clarion" diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 3f7bf461..5276f25e 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -212,3 +212,8 @@ def test_resolve_precedence_flag_beats_env_beats_config(tmp_path: Path, monkeypa def test_resolve_filigree_env(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://fil-env") assert resolve_filigree_url(None, tmp_path, None) == "http://fil-env" + + +def test_resolve_filigree_flag_beats_env(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://fil-env") + assert resolve_filigree_url("http://fil-flag", tmp_path, None) == "http://fil-flag" From a8a41114de1ddd9347023afc3ea8ea3e5af324c5 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:30:17 +1000 Subject: [PATCH 05/17] feat(install): hash-fenced instruction block render+inject Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/__init__.py | 0 src/wardline/install/block.py | 54 ++++++++++++++++++++++++++++++++ tests/unit/install/__init__.py | 0 tests/unit/install/test_block.py | 48 ++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 src/wardline/install/__init__.py create mode 100644 src/wardline/install/block.py create mode 100644 tests/unit/install/__init__.py create mode 100644 tests/unit/install/test_block.py diff --git a/src/wardline/install/__init__.py b/src/wardline/install/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/wardline/install/block.py b/src/wardline/install/block.py new file mode 100644 index 00000000..236bf09d --- /dev/null +++ b/src/wardline/install/block.py @@ -0,0 +1,54 @@ +"""Render + idempotently inject the hash-fenced wardline instruction block.""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +_BLOCK_VERSION = "1" + +_BODY = ( + "This project uses **wardline** as its trust-boundary gate. Before handing " + "back code that touches external input, run `wardline scan . --fail-on ERROR` " + "(exit 0 = clean, 1 = gate tripped, 2 = wardline error) and fix findings at " + "the boundary, not the sink. The full scan -> explain -> fix -> rescan loop " + "and the baseline-vs-waiver discipline live in the `wardline-gate` skill and " + "in `docs/agents.md`." +) + +_FENCE_RE = re.compile( + r".*?", + re.DOTALL, +) + + +def _body_hash() -> str: + return hashlib.sha256(_BODY.encode("utf-8")).hexdigest()[:8] + + +def render_block() -> str: + return ( + f"\n" + f"{_BODY}\n" + "" + ) + + +def inject_block(file_path: Path) -> str: + """Create / append / replace the block. Returns created|updated|unchanged.""" + block = render_block() + if not file_path.exists(): + file_path.write_text(block + "\n", encoding="utf-8") + return "created" + text = file_path.read_text(encoding="utf-8") + match = _FENCE_RE.search(text) + if match is None: + sep = "" if text.endswith("\n") else "\n" + file_path.write_text(f"{text}{sep}\n{block}\n", encoding="utf-8") + return "updated" + if match.group(0) == block: + return "unchanged" + new = text[: match.start()] + block + text[match.end() :] + file_path.write_text(new, encoding="utf-8") + return "updated" diff --git a/tests/unit/install/__init__.py b/tests/unit/install/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/install/test_block.py b/tests/unit/install/test_block.py new file mode 100644 index 00000000..e66ac585 --- /dev/null +++ b/tests/unit/install/test_block.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from wardline.install.block import inject_block, render_block + + +def test_render_block_is_fenced_and_mentions_the_gate() -> None: + block = render_block() + assert block.startswith("") + assert "wardline scan" in block + assert "wardline-gate" in block + + +def test_inject_into_absent_file_creates_it(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + assert inject_block(f) == "created" + assert f.read_text(encoding="utf-8").count("wardline:instructions:v") == 1 + + +def test_inject_appends_when_no_fence_present(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + f.write_text("# My project\n\nExisting content.\n", encoding="utf-8") + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert "Existing content." in text + assert "wardline:instructions" in text + + +def test_reinject_same_version_is_unchanged(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + inject_block(f) + before = f.read_text(encoding="utf-8") + assert inject_block(f) == "unchanged" + assert f.read_text(encoding="utf-8") == before + + +def test_inject_replaces_a_stale_fenced_block(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + f.write_text( + "intro\n\n\nOLD BODY\n" + "\n\noutro\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert "OLD BODY" not in text + assert "intro" in text and "outro" in text + assert text.count("wardline:instructions:v") == 1 From 2f4577a9f41b2ef3ef4f8fc32faadf8d303b5fc6 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:34:05 +1000 Subject: [PATCH 06/17] feat(install): hash-fenced instruction block render+inject inject_block collapses to a single canonical block (idempotent even if a file already holds multiple blocks) and handles files lacking a trailing newline. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/block.py | 20 ++++++++++++++------ tests/unit/install/test_block.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/wardline/install/block.py b/src/wardline/install/block.py index 236bf09d..56e531db 100644 --- a/src/wardline/install/block.py +++ b/src/wardline/install/block.py @@ -36,19 +36,27 @@ def render_block() -> str: def inject_block(file_path: Path) -> str: - """Create / append / replace the block. Returns created|updated|unchanged.""" + """Create / append / replace the block, collapsing to one canonical block. + + Returns created|updated|unchanged. + """ block = render_block() if not file_path.exists(): file_path.write_text(block + "\n", encoding="utf-8") return "created" text = file_path.read_text(encoding="utf-8") - match = _FENCE_RE.search(text) - if match is None: + matches = list(_FENCE_RE.finditer(text)) + if not matches: sep = "" if text.endswith("\n") else "\n" file_path.write_text(f"{text}{sep}\n{block}\n", encoding="utf-8") return "updated" - if match.group(0) == block: + # Canonicalise to a single block at the position of the first existing one; + # drop any additional blocks (e.g. from a botched prior write) so repeated + # calls converge. + prefix = text[: matches[0].start()] + suffix = _FENCE_RE.sub("", text[matches[0].end() :]) + candidate = prefix + block + suffix + if candidate == text: return "unchanged" - new = text[: match.start()] + block + text[match.end() :] - file_path.write_text(new, encoding="utf-8") + file_path.write_text(candidate, encoding="utf-8") return "updated" diff --git a/tests/unit/install/test_block.py b/tests/unit/install/test_block.py index e66ac585..292222b1 100644 --- a/tests/unit/install/test_block.py +++ b/tests/unit/install/test_block.py @@ -46,3 +46,31 @@ def test_inject_replaces_a_stale_fenced_block(tmp_path: Path) -> None: assert "OLD BODY" not in text assert "intro" in text and "outro" in text assert text.count("wardline:instructions:v") == 1 + + +def test_inject_appends_when_file_has_no_trailing_newline(tmp_path: Path) -> None: + f = tmp_path / "CLAUDE.md" + f.write_text("no trailing newline", encoding="utf-8") # no final \n + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert text.startswith("no trailing newline\n") + assert text.count("wardline:instructions:v") == 1 + + +def test_inject_collapses_multiple_existing_blocks(tmp_path: Path) -> None: + block = render_block() + f = tmp_path / "CLAUDE.md" + # Two current blocks already present (e.g. a botched prior write). + f.write_text(f"head\n\n{block}\n\nmid\n\n{block}\n\ntail\n", encoding="utf-8") + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert text.count("wardline:instructions:v") == 1 + assert "head" in text and "tail" in text + + +def test_inject_is_idempotent_after_collapse(tmp_path: Path) -> None: + block = render_block() + f = tmp_path / "CLAUDE.md" + f.write_text(f"{block}\n\n{block}\n", encoding="utf-8") + inject_block(f) # collapses to one + assert inject_block(f) == "unchanged" # now converged From 18bed0328823ad7c3a98745ea2e0099504e3cddd Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:37:18 +1000 Subject: [PATCH 07/17] feat(install): bundle wardline-gate skill + copier, ship in wheel Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 1 + src/wardline/install/skill.py | 29 ++++++++++ src/wardline/skills/wardline-gate/SKILL.md | 64 ++++++++++++++++++++++ tests/unit/install/test_skill.py | 21 +++++++ 4 files changed, 115 insertions(+) create mode 100644 src/wardline/install/skill.py create mode 100644 src/wardline/skills/wardline-gate/SKILL.md create mode 100644 tests/unit/install/test_skill.py diff --git a/pyproject.toml b/pyproject.toml index 0fd4fdcb..e3a383d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ packages = ["src/wardline"] [tool.hatch.build.targets.wheel.force-include] "src/wardline/scanner/taint/stdlib_taint.yaml" = "wardline/scanner/taint/stdlib_taint.yaml" "src/wardline/core/vocabulary.yaml" = "wardline/core/vocabulary.yaml" +"src/wardline/skills/wardline-gate/SKILL.md" = "wardline/skills/wardline-gate/SKILL.md" [tool.ruff] line-length = 120 diff --git a/src/wardline/install/skill.py b/src/wardline/install/skill.py new file mode 100644 index 00000000..f7416bc7 --- /dev/null +++ b/src/wardline/install/skill.py @@ -0,0 +1,29 @@ +"""Copy the bundled wardline-gate skill into a project's .claude / .agents.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + + +def _skill_source() -> Path: + # src/wardline/install/skill.py -> src/wardline/skills/wardline-gate + return Path(__file__).resolve().parent.parent / "skills" / "wardline-gate" + + +def install_skill(root: Path) -> dict[str, str]: + """Copy the skill into .claude/skills and .agents/skills (idempotent overwrite). + + Returns a per-target status: created | overwritten. + """ + src = _skill_source() + results: dict[str, str] = {} + for base in (".claude", ".agents"): + dest = root / base / "skills" / "wardline-gate" + existed = dest.exists() + if existed: + shutil.rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dest) + results[base] = "overwritten" if existed else "created" + return results diff --git a/src/wardline/skills/wardline-gate/SKILL.md b/src/wardline/skills/wardline-gate/SKILL.md new file mode 100644 index 00000000..dd023114 --- /dev/null +++ b/src/wardline/skills/wardline-gate/SKILL.md @@ -0,0 +1,64 @@ +--- +name: wardline-gate +description: > + Use when scanning for or fixing trust-boundary / taint findings, when a + `wardline scan` reports a defect, or when wiring wardline into an agent's + edit-verify loop. Explains the scan -> explain -> fix-at-the-boundary -> + rescan cycle and the baseline-vs-waiver discipline. +--- + +# Wardline: the trust-boundary gate + +Wardline is a deterministic, whole-program static taint analyzer. It marks trust +boundaries with two decorators from `wardline.decorators`: `@external_boundary` +(untrusted data arriving from outside) and `@trusted` (a producer that must only +receive validated data). When untrusted data reaches a trusted producer it raises +`PY-WL-101` at `ERROR`. + +## The loop + +1. **Scan.** Run `wardline scan . --fail-on ERROR` (or call the `scan` MCP tool). + Read the gate verdict and the active (non-suppressed) findings — `active` is + the population the gate enforces on. +2. **Explain.** For each active defect, call `explain_taint` with the finding's + `fingerprint`, `path`+`line`, and its `qualname` as `sink_qualname`. Do this + right after the scan and before editing — a stale fingerprint returns an error. + With a Clarion store configured, pass `chain: true` to walk the full taint + chain back to the originating boundary. +3. **Fix at the BOUNDARY, not the sink.** Add validation or rejection at the hop + where untrusted data should have been checked — not a band-aid at the sink. +4. **Re-scan.** Confirm the finding is gone. + +## Exit codes (CLI path) + +- `0` — clean (or gate not requested). +- `1` — the gate tripped: a non-suppressed defect at/above `--fail-on`. +- `2` — a wardline error (bad config, unreadable path). Not a finding. + +Branch on the code. On a trip, read the structured report wardline just wrote — +the finding names the function, file, and lines, which is enough to locate the +leak. + +## Suppression discipline + +Prefer FIXING a finding. Suppress only a finding you have judged a true +non-issue, always with a reason: + +- `baseline_create` / `baseline_update` — snapshot current defects so only NEW + findings surface. A coarse, whole-set tool; requires a reason. +- `waiver_add` — waive ONE finding by fingerprint with a mandatory reason and an + expiry date. An audited, time-boxed exception. +- `wardline judge` (opt-in, network) — an LLM pass that labels each defect + TRUE/FALSE positive. Never runs automatically, never folded into scan; fails + loud with no API key so "couldn't triage" is never mistaken for "nothing to + triage". Above-floor false positives can be recorded as audited suppressions. + +## CLI vs MCP + +- **CLI:** `wardline scan`, `wardline judge`, `wardline baseline create/update`. + Branch on the exit code; read the findings file it writes. +- **MCP:** `wardline mcp` exposes `scan`, `explain_taint`, `judge` (network), + `baseline_create`, `baseline_update`, `waiver_add`; resources + `wardline://vocab|rules|config|config-schema`; and the `wardline:loop` prompt. + The server is stateless — the read-only tools are pure functions of your code + on disk and your config. diff --git a/tests/unit/install/test_skill.py b/tests/unit/install/test_skill.py new file mode 100644 index 00000000..0a1b6997 --- /dev/null +++ b/tests/unit/install/test_skill.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from wardline.install.skill import install_skill + + +def test_install_skill_creates_both_targets(tmp_path: Path) -> None: + results = install_skill(tmp_path) + assert results == {".claude": "created", ".agents": "created"} + for base in (".claude", ".agents"): + skill = tmp_path / base / "skills" / "wardline-gate" / "SKILL.md" + assert skill.is_file() + assert "name: wardline-gate" in skill.read_text(encoding="utf-8") + + +def test_reinstall_overwrites(tmp_path: Path) -> None: + install_skill(tmp_path) + stale = tmp_path / ".claude" / "skills" / "wardline-gate" / "SKILL.md" + stale.write_text("STALE", encoding="utf-8") + results = install_skill(tmp_path) + assert results[".claude"] == "overwritten" + assert "name: wardline-gate" in stale.read_text(encoding="utf-8") From 33de73b0b899ff12853b944e6c8757f35e078572 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:42:35 +1000 Subject: [PATCH 08/17] refactor(install): harden skill copier against dangling symlinks; assert .agents overwrite Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/skill.py | 2 +- tests/unit/install/test_skill.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wardline/install/skill.py b/src/wardline/install/skill.py index f7416bc7..e7c1d74f 100644 --- a/src/wardline/install/skill.py +++ b/src/wardline/install/skill.py @@ -20,7 +20,7 @@ def install_skill(root: Path) -> dict[str, str]: results: dict[str, str] = {} for base in (".claude", ".agents"): dest = root / base / "skills" / "wardline-gate" - existed = dest.exists() + existed = dest.exists() or dest.is_symlink() if existed: shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/unit/install/test_skill.py b/tests/unit/install/test_skill.py index 0a1b6997..77d7b269 100644 --- a/tests/unit/install/test_skill.py +++ b/tests/unit/install/test_skill.py @@ -18,4 +18,5 @@ def test_reinstall_overwrites(tmp_path: Path) -> None: stale.write_text("STALE", encoding="utf-8") results = install_skill(tmp_path) assert results[".claude"] == "overwritten" + assert results[".agents"] == "overwritten" assert "name: wardline-gate" in stale.read_text(encoding="utf-8") From 3a8293030dca7a2ab2b472a08ab47f51e7c3f01a Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:44:46 +1000 Subject: [PATCH 09/17] feat(install): merge wardline entry into .mcp.json (preserve siblings) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/mcp_json.py | 36 ++++++++++++++++++++++++++ tests/unit/install/test_mcp_json.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/wardline/install/mcp_json.py create mode 100644 tests/unit/install/test_mcp_json.py diff --git a/src/wardline/install/mcp_json.py b/src/wardline/install/mcp_json.py new file mode 100644 index 00000000..0d19d768 --- /dev/null +++ b/src/wardline/install/mcp_json.py @@ -0,0 +1,36 @@ +"""Merge a `wardline` stdio server into a project's .mcp.json, preserving siblings.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from wardline.core.errors import WardlineError + +_ENTRY = {"type": "stdio", "command": "wardline", "args": ["mcp", "--root", "."]} + + +def merge_mcp_entry(root: Path) -> str: + """Add/replace the `wardline` entry under mcpServers. Returns created|updated|unchanged.""" + path = root / ".mcp.json" + if not path.exists(): + payload = {"mcpServers": {"wardline": dict(_ENTRY)}} + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return "created" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise WardlineError(f"malformed .mcp.json: {exc}") from exc + if not isinstance(data, dict): + raise WardlineError(".mcp.json must be a JSON object") + servers = data.get("mcpServers") + if servers is None: + servers = {} + data["mcpServers"] = servers + if not isinstance(servers, dict): + raise WardlineError(".mcp.json mcpServers must be an object") + if servers.get("wardline") == _ENTRY: + return "unchanged" + servers["wardline"] = dict(_ENTRY) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return "updated" diff --git a/tests/unit/install/test_mcp_json.py b/tests/unit/install/test_mcp_json.py new file mode 100644 index 00000000..6ed50594 --- /dev/null +++ b/tests/unit/install/test_mcp_json.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path + +import pytest + +from wardline.core.errors import WardlineError +from wardline.install.mcp_json import merge_mcp_entry + +_WARDLINE_ENTRY = {"type": "stdio", "command": "wardline", "args": ["mcp", "--root", "."]} + + +def test_create_when_absent(tmp_path: Path) -> None: + assert merge_mcp_entry(tmp_path) == "created" + data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY + + +def test_merge_preserves_siblings(tmp_path: Path) -> None: + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"filigree": {"type": "stdio", "command": "filigree-mcp", "args": []}}}), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "updated" + data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["filigree"]["command"] == "filigree-mcp" + assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY + + +def test_idempotent_when_entry_matches(tmp_path: Path) -> None: + merge_mcp_entry(tmp_path) + assert merge_mcp_entry(tmp_path) == "unchanged" + + +def test_malformed_json_raises_without_clobbering(tmp_path: Path) -> None: + bad = tmp_path / ".mcp.json" + bad.write_text("{not json", encoding="utf-8") + with pytest.raises(WardlineError): + merge_mcp_entry(tmp_path) + assert bad.read_text(encoding="utf-8") == "{not json" From 5b9e96859d7029a61b3acf8f2c245ee61846f3c3 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:47:09 +1000 Subject: [PATCH 10/17] test(install): cover .mcp.json non-dict guard and null mcpServers Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/install/test_mcp_json.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/install/test_mcp_json.py b/tests/unit/install/test_mcp_json.py index 6ed50594..a36a4a1e 100644 --- a/tests/unit/install/test_mcp_json.py +++ b/tests/unit/install/test_mcp_json.py @@ -37,3 +37,21 @@ def test_malformed_json_raises_without_clobbering(tmp_path: Path) -> None: with pytest.raises(WardlineError): merge_mcp_entry(tmp_path) assert bad.read_text(encoding="utf-8") == "{not json" + + +def test_mcpservers_non_dict_raises(tmp_path: Path) -> None: + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": []}), encoding="utf-8" + ) + with pytest.raises(WardlineError): + merge_mcp_entry(tmp_path) + + +def test_mcpservers_null_is_treated_as_absent(tmp_path: Path) -> None: + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": None, "other": 1}), encoding="utf-8" + ) + assert merge_mcp_entry(tmp_path) == "updated" + data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY + assert data["other"] == 1 # unrelated top-level keys preserved From 154e20eec15a5eccc83230ee5b91fe087d358cb8 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:50:05 +1000 Subject: [PATCH 11/17] feat(install): detect Clarion/Filigree and record wardline.yaml bindings Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/detect.py | 78 +++++++++++++++++++++++++++++++ tests/unit/install/test_detect.py | 59 +++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/wardline/install/detect.py create mode 100644 tests/unit/install/test_detect.py diff --git a/src/wardline/install/detect.py b/src/wardline/install/detect.py new file mode 100644 index 00000000..989360ec --- /dev/null +++ b/src/wardline/install/detect.py @@ -0,0 +1,78 @@ +"""Detect sibling tools (Clarion, Filigree) and record bindings in wardline.yaml. + +Presence is detectable (a marker file or a binary on PATH / an env URL); a service +URL is not discoverable, so we write a live stanza only when an env URL is set, +otherwise a commented stanza for the user to fill. Writes are text-appends guarded +by a key/sentinel check, so re-running never duplicates or clobbers. +""" + +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path + + +def _detect_clarion() -> tuple[bool, str | None]: + url = os.environ.get("WARDLINE_CLARION_URL") or None + present = bool(url) or shutil.which("clarion") is not None + return present, url + + +def _detect_filigree(root: Path) -> tuple[bool, str | None]: + url = os.environ.get("WARDLINE_FILIGREE_URL") or None + present = bool(url) or (root / ".filigree.conf").is_file() + return present, url + + +def _live_stanza(key: str, url: str) -> str: + return f'{key}:\n url: "{url}" # wardline-install:{key} (from env at install time)\n' + + +_COMMENTED = { + "clarion": ( + "# wardline-install:clarion — Clarion taint store detected, no URL configured.\n" + "# Set the taint-store URL to enable per-entity taint-fact enrichment:\n" + "# clarion:\n" + '# url: "http://localhost:PORT"\n' + ), + "filigree": ( + "# wardline-install:filigree — Filigree detected (.filigree.conf), no URL configured.\n" + "# Set the Loom scan-results URL to POST findings into Filigree:\n" + "# filigree:\n" + '# url: "http://localhost:PORT/api/loom/scan-results"\n' + ), +} + + +def _already_recorded(text: str, key: str) -> bool: + # Live key at column 0, or our sentinel from a previous commented write. + return bool(re.search(rf"(?m)^{key}:", text)) or f"wardline-install:{key}" in text + + +def record_bindings(root: Path) -> dict[str, str]: + """Detect siblings and append stanzas to wardline.yaml. Returns per-key status.""" + cfg = root / "wardline.yaml" + text = cfg.read_text(encoding="utf-8") if cfg.exists() else "" + detections = {"clarion": _detect_clarion(), "filigree": _detect_filigree(root)} + additions: list[str] = [] + results: dict[str, str] = {} + for key, (present, url) in detections.items(): + if not present: + results[key] = "absent" + continue + if _already_recorded(text + "".join(additions), key): + results[key] = "present (left untouched)" + continue + if url: + additions.append(_live_stanza(key, url)) + results[key] = "wired (env URL)" + else: + additions.append(_COMMENTED[key]) + results[key] = "detected (commented)" + if additions: + sep = "" if (not text or text.endswith("\n")) else "\n" + lead = "\n" if text else "" + cfg.write_text(text + sep + lead + "\n".join(additions), encoding="utf-8") + return results diff --git a/tests/unit/install/test_detect.py b/tests/unit/install/test_detect.py new file mode 100644 index 00000000..eb6e93c6 --- /dev/null +++ b/tests/unit/install/test_detect.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from wardline.install.detect import record_bindings + + +def test_no_siblings_writes_nothing(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + results = record_bindings(tmp_path) + assert results == {"clarion": "absent", "filigree": "absent"} + assert not (tmp_path / "wardline.yaml").exists() + + +def test_filigree_marker_writes_commented_stanza(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / ".filigree.conf").write_text("{}", encoding="utf-8") + results = record_bindings(tmp_path) + assert results["filigree"] == "detected (commented)" + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert "wardline-install:filigree" in text + assert "# filigree:" in text + + +def test_env_url_writes_live_stanza(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://clar:9100") + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + results = record_bindings(tmp_path) + assert results["clarion"] == "wired (env URL)" + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert 'clarion:\n url: "http://clar:9100"' in text + + +def test_existing_key_left_untouched(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://new") + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / "wardline.yaml").write_text( + 'clarion:\n url: "http://existing"\n', encoding="utf-8" + ) + results = record_bindings(tmp_path) + assert results["clarion"] == "present (left untouched)" + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert text.count("clarion:") == 1 + assert "http://new" not in text + + +def test_rerun_does_not_duplicate_commented_stanza(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / ".filigree.conf").write_text("{}", encoding="utf-8") + record_bindings(tmp_path) + record_bindings(tmp_path) + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert text.count("wardline-install:filigree") == 1 From d142f9c9a7bbd9fe16d699a48fcab8d863a28c0c Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:53:44 +1000 Subject: [PATCH 12/17] fix(install): YAML-escape binding URLs via json.dumps; cover multi-sibling + quote cases A URL containing a quote previously produced malformed wardline.yaml that broke the next scan. json.dumps yields a YAML-valid escaped scalar. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/detect.py | 5 ++++- tests/unit/install/test_detect.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/wardline/install/detect.py b/src/wardline/install/detect.py index 989360ec..0fa0830d 100644 --- a/src/wardline/install/detect.py +++ b/src/wardline/install/detect.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json import os import re import shutil @@ -27,7 +28,9 @@ def _detect_filigree(root: Path) -> tuple[bool, str | None]: def _live_stanza(key: str, url: str) -> str: - return f'{key}:\n url: "{url}" # wardline-install:{key} (from env at install time)\n' + # json.dumps yields a YAML-valid, properly escaped double-quoted scalar + # (so a URL containing a quote/backslash can't corrupt wardline.yaml). + return f"{key}:\n url: {json.dumps(url)} # wardline-install:{key} (from env at install time)\n" _COMMENTED = { diff --git a/tests/unit/install/test_detect.py b/tests/unit/install/test_detect.py index eb6e93c6..e616d901 100644 --- a/tests/unit/install/test_detect.py +++ b/tests/unit/install/test_detect.py @@ -57,3 +57,40 @@ def test_rerun_does_not_duplicate_commented_stanza(tmp_path: Path, monkeypatch) record_bindings(tmp_path) text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") assert text.count("wardline-install:filigree") == 1 + + +def test_both_siblings_live_written_once(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://clar:9100") + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://fil:9200/api/loom/scan-results") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + results = record_bindings(tmp_path) + assert results == {"clarion": "wired (env URL)", "filigree": "wired (env URL)"} + text = (tmp_path / "wardline.yaml").read_text(encoding="utf-8") + assert text.count("clarion:") == 1 + assert text.count("filigree:") == 1 + + +def test_appends_to_file_without_trailing_newline(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_CLARION_URL", "http://clar:9100") + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / "wardline.yaml").write_text("exclude:\n - build", encoding="utf-8") # no trailing \n + assert record_bindings(tmp_path)["clarion"] == "wired (env URL)" + # The result must still be loadable (no run-together lines). + from wardline.core.config import load + + cfg = load(tmp_path / "wardline.yaml") + assert cfg.clarion_url == "http://clar:9100" + assert cfg.exclude == ("build",) + + +def test_url_with_quote_stays_valid_yaml(tmp_path: Path, monkeypatch) -> None: + weird = 'http://h/p?q="v"' + monkeypatch.setenv("WARDLINE_CLARION_URL", weird) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + record_bindings(tmp_path) + from wardline.core.config import load + + cfg = load(tmp_path / "wardline.yaml") + assert cfg.clarion_url == weird From ab692126ce05e93194e66c0bc2ad4f50b9d8b3d4 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:56:48 +1000 Subject: [PATCH 13/17] feat(cli): wardline install command (block + skill + mcp + bindings) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/cli/install.py | 53 ++++++++++++++++++++++++++++++++++ src/wardline/cli/main.py | 2 ++ tests/unit/cli/test_install.py | 46 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/wardline/cli/install.py diff --git a/src/wardline/cli/install.py b/src/wardline/cli/install.py new file mode 100644 index 00000000..ad3b195e --- /dev/null +++ b/src/wardline/cli/install.py @@ -0,0 +1,53 @@ +# src/wardline/cli/install.py +"""`wardline install` — push agent-enablement artifacts into a project.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from wardline.core.errors import WardlineError +from wardline.install.block import inject_block +from wardline.install.detect import record_bindings +from wardline.install.mcp_json import merge_mcp_entry +from wardline.install.skill import install_skill + + +@click.command() +@click.option("--root", type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", help="Project root to install into (default: cwd).") +@click.option("--no-claude-md", is_flag=True, help="Skip the CLAUDE.md instruction block.") +@click.option("--no-agents-md", is_flag=True, help="Skip the AGENTS.md instruction block.") +@click.option("--no-skill", is_flag=True, help="Skip the wardline-gate skill.") +@click.option("--no-mcp", is_flag=True, help="Skip wiring .mcp.json.") +@click.option("--no-bindings", is_flag=True, help="Skip Clarion/Filigree detection.") +def install( + root: Path, + no_claude_md: bool, + no_agents_md: bool, + no_skill: bool, + no_mcp: bool, + no_bindings: bool, +) -> None: + """Install wardline's agent-facing guidance and sibling bindings into ROOT.""" + lines: list[str] = [] + try: + if not no_claude_md: + lines.append(f"CLAUDE.md: {inject_block(root / 'CLAUDE.md')}") + if not no_agents_md: + lines.append(f"AGENTS.md: {inject_block(root / 'AGENTS.md')}") + if not no_skill: + for base, status in install_skill(root).items(): + lines.append(f"skill {base}/skills/wardline-gate: {status}") + if not no_mcp: + lines.append(f".mcp.json (wardline entry): {merge_mcp_entry(root)}") + if not no_bindings: + for name, status in record_bindings(root).items(): + lines.append(f"{name}: {status}") + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc + click.echo("wardline install:") + for line in lines: + click.echo(f" {line}") diff --git a/src/wardline/cli/main.py b/src/wardline/cli/main.py index a7800bbb..0b32cf45 100644 --- a/src/wardline/cli/main.py +++ b/src/wardline/cli/main.py @@ -9,6 +9,7 @@ import click from wardline._version import __version__ +from wardline.cli.install import install from wardline.cli.judge import judge as judge_command from wardline.cli.mcp import mcp from wardline.cli.scan import scan @@ -27,6 +28,7 @@ def cli() -> None: cli.add_command(scan) cli.add_command(judge_command) cli.add_command(mcp) +cli.add_command(install) @cli.command() diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py index f6e73015..c749ec61 100644 --- a/tests/unit/cli/test_install.py +++ b/tests/unit/cli/test_install.py @@ -45,3 +45,49 @@ def run_stdio(self) -> None: result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)]) assert result.exit_code == 0, result.output assert captured["clarion_url"] == "http://configured-clarion" + + +def test_install_writes_all_artifacts(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert (tmp_path / "CLAUDE.md").is_file() + assert (tmp_path / "AGENTS.md").is_file() + assert (tmp_path / ".claude" / "skills" / "wardline-gate" / "SKILL.md").is_file() + assert (tmp_path / ".agents" / "skills" / "wardline-gate" / "SKILL.md").is_file() + assert (tmp_path / ".mcp.json").is_file() + assert "CLAUDE.md" in result.output + + +def test_install_is_idempotent(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "unchanged" in result.output + + +def test_install_opt_outs(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + result = CliRunner().invoke( + cli, + ["install", "--root", str(tmp_path), "--no-agents-md", "--no-skill", + "--no-mcp", "--no-bindings"], + ) + assert result.exit_code == 0, result.output + assert (tmp_path / "CLAUDE.md").is_file() + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / ".claude").exists() + assert not (tmp_path / ".mcp.json").exists() + + +def test_install_fails_2_on_malformed_mcp_json(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / ".mcp.json").write_text("{bad", encoding="utf-8") + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 2 + assert "malformed .mcp.json" in result.output From 226a50d240076b48723b296fdb0d74ecfa8bca75 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 18:59:51 +1000 Subject: [PATCH 14/17] docs/test(install): document re-run safety; tighten idempotency + binding-summary tests Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/cli/install.py | 7 ++++++- tests/unit/cli/test_install.py | 25 ++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/wardline/cli/install.py b/src/wardline/cli/install.py index ad3b195e..5a5a3502 100644 --- a/src/wardline/cli/install.py +++ b/src/wardline/cli/install.py @@ -30,7 +30,12 @@ def install( no_mcp: bool, no_bindings: bool, ) -> None: - """Install wardline's agent-facing guidance and sibling bindings into ROOT.""" + """Install wardline's agent-facing guidance and sibling bindings into ROOT. + + Idempotent; re-running is safe (and refreshes stale artifacts). If a step + fails (e.g. a malformed .mcp.json), earlier artifacts may already be written + — fix the cause and re-run. + """ lines: list[str] = [] try: if not no_claude_md: diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py index c749ec61..dbed0b5e 100644 --- a/tests/unit/cli/test_install.py +++ b/tests/unit/cli/test_install.py @@ -68,7 +68,8 @@ def test_install_is_idempotent(tmp_path: Path, monkeypatch) -> None: CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) assert result.exit_code == 0, result.output - assert "unchanged" in result.output + assert "CLAUDE.md: unchanged" in result.output + assert ".mcp.json (wardline entry): unchanged" in result.output def test_install_opt_outs(tmp_path: Path, monkeypatch) -> None: @@ -85,6 +86,28 @@ def test_install_opt_outs(tmp_path: Path, monkeypatch) -> None: assert not (tmp_path / ".mcp.json").exists() +def test_install_no_claude_md_still_writes_agents(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + result = CliRunner().invoke( + cli, ["install", "--root", str(tmp_path), "--no-claude-md"] + ) + assert result.exit_code == 0, result.output + assert not (tmp_path / "CLAUDE.md").exists() + assert (tmp_path / "AGENTS.md").is_file() + + +def test_install_summary_includes_binding_lines(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_CLARION_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + result = CliRunner().invoke(cli, ["install", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "clarion:" in result.output + assert "filigree:" in result.output + + def test_install_fails_2_on_malformed_mcp_json(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) (tmp_path / ".mcp.json").write_text("{bad", encoding="utf-8") From 895e2228d92a9b6cbdddffe4b01349f74a8850f6 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 19:01:35 +1000 Subject: [PATCH 15/17] docs: document wardline install (agents.md + changelog) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/agents.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50980e0b..1920ef66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`wardline install`** — one-command agent enablement. Injects a hash-fenced + instruction block into `CLAUDE.md`/`AGENTS.md`, installs the `wardline-gate` + skill into `.claude/`/`.agents/`, merges a `wardline` entry into `.mcp.json`, + and detects Clarion/Filigree to record bindings in `wardline.yaml`. + `clarion.url`/`filigree.url` are now runtime-read config fields (precedence: + CLI flag > env var > `wardline.yaml`). Opt-out flags `--no-claude-md`, + `--no-agents-md`, `--no-skill`, `--no-mcp`, `--no-bindings`; no SessionStart + hook (re-run to refresh). + ## [0.2.1] - 2026-05-31 ### Added diff --git a/docs/agents.md b/docs/agents.md index 9bb5ef90..8d65a750 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -23,6 +23,39 @@ asks you to look. If you have not installed Wardline yet, start with [Getting Started](getting-started.md). +## One-command setup: `wardline install` + +`wardline install` wires wardline into a project's agent context in one step: + +- injects a small, hash-fenced block into `CLAUDE.md` and `AGENTS.md` pointing + the agent at the gate and the loop; +- installs the `wardline-gate` skill into `.claude/skills/` and `.agents/skills/`; +- merges a `wardline` entry into `.mcp.json` (preserving any existing servers); +- detects a Clarion taint store (`clarion` on `PATH` or `WARDLINE_CLARION_URL`) + and a Filigree project (`.filigree.conf`), recording a `clarion:`/`filigree:` + binding in `wardline.yaml` — live when a URL env var is set, otherwise a + commented stanza for you to fill. + +```console +$ wardline install +wardline install: + CLAUDE.md: created + AGENTS.md: created + skill .claude/skills/wardline-gate: created + skill .agents/skills/wardline-gate: created + .mcp.json (wardline entry): created + clarion: detected (commented) + filigree: detected (commented) +``` + +It is idempotent (re-run to refresh after upgrading wardline) and non-interactive +(safe in CI). Opt out of any piece with `--no-claude-md`, `--no-agents-md`, +`--no-skill`, `--no-mcp`, or `--no-bindings`. There is no SessionStart hook — +freshness is enforced only when you re-run `wardline install`. + +Once installed, the MCP server resolves the Clarion URL from `wardline.yaml`, so +the `.mcp.json` entry stays a bare `wardline mcp --root .` with no URL in its args. + ## Gate the agent's work with `wardline scan` Wardline marks trust boundaries with two decorators from `wardline.decorators`: From 88679ca7812be7dc5a81c5647d812d8f9af610ad Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 19:08:36 +1000 Subject: [PATCH 16/17] test(install): cover replacing a stale .mcp.json wardline entry Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/install/test_mcp_json.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/install/test_mcp_json.py b/tests/unit/install/test_mcp_json.py index a36a4a1e..4671dbe4 100644 --- a/tests/unit/install/test_mcp_json.py +++ b/tests/unit/install/test_mcp_json.py @@ -55,3 +55,13 @@ def test_mcpservers_null_is_treated_as_absent(tmp_path: Path) -> None: data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY assert data["other"] == 1 # unrelated top-level keys preserved + + +def test_replaces_stale_wardline_entry(tmp_path: Path) -> None: + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"wardline": {"type": "stdio", "command": "OLD", "args": []}}}), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "updated" + data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8")) + assert data["mcpServers"]["wardline"] == _WARDLINE_ENTRY # stale entry replaced From 0fb181300a4ba15f36a24b4b4b425d424d92ca0d Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 19:27:38 +1000 Subject: [PATCH 17/17] release: 0.3.0 Promote [Unreleased] to [0.3.0]; bump _version to 0.3.0 (wardline install). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++++- src/wardline/_version.py | 2 +- tests/unit/test_package.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1920ef66..a2c2493b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-05-31 + ### Added - **`wardline install`** — one-command agent enablement. Injects a hash-fenced @@ -178,7 +180,8 @@ for Python — enterprise-class trust-boundary analysis at small-team weight. - **Packaging** — MIT-licensed; optional extras `scanner` (config + CLI) and `loom` (HTTP integrations). -[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v0.2.1...HEAD +[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/foundryside-dev/wardline/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/foundryside-dev/wardline/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/foundryside-dev/wardline/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/foundryside-dev/wardline/releases/tag/v0.1.0 diff --git a/src/wardline/_version.py b/src/wardline/_version.py index 3ced3581..493f7415 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 36977970..bfdfd6e6 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -3,4 +3,4 @@ def test_version_is_exported() -> None: assert isinstance(wardline.__version__, str) - assert wardline.__version__.startswith("0.2.1") + assert wardline.__version__.startswith("0.3.0")