Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 57 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-05-31

Adds a first-class MCP server and an opt-in persistent taint store, ships a
documentation site, and closes a taint soundness hole plus a batch of
hardening fixes. The base package stays zero-dependency.

### Added

- SP9: opt-in Clarion-backed persistent taint store (`wardline[clarion]` extra).
`wardline scan --clarion-url` persists per-entity taint facts; `explain_taint`
queries them with a never-serve-stale freshness gate and falls back to a local
re-scan; the MCP `explain_taint` tool gains `chain: true` for the full N-hop
taint chain. Base package stays zero-dependency; HMAC auth is stdlib.
- **MCP server** — a dependency-free, stdlib-only MCP-over-stdio server
(`wardline mcp`, JSON-RPC 2.0, no SDK). Tools: `scan`, `explain_taint`,
`judge` (network-fenced), `baseline_create`, `baseline_update`, `waiver_add`;
resources `wardline://vocab|rules|config|config-schema` (findings are never a
resource); one `wardline:loop` prompt. Tool-execution errors surface as
`isError` results; protocol faults are JSON-RPC errors.
- **`explain_taint` provenance** — projects the real contributing return-taint
callee for an anchored `PY-WL-101`, and (with the Clarion store) walks the
full N-hop taint chain (`chain: true`, explicit truncation via `max_hops`).
- **Clarion taint store** — opt-in Clarion-backed persistent taint store
(`wardline[clarion]` extra). `wardline scan --clarion-url` persists per-entity
taint facts; `explain_taint` serves a fresh fact from the store behind a
never-serve-stale `blake3` freshness gate, falling back to a local re-scan.
HMAC auth is stdlib; `blake3` is the sole (lazy) extra dependency.
- **Documentation site** — a Material for MkDocs site (home, getting-started,
concepts, guides, CLI + vocabulary reference, agent-integration), built
`--strict` in CI and deployed to GitHub Pages. New `docs` extra; the base
package stays zero-dependency.

### Fixed

- **Taint soundness (fail-open)** — the L2 resolver (`_resolve_expr`) fell
through to the function taint for unmodelled AST shapes, which in a `@trusted`
producer reset untrusted data to the trusted tier and emitted a clean report.
f-strings, `str()`/`.format()`/`.join()`, `.get()`/subscript, BoolOp,
attribute reads, `await`, comprehensions, container-writes, `self`-method
calls, and aliased serialization sinks now propagate taint correctly.
- **Expression-combiner over-tainting (false positives)** — value-building /
either-or / container-summary combiners (BinOp, IfExp, BoolOp, list/dict
literals, comprehensions, `.get`/`.pop` defaults, `+=`, container writes)
combined via the provenance-clash join, so a benign literal + validated data
spuriously became `MIXED_RAW`. They now combine via the rank-meet
weakest-link, matching the f-string/`.format`/`.join` paths; raw still
propagates. Control-flow merges deliberately retain the provenance join.
- **Scan observability** — parse-error, unreadable, recursion-skipped, and
missing-source-root files are now counted (`ScanSummary.unanalyzed`) and
surfaced, with an opt-in `--fail-on-unanalyzed` gate.
- An explicit `--config` path that does not exist now errors instead of
silently falling back to the default policy.
- Line-less engine-diagnostic findings no longer crash the scan.
- The MCP server returns an `isError` result (which clients reliably surface)
for unexpected tool-handler exceptions instead of a dropped `-32603`.

### Security

- **Path confinement (THREAT-001 residual)** — a symlinked `.py` inside a
source-root could escape the project root and be read out-of-tree via the MCP
`scan` tool. Each discovered file is now resolved under the root when
confinement is requested (MCP path); CLI default behavior is unchanged.

### Removed

Expand Down Expand Up @@ -52,5 +102,6 @@ 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.1.0...HEAD
[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v0.2.0...HEAD
[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
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ wardline --version
```

```text
wardline, version 0.1.0
wardline, version 0.2.0
```

## 2. Run a first scan
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLI reference

Complete reference for the `wardline` command-line interface, version `0.1.0`.
Complete reference for the `wardline` command-line interface, version `0.2.0`.
Every `--help` block below is the verbatim output of the installed CLI; every
example is a realistic invocation.

Expand Down Expand Up @@ -51,7 +51,7 @@ Check the installed version:

```text
$ wardline --version
wardline, version 0.1.0
wardline, version 0.2.0
```

Use `--version` in CI before a scan to pin the toolchain in your build log; the
Expand Down
2 changes: 1 addition & 1 deletion src/wardline/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.2.0"
5 changes: 3 additions & 2 deletions src/wardline/clarion/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
SOFT (the caller degrades to the SP8 re-run) while a 4xx is a LOUD ClarionError
(Wardline sent a bad request). The split adds: 403 WRITE_DISABLED/PROJECT_MISMATCH
are soft (the store is off / wrong project — not a Wardline bug). The client routes
on the envelope `code`, not the HTTP status (the same code can carry different
statuses by route).
on the HTTP status band (``>= 500`` and ``403`` are soft; other non-2xx are a loud
ClarionError); the envelope `code` is surfaced only as a label (``disabled_reason``)
and in error-message text, never as the soft-vs-loud decision.
"""

from __future__ import annotations
Expand Down
24 changes: 22 additions & 2 deletions src/wardline/cli/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
@click.option("--output", type=click.Path(path_type=Path), default=None)
# exit 1 if any non-suppressed DEFECT has severity >= this threshold (SP3b)
@click.option("--fail-on", type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO"]), default=None)
# Opt-in CI enforcement: exit 1 when any file was discovered but not analysed
# (parse error / too-deep / missing source root — NOT benign no-module skips).
# Default FALSE preserves the released exit-code behaviour; the count is ALWAYS
# surfaced.
@click.option("--fail-on-unanalyzed/--no-fail-on-unanalyzed", default=False,
help="Exit 1 if any file was discovered but could not be analyzed.")
@click.option("--cache-dir", type=click.Path(path_type=Path), default=None,
help="Persist L3 summary cache here for faster incremental scans.")
@click.option("--filigree-url", "filigree_url", default=None,
Expand All @@ -38,6 +44,7 @@ def scan(
fmt: str,
output: Path | None,
fail_on: str | None,
fail_on_unanalyzed: bool,
cache_dir: Path | None,
filigree_url: str | None,
clarion_url: str | None,
Expand Down Expand Up @@ -103,10 +110,23 @@ def scan(
line += f"; {len(clarion_result.unresolved_qualnames)} qualname(s) unresolved (not indexed by Clarion)"
click.echo(line)
s = result.summary
unanalyzed_segment = (
f"; {s.unanalyzed} file(s) could not be analyzed" if s.unanalyzed else ""
)
click.echo(
f"scanned {result.files_scanned} file(s); {s.total} finding(s) — "
f"{s.baselined + s.waived + s.judged} suppressed "
f"({s.baselined} baseline / {s.waived} waiver / {s.judged} judged), {s.active} new -> {output}"
f"({s.baselined} baseline / {s.waived} waiver / {s.judged} judged), {s.active} new"
f"{unanalyzed_segment} -> {output}"
)
if fail_on is not None and gate_decision(result, Severity(fail_on)).tripped:
# A discovered-but-not-analysed file is a silent under-scan; never hide it.
if s.unanalyzed:
click.echo(
f"warning: {s.unanalyzed} file(s) were discovered but could not be analyzed "
f"(see WLN-ENGINE-* facts in {output}).",
err=True,
)
gate_tripped = fail_on is not None and gate_decision(result, Severity(fail_on)).tripped
# Independent of the severity gate: opt-in enforcement of "everything analysed".
if gate_tripped or (fail_on_unanalyzed and s.unanalyzed):
raise SystemExit(1)
28 changes: 28 additions & 0 deletions src/wardline/core/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ def discover(
for path in sorted(base.rglob("*.py")):
if any(part in _ALWAYS_SKIP for part in path.parts):
continue
if confine_to_root and not path.resolve().is_relative_to(root):
# A *.py symlink inside a legitimate source_root can point at an
# out-of-root target (rglob does not descend directory symlinks,
# so only file symlinks leak). Refuse to read out-of-root content
# by skipping it — the MCP confinement guarantee (THREAT-001).
continue
relposix = (
path.relative_to(root).as_posix()
if path.is_relative_to(root)
Expand All @@ -46,5 +52,27 @@ def discover(
return found


def missing_source_roots(
root: Path, config: WardlineConfig, *, confine_to_root: bool = False
) -> list[str]:
"""Return the configured ``source_roots`` that do not exist on disk.

``discover`` skips a non-existent root with a ``warnings.warn`` (invisible to a
structured consumer like the MCP agent). ``run_scan`` calls this sibling to turn
each missing root into a finding so the silent under-scan is surfaced. An
ESCAPING root (under ``confine_to_root``) is excluded here — that is ``discover``'s
loud ``ConfigError``, a different case.
"""
root = root.resolve()
missing: list[str] = []
for src in config.source_roots:
base = (root / src).resolve()
if confine_to_root and not base.is_relative_to(root):
continue # escape is discover()'s ConfigError, not a missing root
if not base.exists():
missing.append(src)
return missing


def _excluded(relposix: str, patterns: Iterable[str]) -> bool:
return any(fnmatch.fnmatch(relposix, pattern) for pattern in patterns)
31 changes: 31 additions & 0 deletions src/wardline/core/finding.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@
from enum import StrEnum
from typing import Any

# Sentinel ``Location.path`` for whole-run engine diagnostics not tied to any
# source file (``WLN-L3-*`` and the unknown-kernel-code ``WLN-ENGINE-DIAGNOSTIC``
# fallback). NOTE: the per-file engine FACTs (PARSE-ERROR / FILE-SKIPPED /
# NO-MODULE / SOURCE-ROOT-MISSING) carry their real relpath, NOT this sentinel.
# Sentinel findings are not tied to a source line; their fingerprint is built
# from identifying fields, so the line-based fingerprint invariant does not
# apply to them. Lives in core so both the emitter (scanner.diagnostics) and
# the suppression guard (core.suppression) reference one constant.
ENGINE_PATH = "<engine>"

# Rule ids for files where analysis was ATTEMPTED OR EXPECTED but FAILED / never
# happened — a genuine under-scan: parse/read failures, files too deep to walk
# (recursion), and missing source roots. These are Severity.NONE FACTs that never
# trip the severity gate, so callers count them separately (ScanSummary.unanalyzed)
# to surface the silent under-scan and (opt-in) gate on it.
#
# WLN-ENGINE-NO-MODULE is DELIBERATELY EXCLUDED: a file that maps to no module
# (e.g. a top-level / src/__init__.py) is a benign layout artifact with nothing to
# analyze, not a failure. It is still emitted as an observable FACT, but folding it
# in here would make a normal src-layout repo report "could not be analyzed" on
# every scan — diluting the signal and hiding real failures in habitual noise.
#
# Single source of truth shared by the analyzer (emitter), discovery, and run.
UNANALYZED_RULE_IDS = frozenset(
{
"WLN-ENGINE-PARSE-ERROR",
"WLN-ENGINE-FILE-SKIPPED",
"WLN-ENGINE-SOURCE-ROOT-MISSING",
}
)


class Severity(StrEnum):
CRITICAL = "CRITICAL"
Expand Down
47 changes: 45 additions & 2 deletions src/wardline/core/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,23 @@

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from datetime import date
from pathlib import Path

from wardline.core import config as config_mod
from wardline.core.baseline import load_baseline
from wardline.core.discovery import discover
from wardline.core.finding import Finding, Kind, Severity, SuppressionState
from wardline.core.discovery import discover, missing_source_roots
from wardline.core.errors import ConfigError
from wardline.core.finding import (
UNANALYZED_RULE_IDS,
Finding,
Kind,
Location,
Severity,
SuppressionState,
)
from wardline.core.judged import load_judged
from wardline.core.suppression import apply_suppressions, gate_trips
from wardline.core.waivers import WaiverSet, parse_waivers
Expand All @@ -24,13 +33,25 @@
from wardline.scanner.taint.summary_cache import SummaryCache


def _fp(*parts: str) -> str:
digest = hashlib.sha256()
digest.update("\x00".join(parts).encode("utf-8"))
return digest.hexdigest()


@dataclass(frozen=True, slots=True)
class ScanSummary:
total: int # every finding (defects + facts/metrics)
active: int # non-suppressed DEFECTs — the gate population
baselined: int
waived: int
judged: int
# Files DISCOVERED but NEVER analysed despite being analysable — a genuine
# under-scan (parse errors, too-deep skips, missing source roots). Benign
# no-module skips (WLN-ENGINE-NO-MODULE) are EXCLUDED — see UNANALYZED_RULE_IDS.
# These are Severity.NONE FACTs that never trip the severity gate, so they are
# counted separately to surface a silent under-scan / false-green.
unanalyzed: int = 0


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -66,6 +87,12 @@ def run_scan(
``discover`` reject any ``source_root`` that resolves outside ``root`` — the
MCP server passes True so a poisoned config cannot read out-of-root source.
"""
# An EXPLICIT --config path that doesn't exist must NOT silently fall back to
# default policy (dropping the operator's severity overrides/excludes) — that
# is a false-green. The IMPLICIT default (root/wardline.yaml) may legitimately
# be absent; config_mod.load tolerates that.
if config_path is not None and not config_path.exists():
raise ConfigError(f"config file does not exist: {config_path}")
cfg_path = config_path or (root / "wardline.yaml")
cfg = config_mod.load(cfg_path)
cache = None
Expand All @@ -75,6 +102,21 @@ def run_scan(
files = discover(root, cfg, confine_to_root=confine_to_root)
analyzer = WardlineAnalyzer(summary_cache=cache)
raw = list(analyzer.analyze(files, cfg, root=root))
# A non-existent (non-escaping) source_root is otherwise only a stderr warning
# from discover — invisible to the MCP agent. Surface it as a finding that
# reaches both the CLI summary and the MCP result, and counts toward unanalyzed.
for src in missing_source_roots(root, cfg, confine_to_root=confine_to_root):
raw.append(
Finding(
rule_id="WLN-ENGINE-SOURCE-ROOT-MISSING",
message=f"source root does not exist: {src}",
severity=Severity.NONE,
kind=Kind.FACT,
location=Location(path=src),
fingerprint=_fp("WLN-ENGINE-SOURCE-ROOT-MISSING", src),
properties={"source_root": src},
)
)
if cache is not None:
cache.save()
baseline = load_baseline(root / ".wardline" / "baseline.yaml")
Expand All @@ -89,6 +131,7 @@ def run_scan(
baselined=sum(1 for f in defects if f.suppressed is SuppressionState.BASELINED),
waived=sum(1 for f in defects if f.suppressed is SuppressionState.WAIVED),
judged=sum(1 for f in defects if f.suppressed is SuppressionState.JUDGED),
unanalyzed=sum(1 for f in findings if f.rule_id in UNANALYZED_RULE_IDS),
)
return ScanResult(
findings=findings,
Expand Down
17 changes: 11 additions & 6 deletions src/wardline/core/suppression.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from datetime import date

from wardline.core.baseline import Baseline
from wardline.core.finding import Finding, Kind, Severity, SuppressionState
from wardline.core.finding import ENGINE_PATH, Finding, Kind, Severity, SuppressionState
from wardline.core.judged import JudgedSet
from wardline.core.waivers import WaiverSet

Expand All @@ -37,11 +37,16 @@ def apply_suppressions(
if f.kind is not Kind.DEFECT:
out.append(f)
continue
# Engine invariant (spec §12): a DEFECT must carry a line, or its fingerprint's
# line discriminator collapses to "None" and collision risk rises under the
# strict match. Rule findings always set line_start; assert it to catch any
# future rule that emits a line-less DEFECT.
assert f.location.line_start is not None, (
# Engine invariant (spec §12): a *rule* DEFECT (PY-WL-*) must carry a line,
# or its line-based fingerprint's line discriminator collapses to "None" and
# collision risk rises under the strict match. Rule findings always set
# line_start; assert it to catch any future rule that emits a line-less DEFECT.
# Engine-diagnostic DEFECTs (<engine> path, e.g. WLN-L3-MONOTONICITY-VIOLATION,
# WLN-ENGINE-DIAGNOSTIC) are exempt: they are not tied to a source line and build
# a line-independent fingerprint from identifying fields, so the invariant does
# not apply — and they MUST surface (the "fail loud-but-survivable" safety net),
# not abort the run.
assert f.location.path == ENGINE_PATH or f.location.line_start is not None, (
f"DEFECT {f.rule_id} entered suppression with line_start=None — "
f"weak fingerprint identity (collision risk)"
)
Expand Down
14 changes: 14 additions & 0 deletions src/wardline/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ def _scan(args: dict[str, Any], root: Path, clarion: Any = None) -> dict[str, An
"baselined": result.summary.baselined,
"waived": result.summary.waived,
"judged": result.summary.judged,
# Files discovered but NOT analysed (parse error / too-deep / missing
# source root — benign no-module skips are excluded). Surfaced so the
# silent under-scan reaches the agent, not just the human-facing stderr.
"unanalyzed": result.summary.unanalyzed,
},
"gate": {"tripped": decision.tripped, "fail_on": decision.fail_on,
"exit_class": decision.exit_class},
Expand Down Expand Up @@ -423,6 +427,16 @@ def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
# Bad config / unreadable path during a tool call: a tool-execution
# error the agent must read and act on → isError result.
return self._is_error(str(exc))
except McpError:
# A handler may DELIBERATELY raise McpError to signal a protocol fault;
# that stays a JSON-RPC error (dispatch maps it), so let it propagate.
raise
except Exception as exc: # noqa: BLE001
# An UNEXPECTED crash deep in a handler (e.g. a KeyError/RecursionError from
# the taint engine mid-scan) is a tool-EXECUTION error, not a protocol fault.
# Surface it as an isError RESULT so the detail lands in the channel MCP
# clients reliably relay, rather than a -32603 whose message they may drop.
return self._is_error(f"wardline internal error: {exc}")
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}

def _resources_list(self, params: dict[str, Any]) -> dict[str, Any]:
Expand Down
Loading
Loading