diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 966f1ec0..f35955c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name != 'schedule' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.13" @@ -42,10 +42,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name != 'schedule' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.13" @@ -60,10 +60,10 @@ jobs: matrix: python-version: ["3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -76,10 +76,10 @@ jobs: needs: test if: github.event_name != 'schedule' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.13" @@ -120,10 +120,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'schedule' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.13" @@ -163,10 +163,10 @@ jobs: - name: Warpline marker: warpline_e2e steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.13" diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 51e65f19..dbc82a9a 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -4,7 +4,7 @@ # copied verbatim into the build output). It consumes the shared @weft/site-kit, # which lives in a SUBDIRECTORY of a DIFFERENT repo (foundryside-dev/weft). # npm cannot install a git subdirectory as a file: dep directly, so the build -# sparse-fetches packages/site-kit into site/vendor/site-kit first +# sparse-fetches a pinned packages/site-kit commit into site/vendor/site-kit first # (scripts/fetch-site-kit.mjs), then `npm install` resolves the file: dep and # `astro build` compiles it. The fetch also runs as a preinstall hook, but the # explicit step keeps the order legible. @@ -29,6 +29,11 @@ concurrency: group: pages cancel-in-progress: false +env: + # Privileged Pages builds must consume an immutable site-kit revision. Update + # this SHA deliberately when promoting a new foundryside-dev/weft site kit. + WEFT_SITE_KIT_REF: a8f9a6a77458d2ec697cfbc1f71dd88a51962cb7 + jobs: build: runs-on: ubuntu-latest @@ -37,13 +42,13 @@ jobs: working-directory: site steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Configure Pages # Pin the Pages source to GitHub Actions (build_type=workflow); enables # Pages if needed. Without this, deploy-pages fails when the repo is # still set to "Deploy from a branch". - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 with: enablement: true @@ -78,4 +83,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11feadeb..d28b24d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,10 +12,10 @@ jobs: name: Build distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.13" @@ -59,4 +59,4 @@ jobs: # twine reject it ("Unknown distribution format") and blocks the release. # Verification above already consumed it. run: rm -f dist/SHA256SUMS - - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.gitignore b/.gitignore index 4a6b1c12..52cd191c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,12 @@ output/ # port (a live, never-committed runtime artifact, not tracked state). .weft/*/ephemeral.port +# Local sibling tool stores are runtime/tooling state for this checkout. Keep +# wardline's own .weft/wardline/ suppression state visible and auditable. +.weft/filigree/ +.weft/loomweave/ +.weft/warpline/ + # Filigree issue tracker .filigree/ .env @@ -56,7 +62,6 @@ coverage.json loomweave.yaml # Filigree issue tracker -.weft/ .filigree.conf .agents/skills/loomweave-workflow/.fingerprint .agents/skills/loomweave-workflow/SKILL.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..a48a87f5 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,9 @@ +## 2025-02-14 - Prevent Git Config Code Execution +**Vulnerability:** Invoking `git` via `subprocess` against untrusted directories without overriding config can allow malicious repositories to execute code via `.git/config` hooks like `core.fsmonitor`. +**Learning:** `git` uses configurations from the `.git/config` file in the current working directory or `cwd` argument, which could be controlled by an attacker when analyzing untrusted codebases. +**Prevention:** Explicitly pass `("-c", "core.fsmonitor=false")` as `_SAFE_GIT_CONFIG` to all `git` subprocess commands in the codebase. + +## 2026-06-21 - [Add Unsafe PyYAML Loaders to Taint Tracking] +**Vulnerability:** The static analyzer was missing `yaml.unsafe_load` and `yaml.full_load` in its `_SERIALISATION_SINKS` mapping, potentially leading to false negatives when tracking untrusted data flowing into these dangerous deserialization functions. +**Learning:** Even if functions are listed in rule specifications (like `_SINK_SPECS`), they also need to be properly categorized in the core taint propagation logic (`_SERIALISATION_SINKS`) to ensure the analyzer correctly sheds validation provenance (converting output to `UNKNOWN_RAW`). +**Prevention:** When adding new sinks to rule definitions, always verify if they need to be added to core propagation mappings like `_SERIALISATION_SINKS` or `_PROPAGATING_BUILTINS`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 07915642..a37c7165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Default scan artifacts now anchor to the weft-project root (the `weft.toml` directory) + rather than the scan cwd, so a subdirectory scan writes to `/.wardline/`. + Retention is therefore project-root-wide across heterogeneous subdir/root scans sharing + one `.wardline/`. **Migration:** the artifact moves to the project root; `wardline doctor + --repair` sweeps now-stale per-subdir `.wardline/` dirs — update any CI/automation reading + a hardcoded `/.wardline/*-findings.jsonl` path. + +### Added +- `wardline doctor --repair` gitignores the artifacts dir and sweeps stray managed + artifacts; deletion is available on both the CLI and the MCP `doctor` tool (`repair:true`, + advertised `destructiveHint: true`), bounded to managed (timestamped) files inside + non-standard `.wardline/` dirs under the project root; emptied dirs are removed + best-effort (non-empty dirs are left in place). + +### Fixed +- **Candidate-set merge no longer scales cubically (scan DoS).** The Level-2 + branch-join merges for lambda bindings (`_merge_branch_bindings`) and + receiver-type candidates (`_merge_branch_types`) deduplicated with a nested + linear scan of the growing candidate list — O(bucket²) per merge, O(N³) + across a chain of `N` one-armed branches rebinding the same name. An + attacker-authored file (~1100 such branches) could drive a default-gate scan + to ~15s and exhaust CPU on every local and CI run. Both merges now dedup via + an identity/equality set (O(bucket) per merge, O(N²) cumulative), preserving + the exact candidate set and insertion order; the demonstrated 1100-branch case + drops from seconds to milliseconds. No analysis behavior changes — the + candidate sets are identical, so no false negative is introduced. + Reviewed regression source: `eff4eed2` (wardline-c797baf28b). + ## [1.0.7] - 2026-06-24 ### Fixed @@ -1353,6 +1382,7 @@ for Python — enterprise-class trust-boundary analysis at small-team weight. - **Packaging** — MIT-licensed; optional extras `scanner` (config + CLI) and `weft` (HTTP integrations). +[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v1.0.6...HEAD [1.0.6]: https://github.com/foundryside-dev/wardline/compare/v1.0.5...v1.0.6 [1.0.5]: https://github.com/foundryside-dev/wardline/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/foundryside-dev/wardline/compare/v1.0.3...v1.0.4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a56ad9f..54ef6a99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ see `CLAUDE.md`). - **TDD.** Write the failing test first. - Keep PRs focused — one logical change per PR. -- New behavior needs tests. New `wardline.yaml` keys need a `config_schema.py` update. +- New behavior needs tests. New `[wardline]` keys in `weft.toml` need a `config_schema.py` update. - No back-compat shims for unreleased specs — make clean changes. - Wardline scans its own source as a CI gate; keep the tree finding-clean (or baselined). diff --git a/README.md b/README.md index 3947a9ee..f5dcd7e2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ def build_record(req): ```console $ wardline scan . --fail-on ERROR -scanned 1 file(s); 3 finding(s) — 0 suppressed (0 baseline / 0 waiver / 0 judged), 1 active -> .wardline/20260620T153012Z-findings.jsonl +scanned 1 file(s); 2 finding(s) — 0 suppressed (0 baseline / 0 waiver / 0 judged), 1 active -> .wardline/20260620T153012Z-findings.jsonl $ echo $? 1 ``` @@ -33,8 +33,8 @@ The gate trips (exit 1) and the findings land in timestamped JSON Lines under `.wardline/` by default (`--output PATH` writes to an exact path; `--format sarif` emits SARIF for GitHub code scanning). Wardline is agent-first — you don't read that file by hand. Your coding agent does: ask it *"why did the scan -fail?"* and it surfaces the one active defect (the other two findings are -`NONE`-severity engine facts): +fail?"* and it surfaces the one active defect (the other finding is a +`NONE`-severity engine fact): > **`demo.build_record`** declares return trust `ASSURED` but actually returns > `EXTERNAL_RAW` (less trusted) — untrusted data reaches a trusted producer. @@ -137,7 +137,7 @@ Prefer `weft_markers` in application code. Wardline still recognizes | `scanner` | pyyaml, jsonschema, click | the `wardline` CLI and `wardline mcp` server | | `loomweave` | blake3 | persisting taint facts to a Loomweave store | | `rust` | scanner extra, tree-sitter, tree-sitter-rust | `wardline scan --lang rust` | -| `docs` | mkdocs, mkdocs-material | building the documentation site | +| `docs` | mkdocs, mkdocs-material | a local MkDocs render of `docs/` | The LLM triage judge (`wardline judge`) is dependency-free (stdlib `urllib` → OpenRouter) and needs no extra. @@ -150,8 +150,9 @@ wardline install This injects a hash-fenced instruction block into `CLAUDE.md`/`AGENTS.md`, installs the `wardline-gate` skill, merges a `wardline` entry into `.mcp.json`, -and writes Codex's `~/.codex/config.toml` MCP entry. Agents then run the scan → -explain → fix-at-boundary → rescan loop natively. The `wardline mcp` server +writes Codex's `~/.codex/config.toml` MCP entry, detects Loomweave/Filigree +siblings, mints an attest signing key, and adds pre-commit hook config. Agents +then run the scan → explain → fix-at-boundary → rescan loop natively. The `wardline mcp` server exposes the primary tool surface over JSON-RPC with no SDK, including scan, filtered findings, explain-taint, fix, judge, baseline/waiver, doctor, rekey, assure, attest, dossier, and Filigree filing tools. @@ -196,23 +197,23 @@ It is **not** the right tool when you need: ## Documentation -Full documentation lives at ****. +Full documentation lives in the [`docs/`](https://github.com/foundryside-dev/wardline/tree/main/docs) tree. | Document | Description | |----------|-------------| -| [Getting Started](https://foundryside-dev.github.io/wardline/getting-started/) | Install, decorate, first scan | -| [Taint & Trust Model](https://foundryside-dev.github.io/wardline/concepts/model/) | The lattice, decorators, and propagation | -| [Rules](https://foundryside-dev.github.io/wardline/concepts/rules/) | The boundary, exception-flow, and sink rules | -| [Configuration](https://foundryside-dev.github.io/wardline/guides/configuration/) | `weft.toml` `[wardline]`: rules, severity, excludes | -| [Suppression](https://foundryside-dev.github.io/wardline/guides/suppression/) | Baselines and waivers | -| [LLM Triage Judge](https://foundryside-dev.github.io/wardline/guides/judge/) | Opt-in TRUE/FALSE-positive labelling | -| [Rust Support](https://foundryside-dev.github.io/wardline/guides/rust-preview/) | Preview Rust command-injection frontend | -| [Weft Integration](https://foundryside-dev.github.io/wardline/guides/weft/) | SARIF, Filigree, Loomweave, and sibling URL resolution | -| [Assurance Posture](https://foundryside-dev.github.io/wardline/guides/assurance-posture/) | Coverage posture, attestations, and trust-surface evidence | -| [Loomweave Taint Store](https://foundryside-dev.github.io/wardline/guides/loomweave-taint-store/) | Persisting taint facts | -| [CLI Reference](https://foundryside-dev.github.io/wardline/reference/cli/) | Every command and flag | -| [Trust Vocabulary](https://foundryside-dev.github.io/wardline/reference/vocabulary/) | The decorators and their arguments | -| [Agent Integration](https://foundryside-dev.github.io/wardline/guides/agents/) | Using Wardline from a coding agent | +| [Getting Started](https://github.com/foundryside-dev/wardline/blob/main/docs/getting-started.md) | Install, decorate, first scan | +| [Taint & Trust Model](https://github.com/foundryside-dev/wardline/blob/main/docs/concepts/model.md) | The lattice, decorators, and propagation | +| [Rules](https://github.com/foundryside-dev/wardline/blob/main/docs/concepts/rules.md) | The boundary, exception-flow, and sink rules | +| [Configuration](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/configuration.md) | `weft.toml` `[wardline]`: rules, severity, excludes | +| [Suppression](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/suppression.md) | Baselines and waivers | +| [LLM Triage Judge](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/judge.md) | Opt-in TRUE/FALSE-positive labelling | +| [Rust Support](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/rust-preview.md) | Preview Rust command-injection frontend | +| [Weft Integration](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/weft.md) | SARIF, Filigree, Loomweave, and sibling URL resolution | +| [Assurance Posture](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/assurance-posture.md) | Coverage posture, attestations, and trust-surface evidence | +| [Loomweave Taint Store](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/loomweave-taint-store.md) | Persisting taint facts | +| [CLI Reference](https://github.com/foundryside-dev/wardline/blob/main/docs/reference/cli.md) | Every command and flag | +| [Trust Vocabulary](https://github.com/foundryside-dev/wardline/blob/main/docs/reference/vocabulary.md) | The decorators and their arguments | +| [Agent Integration](https://github.com/foundryside-dev/wardline/blob/main/docs/guides/agents.md) | Using Wardline from a coding agent | ## Development diff --git a/ROADMAP.md b/ROADMAP.md index 500b9a7a..cc0c8ed4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,24 +5,26 @@ a direction sketch, not a commitment — dates are deliberately omitted. ## Where we are -**0.3.0 — shipped.** The staged build (SP0–SP9) is complete: +**1.0.6 — shipped.** The staged build (SP0–SP9) is complete: - Function-, variable-, and project-level taint over an inter-module call graph (L1–L2 with an L3 fixed point). - The NG-25 trust vocabulary and three opt-in decorators. -- Four policy rules (PY-WL-101..104), severity/enable config, baselines + waivers. +- 26 Python policy rules (PY-WL-101..126) plus Rust preview rules + (RS-WL-108/112), severity/enable config, baselines + waivers. - JSONL + SARIF + native Filigree emit. - Dependency-free MCP-over-stdio server (`wardline mcp`). - Opt-in LLM triage judge (`wardline judge`). - `wardline install` agent enablement. - Opt-in Loomweave taint-store integration. -- Published to PyPI; docs site live; CI dogfoods Wardline on its own source. +- Published to PyPI; CI dogfoods Wardline on its own source. ## Scope Wardline is deliberately **L1–L2 with an L3 project fixed point**, not an -exhaustive path-sensitive whole-program prover, and Python-only. We favor a -small, precise, opt-in rule set over broad SAST coverage. +exhaustive path-sensitive whole-program prover, and Python-first (with a Rust +preview, `wardline scan --lang rust`). We favor a small, precise, opt-in rule +set over broad SAST coverage. ## Near-term threads @@ -39,6 +41,6 @@ Tracked in the project's Filigree issues: ## Out of scope (for now) -- Languages other than Python. +- Broad multi-language coverage beyond the Python core and Rust preview. - A general-purpose, dozens-of-rules SAST suite. - A hosted/cloud service — Wardline stays local-first. diff --git a/docs/concepts/model.md b/docs/concepts/model.md index 8eec6b49..3e67641e 100644 --- a/docs/concepts/model.md +++ b/docs/concepts/model.md @@ -66,6 +66,7 @@ emit the canonical list: ```console $ wardline vocab +schema: wardline.vocabulary/v1 version: wardline-generic-2 entries: - canonical_name: external_boundary diff --git a/docs/concepts/rules.md b/docs/concepts/rules.md index 417632f1..29743029 100644 --- a/docs/concepts/rules.md +++ b/docs/concepts/rules.md @@ -258,7 +258,8 @@ handler to the specific exception you expect (`except ValueError:`). ### PY-WL-104 — silently swallowed exception in a trusted-tier function -Fires on a handler whose body only `pass`/`...`/`continue`/`break` — it discards +Fires on a handler whose body is only `pass`/`...`/`continue`/`break` or a bare +constant expression (a docstring-like string literal or a number) — it discards the error with no logging, re-raise, or recovery. The failure vanishes silently. diff --git a/docs/concepts/taint-algebra.md b/docs/concepts/taint-algebra.md index 839fbed8..f1cfe170 100644 --- a/docs/concepts/taint-algebra.md +++ b/docs/concepts/taint-algebra.md @@ -40,7 +40,7 @@ least_trusted(INTEGRAL, ASSURED) == ASSURED # weakest link wins absorbing top `MIXED_RAW`. After the three `least_trusted` migrations it has **no production call site** — it is retained deliberately as the documented contrast operator. See the ADR: -[Retain the 8-state lattice](https://github.com/foundryside-dev/wardline/blob/main/docs/decisions/2026-05-31-wardline-taint-lattice-retain.md). +[Retain the 8-state lattice](../decisions/2026-05-31-wardline-taint-lattice-retain.md). ## The discriminator: why even genuine value-merges use `least_trusted` @@ -150,7 +150,7 @@ crosses between maps: When a caller launders raw data through a `@trust_boundary` validator, `PY-WL-101` reads the validator's **declared** output tier (`effective_return`, -`project_resolver.py:156`) — not the raw input — because the trust model treats +`project_resolver.py:268`) — not the raw input — because the trust model treats the annotation as the contract. This is sound for the statically-decidable property. A **broken** validator with @@ -172,6 +172,6 @@ promises and what a value-level semantic analysis would require. - [Taint & trust model](model.md) — the reader-facing introduction. - [Rules](rules.md) — the checks built on this algebra. -- [ADR: Retain the 8-state lattice](https://github.com/foundryside-dev/wardline/blob/main/docs/decisions/2026-05-31-wardline-taint-lattice-retain.md). +- [ADR: Retain the 8-state lattice](../decisions/2026-05-31-wardline-taint-lattice-retain.md). - `docs/audits/2026-05-31-taint-combination-audit.md` — the audit this spec consolidates (findings F1–F6). diff --git a/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md b/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md index 88e9a745..2df429be 100644 --- a/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md +++ b/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md @@ -111,16 +111,18 @@ pass unchanged.** Concretely: across builds for identical source. Call-site-anchored rules that can emit more than one finding per `(rule_id, path, line_start, qualname)` discriminate by the sink/callee spelling plus the call node's **full lexical span**, serialized as - `{col_offset}:{end_col_offset}`. These are CPython `ast` column coordinates: - **0-based UTF-8 byte offsets**, with a `Call` anchored at its func-expression - start (so a method chain's outer and inner calls share `col_offset` but differ in - `end_col_offset`). A second engine MUST reproduce these byte-offset column - semantics — the same obligation `entity_spans` (§3) already imposes — or the - hashed join key drifts *silently* (unlike a span diff, which the parity test - surfaces). A per-line source-order ordinal was considered as a more portable - alternative but rejected: it would require the rule to sort calls by - `(lineno, col_offset)` anyway, reintroducing the column dependency without the - span's collision-completeness. + `{lineno - entity_line_start}:{col_offset}:{end_lineno - entity_line_start}:{end_col_offset}`. + The line coordinates are entity-relative so moving the whole function does not + rekey the finding; the column coordinates are CPython `ast` **0-based UTF-8 byte + offsets**. `end_col_offset` is relative to `end_lineno`, not globally unique: + multiline chained calls can share start line/column and ending column while + differing only in `end_lineno`, so both line deltas are load-bearing. A second + engine MUST reproduce these byte-offset column semantics — the same obligation + `entity_spans` (§3) already imposes — or the hashed join key drifts *silently* + (unlike a span diff, which the parity test surfaces). A per-line source-order + ordinal was considered as a more portable alternative but rejected: it would + require the rule to sort calls by `(lineno, col_offset)` anyway, reintroducing + the column dependency without the span's collision-completeness. ## Consequences diff --git a/docs/getting-started.md b/docs/getting-started.md index bd9257c6..a42db6bf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,6 +38,10 @@ scanned 2 file(s); 4 finding(s) — 0 suppressed (0 baseline / 0 waiver / 0 judg !!! note "Where the findings go" In `jsonl` mode the findings are written to a file, not printed. The summary line names the destination — here, a timestamped artifact under `.wardline/`. + The artifact always lands under `/.wardline/`, where the project + root is the directory containing `weft.toml`, independent of which subdirectory + `wardline scan` was invoked from. A subdirectory scan is still flagged with + `WLN-ENGINE-NESTED-SCAN-ROOT`; the artifact location itself is always the root. Use `--output PATH` to write to an exact path, or `--format sarif` for SARIF. The summary itself is printed to standard output. diff --git a/docs/guides/agents.md b/docs/guides/agents.md index 0ef57794..e85e1041 100644 --- a/docs/guides/agents.md +++ b/docs/guides/agents.md @@ -79,7 +79,13 @@ $ wardline doctor Use `wardline doctor --repair` after moving binaries, starting a Filigree dashboard, or changing sibling tool config. It refreshes the instruction blocks, skills, and MCP entries, and re-detects siblings using the same discovery rules -as `wardline install` — it never writes `weft.toml` or a sibling binding. +as `wardline install` — it never writes `weft.toml` or a sibling binding. It +also gitignores the artifacts directory (`[wardline.artifacts].dir`, default +`.wardline/`) and sweeps stray Wardline-managed artifacts under the project root, +deleting managed-pattern (timestamped) files inside `.wardline/` directories and +reporting unstamped or bare strays for manual review. The MCP `doctor` tool +with `repair: true` performs the same hygiene and is advertised as destructive +(`destructiveHint: true`). Over MCP, the `doctor` tool returns the same machine-readable envelope (read-only by default; pass `repair: true` for the write-gated repair) **plus @@ -165,11 +171,13 @@ wardline scan . --fail-on ERROR --output "$out" ``` A `scan` always writes a findings file. By default it goes to a timestamped -artifact under `.wardline/` with retention; point `--output` at a per-run -temporary file — as above — when a hook needs exact cleanup semantics. Avoid -predictable filenames in shared directories such as `/tmp`. The script's exit -code becomes the hook's exit code: a clean tree commits, a new defect aborts the -commit with the finding already on screen for the agent to act on. +artifact under `.wardline/` at the **project root** (the `weft.toml` directory), +with retention applied project-root-wide — a subdirectory scan writes to the +same pool as a root scan. Point `--output` at a per-run temporary file — as +above — when a hook needs exact cleanup semantics. Avoid predictable filenames +in shared directories such as `/tmp`. The script's exit code becomes the hook's +exit code: a clean tree commits, a new defect aborts the commit with the finding +already on screen for the agent to act on. ## Let the agent triage with `wardline judge` diff --git a/docs/guides/assurance-posture.md b/docs/guides/assurance-posture.md index 767d0254..b756de2e 100644 --- a/docs/guides/assurance-posture.md +++ b/docs/guides/assurance-posture.md @@ -250,7 +250,7 @@ are confined under the server root (the same guarantee as `scan`). ```console $ wardline assure src/myproject --format human -Trust-surface coverage: 91.7% (11/12 boundaries reached a definite verdict) +Trust-surface coverage: 91.7% (11/12 surface item(s) reached a definite verdict) proven: 9 defect: 1 unknown: 1 (1 engine-limited) diff --git a/docs/guides/attestation.md b/docs/guides/attestation.md index 88ac1821..cd21485c 100644 --- a/docs/guides/attestation.md +++ b/docs/guides/attestation.md @@ -165,7 +165,7 @@ Verification is two separable checks: true`. A mismatch may mean the tree moved on since the bundle was produced — not necessarily tamper. The `note` field in the result says so explicitly. -The result object from both CLI and MCP `verify_attestation`: +The result object from both CLI (`attest --verify`) and MCP (`verify_attestation`): ```json { @@ -193,8 +193,10 @@ The result object from both CLI and MCP `verify_attestation`: The `bundle` argument is required (the parsed JSON object, not a path). `reproduce` defaults to `false`. The tool returns the result object above. -CLI exit codes for `--verify`: `0` if `signature_valid`, `1` if not. The -reproducibility result does not affect the exit code. +CLI exit codes for `--verify`: `0` if `signature_valid` (and, when `--reproduce` +is passed, also `reproduced`); `1` otherwise. So without `--reproduce` the +reproducibility result does not affect the exit code, but with `--reproduce` a +reproducibility mismatch yields exit `1` even when the signature is valid. ### CLI diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index a1a4355d..454841bd 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -18,9 +18,10 @@ defaults: it scans `.` with all rules enabled. !!! warning "But unknown keys and out-of-range values in a *present* `[wardline]` table are hard errors" Once a `[wardline]` table parses, it is validated against a JSON Schema (draft 2020-12). The table, the `[wardline.rules]` block, the - `[wardline.judge]` block, and the `[wardline.autofix]` block all set - `additionalProperties: false`, so a typo'd key or an out-of-range value - **fails loud** — Wardline exits `2` rather than silently ignoring it. + `[wardline.judge]` block, the `[wardline.artifacts]` block, and the + `[wardline.autofix]` block all set `additionalProperties: false`, so a + typo'd key or an out-of-range value **fails loud** — Wardline exits `2` + rather than silently ignoring it. ```console $ wardline scan . @@ -87,18 +88,30 @@ A relative `store_dir` resolves under the scan root. The attest signing key is ### `[wardline.artifacts]` Scan outputs are written under `.wardline/` by default using timestamped names -such as `20260620T153012Z-findings.jsonl`. Wardline prunes older artifacts for -the same output format after each default-output scan: +such as `20260620T153012Z-findings.jsonl`. The artifact directory always anchors +to the **weft-project root** — the directory containing `weft.toml` — regardless +of which subdirectory `wardline scan` is invoked from. This means subdir and +root scans share one `.wardline/` pool and retention is project-root-wide. +A subdirectory scan is still flagged with `WLN-ENGINE-NESTED-SCAN-ROOT`. + +Wardline prunes older artifacts for the same output format after each +default-output scan: ```toml [wardline.artifacts] -dir = ".wardline" # the default; relative paths resolve under the scan root +dir = ".wardline" # the default; relative paths resolve under the project root retain = 20 # keep the newest 20 artifacts per format ``` Use `--output PATH` when a workflow needs an exact file path; explicit output paths bypass artifact timestamping and retention. +`wardline doctor --repair` (CLI) and the MCP `doctor` tool with `repair: true` +gitignore the artifacts directory and sweep stray Wardline-managed (timestamped) +artifacts found under the project root. Deletion is bounded to managed-pattern +files inside `.wardline/` directories; unstamped or bare files are reported for +manual review rather than deleted. + ### `packs` Trust-grammar packs extend Wardline's vocabulary. Because a pack imports and diff --git a/docs/guides/judge.md b/docs/guides/judge.md index b90e657d..bc154e29 100644 --- a/docs/guides/judge.md +++ b/docs/guides/judge.md @@ -48,12 +48,22 @@ wardline judge [OPTIONS] [PATH] Triage active DEFECTs with the opt-in LLM judge. Options: - --config PATH + --config FILE --model TEXT OpenRouter model slug (overrides config). --context-lines INTEGER Excerpt radius (default 30). --max-findings INTEGER Cap findings triaged this run. --write Append FALSE_POSITIVE verdicts to .weft/wardline/judged.yaml (default: dry-run). + --trust-judge-policy Allow loading judge.policy_file from the scanned + project as untrusted judge context. + --trust-judge-config Allow project judge config to select model, + context, cap, and write confidence floor. + --trust-pack TEXT Allow importing this trust-grammar pack from + weft.toml [wardline]. May be repeated. + --allow-custom-packs Allow loading custom trust-grammar packs from the + local project directory. + --strict-defaults Ignore repository-supplied custom configuration + overrides (weft.toml). --help Show this message and exit. ``` diff --git a/docs/guides/legis-handoff.md b/docs/guides/legis-handoff.md index 8f262d6b..eb4bcd79 100644 --- a/docs/guides/legis-handoff.md +++ b/docs/guides/legis-handoff.md @@ -151,7 +151,7 @@ lowercase hex, where `fields` is the whole scan **minus** `artifact_signature`, `canonical_json` is sorted-key, tight-separator (`,`/`:`), non-ASCII-preserving, NaN-rejecting JSON — byte-identical to legis's `canonical.py`. The signer is pinned by a golden vector captured from the real legis signer and a hermetic conformance test -([`tests/conformance/test_legis_intake_contract.py`](https://github.com/foundryside-dev/wardline)). +([`tests/conformance/test_legis_intake_contract.py`](https://github.com/foundryside-dev/wardline/blob/main/tests/conformance/test_legis_intake_contract.py)). !!! warning "Threat model" HMAC-SHA256 with a **shared secret** is tamper-evidence within a key-holding trust diff --git a/docs/guides/suppression.md b/docs/guides/suppression.md index ac68e411..46c7a36c 100644 --- a/docs/guides/suppression.md +++ b/docs/guides/suppression.md @@ -31,7 +31,7 @@ precise meaning of every state word — `active`, `baselined`, `waived`, `judged and the three distinct meanings of "new" — see [Finding lifecycle & gate vocabulary](../reference/finding-lifecycle-vocabulary.md). -## Suppressions and the `--fail-on` gate (read this first) +## Suppressions and the fail-on gate (read this first) All three layers — baseline, waiver, judged — live in **committed repository content** (`.weft/wardline/baseline.yaml`, `.weft/wardline/waivers.yaml`, diff --git a/docs/guides/weft.md b/docs/guides/weft.md index 0c223457..aa7f1cdb 100644 --- a/docs/guides/weft.md +++ b/docs/guides/weft.md @@ -43,9 +43,10 @@ $ wardline scan src/wardline --format sarif --output results.sarif ``` With `--format sarif` and no `--output`, the default file is a timestamped SARIF -artifact under `.wardline/` (or `[wardline.artifacts].dir`). The log carries one -run with a `wardline` driver, minimal rule descriptors (the distinct rule IDs -seen), and one result per finding — +artifact under `.wardline/` (or `[wardline.artifacts].dir`) at the project root +(the `weft.toml` directory), independent of where `wardline scan` is invoked. +The log carries one run with a `wardline` driver, minimal rule descriptors (the +distinct rule IDs seen), and one result per finding — `ruleId` + `ruleIndex`, a `level` mapped from severity (`CRITICAL`/`ERROR` → `error`, `WARN` → `warning`, `INFO` → `note`, `NONE` → `none`), a physical location, and `partialFingerprints` carrying Wardline's stable fingerprint. diff --git a/docs/index.md b/docs/index.md index 09a35615..41b47ddf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ boundaries. It scans Python source, includes a Rust command-injection preview, and gives agents and CI a deterministic gate for untrusted data reaching trusted code. -This is the wardline documentation site — the front door for installing, +This is the wardline documentation index — the front door for installing, running, and integrating the tool, with the concept, guide, and reference material behind it. diff --git a/docs/product/PRD-0001-codex-p1-closeout.md b/docs/product/PRD-0001-codex-p1-closeout.md new file mode 100644 index 00000000..2d60426a --- /dev/null +++ b/docs/product/PRD-0001-codex-p1-closeout.md @@ -0,0 +1,134 @@ +# PRD-0001 — Codex P1 close-out Status: ready-for-planning +Decision: PDR-0001 Bet (roadmap.md): Now Target metric (metrics.md): G2 — soundness / surface integrity + +## Problem + +**Who** — the 1–2 developer team running Wardline in their agent's edit-verify +loop and in CI, and the operator who runs `wardline doctor` to set integrations +up. They point Wardline at code they did not write — that is the whole job. + +**The problem (their pain)** — two confirmed findings let an *untrusted +repository under analysis* subvert the analyzer itself. (1) A single +attacker-crafted Python file (≈1100 one-armed branches) drives the lambda +candidate-set merge to O(N³) — ~15s and climbing — and this is on the **default +`wardline scan --fail-on` gate**, no opt-in. The gate of record becomes a denial +of service on every local and CI run. (2) `wardline doctor` reads a +repo-controlled `.weft/filigree/ephemeral.port` and sends the operator's +Filigree federation **bearer token** to whatever loopback port the repo names; +under `--repair` it also probes the cross-project home mint +`~/.config/filigree/federation_token`. The tool whose purpose is to make +untrusted input safe is itself turned by untrusted input — a breach of Wardline's +own trust boundary. + +**Desired outcome** — scanning or doctoring a hostile repository is safe: +analysis completes in bounded time regardless of input shape, and no credential +is disclosed to an endpoint that has not proven it is a genuine Filigree daemon. +No fail-open, no false green, no token leak. + +**Why now** — an external Codex security review surfaced 26 findings; the +2026-06-22 deep triage (52 agents, adversarially verified) confirmed these are +the **only two** that breach the default gate or expose a credential — while 23 +others are scoped to opt-in or preview surfaces and 2 were already fixed. Closing +this breach class is the precondition for declaring the hardening campaign sound +and opening the next (MCP-primary) front. Both fixes are size-S. + +## Success metric (the signal the bet paid off) + +**G2 — soundness / surface integrity** (`metrics.md`): *zero known fail-open or +policy-bypass holes on the agent surface.* This bet moves the P1 slice of that +guardrail. +- BASELINE (2026-06-22): 2 confirmed default-gate-reachable / credential-exposure + holes open (`c797baf28b`, `d96b94d4e9`). +- TARGET: **0** such holes — both resolved and regression-pinned — within the + campaign window (`metrics.md` G2 backstop: 2026-07-31). +- Falsification: if either P1 hole remains demonstrable at the window close, the + bet has not paid off. + +## Acceptance criteria (falsifiable) + +1. **SUCCESS — DoS bound (`c797baf28b`).** A regression test that reproduces the + adversarial input (N one-armed lambda branches) **fails on pre-fix code and + passes on the fix**, and analysis of that input completes within a committed, + deterministic time/space bound (no superlinear blow-up), merged to `main` + within 7 days of the close-out PR opening. + *Reject branch:* no such test, or analysis still superlinear at the window + close → bet rejected for this finding; open follow-up PDR. +2. **SUCCESS — credential gate (`d96b94d4e9`).** `wardline doctor` (and + `--repair`) sends **no** Filigree token to a loopback endpoint sourced solely + from a repo-controlled port file; a test proves the pre-fix leak and its + absence post-fix, merged within 7 days of the close-out PR opening. + *Reject branch:* token still reaches a non-provenanced endpoint → rejected. +3. **GUARDRAIL — precision (G1) must not degrade.** The DoS bound drops **zero** + real findings: Wardline's full suite **and** a dogfood scan of its own source + yield a byte-identical active-finding set before vs. after, over the same + window. + *Reject branch:* any active finding lost, or any new false-negative → bet + rejected **even if (1) passes**; the bound is redesigned. +4. **GUARDRAIL — no new bypass (G2).** The doctor provenance gate is + fail-**closed** and introduces no regression: with no genuine daemon present, + no token is sent anywhere; with a genuine daemon present, doctor still + succeeds. Verified over the same window. + *Reject branch:* token sent to an unverified endpoint, OR doctor breaks + against a real daemon → rejected. +5. **SCOPE — default path, no flag.** Both fixes are always-on on the default + surfaces (c797 in the default scan, d96b in plain `doctor`) — not behind an + opt-in toggle — at merge. + *Reject branch:* either fix gated to a subset/flag → this criterion is unmet. + +## Non-goals (this bet) + +- The remaining **21 P3** Codex findings (their own batches B2–B6; tagged + `codex-triage-2026-06-22`). +- **B7 / `c852f6d8b5`** — the outward-facing site-kit CI pin. Escalated and + **gated** for owner confirmation; explicitly out of this bet. +- Any new capability, rule, or surface — this is a hardening close-out, not a + feature. +- Broad refactor of the `doctor` / scan-merge subsystems beyond the two fixes. +- **Secondary (non-gating):** `4e664591e6` (P2) and `044a260b6a` (P3) may ride + the same PRs if they fit without scope growth; if not, they fall back to their + own queue. They do **not** gate this bet's acceptance. + +## Constraints & guardrails + +- **G1 precision floor** and **G2 no-new-bypass** are hard (criteria 3–4). +- **G4 weight:** fix within the stdlib — no new runtime dependency. +- **G3 zero-config:** the doctor provenance gate must require **no new human + configuration**; it activates by default. +- **Determinism:** the c797 bound must be deterministic — no flaky/time-based + threshold that varies by host. +- **Fix at the boundary**, per the `wardline-gate` discipline — bound the + algorithm / gate the credential at the trust boundary, not by masking the sink. + +## Open questions / assumptions + +- **Measurement instrument.** G2's "0 known holes" is asserted by the re-triage + + adversarial-verify pass and the regression tests, not by live telemetry — which + is the appropriate instrument for a security-hardening bet. The north-star + (agent-fix success) is not yet instrumented; this bet is judged on guardrails by + design. *If wrong:* if the owner wants a continuous surface-integrity metric, + that must be added to `metrics.md` first. +- **c797 bound mechanism** (hard cap on candidate-set size vs. an O(N) merge + rewrite) is a **design** choice → `/axiom-solution-architect`. Assumption: a + bound exists that preserves every real finding (criterion 3); if no such bound + does, the finding-model itself needs rework — a bigger bet. +- **d96b provenance mechanism** (how a loopback endpoint *proves* it is the real + Filigree daemon — registry/identity handshake vs. refuse-and-fail-closed) → + `/axiom-solution-architect`. Assumption: fail-closed (send nothing) is always an + acceptable floor. +- Triage confirmed both P1s are present at HEAD `09eae7a2`; assumes no in-flight + branch already fixes them. + +## Handoff + +- **Top item → `/axiom-planning`:** the **c797 DoS bound** (`wardline-c797baf28b`) + — the only default-gate breach, highest blast radius, size-S. Turn into an + executable, codebase-validated implementation plan first. +- **Solution shape → `/axiom-solution-architect`:** the **d96b daemon-provenance + gate** (authenticate the loopback Filigree daemon before sending a token) and + the **c797 bound mechanism** (cap vs. O(N) merge). The PRD names the constraints; + the design is theirs. +- **Tracker IDs:** `wardline-c797baf28b` (P1), `wardline-d96b94d4e9` (P1), + `wardline-4e664591e6` (P2, secondary), `wardline-044a260b6a` (P3, secondary). + Decision: PDR-0001. Batches B1 / B5 in `docs/product/codex-triage-2026-06-22.md`. +- **Forecast/sequencing → `/axiom-program-management`.** No delivery date is set + here; the dated commitment comes from its forecast. diff --git a/docs/product/codex-triage-2026-06-22.md b/docs/product/codex-triage-2026-06-22.md new file mode 100644 index 00000000..ab873e93 --- /dev/null +++ b/docs/product/codex-triage-2026-06-22.md @@ -0,0 +1,64 @@ +# Codex security batch — deep triage (2026-06-22) + +> Ground-truth re-triage of the 26 open `codex-security` bugs against current HEAD +> (`09eae7a2`), grounded in the actual code, not the import-time scanner labels. +> Method: 26 triage agents + 26 independent adversarial verifiers (52 agents, +> ~2.9M tokens). Every verdict was re-checked against live code. The full agent +> output is ephemeral; this file is the durable record. + +## Bottom line + +- **2 tickets are already fixed** (by `b1a9de36`) but still sat in `triage` — closed. +- **Nothing is P0.** The default `wardline scan --fail-on` gate of record is clean. + Residual risk lives on **opt-in surfaces** (Filigree emit, MCP, the Rust + *preview*, Loomweave enrichment, `doctor`/install). +- Re-grade: **2 P1, 1 P2, 21 P3** (down from a flat 14 P2 / 12 P3 import labeling). + +## Already fixed — closed +| Ticket | Title | Closed by | +|---|---|---| +| `wardline-8c576deeb3` | Rust assignment drops self-referential taint | `b1a9de36` (live-repro verified) | +| `wardline-124edc2a7a` | Rust shadowing erases taint before RHS | `b1a9de36` (live-repro verified) | + +## Priority re-grade +**P1** — `wardline-c797baf28b` (unbounded lambda candidate sets — **only finding on the default gate**, O(N³) from one `.py`); `wardline-d96b94d4e9` (doctor leaks Filigree federation token via planted `.weft/.../ephemeral.port`). +**P2** — `wardline-4e664591e6` (invalid-UTF-8 `federation_token` crashes any Filigree-emitting scan). +**P3** — remaining 21 (gradient: real-bypass/soundness on opt-in/preview surfaces → hygiene/enrichment/CI). + +Relabeled P2→P3 during this pass: `a456b4f662`, `c852f6d8b5`, `2ab78ad8ed`, `bdabb69446`, `8489bbb3fc`, `31540f8492`, `dbe1117440`, `a1bcb70c15`, `ea10bcd5c9`. + +Adversarial overrides of triage: downgraded `8c576deeb3`/`e441f8ef43`; **upgraded `a6d8b5efce`** back to still-present P3 (triage wrongly thought it fixed). + +## Concurrency (from actual fix-files) + +**Hard sequential chains (same file → serialize):** +- `doctor.py`: `d96b94d4e9` → `cb66016a5c` +- `core/finding.py`: `31540f8492` → `a1bcb70c15` +- `mcp/server.py`: `2ab78ad8ed` → `bdabb69446` → `66bd8ced4b` +- `rust/analyzer.py`(+`mounts.py`): `8489bbb3fc` ↔ `a6d8b5efce` ↔ `dbe1117440` + +**Shallow seams (same file / different function → rebase):** `filigree_emit.py` (`66bd8ced4b` ∩ `a456b4f662`); `cli/scan.py` (`66bd8ced4b` ∩ `e441f8ef43`). Land `66bd8ced4b`'s URL-redaction first to clear both. + +Everything else is file-disjoint → fully parallel. + +## Batches + assigned agents +| # | Batch | Tickets (internal order) | Implementation | Review | +|---|---|---|---|---| +| B1 | Credential & Filigree-emit | `d96b`(P1)→`cb66`; `4e66`(P2); `a456`; `2def` | general-purpose + `controls-designer` (d96b token-provenance gate) | `threat-analyst` + `silent-failure-hunter` + `python-code-reviewer` | +| B2 | MCP capability/policy | `2ab7`→`bdab`→`66bd` | general-purpose (MCP/policy) | `threat-analyst` + `silent-failure-hunter` + `python-code-reviewer` | +| B3 | Rust frontend (preview) | crash: `8489`↔`a6d8`↔`dbe1`,`87ef`; soundness: `ef9a`,`b757`,`6169` | general-purpose (Rust-dialect) | `false-positive-analyst` + `silent-failure-hunter` + `test-suite-reviewer` | +| B4 | Finding-identity / fingerprint | `31540`→`a1bc` | general-purpose (static-analysis) | `false-positive-analyst` + `python-code-reviewer` + `test-suite-reviewer` | +| B5 | Scanner & installer DoS bounds | `c797`(P1); `044a` | general-purpose (perf) | `python-code-reviewer` + `test-suite-reviewer` + `false-positive-analyst` | +| B6 | Path-safety & enrichment integrity | `e441`,`56189`,`f55e`,`ea10` | general-purpose | `python-code-reviewer` + `threat-analyst` (e441) | +| B7 ⚠️ | Supply-chain / CI (outward-facing) | `c852` | general-purpose (devops) | `pipeline-reviewer` | + +In-progress `wardline-14359d070b` (waiver_add MCP network bypass, claimed by codex) belongs to B2. + +## Execution +1. Close the 2 fixed tickets (done). +2. Wave 1 (parallel): B1 (d96b P1), B5 (c797 P1), B3, B4. +3. Wave 2 (parallel): B2, B6, B7 — after `66bd` lands the redaction. +4. Respect the 4 sequential chains within batches. + +## ⚠️ Escalation +B7 / `c852f6d8b5` modifies the GitHub Pages deploy pipeline for `wardline.foundryside.dev` (outward-facing). Pinning the `@weft/site-kit` fetch to a SHA is internal hardening but touches the publish path — gated for owner confirmation before dispatch. diff --git a/docs/product/current-state.md b/docs/product/current-state.md new file mode 100644 index 00000000..c09a4df7 --- /dev/null +++ b/docs/product/current-state.md @@ -0,0 +1,79 @@ +# Current State — Wardline + +> The resume brief: the fastest path back to the running picture. Read this +> first next session. Bootstrapped 2026-06-22 from observed reality (no prior +> workspace existed). + +## The bet right now + +**Close out the Codex security-review hardening campaign on the shipped 1.0.x +agent surface** (see `roadmap.md` → Now). Wardline 1.0.6 is shipped and live on +PyPI; the active work is hardening the agent-facing MCP / CLI / federation +surfaces against an external (Codex) security review, not building a new +capability front. + +**Dispatchable top of the bet: `PRD-0001` (Codex P1 close-out)** — awaiting +planning/acceptance. The 2026-06-22 deep triage (52 agents, adversarially +verified; full record in `codex-triage-2026-06-22.md`) re-graded the 26 open +Codex bugs: **2 already-fixed → closed**, **2 P1**, **1 P2**, **21 P3**. Nothing +is P0; the default `wardline scan --fail-on` gate is clean. The two P1s +(`wardline-c797baf28b` default-gate DoS, `wardline-d96b94d4e9` doctor token leak) +are the entire `PRD-0001` acceptance core. + +## In flight (by tracker ID) + +- **`wardline-14359d070b`** (P2, bug, *in progress*) — "waiver_add bypasses MCP + network policy for entity_symbol." The single claimed item; representative of + the Now bet. +- **`wardline-bf004e2aea`** (P1, task) — "Holistic risk review 2026-06-10 — + findings tracker." Parent tracker; triage on 2026-06-20 narrowed it to 4 live + children (`wardline-80e457bc41` federation-status envelope dup; + `wardline-18499aaa2d` shared transport extract; `wardline-d59f35c626` + verify_attestation edge tests; `wardline-bf93236656` confinement sweep). +- **Codex security batch** — `codex-security-2026-06-20` label carries ~44 + findings; `codex-security` ~89 total. Many are the P2 bugs in the ready queue + (rekey probe write-policy, doctor token leak via planted port file, scan + advertised read-only despite effects, Rust mount-overlay crash, move-stable + fingerprint misapply, etc.). **This batch is the operational heart of the Now + bet.** +- **MCP-primary program** — `wardline-8528e67192` (gap tracker), label + `mcp-primary-2026-06-11` (~16). Queued as **Next**. + +Tracker scale: 52 ready, 0 blocked at bootstrap. Recent git history is almost +entirely `fix:` commits — consistent with a hardening campaign, not a feature +push. + +## Open questions (bootstrap could not resolve) + +1. **North-star instrumentation.** Agent-fix success rate (metrics.md) has no + baseline — there is no labeled findings+outcome corpus yet. How should this + be measured, and is it the right north star, or is precision (G1) the truer + headline metric for an analyzer? +2. **Horizon confirmation.** Is finishing the Codex batch genuinely the *whole* + Now bet, or should the MCP-primary program run concurrently rather than as + Next? +3. **"Done" definition for the campaign.** Is the bet complete when the + `codex-security-2026-06-20` batch hits zero open, or when a clean re-review + confirms no residual class? (Proposed: the latter — re-review, not just + count-to-zero.) +4. **Guardrail baselines.** G1 (FP rate) and the north star need a measurement + pass to turn TBD placeholders into real targets. + +## Where the next session starts + +1. Confirm the authority grant still holds (it was confirmed standard on + 2026-06-22). +2. Confirm the Now/Next horizon split in `roadmap.md` — especially open + question 2. +3. `DECIDE` on the Codex-batch "done" definition (open question 3), then + `DISPATCH`: the top dispatchable bet is driving `codex-security-2026-06-20` + to zero — candidate for `/write-prd` to pin its falsifiable acceptance + criterion (batch → 0 open + clean re-review, no FP-rate regression). +4. Schedule the north-star instrumentation question (open question 1) as a + discovery task before committing the metric. + +## Provenance + +This workspace was inferred from observed state, not a remembered history — see +`decisions/0001-bootstrap-from-observed-state.md`. Reversal trigger: revisit +once the human confirms the vision and the Now-bet framing. diff --git a/docs/product/decisions/0001-bootstrap-from-observed-state.md b/docs/product/decisions/0001-bootstrap-from-observed-state.md new file mode 100644 index 00000000..37a8ae04 --- /dev/null +++ b/docs/product/decisions/0001-bootstrap-from-observed-state.md @@ -0,0 +1,45 @@ +# PDR 0001 — Bootstrap the product workspace from observed state + +`Date: 2026-06-22` · `Status: Accepted` · `Decider: product-owner agent +(confirmed with john@foundryside.dev)` + +## Context + +No `docs/product/` workspace existed. The `/own-product` command branched to +BOOTSTRAP. There was no remembered product history to resume; the workspace had +to be constructed from observed reality rather than fabricated from memory. + +## What was observed + +- **Repo:** README, ROADMAP, docs/index.md — Wardline is a lightweight, opt-in, + agent-first semantic-tainting trust-boundary analyzer (Python core, Rust + preview). 1.0.6 shipped, live on PyPI, base package zero runtime deps. +- **Recorded thesis** (product memory, 2026-05-30 / 2026-06-01): enterprise-class + capability for a 1–2 dev agent-enabled team without enterprise weight; two + invariants (zero-human-config guardrail; most-powerful-version-within-it). +- **Git history:** 26 of the last 50 commits are `fix:` — a hardening campaign, + not a feature push. +- **Tracker:** dominant labels `codex-security` (×89), + `codex-security-2026-06-20` (×44), `security-finding` (×47); the single + in-progress item is a Codex security bug; 52 ready / 0 blocked. + +## The call + +Seed all five workspace artifacts from this evidence. Set the **Now** bet to +"close out the Codex security-review hardening campaign," with MCP-primary and +frictionless-surface completion as **Next**. Seed metrics with an agent-fix +success-rate north star and four guardrails (precision, soundness/surface +integrity, zero-config activation, weight discipline), all as falsifiable +`BASELINE → TARGET` placeholders pending a human-set measurement. + +The authority grant was proposed and **confirmed standard** by the human owner +in this session (autonomous within strategy; escalate releases incl. PyPI, +vision/grant changes, deprecations, pricing, data deletion, external parties). + +## Reversal trigger + +Revisit once the human confirms the vision framing and the Now-bet scope — +specifically if (a) the Now bet is not actually the Codex hardening campaign, (b) +the agent-fix-success north star is rejected in favor of precision as the +headline metric, or (c) the thesis has moved from the recorded 2026-06-01 +statement. diff --git a/docs/product/metrics.md b/docs/product/metrics.md new file mode 100644 index 00000000..adb92909 --- /dev/null +++ b/docs/product/metrics.md @@ -0,0 +1,68 @@ +# Metrics — Wardline + +> The scoreboard the product is judged against. Every target is **falsifiable**: +> a number and a date against a `BASELINE → TARGET by ` placeholder. A +> directional word ("improve precision") is not a metric. Seeded on bootstrap +> 2026-06-22 — the human owner sets the real BASELINE/TARGET numbers; the +> placeholders mark *what* to measure and *which way is good*, not confirmed +> goals. + +## North star + +**Agent-fix success rate** — of the ERROR-severity findings Wardline surfaces in +an agent's edit-verify loop, the share the agent resolves *at the boundary* such +that a rescan confirms the finding cleared, within one fix-verify cycle and +without human help. + +This is the thesis ("tools that work first time, every time") made measurable: +an analyzer the agent can actually act on, not just one that flags. It rises +only when findings are both *real* (precision) and *actionable* (explanation +points at the boundary, not the sink). + +- `BASELINE → TARGET`: `BASELINE: TBD (instrument on a dogfood corpus) → TARGET: + ≥ 0.90 by 2026-09-30` +- *Instrumentation gap:* requires a labeled corpus of findings + agent-fix + outcomes. Not yet measured. **Open question for the human owner.** + +## Guardrails (must-not-degrade) + +### G1 — False-positive rate (precision) +An analyzer that cries wolf gets turned off. The unsuppressed ERROR/HIGH +population must stay true-positive-dominant. +- `BASELINE → TARGET`: `BASELINE: TBD → keep FP rate ≤ 0.05 of active findings, + measured 2026-09-30` +- Proxy already in the repo: suppression/waiver growth vs. rule growth (a single + rule accruing disproportionate waivers signals lattice mis-design). + +### G2 — Soundness / surface integrity (no false green, no policy bypass) +Zero known fail-open taint holes (untrusted→trusted laundering) **and** zero +known agent-surface policy bypasses (MCP network/write-policy escapes, sibling +URL trust, fingerprint-suppression misapply). This is the guardrail the **Now** +bet directly serves. +- `BASELINE → TARGET`: `BASELINE: open codex-security-2026-06-20 batch (≈44 + open) → TARGET: 0 open in that batch by 2026-07-31; thereafter 0 known + fail-open/bypass holes, held continuously` +- Enforced by: the soundness oracle + the security regression suite. A new + fail-open hole is a P0. + +### G3 — Zero-config activation +`wardline scan .` runs and gates on an unconfigured repository with no required +human configuration — power arrives as activation, never as a form. +- `BASELINE → TARGET`: `BASELINE: holds at 1.0.6 (base package zero runtime + deps; scanner via one extra) → TARGET: still holds, 0 required-config steps, + re-checked each release` + +### G4 — Weight discipline (anti-enterprise-creep) +The base package stays zero-runtime-dependency; capability stays behind opt-in +extras; no enterprise-process machinery (governance boards, formal V&V, corpus +gates) enters the tool. +- `BASELINE → TARGET`: `BASELINE: base = 0 runtime deps at 1.0.6 → TARGET: base + stays 0 runtime deps; new deps only behind a named extra, re-checked each + release` + +## Notes + +- All BASELINE values marked TBD need a measurement pass before they are real + targets — flagged as open questions in `current-state.md`. +- A metric reading that crosses a guardrail is a reversal trigger for the bet + that touches it; `/product-checkpoint` flags any such crossing. diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md new file mode 100644 index 00000000..e887f7fd --- /dev/null +++ b/docs/product/roadmap.md @@ -0,0 +1,67 @@ +# Roadmap — Wardline + +> **Routing banner.** This roadmap is **intent only** — Now / Next / Later +> horizons, no dates, no WSJF scores, no sequencing. Turning a committed bet +> into a dated, sequenced, capacity-checked plan is `/axiom-program-management`; +> turning one bet into an implementation plan is `/axiom-planning`. Do not add +> dates or scores here. + +Seeded on bootstrap (2026-06-22) from observed direction: recent git history +(26 of the last 50 commits are `fix:`), the dominant tracker labels +(`codex-security` ×89, `codex-security-2026-06-20` ×44, `security-finding` ×47), +the in-progress item, and the recorded MCP-primary and frictionless-surface +programs. Treat horizon placement as a proposal for the human to confirm in +`DECIDE`. + +## Now — the current bet + +**Close out the Codex security-review hardening campaign on the shipped 1.0.x +agent surface.** A large external (Codex) security review produced ~89 findings +against the agent-facing MCP / CLI / federation surfaces (sibling-URL trust, +network-policy bypasses, rekey/provenance, fingerprint-suppression misapply, +Rust-frontend crashes). The last ~25 commits and the single in-progress ticket +(`wardline-14359d070b`) are all part of this. **Intent: drive the +`codex-security-2026-06-20` batch to zero, with each fix verified at the +boundary, before opening a new capability front.** + +- *Metric it moves:* the **soundness / surface-integrity guardrail** (no known + fail-open or policy-bypass holes on the agent surface). + +## Next — proposed, not committed + +- **MCP-primary surface program.** Make MCP the first-class agent surface, at + parity with or ahead of the CLI: structured output, where-filters + + pagination on inventory tools (the `decorator_coverage` unbounded-output + class), de-duplicated federation-status envelope, agent-first guidance docs. + Tracked under `mcp-primary-2026-06-11` (×16) and the gap tracker + `wardline-8528e67192`. + - *Metric it moves:* agent-fix success rate (north star) — a richer, + bounded, structured MCP surface is what an agent actually drives. + +- **Frictionless-surface completion (WS-C/E/F/G).** The remaining workstreams + from the frictionless-agent-surface program: delta gate, SEI-native + addressing, activation hardening / rule packs, collapse of overlapping + baseline tools. Tracked under `frictionless-surface` (×8). + - *Metric it moves:* zero-config activation guardrail + agent-fix success. + +- **Coverage expansion, as an attributed backlog.** The reviewer-named + dangerous-but-unmodelled sinks and false-negative gaps (`expansion` ×9, + `false-negative` ×9) are the roadmap for engine power — kept separate from + defect bugs. + - *Metric it moves:* north star (more real defects caught) **without** + breaching the false-positive guardrail. + +## Later — direction, not plan + +- Generative agent-extension plane: agent-authored boundary types and rules in + the shared trust grammar, inheriting the soundness invariants (the invariant-2 + "most powerful version" ceiling). +- Deeper Weft federation: dossier / SEI-native cross-tool identity once sibling + tools' contracts stabilize. +- Rust frontend beyond the command-injection preview — only if the precision bar + the Python core holds can be met. + +## Explicitly parked (see anti-goals in vision.md) + +Broad multi-language SAST, whole-program path-sensitive proving, and any +hosted/cloud service are out of scope by design, not by sequencing. diff --git a/docs/product/vision.md b/docs/product/vision.md new file mode 100644 index 00000000..5afb1427 --- /dev/null +++ b/docs/product/vision.md @@ -0,0 +1,98 @@ +# Vision — Wardline + +> Standing product vision. The authority grant at the bottom governs what the +> product-owner agent may do autonomously versus what it must escalate. This +> file is never rewritten silently — a change here is a vision change and is +> escalated to the human owner. + +## Purpose + +Wardline is a **lightweight, opt-in semantic-tainting static analyzer for trust +boundaries**. It reads code statically (never runs it) and asks one question of +every trust-annotated boundary: *is the data this function works with as trusted +as it claims?* For Python it tracks a trust level (taint) through function +bodies and the project call graph and flags where untrusted data reaches a +trusted producer with no validation between. For Rust it ships a +command-injection preview around `std::process::Command`. + +Wardline gives a coding agent and CI a **deterministic gate** for untrusted data +reaching trusted code — and surfaces each finding in terms an agent can act on +and fix *at the boundary*, not at the sink. + +## Who it serves + +- **Primary: the coding agent.** Wardline is agent-first — "humans on the loop, + not in the loop." The agent runs the scan in its edit-verify loop, reads the + explanation, and fixes the boundary. A human rarely reads the findings file by + hand. +- **The 1–2 developer team that arms that agent.** They want enterprise-class + trust-boundary analysis without enterprise-class process weight (no governance + boards, no formal V&V apparatus, no corpus-benchmark gates). +- **Their CI**, as the gate of record on the unsuppressed finding population. + +Wardline is one tool in the **Weft** suite (federation hub at `~/weft`); it +composes with Filigree (issues), Loomweave (code archaeology), and legis +(governance) under an enrich-only axiom. + +## The thesis (the filter for every decision) + +**Enterprise-class capability for a 1–2 dev agent-enabled team, without +enterprise-class weight.** Two governing invariants, as a constrained +optimization: + +1. **Plug-and-play, zero-*human*-config is the hard guardrail.** You don't + configure Wardline — you install it and it stands itself up. The + agent-calibrated instruction layer (`wardline install`) *is* the + zero-config mechanism: the tool ships pre-instructed for the agent to drive + it; the human never fills in a form. Power reaches the human as opt-in + **activation** (a switch + sane preloaded defaults), never opt-in + **configuration**. +2. **Within that guardrail, build the most powerful version of the idea.** Zero + *human* config ≠ zero config: the agent may configure — and generatively + *extend* — the environment, defining new boundary types and the rules + enforced at them, expressed in the one shared trust grammar. The human's + ceiling is a switch; the agent's ceiling is "define new abstractions." + Extensions still inherit the soundness invariants — an agent-defined boundary + the engine cannot prove yields an honest `UNKNOWN_*`, never a false green. + +> Assumption to confirm: purpose and audience above are drawn from README, +> ROADMAP, docs/index.md, and the recorded product thesis (2026-05-30 / +> 2026-06-01). They match the shipped 1.0.6 reality. Flag if the thesis has +> moved. + +## Anti-goals (what Wardline deliberately is NOT) + +- **Not a broad multi-language SAST suite.** Python-first, Rust preview only. A + small, precise, opt-in rule set beats dozens-of-rules coverage. +- **Not an exhaustive whole-program path-sensitive prover.** Deliberately L1–L2 + with an L3 project fixed point. +- **Not a hosted/cloud service.** Local-first, stays that way. +- **Not enterprise process.** No governance boards, formal V&V apparatus, or + benchmark-corpus CI gates baked into the tool. (Governance lives in legis, as + an opt-in sibling — flip it on, don't map your controls.) +- **Not noisy.** Silent until opted in; undecorated code produces no findings. + An analyzer that cries wolf gets turned off. + +## Authority grant + +`Status: CONFIRMED` · `Granted by: john@foundryside.dev` · `Last reviewed: +2026-06-22` · `Review cadence: 90 days` + +The product-owner agent acts **autonomously within strategy**: +- prioritize and reprioritize the backlog, +- write PRDs and shape bets, +- dispatch delivery work, +- accept work against its stated acceptance criteria, +- kill a failing bet. + +The agent **escalates to the human owner before** anything irreversible or +outward-facing: +- changing the vision, strategy, or this authority grant, +- a public release (including any PyPI publish) or public announcement, +- deprecating a feature users depend on, +- a pricing or commercial change, +- data deletion, +- anything touching an external party. + +A widened or narrowed grant is itself a vision change — escalate it, never edit +it silently. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index dd53607e..ab1d2837 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -59,6 +59,7 @@ Commands: rekey Re-key baseline/waiver/judge verdicts across a... scan Scan PATH for findings. scan-file-findings Run the agent workflow from scan to optionally... + scan-job Start and poll file-backed Wardline scan jobs. vocab Emit the NG-25 trust-vocabulary descriptor as YAML... ``` @@ -309,6 +310,7 @@ Options: association. --priority TEXT Filigree priority, e.g. P2. --label TEXT Label to attach (repeatable). + --lang [python|rust] --help Show this message and exit. ``` @@ -382,6 +384,7 @@ Options: --trust-pack TEXT --allow-custom-packs --strict-defaults + --lang [python|rust] --help Show this message and exit. ``` @@ -393,6 +396,27 @@ whole active set. Partial failures stay visible in the JSON: Filigree emission, per-finding promotion, unknown fingerprints, and Loomweave identity attachment are reported independently. +## `wardline fix` + +**Purpose:** scan PATH and apply autofixes interactively — Wardline proposes a +change per fixable finding and applies the ones you accept. + +```text +Usage: wardline fix [OPTIONS] [PATH] + + Scan PATH and apply autofixes interactively. + +Options: + --config FILE + -y, --yes Automatically apply all fixes without prompting. + --dry-run Print the changes that would be made without modifying files. + --help Show this message and exit. +``` + +`PATH` is the directory to scan (current directory if omitted). Use `--dry-run` +to preview the changes without touching files, or `-y`/`--yes` to apply every +fix without prompting. + ## `wardline judge` **Purpose:** run the opt-in LLM triage judge over the *active* DEFECT findings @@ -406,18 +430,28 @@ Usage: wardline judge [OPTIONS] [PATH] Triage active DEFECTs with the opt-in LLM judge. Options: - --config PATH + --config FILE --model TEXT OpenRouter model slug (overrides config). --context-lines INTEGER Excerpt radius (default 30). --max-findings INTEGER Cap findings triaged this run. --write Append FALSE_POSITIVE verdicts to .weft/wardline/judged.yaml (default: dry-run). + --trust-judge-policy Allow loading judge.policy_file from the scanned + project as untrusted judge context. + --trust-judge-config Allow project judge config to select model, + context, cap, and write confidence floor. + --trust-pack TEXT Allow importing this trust-grammar pack from + weft.toml [wardline]. May be repeated. + --allow-custom-packs Allow loading custom trust-grammar packs from the + local project directory. + --strict-defaults Ignore repository-supplied custom configuration + overrides (weft.toml). --help Show this message and exit. ``` | Option | Effect | | --- | --- | -| `--config PATH` | Path to a `weft.toml` config; its `[wardline]` table supplies the default model slug and other judge settings. The API key is **never** read from config — it comes only from the `WARDLINE_OPENROUTER_API_KEY` environment variable or a `.env` in the scan root. | +| `--config FILE` | Path to a `weft.toml` config; its `[wardline]` table supplies the default model slug and other judge settings. The API key is **never** read from config — it comes only from the `WARDLINE_OPENROUTER_API_KEY` environment variable or a `.env` in the scan root. | | `--model TEXT` | OpenRouter model slug, overriding whatever the config sets for this one run. | | `--context-lines INTEGER` | How many source lines on each side of a finding to include in the excerpt sent to the model. Default is `30`. | | `--max-findings INTEGER` | Hard cap on how many findings to triage this run — useful to bound token spend. | @@ -461,6 +495,7 @@ It takes no arguments. The output is the canonical descriptor: ```text $ wardline vocab +schema: wardline.vocabulary/v1 version: wardline-generic-2 entries: - canonical_name: external_boundary @@ -519,8 +554,16 @@ Usage: wardline baseline create [OPTIONS] [PATH] Write a new baseline from current findings (refuses if one exists). Options: - --config PATH - --help Show this message and exit. + --config FILE + --cache-dir PATH Persist L3 summary cache here for faster incremental + scans. + --trust-pack TEXT Allow importing this trust-grammar pack from weft.toml + [wardline]. May be repeated. + --allow-custom-packs Allow loading custom trust-grammar packs from the + local project directory. + --strict-defaults Ignore repository-supplied custom configuration + overrides (weft.toml). + --help Show this message and exit. ``` `PATH` is the directory to scan (current directory if omitted). `--config` @@ -548,8 +591,16 @@ Usage: wardline baseline update [OPTIONS] [PATH] Re-derive and overwrite the baseline from current findings. Options: - --config PATH - --help Show this message and exit. + --config FILE + --cache-dir PATH Persist L3 summary cache here for faster incremental + scans. + --trust-pack TEXT Allow importing this trust-grammar pack from weft.toml + [wardline]. May be repeated. + --allow-custom-packs Allow loading custom trust-grammar packs from the + local project directory. + --strict-defaults Ignore repository-supplied custom configuration + overrides (weft.toml). + --help Show this message and exit. ``` Unlike `create`, `update` expects a baseline to exist and replaces it diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index a91112a0..0781f15e 100644 --- a/docs/reference/finding-lifecycle-vocabulary.md +++ b/docs/reference/finding-lifecycle-vocabulary.md @@ -21,8 +21,8 @@ Before lifecycle state, two orthogonal axes classify every finding: | Axis | Values | Defined at | | --- | --- | --- | -| `kind` | `defect`, `fact`, `classification`, `metric`, `suggestion` | `src/wardline/core/finding.py:63-69` (`Kind`) | -| `severity` | `CRITICAL`, `ERROR`, `WARN`, `INFO`, `NONE` | `src/wardline/core/finding.py:55-60` (`Severity`) | +| `kind` | `defect`, `fact`, `classification`, `metric`, `suggestion` | `src/wardline/core/finding.py:68-73` (`Kind`) | +| `severity` | `CRITICAL`, `ERROR`, `WARN`, `INFO`, `NONE` | `src/wardline/core/finding.py:60-65` (`Severity`) | Only `Kind.DEFECT` findings are ever suppressed or gated; facts and metrics (`Severity.NONE`) never participate in the `--fail-on` gate @@ -30,12 +30,12 @@ Only `Kind.DEFECT` findings are ever suppressed or gated; facts and metrics ## The four suppression states -`SuppressionState` (`src/wardline/core/finding.py:71-75`) has exactly four +`SuppressionState` (`src/wardline/core/finding.py:76-80`) has exactly four values. Every emitted `DEFECT` carries exactly one: | State | Meaning | Set by | | --- | --- | --- | -| `active` | Not suppressed — the default. A live defect. | default (`src/wardline/core/finding.py:72`, `src/wardline/core/finding.py:107`) | +| `active` | Not suppressed — the default. A live defect. | default (`src/wardline/core/finding.py:77`, `src/wardline/core/finding.py:112`) | | `baselined` | Matched a fingerprint in `.weft/wardline/baseline.yaml`. | `src/wardline/core/suppression.py:24` | | `waived` | Matched an unexpired waiver in `.weft/wardline/waivers.yaml`. | `src/wardline/core/suppression.py:22` | | `judged` | The LLM triage judge ruled it a false positive (`.weft/wardline/judged.yaml`). | `src/wardline/core/suppression.py:23` | @@ -45,23 +45,23 @@ waiver > judged > baseline** — explicit human intent wins, then the LLM verdic (so its rationale is the visible reason), then the silent baseline. The precedence itself lives in the single JOIN predicate `resolve_identity` (`src/wardline/core/finding_identity.py`); the suppression layer maps its verdict -onto the state (`src/wardline/core/suppression.py:78-87`). +onto the state (`src/wardline/core/suppression.py:72-77`). ### The per-finding key is `suppression_state` (not `suppressed`) Each serialized finding carries its state under the key **`suppression_state`** -(`src/wardline/core/finding.py:140` in `to_jsonl`; `src/wardline/core/finding.py:295` +(`src/wardline/core/finding.py:145` in `to_jsonl`; `src/wardline/core/finding.py:305` in the Filigree `metadata.wardline.*` subtree; the agent-summary entries and the legis artifact use the same key). The key was renamed from `suppressed` → `suppression_state` (weft-f506e5f845) so the per-finding **state** never reads as the opposite of the summary's `active` **count**: `suppression_state: "active"` clearly names a state, while `summary.active` is a count of unsuppressed defects. The Filigree metadata only carries the key when the state is not `active` -(`src/wardline/core/finding.py:295`). +(`src/wardline/core/finding.py:305`). **"suppressed"** survives only as the umbrella *word* for "any state other than `active`": `baselined` + `waived` + `judged`. The CLI prints this sum as the -`suppressed` count (`src/wardline/cli/scan.py:565`). +`suppressed` count (`src/wardline/cli/scan.py:568`). ## `active` is the one word for "non-suppressed defect" @@ -72,14 +72,14 @@ consistently, on every surface: | --- | --- | --- | | Enum | `src/wardline/core/finding.py:72` | `SuppressionState.ACTIVE = "active"` | | Summary field | `src/wardline/core/run.py:71`, built at `src/wardline/core/run.py:551` | `ScanSummary.active` | -| CLI summary line | `src/wardline/cli/scan.py:566` | `… {s.active} active` | -| MCP scan response | `src/wardline/mcp/server.py:1010` | `summary.active` | -| Agent-summary JSON | `src/wardline/core/agent_summary.py:140` | `summary.active_defects` | +| CLI summary line | `src/wardline/cli/scan.py:569` | `… {s.active} active` | +| MCP scan response | `src/wardline/mcp/server.py:927` | `summary.active` | +| Agent-summary JSON | `src/wardline/core/agent_summary.py:129` | `summary.active_defects` | | `wardline:loop` prompt | `src/wardline/mcp/prompts.py:13` | "Read `summary.active`" | The agent-summary key is `active_defects` rather than bare `active` — that is a descriptive-suffix convention alongside `total_findings` / `suppressed_findings` -(`src/wardline/core/agent_summary.py:144-152`), not a different concept. It counts +(`src/wardline/core/agent_summary.py:133-141`), not a different concept. It counts the same population. The discipline test `tests/cli/test_scan_summary_vocab.py` pins this: the CLI @@ -101,9 +101,9 @@ So `active + baselined + waived + judged + informational == total` (`src/wardline/core/run.py:70` for `total: int`). `unanalyzed` (`src/wardline/core/run.py:89`) is an **overlay** — a subset of `informational` that surfaces a silent under-scan — and is deliberately **not** a partition member. -The MCP `summary` block exposes `informational` (`src/wardline/mcp/server.py:1018`) -and `unanalyzed` (`src/wardline/mcp/server.py:1022`); the agent-summary block mirrors -both (`src/wardline/core/agent_summary.py:151`, `src/wardline/core/agent_summary.py:152`). +The MCP `summary` block exposes `informational` (`src/wardline/mcp/server.py:935`) +and `unanalyzed` (`src/wardline/mcp/server.py:939`); the agent-summary block mirrors +both (`src/wardline/core/agent_summary.py:140`, `src/wardline/core/agent_summary.py:141`). ## Emitted-active vs the gate population @@ -111,19 +111,19 @@ There are **two distinct populations** of defects in one scan, and they can differ on purpose: 1. **Emitted-active** — `summary.active` counts `active` defects in the - **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:551`). + **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:550`). Baseline / waiver / judged annotate these findings in place; a suppressed defect is still emitted, just not counted as `active`. 2. **Gate population** — the `--fail-on` gate evaluates a **separate** `ScanResult.gate_findings` list: the *unsuppressed* population - (`src/wardline/core/run.py:486`). By default, repository-controlled + (`src/wardline/core/run.py:485`). By default, repository-controlled baseline / waiver / judged entries **annotate** the emitted findings but do **not** clear the gate — so a malicious PR cannot green the gate by committing a suppression keyed to its own new defect. `gate_decision` evaluates `gate_findings` when present, else falls back to `findings` (the trusted `--trust-suppressions` / directly-constructed path), selected at - `src/wardline/core/run.py:643` (`honors_suppressions`). + `src/wardline/core/run.py:642` (`honors_suppressions`). This is why **`summary.active: 0` can co-exist with `gate.tripped: true`**: every defect was suppressed by a committed baseline (so emitted-active is 0), but those @@ -150,20 +150,20 @@ trips when any file was discovered but never analysed; benign no-module skips excluded). `severity_tripped` / `unanalyzed_tripped` attribute an overall `tripped` to its sub-gate(s) so no consumer has to parse `reason`. -The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:1025`), -`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:1029`), +The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:942`), +`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:946`), `gate.severity_tripped`, `gate.unanalyzed_tripped`, `would_trip_at`, `reason`, -`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:1024` +`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:941` (`"gate": {`); the agent-summary mirrors them at -`src/wardline/core/agent_summary.py:155` (`tripped`) and -`src/wardline/core/agent_summary.py:158` (`verdict`). The CLI prints +`src/wardline/core/agent_summary.py:144` (`tripped`) and +`src/wardline/core/agent_summary.py:147` (`verdict`). The CLI prints `gate: FAILED () — ` then `gate: evaluated <…>`, or a `gate: NOT_EVALUATED — …` line for a bare scan -(`src/wardline/cli/scan.py:618`). +(`src/wardline/cli/scan.py:621`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists -(`src/wardline/core/run.py:496`, `def apply_delta_scope`). +(`src/wardline/core/run.py:495`, `def apply_delta_scope`). ## The three meanings of "new" @@ -175,7 +175,7 @@ still legitimately means three different things depending on the surface: | --- | --- | --- | | Filigree store | An **unseen fingerprint** — first time this finding identity is seen for a `(file, scan_source)`. | **Filigree-owned** lifecycle (`src/wardline/core/filigree_emit.py:68-76`) | | `wardline scan --new-since ` | **Delta-scope**: the gate fires only on defects in files/entities changed since a git ref; everything else is re-marked `baselined`. | `src/wardline/core/run.py:496`; help text `src/wardline/cli/scan.py` (`--new-since`) | -| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:565` | +| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:568` | The first-seen Filigree sense and the delta-scope `--new-since` sense are genuinely distinct concepts; neither is "active". @@ -186,19 +186,19 @@ How each concept appears on each surface: | Concept | CLI summary text | `ScanSummary` field | MCP `summary` key | Agent-summary key | Filigree store | | --- | --- | --- | --- | --- | --- | -| every finding | `N finding(s)` | `total` (`run.py:70`) | `total` (`server.py:1009`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | -| live defect | `N active` (`scan.py:566`) | `active` (`run.py:71,551`) | `active` (`server.py:1010`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | -| suppressed (sum) | `N suppressed` (`scan.py:565`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:141`) | `metadata.wardline.suppression_state` (`finding.py:295`) | -| baselined | `N baseline` | `baselined` (`run.py:73`) | `baselined` (`server.py:1011`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | -| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:1012`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | -| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:1013`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | -| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:1018`) | `informational` (`agent_summary.py:151`) | facts/metrics | -| informational (display) | n/a | n/a | n/a | `informational` display array (`agent_summary.py:176`) — non-defect, non-engine-fact findings (metrics, classifications, suggestions, non-engine facts); excludes `engine_facts` which has its own display slot | facts/metrics | -| under-scan | `N file(s) could not be analyzed` | `unanalyzed` (`run.py:89`) | `unanalyzed` (`server.py:1022`) | `unanalyzed` (`agent_summary.py:152`) | `WLN-ENGINE-*` facts | -| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:110`; `GateDecision`, `run.py:152`, `verdict` `run.py:162`) | `gate` (`server.py:1024`), `gate.tripped` (`server.py:1025`), `gate.verdict` (`server.py:1029`) | `gate.tripped` (`agent_summary.py:155`), `gate.verdict` (`agent_summary.py:158`) | not emitted to Filigree | +| every finding | `N finding(s)` | `total` (`run.py:70`) | `total` (`server.py:926`) | `total_findings` (`agent_summary.py:128`) | one finding per wire entry | +| live defect | `N active` (`scan.py:569`) | `active` (`run.py:71,551`) | `active` (`server.py:927`) | `active_defects` (`agent_summary.py:129`) | no `suppression_state` key (`finding.py:295`) | +| suppressed (sum) | `N suppressed` (`scan.py:568`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:130`) | `metadata.wardline.suppression_state` (`finding.py:295`) | +| baselined | `N baseline` | `baselined` (`run.py:73`) | `baselined` (`server.py:928`) | `baselined` (`agent_summary.py:132`) | `suppression_state: "baselined"` | +| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:929`) | `waived` (`agent_summary.py:133`) | `suppression_state: "waived"` | +| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:930`) | `judged` (`agent_summary.py:134`) | `suppression_state: "judged"` | +| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:935`) | `informational` (`agent_summary.py:140`) | facts/metrics | +| informational (display) | n/a | n/a | n/a | `informational` display array (`agent_summary.py:165`) — non-defect, non-engine-fact findings (metrics, classifications, suggestions, non-engine facts); excludes `engine_facts` which has its own display slot | facts/metrics | +| under-scan | `N file(s) could not be analyzed` | `unanalyzed` (`run.py:89`) | `unanalyzed` (`server.py:939`) | `unanalyzed` (`agent_summary.py:141`) | `WLN-ENGINE-*` facts | +| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:110`; `GateDecision`, `run.py:152`, `verdict` `run.py:162`) | `gate` (`server.py:941`), `gate.tripped` (`server.py:942`), `gate.verdict` (`server.py:946`) | `gate.tripped` (`agent_summary.py:144`), `gate.verdict` (`agent_summary.py:147`) | not emitted to Filigree | The unsuppressed gate population is built from `Baseline(frozenset())` -(`src/wardline/core/run.py:486`). +(`src/wardline/core/run.py:485`). ## For the suite diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 8fb37116..90a1d78a 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -30,19 +30,21 @@ For the matching command-line surface, see the [CLI reference](cli.md). | `dossier` | yes | — | opt | one-call entity trust dossier | | `assure` | yes | — | — | trust-surface coverage posture | | `decorator_coverage` | yes | — | opt | inventory of trust-decorated entities | -| `attest` | yes | — | — | build a signed evidence bundle | -| `verify_attestation` | yes | — | — | verify an attestation bundle | +| `attest` | yes | — | opt | build a signed evidence bundle | +| `verify_attestation` | yes | — | opt | verify an attestation bundle | | `file_finding` | yes | yes | yes | promote ONE finding to a Filigree issue | | `scan_file_findings` | yes | yes | yes | scan → emit → promote, one call | | `judge` | yes | yes | yes | opt-in LLM triage of active defects | | `baseline` | yes | yes | — | snapshot findings as the baseline | | `waiver_add` | yes | yes | — | add a time-boxed waiver for ONE finding | | `fix` | yes | yes | — | apply mechanical autofixes | -| `doctor` | yes | opt | — | install + federation health check | +| `doctor` | yes | opt | opt | install + federation health check | | `rekey` | yes | opt | opt | migrate verdicts across a fingerprint-scheme change | "opt" = the capability is exercised only when the relevant URL/flag is -configured (or, for `doctor`/`rekey`, only under the explicit write opt-in). +configured — a sibling URL (`attest`/`verify_attestation` reach Loomweave for +SEI keying; `doctor` probes a loopback Filigree for emit auth), or, for the +write column of `doctor`/`rekey`, the explicit write opt-in. --- diff --git a/docs/reference/vocabulary.md b/docs/reference/vocabulary.md index 1f756aed..a7cef2ca 100644 --- a/docs/reference/vocabulary.md +++ b/docs/reference/vocabulary.md @@ -25,6 +25,7 @@ names are also discoverable without importing Wardline at all, via `wardline vocab`, which prints: ```text +schema: wardline.vocabulary/v1 version: wardline-generic-2 entries: - canonical_name: external_boundary diff --git a/docs/superpowers/plans/2026-06-24-wardline-warpline-integration-p1.md b/docs/superpowers/plans/2026-06-24-wardline-warpline-integration-p1.md new file mode 100644 index 00000000..8aaff374 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-wardline-warpline-integration-p1.md @@ -0,0 +1,941 @@ +# Wardline ↔ Warpline Integration (Item 4, P1) 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:** Bring online the wardline-side surfaces warpline needs for (a) scoped-rescan provenance and (b) per-SEI proven-clean-at-commit reads — *without* wardline ever calling warpline or ever declaring a change clean on warpline's behalf. + +**Architecture:** Two independent capabilities on existing surfaces. (a) extends the already-shipped `--affected` delta scan's honesty block (`DeltaScopeReport`) with the *scope source* and the producer's *unverified* `generated_at` staleness proxy. (b) reframes the proposal's literal "resolved-finding read" into the property it actually names — "wardline cleared entity E at commit X" — by extending the already-shipped `attest` bundle (full-scan, commit-pinned, SEI-keyed, fail-closed 3-valued verdict) with a per-boundary `content_hash` binding key, bumped to schema `wardline-attest-2`. Warpline *pulls* the published bundle and does only mechanical `(commit, content_hash)` equality; wardline owns the trust verdict. Both are versioned cross-tool contracts. + +**Tech Stack:** Python 3.12+, stdlib-only base, pytest (`.venv/bin/pytest`), ruff, mypy --strict. JSON Schema for the MCP output contract. HMAC-SHA256 (stdlib) for attest signing. + +## Global Constraints + +- **Zero-dependency base.** Stdlib only in `core`/`mcp`; no new third-party deps. `blake3` stays an opt-in extra, lazy-imported. (verbatim from `attest.py`/`dossier.py` module docstrings) +- **wardline NEVER calls warpline.** All warpline input arrives as a *pushed, untrusted, unauthenticated* payload (`delta_scope.py:8-16`). Do not add any `warpline_*_get` MCP/HTTP call from wardline. Producer claims (`generated_at`, future `completeness`) are unverified — namespace them and never let them feed `mode`, `gate_authority`, or any verdict. +- **Fail-closed; `unknown` ≠ `clean`.** Never report unproven code as clean. The attest verdict stays 3-valued (`clean`/`defect`/`unknown`); absence-of-proof maps to warpline `risk=unavailable`, never clean. +- **INV-4 holds.** The delta filter narrows only displayed findings, never the gate population. `--affected` stays incompatible with `--fail-on`. Do not touch `gate_findings`. +- **No back-compat shims for the unreleased contract.** `wardline-attest-1` has no external consumer yet (warpline is the first, built against v2); bump cleanly to `wardline-attest-2`, no dual-version support. +- **mypy --strict and ruff clean** after every task: `.venv/bin/mypy src` and `.venv/bin/ruff check src tests`. +- **Single-branch discipline.** Do all of this on ONE branch (e.g. `feat/warpline-p1` or the active `rcX` branch); one PR to `main`; merge back when green — never orphan the branch. +- **Glossary line-anchor lock (GOTCHA).** `tests/docs/test_glossary_vocabulary.py` binds doc citations to exact line numbers in `run.py`/`scan.py`/`server.py`. Editing `run.py` (Task A3) or `server.py` (Task A4) shifts lines and may break it — if it fails, re-anchor BOTH the test's `_ANCHORS` and the cited lines in `docs/reference/finding-lifecycle-vocabulary.md`. `delta_scope.py`/`attest.py` edits are not anchored. + +--- + +## File Structure + +| File | Responsibility | Touched by | +|---|---|---| +| `src/wardline/core/delta_scope.py` | `AffectedScope` (carry `producer_generated_at`); `DeltaScopeReport` (carry `scope_source` + `producer_generated_at`) + `to_dict()` | A1, A2 | +| `src/wardline/core/run.py` | Thread `source_kind`/`generated_at` from `AffectedScope` into the `DeltaScopeReport` it builds (`~L340`, `~L564`) | A3 | +| `src/wardline/mcp/server.py` | Mirror the two new fields into the hand-maintained `_SCAN_OUTPUT_SCHEMA` scope block (`~L1398`) | A4 | +| `src/wardline/core/attest.py` | Add per-boundary `content_hash`; bump `ATTEST_SCHEMA` to `wardline-attest-2` | B1 | +| `tests/unit/core/test_delta_scope_report.py` | Unit tests for the new report fields + parser capture | A1, A2 | +| `tests/unit/core/test_run_affected.py` | Integration: `run_scan(affected=)` surfaces the new fields | A3 | +| `tests/unit/mcp/test_scan_output_schema_parity.py` (new) | Key-parity drift guard: `DeltaScopeReport.to_dict()` keys == MCP scope schema keys (80e457bc41-class) | A4 | +| `tests/unit/core/test_attest.py` | Attest unit tests (schema string + `content_hash`) | B1 | +| `docs/contracts/wardline-attest-2.md` (new) | Published consumer contract: boundary shape, commit-as-temporal-pin, `enrichment_reasons` triple, boundary rule | B2 | +| `tests/conformance/test_attest_contract_freeze.py` (new) | Freeze the attest producer shape + schema tag | B2 | +| `tests/conformance/wardline_delta_scope_contract.v1.json` (new) | Published `wardline.delta_scope.v1` producer artifact | C1 | +| `tests/conformance/test_wardline_delta_scope_contract.py` (new) | Drift-check `DeltaScopeReport.to_dict()` against the published artifact | C1 | +| `tests/conformance/test_warpline_delta_scope.py` | Extend: assert `producer_generated_at` captured from fixtures + gated live-drift marker | C2 | +| `CHANGELOG.md` | `[Unreleased] Added` entries | A4, B1 | + +--- + +## Phase A — (a) scoped-rescan provenance (UNBLOCKED, ship now) + +### Task A1: `AffectedScope` captures the producer's `generated_at` + +**Files:** +- Modify: `src/wardline/core/delta_scope.py` (`AffectedScope` ~L55-69; `_parse_worklist` ~L170-196) +- Test: `tests/unit/core/test_delta_scope_report.py` + +**Interfaces:** +- Produces: `AffectedScope.producer_generated_at: str | None` — the worklist's `data.generated_at` (unverified producer claim), `None` for a bare entity-list or when absent. + +- [ ] **Step 1: Write the failing test** + +In `tests/unit/core/test_delta_scope_report.py` add: + +```python +from wardline.core.delta_scope import parse_affected_scope + + +def test_worklist_captures_generated_at(): + payload = { + "schema": "warpline.reverify_worklist.v1", + "data": { + "generated_at": "2026-06-18T00:00:00Z", + "items": [{"entity": {"locator": "python:function:a.alpha", "sei": None}}], + }, + } + scope = parse_affected_scope(payload) + assert scope.source_kind == "reverify_worklist_v1" + assert scope.producer_generated_at == "2026-06-18T00:00:00Z" + + +def test_entity_list_has_no_generated_at(): + scope = parse_affected_scope([{"locator": "python:function:a.alpha"}]) + assert scope.source_kind == "entity_list" + assert scope.producer_generated_at is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/core/test_delta_scope_report.py::test_worklist_captures_generated_at -v` +Expected: FAIL — `AttributeError: 'AffectedScope' object has no attribute 'producer_generated_at'` + +- [ ] **Step 3: Add the field to `AffectedScope`** + +In `delta_scope.py`, extend the dataclass (keep existing docstring): + +```python +@dataclass(frozen=True, slots=True) +class AffectedScope: + entities: frozenset[AffectedEntity] + source_kind: str + item_count: int + producer_generated_at: str | None = None +``` + +- [ ] **Step 4: Capture `generated_at` in `_parse_worklist`** + +In `_parse_worklist`, immediately after the `data` mapping is validated (after the `if not isinstance(data, dict): raise ...` block), add: + +```python + generated_at = _coerce_str(data.get("generated_at")) +``` + +Then thread it into all three `AffectedScope(...)` returns in that function: + +```python + if items is None: + return AffectedScope(frozenset(), "empty", 0, producer_generated_at=generated_at) +``` +```python + if not entities: + return AffectedScope(frozenset(), "empty", len(items), producer_generated_at=generated_at) + return AffectedScope( + frozenset(entities), "reverify_worklist_v1", len(items), producer_generated_at=generated_at + ) +``` + +(`_parse_entity_list` is unchanged — a bare list carries no `generated_at`, so the `None` default applies.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/unit/core/test_delta_scope_report.py -v` +Expected: PASS (both new tests + all existing report tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/wardline/core/delta_scope.py tests/unit/core/test_delta_scope_report.py +git commit -m "feat(delta): capture warpline worklist generated_at on AffectedScope" +``` + +--- + +### Task A2: `DeltaScopeReport` carries `scope_source` + `producer_generated_at` + +**Files:** +- Modify: `src/wardline/core/delta_scope.py` (`DeltaScopeReport` ~L242-289) +- Test: `tests/unit/core/test_delta_scope_report.py` + +**Interfaces:** +- Consumes: `AffectedScope.source_kind`, `AffectedScope.producer_generated_at` (Task A1). +- Produces: `DeltaScopeReport.scope_source: str`, `DeltaScopeReport.producer_generated_at: str | None`, both in `to_dict()`. The serialized key set grows from 11 to 13. + +- [ ] **Step 1: Write the failing test** + +In `tests/unit/core/test_delta_scope_report.py` add: + +```python +from wardline.core.delta_scope import DeltaScopeReport + + +def _report(**overrides): + base = dict( + mode="delta", + gate_authority="advisory", + scope_source="reverify_worklist_v1", + entities_requested=1, + files_discovered=1, + files_analyzed=1, + in_scope_findings=0, + fell_back_count=0, + stale_sei_count=0, + unresolved_entities=(), + loomweave_used=False, + producer_generated_at="2026-06-18T00:00:00Z", + ) + base.update(overrides) + return DeltaScopeReport(**base) + + +def test_report_serializes_scope_source_and_generated_at(): + d = _report().to_dict() + assert d["scope_source"] == "reverify_worklist_v1" + assert d["producer_generated_at"] == "2026-06-18T00:00:00Z" + assert set(d) >= {"scope_source", "producer_generated_at"} + + +def test_report_generated_at_defaults_none(): + d = _report(producer_generated_at=None, scope_source="entity_list").to_dict() + assert d["producer_generated_at"] is None + assert d["scope_source"] == "entity_list" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/core/test_delta_scope_report.py::test_report_serializes_scope_source_and_generated_at -v` +Expected: FAIL — `TypeError: __init__() got an unexpected keyword argument 'scope_source'` + +- [ ] **Step 3: Add the fields + serialize them** + +In `DeltaScopeReport`, add `scope_source` among the no-default fields and `producer_generated_at` among the defaulted ones (ordering rule: defaulted fields last): + +```python +@dataclass(frozen=True, slots=True) +class DeltaScopeReport: + mode: str + gate_authority: str + scope_source: str + entities_requested: int + files_discovered: int + files_analyzed: int + in_scope_findings: int + fell_back_count: int + stale_sei_count: int + unresolved_entities: tuple[dict[str, str | None], ...] + loomweave_used: bool + producer_generated_at: str | None = None + boundary_caveat: str = field(default=BOUNDARY_CAVEAT) +``` + +Extend `to_dict()` (add the two keys; keep the rest): + +```python + return { + "mode": self.mode, + "gate_authority": self.gate_authority, + "scope_source": self.scope_source, + "entities_requested": self.entities_requested, + "files_discovered": self.files_discovered, + "files_analyzed": self.files_analyzed, + "in_scope_findings": self.in_scope_findings, + "fell_back_count": self.fell_back_count, + "stale_sei_count": self.stale_sei_count, + "unresolved_entities": [dict(e) for e in self.unresolved_entities], + "loomweave_used": self.loomweave_used, + "producer_generated_at": self.producer_generated_at, + "boundary_caveat": self.boundary_caveat, + } +``` + +Also extend the dataclass docstring with one line: `scope_source` records the parsed producer shape (`reverify_worklist_v1` / `entity_list` / `empty`); `producer_generated_at` is the worklist's UNVERIFIED `data.generated_at` staleness proxy, never wardline-vouched. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/unit/core/test_delta_scope_report.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/core/delta_scope.py tests/unit/core/test_delta_scope_report.py +git commit -m "feat(delta): add scope_source + producer_generated_at to DeltaScopeReport" +``` + +--- + +### Task A3: Thread the new fields through `run_scan` + +**Files:** +- Modify: `src/wardline/core/run.py` (locals ~L340-347; affected block ~L349-356; report build ~L562-575) +- Test: `tests/unit/core/test_run_affected.py` + +**Interfaces:** +- Consumes: `AffectedScope.source_kind`, `AffectedScope.producer_generated_at` (A1); `DeltaScopeReport(scope_source=, producer_generated_at=)` (A2). +- Produces: `ScanResult.scope.scope_source` and `ScanResult.scope.producer_generated_at` populated for every `--affected` run (delta and full-fallback). + +- [ ] **Step 1: Write the failing test** + +In `tests/unit/core/test_run_affected.py` add (mirror the file's existing `run_scan(..., affected=...)` setup; this asserts the new fields): + +```python +from wardline.core.delta_scope import parse_affected_scope + + +def test_run_scope_block_declares_source_and_generated_at(tmp_path): + (tmp_path / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8") + affected = parse_affected_scope( + { + "schema": "warpline.reverify_worklist.v1", + "data": { + "generated_at": "2026-06-18T00:00:00Z", + "items": [{"entity": {"locator": "python:function:a.alpha", "sei": None}}], + }, + } + ) + result = run_scan(tmp_path, affected=affected) + assert result.scope is not None + assert result.scope.scope_source == "reverify_worklist_v1" + assert result.scope.producer_generated_at == "2026-06-18T00:00:00Z" +``` + +(If `run_scan` is not already imported at the top of this file, add `from wardline.core.run import run_scan`.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/core/test_run_affected.py::test_run_scope_block_declares_source_and_generated_at -v` +Expected: FAIL — `AttributeError: 'DeltaScopeReport' object has no attribute 'scope_source'` is already fixed by A2, so this fails instead on `scope_source == ""` (the default local) until wired. + +- [ ] **Step 3: Add the locals** + +In `run.py`, alongside the other scope locals (~L340-347, near `scope_mode: str | None = None`), add: + +```python + scope_source: str = "" + producer_generated_at: str | None = None +``` + +- [ ] **Step 4: Populate them inside the `if affected is not None:` block** + +Immediately after `entities_requested = affected.item_count` (~L350), add: + +```python + scope_source = affected.source_kind + producer_generated_at = affected.producer_generated_at +``` + +- [ ] **Step 5: Pass them into the `DeltaScopeReport(...)` constructor** + +In the `scope = DeltaScopeReport(...)` block (~L564), add the two kwargs: + +```python + scope = DeltaScopeReport( + mode=scope_mode, + gate_authority="advisory" if scope_mode == "delta" else "gate-of-record", + scope_source=scope_source, + entities_requested=entities_requested, + files_discovered=len(files), + files_analyzed=len(analyze_files), + in_scope_findings=len(findings), + fell_back_count=fell_back_count, + stale_sei_count=stale_sei_count, + unresolved_entities=unresolved_entities, + loomweave_used=loomweave_used, + producer_generated_at=producer_generated_at, + ) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/unit/core/test_run_affected.py tests/unit/core/test_affected_invariants.py -v` +Expected: PASS (the new test + INV-1..INV-5 still green) + +- [ ] **Step 7: Verify SARIF + CLI auto-propagation and the glossary lock** + +The CLI agent-summary (`scan.py:458`), SARIF (`scan.py:343` → `core/sarif.py`), and MCP response (`server.py:1048`) all consume `result.scope.to_dict()`, so they pick up the new keys with no code change. Confirm + catch the line-anchor lock: + +Run: `.venv/bin/pytest tests/unit/cli/test_scan_affected_cli.py tests/conformance/test_warpline_delta_scope.py tests/docs/test_glossary_vocabulary.py -v` +Expected: PASS. If `test_glossary_vocabulary.py` FAILS, re-anchor its `_ANCHORS` line numbers and the citations in `docs/reference/finding-lifecycle-vocabulary.md` to the new `run.py` lines, then re-run. + +- [ ] **Step 8: Commit** + +```bash +git add src/wardline/core/run.py tests/unit/core/test_run_affected.py +git commit -m "feat(delta): thread scope_source + producer_generated_at into run_scan scope block" +``` + +--- + +### Task A4: Mirror the new fields into the MCP output schema + add the key-parity drift guard + +**Files:** +- Modify: `src/wardline/mcp/server.py` (`_SCAN_OUTPUT_SCHEMA` scope block ~L1405-1480) +- Create: `tests/unit/mcp/test_scan_output_schema_parity.py` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: `DeltaScopeReport.to_dict()` key set (A2). +- Produces: a structural invariant — the MCP scope schema's `properties` + `required` exactly equal `DeltaScopeReport.to_dict()` keys. + +- [ ] **Step 1: Write the failing parity test** + +Create `tests/unit/mcp/test_scan_output_schema_parity.py`: + +```python +"""Guard the 80e457bc41-class drift: the hand-maintained MCP scope schema must stay +key-identical to DeltaScopeReport.to_dict(). A field added to one but not the other +silently desyncs structuredContent from the payload.""" + +from __future__ import annotations + +from wardline.core.delta_scope import DeltaScopeReport +from wardline.mcp.server import _SCAN_OUTPUT_SCHEMA + + +def _sample_report() -> DeltaScopeReport: + return DeltaScopeReport( + mode="delta", + gate_authority="advisory", + scope_source="reverify_worklist_v1", + entities_requested=1, + files_discovered=1, + files_analyzed=1, + in_scope_findings=0, + fell_back_count=0, + stale_sei_count=0, + unresolved_entities=(), + loomweave_used=False, + producer_generated_at="2026-06-18T00:00:00Z", + ) + + +def test_scope_schema_properties_match_report_keys(): + report_keys = set(_sample_report().to_dict().keys()) + schema_keys = set(_SCAN_OUTPUT_SCHEMA["properties"]["scope"]["properties"].keys()) + assert schema_keys == report_keys + + +def test_scope_schema_required_matches_report_keys(): + report_keys = set(_sample_report().to_dict().keys()) + required = set(_SCAN_OUTPUT_SCHEMA["properties"]["scope"]["required"]) + assert required == report_keys +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/mcp/test_scan_output_schema_parity.py -v` +Expected: FAIL — schema is missing `scope_source` and `producer_generated_at`. + +- [ ] **Step 3: Add the two properties to the scope schema** + +In `server.py`, inside the scope block's `"properties": {...}`, add (place `scope_source` after `gate_authority`, `producer_generated_at` after `loomweave_used`): + +```python + "scope_source": { + "type": "string", + "enum": ["reverify_worklist_v1", "entity_list", "empty"], + "description": "Which producer scope shape was parsed: a warpline.reverify_worklist.v1 " + "worklist, a bare entity_list, or empty (zero usable entities). Declares the scope SOURCE.", + }, +``` +```python + "producer_generated_at": { + "type": ["string", "null"], + "description": "UNVERIFIED producer claim, echoed verbatim: the warpline worklist's " + "data.generated_at (ISO-8601), a staleness proxy. Unauthenticated and never wardline-vouched; " + "it never feeds mode, gate_authority, or any verdict. Null for a bare entity_list or when omitted.", + }, +``` + +- [ ] **Step 4: Add both keys to the scope `required` list** + +```python + "required": [ + "mode", + "gate_authority", + "scope_source", + "entities_requested", + "files_discovered", + "files_analyzed", + "in_scope_findings", + "fell_back_count", + "stale_sei_count", + "unresolved_entities", + "loomweave_used", + "producer_generated_at", + "boundary_caveat", + ], +``` + +- [ ] **Step 5: Run the parity + MCP structured-output tests** + +Run: `.venv/bin/pytest tests/unit/mcp/test_scan_output_schema_parity.py tests/unit/mcp/test_scan_affected_mcp.py tests/conformance/test_mcp_structured_output.py tests/docs/test_glossary_vocabulary.py -v` +Expected: PASS. If the glossary lock fails (server.py lines shifted), re-anchor as in Task A3 Step 7. + +- [ ] **Step 6: Add a CHANGELOG entry** + +In `CHANGELOG.md` under `## [Unreleased]` → `### Added`: + +```markdown +- Delta-scan scope block now declares its `scope_source` and echoes warpline's unverified `producer_generated_at` (staleness proxy) across CLI/SARIF/MCP; MCP scope schema is key-parity-tested against `DeltaScopeReport`. +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/wardline/mcp/server.py tests/unit/mcp/test_scan_output_schema_parity.py CHANGELOG.md +git commit -m "feat(mcp): mirror scope_source/producer_generated_at into scan schema + key-parity guard" +``` + +--- + +## Phase B — (b) per-SEI proven-clean-at-commit via `wardline-attest-2` (UNBLOCKED, ship now) + +### Task B1: Add per-boundary `content_hash` and bump the attest schema + +**Files:** +- Modify: `src/wardline/core/attest.py` (`ATTEST_SCHEMA` L62; boundary build L215-222; `_enrich_seis` L150-163) +- Modify: `tests/unit/core/test_attest.py` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: `EntityBinding.content_hash` (already populated by `SeiResolver.resolve_locator`, `identity.py:139-146`; resolved-and-discarded today in `_enrich_seis`). +- Produces: `ATTEST_SCHEMA == "wardline-attest-2"`; each `payload.boundaries[]` entry is `{qualname, sei, content_hash, verdict, tier}`. `content_hash` is `None` when no Loomweave client resolved it (honest) — whole-file blake3 granularity, never entity-span. + +- [ ] **Step 1: Write the failing tests** + +In `tests/unit/core/test_attest.py` add (the file already defines `_annotated_tree`, `_KEY`, `_PINNED`): + +```python +import types + + +class _FakeLoomweave: + """Minimal client for SEI enrichment: SEI-capable, resolves every locator to an + ALIVE binding carrying a content_hash. Satisfies the capabilities()/resolve()/ + resolve_identity()/resolve_sei() surface _enrich_seis exercises.""" + + def capabilities(self): + return {"sei": {"supported": True, "version": 1}} + + def resolve(self, qualnames, *, plugin=None): + return types.SimpleNamespace(resolved={q: f"python:function:{q}" for q in qualnames}) + + def resolve_identity(self, locator): + return { + "alive": True, + "sei": "loomweave:eid:" + "a" * 32, + "current_locator": locator, + "content_hash": "blake3:deadbeef", + } + + def resolve_sei(self, sei): + return {"alive": True} + + +def test_schema_is_attest_2(tmp_path): + bundle = build_attestation(_annotated_tree(tmp_path), _KEY, today=_PINNED) + assert bundle["schema"] == "wardline-attest-2" + + +def test_boundaries_carry_content_hash_key_without_client(tmp_path): + bundle = build_attestation(_annotated_tree(tmp_path), _KEY, today=_PINNED) + boundaries = bundle["payload"]["boundaries"] + assert boundaries # src / clean / leak are declared boundaries + for b in boundaries: + assert "content_hash" in b + assert b["content_hash"] is None # no loomweave client → honest None + + +def test_boundaries_carry_resolved_content_hash_with_client(tmp_path): + bundle = build_attestation( + _annotated_tree(tmp_path), _KEY, today=_PINNED, loomweave_client=_FakeLoomweave() + ) + clean = next(b for b in bundle["payload"]["boundaries"] if b["qualname"].endswith(".clean")) + assert clean["content_hash"] == "blake3:deadbeef" + assert clean["sei"] == "loomweave:eid:" + "a" * 32 + assert bundle["payload"]["sei_source"] == "loomweave" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/unit/core/test_attest.py::test_schema_is_attest_2 tests/unit/core/test_attest.py::test_boundaries_carry_content_hash_key_without_client -v` +Expected: FAIL — schema is `"wardline-attest-1"`; boundaries have no `content_hash` key. + +- [ ] **Step 3: Bump the schema constant** + +In `attest.py`: + +```python +ATTEST_SCHEMA = "wardline-attest-2" +``` + +- [ ] **Step 4: Add `content_hash` to the boundary dict** + +In `_build_payload`, in the boundary append (L215-222): + +```python + boundaries.append( + { + "qualname": qualname, + "sei": None, # filled by _enrich_seis behind a lazy Loomweave import + "content_hash": None, # filled by _enrich_seis from the resolved binding (whole-file blake3) + "verdict": verdict.verdict, + "tier": verdict.declared_tier, + } + ) +``` + +- [ ] **Step 5: Capture `content_hash` in `_enrich_seis`** + +In `_enrich_seis`, in the per-boundary resolved block (L156-158): + +```python + if binding is not None and binding.sei: + boundary["sei"] = binding.sei + boundary["content_hash"] = binding.content_hash + resolved_any = True +``` + +(`binding.content_hash` may be `None` even when the SEI resolves — that is honest; do not synthesize one.) + +- [ ] **Step 6: Update remaining hardcoded `wardline-attest-1` references** + +Run: `rg -n "wardline-attest-1" src tests docs` +For each hit (existing assertions, docstrings, CHANGELOG, the `verify_attestation` cross-schema test if any), update to `wardline-attest-2`. The `_sign`/`verify_attestation` logic needs no change — they read `ATTEST_SCHEMA`; a `wardline-attest-1` bundle now correctly reports `signature_valid=False` (clean break, no external consumer). + +- [ ] **Step 7: Run the full attest suite** + +Run: `.venv/bin/pytest tests/unit/core/test_attest.py tests/conformance -k attest -v` +Expected: PASS. Reproduction tests stay green (re-derivation now includes `content_hash` on both sides). If any test pins exact boundary bytes/shape, update its expectation to include `content_hash`. + +- [ ] **Step 8: Add a CHANGELOG entry** + +In `CHANGELOG.md` under `## [Unreleased]`: + +```markdown +### Changed +- **BREAKING (unreleased contract):** attest bundle schema bumped `wardline-attest-1` → `wardline-attest-2`; each boundary now carries `content_hash` (whole-file blake3 binding key, null when unresolved). `wardline-attest-1` bundles no longer verify. +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/wardline/core/attest.py tests/unit/core/test_attest.py CHANGELOG.md +git commit -m "feat(attest): add per-boundary content_hash; bump schema to wardline-attest-2" +``` + +--- + +### Task B2: Publish the `wardline-attest-2` consumer contract + freeze the producer shape + +**Files:** +- Create: `docs/contracts/wardline-attest-2.md` +- Create: `tests/conformance/test_attest_contract_freeze.py` + +**Interfaces:** +- Consumes: the attest bundle shape from B1. +- Produces: a frozen producer contract (boundary keys + schema tag) and the documented consumer rules (commit-as-temporal-pin; `enrichment_reasons` triple; the boundary rule that warpline never declares clean). + +- [ ] **Step 1: Write the freeze test (failing on the doc absence is fine; assert the shape)** + +Create `tests/conformance/test_attest_contract_freeze.py`: + +```python +"""Freeze the wardline-attest-2 PRODUCER contract: the boundary key set and schema tag +warpline's risk-as-verification consumer keys on. A change here is a deliberate contract +bump (and must update docs/contracts/wardline-attest-2.md + warpline's consumer).""" + +from __future__ import annotations + +from pathlib import Path + +from wardline.core.attest import ATTEST_SCHEMA, build_attestation + +_KEY = "0" * 64 + +_MODULE = ( + "from wardline.decorators.trust import trusted, external_boundary\n" + "@external_boundary\n" + "def src():\n" + " return object()\n" + "@trusted(level='INTEGRAL')\n" + "def clean():\n" + " return 1\n" +) + +_FROZEN_BOUNDARY_KEYS = {"qualname", "sei", "content_hash", "verdict", "tier"} +_FROZEN_VERDICTS = {"clean", "defect", "unknown"} + + +def test_attest_schema_tag_frozen(): + assert ATTEST_SCHEMA == "wardline-attest-2" + + +def test_boundary_shape_frozen(tmp_path): + from datetime import date + + (tmp_path / "m.py").write_text(_MODULE, encoding="utf-8") + bundle = build_attestation(tmp_path, _KEY, today=date(2026, 6, 24)) + for b in bundle["payload"]["boundaries"]: + assert set(b.keys()) == _FROZEN_BOUNDARY_KEYS + assert b["verdict"] in _FROZEN_VERDICTS + + +def test_consumer_contract_doc_exists(): + doc = Path(__file__).resolve().parents[2] / "docs" / "contracts" / "wardline-attest-2.md" + assert doc.is_file(), "publish the wardline-attest-2 consumer contract" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/conformance/test_attest_contract_freeze.py -v` +Expected: FAIL on `test_consumer_contract_doc_exists` (doc not yet written); the shape tests PASS (B1 done). + +- [ ] **Step 3: Write the consumer contract doc** + +Create `docs/contracts/wardline-attest-2.md`: + +```markdown +# Contract: `wardline-attest-2` (producer: wardline · consumer: warpline) + +Wardline publishes a signed, full-scan, commit-pinned attest bundle. Warpline's +risk-as-verification ("Rung 2") consumes it to decide whether an entity was *proven +clean at a commit*. **Wardline is the trust authority; warpline never declares clean.** + +## Bundle shape (verbatim) + +`payload.boundaries[]`: `{qualname, sei, content_hash, verdict, tier}` +- `verdict` ∈ `{clean, defect, unknown}` — fail-closed 3-valued. `unknown` (undeclared / + under-scanned / unprovable) is **never** `clean`. +- `sei`: opaque Loomweave SEI, or `null` when no Loomweave client resolved it. +- `content_hash`: whole-file blake3 binding key, or `null` when unresolved. **File + granularity, not entity-span** — do not key on it as entity-precise. +- `payload.commit`: the git HEAD the full scan ran against (`dirty` refused at build). +- `payload.attested_at`: the BUILD date (analysis freshness) — **NOT** a resolution time. + +## Consumer rules (warpline) + +1. **Temporal pin is `commit`** (+ `content_hash`), never `attested_at`. To claim + "proven clean at commit X", match `payload.commit == X` AND the entity's current + `content_hash` byte-equals the boundary's. This is a mechanical equality check, not a + trust judgement. +2. **Only `verdict == "clean"` AND a matched `(commit, content_hash)` → proven-good.** + Anything else → `risk=unavailable`. +3. **`enrichment_reasons` triple** — the three codes warpline reports when it cannot + assert proven-good: + - `not_attested` — no bundle for this commit (absent / commit mismatch). + - `sei_unkeyed` — bundle present but `sei_source == "unavailable"`, so no boundary + matches this SEI. + - `verdict_unknown` — entity SEI-matched but `verdict == "unknown"`. +4. **Signature caveat:** HMAC-SHA256 with a shared project key is tamper-evidence within + a key-holding domain, NOT non-repudiable proof of *who* produced the bundle. + +## Versioning + +A change to the boundary key set or `verdict` vocabulary is a schema bump (e.g. +`wardline-attest-3`) and must update this doc, `test_attest_contract_freeze.py`, and +warpline's consumer. Tracked under `wardline-c0563eee74`. +``` + +- [ ] **Step 4: Run the freeze test to verify it passes** + +Run: `.venv/bin/pytest tests/conformance/test_attest_contract_freeze.py -v` +Expected: PASS (all three). + +- [ ] **Step 5: Commit** + +```bash +git add docs/contracts/wardline-attest-2.md tests/conformance/test_attest_contract_freeze.py +git commit -m "docs(contract): publish wardline-attest-2 consumer contract + freeze test" +``` + +--- + +## Phase C — Published versioned contracts + drift checks (overlaps `wardline-c0563eee74`) + +> These close the cross-tool contract-integrity gap so warpline can build on stable, +> drift-checked artifacts. They also satisfy `wardline-c0563eee74`'s "publish +> `wardline.delta_scope.v1`" and "verify the worklist consumer vs a published artifact" +> acceptance — track the work there. + +### Task C1: Publish + freeze the `wardline.delta_scope.v1` producer artifact + +**Files:** +- Create: `tests/conformance/wardline_delta_scope_contract.v1.json` +- Create: `tests/conformance/test_wardline_delta_scope_contract.py` + +**Interfaces:** +- Consumes: `DeltaScopeReport.to_dict()` (A2). +- Produces: a versioned, drift-checked producer artifact (mirrors `filigree_suppression_filter_contract.json`). + +- [ ] **Step 1: Write the drift test** + +Create `tests/conformance/test_wardline_delta_scope_contract.py`: + +```python +"""Drift-check DeltaScopeReport.to_dict() against the published wardline.delta_scope.v1 +contract. A new/removed field here is a deliberate contract change — bump the artifact.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from wardline.core.delta_scope import DeltaScopeReport + +_CONTRACT = Path(__file__).resolve().parent / "wardline_delta_scope_contract.v1.json" + + +def _sample() -> dict: + return DeltaScopeReport( + mode="delta", + gate_authority="advisory", + scope_source="reverify_worklist_v1", + entities_requested=1, + files_discovered=1, + files_analyzed=1, + in_scope_findings=0, + fell_back_count=0, + stale_sei_count=0, + unresolved_entities=(), + loomweave_used=False, + producer_generated_at="2026-06-18T00:00:00Z", + ).to_dict() + + +def test_delta_scope_matches_published_contract(): + contract = json.loads(_CONTRACT.read_text(encoding="utf-8")) + assert contract["schema"] == "wardline.delta_scope.v1" + assert set(_sample().keys()) == set(contract["fields"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/conformance/test_wardline_delta_scope_contract.py -v` +Expected: FAIL — contract file missing. + +- [ ] **Step 3: Publish the contract artifact** + +Create `tests/conformance/wardline_delta_scope_contract.v1.json`: + +```json +{ + "schema": "wardline.delta_scope.v1", + "description": "The --affected delta-scan honesty/provenance block (DeltaScopeReport.to_dict()). Producer: wardline. gate_authority='advisory' in delta mode is never a gate-of-record pass. producer_generated_at is an UNVERIFIED warpline claim.", + "fields": [ + "mode", + "gate_authority", + "scope_source", + "entities_requested", + "files_discovered", + "files_analyzed", + "in_scope_findings", + "fell_back_count", + "stale_sei_count", + "unresolved_entities", + "loomweave_used", + "producer_generated_at", + "boundary_caveat" + ] +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/conformance/test_wardline_delta_scope_contract.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/conformance/wardline_delta_scope_contract.v1.json tests/conformance/test_wardline_delta_scope_contract.py +git commit -m "feat(contract): publish + drift-check wardline.delta_scope.v1 artifact" +``` + +--- + +### Task C2: Strengthen the `warpline.reverify_worklist.v1` consumer drift check + +**Files:** +- Modify: `tests/conformance/test_warpline_delta_scope.py` + +**Interfaces:** +- Consumes: the vendored `tests/conformance/fixtures/warpline_delta/*.v1.json` fixtures + `parse_affected_scope` (A1). +- Produces: a hermetic assertion that the consumer captures `generated_at`, plus a gated marker for verifying against warpline's *published* artifact (mirrors the SEI oracle's `LOOMWEAVE_REPO` gating). + +- [ ] **Step 1: Write the failing assertion** + +In `tests/conformance/test_warpline_delta_scope.py` add: + +```python +import json +import os +from pathlib import Path + +import pytest + +from wardline.core.delta_scope import parse_affected_scope + +_FIXTURES = Path(__file__).resolve().parent / "fixtures" / "warpline_delta" + + +def test_consumer_captures_worklist_generated_at(): + payload = json.loads((_FIXTURES / "worklist_alpha.v1.json").read_text(encoding="utf-8")) + scope = parse_affected_scope(payload) + assert scope.source_kind == "reverify_worklist_v1" + assert scope.producer_generated_at == "2026-06-18T00:00:00Z" + + +@pytest.mark.skipif( + not os.environ.get("WARPLINE_REPO"), + reason="set WARPLINE_REPO to drift-check the vendored fixtures vs warpline's published " + "warpline.reverify_worklist.v1 artifact (gated on warpline publishing it)", +) +def test_vendored_worklist_matches_published_artifact(): + published = Path(os.environ["WARPLINE_REPO"]) / "contracts" / "reverify_worklist.v1.schema.json" + assert published.is_file(), "warpline has not published the worklist contract artifact yet" + # When warpline publishes, assert each vendored fixture validates against `published`. + # Until then this is the documented integration point (skips clean). +``` + +- [ ] **Step 2: Run test to verify it fails then passes the hermetic part** + +Run: `.venv/bin/pytest tests/conformance/test_warpline_delta_scope.py::test_consumer_captures_worklist_generated_at -v` +Expected: PASS (A1 shipped). The gated test SKIPS without `WARPLINE_REPO`. + +- [ ] **Step 3: Commit** + +```bash +git add tests/conformance/test_warpline_delta_scope.py +git commit -m "test(contract): assert worklist generated_at capture + gated published-artifact drift marker" +``` + +--- + +## Phase D — Deferred / Out of scope (documented, not implemented) + +These are intentionally NOT built now. Each names its trigger so it can be picked up later. + +- **D1 — warpline declared `completeness` propagation (GATED on warpline).** The proposal's + acceptance asks wardline to declare warpline's *completeness*. No `completeness` field + exists in `warpline.reverify_worklist.v1` today. **Do not add a wardline-side placeholder + with a default** — emit absence; warpline reports `risk=unavailable(completeness_not_declared)` + at its own layer. **Trigger:** warpline publishes a `completeness` field in the worklist + contract. Then mirror A1/A2/A4 for one more field (`producer_completeness`). +- **D2 — `warpline.impact_radius.v1` / `blast_radius` consumption (REJECTED).** The schema + does not exist, and "Read `warpline_impact_radius_get`" violates the never-call invariant. + The worklist already carries `depth`/`why`/`enrichment` that the parser drops. **If a + blast-radius signal is wanted later,** un-drop those existing worklist fields inside the + current consumer (a pushed shape), rather than standing up a second schema + parser + DoS + caps + drift contract. +- **D3 — live-pull of warpline (CONSTRAINT CONFLICT, confirm before any build).** If the + requester genuinely wants wardline to *call* `warpline_*_get`, that reverses the shipped + `delta_scope.py:8-16` invariant and adds a liveness/SSRF/trust surface. Surface it as a + decision, do not implement silently. + +--- + +## Self-Review + +**Spec coverage (against the evaluation):** +- (a) declare scope source → A2/A3/A4 (`scope_source`). ✓ +- (a) declare warpline staleness → A1/A3 (`producer_generated_at`). ✓ +- (a) completeness → D1 (gated, correctly deferred). ✓ +- (a) impact_radius → D2 (rejected with rationale). ✓ +- (a) full scan stays authoritative → unchanged; INV-4 + `--affected`/`--fail-on` rejection asserted green (A3 Step 6). ✓ +- (b) per-SEI clean-at-commit via attest, not Finding lifecycle → B1 (`content_hash`, schema bump). ✓ +- (b) timestamp = commit pin, not build date → documented in B2 contract. ✓ +- (b) `enrichment_reasons` triple → defined in B2. ✓ +- (b) 3-valued fidelity / boundary → frozen in B2 (`_FROZEN_VERDICTS`) + contract rules. ✓ +- Versioned, drift-checked contracts → C1 (delta_scope.v1), C2 (worklist consumer), B2 (attest-2). ✓ +- Security guards: GUARD-1 (clean only from full attest, never delta) — attest runs full by construction; GUARD-2 (content_hash binding) — B1; GUARD-3 (stale/orphaned can't transfer) — warpline equality check on `content_hash`, documented B2. ✓ + +**Placeholder scan:** every code/test step contains complete, runnable code; no TBD/"similar to". ✓ + +**Type consistency:** field names are identical across tasks — `producer_generated_at` (A1 `AffectedScope` → A2 `DeltaScopeReport` → A3 `run_scan` → A4 schema/required → C1 contract), `scope_source` (A2→A3→A4→C1), `content_hash` (B1→B2 frozen key set). The `_FROZEN_BOUNDARY_KEYS` in B2 == the boundary dict built in B1. ✓ diff --git a/docs/superpowers/plans/2026-06-24-weft-seam-conformance-program.md b/docs/superpowers/plans/2026-06-24-weft-seam-conformance-program.md new file mode 100644 index 00000000..3949a3c9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-weft-seam-conformance-program.md @@ -0,0 +1,509 @@ +# Weft-Seam Conformance Program — executable plan (merge & publish) + +> **Status:** PLAN — awaiting approval. No execution (no merges, no publishes) until signed off. +> **Date:** 2026-06-24 +> **Provenance:** produced by a throttled (4-wide) multi-agent audit across the five Loom repos +> + warpline. 40 seams discovered; skeptically audited against the SEI bar; reusable kit designed; +> plan synthesized and adversarially reviewed (24 findings, 8 blocking — folded into this final). +> **Companion artifact:** the reusable recipe lives in +> `docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md` (§0–§8). +> +> **Headline finding:** of 40 weft seams, only **1** (`Wardline→legis scan-artifact wire`, G1) +> is fully at the SEI bar today. The rest: ~24 partial, ~15 gap. "All interfaces between peers" +> is therefore a real program, not a polish pass. + +## Thesis + +Of the 40 weft seams that connect the Loom peers, exactly **one** — the `Wardline→legis scan-artifact wire` — clears the SEI conformance bar today. The other 39 are partial or gap: a contract that lives in prose, an oracle only one side runs, a drift check that skips clean in CI. This program brings every seam to that bar and lands the result **merged and published**. + +**The bar** is the kit's §6: a seam is *at the bar* only when it has a frozen, machine-checkable contract (item 2), an oracle that asserts the live wire (item 5), and a CI gate that fails closed (item 10) — plus, for two-sided seams, one shared corpus both peers load behind a two-layer drift alarm (item 8). The reusable recipe for getting there is the companion `2026-06-24-weft-seam-conformance-kit.md` (§0–§8). + +**The approach** is four movements: + +- **P0 — make the kit enforceable.** A CI-gated `tests/conformance/seam_registry.json` + `test_seam_registry.py` whose `bar_verdict` enum (`gap|partial|at_bar|deferred|one_sided_na`) cannot claim `at_bar` unless the contract, oracle, and a *fail-closed-armable* marker actually exist — so the registry cannot lie and an in-flight seam has an honest state. +- **P1 — land the structural substrate** the kit depends on: the shared `WeftHttp` transport (`wardline-18499aaa2d`) and the federation-status envelope dedup (`wardline-80e457bc41`). +- **P2–P7 — walk the below-bar seams in descending drift-blast-radius order:** released, tightly-coupled wires first (a broken released wire is already in production); the greenfield Charter (5th member) and warpline seams last, where clean breaks are free. +- **P8 — verify and ship:** confirm fail-closed CI everywhere, fix the two source-confirmed gate gaps, populate the canonical hub seam index from the gated registry, publish docs, and cut coordinated releases. + +**Released seams never take a clean break.** Each workstream carries a `wire_change` classification (none/additive/breaking) that *derives* its cross-repo landing protocol, and a tag-time **cross-repo publish guard** fails any release whose peer is still on the old wire — so the producer/consumer skew window is closed by a mechanism, not by intent. SEI itself is the untouched gold reference; its only remaining work is two consumer-side byte-pin closures. `rcX` is a **wardline** house convention, not a federation-wide rule — each peer lands on its own native branch. + +## Sequencing rationale + +Macro-spine dictated by requirements: P0 (gated registry; hub is a non-blocking parallel track) -> substrate -> per-seam (blast-radius order) -> cross-cutting verification/release. Per-seam ordering: strategies B (criticality-first) and C (hardest-coupled-first) CONVERGE because released-status and coupling-degree point the same way — the released wire seams are exactly the tight two-way couplings. Strategy A ('extract the cleaner transport first, everything rides it') is REJECTED: the audits prove the transport is wire-invariant and the canonicalizations only accidentally interoperate (VERIFIED in source: legis weft_signing.py uses ensure_ascii=True while legis canonical.py uses ensure_ascii=False and its docstring names the latter the byte-for-byte HMAC contract — freezing the prettier code would bake a latent break for the first non-ASCII or reordered-key body), so risk #4's round-trip-through-both-impls gate is wired into every HMAC/signature/canonical-JSON workstream as a hard exit-criterion. DEPENDENCY DISCIPLINE: hard depends_on is reserved for genuine artifact/constant reuse (P6-W3 reuses P4-W1 fixtures; P7-W1/P5-W2 reuse the finding-identity vector; P7-W3 reuses the P6-W1 legis primitive); 'substrate underpins plane' relationships are SOFT ordering (confidence sequence), so the P3 data plane runs in PARALLEL with the P2 identity substrate (P2 is consumer-side test-harness fixture pins touching no production identity code; the taint oracle asserts bytes against already-released-and-unchanged production). This unblocks parallelism and shortens the critical path. rcX BRANCH MECHANICS are wardline-only (a wardline house directive, memory: feedback_single_rc_branch_no_scatter); peers use their actual branching (VERIFIED: loomweave=docs/language-support-coverage, legis=warpline-interfaces, filigree=feat/warpline-commit-anchor, charter=main, warpline=main — none use rcX), described per-workstream as feature-branch -> PR -> main, with an open_question to confirm each peer maintainer's model. RELEASED-SEAM LANDING is governed by a DERIVED rule, not intent: every workstream carries wire_change (none|additive|breaking); none/additive (gate-only additions; wire unchanged) are safe either order behind the shared-vector PR gate; the one breaking seam (P7-W2 attest-2 v1->v2) carries a hard consumer-dual-accept-first sequencing exit-criterion and a tag-gate on the consumer release being published. Charter is last and empirically verified-last (no released peer references charter; charter's service.py consumes the four peers). P0's SOLE blocking gate is the wardline-internal registry+test; hub creation (~/loom absent, owner undecided) is non-blocking. P7's two cross-team decisions are promoted to named decision gates with owners and stated defaults so P7 cannot deadlock and never blocks closeout — if unresolved at deadline, P0-P6 ship as a milestone and the affected P7 workstream splits to a follow-on. + +## Seam registry (target state) + +| Seam | Peers | Now | Target | +|---|---|---|---| +| SEI conformance (loomweave -> wardline) | loomweave↔wardline | partial -- gold-core but vendored-fixture byte-pin absent; drift check skips-clean | at_bar via P2-W1 (UPSTREAM_BLOB_SHA byte-pin + sei_drift live recheck; §6 items 2+5+10+8) | +| SEI conformance (loomweave -> legis) | loomweave↔legis | partial -- same byte-pin gap on legis side | at_bar via P2-W2 | +| SEI consumer (loomweave -> charter) | loomweave↔charter | gap -- prose contract, dangling ~/loom/sei-standard.md (never populated), no CI | at_bar via P7-W4 | +| Filigree entity-associations -> loomweave drift-consumer | filigree↔loomweave | partial -- live v1-vs-v3 fixture drift, no cross-repo byte-pin | at_bar via P4-W4 | +| loomweave HTTP Read API | loomweave↔filigree | partial (audit downgrades registry 'gold') -- consumer asserts self-authored mock, missing linkages field undetected | at_bar via P4-W1 | +| loomweave HMAC/Bearer inbound auth | loomweave↔legis | partial -- prose+parallel-code contract, no shared vector, one-sided oracle | at_bar via P4-W2 | +| loomweave->legis SEI HTTP-transport wire | loomweave↔legis | partial -- prose transport contract, no shared HTTP-wire corpus, live oracle skip-clean | at_bar via P4-W3 | +| Wardline->loomweave taint-fact store | wardline↔loomweave | partial -- no shared wire corpus, cross-impl live oracle weekly-only | at_bar via P3-W1 | +| Wardline qualname normalization (Python axis) | wardline↔loomweave | partial -- Rust axis gold, Python axis lacks drift alarm; false provenance docstring | at_bar via P3-W2 | +| Wardline->legis scan-artifact wire (G1) | wardline↔legis | at_bar (audit ran the tests; §6 items 2+5+10+8 all met) | FINE AS-IS -- verify only; the risk-#4 round-trip gate is asserted here too as a regression guard | +| Wardline->Filigree scan-results emission/intake | wardline↔filigree | partial -- no PR-gated producer oracle, no shared corpus/byte-pin | at_bar via P5-W1 | +| Wardline suppression-state filter vocabulary | wardline↔filigree | partial -- manually-synced mirrors, no byte-pin | at_bar via P5-W3 | +| Finding identity & wire contract | wardline↔filigree | partial (audit downgrades registry 'gold') -- no shared corpus, no drift alarm on identity wire | at_bar via P5-W2 | +| Vocabulary descriptor (trust-vocab) | wardline↔loomweave | gap (audit downgrades registry 'gold') -- consumer hand-copies + ignores schema axis; consumer CI not fail-closed | at_bar via P5-W5 | +| Weft canonical reason-vocabulary | weft-hub↔filigree/legis/wardline | partial (audit downgrades registry 'gold') -- 3 hand-typed copies, hub has no CI, skip-clean cross-check, no hub-contract release discipline | at_bar via P5-W4 | +| WEFT_FEDERATION_TOKEN bearer auth | filigree↔wardline | partial -- no consumer 401 oracle, no shared token-wire vector | at_bar via P2-W3 | +| Filigree ephemeral-port discovery | filigree↔loomweave/wardline | partial (audit downgrades registry 'gold') -- prose-only, one-sided oracle, cross-consumer legacy-path divergence | at_bar via P4-W5 | +| loomweave->Filigree scan-results intake (analyze Phase 8) | loomweave↔filigree | partial -- independent mirrors, no shared corpus/drift alarm | at_bar via P6-W3 | +| loomweave->Filigree issue-detail enrichment | loomweave↔filigree | partial -- shape test only, no shared corpus | at_bar via P6-W3 | +| loomweave->Filigree Flow-B reconciliation | loomweave↔filigree | gap -- self-authored mock, prose contract, deferred resolve oracle | at_bar via P6-W3 | +| loomweave->Filigree clean-stale sweep | loomweave↔filigree | partial -- independent shape mirrors, no drift alarm | at_bar via P6-W3 | +| legis git-rename provider for loomweave | legis↔loomweave | partial -- two parallel parser re-impls, no shared vector, half-wired feed | at_bar via P6-W2 | +| legis->Filigree governed sign-off binding | legis↔filigree | partial -- no shared sign-off-wire fixture, live oracle skip-clean, legis has NO fail-closed primitive to mirror | at_bar via P6-W1 (also builds LEGIS_LIVE_ORACLE_REQUIRED) | +| Warpline reverify-worklist | warpline↔wardline/filigree | partial -- no published schema, hand-rolled copies already drifted, no PR-gated wire oracle | at_bar via P7-W1 (maps wardline-c0563eee74) | +| Wardline->warpline attest (wardline-attest-2) | wardline↔warpline | gap -- unpublished, no contract doc, no freeze test, no CI gate, consumer absent; the ONE breaking seam | at_bar via P7-W2 (consumer dual-accept-first; maps wardline-c0563eee74) | +| legis->warpline preflight advisory read | legis↔warpline | gap -- pre-impl, transport disagreement (warpline has no HTTP server) | at_bar via P7-W3 (Decision Gate A; default = consume warpline MCP/golden-vector) | +| legis per-SEI attestation_get for warpline | legis↔warpline | gap -- pre-impl, no MCP tool, no oracle | at_bar via P7-W3 | +| Charter->legis preflight-facts envelope (weft.charter.preflight_facts.v1) | charter↔legis | gap -- prose ADR-006, no impl on either side, no CI | at_bar via P7-W3 (explicitly owned there; the 'if folded' hedge removed) | +| Charter->Filigree issue/work traceability | charter↔filigree | gap -- prose ontology, no CI, fixtures planned-not-implemented | at_bar via P7-W4 | +| Charter->Wardline finding violations | charter↔wardline | gap -- ontology only, no integration code, no CI | at_bar via P7-W4 | +| Charter requirement identity (charter:req:*) | charter↔filigree/loomweave | gap (audit downgrades registry 'gold') -- f-string mint + static fixture, no CI, no schema | at_bar via P7-W4 | +| Charter JSON envelope + trace-link ontology | charter↔filigree/loomweave | gap (audit downgrades registry 'gold') -- prose+assert, no JSON Schema, no CI, one-sided | at_bar via P7-W4 | +| Charter requirement-dossier peer_facts | charter↔loomweave | partial -- producer stub oracle only, no CI, no consumer | at_bar via P7-W4 | +| Charter<->Filigree shared actor/attestation registry | charter↔filigree | gap -- frozensets+prose, no CI, no consumer, no shared format contract | at_bar via P7-W4 | +| Charter<->peers MCP read-only inventory co-registration | charter↔filigree | partial -- frozen fixture + name oracle but no CI, no wire-replay, no consumer | at_bar via P7-W4 | +| Wardline MCP tool/resource contracts (B1/B2) | wardline↔filigree | partial -- circular oracle (validates output against the same in-process schema), no frozen golden | at_bar via P5-W6 (freeze _SCAN_OUTPUT_SCHEMA golden + break circularity); producer-only -> §6 item 8 one_sided_na | +| weft.toml shared federation config contract | wardline↔filigree | deferred -- DRAFT schema, cross-read reserved-not-implemented on BOTH sides, no oracle/byte-pin/CI | DEFERRED (registry bar_verdict=deferred, deferred_reason enforced): hub schema (loomweave-009, four open questions) must merge AND a peer must ship the cross-reader before it can be oracled; the registry row reds until re-triaged | +| loomweave.yaml federation configuration | loomweave↔loomweave | partial -- single-reader; deny_unknown_fields + round-trip oracle + fail-closed CI met; lacks a frozen serialized golden | one_sided_na for §6 item 8 (single-reader); FINE AS-IS; optional one-line close: byte-pinned serialized golden of the default config; recorded as an enforced one_sided_na registry row | +| Wardline federation status envelope | wardline↔wardline | gap -- 3 divergent surface projectors, no parity gate | at_bar via P1-W2 (dedup + parity oracle + registry-flip in the same PR; maps wardline-80e457bc41); §6 item 8 one_sided_na | +| Wardline shared HTTP transport (WeftHttp) | wardline↔wardline | gap -- 3 hand-rolled urllib clients (wire-invariant internal debt) | at_bar via P1-W1 (maps wardline-18499aaa2d); §6 item 8 one_sided_na (not a cross-repo wire) | + +## Conformance kit (P0 keystone) + +The Weft Seam Conformance Kit (docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md, 482 lines) is organized as sections §0-§8 with a §6 'Per-seam checklist' that is a NUMBERED LIST of items 1-14 (this plan cites the spec's real numbering; it does NOT invent a parallel 'C0-C13' scheme). Per §6 verbatim: 'A seam is AT THE BAR only when items 2, 5, 10 exist (contract + oracle + fail-closed CI) and — for two-sided — item 8 (shared corpus + drift alarm). Anything less is BELOW the bar regardless of how much prose documentation the seam carries.' So at-bar = §6 item 2 (a frozen machine-checkable CONTRACT artifact, not prose; §2) + §6 item 5 (an ORACLE that asserts the LIVE wire, picking a shape by seam type; §3) + §6 item 10 (a CI gate that FAILS CLOSED; §5), plus §6 item 8 (ONE shared corpus both peers load + the §4 two-layer drift alarm) for two-sided seams. The remaining §6 items (1 name-authority, 3 _capture.py, 4 regen.py, 6 non-vacuity guards, 7 determinism, 9 marker-registration-in-three-places, 11 conftest diff dump, 12 CODEOWNERS, 13 release ride-along, 14 register-the-seam) are the supporting machinery that makes items 2/5/10/8 real and accountable; this plan wires them per workstream rather than over-scoping every exit criterion to the full sweep. Three oracle shapes (§3) route by seam type: byte-frozen golden corpus (§3a, one-sided engine surface), shared signed vector (§3b, two-sided cross-repo wire), scenario oracle (§3b-bis, capability negotiation). The §3c keystone is per-invariant non-vacuity: for every invariant the oracle asserts, a fixture must FAIL if that one invariant breaks. Default every seam BELOW the bar until each applicable item is SEEN in source — a vendored mirror, a vacuous corpus, or a skip-clean oracle all LOOK conformant but are not. + +**Required artifacts:** +- tests/conformance/seam_registry.json (row schema = §8 tuple + bar_verdict enum {gap,partial,at_bar,deferred,one_sided_na} + deferred_reason + wire_change) + test_seam_registry.py (the gated registry, P0; enforces per-verdict artifact requirements AND that any drift-alarm marker is present in LIVE_ORACLE_MARKERS) +- per-seam: the CONTRACT artifact (§6 item 2) — corpus/*.json+META.json | _wire.golden.json | fixtures/-conformance-oracle.json +- _capture.py canonicalizer (§6 item 3; reuses the production serializer; total-order sorts; STRICT default that RAISES) +- regen.py (§6 item 4; --reason; the only sanctioned writer; stamps META.json) +- the ORACLE (§6 item 5) + non-vacuity guards (§6 item 6) + determinism proof (§6 item 7) +- (two-sided) single shared vendored corpus + UPSTREAM_BLOB_SHA constant + Layer-2 _drift test (§6 item 8); the signed/canonical components round-trip through BOTH impls incl. non-ASCII + reordered-key before the pin (risk #4) +- marker registration in all THREE places (pyproject markers, addopts exclusion, _live_oracle.LIVE_ORACLE_MARKERS) — §6 item 9 — done IN the workstream PR that mints the marker, plus a test_ci_live_oracles.py assertion (refactored to iterate LIVE_ORACLE_MARKERS) +- .github/workflows/ci.yml Tier-2 live-oracles matrix row with WARDLINE_LIVE_ORACLE_REQUIRED=1 (§6 item 10); release.yml publish needs: the Tier-1 conformance suite + the cross-repo publish guard (§6 item 13, Finding 1) +- CODEOWNERS on corpus/** and *_wire.golden.json; conftest.py on-failure /tmp dump + unified-diff head (§6 items 11-12) +- HUB (cross-repo PR): ~/loom/seam-conformance-kit.md + ~/loom/seam-index.md, linked from ~/loom/doctrine.md, with a MANDATORY sync lint against the gated registry export (note: the SEI promotion to ~/loom was never actually executed, so this is the FIRST real instance of the hub-pointer pattern) + +**Per-seam checklist:** +- §6 item 1 — NAME AUTHORITY: state which side mints the bytes and which reproduces/consumes; if A is a sibling repo, B (this repo) VENDORS A's corpus (inversion rule, §1). +- §6 item 2 (BAR) — CONTRACT-AS-ARTIFACT: a frozen machine-readable file IS the contract (corpus/*.json+META.json | _wire.golden.json | fixtures/-conformance-oracle.json); prose may only POINT to it; canonical JSON, host-free, totally ordered, produced by reusing the REAL production serializer (§2). +- §6 item 3 — _capture.py: reuse the REAL wire serializer, apply the positive allowlist predicate, canonicalize (§2). +- §6 item 4 — regen.py: requires --reason, the ONLY sanctioned writer (§5e). +- §6 item 5 (BAR) — ORACLE / LIVE RE-DERIVATION: re-derive from a LIVE call and assert identity (byte-exact corpus, or key-set-exact vector top-level AND per-element, or scenario-coverage); a self-checking mirror is BELOW the bar (§3a/§3b/§3b-bis). +- §6 item 6 — NON-VACUITY + SOUNDNESS GUARDS (§3c keystone): per asserted invariant a fixture FAILS if that invariant alone breaks; per-surface non-empty + load-bearing marker; edge-construct coverage; join-key collision-freedom; fixture hygiene. +- §6 item 7 — DETERMINISM PRECONDITION (§3d): in-process / path / cross-process (PYTHONHASHSEED) / cross-interpreter (3.12==3.13) independence proven before any byte-freeze; total order on walker-order arrays; STRICT default that RAISES on non-serializable; no .weft/ or weft.toml in fixtures; LF-pinned. +- §6 item 8 (BAR, two-sided) — SINGLE SHARED ARTIFACT + TWO-LAYER DRIFT ALARM (§4): ONE file both peers load (not two hand-copied mirrors), offline-verifiable via fixed sentinels + a documented GOLDEN_KEY; Layer-1 default-suite UPSTREAM_BLOB_SHA git-blob byte-pin + Layer-2 opt-in _drift live recheck vs the sibling checkout (skip-when-absent, FAIL-on-drift, release-gate); contract header names who-mints-vs-vendors + a byte-verbatim RE-VENDOR PROCEDURE that bumps the pin in the SAME commit. +- §6 item 9 — MARKER REGISTRATION (§5d): register _e2e (+ _drift if two-sided) in pyproject markers, the addopts exclusion, AND _live_oracle.LIVE_ORACLE_MARKERS — all three, else the marker skips-clean even when WARDLINE_LIVE_ORACLE_REQUIRED=1 is armed (Finding 2 root cause). +- §6 item 10 (BAR) — FAIL-CLOSED CI (§5a/§5b/§5c): hermetic pins every PR with NO marker; live round-trip on schedule/dispatch behind _e2e with WARDLINE_LIVE_ORACLE_REQUIRED=1 so a peer-absent SKIP becomes a hard FAILURE. +- §6 item 11 — CONFTEST diff dump (§3 / golden conftest): on-failure live capture to /tmp + unified-diff head so a real regression is distinguishable from an intentional rekey. +- §6 item 12 — CODEOWNERS on corpus/** and *_wire.golden.json so a rekey needs maintainer review (§5e). +- §6 item 13 — RELEASE RIDE-ALONG (§5f, Finding 1): release/publish workflow needs:-depends on (or re-runs) the Tier-1 hermetic conformance suite; the runbook runs pytest -m _drift against siblings BEFORE tagging. +- §6 item 14 — REGISTER THE SEAM (§8): add the row to tests/conformance/seam_registry.json; test_seam_registry.py asserts its oracle test + marker + (two-sided) drift alarm are actually wired; the registry feeds ~/loom/seam-index.md. A prose-only index entry with no enforcement is itself BELOW the bar. + +## Phases + +### P0 — Keystone: land the gated conformance registry; seed the hub index as a NON-BLOCKING parallel track + +**Goal:** Turn the drafted kit (docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md, §6) from prose into ENFORCEABLE, CI-gated machinery so every later phase is a checklist application. The SOLE blocking exit gate is the wardline-internal seam_registry.json + test_seam_registry.py (self-contained, single-repo). Hub creation is a separate non-blocking track because ~/loom does not exist and its ownership is undecided. + +#### P0-W1 · Conformance-registry machinery (the keystone) +- **Repos:** /home/john/wardline +- **Contract:** Create tests/conformance/seam_registry.json (the §8 'Wardline executable reference' / §6-item-14 registry). Row schema adds bar_verdict (enum gap|partial|at_bar|deferred|one_sided_na), deferred_reason (string, required non-empty when verdict is deferred or one_sided_na), and wire_change (none|additive|breaking|n/a) to the §8 tuple. Populate every below-bar seam plus the explicitly-disposed one-sided/deferred rows; stamp bar_verdict from the per-seam AUDIT verdict. +- **Oracle:** Write tests/conformance/test_seam_registry.py with the per-verdict enforcement above. The keystone clause: assert that for every row whose drift_alarm is non-null, the named marker is present in _live_oracle.LIVE_ORACLE_MARKERS (so a row that claims a drift alarm whose marker would skip-clean FAILS). Self-referential: the registry cannot claim at_bar without the artifacts existing AND the marker being fail-closed-armable. +- **CI gate:** Runs in the default test job (.github/workflows/ci.yml, no marker, fails closed on any registry/artifact/marker mismatch). +- **Merge path:** wardline rcX branch -> single PR rcX->main (wardline house convention; memory: feedback_single_rc_branch_no_scatter). Merge back unprompted once green. +- **Publish:** None (internal test harness; ships in wheel, not user-facing). +- **Exit:** seam_registry.json + test_seam_registry.py committed and green on PR; every gap/partial/at_bar/deferred/one_sided_na verdict matches the audit; the marker-in-LIVE_ORACLE_MARKERS clause is exercised by at least one at_bar two-sided row + +#### P0-W2 · Hub doctrine home (NON-BLOCKING parallel track) +- **Repos:** /home/john/loom +- **Contract:** ~/loom does NOT exist on disk AND the SEI 'promotion to ~/loom/sei-standard.md' precedent the kit §8 cites was never executed (only the in-repo SEI spec exists) — so this CREATES the hub for the first time; there is no live layout to copy. Establish the hub home and a skeleton doctrine.md linking the kit doctrine (~/loom/seam-conformance-kit.md) and the canonical cross-peer index (~/loom/seam-index.md). Seed skeletons only; the index is POPULATED in P8 from the gated wardline registry. +- **Oracle:** None at this stage (enforcement lives in wardline's test_seam_registry.py; the hub-index sync lint is added in P8-W2). +- **CI gate:** None yet (hub is a docs/coordination repo; the mandatory sync lint lands in P8-W2). +- **Merge path:** Hub repo: its own branch + PR (the hub's native branching, NOT rcX). This is a genuinely cross-repo PR with currently-unowned review. +- **Publish:** Hub markdown only; NEVER github.io (the github.io MkDocs site was retired). +- **Exit:** ~/loom exists with doctrine.md, seam-conformance-kit.md placeholder, seam-index.md skeleton; OPEN QUESTION resolved or escalated: who owns the hub repo and where it lives (see open_questions). This track does NOT gate P0 exit. + +**Phase exit:** tests/conformance/seam_registry.json exists with one row per seam carrying {seam, authority, consumer_or_second_producer, wire, two_sided, oracle_shape, marker, drift_alarm, bar_verdict, deferred_reason, wire_change, evidence_paths}; bar_verdict is an enum {gap, partial, at_bar, deferred, one_sided_na}; bar_verdict is copied from the per-seam AUDIT verdict (NOT a registry maturity field); every below-bar seam is present, plus the explicitly-disposed one-sided/deferred rows; test_seam_registry.py enforces per bar_verdict value: at_bar => named oracle test file exists AND its marker is in all three places (pyproject markers + addopts exclusion + _live_oracle.LIVE_ORACLE_MARKERS) AND (two_sided) the drift-alarm test exists; partial => the oracle test path exists; deferred/one_sided_na => deferred_reason is non-empty; gap => no artifact requirement. A row claiming at_bar whose drift-alarm marker is ABSENT from LIVE_ORACLE_MARKERS FAILS the test (this closes Finding 2 at the keystone, not only at P8); test_seam_registry.py runs in the DEFAULT PR suite (no e2e marker) and fails closed; NON-BLOCKING parallel track (does NOT gate P0 exit): ~/loom hub skeleton seeded — see P0-W2 — completes any time before P8-W2; the kit spec stays authoritative-for-now; it is reduced to pointer-to-hub ONLY in P8 after the hub file exists + +--- + +### P1 — Structural-debt substrate: shared WeftHttp transport + federation-status envelope dedup (and its registry closeout) + +**Goal:** Land the two structural-debt items the requirements name. The transport is WIRE-INVARIANT internal plumbing (no peer can observe whether wardline used one transport or three), so per-seam oracles do NOT block on it. The envelope dedup is the one piece that couples a downstream seam (cross-surface status envelope) — and that seam's registry row is flipped to at_bar IN this phase (former P6-W4 folded in), so the gated registry never carries a stale gap verdict for converged work. Both are wardline-internal, single-repo. + +**Depends on:** P0 + +#### P1-W1 · Wardline shared HTTP transport (WeftHttp consolidation) +- **Repos:** /home/john/wardline +- **Contract:** Add a single WeftHttp transport to src/wardline/core/http.py: Request build + scheme allow-list (http/https) + timeout + HTTPError->Response status mapping (the one home for the noqa:S310 + scheme-lowercasing dance). Keep per-sibling auth/signing/credential layers OUT of it (byte-pinned to each verifier — must NOT be unified). +- **Oracle:** Add transport-level tests to tests/unit/core/test_http.py pinning the currently-divergent behaviors: non-http(s) scheme raises; urllib.error.HTTPError (4xx/5xx) -> Response(status, body) not an outage; timeout/URLError -> unreachable branch. Internal plumbing — appropriate-altitude conformance, not golden vectors. +- **CI gate:** Default PR suite; the per-sibling contract tests (test_filigree_emit.py, test_client.py, test_dossier_client.py) staying green IS the wire-invariance proof. +- **Merge path:** wardline rcX -> PR rcX->main. +- **Publish:** Ships in the wardline release cut at P8 (PyPI). +- **Issues:** wardline-18499aaa2d +- **Exit:** all three UrllibTransport classes deleted; clients rewired to WeftHttp; auth/signing untouched; per-sibling contract tests green; wardline-18499aaa2d closed + +#### P1-W2 · Wardline federation status envelope (cross-surface emission status) + registry closeout +- **Repos:** /home/john/wardline +- **Contract:** Add a single shared projector core/federation_status.py (build_filigree_emit_status + build_loomweave_write_status). MCP envelope is the SUPERSET shape; CLI (cli/scan.py:660) and scan_jobs (scan_jobs.py:258) adopt it. Lift the MCP $defs/filigree + $defs/loomweave sub-schemas into a standalone frozen federation_status_schema.json that MCP outputSchema $refs. IN THE SAME PR, flip the cross-surface status-envelope seam_registry.json row GAP->at_bar (former P6-W4 folded in here — registry updates ride the producing workstream, never a deferred bookkeeping step). +- **Oracle:** Add tests/conformance/test_federation_status_parity.py: feed one EmitResult to all three projectors, jsonschema.validate each against the frozen schema, assert the three dicts are EQUAL (cover not-configured AND 401/5xx/reachable branches). Hermetic -> default PR suite -> fails closed. +- **CI gate:** Default PR suite, no marker. +- **Merge path:** wardline rcX -> PR rcX->main. +- **Publish:** PyPI at P8. +- **Issues:** wardline-80e457bc41 +- **Exit:** three hand-maintained projectors collapsed to one; frozen schema is the sole producer; parity oracle green on PR; the status-envelope registry row reads at_bar in the same PR; wardline-80e457bc41 closed + +**Phase exit:** one WeftHttp transport in core/http.py consumed by all three urllib clients (filigree_emit, loomweave/client, filigree/dossier_client); per-sibling auth/signing/credential layers UNTOUCHED and their contract tests still green (wire-invariance proof); transport-level tests pin scheme-rejection / HTTPError->Response mapping / timeout (the three currently-divergent behaviors); one shared federation-status projector consumed by all three surfaces (MCP / CLI / scan-jobs); a hermetic three-surface parity oracle runs in the DEFAULT PR suite and fails closed; the cross-surface status-envelope seam_registry.json row is flipped GAP->at_bar IN THIS PR; wardline-18499aaa2d and wardline-80e457bc41 closed + +--- + +### P2 — Identity + auth substrate closure (max blast radius; every HTTP seam rides it at runtime) + +**Goal:** Close the consumer-side byte-pin gaps on the identity spine and the federation-token wire. SEI-CORE STAYS THE UNTOUCHED REFERENCE — the only work is the cheapest possible §6-item-8 demo: add the missing UPSTREAM_BLOB_SHA byte-pins so vendored SEI fixtures cannot silently drift, plus the WEFT_FEDERATION_TOKEN consumer-side 401 oracle. NOTE: these are consumer-side TEST-HARNESS fixture pins; they do NOT touch production identity code, so they are a SOFT ordering predecessor to the data plane, not a hard build dependency. + +**Depends on:** P0 + +#### P2-W1 · SEI conformance (loomweave producer <-> wardline consumer) +- **Repos:** /home/john/wardline +- **Contract:** Pin the vendored SEI fixture's git blob SHA (0ea577025d94c028a0f682b7d29765079455718c) as a module constant in tests/conformance/test_sei_oracle.py; record provenance in tests/conformance/fixtures/PROVENANCE.md. Fix the citation drift (spec is at docs/superpowers/specs/2026-06-01-loom-stable-entity-identity-conformance.md, NOT archive/; stop listing tests/golden/identity/README.md as the SEI contract). +- **Oracle:** Add an UNMARKED default-suite test (mirror test_loomweave_rust_qualname_parity.py:193-208) asserting git-hash-object over the vendored fixture == the pinned SHA. Mark test_vendored_oracle_matches_loomweave_source with a sei_drift marker; REGISTER that marker in all three places (pyproject markers + addopts exclusion + _live_oracle.LIVE_ORACLE_MARKERS) IN THIS PR (the discipline is per-workstream, not deferred to P8) and extend tests/unit/test_ci_live_oracles.py to assert it; check out loomweave in the live-oracles job so a real upstream amendment reds the scheduled run under WARDLINE_LIVE_ORACLE_REQUIRED=1. +- **CI gate:** Layer-1 byte-pin in default PR suite (fails closed with no peer); Layer-2 sibling recheck (sei_drift) in the fail-closed live-oracles job. +- **Merge path:** wardline rcX -> PR rcX->main. +- **Publish:** PyPI at P8. +- **Exit:** vendored SEI fixture edit reds the default PR suite without a loomweave checkout; sei_drift marker registered in all three places + asserted by test_ci_live_oracles.py in this PR; citation drift fixed + +#### P2-W2 · SEI conformance (loomweave producer <-> legis consumer) +- **Repos:** /home/john/legis +- **Contract:** Add UPSTREAM_BLOB_SHA constant (0ea577025d94c028a0f682b7d29765079455718c) + record the source loomweave commit in tests/conformance/fixtures/PROVENANCE.md. +- **Oracle:** Add an UNMARKED test_vendored_oracle_matches_upstream_blob_pin in tests/conformance/test_sei_oracle.py computing the git-blob hash over fixtures/sei-conformance-oracle.json asserting == the constant; re-vendors bump the constant in the same commit. NOTE: legis has NO fail-closed live-oracle primitive today (it skip-on-LOOMWEAVE_LIVE_ORACLE_LOCATOR-absent, test_live_loomweave_oracle.py:33); this WS only needs the hermetic Layer-1 byte-pin, which fails closed without a peer. (The LEGIS_LIVE_ORACLE_REQUIRED primitive is BUILT in P6-W1 where the live wire oracle first needs it.) +- **CI gate:** Default PR suite byte-pin (fails closed even with loomweave absent); the existing six-scenario oracle stays unconditional. +- **Merge path:** legis branch (legis's native branching: currently warpline-interfaces; NOT rcX) -> PR -> main. +- **Publish:** legis release at P8 (its own pipeline). +- **Exit:** legis CI reds on any vendored-SEI-fixture byte change without a loomweave checkout + +#### P2-W3 · WEFT_FEDERATION_TOKEN bearer-token auth contract +- **Repos:** /home/john/wardline, /home/john/filigree +- **Contract:** Author a shared frozen token-wire vector enumerating each token state -> expected (status, error code, WWW-Authenticate header): absent->401/PERMISSION/Bearer, wrong->401, malformed-scheme->401, valid+sentinel-body->400, valid+valid-body->2xx. ONE shared corpus both repos load (wardline tests/golden/federation/weft_token_contract.json; filigree loads the same with a byte-pin). +- **Oracle:** wardline: replace the in-test status hardcoding in test_filigree_verify_token.py with load-and-assert against the vector; add a live filigree_e2e token round-trip (test_filigree_auth_live.py) asserting accepted=True with the right token, 401 with a wrong token, header/code parity. filigree: have test_weft_auth.py derive its assertions from the shared vector. HARD GATE (risk #4): any signed component in the vector must be reproduced byte-for-byte by BOTH the producer serializer AND accepted by the real verifier, including >=1 non-ASCII-body and >=1 reordered-key case, BEFORE the byte-pin is committed. +- **CI gate:** Hermetic vector-conformance test on every PR (both repos, fails closed); the live token round-trip is filigree_e2e (fail-closed in the scheduled job; marker already in all three places). +- **Merge path:** RELEASED two-sided, wire_change=additive (gate-only addition; existing wire unchanged) => EITHER ORDER IS SAFE behind the shared-vector PR gate. wardline rcX -> PR rcX->main; filigree feat-branch -> PR -> main. The two halves prove agreement against the same bytes before either tags. +- **Publish:** wardline -> PyPI; filigree -> its release pipeline (P8). +- **Exit:** one shared token-wire vector, byte-pinned, both repos load it; round-trips through both impls incl. non-ASCII + reordered-key before pin; wardline has a real 401/403 consumer oracle; producer + consumer assert the SAME bytes + +**Phase exit:** wardline + legis each carry a fail-closed default-suite byte-pin on their vendored SEI fixture (no longer skip-clean when loomweave absent); WEFT_FEDERATION_TOKEN has a shared frozen token-wire vector both repos load + a wardline consumer-side 401/403 round-trip oracle; the vector's signed components round-trip through BOTH the producer serializer AND the real verifier (incl. one non-ASCII-body and one reordered-key case) before its blob SHA is pinned; SEI producer change-control recorded: loomweave's authoritative sei-conformance-oracle.json amendment must bump a version, and the consumers' Layer-2 rechecks are the release-gate alarm (named in the registry SEI rows) — confirms the 'SEI = done' reading; all three SEI/token seams move PARTIAL->at_bar in the registry (§6 items 2+5+10+8) + +--- + +### P3 — Wardline<->loomweave data plane (taint-store wire + qualname dialect) — runs in PARALLEL with P2 + +**Goal:** Gold the tightest two-way coupling: the released, HMAC-signed, blake3-gated taint-fact store wire and the Python qualname-parity axis (the Rust axis is already at-bar). Both are cross-implementation (Python stdlib vs Rust crates), so they get a single shared corpus both repos load plus the two-layer drift alarm. SOFT-ORDERS after P2 (confidence: gold the identity spine before the data plane) but does NOT hard-depend on it — P2 is consumer-side test-harness fixture pins and the taint oracle asserts bytes against already-released-and-unchanged production code, so P3 is independently landable and may run concurrently with P2. + +**Depends on:** P0 + +#### P3-W1 · Wardline->loomweave taint-fact store (HMAC write + read-by-SEI, blake3 freshness) +- **Repos:** /home/john/wardline, /home/john/loomweave +- **Contract:** Author ONE shared machine-checkable wire corpus: (a) canonical HMAC vectors (secret/method/path_and_query/body/timestamp/nonce -> canonical_message + lowercase-hex signature), (b) blake3 whole-file raw-byte content-hash vectors, (c) wardline-taint-1 blob exemplars + a JSON Schema. Canonical home loomweave docs/federation/fixtures/wardline-taint-wire.json; vendor byte-verbatim into wardline tests/conformance/wardline_taint_wire.json. Mark contracts.md sec 854-1162 FROZEN. RISK-#4 GATE: freeze the HMAC vector against what the VERIFIER accepts (capture live wire), and require the vector to round-trip through BOTH real impls (incl. one non-ASCII body + one reordered-key case) before pinning. +- **Oracle:** wardline: new tests/conformance/test_loomweave_taint_wire_parity.py (NO e2e marker) loads the vendored corpus, asserts _hmac.sign_request/canonical_message + facts.py blake3 reproduce every vector byte-for-byte (replaces the self-recompute in test_facts.py:45-53); add UPSTREAM_BLOB_SHA + opt-in taint_drift live recheck; REGISTER taint_drift in all three places + assert via test_ci_live_oracles.py in this PR. loomweave: default-gate nextest test loading the SAME corpus, asserting canonical_hmac_message/component_hmac_hex (auth.rs:206-235) + current_file_hash blake3 reproduce every vector + serde round-trip of blob exemplars vs the schema. +- **CI gate:** Both sides assert the shared corpus in their default gate (wardline pytest, loomweave nextest) so a cross-impl drift reds on EVERY PR. Keep the live e2e (loomweave_e2e) as the integration backstop, fail-closed in the scheduled job. +- **Merge path:** RELEASED two-sided, wire_change=additive (gate-only; wire unchanged) => EITHER ORDER SAFE behind the shared corpus. wardline rcX -> PR rcX->main; loomweave feat-branch (currently docs/language-support-coverage; NOT rcX) -> PR -> main. +- **Publish:** wardline -> PyPI; loomweave -> its release (P8). +- **Exit:** one shared taint-wire corpus both repos load; both default gates assert it byte-for-byte; byte-pin + drift alarm wired + marker registered in all three places this PR; HMAC vector round-trips through both impls incl. non-ASCII + reordered-key before pin; seam moves PARTIAL->at_bar in the registry + +#### P3-W2 · Wardline qualname normalization / parity (Python axis) +- **Repos:** /home/john/wardline, /home/john/loomweave +- **Contract:** The Rust axis is already gold (triple-pinned d81fb975). Close the Python axis: add a PYTHON_UPSTREAM_BLOB_SHA byte-pin for the vendored loomweave_qualname_parity.json. Fix the false provenance docstring in test_qualname_conformance.py (loomweave does NOT vendor qualnames.json) — correct the claim. +- **Oracle:** wardline: add test_vendored_python_corpus_matches_upstream_blob_pin (default suite) + a Layer-2 opt-in qualname_drift recheck comparing the VECTOR arrays (not raw bytes, since doc keys legitimately differ) against the sibling loomweave checkout; register qualname_drift in all three places + assert via test_ci_live_oracles.py in this PR. +- **CI gate:** Layer-1 byte-pin in default PR suite; Layer-2 (qualname_drift) in the fail-closed live-oracles job. +- **Merge path:** RELEASED two-sided, wire_change=additive; mostly single-repo (loomweave side already at-bar; only the false-claim fix may touch loomweave). wardline rcX -> PR rcX->main; loomweave feat-branch -> PR -> main if needed. +- **Publish:** wardline -> PyPI (P8). +- **Depends on:** P3-W1 +- **Exit:** Python qualname fixture has Layer-1 byte-pin + Layer-2 drift alarm matching the Rust axis; marker registered in all three places this PR; false provenance docstring corrected + +**Phase exit:** ONE shared frozen taint-wire corpus (HMAC vectors + blake3 vectors + wardline-taint-1 blob exemplars + JSON Schema) vendored byte-verbatim into both wardline and loomweave; wardline default-suite parity test (no e2e marker) asserts _hmac.sign_request + facts.py blake3 reproduce every vector byte-for-byte; loomweave nextest test asserts its Rust canonical_hmac_message + current_file_hash reproduce them; the HMAC vectors round-trip through BOTH impls (incl. non-ASCII + reordered-key) before the byte-pin (risk #4); UPSTREAM_BLOB_SHA byte-pin + opt-in taint_drift live recheck on the taint corpus, with taint_drift registered in all three places + asserted by test_ci_live_oracles.py IN THESE PRs; same applied to the Python qualname fixture (qualname_drift); the cross-impl live wire oracle (test_loomweave_live.py blake3 byte-equality + read-by-SEI) promoted onto a PR-gated path OR backed by the new default-suite parity test + +--- + +### P4 — loomweave producer surfaces -> consumers (HTTP Read API, HMAC auth, SEI HTTP wire, entity-associations, ephemeral-port) + +**Goal:** Gold the released loomweave-produced HTTP/wire seams whose consumers (filigree, legis) currently assert against self-authored mocks or skip-clean drift checks. Uniform pattern: vendor loomweave's frozen fixtures into the consumer with a byte-pin drift alarm and drive the consumer oracle off the vendored fixtures instead of formula-synthesized fakes. Every new _drift / _e2e marker is registered in all three places + asserted by test_ci_live_oracles.py in the SAME PR that mints it. + +**Depends on:** P2 + +#### P4-W1 · loomweave HTTP Read API (file resolution, call-graph linkages, capability discovery) +- **Repos:** /home/john/filigree, /home/john/loomweave +- **Contract:** Vendor loomweave's HTTP Read API fixtures (get-api-v1-capabilities.json, get-api-v1-files.demo-python.json, post-api-v1-files-batch.json, post-api-v1-files-resolve.batch.json) into filigree tests/federation/fixtures/. loomweave: add frozen fixtures + serve.rs oracle assertions for the call-graph linkages routes (callers/callees + batch-get) which are prose-only today — scope the shared-corpus to the ACTUAL consumer (filigree registry does not read linkages; the dossier assembler does). +- **Oracle:** filigree: new tests/federation/test_loomweave_http_contract_drift.py byte-pins each vendored fixture vs loomweave source + opt-in live recheck vs spawned loomweave serve. Drive the registry conformance tests off the vendored fixtures' examples (assert LoomweaveRegistry parses resolved/not_found/briefing_blocked/errors partitions) instead of the formula-synthesizing fake; make the clarion_http fake derive its shapes from the vendored fixtures (this catches the missing `linkages` field). +- **CI gate:** Promote the byte-pin alarm into the fail-closed loomweave-contract PR job; ensure loomweave-contract is a required status check. New drift marker registered in all three places this PR. +- **Merge path:** RELEASED two-sided, wire_change=additive (gate-only) => either order safe. filigree feat-branch -> PR -> main; loomweave feat-branch -> PR -> main (loomweave only changes if linkages fixtures are added). +- **Publish:** filigree + loomweave releases (P8). +- **Exit:** filigree drives consumer conformance off vendored loomweave fixtures with a byte-pin; missing-field drift reds on PR; seam restored to at_bar with the shared corpus + +#### P4-W2 · loomweave HMAC/Bearer inbound auth for HTTP Read API +- **Repos:** /home/john/legis, /home/john/loomweave +- **Contract:** legis: add a frozen shared HMAC-auth vector loomweave_hmac_auth.v1.json (fixed secret/method/path?query/body/timestamp/nonce -> canonical message + lowercase-hex signature + expected X-Weft-Component/Timestamp/Nonce headers), mirroring wardline_scan_artifact.v1.json. loomweave: load the SAME vector and assert production canonical_hmac_message/component_hmac_hex (auth.rs:206-235) reproduce the frozen message + signature byte-for-byte. RISK-#4 GATE: freeze against the verifier; round-trip the vector through BOTH impls (incl. one non-ASCII body + one reordered-key case) before pinning. +- **Oracle:** legis: drive test_weft_signing.py off the vector (replace the in-test hmac.new recompute at :39-52 with load-and-assert). loomweave: a unit test next to auth.rs loads the vector. Add an upstream byte-pin drift alarm (blob hash of the shared vector) in loomweave's unconditional gate (verify.yml), with opt-in live recheck. Extend legis's live oracle to assert one auth-rejection path (401 on missing/stale/replayed) — this requires the LEGIS_LIVE_ORACLE_REQUIRED primitive, which is BUILT in P6-W1; until then this WS ships only the hermetic byte-pin (fails closed without a peer). +- **CI gate:** Both sides assert the frozen vector in their default gate -> cross-impl conformance is fail-closed OFFLINE. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. legis branch -> PR -> main; loomweave feat-branch -> PR -> main. +- **Publish:** legis + loomweave releases (P8). +- **Depends on:** P2-W2 +- **Exit:** one shared HMAC-auth vector both repos load; both default gates reproduce it byte-for-byte; round-trips through both impls incl. non-ASCII + reordered-key before pin; byte-pin drift alarm wired + +#### P4-W3 · loomweave->legis SEI HTTP-transport wire (identity routes) +- **Repos:** /home/john/loomweave, /home/john/legis +- **Contract:** loomweave: freeze the SEI HTTP-transport wire as docs/federation/fixtures/sei-identity-wire.json (each route's request/response object shape) + weft-component-hmac-vectors.json (the X-Weft HMAC canonicalization with a worked vector); pin them in serve.rs like the files fixtures. legis: vendor both byte-verbatim with a Layer-1 UPSTREAM_BLOB_SHA byte-pin. +- **Oracle:** legis: new tests/conformance/test_sei_wire_pin.py (Layer-1 byte-pin, default suite, fails closed with no peer); drive test_loomweave_client.py canned responses + the HMAC contract test FROM the vendored vectors instead of hand-written dicts. Add the Layer-1 byte-pin to test_sei_oracle.py:84-88 so the corpus drift gate runs without the loomweave checkout. Convert ci.yml:27 live oracle from skip-clean to a real gate using the LEGIS_LIVE_ORACLE_REQUIRED primitive built in P6-W1 (spin up loomweave serve OR fail-not-skip when declared-released but unconfigured). +- **CI gate:** Layer-1 byte-pins in default PR suite (both repos); live wire oracle fail-closed in the gated job once the legis primitive exists. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. loomweave feat-branch -> PR -> main; legis branch -> PR -> main. +- **Publish:** loomweave + legis releases (P8). +- **Depends on:** P2-W2 +- **Exit:** frozen SEI-transport-wire artifact exists; legis vendors it with a fail-closed byte-pin; the live oracle no longer skips-clean (after P6-W1 primitive) + +#### P4-W4 · Filigree entity-associations (SEI/locator opaque binding) -> loomweave drift-consumer +- **Repos:** /home/john/filigree, /home/john/loomweave +- **Contract:** Re-sync loomweave's filigree-entity-associations-response.json from fixture_version 1 to filigree's fixture_version 3 (add claimed_at/closed_at/claim_commit/close_commit/status/status_category) — closes the live drift that proves the gate is absent. Collapse to ONE shared corpus the filigree producer test asserts byte-exact against the LIVE route emit and loomweave loads. +- **Oracle:** filigree: new tests/federation/test_entity_associations_contract_drift.py (NOT the skip-clean pattern) pins the SHA-256 of the canonical fixture body, asserts the vendored loomweave copy matches when the sibling path is present AND asserts the pinned hash unconditionally (fails closed without the peer); wire into the loomweave-contract PR job. Strengthen loomweave's consumer assertion (filigree.rs:1139-1154) to assert the full row shape, not 5 fields, so a producer field rename reds the consumer too. +- **CI gate:** Byte-pin drift test gates merge in the fail-closed loomweave-contract PR job. +- **Merge path:** RELEASED two-sided, wire_change=additive (re-sync is a fixture catch-up to the ALREADY-shipped v3 producer; the live wire is filigree v3 today, so the consumer fixture is being corrected to match production, not the wire changed) => either order safe. filigree feat-branch -> PR -> main; loomweave feat-branch -> PR -> main. +- **Publish:** filigree + loomweave releases (P8). +- **Exit:** v1-vs-v3 drift closed; single shared corpus + byte-pin; consumer asserts full row shape + +#### P4-W5 · Filigree ephemeral-port discovery convention +- **Repos:** /home/john/loomweave, /home/john/filigree, /home/john/wardline +- **Contract:** loomweave: add a frozen fixture docs/federation/fixtures/filigree-ephemeral-port.json pinning the canonical path '.weft/filigree/ephemeral.port', the format 'plain trimmed ASCII integer 1..65535', the legacy-path refusal policy, and the precedence ladder. filigree: add the producer SHA of the write contract to _meta. wardline: reconcile the cross-consumer legacy-path divergence (loomweave refuses legacy .filigree/; wardline tolerates it config.py:433-434,492-494) — align wardline to the clean break OR record an intentional-divergence clause and pin both policies. +- **Oracle:** loomweave: make filigree_url.rs tests load the fixture instead of hard-coded literals; add an upstream byte-pin of filigree's write contract (path join + str(port)) with opt-in live recheck. filigree: add a producer conformance test running the real write path asserting the file lands at the pinned path with the pinned format. wardline: add a test asserting its chosen legacy-path policy so it cannot drift from loomweave silently. +- **CI gate:** Default gates on all three repos; byte-pin fails closed. +- **Merge path:** RELEASED, three-consumer, wire_change=additive (frozen fixture documents the ALREADY-live convention; only the wardline legacy-path reconciliation is a behavior decision — if it is a clean break, gate it as additive-with-policy-pin, not a wire change) => either order safe. loomweave + filigree + wardline each on their own branch -> PR -> main. +- **Publish:** all three releases (P8). +- **Exit:** frozen ephemeral-port fixture + producer conformance test; cross-consumer legacy-path divergence reconciled and pinned + +**Phase exit:** filigree vendors loomweave's HTTP Read API fixtures (capabilities/files/batch/resolve) with a byte-pin drift alarm; the registry-backend consumer tests drive off the vendored fixtures (catches the missing `linkages` field); loomweave HMAC auth seam has a shared loomweave_hmac_auth.v1.json frozen vector both legis and loomweave load + a default-suite byte-pin; the vector round-trips through both impls (incl. non-ASCII + reordered-key) before pin; loomweave SEI HTTP-transport wire frozen as sei-identity-wire.json + weft-component-hmac-vectors.json, vendored into legis with a Layer-1 byte-pin; filigree entity-associations: loomweave copy re-synced to fixture_version 3 (closing the live v1-vs-v3 drift), single shared corpus + byte-pin, consumer assertion strengthened beyond serde-default; filigree ephemeral-port: frozen fixture (path + plain-integer format + legacy-path policy + precedence); producer + consumer conformance test; wardline/loomweave legacy-path divergence reconciled; every new drift/e2e marker minted here registered in all three places + asserted by test_ci_live_oracles.py in its own PR + +--- + +### P5 — Wardline producer fan-out (scan-results intake, finding identity, suppression vocab, reason vocab, vocabulary descriptor) + MCP B1/B2 producer freeze + +**Goal:** Gold the released seams where wardline (and the weft hub) is the producer fanning out to filigree/loomweave/legis. These currently use independent hand-copied mirrors. Convert each to ONE shared corpus both peers load + a byte-pin drift alarm. ALSO closes the previously-orphaned MCP B1/B2 producer-only seam (former 'P5 follow-on' phantom) as P5-W6. + +**Depends on:** P3 + +#### P5-W1 · Wardline->Filigree scan-results emission/intake +- **Repos:** /home/john/wardline, /home/john/filigree +- **Contract:** wardline: vendor filigree's weft/scan-results.json (+ classic alias) as a SHARED CORPUS with an upstream byte-pin DRIFT ALARM (mirror SEI 36c8adcf). filigree: promote the weft fixture _meta.status from 'DECLARED -- Phase C1' to mounted/verified and cite the live parity + fingerprint-survival oracle. +- **Oracle:** wardline: new tests/conformance/test_filigree_emit_contract_conformance.py (NO filigree_e2e marker) feeds real build_scan_results_body/FiligreeEmitter (injectable transport) output and asserts it conforms to the vendored request shape_decl + examples (required keys, fingerprint_scheme value, severity-map, metadata.wardline.* namespace, optional scanned_paths/scan_run_id). Optionally emit scan_run_id. Add tests/conformance/test_filigree_scan_results_contract_freeze.py drift alarm (fails closed on PR). +- **CI gate:** Producer conformance + drift alarm in the default PR suite (peer-binary-independent); keep live e2e weekly fail-closed. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. wardline rcX -> PR rcX->main; filigree feat-branch -> PR -> main. +- **Publish:** wardline -> PyPI; filigree -> release (P8). +- **Exit:** wardline has a PR-gated producer emission oracle + vendored byte-pinned scan-results corpus; seam PARTIAL->at_bar + +#### P5-W2 · Finding identity & wire contract (fingerprint/qualname/spans) +- **Repos:** /home/john/wardline, /home/john/filigree +- **Contract:** wardline: add a SHARED finding-identity golden vector (mirror legis G1) pinning the identity-bearing per-finding wire (scheme-prefixed fingerprint, qualname, spans line/col, suppression_state, kind, fingerprint_scheme=wlfp2). Fix stale wlfp1 prose in the ADR (line_start is NOT hashed in wlfp2). Convert the existing suppression-filter mirror to the same shared-load+drift-alarm design while in the area. +- **Oracle:** wardline: producer half (test_finding_identity_wire_golden.py) asserts the LIVE filigree-emit body carries EXACTLY the vector's key-sets and values. filigree: consumer half LOADS the same vector (not a copy), feeds through db.process_scan_results, asserts dedup/lifecycle keys on the exact fingerprint values + scheme handshake reads the vector's fingerprint_scheme; replace hand-rolled fp-abc/wlfp2:aaa literals. Add a drift alarm (identity_drift marker, registered in all three places this PR) byte-pinning the shared vector's upstream blob, fail-closed in CI. RISK-#4 GATE: if any vector field is signed/canonicalized, round-trip it through both impls before pinning. +- **CI gate:** Both halves in default PR suite; drift alarm in the fail-closed live-oracles matrix. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. wardline rcX -> PR rcX->main; filigree feat-branch -> PR -> main. +- **Publish:** wardline -> PyPI; filigree -> release (P8). +- **Depends on:** P5-W1 +- **Exit:** ONE shared finding-identity vector both repos load; producer rename reds both halves; audit-downgraded gold->PARTIAL restored to at_bar + +#### P5-W3 · Wardline suppression-state filter vocabulary +- **Repos:** /home/john/filigree, /home/john/wardline +- **Contract:** filigree: make its copy a byte-pinned vendor of the wardline-owned fixture; add UPSTREAM_BLOB_SHA == git hash-object of the wardline source fixture. wardline: document the wardline fixture as upstream source-of-truth + single-commit re-vendor discipline. +- **Oracle:** filigree: assert the vendored copy's blob hash == UPSTREAM_BLOB_SHA in the default suite (fails loud on any byte change) + an opt-in suppression_drift recheck vs the wardline checkout (marker registered in all three places this PR). wardline: optionally add the symmetric byte-pin of filigree's copy. +- **CI gate:** Layer-1 byte-pin default PR suite (filigree); Layer-2 (suppression_drift) opt-in. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. filigree feat-branch -> PR -> main (+ wardline rcX if symmetric pin added). +- **Publish:** filigree -> release; wardline -> PyPI (P8). +- **Exit:** filigree's suppression-filter copy is byte-pinned to wardline's; an enum drift between repos reds; seam PARTIAL->at_bar + +#### P5-W4 · Weft canonical reason-vocabulary conformance +- **Repos:** /home/john/weft, /home/john/filigree, /home/john/legis, /home/john/wardline +- **Contract:** weft hub: add contracts/weft-reason-vocab.schema.json (closed 11-class enum + carrier rule) and a CI test job validating the canonical JSON + emitting its blob hash; the canonical contract carries a VERSION + blob hash in _meta. filigree/legis/wardline: each vendors the canonical contract and pins its git blob SHA. +- **Oracle:** weft: new .github/workflows/ci.yml + tests/test_reason_vocab_contract.py (owner fails closed on its own contract edits). Each consumer: replace the bare hardcoded frozenset / skip-clean hub read with a default-suite assertion that the vendored copy == the pinned hash (a hub change forces a deliberate re-vendor commit); demote the sibling-path hub read to an opt-in _drift marker (registered in all three places per consumer PR). +- **CI gate:** Owner CI fails closed on contract edits; each consumer's default suite byte-pins the vendored copy (no skip-clean). HUB-CONTRACT RELEASE DISCIPLINE: a hub edit bumps the _meta version and is announced via the hub index; each consumer's Layer-2 _drift recheck against the hub FAILS until re-vendored; a NAMED owner runs the hub-change->consumer-re-vendor cycle (ties to the release-owner open_question). +- **Merge path:** RELEASED multi-sided, wire_change=additive => either order safe behind the byte-pin. weft hub PR (hub native branching) + filigree feat-branch + legis branch + wardline rcX, each -> PR -> main. +- **Publish:** weft hub markdown; consumer releases (P8). +- **Exit:** weft hub has a fail-closed contract gate + versioned _meta + named re-vendor owner; all three consumers byte-pin a vendored copy; no skip-clean hub read in any default suite + +#### P5-W5 · Vocabulary descriptor (cross-product trust-vocab contract) +- **Repos:** /home/john/loomweave, /home/john/wardline +- **Contract:** loomweave: vendor the real wardline src/wardline/core/vocabulary.yaml into plugins/python/tests/fixtures/wardline_vocabulary.golden.yaml; replace the hand-written _DESCRIPTOR string with a read of that file; make the reader honor the schema axis (gate on schema == 'wardline.vocabulary/v1', distinct from the version content gate) and drop the stale 'pending Wardline Task B' docstring. wardline: make the coordinated-consumer-migration commitment machine-enforced (a regen/sync note tied to the consumer golden). +- **Oracle:** loomweave: add an upstream byte-pin + opt-in vocab_drift live-recheck (mirror 36c8adcf; marker registered in all three places this PR) so any producer reshape trips a consumer alarm; add a fail-closed consumer CI conformance test (loads the vendored shared corpus, runs load_wardline_descriptor, asserts status=enabled + entries/schema) wired into verify.yml. The self-referential check-wardline-version-bounds.py is necessary but not sufficient. +- **CI gate:** loomweave consumer CI gate fails closed on producer-shaped drift (today verify.yml only self-checks the manifest pin). +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. loomweave feat-branch -> PR -> main; wardline rcX -> PR rcX->main. +- **Publish:** loomweave + wardline releases (P8). +- **Exit:** loomweave vendors the real vocabulary.yaml byte-pinned, honors schema axis, fail-closed consumer gate; seam GAP->at_bar + +#### P5-W6 · Wardline MCP tool/resource contracts (B1/B2) — producer-only freeze +- **Repos:** /home/john/wardline +- **Contract:** Freeze _SCAN_OUTPUT_SCHEMA to a committed golden tests/conformance/golden/mcp/scan_output_schema.golden.json (lifts the audit's work_to_close verbatim). This was a 'P5 follow-on' phantom in the draft registry with no owning workstream; it now has an executable home. +- **Oracle:** Validate live structuredContent against the FROZEN golden, NOT _entries(server)[name]['outputSchema'] (which makes the oracle circular). Add a schema-vs-emitter drift test asserting the live in-process schema == the frozen golden, so a producer reshape reds against the committed artifact. +- **CI gate:** Default PR suite; fails closed on schema/emitter drift. +- **Merge path:** wardline rcX -> PR rcX->main. +- **Publish:** PyPI at P8. +- **Exit:** _SCAN_OUTPUT_SCHEMA frozen to a committed golden; live structuredContent validated against the golden; oracle circularity broken; registry row one_sided_na for §6 item 8, at_bar on items 2+5+10 + +**Phase exit:** wardline->filigree scan-results: producer-side machine-checkable conformance oracle on the default PR suite + vendored scan-results contract with upstream byte-pin; finding identity: ONE shared finding-identity golden vector both repos load (fingerprint/qualname/spans), producer + consumer halves coupled, drift alarm; signed/identity components round-trip through both impls before pin; suppression-state vocab: filigree's copy becomes a byte-pinned vendor of the wardline-owned fixture with UPSTREAM_BLOB_SHA + opt-in suppression_drift recheck; weft reason-vocab: weft hub gets a CI test job validating the contract; filigree/legis/wardline each vendor + byte-pin the canonical 11-class set; HUB-CONTRACT RELEASE DISCIPLINE recorded (version + blob hash in _meta; a hub edit bumps the version and reds each consumer's Layer-2 recheck until re-vendored; named owner of the hub-change->re-vendor cycle); vocabulary descriptor: loomweave vendors the real vocabulary.yaml as a byte-pinned shared corpus + honors the schema axis + fail-closed consumer CI gate; MCP B1/B2: _SCAN_OUTPUT_SCHEMA frozen to a committed golden; live structuredContent validated against the FROZEN golden (not the in-process schema) — oracle circularity broken; producer-only (one_sided_na for §6 item 8) + +--- + +### P6 — legis governance bindings + remaining loomweave<->filigree enrich seams (cross-surface status envelope already closed in P1) + +**Goal:** Gold the lower-blast-radius released seams: legis governance sign-off binding (and the net-new LEGIS_LIVE_ORACLE_REQUIRED fail-closed primitive that legis does NOT have today), the legis git-rename provider, and the four loomweave->filigree enrich-only reads. NOTE: the wardline scan-artifact wire is already at_bar (verify only); the cross-surface status-envelope seam was closed and registry-flipped in P1-W2 (no separate closeout workstream). + +**Depends on:** P3, P4 + +#### P6-W1 · legis->Filigree governed sign-off binding (+ build the LEGIS_LIVE_ORACLE_REQUIRED primitive) +- **Repos:** /home/john/filigree, /home/john/legis +- **Contract:** FIRST build the legis fail-closed primitive (net-new, not a mirror): port wardline's _live_oracle.py (LIVE_ORACLE_MARKERS frozenset, should_fail_live_oracle_skip) + the conftest pytest_runtest_makereport SKIP->FAIL hook into legis under LEGIS_LIVE_ORACLE_REQUIRED, with legis marker registration in its own pyproject. THEN add a frozen request-body contract fixture for the ATTACH wire {entity_id, content_hash, actor, signoff_seq, signature} with _meta.producer=legis, _meta.consumer=filigree, repo_local_copies in BOTH repos. +- **Oracle:** legis: producer test asserts HttpFiligreeClient.attach emits exactly this body; consumer test (filigree) asserts the stored/echoed row. Byte-pin drift alarm in BOTH repos. Convert test_signoff_binding_real_filigree.py from skip-clean to fail-closed using the NEW LEGIS_LIVE_ORACLE_REQUIRED hook (stand up an ephemeral filigree daemon OR turn skipif into hard failure); tighten _contains() to pin column NAMES not just values. filigree: add a local signature-freshness oracle vs a real legis-authored signature over the shared tuple. RISK-#4 GATE: the signature in the fixture must be reproduced by the producer serializer AND accepted by the real verifier (incl. non-ASCII + reordered-key) before the byte-pin. +- **CI gate:** Byte-pin drift fails closed in both repos' default suites; the live wire oracle becomes a real gate under the new legis primitive. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe. legis branch -> PR -> main; filigree feat-branch -> PR -> main. +- **Publish:** legis + filigree releases (P8). +- **Exit:** LEGIS_LIVE_ORACLE_REQUIRED primitive built in legis (ported, not assumed-present); frozen sign-off wire fixture both repos load + cross-repo byte-pin; live oracle fail-closed; local freshness oracle; signature round-trips through both impls before pin + +#### P6-W2 · legis git-rename provider for loomweave SEI matcher +- **Repos:** /home/john/legis, /home/john/loomweave +- **Contract:** Create frozen byte-vendored shared vectors legis_git_renames.v1.json + legis_git_rename_feed.v1.json (canonical key set commit_sha/old_path/new_path/similarity/old_blob/new_blob, array vs feed-object envelopes, status enum), modeled on wardline_scan_artifact.v1.json + README. Vendor byte-identical into BOTH repos; document the bump-version+regenerate-both discipline. +- **Oracle:** loomweave: CI-gated test loads the shared vector bytes through parse_legis_rename_json / file_renames_to_locator_renames asserting canonical keys extract (promotes the RenameParseOutcome warn at sei_git.rs:352-367 to an assertion — the deferred G16). legis: contract test loads the SAME bytes and asserts the live /git/renames + /git/rename-feed reproduce the vector. Complete the /git/rename-feed re-point (ledger B3) so the consumer reads the feed's committed leg, OR drop the half-wired feed contract. +- **CI gate:** Both sides load the shared vector in their default gate; the rename parse drift becomes a red test, not a runtime warn. +- **Merge path:** Two-sided, wire_change=additive; loomweave consumer is released:false (WS9 optional, may change more freely). legis branch -> PR -> main; loomweave feat-branch -> PR -> main. +- **Publish:** legis + loomweave releases (P8). +- **Exit:** shared git-rename vectors both repos load; consumer parse drift reds in CI; feed contract either re-pointed or dropped + +#### P6-W3 · loomweave->Filigree enrich-only reads (scan-results intake, issue-detail, Flow-B reconciliation, clean-stale sweep) +- **Repos:** /home/john/loomweave, /home/john/filigree +- **Contract:** For each of the four enrich seams, establish ONE shared golden vector both repos load, replacing the two independent hand-maintained mirrors. scan-results intake: a shared request+200-response golden. issue-detail: add a 200 success-body example to filigree's frozen issues-get.json + vendor into loomweave. Flow-B reconciliation: add a scan_source=wardline finding row carrying metadata.wardline.qualname to filigree's findings.json + the two-hop files->findings pairing. clean-stale: vendor filigree's findings-clean-stale.json into loomweave + add the fixture sha to _meta. +- **Oracle:** filigree: producer oracle replays the route against the in-process app asserting byte-equality to the shared vector. loomweave: replace self-authored mock bytes / hard-coded literals (scan_results.rs, filigree.rs) with loads of the shared vector + an UPSTREAM_BLOB_SHA drift alarm (mirror 36c8adcf). Add fail-closed scheduled live-oracle jobs (turning SKIP->FAIL) for at least the scan-results and reconciliation paths; register each new marker in all three places this PR. Implement or formally retract the DEFERRED GET /api/v1/entities/resolve?scheme=wardline_qualname oracle. +- **CI gate:** Shared-vector loads in both default gates; drift alarms fail closed; live oracles fail-closed in the scheduled job. +- **Merge path:** RELEASED two-sided, wire_change=additive => either order safe (four seams, one coordinated cycle behind the shared vectors). loomweave feat-branch -> PR -> main; filigree feat-branch -> PR -> main. +- **Publish:** loomweave + filigree releases (P8). +- **Depends on:** P4-W1 +- **Exit:** each of the four enrich seams has one shared vector both repos load + a drift alarm; mock-only / hand-mirror assertions removed + +**Phase exit:** legis fail-closed live-oracle primitive BUILT: a LEGIS_LIVE_ORACLE_REQUIRED env + a conftest SKIP->FAIL hook ported from wardline's _live_oracle.py + conftest pattern (legis today only skips-on-locator-absent, test_live_loomweave_oracle.py:33 — there is NO existing primitive to 'mirror'); legis<->filigree sign-off: frozen request-body contract fixture both repos load + cross-repo byte-pin drift alarm; legis live wire oracle converted from skip-clean to fail-closed via the new primitive; filigree local signature-freshness oracle vs a real legis-authored signature; signed components round-trip through both impls before pin; legis git-rename: frozen shared vectors both repos load; consumer loads them through parse_legis_rename_json (promotes the runtime warn to an assertion); rename-feed re-point completed or feed contract dropped; the four loomweave->filigree enrich seams each get a single shared golden vector both repos load + drift alarm; every new drift/e2e marker registered in all three places + asserted by test_ci_live_oracles.py in its PR + +--- + +### P7 — Unreleased / greenfield tail: warpline integration seams + Charter (5th member). DECISION GATES front-loaded. + +**Goal:** Gold the unreleased seams that are free to take clean breaks (no-backcompat applies in full) and depend on the spine above. Two clusters: (a) warpline integration seams (attest-2, reverify-worklist, legis<->warpline preflight/attestation + the Charter->legis preflight-facts envelope) and (b) Charter, the 5th member whose seams are all released:false / pre-1.0. Two hard PRECONDITIONS are promoted to NAMED DECISION GATES with owners and stated defaults so P7 cannot deadlock and never blocks closeout: if a gate is unresolved at its deadline, P0-P6 ship as a coherent milestone and the affected P7 workstream splits to a follow-on. + +**Depends on:** P2, P3, P5, P6 + +#### P7-W1 · Warpline reverify-worklist (warpline.reverify_worklist.v1) +- **Repos:** /home/john/warpline, /home/john/wardline, /home/john/filigree +- **Contract:** warpline: promote warpline.reverify_worklist.v1 to a published portable JSON Schema (contracts/schemas/warpline.reverify_worklist.v1.schema.json) as single source of truth + a PR-gated test that real reverify output validates against it. +- **Oracle:** wardline: execute the A follow-on (wardline-c0563eee74) — stop vendoring the prose-derived copy, load the warpline schema/corpus, validate parse_affected_scope against it, add an UPSTREAM_BLOB_SHA drift alarm + opt-in live recheck (worklist_drift, registered in all three places this PR); fix the already-present drift in worklist_alpha.v1.json (priority/enrichment); flip the wardline consumer to released. filigree: replace the inline _worklist() helper with the shared corpus and validate warpline_consumer ingest against it (PR-gated). +- **CI gate:** Hermetic schema/corpus validation on every PR (wardline + filigree); promote a cross-repo wire check to a fail-closed PR gate or tighten test_warpline_e2e.py to assert field-level shape. +- **Merge path:** UNRELEASED wardline consumer (free clean break) + RELEASED filigree consumer. warpline main-branch PR; wardline rcX -> PR rcX->main; filigree feat-branch -> PR -> main. wire_change=additive on the filigree (released) side (gate-only); the wardline consumer flip is a clean break (unreleased). +- **Publish:** wardline -> PyPI; filigree -> release (P8). +- **Depends on:** P5-W2 +- **Issues:** wardline-c0563eee74 +- **Exit:** warpline schema published; both consumers load it; drift alarm; wardline consumer flipped to released + +#### P7-W2 · Wardline->warpline attest / change-impact contract (wardline-attest-2) — the one BREAKING seam +- **Repos:** /home/john/wardline, /home/john/warpline +- **Contract:** wardline: build the producer (per-boundary content_hash binding key from EntityBinding.content_hash; bump ATTEST_SCHEMA wardline-attest-1->2; update MCP enum + attestation guide; CHANGELOG). Publish the frozen contract docs/contracts/wardline-attest-2.md (boundary shape, commit-as-temporal-pin, verdict vocab clean/defect/unknown, the rule that warpline never declares clean). +- **Oracle:** wardline: tests/conformance/test_attest_contract_freeze.py freezes the boundary key set + schema tag + verdict vocab; add the shared attest golden bundle both repos load + an UPSTREAM_BLOB_SHA drift alarm (attest_drift, registered in all three places this PR); make the conformance test run fail-closed on every PR. warpline: build the CONSUMER to DUAL-ACCEPT wardline-attest-1 AND wardline-attest-2 on its read path (the no-backcompat rule does NOT forbid a temporary dual-accept on a consumer READ path), perform (commit, content_hash) equality, map absence/unknown to risk=unavailable, NEVER declare clean; vendor the contract; add a warpline-side fail-closed conformance test loading the shared corpus. +- **CI gate:** wardline producer freeze + drift alarm fail-closed on PR; warpline consumer fail-closed CI gate (hermetic, no live wardline binary), exercising BOTH attest-1 and attest-2 inputs. +- **Merge path:** UNRELEASED breaking v1->v2. wire_change=BREAKING => DERIVED PROTOCOL: (1) land the warpline consumer dual-accept reader FIRST (hard exit-criterion BEFORE the wardline schema bump merges); (2) then flip the wardline producer to emit attest-2; (3) gate the wardline producer tag on the warpline release already being published; (4) drop warpline dual-accept once no attest-1 producer remains. warpline main-branch PR; wardline rcX -> PR rcX->main. +- **Publish:** wardline -> PyPI; warpline -> its release. +- **Issues:** wardline-c0563eee74 +- **Exit:** attest-2 producer + frozen contract + freeze test + shared golden + drift alarm; warpline consumer DUAL-ACCEPTS attest-1 AND attest-2 and is LANDED before the wardline schema bump merges; producer tag gated on warpline release published; attest_drift marker registered in all three places this PR + +#### P7-W3 · legis<->warpline preflight advisory read + per-SEI attestation_get + Charter->legis preflight-facts envelope +- **Repos:** /home/john/legis, /home/john/warpline, /home/john/charter +- **Contract:** PRECONDITION = DECISION GATE A (resolved early, with the stated default): warpline builds HTTP routes + freezes payloads as golden vectors, OR legis consumes warpline's existing MCP/golden-vector contract (the default). Then: legis implements attestation_get (cleared sign-offs + operator-overrides only, queried by content_hash) + the preflight client + freezes a machine-checkable attestation-get oracle fixture. ALSO: the Charter->legis preflight-facts envelope (weft.charter.preflight_facts.v1, an ADR-006 artifact distinct from Charter trace links) is OWNED HERE — Charter produces it, legis consumes it; freeze docs/.../legis-preflight-facts.json + the legis intake oracle + shared corpus + drift alarm. +- **Oracle:** legis: write the design-spec tests (test_preflight.py, test_warpline_advisory_boundary.py asserting byte-identical governance with WARPLINE_API_URL set vs unset, read_sei_attestations cases incl. AUDIT_INTEGRITY_FAILURE on tamper, outputSchema vectors). Establish a shared corpus both repos load + a byte-pin drift alarm. Add a FAIL-CLOSED CI gate (offline-injected via the P6-W1 LEGIS_LIVE_ORACLE_REQUIRED primitive so the assertion runs without a live peer; do NOT skip-clean when warpline absent). warpline: implement the consumer Rung-2 path (content_hash match against the attested SEI, never declares clean; replace the hardcoded empty governance stub). charter: produce the preflight-facts envelope; legis adds the intake oracle. +- **CI gate:** Both sides fail closed on the seam via pinned vectors; legis CI runs the advisory-boundary + drift tests as required steps under the new primitive. +- **Merge path:** UNRELEASED pre-implementation (free both sides). wire_change=additive (greenfield). legis branch -> PR -> main; warpline main-branch PR; charter branch -> PR -> main, coordinated once Gate A is decided. +- **Publish:** legis + warpline + charter releases. +- **Depends on:** P2-W2, P6-W1 +- **Exit:** Gate A resolved (or default applied); both warpline seams + the Charter->legis preflight-facts envelope implemented with contract + oracle + shared corpus + drift alarm + fail-closed CI on each side + +#### P7-W4 · Charter (5th member) — SEI consumer, requirement-identity, envelope+ontology, peer_facts, actor-registry, trace links to filigree/wardline/legis, MCP inventory +- **Repos:** /home/john/charter, /home/john/loomweave, /home/john/filigree, /home/john/wardline, /home/john/legis +- **Contract:** PRECONDITION: a 'peer targets frozen' checklist naming the specific P2-P6 workstreams Charter consumes must be green (Gate B roster resolved in P0). Charter has NO .github (no CI) and consumes a NON-EXISTENT ~/loom/sei-standard.md pointer (which, per the thesis note, was never actually populated for SEI either). Apply the kit's worked instantiation per seam: (a) SEI consumer — frozen SEI-wire schema fixture replacing the dangling pointer; (b) requirement-identity — a JSON Schema/regex pinning charter:req:KEY:NNNN as the single source (not an f-string + static fixture); (c) envelope+ontology — emit frozen JSON Schemas for every weft.charter.*.v1 envelope; (d) peer_facts/actor-registry — frozen fixtures; (e) charter->{filigree,wardline,legis} trace links — create the planned-but-absent federation fixtures. NOTE: the Charter->legis preflight-facts ENVELOPE is owned by P7-W3 (not duplicated here); the charter->legis TRACE LINKS are owned here. +- **Oracle:** Per seam: a scenario/golden oracle that asserts the REAL wire (replace hardcoded 'sei:abc123' / 'GAP-0001' literals + fabricated issue ids with live-resolved data or pinned corpora); for the two-sided ones, a shared corpus both peers load + a byte-pin drift alarm (mirror 2441c1d0 + 36c8adcf; markers registered in all three places per PR); coverage gate (COVERED_SCENARIOS == fixture ids). Implement the deferred ADR-005 live SEI resolution so the consumer side actually exists. +- **CI gate:** Create .github/workflows/ci.yml for Charter (none exists) running the conformance oracles fail-closed — a federation oracle must FAIL (not skip-clean) on drift; promote `make ci` coverage gate into hosted CI; mirror wardline d87db0cd fail-closed posture (build Charter's own primitive, do not assume one exists). +- **Merge path:** UNRELEASED (charter pre-1.0, free clean breaks). wire_change=additive (greenfield). charter branch -> PR -> main; producer-side peers (loomweave/filigree/wardline/legis) add reciprocal pins on their own native branches where a shared corpus is needed. +- **Publish:** charter is pre-1.0 (no PyPI yet); docs to charter repo markdown. +- **Depends on:** P2-W1 +- **Exit:** 'peer targets frozen' checklist green (Gate B resolved in P0); every Charter seam has a frozen contract + wire-asserting oracle + (two-sided) shared corpus + drift alarm + a fail-closed Charter CI workflow; no dangling ~/loom/sei-standard.md pointer + +**Phase exit:** DECISION GATE A (resolve EARLY, routed as a one-page ADR to the hub, owner = release coordinator): warpline producer/consumer transport. DEFAULT if unresolved by the P7 start deadline: legis consumes warpline's EXISTING MCP/golden-vector contract (requires no new warpline HTTP server), so the workstream proceeds rather than stalling; DECISION GATE B (resolve in P0, not P7): the 5th-member roster question (Charter-as-member vs warpline-as-member per the hub B-8 ruling). DEFAULT follows the task: Charter = member, warpline = integration target. If hub roster authority is later invoked, reconcile before treating Charter adapters as launch-core; warpline reverify-worklist.v1 promoted to a published JSON Schema single-source-of-truth; wardline + filigree consumers load it; byte-pin drift alarm; wardline consumer flipped to released; maps wardline-c0563eee74; wardline-attest-2: producer content_hash + schema bump + docs/contracts/wardline-attest-2.md + test_attest_contract_freeze.py + shared attest golden + drift alarm; warpline consumer built with a fail-closed conformance test; LANDING IS CONSUMER-DUAL-ACCEPT-FIRST (see P7-W2); maps wardline-c0563eee74; legis<->warpline preflight + attestation_get + the Charter->legis preflight-facts envelope: transport DECIDED via Gate A, then full kit on both sides; Charter seams (SEI consumer, requirement-identity, envelope+ontology, peer_facts, actor-registry, charter->{filigree,wardline,legis} trace links, preflight-facts envelope) each get the worked-instantiation kit + a NEW Charter CI workflow that fails closed (Charter has NO .github today). Charter start is gated on an explicit 'peer targets frozen' checklist naming the specific P2-P6 workstreams Charter consumes + +--- + +### P8 — Cross-cutting closeout: VERIFY fail-closed CI everywhere, fix the two gate gaps, populate the hub index, docs publish, coordinated releases + +**Goal:** This is a VERIFICATION sweep, not the first enforcement: per-workstream PRs in P0-P7 already registered each new marker in all three places and asserted SKIP->FAIL for it. Here we verify the topology, apply the two precise source-confirmed gate-gap fixes, populate the canonical hub seam index from the gated registry via a MANDATORY sync lint, publish docs, and cut coordinated releases with a real cross-repo publish guard. + +**Depends on:** P1, P2, P3, P4, P5, P6, P7 + +#### P8-W1 · Fail-closed CI verification sweep + the two precise gate-gap fixes (Findings 1 & 2) +- **Repos:** /home/john/wardline, /home/john/loomweave, /home/john/filigree, /home/john/legis, /home/john/charter +- **CI gate:** FINDING 2 (precise): add loomweave_drift to _live_oracle.LIVE_ORACLE_MARKERS ONLY (verified already present in pyproject markers:154 + addopts:147). Document the *_drift semantics decision (skip-when-sibling-absent at PR time; FAIL at release gate) and apply it consistently to every *_drift marker minted in P0-P7. Refactor tests/unit/test_ci_live_oracles.py to ITERATE LIVE_ORACLE_MARKERS (replace the hard-coded e2e tuple at line 28) and assert SKIP->FAIL for each; verify every P0-P7 marker is in all three places. FINDING 1: edit each repo's release.yml so publish needs: the Tier-1 conformance suite (wardline release.yml line 43 today needs: build ONLY); add a tag-time re-run + branch-protection required-status-check; add the CROSS-REPO PUBLISH GUARD (Layer-2 drift run at tag time vs the sibling's published version). Charter/legis/filigree get the equivalent in their own pipelines. +- **Merge path:** Each repo on its native branch (wardline rcX; peers their own) -> PR -> main. +- **Publish:** Gates the releases below. +- **Exit:** loomweave_drift in LIVE_ORACLE_MARKERS (one-place fix); *_drift semantics documented; test_ci_live_oracles.py iterates the frozenset and covers drift markers; no repo can publish without the conformance suite green; the cross-repo publish guard makes a half-landed two-sided wire un-taggable + +#### P8-W2 · Canonical hub seam index + doctrine promotion + MANDATORY sync lint +- **Repos:** /home/john/loom, /home/john/wardline +- **Contract:** Create ~/loom/seam-conformance-kit.md (kit doctrine) and ~/loom/seam-index.md (the cross-peer UNION index), both linked from ~/loom/doctrine.md. The index is FED by wardline's gated seam_registry.json. Define a deterministic export: seam_registry.json -> a seam-index rows artifact under the 41-row->33-seam reconciliation rule (group by canonical seam name; a seam is at_bar only when ALL its consumer rows are at_bar). Reduce the wardline kit spec to pointer-to-hub + wardline implementation notes (same shape the SEI pointer DOCUMENTS — noting the SEI promotion was never actually executed, so this is the first real instance of the pattern). +- **Oracle:** MANDATORY (not optional) sync lint: a CI check (in the hub-owning repo, or a wardline job that fetches the hub) asserts the hub index rows == the deterministic export of seam_registry.json. A registry change that is not reflected in the hub index reds the lint. +- **Merge path:** Hub repo PR (its native branching) + wardline rcX -> PR rcX->main (the spec reduction). +- **Publish:** Hub markdown; NEVER github.io. +- **Depends on:** P0-W1, P0-W2 +- **Exit:** hub seam index + kit doctrine exist, linked from doctrine.md, fed by the gated registry; the sync lint is MANDATORY and reds on registry/index divergence; wardline spec is a pointer + +#### P8-W3 · Docs publish + coordinated release cut +- **Repos:** /home/john/wardline, /home/john/loomweave, /home/john/filigree, /home/john/legis +- **CI gate:** Release gated on the Tier-1 conformance suite + the tag-time pytest -m _drift against siblings' published versions (P8-W1). +- **Merge path:** Each repo's single PR to main already merged; this is the tag/publish step. +- **Publish:** wardline: publish docs to the Astro site wardline.foundryside.dev AND repo docs/ markdown (NEVER github.io); cut wardline -> PyPI via Trusted Publishing once P8-W1 gates are green. loomweave/filigree/legis: cut their own releases. The CROSS-REPO PUBLISH GUARD (P8-W1) enforces that all RELEASED two-sided seams are both-sided within ONE window; the NAMED release-coordination owner (resolved open_question) holds the tag sequence: for additive seams require the consumer PR merged before the producer tags; for the one breaking seam (attest-2) gate the producer tag on the consumer release published. +- **Depends on:** P8-W1, P8-W2 +- **Exit:** wardline on PyPI + docs on Astro/repo markdown; peer releases cut; the publish guard demonstrably blocks a half-landed released seam from tagging + +**Phase exit:** FINDING 2 FIX (precise scope): loomweave_drift is added to _live_oracle.LIVE_ORACLE_MARKERS ONLY (it is ALREADY in pyproject markers line 154 + addopts exclusion line 147 — VERIFIED — so this is a ONE-PLACE fix, not three). DECIDE + DOCUMENT the *_drift semantics: a Layer-2 drift recheck SKIPS when the sibling is absent and must become FAIL only at the RELEASE gate; whether *_drift belongs in LIVE_ORACLE_MARKERS at all (which makes WARDLINE_LIVE_ORACLE_REQUIRED=1 fail it on ANY skip) is resolved explicitly per the chosen semantics. test_ci_live_oracles.py is REFACTORED to iterate _live_oracle.LIVE_ORACLE_MARKERS as the source of truth (today it hard-codes the e2e tuple at line 28 and does NOT cover drift markers); a verification sweep confirms every marker minted in P0-P7 is registered in all three places; FINDING 1 FIX: each repo's release/publish workflow needs:-depends on (or re-runs) the Tier-1 hermetic conformance suite (today wardline release.yml line 43 publish needs: build ONLY — VERIFIED); add a tag-time re-run + branch-protection required-status-check on the conformance job; the release runbook runs pytest -m _drift against siblings BEFORE tagging; CODEOWNERS on corpus/** and *_wire.golden.json. legis/filigree/charter get the equivalent in their own pipelines (legis using the P6-W1 primitive); CROSS-REPO PUBLISH GUARD (mechanism, not intent): for every RELEASED two-sided seam, the Layer-2 _drift recheck runs at TAG time against the sibling's PUBLISHED/tagged version (not just a local checkout), failing the tag if the peer is on the old wire; for additive-gate seams, the consumer-side PR is required MERGED before the producer tags. A NAMED coordination owner (resolved from the release-owner open_question, not deferred) holds the tag window; ~/loom/seam-conformance-kit.md + ~/loom/seam-index.md created and linked from ~/loom/doctrine.md; the index is FED by the gated wardline registry via a MANDATORY sync lint (not optional): a CI check asserts the hub index rows == the deterministic export of seam_registry.json under the 41-row->33-seam reconciliation rule (group by canonical seam name; a seam is at_bar only when ALL its consumer rows are at_bar); the wardline kit spec reduced to a pointer-to-hub; wardline docs published to the Astro site wardline.foundryside.dev AND repo docs/ markdown (NEVER github.io); CHANGELOG updated; coordinated releases cut: wardline -> PyPI via Trusted Publishing; loomweave/filigree/legis -> their own pipelines; all RELEASED two-sided seams landed both-sided within the same release window (the publish guard makes a half-landed wire un-taggable); seam_registry.json: every previously-below-bar seam now at_bar (or explicitly deferred/one_sided_na with a non-empty reason the gate enforces) + + +## Cross-cutting + +- **CI:** Two-tier fail-closed (modeled on wardline ci.yml + conftest.py + _live_oracle.py, commit d87db0cd). Tier 1 (every PR, hermetic, no live peer): the full default pytest suite — byte-corpus parity, shared-vector key-set freezes, Layer-1 blob byte-pins, scenario oracles (none carry an _e2e marker so they always run) + the gated self-scan dogfood (wardline scan src/ --fail-on ERROR) + test_seam_registry.py + the federation-status parity oracle. Tier 2 (schedule + workflow_dispatch only, because a runner cannot host compiled loomweave serve / live legis+filigree): a live-oracles matrix, one job per _e2e/_drift marker, each with WARDLINE_LIVE_ORACLE_REQUIRED=1 so conftest pytest_runtest_makereport rewrites a live-oracle SKIP into a FAILURE. FINDING 2 (PRECISE, VERIFIED): loomweave_drift is ALREADY in pyproject markers (line 154) and the addopts exclusion (line 147) — it is missing from ONLY _live_oracle.LIVE_ORACLE_MARKERS (frozenset = {network, loomweave_e2e, legis_e2e, filigree_e2e, warpline_e2e}, line 7), so the loomweave_drift fix is ONE-PLACE. The 'register in three places' rule applies to genuinely NEW _e2e/_drift markers minted by P0-P7; each is registered in all three places IN THE WORKSTREAM PR THAT MINTS IT (not deferred to P8). DECIDE + document the *_drift semantics: a Layer-2 drift recheck skips when the sibling is absent at PR time and becomes FAIL only at the RELEASE gate; whether *_drift belongs in LIVE_ORACLE_MARKERS at all (which makes WARDLINE_LIVE_ORACLE_REQUIRED=1 fail it on ANY skip) is resolved explicitly. test_ci_live_oracles.py is REFACTORED to ITERATE LIVE_ORACLE_MARKERS as the source of truth (today it hard-codes the e2e tuple at line 28 and does not cover drift markers). NET-NEW PRIMITIVES (not mirrors of an existing pattern): legis has NO fail-closed live-oracle primitive today (it skip-on-LOOMWEAVE_LIVE_ORACLE_LOCATOR-absent, test_live_loomweave_oracle.py:33) — P6-W1 BUILDS LEGIS_LIVE_ORACLE_REQUIRED + a conftest SKIP->FAIL hook ported from wardline's _live_oracle.py; Charter has NO .github at all — P7-W4 BUILDS Charter's first ci.yml and its own fail-closed primitive. +- **Docs publish:** wardline docs publish to the Astro site at wardline.foundryside.dev AND repo docs/ markdown (tree/main/docs/...) — NEVER github.io (the github.io MkDocs site was retired). The canonical cross-peer seam index lives at the hub ~/loom/seam-index.md (created in P8, fed by wardline's gated seam_registry.json via a MANDATORY sync lint, NOT an optional one), linked from ~/loom/doctrine.md, alongside the promoted kit doctrine ~/loom/seam-conformance-kit.md; the in-repo kit spec is reduced to a pointer-to-hub (the same shape the SEI pointer DOCUMENTS — noting the SEI promotion to ~/loom was never actually executed, so this is the first real instance of the pattern, not a copy of a live exemplar). Charter/legis/loomweave/filigree contract docs stay as repo markdown. +- **Release cut:** FINDING 1 (VERIFIED): wardline release.yml line 43 publish needs: build ONLY. FIX: each repo's release/publish workflow needs:-depends on (or re-runs) the Tier-1 hermetic conformance suite; add a tag-time re-run + a branch-protection required-status-check on the conformance job; the release runbook runs pytest -m _drift against siblings BEFORE tagging. CROSS-REPO PUBLISH GUARD (a real mechanism, not intent): for every RELEASED two-sided seam the Layer-2 _drift recheck runs at TAG time against the sibling's PUBLISHED/tagged version (not just a local checkout) and FAILS the tag if the peer is on the old wire; for additive-gate seams the consumer-side PR must be MERGED before the producer tags; a NAMED release-coordination owner (resolve the open_question, do not defer) holds the tag window. LANDING PROTOCOL is DERIVED from each workstream's wire_change: none/additive => safe either order behind the shared-vector PR gate; breaking (only P7-W2 attest-2 v1->v2) => consumer dual-accepts old+new first, lands, then the producer flips, then dual-accept is dropped, with the producer tag gated on the consumer release published. Branch mechanics: wardline uses ONE rcX branch + ONE PR rcX->main (a wardline house rule, NOT federation-wide); peers use their native branching (loomweave feat-branch, legis branch, filigree feat-branch, charter/warpline branch) -> PR -> main; charter stays pre-1.0 (no PyPI). wardline -> PyPI via Trusted Publishing; loomweave/filigree/legis cut their own releases under the same release window enforced by the publish guard. + +## Risks + +- **Released-seam skew window misapplied as a clean break. The no-backcompat-shims rule is scoped to UNRELEASED specs; applying it to a released two-sided wire breaks live federation the instant one side merges. Two repos cannot merge atomically, so the interval between producer and consumer merges is a broken-federation window.** → Every workstream carries wire_change (none|additive|breaking). The released seams in this program are all wire_change=additive (gate-only additions; the existing wire is UNCHANGED — we only add the shared corpus + byte-pin gate), which makes them safe to land in either order behind the shared-vector PR gate and collapses the skew risk. The ONE breaking seam (P7-W2 attest-2 v1->v2) uses the no-backcompat-permitted CONSUMER dual-accept: warpline dual-accepts attest-1+attest-2 on its read path, lands first, then the wardline producer flips, with the producer tag gated on the warpline release published. Enforced by the cross-repo publish guard, not by intent. +- **Treating the WeftHttp substrate as if per-seam oracles ride it (strategy A). Drawing blocking depends_on edges from every per-seam oracle to the transport extraction would serialize the whole program behind one internal refactor and mis-model the coupling.** → The audits prove the transport is wire-invariant internal plumbing — the oracles assert wire BYTES via _capture.py reusing the production serializer, not the HTTP transport. P1 (substrate) is required (it maps the two open issues) but is NOT a depends_on for per-seam oracles; only the envelope dedup couples, and only to the cross-surface status-envelope seam, which is closed AND registry-flipped inside P1-W2 itself. +- **Two-sided rcX/branch deadlock / half-landed seam. A released seam needs both repos green before either merges; a one-side-merged seam is a live drift window. (rcX is wardline-only; peers use their own branches.)** → Pair the two repos' branch cycles for every released two-sided workstream behind the shared vector (neither side merges until the other's half proves agreement against the same bytes). The cross-repo PUBLISH GUARD (Layer-2 drift at tag time vs the sibling's PUBLISHED version) makes a half-landed wire un-taggable, turning the discipline into a gate rather than a hope. A named release owner holds the window. +- **Canonicalization divergence baked as canonical. VERIFIED: wardline serializes some signed bodies differently from legis (legis weft_signing.py uses ensure_ascii=True; legis canonical.py uses ensure_ascii=False and its own docstring names the LATTER the byte-for-byte HMAC contract). They interoperate today only because payloads are ASCII / the verifier rehashes received bytes. Freezing the prettier serializer would bake a latent break for the first non-ASCII or reordered-key body.** → Wired into the oracle_work of EVERY workstream that mints an HMAC/signature/canonical-JSON shared vector (P2-W3 token, P3-W1 taint HMAC, P4-W2 loomweave HMAC, P6-W1 sign-off; and asserted as a regression guard on the already-gold P5/legis-artifact path) as a hard exit-criterion: the frozen vector's signature must be reproduced byte-for-byte by BOTH the producer serializer AND accepted by the real verifier, including >=1 non-ASCII-body and >=1 reordered-key case, BEFORE the UPSTREAM_BLOB_SHA is pinned. This converts risk #4's principle (§3 live re-derivation) into a per-workstream gate. +- **Skip-clean drift gates masquerading as fail-closed; AND a NEW drift/e2e marker born missing from LIVE_ORACLE_MARKERS reintroduces Finding 2 (it deselects by default and does not fail-closed when armed). Scheduling marker-registration only in P8 would let every P3-P7 drift gate be latently broken until closeout, and a workstream could be declared at_bar with a silently-skipping alarm.** → Two-layer pattern PLUS per-workstream registration discipline: (a) Layer-1 UPSTREAM_BLOB_SHA byte-pin in the DEFAULT suite (fails closed with NO peer) catches 'the vendored file changed'; (b) Layer-2 _drift live recheck catches 'upstream moved and nobody re-vendored', fail-closed at the release gate. CRITICALLY, any workstream that mints a _drift/_e2e marker MUST register it in all three places AND extend test_ci_live_oracles.py to assert SKIP->FAIL for it IN THE SAME PR; and test_seam_registry.py (P0) asserts that every marker named in any at_bar registry row is present in LIVE_ORACLE_MARKERS, so a row claiming a drift alarm whose marker skips-clean FAILS the keystone gate. P8 is a verification sweep, not the first enforcement. +- **Charter has NO CI at all and consumes a non-existent ~/loom/sei-standard.md pointer (never populated even for SEI); its 'gold' registry entries are audit-downgraded to gap. Front-loading it would gold seams against pre-frozen peer targets.** → Charter is sequenced LAST (P7-W4), gated on an explicit 'peer targets frozen' checklist naming the specific P2-P6 workstreams it consumes, and the 5th-member roster question (Gate B) is resolved in P0 not P7. P7-W4 explicitly creates Charter's first .github/workflows/ci.yml (building Charter's own fail-closed primitive) and replaces the dangling pointer with an in-repo frozen SEI-wire fixture. +- **warpline producer/consumer transport disagreement. legis's preflight design assumes HTTP GET routes warpline does NOT serve (warpline exposes data over MCP/CLI); building the legis client against warpline-as-it-exists would always read 'unavailable', silently masking the mismatch. Left unresolved it can stall P7 indefinitely and block closeout.** → Promoted to NAMED DECISION GATE A with an owner (the release coordinator) and a STATED DEFAULT: absent a ruling by the P7 start deadline, legis consumes warpline's EXISTING MCP/golden-vector contract (requires no new warpline server), so P7-W3 proceeds rather than deadlocking. If the gate stays unresolved at deadline, P0-P6 ship as a coherent milestone and P7-W3 splits to a follow-on. +- **Hub home undecided AND the program's keystone phase contains a cross-repo action with no owner. ~/loom does not exist; if the hub question stalls and P0 is gated on it, every downstream phase stalls.** → P0's SOLE blocking gate is the wardline-internal seam_registry.json + test_seam_registry.py (single-repo, self-contained). Hub creation (P0-W2) is demoted to a NON-BLOCKING parallel track completing any time before P8-W2; the wardline gated registry is the executable ground truth and the hub index is its downstream consumer (with a MANDATORY sync lint added in P8-W2). The ownership/home question is surfaced in open_questions. +- **The hub index — the program's top-level deliverable — could itself be a prose index with no enforcement (the very anti-pattern §6 item 14 names), since the 41-row wardline-internal registry must be reconciled to the 33-seam cross-peer union.** → P8-W2 makes the hub-side sync lint MANDATORY (not optional): a deterministic export from seam_registry.json under the 41-row->33-seam reconciliation rule (group by canonical seam name; a seam is at_bar only when ALL its consumer rows are at_bar), and a CI check asserting the hub index == that export. A registry change unreflected in the index reds the lint. +- **Hub-as-producer contracts (weft reason-vocab, and by extension the weft.toml schema and the token GOLDEN_KEY) have no release/versioning discipline: when the hub edits the 11-class set, nothing tells the three consumers to re-vendor.** → P5-W4 adds hub-contract release discipline generalized in cross_cutting.release_cut: the hub contract carries a version + blob hash in _meta; a hub edit bumps the version and is announced via the hub index; each consumer's Layer-2 _drift recheck against the hub FAILS until re-vendored; a NAMED owner runs the hub-change->consumer-re-vendor cycle (ties to the release-owner open_question). + +## Open questions + +- SEI 'no work' reconciliation: DECISION TAKEN HERE — SEI's oracle/scenario/determinism machinery is the untouched gold reference (no work); the two consumer-side byte-pin closures (P2-W1, P2-W2) plus the loomweave-side change-control note are the cheapest possible §6-item-8 demo. Confirm this is the intended reading of 'SEI = done'. NOTE: the kit §8 cites an SEI 'promotion to ~/loom/sei-standard.md' precedent that was NEVER actually executed (~/loom does not exist), so the 'same shape as the SEI pointer' reduction has no live exemplar — P0 creates the hub for the first time. +- Hub home + ownership: ~/loom does not exist (verified). Where does the Weft federation hub live (repo URL, owner), and who reviews the cross-repo hub PR that creates seam-conformance-kit.md + seam-index.md and runs the mandatory sync lint? Until answered, the wardline gated registry is the executable ground truth and P0 does not block on the hub. +- Peer branch model: rcX (one branch + one PR to main) is a wardline house rule (memory: feedback_single_rc_branch_no_scatter), NOT federation-wide. VERIFIED current peer branches: loomweave=docs/language-support-coverage, legis=warpline-interfaces, filigree=feat/warpline-commit-anchor, charter=main, warpline=main. Confirm each peer maintainer's branching + release model (the plan assumes feature-branch -> PR -> main / their own release.yml per repo). +- Release coordination owner (RESOLVE before P8, do not defer): with up to 5 repos cutting releases for the released two-sided seams, who owns the coordinated tag window and the cross-repo publish guard? The plan REQUIRES a named owner so the publish guard (Layer-2 drift at tag time vs the sibling's published version) is centrally enforced rather than per-pair-hoped. +- Decision Gate A (warpline transport, P7-W3): confirm the stated default (legis consumes warpline's existing MCP/golden-vector contract, no new warpline HTTP server) is acceptable, or rule that warpline builds HTTP routes + golden vectors. Owner = release coordinator; deadline = P7 start. +- Decision Gate B (5th-member roster, resolve in P0): the task names Charter as the 5th member; the registry discovery_notes record a hub B-8 ruling admitting Warpline as the 5th member with Charter a separate planned-integration repo. This plan follows the task (Charter = member, Warpline = integration target). If hub roster authority is invoked, reconcile before treating Charter adapters as launch-core. +- weft.toml config seam: registry bar_verdict=deferred with an ENFORCED deferred_reason (the gate reds the row until re-triaged). The load-bearing two-sided contract (sibling [X].url cross-read) is reserved-not-implemented on BOTH sides and the schema is DRAFT (loomweave-009, four open questions). Confirm it is acceptable to defer to a follow-on once the hub schema merges and a peer ships the cross-reader. +- One-sided dispositions: loomweave.yaml and the two producer-only seams (WeftHttp transport, MCP B1/B2) are recorded as ENFORCED one_sided_na registry rows (§6 item 8 N/A, not failed). loomweave.yaml is FINE AS-IS (optional one-line golden); MCP B1/B2 gets the producer-side freeze in P5-W6. Confirm these one-sided closes are acceptable. +- *_drift marker semantics (resolve in P8-W1): should _drift markers be in LIVE_ORACLE_MARKERS at all? Adding them makes WARDLINE_LIVE_ORACLE_REQUIRED=1 fail them on ANY skip, which may be wrong for a Layer-2 opt-in whose intended posture is skip-at-PR-time / fail-at-release-gate. The plan decides this explicitly and refactors test_ci_live_oracles.py to iterate the frozenset accordingly. + diff --git a/docs/superpowers/plans/2026-06-25-project-root-anchored-artifacts.md b/docs/superpowers/plans/2026-06-25-project-root-anchored-artifacts.md new file mode 100644 index 00000000..375b965f --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-project-root-anchored-artifacts.md @@ -0,0 +1,1105 @@ +# Project-Root-Anchored Scan Artifacts + Doctor Hygiene — 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:** Make `wardline scan` write its default findings artifact to one standard location anchored to the weft-project root (not the scan cwd), and make `wardline doctor --repair` set up `.gitignore` for it and sweep stray managed artifacts (deletion reachable from CLI and the MCP `doctor` tool). + +**Architecture:** Part 1 adds two pure helpers to `core/paths.py` (`project_root_for`, `artifacts_dir`) that reuse the existing `weft_state_dir`/`enclosing_project_root` machinery, and re-anchors `core/artifacts.py` from the scan path to the project root. Part 2 adds two `DoctorCheck`-returning helpers to `install/doctor.py` (`_check_gitignore`, `_sweep_stray_artifacts`) wired into `machine_readable_doctor` and both `cli/doctor.py` render branches, plus the MCP `doctor` tool destructive-hint flip. + +**Tech Stack:** Python 3.11+ (stdlib `pathlib`/`os`/`re`/`tomllib`), `click` CLI, `pytest`. Base package stays zero-dependency. + +**Worktree:** Implement in `/home/john/wardline-artifacts` (branch `feat/project-root-anchored-artifacts`, off `release/consolidation-2026-06-25`). All paths below are relative to that worktree root. Run `git` only here. + +**Spec:** `docs/superpowers/specs/2026-06-25-wardline-project-root-anchored-artifacts-design.md` (this worktree). Read it before starting; every task implements a part of it. + +## Global Constraints + +- Base package stays **zero-dependency**: only stdlib + already-present imports in touched modules. No new third-party imports. +- `weft.toml` is **untrusted** when scanning an untrusted repo: every new path resolution confines under the project root and every new write/delete is no-follow. +- No new config key. `artifacts.dir` (default `.wardline`) / `artifacts.retain` (default `20`) keep their meaning; only the *anchor* of `artifacts.dir` changes. +- Public signatures of `artifacts.write_scan_artifact` / `artifacts.timestamped_scan_artifact` stay unchanged (they still take the scan `root`). +- Run the suite with the project venv: `.venv/bin/pytest`. Lint/type with `.venv/bin/ruff check .` and `.venv/bin/mypy src`. Keep both clean. +- Commit after every task (each task ends green). + +--- + +## File structure + +| File | Change | Responsibility | +|------|--------|----------------| +| `src/wardline/core/paths.py` | modify | Own `DEFAULT_ARTIFACT_DIR`; add `project_root_for`, `artifacts_dir` | +| `src/wardline/core/config.py` | modify | Re-export `DEFAULT_ARTIFACT_DIR` from `paths` (drop local literal) | +| `src/wardline/core/artifacts.py` | modify | Anchor artifact dir + confinement base to project root | +| `src/wardline/core/run.py` | modify | Amend `WLN-ENGINE-NESTED-SCAN-ROOT` message clause | +| `src/wardline/core/discovery.py` | modify | Public alias `WALK_SKIP_DIRS` for `_ALWAYS_SKIP` | +| `src/wardline/install/doctor.py` | modify | `DoctorCheck` `removed`/`review` fields; `_check_gitignore`; `_sweep_stray_artifacts`; wire into `machine_readable_doctor` | +| `src/wardline/cli/doctor.py` | modify | Render the two new checks in `--repair` + check-only branches | +| `src/wardline/cli/scan.py` | modify | Fix the `doctor --repair` hint to point at the project root | +| `src/wardline/mcp/server.py` | modify | Flip `_DOCTOR_TOOL` `destructiveHint`; note deletion in description | +| `docs/getting-started.md`, `docs/guides/configuration.md`, `docs/guides/agents.md`, `CHANGELOG.md` | modify | Document the anchor change + doctor hygiene | +| `tests/unit/core/test_paths.py` | modify/create | `project_root_for`/`artifacts_dir` matrix | +| `tests/unit/core/test_artifacts.py` | modify | Anchoring + retention | +| `tests/unit/install/test_doctor_hygiene.py` | create | gitignore + sweep + wiring + MCP confinement | +| `tests/unit/cli/test_scan_artifacts.py` (or existing scan CLI test) | modify | End-to-end subdir/root/unfederated anchoring | + +--- + +## Task 1: `paths` helpers + `config` re-export + +**Files:** +- Modify: `src/wardline/core/paths.py` +- Modify: `src/wardline/core/config.py:25` +- Test: `tests/unit/core/test_paths.py` + +**Interfaces:** +- Produces: `paths.DEFAULT_ARTIFACT_DIR: str` (`".wardline"`); `paths.project_root_for(scan_path: Path) -> Path` (always fully-resolved); `paths.artifacts_dir(scan_path: Path, artifacts_dir_value: str) -> Path` (resolved, confined under the project root; escape → default `/.wardline`). +- Consumes: existing `paths.enclosing_project_root`. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/core/test_paths.py` (create the file with the standard imports if absent: `from pathlib import Path`, `import pytest`, `from wardline.core import paths`): + +```python +def _mark_project(root: Path) -> None: + (root / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + +def test_project_root_for_self_when_marked(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.project_root_for(tmp_path) == tmp_path.resolve() + +def test_project_root_for_climbs_to_enclosing(tmp_path: Path) -> None: + _mark_project(tmp_path) + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + assert paths.project_root_for(sub) == tmp_path.resolve() + +def test_project_root_for_unfederated_is_self(tmp_path: Path) -> None: + sub = tmp_path / "a" / "b" + sub.mkdir(parents=True) + assert paths.project_root_for(sub) == sub.resolve() + +def test_artifacts_dir_default(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, ".wardline") == (tmp_path.resolve() / ".wardline") + +def test_artifacts_dir_relative_override(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, "out/wl") == (tmp_path.resolve() / "out" / "wl") + +def test_artifacts_dir_absolute_inside_honored(tmp_path: Path) -> None: + _mark_project(tmp_path) + inside = tmp_path.resolve() / "build" / "wl" + assert paths.artifacts_dir(tmp_path, str(inside)) == inside + +def test_artifacts_dir_absolute_outside_falls_back(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, "/etc/wardline") == (tmp_path.resolve() / ".wardline") + +def test_artifacts_dir_dotdot_escape_falls_back(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, "../../etc") == (tmp_path.resolve() / ".wardline") + +def test_artifacts_dir_anchors_to_enclosing_for_subdir(tmp_path: Path) -> None: + _mark_project(tmp_path) + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + assert paths.artifacts_dir(sub, ".wardline") == (tmp_path.resolve() / ".wardline") +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/core/test_paths.py -q` +Expected: FAIL — `AttributeError: module 'wardline.core.paths' has no attribute 'project_root_for'`. + +- [ ] **Step 3: Implement the helpers in `paths.py`** + +Add near the top (after `_WEFT_DIR = ".weft"`): + +```python +DEFAULT_ARTIFACT_DIR = ".wardline" +``` + +Add at the end of `paths.py`: + +```python +def project_root_for(scan_path: Path) -> Path: + """The weft-project root governing a scan of *scan_path* (always resolved). + + enclosing_project_root() returns the nearest STRICT ancestor carrying project + markers, or None when scan_path itself is a root OR no ancestor is one. In both + None cases the governing root is scan_path itself. + """ + return enclosing_project_root(scan_path) or scan_path.resolve() + + +def artifacts_dir(scan_path: Path, artifacts_dir_value: str) -> Path: + """Resolved scan-artifact directory, anchored to project_root_for(scan_path). + + Mirrors weft_state_dir's confinement: a relative value resolves under the project + root; an absolute value is honored only if inside it; any value resolving OUTSIDE + (absolute elsewhere or a ``..`` escape) falls back to the default ``.wardline`` + under the project root. weft.toml is untrusted input, so this denies a malicious + artifacts.dir both a write-redirect and an exit-2 DoS. + """ + project_root = project_root_for(scan_path) # already fully resolved + default = project_root / DEFAULT_ARTIFACT_DIR + candidate = Path(artifacts_dir_value) + resolved = (candidate if candidate.is_absolute() else project_root / candidate).resolve() + try: + resolved.relative_to(project_root) + except ValueError: + return default + return resolved +``` + +- [ ] **Step 4: Re-export from `config.py` (drop the local literal)** + +In `src/wardline/core/config.py`, change the import block (currently lines 19-22) to add `DEFAULT_ARTIFACT_DIR`: + +```python +from wardline.core.paths import ( + DEFAULT_ARTIFACT_DIR, + legacy_sibling_dir, + sibling_state_dir, +) +``` + +And delete the local literal at line 25 (`DEFAULT_ARTIFACT_DIR = ".wardline"`), keeping `DEFAULT_ARTIFACT_RETAIN = 20`. `ArtifactSettings.dir`'s default still resolves to the imported name. (No cycle: `config.py` already imports from `paths.py`, and `paths.py` imports nothing from `config.py`.) + +- [ ] **Step 5: Run tests + lint/type** + +Run: `.venv/bin/pytest tests/unit/core/test_paths.py -q && .venv/bin/ruff check src/wardline/core/paths.py src/wardline/core/config.py && .venv/bin/mypy src/wardline/core/paths.py src/wardline/core/config.py` +Expected: PASS, clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/wardline/core/paths.py src/wardline/core/config.py tests/unit/core/test_paths.py +git commit -m "feat(paths): project_root_for + artifacts_dir helpers (own DEFAULT_ARTIFACT_DIR)" +``` + +--- + +## Task 2: re-anchor `artifacts.py` to the project root + +**Files:** +- Modify: `src/wardline/core/artifacts.py` +- Test: `tests/unit/core/test_artifacts.py` + +**Interfaces:** +- Consumes: `paths.project_root_for`, `paths.artifacts_dir` (Task 1). +- Produces: `write_scan_artifact(root, fmt, config, content)` / `timestamped_scan_artifact(root, fmt, config)` unchanged signatures, now writing under `project_root_for(root)/`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/core/test_artifacts.py`: + +```python +from wardline.core import artifacts +from wardline.core.config import WardlineConfig + +def _project(tmp_path): + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + +def test_subdir_scan_anchors_artifact_to_project_root(tmp_path): + _project(tmp_path) + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + out = artifacts.write_scan_artifact(sub, "jsonl", WardlineConfig(), "{}\n") + assert out.parent == (tmp_path.resolve() / ".wardline") + assert out.read_text(encoding="utf-8") == "{}\n" + +def test_root_scan_unchanged(tmp_path): + _project(tmp_path) + out = artifacts.write_scan_artifact(tmp_path, "jsonl", WardlineConfig(), "{}\n") + assert out.parent == (tmp_path.resolve() / ".wardline") + +def test_unfederated_scan_writes_at_scan_path(tmp_path): + sub = tmp_path / "loose" + sub.mkdir() + out = artifacts.write_scan_artifact(sub, "jsonl", WardlineConfig(), "{}\n") + assert out.parent == (sub.resolve() / ".wardline") + +def test_escaping_artifacts_dir_falls_back_under_project_root(tmp_path): + _project(tmp_path) + cfg = WardlineConfig(artifacts=__import__("wardline.core.config", fromlist=["ArtifactSettings"]).ArtifactSettings(dir="../../etc")) + out = artifacts.write_scan_artifact(tmp_path, "jsonl", cfg, "{}\n") + assert out.parent == (tmp_path.resolve() / ".wardline") +``` + +(If `WardlineConfig`'s `artifacts` field name differs, read `config.py` `WardlineConfig` and use the real field; the spec/code call it `config.artifacts.dir`.) + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/core/test_artifacts.py -q -k anchor` +Expected: FAIL — artifact lands under the subdir, not the project root. + +- [ ] **Step 3: Implement the anchor switch** + +In `src/wardline/core/artifacts.py`: + +- Add import: `from wardline.core import paths`. +- Change `_artifact_dir`: + +```python +def _artifact_dir(root_resolved: Path, config: WardlineConfig) -> Path: + return paths.artifacts_dir(root_resolved, config.artifacts.dir) +``` + +- In `timestamped_scan_artifact` and `write_scan_artifact`, compute the project root once and use it as the confinement base everywhere `root_resolved` was passed to `safe_project_path`: + +```python +def timestamped_scan_artifact(root: Path, fmt: str, config: WardlineConfig) -> Path: + project_root = paths.project_root_for(root) + artifact_dir = _artifact_dir(root, config) + suffix = artifact_suffix(fmt) + for candidate in _timestamped_candidates(project_root, artifact_dir, suffix): + if not candidate.exists(): + return candidate + raise WardlineError(f"{suffix}: could not allocate a unique scan artifact name") + + +def write_scan_artifact(root: Path, fmt: str, config: WardlineConfig, content: str) -> Path: + project_root = paths.project_root_for(root) + artifact_dir = _artifact_dir(root, config) + suffix = artifact_suffix(fmt) + for candidate in _timestamped_candidates(project_root, artifact_dir, suffix): + try: + _write_text_exclusive(project_root, candidate, content, label=candidate.name) + except FileExistsError: + continue + prune_scan_artifacts(project_root, candidate, fmt, config.artifacts.retain) + return candidate + raise WardlineError(f"{suffix}: could not allocate a unique scan artifact name") +``` + +Note `_artifact_dir` now takes the scan `root` (it calls `paths.artifacts_dir` which resolves internally), not the pre-resolved scan path. `prune_scan_artifacts(root, ...)` already calls `root.resolve()` internally; passing `project_root` (already resolved) is idempotent and correct. + +- [ ] **Step 4: Run to verify pass** + +Run: `.venv/bin/pytest tests/unit/core/test_artifacts.py -q` +Expected: PASS (existing retention/collision tests still pass — pruning still runs in `artifact.parent`). + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/core/artifacts.py tests/unit/core/test_artifacts.py +git commit -m "feat(artifacts): anchor default scan artifacts to the weft-project root" +``` + +--- + +## Task 3: amend the `WLN-ENGINE-NESTED-SCAN-ROOT` message + +**Files:** +- Modify: `src/wardline/core/run.py:441-446` +- Test: existing run/scan test (add an assertion) or `tests/unit/core/test_run.py` + +**Interfaces:** none new — message-text-only change. + +- [ ] **Step 1: Write the failing test** + +Add a test that scans a subdir of a marked project and asserts the new message text: + +```python +def test_nested_scan_root_message_drops_output_clause(tmp_path): + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + (sub / "m.py").write_text("x = 1\n", encoding="utf-8") + # Use the same entry point existing run tests use to get findings; assert: + msgs = [f.message for f in _run_and_collect(sub)] # adapt to the test module's helper + nested = [m for m in msgs if "is a subdirectory of the weft project" in m] + assert nested, "expected the nested-scan-root FACT" + assert "output defaults under the subdirectory" not in nested[0] + assert "baseline/waivers/judged state is not loaded" in nested[0] +``` + +(Adapt `_run_and_collect` to whatever the run-test module already uses to invoke a scan and read `Finding.message`.) + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/core/test_run.py -q -k nested_scan_root_message` +Expected: FAIL — current message still contains "output defaults under the subdirectory". + +- [ ] **Step 3: Edit the message in `run.py`** + +Change the `message=(...)` block (lines 441-446) to drop the output clause: + +```python + message=( + f"scan root '{rel.as_posix()}' is a subdirectory of the weft project at " + f"{enclosing}: {qualname_clause}and the project's baseline/waivers/judged " + "state is not loaded. Scan the project root for federation-stable results." + ), +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `.venv/bin/pytest tests/unit/core/test_run.py -q -k nested_scan_root` +Expected: PASS. Also run any glossary/vocabulary or golden test that pins this message and update the golden if it asserts the old text: `.venv/bin/pytest -q -k "nested or glossary"`. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/core/run.py tests/unit/core/test_run.py +git commit -m "fix(run): drop stale 'output defaults under the subdirectory' clause post-anchor" +``` + +--- + +## Task 4: end-to-end scan-CLI anchoring tests + +**Files:** +- Test: `tests/unit/cli/test_scan_artifacts.py` (create) or extend the existing scan CLI test module. + +**Interfaces:** none — integration coverage of Tasks 1-3 through `cli/scan.py`. + +- [ ] **Step 1: Write the tests** (use the existing scan-CLI invocation helper / click `CliRunner`) + +```python +def test_cli_subdir_scan_writes_artifact_at_project_root(tmp_path, run_scan_cli): + (tmp_path / "weft.toml").write_text("[wardline]\nsource_roots = [\".\"]\n", encoding="utf-8") + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + (sub / "m.py").write_text("x = 1\n", encoding="utf-8") + run_scan_cli([str(sub)]) # default output -> artifact written + artifacts_dir = tmp_path / ".wardline" + assert any(p.name.endswith("-findings.jsonl") for p in artifacts_dir.iterdir()) + assert not (sub / ".wardline").exists() + +def test_cli_explicit_output_unaffected(tmp_path, run_scan_cli): + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + out = tmp_path / "ci" / "findings.jsonl" + run_scan_cli([str(tmp_path), "--output", str(out)]) + assert out.exists() + assert not (tmp_path / ".wardline").exists() +``` + +Add the unfederated-fallback and custom-`artifacts.dir` cases analogously (assert the artifact lands at `/.wardline` and `/out/wl` respectively). For the MCP `scan` no-disk-artifact regression, assert `mcp.server._scan(...)` leaves no `.wardline` under root (the existing MCP scan test module likely already has a fixture). + +- [ ] **Step 2: Run to verify** — failures here would indicate Tasks 1-2 wiring gaps; fix in those tasks, not here. + +Run: `.venv/bin/pytest tests/unit/cli/test_scan_artifacts.py -q` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/unit/cli/test_scan_artifacts.py +git commit -m "test(scan): pin project-root artifact anchoring end-to-end" +``` + +--- + +## Task 5: export the shared walk-skip set + +**Files:** +- Modify: `src/wardline/core/discovery.py:16-30` + +**Interfaces:** +- Produces: `discovery.WALK_SKIP_DIRS: frozenset[str]` (public alias of `_ALWAYS_SKIP`). + +- [ ] **Step 1: Add the public alias** (no behavior change; trivial) + +After the `_ALWAYS_SKIP = frozenset({...})` definition add: + +```python +# Public alias for reuse by the doctor stray-artifact sweep (single source of the +# hard directory skip-set). Keep in sync with _ALWAYS_SKIP. +WALK_SKIP_DIRS = _ALWAYS_SKIP +``` + +- [ ] **Step 2: Run + commit** + +Run: `.venv/bin/ruff check src/wardline/core/discovery.py && .venv/bin/pytest tests/unit/core/test_discovery.py -q` +Expected: PASS. + +```bash +git add src/wardline/core/discovery.py +git commit -m "refactor(discovery): expose WALK_SKIP_DIRS for the doctor sweep" +``` + +--- + +## Task 6: `DoctorCheck` gains `removed`/`review` payload fields + +**Files:** +- Modify: `src/wardline/install/doctor.py:45-60` +- Test: `tests/unit/install/test_doctor_hygiene.py` (create) + +**Interfaces:** +- Produces: `DoctorCheck(id, status, fixed=False, message=None, removed=(), review=())`; `to_dict()` includes `removed`/`review` only when non-empty. + +- [ ] **Step 1: Write the failing test** + +```python +from wardline.install.doctor import DoctorCheck + +def test_doctorcheck_to_dict_includes_payload_when_present(): + c = DoctorCheck("stray_artifacts", "ok", fixed=True, removed=["a/.wardline/x"], review=["findings.jsonl"]) + d = c.to_dict() + assert d["removed"] == ["a/.wardline/x"] + assert d["review"] == ["findings.jsonl"] + +def test_doctorcheck_to_dict_omits_empty_payload(): + c = DoctorCheck("gitignore", "ok") + assert "removed" not in c.to_dict() and "review" not in c.to_dict() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k doctorcheck` +Expected: FAIL — `DoctorCheck.__init__` rejects `removed`/`review`. + +- [ ] **Step 3: Extend the dataclass** + +```python +from collections.abc import Sequence + +@dataclass(frozen=True, slots=True) +class DoctorCheck: + id: str + status: str + fixed: bool = False + message: str | None = None + removed: Sequence[str] = () + review: Sequence[str] = () + + @property + def ok(self) -> bool: + return self.status == "ok" + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = {"id": self.id, "status": self.status, "fixed": self.fixed} + if self.message: + data["message"] = self.message + if self.removed: + data["removed"] = list(self.removed) + if self.review: + data["review"] = list(self.review) + return data +``` + +- [ ] **Step 4: Run + commit** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k doctorcheck && .venv/bin/mypy src/wardline/install/doctor.py` +Expected: PASS, clean. + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_hygiene.py +git commit -m "feat(doctor): DoctorCheck carries removed/review payload lists" +``` + +--- + +## Task 7: `_check_gitignore` helper + +**Files:** +- Modify: `src/wardline/install/doctor.py` (new helper + imports) +- Test: `tests/unit/install/test_doctor_hygiene.py` + +**Interfaces:** +- Consumes: `paths.artifacts_dir`, `paths.project_root_for`, `config.load`, `safe_paths.safe_read_text_if_regular`, `safe_paths.safe_write_text`. +- Produces: `_check_gitignore(proj: Path, *, fix: bool) -> DoctorCheck` (id `"gitignore"`). **Advisory status contract:** success (already-present, or repaired) → `status="ok"` (`fixed=True` when it wrote); a detected-but-unfixed gap in `fix=False` → still `status="ok"` with the gap in `message`; only a write refusal (symlinked `.gitignore`) → `status="error"`. Never returns `"created"`/`"updated"` (see the status-contract note under Step 3). + +- [ ] **Step 1: Write the failing tests** + +```python +from pathlib import Path +from wardline.install.doctor import _check_gitignore + +def _proj(tmp_path: Path) -> Path: + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + return tmp_path + +def test_gitignore_created_then_idempotent(tmp_path): + proj = _proj(tmp_path) + c1 = _check_gitignore(proj, fix=True) + assert c1.status == "ok" and c1.fixed is True and "added" in (c1.message or "") # success => ok (not created/updated) + body = (proj / ".gitignore").read_text(encoding="utf-8") + assert ".wardline/" in body and "findings.jsonl" in body + c2 = _check_gitignore(proj, fix=True) + assert c2.status == "ok" + assert (proj / ".gitignore").read_text(encoding="utf-8") == body # no duplicate append + +def test_gitignore_tolerates_existing_bare_entry(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text(".wardline\n", encoding="utf-8") # no slash + c = _check_gitignore(proj, fix=True) + body = (proj / ".gitignore").read_text(encoding="utf-8") + assert body.count(".wardline") == 1 + (1 if "findings.jsonl" not in ".wardline" else 0) # .wardline not re-added + assert "findings.jsonl" in body + +def test_gitignore_crlf_idempotent(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text(".wardline/\r\nfindings.jsonl\r\n", encoding="utf-8") + c = _check_gitignore(proj, fix=True) + assert c.status == "ok" # both already present despite CRLF + +def test_gitignore_preserves_existing_content(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text("# mine\n*.log\n", encoding="utf-8") + _check_gitignore(proj, fix=True) + body = (proj / ".gitignore").read_text(encoding="utf-8") + assert "*.log" in body and "# mine" in body + +def test_gitignore_check_only_no_write(tmp_path): + proj = _proj(tmp_path) + c = _check_gitignore(proj, fix=False) + assert c.status == "ok" # advisory — does NOT fail aggregation + assert "missing" in (c.message or "") # but the gap is reported + assert not (proj / ".gitignore").exists() + +def test_gitignore_commented_entry_does_not_satisfy(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text("#.wardline/\n!findings.jsonl\n", encoding="utf-8") + c = _check_gitignore(proj, fix=False) + assert "missing" in (c.message or "") # commented/negated lines don't count as present + +def test_gitignore_symlink_reports_error_not_abort(tmp_path): + import os + proj = _proj(tmp_path) + target = tmp_path.parent / "evil" + target.write_text("", encoding="utf-8") + os.symlink(target, proj / ".gitignore") # untrusted-repo surface + c = _check_gitignore(proj, fix=True) + assert c.status == "error" and "symlink" in (c.message or "") + assert target.read_text(encoding="utf-8") == "" # never written through the link +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k gitignore` +Expected: FAIL — `_check_gitignore` undefined. + +- [ ] **Step 3: Implement the helper** + +Add imports at the top of `install/doctor.py`: `import re`, `from wardline.core import paths`, `from wardline.core.config import ArtifactSettings`. Then: + +```python +_GITIGNORE_HEADER = "# Wardline scan artifacts" + + +def _artifacts_dir_relname(proj: Path) -> str: + """The project-root-relative dir name to ignore (always in-tree by construction).""" + try: + cfg = load(weft_config_path(proj)) + artifacts_dir_value = cfg.artifacts.dir + except (ConfigError, OSError): + artifacts_dir_value = ArtifactSettings().dir + resolved = paths.artifacts_dir(proj, artifacts_dir_value) + rel = resolved.relative_to(proj.resolve()) + return rel.as_posix() + + +def _gitignore_present_entries(text: str) -> set[str]: + out: set[str] = set() + for raw in text.splitlines(): # handles \n, \r\n, \r + line = raw.strip() + if not line or line.startswith("#") or line.startswith("!"): + continue + out.add(line.rstrip("/")) # trailing-slash tolerant + return out + + +def _check_gitignore(proj: Path, *, fix: bool) -> DoctorCheck: + gitignore = proj / ".gitignore" + dir_entry = _artifacts_dir_relname(proj) + "/" + wanted = [dir_entry, "findings.jsonl"] + existing = safe_read_text_if_regular(proj, gitignore, label=".gitignore") or "" + present = _gitignore_present_entries(existing) + missing = [w for w in wanted if w.rstrip("/") not in present] + if not missing: + return DoctorCheck("gitignore", "ok", message="present") + if not fix: + # ADVISORY: a missing ignore line must NOT make .ok False — that would flip + # machine_readable_doctor's all(check.ok) and fail `doctor --fix` / MCP doctor. + # Status stays "ok"; the gap is surfaced in the message. + return DoctorCheck("gitignore", "ok", message="missing ignore lines: " + ", ".join(missing) + " (run --repair)") + block = "\n".join([_GITIGNORE_HEADER, *missing]) + "\n" + if existing and not existing.endswith("\n"): + block = "\n" + block # don't concatenate the header onto a no-newline last line + try: + safe_write_text(proj, gitignore, existing + block, label=".gitignore") + except WardlineError: + # A symlinked/escaping .gitignore is an untrusted-repo surface (spec §8). Report a + # single check error rather than letting the raise abort the whole doctor run. + return DoctorCheck("gitignore", "error", message="refused to write through a symlinked .gitignore") + return DoctorCheck("gitignore", "ok", fixed=True, message="added " + ", ".join(missing)) +``` + +**Status contract (must-fix from plan review):** these checks are *advisory*. A successful +repair returns `status="ok"` + `fixed=True` (never `"created"`/`"updated"` — `DoctorCheck.ok` +is `status == "ok"`, doctor.py:53, and `machine_readable_doctor` does `all(check.ok)`, +doctor.py:571, so a non-`"ok"` success status makes a clean `doctor --fix`/MCP +`doctor(repair:true)` report `ok:false` and exit 1). A detected-but-unfixed gap also stays +`"ok"` (advisory). The only `"error"` is a genuine write refusal (symlinked `.gitignore`), +caught here instead of propagating. `WardlineError` is already imported at doctor.py:16. + +- [ ] **Step 4: Run to verify pass** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k gitignore` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_hygiene.py +git commit -m "feat(doctor): _check_gitignore — idempotent, CRLF-safe managed block" +``` + +--- + +## Task 8: `_sweep_stray_artifacts` helper + +**Files:** +- Modify: `src/wardline/install/doctor.py` +- Test: `tests/unit/install/test_doctor_hygiene.py` + +**Interfaces:** +- Consumes: `artifacts._managed_artifact_pattern`, `artifacts._is_regular_file_no_follow`, `paths.artifacts_dir`, `paths._has_project_markers`, `discovery.WALK_SKIP_DIRS`, `safe_paths.safe_project_path`. +- Produces: `_sweep_stray_artifacts(proj: Path, *, fix: bool) -> DoctorCheck` (id `"stray_artifacts"`). Deletes managed-pattern files inside non-standard `.wardline/` dirs when `fix=True`; reports unstamped + bare-managed strays as `review`; never mutates when `fix=False`. + +- [ ] **Step 1: Write the failing tests** + +```python +import os +from pathlib import Path +from wardline.install.doctor import _sweep_stray_artifacts + +STAMP = "20260624T111539Z" + +def _stray(proj: Path, rel: str) -> Path: + p = proj / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("{}\n", encoding="utf-8") + return p + +def test_sweep_removes_nested_wardline_managed_file(tmp_path): + proj = _proj(tmp_path) + stray = _stray(proj, f"src/pkg/.wardline/{STAMP}-findings.jsonl") + c = _sweep_stray_artifacts(proj, fix=True) + assert not stray.exists() + assert not stray.parent.exists() # emptied .wardline removed + assert any(str(stray) in r or "src/pkg/.wardline" in r for r in c.removed) + +def test_sweep_keeps_standard_dir(tmp_path): + proj = _proj(tmp_path) + keep = _stray(proj, f".wardline/{STAMP}-findings.jsonl") + _sweep_stray_artifacts(proj, fix=True) + assert keep.exists() # standard dir is skipped + +def test_sweep_reports_unstamped_and_bare_managed(tmp_path): + proj = _proj(tmp_path) + bare = _stray(proj, "findings.jsonl") + bare_managed = _stray(proj, f"logs/{STAMP}-findings.jsonl") # managed name, NOT in a .wardline/ dir + c = _sweep_stray_artifacts(proj, fix=True) + assert bare.exists() and bare_managed.exists() + assert any("findings.jsonl" in r for r in c.review) + assert any(f"{STAMP}-findings.jsonl" in r for r in c.review) + +def test_sweep_check_only_no_delete(tmp_path): + proj = _proj(tmp_path) + stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + c = _sweep_stray_artifacts(proj, fix=False) + assert stray.exists() + assert not c.fixed + +def test_sweep_does_not_descend_symlinked_dir(tmp_path): + proj = _proj(tmp_path) + outside = tmp_path.parent / "outside_wl" + (outside / ".wardline").mkdir(parents=True) + target = outside / ".wardline" / f"{STAMP}-findings.jsonl" + target.write_text("{}\n", encoding="utf-8") + os.symlink(outside, proj / "linked") + _sweep_stray_artifacts(proj, fix=True) + assert target.exists() # never followed out of root + +def test_sweep_does_not_unlink_symlinked_managed_file(tmp_path): + proj = _proj(tmp_path) + real = tmp_path.parent / "real.jsonl" + real.write_text("{}\n", encoding="utf-8") + wd = proj / "src" / ".wardline" + wd.mkdir(parents=True) + os.symlink(real, wd / f"{STAMP}-findings.jsonl") + _sweep_stray_artifacts(proj, fix=True) + assert real.exists() # symlink skipped, target intact + +def test_sweep_stops_at_nested_project_root(tmp_path): + proj = _proj(tmp_path) + nested = proj / "vendor" / "subproj" + nested.mkdir(parents=True) + (nested / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + keep = _stray(proj, f"vendor/subproj/.wardline/{STAMP}-findings.jsonl") + _sweep_stray_artifacts(proj, fix=True) + assert keep.exists() # nested project's artifacts untouched +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k sweep` +Expected: FAIL — `_sweep_stray_artifacts` undefined. + +- [ ] **Step 3: Implement the sweep** + +Add imports: `from wardline.core import artifacts as _artifacts`, `from wardline.core import discovery`, `from wardline.core.paths import _has_project_markers, project_root_for`, `from wardline.core.safe_paths import safe_project_path`. Then: + +```python +_MANAGED_SUFFIXES = ("findings.jsonl", "findings.sarif", "findings.agent-summary.json", "scan.legis.json") + + +def _is_managed_name(name: str) -> bool: + return any(_artifacts._managed_artifact_pattern(s).match(name) for s in _MANAGED_SUFFIXES) + + +def _sweep_stray_artifacts(proj: Path, *, fix: bool) -> DoctorCheck: + proj = proj.resolve() + standard = paths.artifacts_dir(proj, _artifacts_dir_relname(proj) or ".wardline") + removed: list[str] = [] + review: list[str] = [] + emptied_dirs: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(proj, followlinks=False): + here = Path(dirpath) + # prune: hard-skip set, .git, the standard artifacts dir, and nested project roots + dirnames[:] = [ + d + for d in dirnames + if d not in discovery.WALK_SKIP_DIRS + and (here / d).resolve() != standard + and not _has_project_markers(here / d) + ] + in_wardline_dir = here.name == ".wardline" and here.resolve() != standard + for fname in filenames: + fpath = here / fname + managed = _is_managed_name(fname) # timestamped: 2026...-findings.jsonl + bare = fname in _MANAGED_SUFFIXES and not managed # unstamped: findings.jsonl + if not managed and not bare: + continue + rel = str(fpath.relative_to(proj)) + # ONLY a timestamped (managed) file INSIDE a non-standard .wardline/ dir is + # auto-deletable; bare-managed, or managed outside .wardline/, is REVIEW. + if not (managed and in_wardline_dir): + review.append(rel) + continue + if not _artifacts._is_regular_file_no_follow(fpath): + continue # symlink / non-regular -> skip + if not fix: + removed.append(rel) # would-remove (no unlink) + continue + try: + safe = safe_project_path(proj, fpath, label=fname) + except WardlineError: + continue # escaping entry -> skip, keep sweeping + try: + safe.unlink() + except OSError: + continue + removed.append(rel) + emptied_dirs.append(here) + if fix: + for d in emptied_dirs: + try: + if d.resolve() != standard and not d.is_symlink(): + d.rmdir() # os.rmdir only; ENOTEMPTY guards + except OSError: + pass + # ADVISORY status (must-fix from plan review): stray artifacts are cleanup items, not a + # health failure, so status stays "ok" and the sweep never flips machine_readable_doctor's + # all(check.ok) aggregation (which would fail `doctor --fix` / MCP doctor on success). + msg = (f"removed {len(removed)}, review {len(review)}" if fix + else f"{len(removed)} removable, review {len(review)}") + return DoctorCheck("stray_artifacts", "ok", fixed=bool(fix and removed), message=msg, removed=removed, review=review) +``` + +(Single walk: `managed` = timestamped name (auto-deletable only inside a non-standard `.wardline/`); `bare` = an unstamped `findings.jsonl`-family name (always REVIEW). The `os.walk(followlinks=False)` plus the `dirnames[:]` prune (hard-skip set + standard-dir + nested-marker) bounds the traversal and never descends a symlinked dir.) + +- [ ] **Step 4: Run to verify pass** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k sweep && .venv/bin/mypy src/wardline/install/doctor.py` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_hygiene.py +git commit -m "feat(doctor): _sweep_stray_artifacts — confined, no-follow, nested-root-aware" +``` + +--- + +## Task 9: wire the two checks into doctor + fix the scan hint + +**Files:** +- Modify: `src/wardline/install/doctor.py` (`machine_readable_doctor`) +- Modify: `src/wardline/cli/doctor.py` (both render branches) +- Modify: `src/wardline/cli/scan.py:238-240` +- Test: `tests/unit/install/test_doctor_hygiene.py`, `tests/unit/cli/test_doctor.py` + +**Interfaces:** +- Consumes: `_check_gitignore`, `_sweep_stray_artifacts`, `paths.project_root_for`. + +- [ ] **Step 1: Write the failing tests** + +```python +from wardline.install.doctor import machine_readable_doctor + +def test_machine_readable_includes_new_checks(tmp_path): + proj = _proj(tmp_path) + _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + payload = machine_readable_doctor(proj, fix=True) + ids = {c["id"] for c in payload["checks"]} + assert {"gitignore", "stray_artifacts"} <= ids + +def test_successful_repair_new_checks_report_ok(tmp_path): + # Must-fix (plan review): a SUCCESSFUL repair must return status "ok" so it does not + # flip machine_readable_doctor's all(check.ok) aggregation and make `doctor --fix` / + # MCP doctor exit 1 on success. (Asserting payload["ok"] is True would be wrong here — + # other checks fail on a bare project — so pin the two new checks specifically.) + proj = _proj(tmp_path) + _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + by_id = {c["id"]: c for c in machine_readable_doctor(proj, fix=True)["checks"]} + assert by_id["gitignore"]["status"] == "ok" and by_id["gitignore"]["fixed"] is True + assert by_id["stray_artifacts"]["status"] == "ok" + +def test_check_only_does_not_mutate(tmp_path): + proj = _proj(tmp_path) + stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + payload = machine_readable_doctor(proj, fix=False) + assert stray.exists() # no delete + assert not (proj / ".gitignore").exists() # no write + sweep = next(c for c in payload["checks"] if c["id"] == "stray_artifacts") + assert sweep["fixed"] is False + +def test_subdir_root_climbs_to_project(tmp_path): + proj = _proj(tmp_path) + sub = proj / "src" / "pkg" + sub.mkdir(parents=True) + stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + machine_readable_doctor(sub, fix=True) # invoked at the SUBDIR + assert (proj / ".gitignore").exists() # gitignore written at the PROJECT root + assert not stray.exists() # swept at the project root +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py -q -k "machine_readable or check_only or climbs"` +Expected: FAIL — new checks absent. + +- [ ] **Step 3: Wire into `machine_readable_doctor`** + +In `install/doctor.py`, after `config_missing_before = ...` (line 530) and **before** the `if fix:` block, snapshot the project root: + +```python + proj = project_root_for(root) # snapshot BEFORE repair_install plants weft.toml at literal root +``` + +Then after the existing `checks.append(_check_filigree_auth(...))` (line 567) append: + +```python + checks.append(_check_gitignore(proj, fix=fix)) + checks.append(_sweep_stray_artifacts(proj, fix=fix)) +``` + +- [ ] **Step 4: Wire into `cli/doctor.py` both branches** + +In the `--repair` branch (after the `filigree.auth` line ~66), compute `proj` **before** `repair_install` (top of the branch) and render: + +```python + proj = project_root_for(root) # project_root_for imported at module top + # ... existing repair_install + after/config/filigree rendering ... + gi = _check_gitignore(proj, fix=True) + click.echo(f" gitignore: {gi.status}" + (f" ({gi.message})" if gi.message else "")) + sw = _sweep_stray_artifacts(proj, fix=True) + click.echo(f" stray artifacts: removed {len(sw.removed)}, review {len(sw.review)}") + for r in sw.review: + click.echo(f" REVIEW {r} (unstamped/bare — remove by hand if it's a stray scan)") +``` + +In the check-only branch (after the `filigree.auth` line ~82): + +```python + proj = project_root_for(root) + gi = _check_gitignore(proj, fix=False) + # gi.status is advisory-"ok" even with a gap, so render on the message, not gi.ok. + if gi.status == "error" or "missing" in (gi.message or ""): + click.echo(f" gitignore: {gi.message}") + sw = _sweep_stray_artifacts(proj, fix=False) + if sw.removed or sw.review: + click.echo(f" stray artifacts: {sw.message}") +``` + +Add `_check_gitignore`, `_sweep_stray_artifacts` to `cli/doctor.py`'s `from wardline.install.doctor import (...)` block, and `from wardline.core.paths import project_root_for` to its module-top imports (do NOT use function-local imports — see Task 9 Step 5 note). Because the two checks are advisory (status stays `"ok"` except on a symlink write-refusal), they do **not** enter the branch's `ok`/`SystemExit` accounting: a missing gitignore line or a present stray must NOT make `wardline doctor` exit 1. Leave the existing `all(check.ok for check in ...)` accounting untouched (gitignore/stray are rendered for information only); add a test asserting `wardline doctor` (check-only) exits 0 when the only "gap" is a missing gitignore line + a stray present. + +- [ ] **Step 5: Fix the scan hint AND the stale docstring** (`cli/scan.py`) + +(a) Add `project_root_for` to `cli/scan.py`'s existing `from wardline.core.paths import ...` block (it already imports `weft_config_path`). Replace the hint (lines 238-240) so it points at the project root, not the scanned subdir: + +```python + proj = project_root_for(path) + click.echo( + "warning: no weft.toml found; using built-in source_roots=['.'], which can make " + "project-root scans broad and slow. Run `wardline doctor --repair --root " + f"{proj}` to create a bounded default policy, or `wardline scan-job start {path}` " + "for a pollable long-running scan.", + err=True, + ) +``` + +(b) **Fix the stale `scan()` docstring** (lines 216-220, should-fix from plan review). It still +says a subdirectory scan "writes output into the subdirectory (wardline warns when it detects +this)" — false after Part 1 (output now lands at the project root). Change the tail of that +sentence: + +```python + root — a subdirectory scan mints qualnames other Weft tools + (Loomweave/Filigree/dossier) will not match and misses the project's + suppression state (wardline warns when it detects this). The default + findings artifact still lands in the project root's .wardline/. +``` + +- [ ] **Step 6: Run tests** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_hygiene.py tests/unit/cli/test_doctor.py -q && .venv/bin/pytest -q -k doctor` +Expected: PASS. Fix any existing doctor test that asserts the exact human-output line set (the new `gitignore`/`stray artifacts` lines are additive). + +- [ ] **Step 7: Commit** + +```bash +git add src/wardline/install/doctor.py src/wardline/cli/doctor.py src/wardline/cli/scan.py tests/ +git commit -m "feat(doctor): wire gitignore+sweep into doctor; anchor to project_root_for; fix scan hint" +``` + +--- + +## Task 10: MCP `doctor` tool — honest destructive hint + confinement regression + +**Files:** +- Modify: `src/wardline/mcp/server.py:4114-4148` (`_DOCTOR_TOOL`) +- Test: `tests/unit/install/test_doctor_hygiene.py` (MCP-surface path), `tests/unit/mcp/test_server*.py` + +**Interfaces:** none new — the MCP `doctor` handler already routes through `machine_readable_doctor(fix=repair)` (server.py:4008), so the sweep is reachable once Task 9 lands. This task only makes the advertisement honest and pins confinement. + +- [ ] **Step 1: Write the failing tests** + +```python +from wardline.mcp.server import _DOCTOR_TOOL +from wardline.install.doctor import machine_readable_doctor + +def test_doctor_tool_advertises_destructive(): + assert _DOCTOR_TOOL["annotations"]["destructiveHint"] is True + +def test_mcp_path_deletes_confined_managed_only(tmp_path): + proj = _proj(tmp_path) + inside = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + bare = _stray(proj, "findings.jsonl") + # symlinked managed file -> must survive + import os + real = tmp_path.parent / "real.jsonl"; real.write_text("x", encoding="utf-8") + wd = proj / "lib" / ".wardline"; wd.mkdir(parents=True) + os.symlink(real, wd / f"{STAMP}-findings.jsonl") + machine_readable_doctor(proj, fix=True) # same builder the MCP _doctor handler calls + assert not inside.exists() # managed-in-.wardline deleted + assert bare.exists() # unstamped -> REVIEW, kept + assert real.exists() # symlink target intact +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `.venv/bin/pytest -q -k "destructive or confined"` +Expected: FAIL — `destructiveHint` still `False`. + +- [ ] **Step 3: Flip the hint + note deletion** + +In `server.py` `_DOCTOR_TOOL`: +- Line 4145 `"destructiveHint": False,` → `"destructiveHint": True,`. +- Extend the tool `description` (ends ~line 4122) to add: `" With repair: true it also deletes stray wardline-managed scan artifacts (timestamped files inside .wardline/ dirs) under the project root."` +- Extend the `repair` property `description` (ends ~line 4131) similarly: `" Also sweeps stray managed scan artifacts under the project root."` + +Leave `idempotentHint: True` (the sweep converges — a second run deletes nothing new). + +- [ ] **Step 4: Run + commit** + +Run: `.venv/bin/pytest -q -k "doctor or destructive or confined" && .venv/bin/pytest tests/unit/mcp -q` +Expected: PASS. Update any MCP golden/schema test that snapshots the doctor tool annotations. + +```bash +git add src/wardline/mcp/server.py tests/ +git commit -m "feat(mcp): doctor repair:true deletes strays; advertise destructiveHint: True" +``` + +--- + +## Task 11: docs + CHANGELOG + +**Files:** +- Modify: `docs/getting-started.md`, `docs/guides/configuration.md`, `docs/guides/agents.md`, `CHANGELOG.md` + +**Interfaces:** none. + +- [ ] **Step 1: Update docs** — in each guide where artifacts/output are described, state: the default findings artifact lands in `‹project-root›/.wardline/`, anchored to the `weft.toml` directory, independent of where `wardline scan` is invoked; a subdir scan is still flagged `WLN-ENGINE-NESTED-SCAN-ROOT`; `wardline doctor --repair` (CLI and MCP `doctor` `repair:true`) sets up `.gitignore` and deletes stray managed artifacts under the project root. + +- [ ] **Step 2: CHANGELOG `[Unreleased]`** + +```markdown +### Changed +- Default scan artifacts now anchor to the weft-project root (the `weft.toml` directory) + rather than the scan cwd, so a subdirectory scan writes to `‹project-root›/.wardline/`. + Retention is therefore project-root-wide across heterogeneous subdir/root scans sharing + one `.wardline/`. **Migration:** the artifact moves to the project root; `wardline doctor + --repair` sweeps now-stale per-subdir `.wardline/` dirs — update any CI/automation reading + a hardcoded `‹subdir›/.wardline/*-findings.jsonl` path. + +### Added +- `wardline doctor --repair` gitignores the artifacts dir and sweeps stray managed + artifacts; deletion is available on both the CLI and the MCP `doctor` tool (`repair:true`, + advertised `destructiveHint: True`), bounded to managed-pattern files inside `.wardline/` + dirs under the project root. +``` + +- [ ] **Step 3: Build docs (if a docs check exists) + commit** + +Run: `.venv/bin/pytest -q -k "glossary or docs"` (whatever pins doc/vocab consistency). + +```bash +git add docs/ CHANGELOG.md +git commit -m "docs: project-root-anchored artifacts + doctor hygiene" +``` + +--- + +## Final verification + +- [ ] **Full suite + lint + type** + +Run: `.venv/bin/pytest -q && .venv/bin/ruff check . && .venv/bin/mypy src` +Expected: all green, clean. Investigate and fix any red — no "pre-existing" excuses. + +- [ ] **Manual smoke (optional)** + +```bash +mkdir -p /tmp/wl-demo/src/pkg && cd /tmp/wl-demo && printf '[wardline]\nsource_roots=["."]\n' > weft.toml && printf 'x=1\n' > src/pkg/m.py +.venv/bin/wardline scan src/pkg ; ls -la .wardline/ ; ls -la src/pkg/.wardline 2>/dev/null || echo "no subdir .wardline (correct)" +.venv/bin/wardline doctor --repair --root . ; cat .gitignore +``` + +--- + +## Spec-coverage self-check (run before handing off) + +| Spec requirement | Task | +|---|---| +| `project_root_for` / `artifacts_dir` (§3.1) + import-cycle resolution | 1 | +| artifacts anchored to project root (§3.2) | 2 | +| retention project-root-wide (documented) (§3.2) | 2 (test), 11 (doc) | +| WLN-ENGINE message amendment (§3.3) | 3 | +| subdir/root/unfederated/escape/--output/MCP-no-artifact (§6 1-7) | 2, 4 | +| `project_root_for`/`artifacts_dir` unit matrix (§6 #8) | 1 | +| `WALK_SKIP_DIRS` export (§4.2) | 5 | +| `DoctorCheck` removed/review (§4.3) | 6 | +| `_check_gitignore` idempotent/CRLF/never-clobber/commented (§4.1, §6 #9-10,17) | 7, 9 | +| `_sweep_stray_artifacts` confined/narrowed/nested-stop/symlink/rmdir (§4.2, §6 #12-16) | 8 | +| wire both checks + proj snapshot + scan hint (§4 intro, §4.3, must-fix #1) | 9 | +| MCP delete + destructiveHint True + confinement (§4.2, §6 #18, §8) | 10 | +| docs + CHANGELOG (§7) | 11 | +| self-describe artifacts | DEFERRED (§9) — not in this plan | diff --git a/docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md b/docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md new file mode 100644 index 00000000..f7a96c6c --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md @@ -0,0 +1,482 @@ +# Weft Seam Conformance Kit (design) + +> The standard, mechanical recipe EVERY weft seam follows so applying conformance +> to a new seam is a checklist, not a bespoke project. Generalized from the SEI +> (Stable Entity Identity) conformance program, which is the GOLD STANDARD. + +## 0. What a "seam" is and the bar it must clear + +A **weft seam** is any interface where one Loom peer (wardline / loomweave / +filigree / legis / charter) produces or consumes data to/from another: HTTP +federation endpoints, HMAC-signed wire formats, shared identifier formats (SEI), +entity-associations, scan-artifact intake, taint-store blobs, the `weft.toml` +config contract, auth tokens (`WEFT_FEDERATION_TOKEN`), and cross-surface status +envelopes. + +A seam is **AT THE SEI BAR** only when it has ALL of: + +1. a frozen, machine-checkable **CONTRACT artifact** (not just prose docs); +2. an automated conformance **ORACLE** (golden-vector or scenario) that actually + asserts the wire; +3. a **CI GATE** that FAILS CLOSED on drift — never a test that skips-clean when a + peer/binary is absent; +4. (two-sided seams only) a **shared corpus both peers load** PLUS a **drift alarm**. + +Default to judging a seam BELOW the bar unless you SEE all four in source. + +The kit below specifies each of the four as a reusable pattern, citing the SEI +source it is derived from. + +--- + +## 1. Lens: distributed two-sided contract & drift (federation lens) + +One contract is defined ONCE and BOTH peers load/test the SAME bytes. Divergence +is impossible to merge green because the producer's live emit and the consumer's +loader are both coupled to one committed artifact, and an upstream byte-pin + +drift alarm catches silent divergence ACROSS repos. + +The SEI program demonstrates the two seam shapes the kit must cover: + +- **One-sided / cross-engine identity** — the producer freezes its own + externally-observable surface so a *second producer of the same string* (the + future Rust core) must reproduce it byte-for-byte. + (`tests/golden/identity/test_identity_parity.py`, + `tests/grammar/test_golden_oracle.py`.) +- **Two-sided / cross-repo contract** — producer and consumer load the SAME shared + vector; an upstream byte-pin + opt-in live recheck catches divergence across + repos. + (`tests/conformance/test_legis_scan_wire_golden.py` (G1, commit `2441c1d0`); + `tests/conformance/test_loomweave_rust_qualname_parity.py` (drift alarm, commit + `36c8adcf`).) + +The kit's INVERSION rule (who is authoritative) is set per-seam: for SEI/identity +Wardline is the producer-of-record; for the Rust qualname seam Loomweave is +authoritative and Wardline VENDORS its corpus and reproduces it as the second +producer. Header of `test_loomweave_rust_qualname_parity.py` states this inversion +explicitly — the kit requires every seam to name its authority and its second +producer/consumer. + +--- + +## 2. Artifact layout (the CONTRACT artifact) + +Derived from `tests/golden/identity/` and `tests/conformance/`. + +Every seam `` gets a fixed directory shape under `tests/`: + +``` +tests/// + README.md # what the seam covers, inputs, determinism notes, regen procedure + _capture.py # the CANONICALIZER: produce the wire bytes, sorted/strict/host-free + regen.py # the ONLY sanctioned writer of the frozen artifact (requires --reason) + conftest.py # on-failure diff dump (real-regression vs intentional-rekey triage) + test__*.py # the ORACLE(s) — assert live emit == frozen artifact + fixtures/ # FIXED input corpus (no .weft/, no weft.toml, LF-pinned) + corpus/ # the frozen artifact(s): .json + META.json +``` + +For a two-sided seam, the single shared vector lives at +`tests/conformance/_wire.golden.json` and is loaded by BOTH repos (the +wardline half couples it to the live emit; the peer half loads the same file). +Cite: `tests/conformance/legis_scan_wire.golden.json` + +`test_legis_scan_wire_golden.py`. + +Required properties of the CONTRACT artifact (from `_capture.py`): + +- **Canonical JSON** — `json.dumps(..., indent=2, sort_keys=True, ensure_ascii=False)` + with a `+ "\n"` trailer (`_capture.to_json`, lines 190-192). +- **Strict default hook** — a `default=` that RAISES on any non-serialisable type + rather than `str()`-ing it, so hash/address-dependent nondeterminism cannot + silently freeze (`_capture._strict_default`, lines 172-187; includes the + documented float caveat). +- **No host data** — no absolute paths, no timestamps, no tool version. The + *mutable* tool version is normalised to a sentinel (`_VERSION_SENTINEL = + ""`, applied to `driver.version` in `_capture._capture_sarif`, + lines 92-94). Scans are rooted AT the fixture so paths are relative + (`_capture.py` module docstring, lines 6-8). +- **Total order on every named array** — emission order is a Python-walker artifact + a different engine won't reproduce, so every array gets a content-derived sort + with a JSON-canonical tiebreaker because the natural key may collide + (`_finding_sort_key`, lines 50-63; `_sarif_result_sort_key`, lines 107-113; the + facts/spans sorts, lines 74-141). EXCEPTION: causal sequences (SARIF `codeFlows`, + taint chains) are NEVER sorted (lines 99-102). +- **A `META.json` recording the scheme/version** the artifact was captured under, + so a future scheme bump is a visible, accountable delta and is asserted against + the live constant (`test_corpus_meta_has_engine_scheme`, + `test_identity_parity.py` lines 37-45; `regen.py` writes + `fingerprint_scheme` + `corpus_version` + `reason`). +- **A positive allowlist predicate** deciding what enters the frozen surface (NOT a + denylist), so a future rule of a new family can't silently enter or be dropped + (`is_identity_bearing`, `_capture.py` lines 40-47). + +The artifact reuses the REAL wire serializer (`Finding.to_jsonl()`, +`build_taint_facts`, `build_sarif`, `build_legis_artifact`) and re-parses for +canonical re-serialization — so the oracle is sensitive to every wire field, not a +hand-mirrored schema (`_capture._capture_findings`, lines 66-71). + +--- + +## 3. Oracle test shape + +Derived from `test_identity_parity.py` and `test_golden_oracle.py`. + +The oracle is a byte-for-byte equality of the live capture against the committed +artifact, PLUS a fixed set of non-vacuity and soundness guards that stop a silently +empty/shallow corpus from passing. + +### 3a. The core equality (one-sided) + +```python +def test_corpus_is_byte_identical(name): + golden = (HERE / "corpus" / f"{name}.json").read_text("utf-8") + actual = c.to_json(c.capture(INPUTS[name])) + assert actual == golden, REGEN_HINT +``` + +(`test_identity_parity.py` lines 48-54; the grammar variant is +`test_golden_oracle.test_builtin_findings_match_golden`.) The `REGEN_HINT` names +the exact regen command and the `/tmp` diff path so the failure is +self-explaining (lines 30-34). + +### 3b. The live-emit coupling (two-sided) + +For a shared vector, the producer half asserts the LIVE signed emit carries +EXACTLY the vector's top-level and per-finding key-sets, and that the vector's one +active record routes as active: + +- `test_live_emit_top_level_keys_match_the_vector` — `set(live) == set(vector)` +- `test_live_emit_per_finding_keys_match_the_vector` — every emitted record's + key-set equals the vector's +- `test_golden_vector_is_a_valid_signed_artifact` — the vector round-trips under a + documented fixed `GOLDEN_KEY` so the CONSUMER verifies it offline +- `test_vector_defect_routes_as_active` — the pinned record is the thing the + consumer must route + +(All from `test_legis_scan_wire_golden.py`.) The key strings are tied to SHARED +CONSTANTS imported from production (`FINDINGS_FIELD`, `FINGERPRINT_SCHEME_FIELD`, +`DIRTY_FIELD`) so a constant-VALUE rename reds +(`test_golden_vector_keys_are_the_named_constants`). + +### 3b-bis. The scenario oracle (capability / protocol round-trips) + +Some seams are not a single frozen wire but a *negotiation* or *protocol round-trip* +(SEI `_capabilities` advertisement, resolve/degrade behaviour). Byte-equality cannot +express "every named scenario the upstream standard defines is handled." For these, +the oracle is a SCENARIO ORACLE: + +- Vendor an upstream-authored fixture of named scenarios + (`tests/conformance/fixtures/sei-conformance-oracle.json`). +- One assertion per scenario id drives the LIVE consumer code path + (`SeiResolver`) through that scenario and asserts the expected status. +- A module-level `COVERED_SCENARIOS` set is asserted EQUAL to the fixture's ids + (`test_sei_oracle.test_every_oracle_scenario_is_covered`, lines 49/90-92) — so a + NEW upstream scenario reds CI until it is explicitly covered. +- Plus a vendored == upstream-source drift check (the one acceptable skip, because + the byte-pin of §4 Layer-1 is its hermetic backstop). + +(`tests/conformance/test_sei_oracle.py` + `fixtures/sei-conformance-oracle.json`.) + +So the kit has THREE sanctioned oracle shapes — pick by seam type: +**(1) byte-frozen golden** (engine-produced identity surfaces, §3a), +**(2) shared signed vector** (two-sided cross-repo wire, §3b), +**(3) scenario oracle** (capability negotiation / protocol round-trips, §3b-bis). +The non-vacuity guards of §3c and the determinism precondition of §3d apply to all three. + +### 3c. Mandatory non-vacuity + soundness guards + +Every seam oracle MUST carry, per input and per surface: + +- **Non-vacuity** — each frozen surface is non-empty AND contains the load-bearing + marker (`test_corpus_surface_non_vacuous`, lines 76-89: findings / entity_spans / + facts / SARIF results / explain all non-empty; `PY-WL-101` present; every span + has real line/col). This stops an empty Rust output from satisfying a vacuous + oracle. +- **Edge-construct coverage** — the stress fixture's span-edge constructs (that + produce NO finding) must appear in the frozen surface + (`test_stress_freezes_span_edge_construct_spans`, lines 92-98). +- **Join-key collision-freedom** — distinct active records must have distinct + fingerprints; the fingerprint is the cross-tool JOIN KEY, so a collision silently + drops one record on the join (`test_corpus_fingerprints_are_collision_free`, + lines 106-132 — the `sinks` fixture deliberately plants same-(rule,line,qualname) + pairs to keep this non-vacuous). +- **Fixture hygiene** — fixtures carry no `.weft/` and no `weft.toml` (a + baseline/waiver would date-poison the corpus via `date.today()`) + (`test_fixture_has_no_local_config`, lines 148-152; + `test_assure_corpus_has_no_waiver_debt`, lines 135-142). + +### 3d. Determinism precondition + +The captured surface must be verified deterministic BEFORE freezing: in-process +stable, path-independent, cross-process (`PYTHONHASHSEED`), cross-interpreter +(CPython 3.12 freeze ↔ 3.13 reproduce byte-identical) — so the gate runs on every +CI interpreter with NO skip (`test_identity_parity.py` docstring lines 5-8; README +"Determinism" section). A seam whose surface isn't deterministic is not yet +freezable; make it deterministic first (impose total order, normalise mutable +fields) — do not relax the equality. + +--- + +## 4. Shared-corpus + drift-alarm mechanism (two-sided seams) + +Derived from `test_loomweave_rust_qualname_parity.py` (commit `36c8adcf`) and the +G1 shared vector (commit `2441c1d0`). + +The failure this prevents: two INDEPENDENT vendored mirrors (each side hand-copies +the schema) — the "hand-copied-both-sides" pattern that lets one side rename a +field and stay green while the other governs an empty payload under a `verified` +status (G1 / seam-S8 root cause, `test_legis_scan_wire_golden.py` docstring; +`2441c1d0` commit body). + +The kit's two-layer drift alarm: + +**Layer 1 — upstream byte-pin (runs in the DEFAULT suite, every PR):** +The vendored copy's git blob SHA is pinned as a module constant; any byte change to +the vendored file fails loudly, so re-vendors are deliberate, atomic, and update +the constant in the SAME commit. + +```python +UPSTREAM_BLOB_SHA = "ed436c825861ad2b9e313f9211f5a55583b80c7c" + +def test_vendored_corpus_matches_upstream_blob_pin(): + data = _CORPUS_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, "...deliberate re-vendor → update SHA in same commit" +``` + +(`test_loomweave_rust_qualname_parity.py` lines 100-189; the pin is computed as a +real git blob hash, `b"blob %d\x00" + data`, so `git hash-object` is the source of +truth.) + +**Layer 2 — live recheck (OPT-IN marker, release-gate only):** +Byte-compares the vendored copy against the sibling checkout +(`WARDLINE_LOOMWEAVE_REPO`, default `/home/john/loomweave`); SKIPS when the checkout +is absent (CI PR runner has no sibling), FAILS on drift +(`test_vendored_corpus_matches_live_sibling_checkout`, marked +`@pytest.mark.loomweave_drift`, lines 191-204). + +**The shared vector itself (the single source both load):** +For a two-sided wire, the ONE concrete signed instance lives in +`tests/conformance/_wire.golden.json`. It is deterministic and +self-consistent: volatile fields (`scanner_identity`, `rule_set_version`, +`commit_sha`, `tree_sha`) are FIXED sentinels and the signature is computed over +that body under a documented fixed key, so the consumer verifies it OFFLINE +(`test_legis_scan_wire_golden.py` docstring; `GOLDEN_KEY = +b"weft-shared-conformance-key"`). The wardline half couples it to the live emit; +the peer adds the matching loader as its half of the same test. Regenerating the +vector to match a rename on one side then REDS the other side's half — that +coupling is the whole point. + +**Re-vendor procedure (a RELEASE-GATE item):** copy byte-verbatim → update +`UPSTREAM_BLOB_SHA` to `git hash-object ` + refresh provenance lines, all in +the SAME commit → re-run conformance and CONFORM the producer until byte-green +(never weaken the comparison). (`test_loomweave_rust_qualname_parity.py` header +RE-VENDOR PROCEDURE.) + +--- + +## 5. CI gate spec (FAILS CLOSED) + +Derived from `.github/workflows/ci.yml`, `tests/conftest.py`, +`src/wardline/_live_oracle.py` (commit `d87db0cd`). + +The cardinal rule: **a live oracle that can't reach its peer must FAIL on the gated +job, never silently skip-clean.** The mechanism splits into PR-time (hermetic) and +scheduled (live), and a conftest hook converts SKIP→FAILURE when the gate is armed. + +**5a. PR-time (hermetic pins, every push/PR):** +The byte-pin oracles, the shared-vector key-set tests, and the identity parity +corpus all run with no live peer — they rely on the committed artifacts, so they +gate every PR. (`ci.yml` jobs `Tests + Coverage`, `Self-Hosting Scan`; the +PR-vs-scheduled split is documented in the workflow comments, +`83d08aee75` commit body.) The self-scan itself is gated: `wardline scan src/ +--format sarif --output results.sarif --fail-on ERROR` — with a NON-VACUOUS proof +fixture (`tests/test_self_hosting_violation.py` + +`tests/fixtures/.../trust_boundary_violation.py`) showing the pipeline CAN trip the +gate (`751a9ae71b`). + +**5b. Scheduled / manual (live oracles, fail-closed):** +Live-peer tests run only on `schedule` / `workflow_dispatch` (a GitHub PR runner +can't host `loomweave serve` / live legis+filigree). They run with +`WARDLINE_LIVE_ORACLE_REQUIRED: "1"`, which arms the fail-close hook +(`ci.yml` jobs `live-judge`, `live-oracles` matrix over markers +`loomweave_e2e` / `legis_e2e` / `filigree_e2e` / `warpline_e2e`). + +**5c. The SKIP→FAILURE hook (the fail-close primitive):** + +```python +# tests/conftest.py +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + if should_fail_live_oracle_skip((m.name for m in item.iter_markers()), report.outcome): + report.outcome = "failed" + report.longrepr = f"{LIVE_ORACLE_REQUIRED_ENV}=1 forbids skipped live oracle tests: ..." +``` + +`should_fail_live_oracle_skip` = `live_oracle_required() and outcome == "skipped" +and has_live_oracle_marker(...)` over `LIVE_ORACLE_MARKERS = {network, +loomweave_e2e, legis_e2e, filigree_e2e, warpline_e2e}` +(`src/wardline/_live_oracle.py`). So when the env is set, a live oracle that would +have skipped (peer unreachable) becomes a hard FAILURE — that is the fail-closed +guarantee. + +**5d. Marker registration + default exclusion:** +Every live/drift marker is registered in `pyproject.toml [tool.pytest.ini_options] +markers` and excluded from the default `addopts` run (`-m 'not network and not +loomweave_e2e and not ... and not loomweave_drift and not warpline_e2e'`), so the +default `pytest` is hermetic and the live tier is opt-in by marker +(`pyproject.toml` lines 147-155). A new seam adds its `_e2e` marker to BOTH +`markers`, the `addopts` exclusion, AND `LIVE_ORACLE_MARKERS` (so the fail-close +hook covers it). + +**5e. Rekey accountability:** +The frozen artifact changes ONLY via `regen.py --reason ""` (stamps `reason` +into `META.json`); the parity test in CI fails any PR that changes `corpus/*` +without a matching production change; the recommended complement is a CODEOWNERS +entry on `corpus/**` so a rekey also needs maintainer review (`regen.py` docstring; +README "Regenerating" section). + +**5f. Release ride-along (the gate must guard the publishable artifact):** +The conformance gate is worthless if it does not execute on the path that produces +a release. TODAY `.github/workflows/release.yml` (on tag `v*`) only `needs: build` +and guards the version tag + SHA256SUMS + PyPI Trusted Publishing — it does NOT +re-run the conformance suite or the `_drift` recheck, so a drifted seam can +ride a tag straight to PyPI because the gate lives only in `ci.yml`, which the tag +push does not depend on. The kit REQUIRES: + +- the release `build`/publish job to `needs:`-depend on (or re-run) the §5a Tier-1 + HERMETIC conformance suite (byte-pins, shared-vector key-sets, identity parity, + the gated `wardline scan src/ --fail-on ERROR` self-scan); +- the release runbook to run the §4 Layer-2 `-m _drift` live recheck against + the sibling checkouts BEFORE tagging (the test header already declares this a + RELEASE-GATE item); +- belt-and-braces: a branch-protection required-status-check on the conformance job + (so the merge-to-main preceding the tag already gated it) AND a tag-time re-run + (so a tag cut from an unprotected ref still gates). + +(`release.yml` line 43 `needs: build` — the gap; `test_loomweave_rust_qualname_parity.py` +RE-VENDOR PROCEDURE header — the release-gate declaration.) + +--- + +## 6. Per-seam checklist (mechanical application) + +For a new seam `` between authority `A` and consumer/second-producer `B`: + +1. **Name the seam's authority and the second producer/consumer.** State which + side mints the bytes and which reproduces/consumes them; if `A` is a sibling + repo, `B` (this repo) VENDORS `A`'s corpus. (Inversion rule, §1; cite header of + `test_loomweave_rust_qualname_parity.py`.) +2. **Write the CONTRACT artifact** under `tests///corpus/` (one-sided) + or `tests/conformance/_wire.golden.json` (two-sided shared). Canonical + JSON, strict default, host-free, totally ordered, with `META.json` recording + the scheme/version. (§2.) +3. **Write `_capture.py`** that reuses the REAL production wire serializer, applies + the positive allowlist predicate, and canonicalizes. (§2.) +4. **Write `regen.py`** requiring `--reason`, as the ONLY writer of the artifact. + (§5e.) +5. **Write the ORACLE**, picking the shape by seam type: byte-equality (one-sided + identity surface, §3a), live-emit key-set coupling + offline signature round-trip + (two-sided shared vector, §3b), OR scenario oracle with `COVERED_SCENARIOS == + fixture ids` (capability negotiation / protocol round-trip, §3b-bis). (§3a/§3b/§3b-bis.) +6. **Add the non-vacuity + soundness guards**: per-surface non-empty, load-bearing + marker present, edge-construct coverage, join-key collision-freedom, fixture + hygiene. (§3c.) +7. **Prove determinism** (in-process / path / cross-process / cross-interpreter) + BEFORE freezing; impose total order on any walker-order array. (§3d.) +8. **(Two-sided) Add the drift alarm**: Layer-1 `UPSTREAM_BLOB_SHA` git-blob pin in + the default suite; Layer-2 opt-in `_drift` live recheck vs the sibling + checkout (skip-when-absent, fail-on-drift). Document the RE-VENDOR PROCEDURE in + the test header. (§4.) +9. **Register markers** `_e2e` (+ `_drift` if two-sided) in + `pyproject.toml markers`, the `addopts` exclusion, AND + `_live_oracle.LIVE_ORACLE_MARKERS`. (§5d.) +10. **Wire CI**: hermetic pins on every PR; live oracle on `schedule` / + `workflow_dispatch` with `WARDLINE_LIVE_ORACLE_REQUIRED=1` so a peer-absent + skip becomes a FAILURE. (§5a/§5b/§5c.) +11. **Add the on-failure diff conftest** (`conftest.py`) dumping the live capture to + `/tmp` + a unified-diff head, so a real regression is distinguishable from an + intentional rekey on a multi-KB artifact. (`tests/golden/identity/conftest.py`.) +12. **(Recommended) CODEOWNERS** on the artifact path so a rekey needs maintainer + review. (§5e.) +13. **Wire the RELEASE ride-along**: make the release/publish workflow + `needs:`-depend on (or re-run) the Tier-1 hermetic conformance suite, and run + the `-m _drift` recheck in the release runbook before tagging — so a + drifted seam cannot ride a tag to publish. (§5f.) +14. **Register the seam in the seam index** (§8) — add its row (authority, + consumer/second-producer, oracle shape, marker, two-sided? Y/N, bar verdict) so + the machine-checkable registry covers it. + +A seam is AT THE BAR only when items 2, 5, 10 exist (contract + oracle + fail-closed +CI) and — for two-sided — item 8 (shared corpus + drift alarm). Anything less is +BELOW the bar regardless of how much prose documentation the seam carries. + +--- + +## 8. Where the kit and the canonical seam index live + +This kit GENERALIZES the SEI standard, and SEI set the precedent: the *standard* +was promoted out of the Wardline tree to the Weft federation hub +(`~/loom/sei-standard.md`, linked from `~/loom/doctrine.md`), leaving an in-repo +**pointer** at `docs/superpowers/specs/2026-06-01-loom-stable-entity-identity-conformance.md`. +The kit mirrors that split exactly, because a seam is a CROSS-PEER object — the +single canonical view can only be assembled where every peer is visible. + +| Artifact | Canonical home | Notes | +|---|---|---| +| **Kit doctrine** (the standard all peers conform to) | hub: `~/loom/seam-conformance-kit.md`, linked from `~/loom/doctrine.md` | governs all peers, so the hub is canonical | +| **Wardline's executable reference** | this file (`docs/superpowers/specs/2026-06-24-weft-seam-conformance-kit.md`) | once promoted, reduce to pointer-to-hub + Wardline implementation notes, same shape as the SEI pointer | +| **Canonical seam INDEX** (cross-peer union: every seam, its authority/consumer, its bar verdict) | hub: `~/loom/seam-index.md` | only the hub sees all peers' halves, so the union index lives there | +| **Wardline's enforceable seam REGISTRY** | Wardline tree, adjacent to enforcement: `tests/conformance/seam_registry.json` (+ a `test_seam_registry.py` asserting it) | the machine-checkable, CI-asserted half that feeds the canonical index | + +**The Wardline registry is itself gated, not prose.** A `test_seam_registry.py` +MUST assert that every seam listed in `seam_registry.json` actually has its oracle +test, its registered marker, and (two-sided) its drift alarm wired — so a +prose-only "index" entry with no enforcement is itself BELOW the bar. The registry +row schema is the §6 item 14 tuple: `{seam, authority, consumer_or_second_producer, +wire, two_sided, oracle_shape, marker, drift_alarm, bar_verdict, evidence_paths}`. + +> Promotion note: `~/loom/` is the cross-repo federation hub and is NOT present in +> this checkout. Creating `~/loom/seam-conformance-kit.md` and `~/loom/seam-index.md` +> is a CROSS-REPO action (a hub PR); until then THIS file is authoritative-for-now +> and the Wardline `seam_registry.json` is the executable ground truth — exactly the +> posture the SEI pointer documents. + +--- + +## 7. SEI source evidence (where each kit element was derived) + +- Contract artifact / canonicalizer: `tests/golden/identity/_capture.py` + (canonical JSON `to_json` L190-192; strict default `_strict_default` L172-187; + version-sentinel normalisation L92-94; total-order sorts L50-141; allowlist + predicate `is_identity_bearing` L40-47; real-wire reuse `_capture_findings` + L66-71). +- Oracle shape: `tests/golden/identity/test_identity_parity.py` (byte equality + L48-54; META scheme assert L37-45; non-vacuity L76-89; edge constructs L92-98; + join-key collision-freedom L106-132; fixture hygiene L135-152) and + `tests/grammar/test_golden_oracle.py` + `tests/grammar/golden_harness.py` + (corpus-not-dogfood rationale). +- Shared corpus + drift alarm: `tests/conformance/test_loomweave_rust_qualname_parity.py` + (byte-pin `UPSTREAM_BLOB_SHA` + `git hash-object` semantics; opt-in + `loomweave_drift` live recheck; RE-VENDOR PROCEDURE) — commit `36c8adcf`; and + `tests/conformance/test_legis_scan_wire_golden.py` + + `tests/conformance/legis_scan_wire.golden.json` (single shared signed vector, + live-emit key-set coupling, offline signature verify, named-constant key + binding) — commit `2441c1d0` (G1). +- CI fail-closed gate: `.github/workflows/ci.yml` (PR-vs-scheduled split; + self-scan `--fail-on ERROR`; `WARDLINE_LIVE_ORACLE_REQUIRED=1` matrix), + `tests/conftest.py` (SKIP→FAILURE hook), `src/wardline/_live_oracle.py` + (`LIVE_ORACLE_MARKERS`, `should_fail_live_oracle_skip`), + `pyproject.toml` (marker registration + default `addopts` exclusion) — commit + `d87db0cd`; non-vacuous self-scan proof `tests/test_self_hosting_violation.py`. +- Rekey accountability: `tests/golden/identity/regen.py` (`--reason`, + `CORPUS_VERSION`, `META.json` stamp), `tests/golden/identity/README.md` + (CI-enforces-no-silent-rekey + CODEOWNERS complement), + `tests/golden/identity/conftest.py` (on-failure diff dump). + +> Note on the canonical home: the SEI *standard* was promoted out of this tree to +> the Loom federation hub (`docs/superpowers/specs/2026-06-01-loom-stable-entity-identity-conformance.md` +> is now a pointer to `~/loom/sei-standard.md`). That hub file is NOT present in +> this checkout; this kit is derived from the LIVE, in-repo SEI implementation +> (tests + CI + tooling above), which is the executable ground truth. diff --git a/docs/superpowers/specs/2026-06-25-wardline-project-root-anchored-artifacts-design.md b/docs/superpowers/specs/2026-06-25-wardline-project-root-anchored-artifacts-design.md new file mode 100644 index 00000000..5d89eba7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-wardline-project-root-anchored-artifacts-design.md @@ -0,0 +1,601 @@ +# Wardline project-root-anchored scan artifacts + doctor hygiene (design) + +**Date:** 2026-06-25 +**Status:** Design — implementation-ready. The agent executing it may produce its own +TDD plan (writing-plans) from this. +**Gate:** none — fully autonomous, Wardline-repo-only. No sibling dependency. +**Revision:** v2 — incorporates the 2026-06-25 adversarial panel review (7 lenses, +groups of 4, adversarial verification + synthesis). Verdict was *approve-with-changes*; +the two confirmed-HIGH blockers (doctor/artifact root divergence; MCP-reachable +destructive sweep) and the grounded medium fixes are folded in below. One downgraded +finding (artifact path self-description) is deferred to §9 backlog by scope discipline. + +> **Why.** The configurable-scan-artifacts feature (`005be60d`, 2026-06-20) writes the +> default findings artifact to `‹scan-root›/.wardline/‹timestamp›-findings.‹ext›`, where +> the scan root is *whatever PATH you point `wardline scan` at*. So the artifact location +> follows the caller's cwd: scan a subdirectory and the `.wardline/` lands deep in the +> tree (observed live in `~/esper-lite`: +> `src/esper/simic/training/.wardline/20260624T111539Z-findings.jsonl`, carrying +> wardline's own `WLN-ENGINE-NESTED-SCAN-ROOT` self-diagnostic). Findings files end up in +> "weird locations." This spec makes scan artifacts land in **one standard, config-defined +> location anchored to the weft-project root**, regardless of where the scan is invoked — +> and makes `wardline doctor --repair` set that up and clean up the existing mess. + +--- + +## 1. Scope & definition of done + +**In scope (two parts):** + +1. **Anchor scan artifacts to the weft-project root.** The default artifact directory + (`config.artifacts.dir`, default `.wardline`) resolves against the **project root** + (the directory carrying `weft.toml` / `.weft/wardline/`), not the scan PATH. A + subdirectory scan writes to `‹project-root›/.wardline/…`, the same place a root scan + does. +2. **`wardline doctor --repair` hygiene.** Two new repair actions: (a) ensure the + project `.gitignore` ignores the artifacts dir + legacy `findings.jsonl`; (b) sweep + wardline-**managed** stray artifacts out of the tree and report unstamped strays for + manual review. The check-only `wardline doctor` reports the same gaps without acting. + Both actions **anchor to the same project root Part 1 writes to** (§4 intro). The + destructive delete is reachable from **both** the CLI and the MCP `doctor` tool + (`repair:true`); its blast radius is bounded by confinement + the narrowed authorship + heuristic (delete only managed-pattern files inside a `.wardline/` dir, under root, + no-follow), and the MCP `doctor` tool's `destructiveHint` is flipped to `True` to + advertise it honestly (§4.2 / §8). + +**Definition of done:** + +- `wardline scan ‹subdir›` writes its artifact to `‹project-root›/.wardline/…`; the + `WLN-ENGINE-NESTED-SCAN-ROOT` warning still fires (with its message clause corrected, + §3.3). +- `wardline scan .` at a true project root is unchanged (artifact at `‹root›/.wardline/`). +- A scan of an unfederated tree (no `weft.toml`/`.weft/wardline/` anywhere up the chain) + still writes `.wardline/` at the scan path (today's behavior preserved). +- An escaping `artifacts.dir` (absolute-elsewhere or `..`-escape) silently falls back to + the default `.wardline` under the project root — no write-redirect, no exit-2 DoS. +- `wardline doctor --repair` (CLI) adds the gitignore block (idempotently), deletes + managed stray artifacts, leaves+reports unstamped strays, and emits this in both human + and `--fix` JSON output. The two new actions target `project_root_for(root)`, the same + dir Part 1 writes to (§4 intro). +- The MCP `doctor` tool with `repair:true` performs the **same** gitignore-ensure + + stray-delete the CLI does, confined under the (possibly untrusted) server root by the + managed-pattern + `.wardline/`-dir + no-follow guards; its `destructiveHint` is `True` + (§8). An MCP-surface regression pins the confinement (§6 test #18). +- Full suite green; ruff/mypy clean; base stays zero-dep. + +**Explicitly NOT in scope (YAGNI):** + +- No new config key. `artifacts.dir` / `artifacts.retain` keep their meaning; only the + *anchor* of `artifacts.dir` changes. +- No relocation of the artifact convention into `.weft/wardline/` (considered and + rejected — keep the shipped, discoverable top-level `.wardline/`). +- No change to explicit `--output` (CI's out-of-tree sink), to the MCP `scan` tool + (returns findings inline, writes no disk artifact), or to `scan-job` (which has its own + `_write_scan_artifact` anchored to the job dir under the MCP `--root` — it does not call + the function this spec changes). +- No change to where the **existing** doctor install artifacts (`CLAUDE.md`, `.mcp.json`, + `weft.toml`, skills, state dir) are written: they keep anchoring to the literal `--root`. + Only the two **new** hygiene actions adopt `project_root_for(root)` (§4 intro). The + recommended invocation is at the project root, where the two roots coincide. +- doctor does **not** auto-delete unstamped files (e.g. a hand-created or + unknown-provenance `findings.jsonl`); it reports them. It also does **not** auto-delete + managed-pattern files sitting *outside* a `.wardline/`-named directory — those are + report-only too (§4.2, narrowed authorship). +- No artifact-content self-description (a `scan_root` key / SARIF `uriBaseId`) in this + spec — deferred to §9 backlog. + +--- + +## 2. Background: how the location is decided today + +`src/wardline/cli/scan.py` (default-output branches) calls +`write_scan_artifact(path, fmt, cfg, content)` where `path` is the `wardline scan` PATH +argument (default `.`). In `src/wardline/core/artifacts.py`: + +```python +def write_scan_artifact(root, fmt, config, content): + root_resolved = root.resolve() # = the SCAN path + artifact_dir = _artifact_dir(root_resolved, config) # root_resolved / config.artifacts.dir + ... + +def _artifact_dir(root_resolved, config): + return safe_project_path(root_resolved, Path(config.artifacts.dir), label="wardline scan artifacts") +``` + +So the artifact dir is `‹scan-path›/‹artifacts.dir›` and `safe_project_path` confines it +**under the scan path**. Point the scan at a subdir → the artifact dir is under that +subdir. + +**Wardline already solved this for its state.** `src/wardline/core/paths.py` +`weft_state_dir(root)` anchors `.weft/wardline/` (baseline/waivers/judged) to the +project root, honors a `[wardline].store_dir` override, and **confines** the override +under root (relative → under root; absolute → only if inside root; escape → default). +`enclosing_project_root(scan_path)` already walks up to find the project root and is what +powers the nested-scan-root warning. We reuse both. Scan artifacts are the lone on-disk +surface that escaped this convention; Part 1 brings them into line. + +--- + +## 3. Part 1 — anchor artifacts to the project root + +### 3.1 New helpers in `core/paths.py` + +`paths.py` is the declared single source of truth for on-disk locations; both helpers +belong there next to `weft_state_dir`. + +```python +DEFAULT_ARTIFACT_DIR = ".wardline" # owned here (single source of truth); re-exported by config.py + + +def project_root_for(scan_path: Path) -> Path: + """The weft-project root governing a scan of *scan_path*. + + enclosing_project_root() returns the nearest STRICT ancestor carrying project + markers, or None when scan_path itself is a root OR no ancestor is one. In both + None cases the governing root is scan_path itself: + * scan_path has markers -> it IS the root + * scan_path is a project subdir -> the enclosing root + * no markers anywhere -> fall back to scan_path (unfederated tree) + Always returns a fully-resolved path: enclosing_project_root resolves internally + and returns resolved ancestors; the fallback resolves scan_path. + """ + return enclosing_project_root(scan_path) or scan_path.resolve() + + +def artifacts_dir(scan_path: Path, artifacts_dir_value: str) -> Path: + """Resolved scan-artifact directory, anchored to project_root_for(scan_path). + + Mirrors weft_state_dir's confinement EXACTLY: artifacts_dir_value (default + ".wardline") resolves under the project root; an absolute value is honored only + if it lands inside the project root; any value resolving OUTSIDE (absolute + elsewhere, or a `..` escape) is ignored and the default ".wardline" under the + project root is used. weft.toml is untrusted input when scanning an untrusted + repo, so this denies a malicious artifacts.dir both a write-redirect primitive + and an exit-2 DoS.""" + project_root = project_root_for(scan_path) # already fully resolved + default = project_root / DEFAULT_ARTIFACT_DIR + candidate = Path(artifacts_dir_value) + resolved = (candidate if candidate.is_absolute() else project_root / candidate).resolve() + try: + resolved.relative_to(project_root) # project_root is resolved; no re-resolve needed + except ValueError: + return default + return resolved +``` + +**`DEFAULT_ARTIFACT_DIR` ownership (resolved — panel finding).** Define +`DEFAULT_ARTIFACT_DIR = ".wardline"` in `paths.py` (its single-source-of-truth role) and +re-export from `config.py` via `from wardline.core.paths import DEFAULT_ARTIFACT_DIR`. +The reverse (paths.py importing the constant from config.py) is a **real import cycle**: +`config.py` already imports from `paths.py` at module top, and `DEFAULT_ARTIFACT_DIR` is +defined *after* that import, so a top-level back-import fails with `ImportError` on a +partially-initialized module. Do not present the directions as equivalent; only the +paths.py-owns / config.py-re-exports direction is correct. (`DEFAULT_ARTIFACT_RETAIN` +stays in `config.py`; it is not referenced from `paths.py`.) + +### 3.2 `core/artifacts.py` uses the project-root anchor + +Replace the scan-root anchor with the project-root anchor and thread the project root as +the confinement base through every `safe_project_path` call: + +- `_artifact_dir(root, config)` → resolve via `paths.artifacts_dir(root, config.artifacts.dir)`. +- `timestamped_scan_artifact` / `write_scan_artifact`: compute + `project_root = paths.project_root_for(root)` once; pass `project_root` (not + `root.resolve()`) as the confinement base to `_timestamped_candidates`, + `prune_scan_artifacts`, and `_write_text_exclusive`. +- `prune_scan_artifacts(root, artifact, fmt, retain)`: its `safe_project_path(...)` + guard base becomes the project root. Pruning still operates within + `artifact.parent` (the resolved artifact dir), so per-directory retention mechanics are + unchanged — it just runs in the right directory. + +Public signatures of `write_scan_artifact` / `timestamped_scan_artifact` stay the same +(they still take the scan `root`); only the internal anchor changes. The `cli/scan.py` +call sites are untouched. + +**Retention is now project-root-WIDE (panel finding — document, do not redesign).** Two +scans of two *different* subdirectories of one project now both write into and prune the +single `‹project-root›/.wardline/` pool. `prune_scan_artifacts` matches by suffix + +timestamp pattern only and cannot distinguish which scan produced a given artifact, so +retention is applied to the merged population, not per-origin. This is benign — no data +loss, and the `-NNN` collision counter (`artifacts.py` +`_timestamped_candidates`) already disambiguates same-second writes — but it is a real +change from the old per-subdir pools. The previous "retention unchanged" framing is true +*per directory* and false for the merged pool; state the project-wide behavior in +CHANGELOG/docs (§7). Per-origin partitioning is explicitly NOT pursued (YAGNI). + +### 3.3 What does NOT change (and the one message that must) + +- `config.artifacts.dir` default (`.wardline`) and `artifacts.retain` default (`20`), + and `core/config_schema.py`. No new keys. +- The `WLN-ENGINE-NESTED-SCAN-ROOT` engine finding stays (a subdirectory scan is still + wrong for identity/suppression reasons; anchoring the artifact does not make it + correct) — **but its message text must be amended.** `core/run.py:441-446` currently + ends `"…the project's baseline/waivers/judged state is not loaded, and output defaults + under the subdirectory. Scan the project root for federation-stable results."` After + Part 1 the **"output defaults under the subdirectory" clause is false** — output now + defaults under the *project* root. Drop that clause; keep the real hazard (qualnames + mis-minted relative to the subdir; project suppression state not loaded). New tail: + `"…the project's baseline/waivers/judged state is not loaded. Scan the project root + for federation-stable results."` This is the lone engine-message edit and must be + listed in the implementation plan. +- Explicit `--output PATH` (all formats): writes verbatim to the chosen path via the + no-follow sinks, exactly as today. This is CI's out-of-tree path + (`--output "$RUNNER_TEMP/…"`). +- MCP `scan` (`mcp/server.py` `_scan`): returns findings inline, writes no disk + artifact — unchanged. `scan-job` (`core/scan_jobs.py`) uses its own + `_write_scan_artifact`, anchored to `job_dir(root, job_id)` under the MCP `--root` + (not `artifacts.write_scan_artifact`), so it is unaffected. The only callers of + `artifacts.write_scan_artifact` are the four default-output branches in `cli/scan.py`. + +--- + +## 4. Part 2 — `wardline doctor --repair` hygiene + +Both actions are **new `DoctorCheck`-returning helpers** in `install/doctor.py`, +appended to `machine_readable_doctor`'s `checks` list (after the existing `_check_*` +appends), exactly as `_check_config` is wired — **§4.3 is authoritative** on plumbing. +Two clarifications the panel forced, because the prior draft was ambiguous: + +- **`repair_install`'s `dict[str, str]` return is NOT the home for these statuses.** + `machine_readable_doctor` calls `repair_install(root)` purely for side effects and + *discards its return value*; statuses placed there never reach the JSON `checks` array. + Leave `repair_install`'s contract untouched. +- **`check_install` is NOT the check-only reporter for these.** It returns + `list[CheckResult]` (`name/ok/message` — a different dataclass with no `fixed`/`removed`/ + `review` slot) and powers the read-only `before` snapshot plus the plain + `wardline doctor` path; folding mutating logic there would mutate from read-only call + sites. The new checks live on the `DoctorCheck` path only. + +Both new helpers take a `fix: bool` and perform **zero filesystem mutation when +`fix=False`** (gitignore: report would-add lines; sweep: report would-remove / +would-review, no `unlink`). When `fix=True` the sweep deletes — on **both** the CLI and +the MCP `doctor` tool (`repair:true`), which route through the same +`machine_readable_doctor(root, fix=repair)` → `repair_install` path. The decision (your +call, 2026-06-25) is to allow MCP-triggered deletion rather than gate it CLI-only: an +agent operating the project should be able to clear stray artifacts, consistent with the +MCP-primary / "agents operate and extend" posture. Safety is carried by *bounding the +action*, not by hiding it from the agent surface — confinement under `proj`, the narrowed +authorship heuristic (§4.2), and no-follow — plus flipping the MCP `doctor` tool's +`destructiveHint` to `True` so the now-destructive op is advertised honestly. The MCP +`doctor` tool **must be added to §1 scope** (it was previously silent) and its deletion +reach justified under §8's untrusted-`weft.toml` threat model; a §6 regression (test #18) +drives the sweep through `machine_readable_doctor(fix=True)` and asserts confinement. + +**Root reconciliation (must-fix #1).** The prior draft said the actions "hook into +`repair_install(root)`", which resolves `root` **literally** — but Part 1 writes the +artifact to `project_root_for(scan_path)`. For the headline subdir-scan case these +differ, and `cli/scan.py:238` even steers the subdir scanner to +`wardline doctor --repair --root ‹subdir›`, so the repair would gitignore/sweep the +*subdir* and never touch the `‹project-root›/.wardline/` that Part 1 actually populated. +Resolution: **the two new helpers compute `proj = paths.project_root_for(root)` and do +all of their work — config read for `artifacts.dir`, gitignore write, and the sweep +walk — against `proj`.** They are the same dir Part 1 writes to. The existing install +checks keep their literal-`--root` anchoring (out of scope, §1). Also **fix the +`cli/scan.py:238` hint** to recommend running doctor at the project root (e.g. drop the +`--root ‹subdir›` suffix, or point it at the enclosing project root) so the steered +invocation lands on the right tree. + +**Snapshot `proj` BEFORE `repair_install` runs (ordering hazard).** `machine_readable_doctor` +computes `config_missing_before` and then, under `fix=True`, calls `repair_install(root)` +*before* the `checks` list is built — and `repair_install` → `_ensure_weft_config(root)` +plants a `weft.toml` at the **literal** `root` when absent. If the new helpers computed +`proj` *after* that, a fresh-subdir invocation (`doctor --repair --root ‹fresh-subdir›` +inside a federated project) would see the just-planted subdir `weft.toml`, so +`project_root_for(subdir)` would return the **subdir** (now a marker-carrying root) and +the climb to the enclosing project is defeated — and a nested `weft.toml` is left behind. +Compute `proj = paths.project_root_for(root)` once, alongside `config_missing_before` +(i.e. before the `if fix:` block), and thread that snapshot into both new helpers. After +the `scan.py:238` hint fix the steered invocation is already at the project root where +this never bites, but the snapshot makes the off-path manual invocation correct too. + +### 4.1 `.gitignore` hygiene — `_check_gitignore(proj, *, fix)` + +Ensure the project `.gitignore` ignores the **configured** artifacts dir (default +`.wardline/`) and the legacy top-level `findings.jsonl`. + +- **Ignore target = the Part 1 resolver's output.** Compute the dir with the *same* + `paths.artifacts_dir(proj, config.artifacts.dir)` resolver Part 1 uses, then gitignore + its **project-root-relative** path (always in-tree by construction). This deletes the + prior draft's "if the configured dir is absolute/outside root, ignore only + `findings.jsonl`, note out-of-tree" branch: that branch was **unreachable and wrong** — + Part 1's confinement makes an escaping `artifacts.dir` fall back to `‹proj›/.wardline`, + so the dir that actually receives artifacts is always in-tree and must be the one + ignored. Ignoring only `findings.jsonl` there would leave the real artifact dir + un-ignored and committable — exactly the misconfigured-weft.toml case we must not leak. +- Append a managed block under a `# Wardline scan artifacts` comment, adding only the + lines not already present (idempotent — running `--repair` twice adds nothing the + second time). Match existing lines by exact normalized entry (`.wardline/`, + `findings.jsonl`), tolerant of an existing trailing-slash/no-slash variant. +- **Idempotence must be CRLF- and edge-case-safe** (panel finding — the DoD says a + second `--repair` is a no-op): split existing content with `str.splitlines()` (handles + `\n`, `\r\n`, `\r`); `.strip()` each line before the normalized compare (so a + `.wardline/\r` entry on a CRLF file still matches and is not re-appended); write the new + block with explicit `\n` joins (LF is canonical for git-consumed files); if the + existing file does not end in a newline, prepend one before the block so the comment + header does not concatenate onto the last entry; and **exclude comment (`#`) and + negation (`!`) lines from the already-present set** so a commented-out or `!.wardline/` + line does not falsely satisfy the check. +- Never clobber existing content: read existing `.gitignore` (safe no-follow read), + append, write atomically. **Prefer reusing the established `install/block.py` + `inject_block` managed-block idiom** (used by `repair_install` today) — it gives atomic + write (tempfile + `os.replace`) and foreign-block safety for free. If a `.gitignore` + needs line-based rather than fenced-marker semantics and `inject_block` does not fit, + use `safe_write_text` but preserve the same atomic-write + never-clobber properties and + say so in the plan. Create `.gitignore` if absent. +- Runs on **both** CLI and MCP `fix=True` (an idempotent, in-root write of the project's + own `.gitignore` is benign, same class as the existing `inject_block` CLAUDE.md write). +- Status: `created` / `updated` / `ok` (already present). Reported as a `gitignore` + check (a `DoctorCheck`). + +### 4.2 Stray-artifact sweep — `_sweep_stray_artifacts(proj, *, fix)` + +Find wardline-**managed** artifacts sitting outside the standard dir and remove them (on +both the CLI and the MCP `doctor` tool when `fix=True`); report unstamped and +out-of-`.wardline` strays. No surface gate — deletion is bounded by the guards below, not +hidden from the agent surface (the 2026-06-25 "MCP can delete too" decision). + +- **Managed pattern:** reuse `artifacts._managed_artifact_pattern(suffix)` across all + four known suffixes (`findings.jsonl`, `findings.sarif`, `findings.agent-summary.json`, + `scan.legis.json`): `^\d{8}T\d{6}Z(-\d{3})?-‹suffix›$`. +- **Narrowed authorship (panel finding — closes the data-loss channel).** A + timestamp-pattern *name* is a heuristic, not proof of wardline authorship. **Auto-delete + a managed-pattern file ONLY when it sits inside a `.wardline/`-named directory** (the + observed mess: `‹subdir›/.wardline/‹stamp›-findings.jsonl`). A managed-pattern file + sitting *bare* in an arbitrary tree location is treated as **REVIEW** (report-only), + exactly like an unstamped file. State plainly in the code/docs that the name match is a + heuristic. This narrowing is what makes the destructive action safe enough to exist. +- **Walk discipline — do NOT reuse `discover()`'s scoping** (panel finding). `discover()` + walks `config.source_roots` (default `('src',)`), not the project root, and applies + `config.exclude` as a *post-walk* `fnmatch` filter (default `()`), so "honor + `config.exclude` like discover()" would both (a) miss strays outside `src/` and (b) + give zero walk-pruning on a default-config repo (walk-DoS into `node_modules`/`.venv`). + Instead: walk the **whole** `proj` with `os.walk(..., followlinks=False)`; prune + directories via a shared hard-skip set (**export `core/discovery`'s `_ALWAYS_SKIP` as a + public constant** and reference it) plus the root `.gitignore` matcher; always skip + `.git/` and the resolved standard artifacts dir; treat `config.exclude` as an optional + *post-match* filter only (a stray can legitimately sit under an excluded/non-source + path, so excludes must not be a hard walk bound). `config.exclude` is read from `proj`'s + weft.toml. +- **Stop at nested project roots (panel finding — hazard created by must-fix #1).** Since + the sweep now walks from `project_root_for(root)`, it can reach a vendored sub-project + that carries its *own* `weft.toml`/`.weft/wardline/` and whose `.wardline/` is its own + legitimately-anchored artifact store. Do **not** descend into or sweep any directory + that `_has_project_markers(dir)` reports True (mirror `enclosing_project_root`'s + own-markers stop, `paths.py`). Otherwise the outer sweep deletes a nested project's + current artifacts. +- **Delete mechanics.** Deletion happens when `fix=True` (both CLI and the MCP `doctor` + tool). Delete managed files found **inside a `.wardline/`-named dir outside the standard + artifacts dir**, confined under `proj` via `safe_project_path`, regular-file/no-follow + checked (`_is_regular_file_no_follow`). Wrap each per-file `safe_project_path` call in + `try/except WardlineError: continue` so one symlinked/escaping entry **skips** rather + than aborting the whole sweep (`safe_project_path` raises on a symlinked final + component). Remove a now-empty stray `.wardline/` with **`os.rmdir` only** (never + `shutil.rmtree`/recursive), after an `lstat` non-symlink check + `safe_project_path`, + letting `ENOTEMPTY`/`ENOTDIR` be the natural guard. Never delete through a symlink or + outside `proj`. +- **Report, never delete:** unstamped files (a bare `findings.jsonl` of unknown + provenance — e.g. esper-lite's 600-mode 834 KB root file) *and* managed-pattern files + outside a `.wardline/` dir, listed under a `REVIEW` line for the human. +- **MCP posture (must-fix #2 — resolved "MCP can delete too").** The MCP `doctor` tool + with `repair:true` performs the same delete as the CLI (same confined, + `.wardline/`-narrowed, no-follow path), since both route through + `machine_readable_doctor(root, fix=repair)` → `repair_install`. The implementation + **must flip the doctor tool's `destructiveHint` from `False` to `True`** (`mcp/server.py`, + the `_DOCTOR_TOOL` annotations) so a now-destructive op is not advertised as + non-destructive, and the agent-facing tool description should note that `repair:true` + may delete managed stray artifacts under the server root. See §8 for the threat-model + justification and §6 test #18 for the confinement regression. +- Status: `stray_artifacts` check (a `DoctorCheck`) — reports counts of removed managed + files and flagged-for-review files, with paths. + +### 4.3 Output shape (authoritative on plumbing) + +The two new checks are `DoctorCheck` instances appended to `machine_readable_doctor`'s +`checks` list (after the existing `_check_*` appends). `DoctorCheck` (or its `to_dict`) +is **extended with optional `removed: list[str]` and `review: list[str]` fields** so the +sweep's structured payload survives JSON serialization; absent fields stay omitted for +the other checks. (Alternative: the sweep returns a distinct richer result the JSON layer +folds in — implementer's call, but the dataclass extension is the smaller change.) + +Both new checks are wired into **both** non-JSON `cli/doctor.py` render branches, since +neither routes through `machine_readable_doctor`: the `--repair` human path (`fix=True`, +as bespoke trailing lines alongside `weft.toml`/`filigree.auth`) and the check-only path +(`fix=False`). + +Human (`wardline doctor --repair`): + +``` +wardline doctor: + ... + weft.toml: ok + gitignore: updated (added .wardline/, findings.jsonl) + stray artifacts: + removed src/esper/simic/training/.wardline/ (1 managed file) + REVIEW findings.jsonl (unstamped; not wardline-managed — remove by hand if it's a stray scan) +``` + +`--fix` JSON (`machine_readable_doctor`): the `gitignore` and `stray_artifacts` checks +join the existing `checks` array with `{id, status, fixed, message}` plus, for the +sweep, the new structured `removed: [...]` and `review: [...]` path lists. Check-only +`doctor` (and `machine_readable_doctor(fix=False)`) prints the same gaps with no `fixed`, +no `removed` deletions, and no gitignore write. + +--- + +## 5. Components & isolation + +| Unit | Responsibility | Depends on | +|------|----------------|------------| +| `paths.DEFAULT_ARTIFACT_DIR` | single-source default dir name (re-exported by config) | — | +| `paths.project_root_for` | scan path → governing project root | `enclosing_project_root` | +| `paths.artifacts_dir` | project-root-anchored, confined artifact dir | `project_root_for`, `DEFAULT_ARTIFACT_DIR` | +| `artifacts.*` | timestamped name allocation, exclusive write, retention | `paths.artifacts_dir`, `safe_project_path` | +| `discovery._ALWAYS_SKIP` (newly exported) | shared hard walk-skip set | — | +| `install/doctor._check_gitignore` | idempotent, CRLF-safe managed-block gitignore on `project_root_for(root)` | `paths.artifacts_dir`, `install/block.inject_block` or `safe_write_text` | +| `install/doctor._sweep_stray_artifacts` | walk `project_root_for(root)`, delete managed strays (CLI), flag the rest | `_managed_artifact_pattern`, `_is_regular_file_no_follow`, `discovery._ALWAYS_SKIP`, `_has_project_markers`, `safe_project_path` | +| `cli/doctor` | render the two new checks in both branches | `install/doctor`, `machine_readable_doctor` | + +Each unit is independently testable: the path resolvers are pure functions of +`(scan_path, fs-markers)`; the gitignore writer is a pure text transform plus one atomic +write; the sweep is a walk + filter + guarded unlink with deletion behind two booleans. + +--- + +## 6. Test plan + +**Artifact anchoring (`tests/unit/core/test_artifacts.py`, `test_paths.py`, + scan CLI tests):** + +1. Subdir scan of a weft project → artifact at `‹project-root›/.wardline/`, NOT under the + subdir; `WLN-ENGINE-NESTED-SCAN-ROOT` still present in findings/stderr **and its + message no longer says "output defaults under the subdirectory"** (§3.3). +2. True-root scan → artifact at `‹root›/.wardline/` (unchanged). +3. Unfederated tree (no markers up the chain) → artifact at scan path (fallback preserved). +4. Custom `artifacts.dir = "out/wl"` → anchored to `‹project-root›/out/wl/`. +5. Escaping `artifacts.dir` — `"../../etc"` and an absolute path outside root — → falls + back to `‹project-root›/.wardline/`; nothing written outside root (security). +6. Retention prunes to `retain` within the resolved dir; **plus a project-wide case**: + two distinct subdir scans sharing one `‹project-root›/.wardline/` with `retain=2` + prune the merged pool to 2 (pins the §3.2 project-wide-retention statement). +7. Explicit `--output` path unaffected; MCP `scan` writes no disk artifact (regression + guard). +8. **Direct unit matrix for `project_root_for` / `artifacts_dir`** in `test_paths.py` + (mirror the `weft_state_dir` matrix): scan_path-is-root, subdir, unfederated, relative + override, absolute-inside-root HONORED, absolute-outside FALLBACK, `..`-escape, + malformed value. (§5 calls these pure/independently-testable; don't cover them only via + the CLI.) + +**Doctor (`tests/unit/install/test_doctor*.py` / cli / mcp):** + +9. `--repair` on a project missing the ignore lines → `.gitignore` gains the managed + block; second `--repair` is a no-op (idempotent), including a **CRLF `.gitignore`** and + a pre-existing bare `.wardline` (no slash) → no duplicate line; pre-existing unrelated + `.gitignore` content preserved verbatim (never-clobber); a commented `#.wardline/` / + `!.wardline/` line does NOT falsely satisfy the check. +10. Custom `artifacts.dir` → that dir's project-root-relative path is what gets ignored; + the dead "absolute/outside → ignore only findings.jsonl" branch is gone (an escaping + value still ignores `.wardline/` via the fallback). +11. **Root reconciliation:** `doctor --repair --root ‹subdir›` of a federated project + gitignores and sweeps the **enclosing project root**, not the subdir (pins must-fix + #1); the `cli/scan.py` hint points at the project root. +12. Sweep removes a nested `‹subdir›/.wardline/‹stamp›-findings.jsonl` and the emptied + dir; **all four managed suffixes** covered, not just `findings.jsonl`. +13. Sweep **report-only** cases: a bare `findings.jsonl` is listed under REVIEW, never + deleted; a managed-pattern file **outside any `.wardline/` dir** is REVIEW, not + deleted (narrowed authorship). +14. **Negative control:** a managed file *inside* the standard `‹proj›/.wardline/` + survives the sweep (guards the skip-standard-dir condition). +15. **Nested-root stop:** a vendored sub-project carrying its own `weft.toml` keeps its + `.wardline/` artifacts through an outer sweep (pins the §4.2 nested-marker stop). +16. **Symlink safety, split:** (16a) walk-time — the sweep does not descend a symlinked + directory; (16b) unlink-time — a managed-named file that is a symlink is not unlinked; + each with an explicit post-sweep presence assertion. Never unlinks outside `proj`. +17. **Check-only non-mutation:** plain `wardline doctor` and + `machine_readable_doctor(fix=False)` leave a planted stray on disk AND add NO managed + gitignore block; `--fix` JSON includes `gitignore` and `stray_artifacts` checks with + the right `status`/`removed`/`review` fields. +18. **MCP-surface deletion confinement (must-fix #2):** drive the sweep through + `machine_readable_doctor(fix=True)` exactly as the MCP `doctor(repair:true)` handler + does, against a planted tree containing (a) a managed stray inside a + `‹subdir›/.wardline/` → deleted, (b) a managed-named file that is a **symlink** → not + unlinked, (c) a managed-named file pointed at via an out-of-root **dir symlink** → not + reached/deleted, (d) an unstamped + a bare-managed file → REVIEW, not deleted. Assert + deletions are confined under root, match only the managed pattern inside `.wardline/`, + and never follow a symlink. Pair with a unit asserting the MCP `doctor` tool's + `_DOCTOR_TOOL` annotation reports `destructiveHint: True`. + +--- + +## 7. Docs & changelog + +- `docs/getting-started.md`, `docs/guides/configuration.md`, `docs/guides/agents.md` (and + `docs/guides/weft.md` where artifacts are described): state that the default findings + artifact lands in `‹project-root›/.wardline/` — anchored to the project root (the + `weft.toml` directory), **independent of where `wardline scan` is invoked** — and that + a subdir scan is still flagged. Note `wardline doctor --repair` sets up the gitignore + and clears stray artifacts — available from both the CLI and the MCP `doctor` tool + (`repair:true`), which deletes managed strays under the project root and is advertised + `destructiveHint: True`. +- `CHANGELOG.md` `[Unreleased]`: + - **Changed** — default scan artifacts now anchor to the weft-project root rather than + the scan cwd; retention is therefore project-root-wide across heterogeneous + subdir/root scans sharing one `.wardline/`. **Migration note:** after upgrade the + artifact MOVES to the project root, and `wardline doctor --repair` will sweep the + now-stale per-subdir `.wardline/` dirs — any CI/automation reading a hardcoded + `‹subdir›/.wardline/*-findings.jsonl` path must be updated. + - **Added** — `wardline doctor --repair` gitignores the artifacts dir and sweeps stray + managed artifacts; deletion is available on both the CLI and the MCP `doctor` tool + (`repair:true`, now advertised `destructiveHint: True`), bounded to managed-pattern + files inside `.wardline/` dirs under the project root. + +--- + +## 8. Risks & rollout + +- **Behavior change to a shipped feature.** Default artifact location moves for + subdirectory scans, and retention becomes project-wide. Intended fix, not a regression; + per project convention no back-compat shim — the `artifacts.dir` key is unchanged, only + its anchor. Documented under CHANGELOG **Changed** with the migration note. +- **Untrusted weft.toml.** `artifacts_dir` confinement (mirroring `weft_state_dir`) is + the guard; test #5 pins it. +- **Destructive sweep, MCP-reachable — intended (must-fix #2, resolved "MCP can delete + too").** The MCP `doctor` tool reads `repair` from agent args and calls the same + `machine_readable_doctor(root, fix=repair)` → `repair_install` path, so the sweep's + delete is reachable from the agent surface against the (possibly untrusted) server-root + checkout. **Decision:** allow it rather than gate it CLI-only — an agent operating the + project should be able to clear stray artifacts (MCP-primary / "agents operate and + extend"). The risk is managed by *bounding the action and advertising it*, not by hiding + it: (1) the delete is confined under `proj` via `safe_project_path`, no-follow, and + matches **only** the managed timestamp pattern **inside a `.wardline/`-named dir** — + blast radius is wardline's own stamped artifacts, never arbitrary source; (2) unstamped + and bare-managed files are report-only; (3) the sweep stops at nested project markers; + (4) the implementation **flips the `_DOCTOR_TOOL` `destructiveHint` to `True`** so the + op is honestly advertised, and the tool description notes deletion. An agent that + shouldn't delete simply does not pass `repair:true`. Tests #16 (symlink safety) and #18 + (MCP-surface confinement + `destructiveHint: True`) pin it. **Residual risk accepted:** a + crafted untrusted repo could induce deletion of files it placed inside a `.wardline/` + dir matching the stamp pattern — i.e. wardline deletes attacker-planted files that + *look* like its own artifacts, which is a no-op-equivalent loss (the attacker's own + planted bytes), not exfiltration or escape. +- **Authorship heuristic.** A timestamp-pattern filename is not proof of wardline + authorship; auto-delete is narrowed to managed files *inside `.wardline/` dirs*, and + everything else is report-only (§4.2). Bounds the blast radius of the destructive path + to wardline's own artifact convention. +- **Nested vendored project.** Walking from `project_root_for(root)` could reach a + sub-project's own `.wardline/`; the sweep stops at nested project markers (§4.2). Test + #15 pins it. +- **Repair-ordering hazard (`_ensure_weft_config` vs the must-fix-#1 climb).** Under + `fix=True`, `repair_install` plants a `weft.toml` at the literal `root` *before* the + new checks run; computing `project_root_for(root)` after that would make a fresh-subdir + invocation anchor to the subdir (now a root) and defeat the climb, leaving a nested + `weft.toml`. Mitigation: snapshot `proj` before the `if fix:` block and thread it in + (§4 intro). Benign on the steered project-root invocation; the snapshot fixes the + off-path manual `--root ‹subdir›` case. +- **Import hygiene.** `paths.py` owns `DEFAULT_ARTIFACT_DIR`; `config.py` re-exports it + (§3.1). The reverse direction is a real cycle and is rejected, not offered as an option. + +--- + +## 9. Deferred (agent-attributed backlog — NOT in this spec) + +The panel surfaced one improvement that is real but is **scope expansion**, not a fix to +shipped correctness, so it is recorded here rather than folded in (keeping this a tight +two-part change): + +- **Self-describing relocated artifacts.** Relocating a *subdir* scan's artifact into the + shared `‹project-root›/.wardline/` co-locates it with root-scan artifacts; its + `location.path` values stay **scan-root-relative** (the subdir), and three of the four + formats carry no base to disambiguate (`legis.json` already embeds `scan_root`; + `findings.jsonl`, `findings.agent-summary.json`, and SARIF do not). A future change + could add a project-root-relative `scan_root` key to the jsonl run-context and the + agent-summary top-level object, and SARIF `originalUriBaseIds` + per-result + `uriBaseId`, so a consumer can rebase the paths. + - **Why deferred, not blocking:** a subdir scan is *already* flagged + `WLN-ENGINE-NESTED-SCAN-ROOT` as wrong-for-identity, so its artifact is in a path the + tool actively discourages; for the correct root-scan usage the artifact and its + project-root-relative paths are fully self-consistent; federation consumers + (Loomweave/Filigree/dossier) key on *fingerprint*, not path-relative resolution; and + GitHub Code Scanning already resolves SARIF URIs against the repo root regardless of + the `.sarif` file location. The net-new harm is limited to co-locating + already-discouraged subdir-scan artifacts — worth a backlog item, not a redesign of + the emitters in this pass. File it as an agent-attributed expansion ticket + (`wardline` tracker, coverage/robustness label) when this ships. diff --git a/overrides/home.html b/overrides/home.html new file mode 100644 index 00000000..ca18c397 --- /dev/null +++ b/overrides/home.html @@ -0,0 +1,228 @@ +{% extends "main.html" %} + +{# + Wardline product landing page — "technical & confident." + Applied only to docs/index.md via front-matter `template: home.html`. + + The hero is built around the REAL PY-WL-101 finding from index.md + (qualname service.current_user, declared INTEGRAL, actually EXTERNAL_RAW). + The hero code panel is hand-marked-up (NOT Material-highlighted) so the flow + motif and verdict can be styled; the REAL JSON finding stays in the markdown + body below via {{ page.content }} so Material's syntax highlighting and + content.code.copy buttons keep working on the genuine artifact. + Everything is pure HTML/CSS/SVG — no JS, so navigation.instant is unaffected. +#} + +{% block content %} +
+ +
+
+ + + Static analysis · zero runtime deps + +

See untrusted data
cross a trust boundary.

+

+ Wardline is a semantic-tainting static analyzer for Python. + It reads your source — never runs it — and checks every + trust-annotated function against one question: is the data this + function works with as trusted as it claims? +

+ +
+ + {# Hero finding panel — the real PY-WL-101 leak, made visible. #} + +
+ +
+
+ +

Three trust decorators

+

+ Declare trust at the source with @external_boundary, + @trust_boundary, and @trusted. Undecorated + code stays in the developer-freedom zone — opt-in, fail-closed. +

+
+
+ +

Eleven policy rules

+

+ PY-WL-101 through PY-WL-111 catch trust-boundary + leaks, untrusted data reaching deserialization, exec, and shell sinks, and + validators that can’t say “no.” +

+
+
+ +

SARIF & JSONL output

+

+ Emit findings as SARIF for code-scanning dashboards or JSONL for tooling. + Gate CI with wardline scan --fail-on ERROR. +

+
+
+ +

Agent-ready: MCP & LLM triage

+

+ A built-in MCP server lets coding agents scan and explain taint. An + opt-in LLM triage judge, baselines, and waivers keep the signal clean. +

+
+
+ + {# The trust model — the distinctive visual section: three decorators feed + an eight-state lattice, most → least trusted. #} +
+

The trust model

+

Eight ordered states. Four you declare.

+

+ You annotate code with three decorators. Wardline propagates trust across + the call graph and grades every value on an eight-state lattice — a + function is only as trusted as the least-trusted value it returns. A leak + is the moment a less-trusted state reaches a producer that claims more. +

+ +
+
+ @external_boundary +

Marks a source of raw, untrusted input — data starts at + EXTERNAL_RAW.

+
+
+ @trust_boundary +

A validator that raises trust — and must have a path + that can say “no.”

+
+
+ @trusted +

A producer that claims a trust level. Wardline checks the + claim against what it actually returns.

+
+
+ + +
+ + {# The real index.md markdown body: install layers, 30-second example, and + the REAL PY-WL-101 finding (matching the hero qualname). Rendered as + Material markdown so code blocks keep highlighting + copy buttons. #} +
+ {{ page.content }} +
+ +
+

Part of Weft Federation

+

Five citizens, one suite

+

+ Wardline is one of five Weft Federation citizens — agent-first tooling built on + “humans on the loop, not in the loop.” Each is zero-config and + opt-in: enterprise-class for one-to-two-developer teams, without enterprise + weight. +

+ +
+ +
+{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 4c4d9fb7..5cfe5fdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ disable_error_code = [ [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-m 'not network and not loomweave_e2e and not legis_e2e and not filigree_e2e and not rust_e2e and not loomweave_drift and not warpline_e2e'" +addopts = "-m 'not network and not loomweave_e2e and not legis_e2e and not filigree_e2e and not rust_e2e and not loomweave_drift and not sei_drift and not warpline_e2e and not reason_vocab_drift and not filigree_token_drift and not worklist_drift and not legis_scan_artifact_drift'" markers = [ "network: tests that need network (live OpenRouter judge e2e — SP5)", "loomweave_e2e: tests that need a real `loomweave serve` binary (SP9 round-trip)", @@ -152,7 +152,12 @@ markers = [ "filigree_e2e: tests that need a real Filigree with the promote route (WS-A2 round-trip)", "rust_e2e: live `wardline scan --lang rust` CLI subprocess over the .rs corpus (WP6)", "loomweave_drift: live recheck of the vendored Rust qualname corpus against the sibling loomweave checkout (release-gate drift alarm)", + "sei_drift: live recheck of the vendored SEI conformance oracle against the sibling loomweave source fixture (release-gate drift alarm)", "warpline_e2e: live `warpline reverify | wardline scan --affected -` round-trip (delta scope; weekly/manual)", + "reason_vocab_drift: live recheck of the vendored weft reason-vocabulary contract against the sibling weft hub source (release-gate drift alarm)", + "filigree_token_drift: live substantive recheck of the vendored WEFT_FEDERATION_TOKEN auth contract against the sibling filigree source (release-gate drift alarm)", + "worklist_drift: live recheck of the vendored warpline.reverify_worklist.v1 wire against the sibling warpline source (release-gate drift alarm)", + "legis_scan_artifact_drift: live recheck of the vendored wardline->legis scan-artifact vector against the sibling legis source (release-gate drift alarm)", ] [tool.coverage.run] diff --git a/site/scripts/fetch-site-kit.mjs b/site/scripts/fetch-site-kit.mjs index 0d93c658..37a15a29 100644 --- a/site/scripts/fetch-site-kit.mjs +++ b/site/scripts/fetch-site-kit.mjs @@ -8,6 +8,7 @@ // realization of the "git subdirectory dependency" decision (IA §1.3, §6): // not a published registry package, not a submodule, not a hand-vendored static // copy — a regenerated, never-committed vendor tree refreshed on every build. +// Privileged GitHub Actions builds must pin the remote fetch to a full commit SHA. // // Runs before `npm install` (the preinstall hook) so the file: target exists // when the install resolves it; the Pages workflow runs it explicitly too. @@ -21,9 +22,12 @@ import { tmpdir } from 'node:os'; const here = dirname(fileURLToPath(import.meta.url)); const siteRoot = join(here, '..'); +const DEFAULT_WEFT_SITE_KIT_REF = 'a8f9a6a77458d2ec697cfbc1f71dd88a51962cb7'; const REPO = process.env.WEFT_SITE_KIT_REPO || 'https://github.com/foundryside-dev/weft.git'; -const REF = process.env.WEFT_SITE_KIT_REF || 'main'; +const REF = process.env.WEFT_SITE_KIT_REF || DEFAULT_WEFT_SITE_KIT_REF; const SUBDIR = 'packages/site-kit'; +const IN_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true'; +const FULL_SHA_RE = /^[0-9a-f]{40}$/i; const dest = join(siteRoot, 'vendor', 'site-kit'); @@ -35,6 +39,12 @@ function run(cmd, args, opts) { execFileSync(cmd, args, { stdio: 'inherit', ...opts }); } +function requirePinnedRefForPrivilegedBuild(ref) { + if (IN_GITHUB_ACTIONS && !FULL_SHA_RE.test(ref)) { + throw new Error(`[fetch-site-kit] WEFT_SITE_KIT_REF must be a 40-character commit SHA in GitHub Actions; got ${ref}`); + } +} + async function vendorFrom(srcDir, label) { if (!existsSync(join(srcDir, 'package.json'))) { throw new Error(`[fetch-site-kit] ${label}: no package.json at ${srcDir}`); @@ -49,19 +59,27 @@ async function vendorFrom(srcDir, label) { } async function main() { - if (process.env.WEFT_SITE_KIT_LOCAL === '1' || (existsSync(localKit) && process.env.WEFT_SITE_KIT_REMOTE !== '1')) { + if ( + !IN_GITHUB_ACTIONS && + (process.env.WEFT_SITE_KIT_LOCAL === '1' || (existsSync(localKit) && process.env.WEFT_SITE_KIT_REMOTE !== '1')) + ) { if (existsSync(localKit)) { await vendorFrom(localKit, `local checkout (${localKit})`); return; } } + requirePinnedRefForPrivilegedBuild(REF); const tmp = await mkdir(join(tmpdir(), `weft-site-kit-${Date.now()}`), { recursive: true }).then( (d) => d || join(tmpdir(), `weft-site-kit-${Date.now()}`), ); const clonePath = join(tmpdir(), `weft-site-kit-${process.pid}-${Date.now()}`); try { - run('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', '--branch', REF, REPO, clonePath]); + run('git', ['init', clonePath]); + run('git', ['remote', 'add', 'origin', REPO], { cwd: clonePath }); + run('git', ['fetch', '--depth', '1', '--filter=blob:none', 'origin', REF], { cwd: clonePath }); + run('git', ['checkout', '--detach', 'FETCH_HEAD'], { cwd: clonePath }); + run('git', ['sparse-checkout', 'init', '--cone'], { cwd: clonePath }); run('git', ['sparse-checkout', 'set', SUBDIR], { cwd: clonePath }); await vendorFrom(join(clonePath, SUBDIR), `${REPO}#${REF}:${SUBDIR}`); } finally { diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index a54b8b56..22e4466f 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -313,12 +313,18 @@ wardline mcp`}

What it is, and what it is not.

- - Wardline is a static analyzer that helps you find untrusted data reaching - trusted code. It does not enforce anything at runtime and does not - adjudicate trust for the federation — Legis governs, Wardline analyses. Do - not read a passing scan as a security guarantee. It is the deterministic - gate that makes a trust-boundary leak visible; the rest is your design. + + Wardline is a static analyzer that makes a trust-boundary leak visible and + can gate a build on its exit code. But its taint findings are + advisory deconfliction signals — not a security + certification and not a compliance audit — and a passing, or silent, scan + is not a clean bill of health: an empty result means you + have not yet declared a boundary, not that the code is safe. It does not + enforce at runtime, encrypt, or sandbox anything, and it does not + adjudicate trust for the federation — Legis governs, Wardline analyses. It + is deconfliction-first, not security: do not treat a passing scan as + security or compliance assurance for a regulated, confidential, or + otherwise sensitive system.
    diff --git a/src/wardline/cli/doctor.py b/src/wardline/cli/doctor.py index a7442657..df4e76cc 100644 --- a/src/wardline/cli/doctor.py +++ b/src/wardline/cli/doctor.py @@ -8,10 +8,13 @@ import click from wardline.core.errors import WardlineError +from wardline.core.paths import project_root_for from wardline.install.doctor import ( _check_config, _check_filigree_auth, - _resolve_probe_url, + _check_gitignore, + _resolve_probe_target, + _sweep_stray_artifacts, check_install, machine_readable_doctor, repair_install, @@ -45,9 +48,10 @@ def doctor(root: Path, repair: bool, fix_json: bool, filigree_url: str | None) - raise SystemExit(1) if repair: + proj = project_root_for(root) # Resolve the probe URL BEFORE repair_install rewrites .mcp.json (which would # erase a configured --filigree-url arg), so repair can still probe/recover. - probe_url = _resolve_probe_url(root, filigree_url) + probe_target = _resolve_probe_target(root, filigree_url) try: statuses = repair_install(root) except WardlineError as exc: @@ -61,13 +65,20 @@ def doctor(root: Path, repair: bool, fix_json: bool, filigree_url: str | None) - click.echo(f" {check.name}: {status}") config_status = statuses.get("weft.toml", "checked") if config_check.ok else f"failed ({config_check.message})" click.echo(f" weft.toml: {config_status}") - fcheck = _check_filigree_auth(root, repair=True, filigree_url=probe_url) + fcheck = _check_filigree_auth(root, repair=True, probe_target=probe_target) fstatus = ("fixed" if fcheck.fixed else fcheck.message) if fcheck.ok else f"failed ({fcheck.message})" click.echo(f" filigree.auth: {fstatus}") - if not all(check.ok for check in after) or not config_check.ok or not fcheck.ok: + gi = _check_gitignore(proj, fix=True) + click.echo(f" gitignore: {gi.status}" + (f" ({gi.message})" if gi.message else "")) + sw = _sweep_stray_artifacts(proj, fix=True) + click.echo(f" stray artifacts: removed {len(sw.removed)}, review {len(sw.review)}") + for r in sw.review: + click.echo(f" REVIEW {r} (unstamped/bare — remove by hand if it's a stray scan)") + if not all(check.ok for check in after) or not config_check.ok or not fcheck.ok or gi.status == "error": raise SystemExit(1) return + proj = project_root_for(root) checks = check_install(root) config_check = _check_config(root, fixed=False) fcheck = _check_filigree_auth(root, repair=False, filigree_url=filigree_url) @@ -80,5 +91,12 @@ def doctor(root: Path, repair: bool, fix_json: bool, filigree_url: str | None) - click.echo(f" weft.toml: {config_check.message}") fmsg = fcheck.message or ("ok" if fcheck.ok else "error") click.echo(f" filigree.auth: {fmsg}") + gi = _check_gitignore(proj, fix=False) + # gi.status is advisory-"ok" even with a gap, so render on the message, not gi.ok. + if gi.status == "error" or "missing" in (gi.message or ""): + click.echo(f" gitignore: {gi.message}") + sw = _sweep_stray_artifacts(proj, fix=False) + if sw.removed or sw.review: + click.echo(f" stray artifacts: {sw.message}") if not ok: raise SystemExit(1) diff --git a/src/wardline/cli/mcp.py b/src/wardline/cli/mcp.py index f2e629df..3de0eda3 100644 --- a/src/wardline/cli/mcp.py +++ b/src/wardline/cli/mcp.py @@ -42,18 +42,20 @@ def mcp( no_network: bool, ) -> None: """Run the Wardline MCP server over stdio (JSON-RPC 2.0).""" - from wardline.core.config import resolve_filigree_url, resolve_loomweave_url + from wardline.core.config import resolve_filigree_url_with_source, resolve_loomweave_url_with_source # 3rd positional (config_path) is the reserved hook for the pending hub # sibling-endpoint key (weft-a2f4cf95c7); not read today. We pass None here whereas the # CLI scan path threads weft_config_path(root) — harmless until the hook lands, at which # point thread the real path here too for parity. See resolve_loomweave_url's docstring. - loomweave_url = resolve_loomweave_url(loomweave_url, root, None) - filigree_url = resolve_filigree_url(filigree_url, root, None) + resolved_loomweave = resolve_loomweave_url_with_source(loomweave_url, root, None) + resolved_filigree = resolve_filigree_url_with_source(filigree_url, root, None) WardlineMCPServer( root=root, - loomweave_url=loomweave_url, - filigree_url=filigree_url, + loomweave_url=resolved_loomweave.url if resolved_loomweave is not None else None, + loomweave_url_source=resolved_loomweave.source if resolved_loomweave is not None else None, + filigree_url=resolved_filigree.url if resolved_filigree is not None else None, + filigree_url_source=resolved_filigree.source if resolved_filigree is not None else None, allow_write=not read_only, allow_network=not no_network, ).rpc.run_stdio() diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index 413c57d3..87abba8d 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import urllib.parse from pathlib import Path from typing import IO, TYPE_CHECKING @@ -20,17 +19,17 @@ ) from wardline.core.emit import JsonlSink from wardline.core.errors import WardlineError +from wardline.core.federation_status import filigree_emit_status, loomweave_write_status from wardline.core.filigree_emit import ( EmitResult, FiligreeEmitter, - filigree_destination, - filigree_disabled_reason, filigree_url_project, + redact_url_for_diagnostics, ) from wardline.core.finding import Severity -from wardline.core.paths import weft_config_path +from wardline.core.paths import project_root_for, weft_config_path from wardline.core.run import baseline_migration_hint, gate_decision, run_scan -from wardline.core.safe_paths import write_text_no_follow +from wardline.core.safe_paths import explicit_output_target, write_explicit_output_text from wardline.core.sarif import SarifSink, build_sarif if TYPE_CHECKING: @@ -215,9 +214,9 @@ def scan( fingerprints are minted relative to it, and baseline/waiver/judged suppression state is read from PATH's .weft/wardline/. Scan the project root — a subdirectory scan mints qualnames other Weft tools - (Loomweave/Filigree/dossier) will not match, misses the project's - suppression state, and writes output into the subdirectory (wardline - warns when it detects this). + (Loomweave/Filigree/dossier) will not match and misses the project's + suppression state (wardline warns when it detects this). The default + findings artifact still lands in the project root's .wardline/. """ if lang == "rust": # Posture banner: RS-WL-* identity is graduated (frozen, baseline-eligible) but @@ -234,10 +233,11 @@ def scan( loomweave_result = None try: if config_path is None and not strict_defaults and not weft_config_path(path).is_file(): + proj = project_root_for(path) click.echo( "warning: no weft.toml found; using built-in source_roots=['.'], which can make " "project-root scans broad and slow. Run `wardline doctor --repair --root " - f"{path}` to create a bounded default policy, or `wardline scan-job start {path}` " + f"{proj}` to create a bounded default policy, or `wardline scan-job start {path}` " "for a pollable long-running scan.", err=True, ) @@ -357,13 +357,15 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: ) else: assert output is not None - SarifSink(output).write(findings, result.context, run_properties=scope_props) + output, output_root = explicit_output_target(path, output) + SarifSink(output, root=output_root).write(findings, result.context, run_properties=scope_props) elif fmt == "jsonl": if output_is_default: output = write_scan_artifact(path, fmt, cfg, "".join(f"{finding.to_jsonl()}\n" for finding in findings)) else: assert output is not None - JsonlSink(output).write(findings) + output, output_root = explicit_output_target(path, output) + JsonlSink(output, root=output_root).write(findings) elif fmt == "legis": # The signed, verbatim-postable scan for legis's POST /wardline/scan-results. # Signs when WARDLINE_LEGIS_ARTIFACT_KEY is provisioned (env/.env); else emits @@ -388,7 +390,7 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: output = write_scan_artifact(path, fmt, cfg, artifact_json) else: assert output is not None - write_text_no_follow(output, artifact_json, label=output.name) + write_explicit_output_text(path, output, artifact_json) # Loud signal: an artifact marked dirty is UNSIGNED (dev/tour only). legis # records it `unverified`; never gate CI on it. The dirty/signed status comes # from the shared authority; the human stderr wording stays CLI-specific. @@ -465,12 +467,13 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: # write_text would follow a repo-controlled symlink at the chosen filename # and truncate an arbitrary user-writable target in an untrusted checkout. assert output is not None - write_text_no_follow(output, agent_summary_json, label=output.name) + write_explicit_output_text(path, output, agent_summary_json) assert output is not None except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc if emit_result is not None: + logged_filigree_url = _redact_url_for_log(filigree_url) if not emit_result.reachable: if emit_result.auth_rejected: # Reachable but refused — actionable, NOT "could not reach" (dogfood #5). @@ -478,7 +481,7 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: # access / blocked → setting a token won't help) so the remedy fits. if emit_result.status == 403: click.echo( - f"warning: Filigree returned 403 (forbidden) at {filigree_url}; the token is " + f"warning: Filigree returned 403 (forbidden) at {logged_filigree_url}; the token is " "present but lacks access (scope/permission) or the request is blocked. " "Findings written locally only.", err=True, @@ -487,26 +490,26 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: # A token WAS sent and rejected — the value is wrong, not absent. Saying # "set the token" here is the C-7 misdiagnosis (weft-23574069a1). click.echo( - f"warning: Filigree rejected the token (401) at {filigree_url}; a token WAS sent but " + f"warning: Filigree rejected the token (401) at {logged_filigree_url}; a token WAS sent but " "its value is wrong — align WEFT_FEDERATION_TOKEN (env or .env) to the canonical " "federation token. Findings written locally only.", err=True, ) else: click.echo( - f"warning: Filigree returned 401 (auth rejected) at {filigree_url}; no token was sent — " + f"warning: Filigree returned 401 (auth rejected) at {logged_filigree_url}; no token was sent — " "set WEFT_FEDERATION_TOKEN (env or .env) to the project token. Findings written locally only.", err=True, ) elif emit_result.status is not None: click.echo( - f"warning: Filigree returned {emit_result.status} (server error) at {filigree_url}; " + f"warning: Filigree returned {emit_result.status} (server error) at {logged_filigree_url}; " "findings written locally only.", err=True, ) else: click.echo( - f"warning: could not reach Filigree at {filigree_url}; findings written locally only.", + f"warning: could not reach Filigree at {logged_filigree_url}; findings written locally only.", err=True, ) else: @@ -520,7 +523,7 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: else "unscoped endpoint (URL pins no project; add ?project= to make routing explicit)" ) line = ( - f"emitted {len(findings)} finding(s) to {filigree_url} [{where}] — " + f"emitted {len(findings)} finding(s) to {logged_filigree_url} [{where}] — " f"{emit_result.created} created / {emit_result.updated} updated" ) if emit_result.failed: @@ -658,71 +661,14 @@ def _build_sei_resolver(loomweave_url: str | None, root: Path) -> SeiResolver | def _filigree_status(result: EmitResult | None) -> dict[str, object]: - if result is None: - return { - "configured": False, - "reachable": None, - "created": 0, - "updated": 0, - "failed": 0, - "failures": [], - "warnings": [], - "disabled_reason": "not configured", - "destination": filigree_destination(None), - } - return { - "configured": True, - "reachable": result.reachable, - "created": result.created, - "updated": result.updated, - "failed": result.failed, - # PDR-0023: per-finding reject reasons so a partial ingest is not flattened to a count. - "failures": [f.to_wire() for f in result.failures], - "warnings": list(result.warnings), - "disabled_reason": filigree_disabled_reason( - reachable=result.reachable, - status=result.status, - token_sent=result.token_sent, - url=result.url, - ), - # N1 / C-10(a): name where findings went so a wrong-project write is visible. - "destination": filigree_destination(result.url), - } + # Canonical builder (core/federation_status). configured is derived from result-is-None + # because the CLI only ever has a result when an emitter was configured. + return filigree_emit_status(result, configured=result is not None, include_destination=True) def _loomweave_status(result: object | None) -> dict[str, object]: - if result is None: - return { - "configured": False, - "reachable": None, - "written": 0, - "unresolved_qualnames": [], - "disabled_reason": "not configured", - } - return { - "configured": True, - "reachable": getattr(result, "reachable", False), - "written": getattr(result, "written", 0), - "unresolved_qualnames": list(getattr(result, "unresolved_qualnames", ())), - "disabled_reason": getattr(result, "disabled_reason", None), - } + return loomweave_write_status(result) def _redact_url_for_log(url: str | None) -> str: - if url is None: - return "" - parts = urllib.parse.urlsplit(url) - if not parts.scheme or not parts.netloc: - return url.split("?", 1)[0].split("#", 1)[0] - try: - host = parts.hostname or "" - port = parts.port - except ValueError: - return f"{parts.scheme}://" - if ":" in host and not host.startswith("["): - host = f"[{host}]" - if port is not None: - host = f"{host}:{port}" - if parts.username is not None or parts.password is not None: - host = f"@{host}" - return urllib.parse.urlunsplit((parts.scheme, host, parts.path, "", "")) + return redact_url_for_diagnostics(url) or "" diff --git a/src/wardline/cli/scan_job.py b/src/wardline/cli/scan_job.py index f113b3e4..e339da44 100644 --- a/src/wardline/cli/scan_job.py +++ b/src/wardline/cli/scan_job.py @@ -8,6 +8,7 @@ import click from wardline.core.errors import WardlineError +from wardline.core.filigree_emit import redact_url_for_diagnostics from wardline.core.scan_jobs import ( DEFAULT_SCAN_JOB_TIMEOUT_SECONDS, cancel_scan_job, @@ -21,6 +22,18 @@ def scan_job() -> None: """Start and poll file-backed Wardline scan jobs.""" +def _redact_scan_job_status(status: dict[str, object]) -> dict[str, object]: + redacted = dict(status) + request = redacted.get("request") + if isinstance(request, dict): + safe_request = dict(request) + url = safe_request.get("filigree_url") + if isinstance(url, str): + safe_request["filigree_url"] = redact_url_for_diagnostics(url) + redacted["request"] = safe_request + return redacted + + @scan_job.command("start") @click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") @click.option("--config", "config_path", type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path)) @@ -90,7 +103,7 @@ def start( except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc - click.echo(json.dumps(status, sort_keys=True)) + click.echo(json.dumps(_redact_scan_job_status(status), sort_keys=True)) @scan_job.command("status") @@ -103,7 +116,7 @@ def status(job_id: str, path: Path) -> None: except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc - click.echo(json.dumps(payload, sort_keys=True)) + click.echo(json.dumps(_redact_scan_job_status(payload), sort_keys=True)) @scan_job.command("cancel") @@ -116,4 +129,4 @@ def cancel(job_id: str, path: Path) -> None: except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc - click.echo(json.dumps(payload, sort_keys=True)) + click.echo(json.dumps(_redact_scan_job_status(payload), sort_keys=True)) diff --git a/src/wardline/core/agent_summary.py b/src/wardline/core/agent_summary.py index 50d6e167..e70cc982 100644 --- a/src/wardline/core/agent_summary.py +++ b/src/wardline/core/agent_summary.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, field from typing import Any +from wardline.core.federation_status import default_filigree_emit_status, default_loomweave_write_status from wardline.core.finding import Finding, Kind, SuppressionState from wardline.core.run import GateDecision, ScanResult @@ -30,26 +31,14 @@ def _is_engine_fact(f: Finding) -> bool: def _default_filigree_status() -> dict[str, Any]: - return { - "configured": False, - "reachable": None, - "created": 0, - "updated": 0, - "failed": 0, - "failures": [], - "warnings": [], - "disabled_reason": "not configured", - } + # Canonical builder (core/federation_status). The agent-summary default has never + # carried ``destination`` on its bare default path — include_destination=False + # preserves that. + return default_filigree_emit_status(include_destination=False) def _default_loomweave_status() -> dict[str, Any]: - return { - "configured": False, - "reachable": None, - "written": 0, - "unresolved_qualnames": [], - "disabled_reason": "not configured", - } + return default_loomweave_write_status() @dataclass(frozen=True, slots=True) diff --git a/src/wardline/core/artifacts.py b/src/wardline/core/artifacts.py index b27f2412..a23c627c 100644 --- a/src/wardline/core/artifacts.py +++ b/src/wardline/core/artifacts.py @@ -11,6 +11,8 @@ from wardline.core.config import WardlineConfig from wardline.core.errors import WardlineError +from wardline.core.paths import artifacts_dir as _artifacts_dir_for +from wardline.core.paths import project_root_for from wardline.core.safe_paths import safe_project_path _FORMAT_SUFFIXES = { @@ -30,10 +32,11 @@ def artifact_suffix(fmt: str) -> str: def timestamped_scan_artifact(root: Path, fmt: str, config: WardlineConfig) -> Path: - root_resolved = root.resolve() - artifact_dir = _artifact_dir(root_resolved, config) + proj_root = project_root_for(root) + artifact_dir = _artifact_dir(root, config) + safe_project_path(proj_root, artifact_dir, label="wardline scan artifacts") suffix = artifact_suffix(fmt) - for candidate in _timestamped_candidates(root_resolved, artifact_dir, suffix): + for candidate in _timestamped_candidates(proj_root, artifact_dir, suffix): if not candidate.exists(): return candidate raise WardlineError(f"{suffix}: could not allocate a unique scan artifact name") @@ -41,15 +44,16 @@ def timestamped_scan_artifact(root: Path, fmt: str, config: WardlineConfig) -> P def write_scan_artifact(root: Path, fmt: str, config: WardlineConfig, content: str) -> Path: """Write a default scan artifact with exclusive create and retention.""" - root_resolved = root.resolve() - artifact_dir = _artifact_dir(root_resolved, config) + proj_root = project_root_for(root) + artifact_dir = _artifact_dir(root, config) + safe_project_path(proj_root, artifact_dir, label="wardline scan artifacts") suffix = artifact_suffix(fmt) - for candidate in _timestamped_candidates(root_resolved, artifact_dir, suffix): + for candidate in _timestamped_candidates(proj_root, artifact_dir, suffix): try: - _write_text_exclusive(root_resolved, candidate, content, label=candidate.name) + _write_text_exclusive(proj_root, candidate, content, label=candidate.name) except FileExistsError: continue - prune_scan_artifacts(root_resolved, candidate, fmt, config.artifacts.retain) + prune_scan_artifacts(proj_root, candidate, fmt, config.artifacts.retain) return candidate raise WardlineError(f"{suffix}: could not allocate a unique scan artifact name") @@ -78,8 +82,8 @@ def prune_scan_artifacts(root: Path, artifact: Path, fmt: str, retain: int) -> N raise WardlineError(f"{path.name}: failed to prune old scan artifact: {exc}") from exc -def _artifact_dir(root_resolved: Path, config: WardlineConfig) -> Path: - return safe_project_path(root_resolved, Path(config.artifacts.dir), label="wardline scan artifacts") +def _artifact_dir(root: Path, config: WardlineConfig) -> Path: + return _artifacts_dir_for(root, config.artifacts.dir) def _timestamped_candidates(root_resolved: Path, artifact_dir: Path, suffix: str) -> Iterator[Path]: diff --git a/src/wardline/core/config.py b/src/wardline/core/config.py index 7d149663..bab1cfbd 100644 --- a/src/wardline/core/config.py +++ b/src/wardline/core/config.py @@ -17,12 +17,12 @@ from wardline.core.errors import ConfigError from wardline.core.optional_deps import require_jsonschema from wardline.core.paths import ( + DEFAULT_ARTIFACT_DIR, legacy_sibling_dir, sibling_state_dir, ) from wardline.core.safe_paths import safe_read_text_if_regular -DEFAULT_ARTIFACT_DIR = ".wardline" DEFAULT_ARTIFACT_RETAIN = 20 @@ -42,6 +42,12 @@ class ArtifactSettings: retain: int = DEFAULT_ARTIFACT_RETAIN +@dataclass(frozen=True, slots=True) +class ResolvedUrl: + url: str + source: str + + @dataclass(frozen=True, slots=True) class WardlineConfig: source_roots: tuple[str, ...] = (".",) @@ -296,12 +302,15 @@ def load( _FILIGREE_URL_ENV = "WARDLINE_FILIGREE_URL" -def _read_published_port(root: Path, sibling: str) -> int | None: +def _read_published_port_with_source(root: Path, sibling: str) -> tuple[int, str] | None: """Read a sibling's live ``ephemeral.port``, preferring the consolidated ``.weft//`` location and tolerating the legacy ``./`` dot-dir - during the federation transition window. Returns a valid port or ``None`` - (missing / unreadable / malformed / out-of-range) — fail-soft.""" - for base in (sibling_state_dir(root, sibling), legacy_sibling_dir(root, sibling)): + during the federation transition window. Returns ``(port, provenance)`` or + ``None`` (missing / unreadable / malformed / out-of-range) — fail-soft.""" + for base, source in ( + (sibling_state_dir(root, sibling), f"published .weft/{sibling}/ephemeral.port"), + (legacy_sibling_dir(root, sibling), f"published .{sibling}/ephemeral.port"), + ): text = safe_read_text_if_regular(root, base / "ephemeral.port", label="ephemeral.port", encoding="ascii") if text is None: continue @@ -316,25 +325,39 @@ def _read_published_port(root: Path, sibling: str) -> int | None: except ValueError: continue if 1 <= port <= 65535: - return port + return port, source return None -def _loomweave_published_url(root: Path) -> str | None: +def _read_published_port(root: Path, sibling: str) -> int | None: + found = _read_published_port_with_source(root, sibling) + return found[0] if found is not None else None + + +def _loomweave_published_url_with_source(root: Path) -> ResolvedUrl | None: """Loomweave's live read-API origin from its published ``ephemeral.port``. Consumer half of Loomweave **ADR-044** (Read-API Ephemeral Port Publication). Loomweave writes its live bound port on a successful loopback bind (atomically; removed on clean shutdown; present only while serving). We *read* it — never derive or guess a port. Prefers ``.weft/loomweave/ephemeral.port`` and falls - back to the legacy ``.loomweave/ephemeral.port``. Returns - ``http://127.0.0.1:`` or ``None``; fail-soft falls through to config. + back to the legacy ``.loomweave/ephemeral.port``. Returns a ``ResolvedUrl`` + carrying ``http://127.0.0.1:`` and the matched port-file source, or + ``None``; fail-soft falls through to config. The host is loopback by construction: ADR-034's ``allow_non_loopback`` bind publishes *no* file, so a port-only value can never under-specify the host. """ - port = _read_published_port(root, "loomweave") - return f"http://127.0.0.1:{port}" if port is not None else None + found = _read_published_port_with_source(root, "loomweave") + if found is None: + return None + port, source = found + return ResolvedUrl(f"http://127.0.0.1:{port}", source) + + +def _loomweave_published_url(root: Path) -> str | None: + resolved = _loomweave_published_url_with_source(root) + return resolved.url if resolved is not None else None def _filigree_server_config_path() -> Path: @@ -402,7 +425,7 @@ def _filigree_server_scope(root: Path) -> tuple[int, str] | None: return None -def filigree_server_scoped_url(root: Path) -> str | None: +def _filigree_server_scoped_url_with_source(root: Path) -> ResolvedUrl | None: """The project-scoped Weft scan-results URL when Filigree runs in *server* mode for *root*, else ``None``. @@ -416,10 +439,15 @@ def filigree_server_scoped_url(root: Path) -> str | None: if scope is None: return None port, prefix = scope - return f"http://localhost:{port}/api/p/{prefix}/weft/scan-results" + return ResolvedUrl(f"http://localhost:{port}/api/p/{prefix}/weft/scan-results", "Filigree server registry") -def _filigree_published_url(root: Path) -> str | None: +def filigree_server_scoped_url(root: Path) -> str | None: + resolved = _filigree_server_scoped_url_with_source(root) + return resolved.url if resolved is not None else None + + +def _filigree_published_url_with_source(root: Path) -> ResolvedUrl | None: """Filigree's live Weft scan-results URL, project-scoped when it runs in server mode. Server mode first: one shared daemon serves many projects on a single port and @@ -434,27 +462,35 @@ def _filigree_published_url(root: Path) -> str | None: legacy ``.filigree/ephemeral.port``. Fail-soft on any defect. Unlike Loomweave's bare-origin contract, Filigree's URL carries the full Weft - route, so this returns the route-suffixed + route, so this returns a ``ResolvedUrl`` carrying the route-suffixed ``http://localhost:/api/[p//]weft/scan-results`` (loopback by construction). The ``localhost`` host self-heals transparently over an install-stamped literal — Filigree's loopback spelling, distinct from Loomweave's ``127.0.0.1``. """ - scoped = filigree_server_scoped_url(root) + scoped = _filigree_server_scoped_url_with_source(root) if scoped is not None: return scoped - port = _read_published_port(root, "filigree") - return f"http://localhost:{port}/api/weft/scan-results" if port is not None else None + found = _read_published_port_with_source(root, "filigree") + if found is None: + return None + port, source = found + return ResolvedUrl(f"http://localhost:{port}/api/weft/scan-results", source) -def resolve_loomweave_url( +def _filigree_published_url(root: Path) -> str | None: + resolved = _filigree_published_url_with_source(root) + return resolved.url if resolved is not None else None + + +def resolve_loomweave_url_with_source( flag: str | None, root: Path, config_path: Path | None = None, *, strict_defaults: bool = False, -) -> str | None: - """Loomweave URL by precedence: explicit flag > env var > published port. +) -> ResolvedUrl | None: + """Loomweave URL + provenance by precedence: explicit flag > env var > published port. Sibling-endpoint *config keys* are NOT read here: a persisted operator-declared endpoint is an instance of the still-pending Weft shared-endpoint fact @@ -469,23 +505,34 @@ def resolve_loomweave_url( sibling-endpoint key once its layout is pinned; it is not read today. """ if flag is not None: - return flag + return ResolvedUrl(flag, "--loomweave-url launch flag") env = os.environ.get(_LOOMWEAVE_URL_ENV) if env: - return env + return ResolvedUrl(env, f"env {_LOOMWEAVE_URL_ENV}") if not strict_defaults: - return _loomweave_published_url(root) + return _loomweave_published_url_with_source(root) return None -def resolve_filigree_url( +def resolve_loomweave_url( flag: str | None, root: Path, config_path: Path | None = None, *, strict_defaults: bool = False, ) -> str | None: - """Filigree Weft URL by precedence: explicit flag > env var > published port. + resolved = resolve_loomweave_url_with_source(flag, root, config_path, strict_defaults=strict_defaults) + return resolved.url if resolved is not None else None + + +def resolve_filigree_url_with_source( + flag: str | None, + root: Path, + config_path: Path | None = None, + *, + strict_defaults: bool = False, +) -> ResolvedUrl | None: + """Filigree Weft URL + provenance by precedence: explicit flag > env var > published port. Twin of :func:`resolve_loomweave_url`: no ``[wardline.filigree].url`` config key is read (pending the hub shared-endpoint schema ``weft-a2f4cf95c7``). The @@ -497,15 +544,26 @@ def resolve_filigree_url( :func:`resolve_loomweave_url`); it is accepted but not read today. """ if flag is not None: - return flag + return ResolvedUrl(flag, "--filigree-url launch flag") env = os.environ.get(_FILIGREE_URL_ENV) if env: - return env + return ResolvedUrl(env, f"env {_FILIGREE_URL_ENV}") if not strict_defaults: - return _filigree_published_url(root) + return _filigree_published_url_with_source(root) return None +def resolve_filigree_url( + flag: str | None, + root: Path, + config_path: Path | None = None, + *, + strict_defaults: bool = False, +) -> str | None: + resolved = resolve_filigree_url_with_source(flag, root, config_path, strict_defaults=strict_defaults) + return resolved.url if resolved is not None else None + + @dataclass(frozen=True, slots=True) class JudgeSettings: model: str = "anthropic/claude-opus-4-8" diff --git a/src/wardline/core/delta.py b/src/wardline/core/delta.py index c4d63f0e..764c3c47 100644 --- a/src/wardline/core/delta.py +++ b/src/wardline/core/delta.py @@ -11,6 +11,8 @@ if TYPE_CHECKING: from wardline.scanner.index import Entity +_SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") + def get_changed_files_since(ref: str, root: Path) -> set[str]: """Get the set of file paths (repo-relative, POSIX-style matching Location.path) @@ -22,7 +24,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 1. Get the git toplevel directory. try: res = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "--show-toplevel"], cwd=root, capture_output=True, text=True, @@ -38,7 +40,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 2. Resolve ref to a verified object id before passing it to git diff. try: res = subprocess.run( - ["git", "rev-parse", "--verify", "--end-of-options", ref], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "--verify", "--end-of-options", ref], cwd=git_toplevel, capture_output=True, text=True, @@ -54,7 +56,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 3. Get changed files since ref (committed since ref, staged, unstaged). try: res = subprocess.run( - ["git", "diff", "--name-only", verified_ref, "--"], + ["git", *_SAFE_GIT_CONFIG, "diff", "--name-only", verified_ref, "--"], cwd=git_toplevel, capture_output=True, text=True, @@ -68,7 +70,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 4. Get untracked files. try: res = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], + ["git", *_SAFE_GIT_CONFIG, "ls-files", "--others", "--exclude-standard"], cwd=git_toplevel, capture_output=True, text=True, diff --git a/src/wardline/core/delta_resolve.py b/src/wardline/core/delta_resolve.py index 35eccb24..6e89d829 100644 --- a/src/wardline/core/delta_resolve.py +++ b/src/wardline/core/delta_resolve.py @@ -396,11 +396,18 @@ def _closure_seed_qualnames(base_qualnames: frozenset[str] | set[str], index: Qu Exact function/method locators seed that entity. Class-level locators seed every indexed method below the class prefix so callers of any class member are included.""" seeds: set[str] = set() + class_member_prefixes: dict[str, set[str]] | None = None for qualname in base_qualnames: if qualname in index.entities: seeds.add(qualname) - prefix = f"{qualname}." - seeds.update(candidate for candidate in index.entities if candidate.startswith(prefix)) + continue + if class_member_prefixes is None: + class_member_prefixes = {} + for candidate in index.entities: + parts = candidate.split(".") + for i in range(2, len(parts)): + class_member_prefixes.setdefault(".".join(parts[:i]), set()).add(candidate) + seeds.update(class_member_prefixes.get(qualname, ())) return seeds diff --git a/src/wardline/core/discovery.py b/src/wardline/core/discovery.py index 3a91815f..82ec1072 100644 --- a/src/wardline/core/discovery.py +++ b/src/wardline/core/discovery.py @@ -29,6 +29,10 @@ } ) +# Public alias for reuse by the doctor stray-artifact sweep (single source of the +# hard directory skip-set). Keep in sync with _ALWAYS_SKIP. +WALK_SKIP_DIRS = _ALWAYS_SKIP + # Python packaging output names are pruned only when they are direct children of the # scan root. Under a configured source root, `build` and `dist` can be legitimate # package names and must not silently disappear from the scan. @@ -47,8 +51,22 @@ def _is_root_build_artifact(child: Path, root: Path) -> bool: ) -def _should_prune_dir(child: Path, root: Path, skip_dirs: frozenset[str]) -> bool: - return _is_floored_dir(child.name, skip_dirs) or _is_root_build_artifact(child, root) +def _is_root_rust_build_artifact(child: Path, root: Path) -> bool: + return child.parent == root and child.name == "target" + + +def _should_prune_dir( + child: Path, + root: Path, + skip_dirs: frozenset[str], + *, + prune_rust_target: bool, +) -> bool: + return ( + _is_floored_dir(child.name, skip_dirs) + or _is_root_build_artifact(child, root) + or (prune_rust_target and _is_root_rust_build_artifact(child, root)) + ) def discover( @@ -57,6 +75,7 @@ def discover( *, confine_to_root: bool = False, suffixes: frozenset[str] = frozenset({".py"}), + respect_gitignore: bool = False, ) -> list[Path]: """Discover source files under the configured roots. @@ -65,19 +84,25 @@ def discover( all requested suffixes are gathered and yielded in one combined sorted order, so finding/entity order stays deterministic and the single-suffix Python case is unchanged. + + Repository ``.gitignore`` files are not honored by default. They are checkout + content, not operator scan policy, and Git still allows tracked files below + ignored paths. Trusted callers that need Git-like pruning may opt in with + ``respect_gitignore=True``. """ root = root.resolve() - # `target` is cargo build output — skip it only in `.rs` mode. It is a legitimate - # Python package name, so adding it to the global skip set would silently under-scan - # Python projects (the very failure wardline surfaces loudly elsewhere). - skip_dirs = _ALWAYS_SKIP | {"target"} if ".rs" in suffixes else _ALWAYS_SKIP - # Read the project-root .gitignore ONCE so a multi-GB gitignored tree (third-party - # deps, build output) is never descended. Per-directory .gitignore files are layered - # in as the top-down walk reaches them, mirroring git's nested-ignore semantics. The - # base is an empty matcher (not None) so a project with ONLY nested .gitignore files - # still gets directory pruning. - root_gitignore = root / ".gitignore" - root_ignore = GitignoreMatcher.from_file(root_gitignore) if root_gitignore.is_file() else GitignoreMatcher.empty() + # `target` is cargo build output only at the project root. Nested directories with + # that name can be legitimate source modules and must not be treated as floor dirs. + prune_rust_target = ".rs" in suffixes + skip_dirs = _ALWAYS_SKIP + root_ignore: GitignoreMatcher | None = None + if respect_gitignore: + # Trusted opt-in only: .gitignore is repository-controlled and can hide tracked + # source files, so the normal scan path must not treat it as a discovery boundary. + root_gitignore = root / ".gitignore" + root_ignore = ( + GitignoreMatcher.from_file(root_gitignore) if root_gitignore.is_file() else GitignoreMatcher.empty() + ) found: list[Path] = [] for src in config.source_roots: base = (root / src).resolve() @@ -91,7 +116,7 @@ def discover( if not base.exists(): warnings.warn(f"source root does not exist: {base}", stacklevel=2) continue - ignore_under_root = root_ignore if base.is_relative_to(root) else None + ignore_under_root = root_ignore if root_ignore is not None and base.is_relative_to(root) else None # Per-directory ignore layers, keyed by the dir's POSIX path relative to root. dir_ignores: dict[str, GitignoreMatcher] = {} candidates: list[Path] = [] @@ -101,7 +126,7 @@ def discover( kept: list[str] = [] for dirname in sorted(dirnames): child = current / dirname - if _should_prune_dir(child, root, skip_dirs): + if _should_prune_dir(child, root, skip_dirs, prune_rust_target=prune_rust_target): continue if ignore is not None and _gitignored_dir(child, root, ignore): continue diff --git a/src/wardline/core/dossier.py b/src/wardline/core/dossier.py index fe8cf598..806fc05a 100644 --- a/src/wardline/core/dossier.py +++ b/src/wardline/core/dossier.py @@ -38,7 +38,7 @@ from typing import TYPE_CHECKING, Any, Protocol from wardline.core.errors import DossierError -from wardline.core.finding import UNANALYZED_RULE_IDS, Kind, SuppressionState +from wardline.core.finding import INCOMPLETE_ANALYSIS_RULE_IDS, Kind, SuppressionState from wardline.core.identity import ContentStatus, EntityBinding, IdentityStatus from wardline.core.paths import enclosing_project_root from wardline.core.run import run_scan @@ -52,10 +52,9 @@ # Per-entity engine under-scan FACTs (carry a qualname) — their presence means the # entity's body was NOT fully analysed, so its trust verdict is "unknown", never -# "clean". UNANALYZED_RULE_IDS covers file/source-root under-scans; FUNCTION-SKIPPED -# is the per-function recursion-limit skip (NOT in UNANALYZED_RULE_IDS by design, -# since the function was reached — but its taint was never computed). -UNDER_SCAN_RULE_IDS: frozenset[str] = UNANALYZED_RULE_IDS | {"WLN-ENGINE-FUNCTION-SKIPPED"} +# "clean". The shared incomplete-analysis set covers file/source-root under-scans plus +# per-function skips (not counted in ScanSummary.unanalyzed by design). +UNDER_SCAN_RULE_IDS: frozenset[str] = INCOMPLETE_ANALYSIS_RULE_IDS if TYPE_CHECKING: from wardline.core.run import ScanResult diff --git a/src/wardline/core/federation_status.py b/src/wardline/core/federation_status.py new file mode 100644 index 00000000..da4861f1 --- /dev/null +++ b/src/wardline/core/federation_status.py @@ -0,0 +1,344 @@ +"""Canonical federation-status envelope: ONE builder + ONE schema source. + +Every Wardline surface that reports the outcome of the optional sibling-tool +writes — the Filigree finding emit and the Loomweave taint-fact write — emits the +same ``{"filigree_emit": , "loomweave_write": }`` shapes. Before +this module those status dicts (and their JSON-schema ``$defs``) were hand-copied +across cli/scan, core/scan_jobs, core/scan_file_workflow, core/agent_summary, and +mcp/server — six near-identical inline copies that could (and did) drift. + +This module is the single source of truth. The builders below reproduce each +surface's CURRENT bytes exactly — same keys, same key order, same null/absent +semantics — so the consolidation is behavior-preserving. The surfaces legitimately +DIFFER in context (the MCP scan response carries the discriminated transport detail +``status/auth_rejected/token_sent/url`` and orders ``disabled_reason`` second; the +CLI/scan-job blocks omit those keys; the scan_file_findings block omits +``destination`` entirely and has no loomweave_write). Those differences are +preserved via explicit flags, not collapsed — collapsing would change emitted bytes. + +The JSON ``$defs`` for the MCP output schema live here too, so the schema and the +runtime builder can never drift: the schema is what the builder's bytes validate +against, and tests/conformance/test_federation_status_envelope_parity.py pins it. +""" + +from __future__ import annotations + +from typing import Any + +from wardline.core.filigree_emit import EmitResult, filigree_destination, filigree_disabled_reason + +# --------------------------------------------------------------------------- +# Filigree emit status builders +# --------------------------------------------------------------------------- + + +def filigree_emit_status( + result: EmitResult | None, + *, + configured: bool, + include_destination: bool = True, +) -> dict[str, Any]: + """The result/None-based ``filigree_emit`` status block. + + Covers the CLI (``cli/scan``), the scan-job artifact (``core/scan_jobs``), and + the one-shot scan_file_findings workflow (``core/scan_file_workflow``). + + ``configured`` is passed explicitly so the configured-true-but-no-result case + (a dry-run where an emitter IS configured but nothing was emitted) renders + ``disabled_reason: null`` instead of ``"not configured"`` — the scan_file + workflow's existing None-semantics, which a result-is-None test cannot express. + + ``include_destination`` toggles the trailing ``destination`` echo: True for the + CLI/scan-job blocks; False for scan_file_findings, whose schema has never carried + it. The block never carries the discriminated transport detail + (``status/auth_rejected/token_sent/url``) — that lives only on the MCP block; + see :func:`filigree_emit_status_from_block`. + """ + if result is None: + block: dict[str, Any] = { + "configured": configured, + "reachable": None, + "created": 0, + "updated": 0, + "failed": 0, + "failures": [], + "warnings": [], + "disabled_reason": None if configured else "not configured", + } + if include_destination: + block["destination"] = filigree_destination(None) + return block + block = { + "configured": configured, + "reachable": result.reachable, + "created": result.created, + "updated": result.updated, + "failed": result.failed, + # PDR-0023: per-finding reject reasons so a partial ingest is distinguishable from clean. + "failures": [f.to_wire() for f in result.failures], + "warnings": list(result.warnings), + # The shared 401/403-vs-5xx-vs-transport ladder (dogfood #5) instead of flattening + # every soft failure to "filigree unreachable". + "disabled_reason": filigree_disabled_reason( + reachable=result.reachable, + status=result.status, + token_sent=result.token_sent, + url=result.url, + ), + } + if include_destination: + # N1 / C-10(a): name where findings went so a wrong-project write is visible. + block["destination"] = filigree_destination(result.url) + return block + + +def filigree_emit_status_from_block(block: dict[str, Any] | None) -> dict[str, Any]: + """The MCP ``filigree_emit`` status block (built from a pre-computed raw block). + + The MCP scan response carries the WIDER shape: the discriminated transport detail + (``status/auth_rejected/token_sent/url``) AND a ``disabled_reason`` at position + two (right after ``configured``). The raw ``block`` is the dict produced by the + MCP ``_emit_filigree`` helper (an emit attempt) or None (no emitter injected). + The configured-false (block is None) bytes match the CLI/scan-job not-configured + block exactly; the configured-true bytes keep MCP's distinct key set and order. + """ + if block is None: + return filigree_emit_status(None, configured=False, include_destination=True) + disabled_reason = filigree_disabled_reason( + reachable=bool(block.get("reachable")), + status=block.get("status"), + token_sent=bool(block.get("token_sent")), + url=block.get("url"), + ) + return {"configured": True, "disabled_reason": disabled_reason, **block} + + +def default_filigree_emit_status(*, include_destination: bool = False) -> dict[str, Any]: + """The not-configured ``filigree_emit`` default (no emit attempted). + + ``include_destination`` defaults to False to preserve the agent_summary default, + which has never carried ``destination`` on its bare default path. Surfaces that + DO carry it (CLI/scan-job/MCP not-configured blocks) pass include_destination=True, + or equivalently call ``filigree_emit_status(None, configured=False)``. + """ + return filigree_emit_status(None, configured=False, include_destination=include_destination) + + +# --------------------------------------------------------------------------- +# Loomweave write status builders +# --------------------------------------------------------------------------- + + +def loomweave_write_status(result: Any | None) -> dict[str, Any]: + """The result/None-based ``loomweave_write`` status block (CLI surface). + + ``result`` is a ``loomweave.client.WriteResult`` (duck-typed via getattr so the + optional ``[clarion]`` extra need not be importable here) or None when no + Loomweave client is configured. + """ + if result is None: + return default_loomweave_write_status() + return { + "configured": True, + "reachable": getattr(result, "reachable", False), + "written": getattr(result, "written", 0), + "unresolved_qualnames": list(getattr(result, "unresolved_qualnames", ())), + "disabled_reason": getattr(result, "disabled_reason", None), + } + + +def loomweave_write_status_from_block(block: dict[str, Any] | None) -> dict[str, Any]: + """The MCP ``loomweave_write`` status block (built from a pre-computed raw block). + + ``block`` is the dict the MCP scan path builds from a ``WriteResult`` (the + reachable/written/unresolved_qualnames/disabled_reason fields) or None when no + Loomweave client is injected. Byte-identical to :func:`loomweave_write_status` + for the configured cases; both share the not-configured default. + """ + if block is None: + return default_loomweave_write_status() + return {"configured": True, **block} + + +def default_loomweave_write_status() -> dict[str, Any]: + """The not-configured ``loomweave_write`` default (no write attempted).""" + return { + "configured": False, + "reachable": None, + "written": 0, + "unresolved_qualnames": [], + "disabled_reason": "not configured", + } + + +# --------------------------------------------------------------------------- +# JSON-schema $defs — ONE schema source for the MCP output schema +# --------------------------------------------------------------------------- + + +def filigree_emit_status_schema(*, include_transport_detail: bool = True) -> dict[str, Any]: + """The JSON-schema for a ``filigree_emit`` status block. + + ``include_transport_detail=True`` is the canonical ``$defs/filigree_emit_status`` + used by the MCP ``scan`` output schema (carries destination + the discriminated + transport detail). ``include_transport_detail=False`` is the narrower variant the + scan_file_findings output schema declares inline (no destination, no transport + detail) — matching what :func:`filigree_emit_status` emits with + ``include_destination=False``. + """ + properties: dict[str, Any] = { + "configured": {"type": "boolean"}, + "reachable": {"type": ["boolean", "null"], "description": "null when not configured."}, + "created": {"type": "integer"}, + "updated": {"type": "integer"}, + "failed": { + "type": "integer", + "description": "Count of un-ingested findings (derived from `failures`); 0 is earned, not assumed.", + }, + "failures": {"$ref": "#/$defs/filigree_emit_failures"}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "disabled_reason": { + "type": ["string", "null"], + "description": "Actionable reason (auth-rejected vs server error vs unreachable vs not " + "configured), or null when reached.", + }, + } + required = [ + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason", + ] + if include_transport_detail: + properties["destination"] = {"$ref": "#/$defs/filigree_destination"} + properties["status"] = { + "type": ["integer", "null"], + "description": "HTTP error status for soft failures; absent when not configured.", + } + properties["auth_rejected"] = {"type": "boolean", "description": "Absent when not configured."} + properties["token_sent"] = {"type": "boolean", "description": "Absent when not configured."} + properties["url"] = {"type": ["string", "null"], "description": "Absent when not configured."} + required.append("destination") + return { + "type": "object", + "description": "Normalized Filigree emit status (always an object; configured:false when no emitter).", + "properties": properties, + "required": required, + "additionalProperties": False, + } + + +# The scan_file_findings output schema is self-contained (it declares no ``$defs``), +# so its ``filigree_emit`` cannot use the ``$ref``-based canonical schema above. This +# verbatim constant — moved here from mcp/server.py — is the ONE source for that +# surface's block schema: the no-destination, no-transport-detail variant with the +# tool's own richer descriptions and an INLINE failures array. Structurally it is the +# include_transport_detail=False shape; the runtime block is built by +# ``filigree_emit_status(result, configured=..., include_destination=False)``. +SCAN_FILE_FINDINGS_FILIGREE_EMIT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Outcome of bulk-emitting scan findings to Filigree (runs only when findings were selected " + "and an emitter is configured).", + "properties": { + "configured": { + "type": "boolean", + "description": "Whether a Filigree emitter is configured for this server.", + }, + "reachable": { + "type": ["boolean", "null"], + "description": "Whether Filigree was reachable for the emit; null when no emit was attempted.", + }, + "created": {"type": "integer", "description": "Findings newly created in Filigree."}, + "updated": {"type": "integer", "description": "Findings updated in Filigree."}, + "failed": { + "type": "integer", + "description": "Count of findings that did NOT land in Filigree (derived from `failures`). " + "0 here is earned from real per-finding records, not assumed — see `failures` for which and why.", + }, + "failures": { + "type": "array", + "description": "PDR-0023 honesty surface: one record per finding that failed to land, so a " + "PARTIAL ingest ('M of N emitted, K rejected because R') is distinguishable from a clean emit " + "('all N emitted'). Empty on a clean run — but earned, not hardwired.", + "items": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": ["rejected", "validation_error", "scheme_mismatch", "partial"], + "description": "Machine-readable failure case: rejected (Filigree refused this " + "finding), validation_error (malformed body), scheme_mismatch (fingerprint-scheme " + "drift — a join-miss, not a true-negative), partial (the whole chunk was rejected at " + "the protocol layer, so the cause is the request not the body).", + }, + "detail": {"type": "string", "description": "Filigree's per-finding reject explanation."}, + "reason_class": { + "type": "string", + "enum": ["rejected", "scheme_mismatch", "partial"], + "description": "weft-reason (G1): the canonical reason_class this failure maps to " + "(one of the closed 11 in contracts/weft-reason-vocab.json). validation_error maps to " + "rejected; the domain term stays in `reason`/`cause`.", + }, + "cause": { + "type": "string", + "description": "weft-reason carrier `cause`: the why (Filigree's detail, else the " + "domain reason). Always present on a failure (a failure is never clean).", + }, + "fix": { + "type": "string", + "description": "weft-reason carrier `fix` (MANDATORY on a non-clean carrier): the " + "remedial action.", + }, + "fingerprint": { + "type": "string", + "description": "The wardline join key for the failed finding (absent when the " + "failure is chunk-wide and not attributable to one finding).", + }, + }, + "required": ["reason", "detail", "reason_class", "cause", "fix"], + "additionalProperties": False, + }, + }, + "warnings": {"type": "array", "items": {"type": "string"}, "description": "Non-fatal emit warnings."}, + "disabled_reason": { + "type": ["string", "null"], + "description": "Why the emit failed soft — the discriminated 401/403-vs-5xx-vs-transport " + "ladder ('not configured', 'filigree rejected the token (401)...', 'filigree unreachable'). " + "null means success OR no emit was attempted (dry-run / nothing selected) — read `reachable` " + "to tell them apart (null = no attempt).", + }, + }, + "required": [ + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason", + ], + "additionalProperties": False, +} + + +def loomweave_write_status_schema() -> dict[str, Any]: + """The JSON-schema for a ``loomweave_write`` status block (``$defs/loomweave_write_status``).""" + return { + "type": "object", + "description": "Normalized Loomweave taint-fact write status (always an object; configured:false when no " + "client).", + "properties": { + "configured": {"type": "boolean"}, + "reachable": {"type": ["boolean", "null"], "description": "null when not configured."}, + "written": {"type": "integer"}, + "unresolved_qualnames": {"type": "array", "items": {"type": "string"}}, + "disabled_reason": {"type": ["string", "null"]}, + }, + "required": ["configured", "reachable", "written", "unresolved_qualnames", "disabled_reason"], + "additionalProperties": False, + } diff --git a/src/wardline/core/filigree_emit.py b/src/wardline/core/filigree_emit.py index 9fd911ec..72cf2ed9 100644 --- a/src/wardline/core/filigree_emit.py +++ b/src/wardline/core/filigree_emit.py @@ -12,11 +12,11 @@ from __future__ import annotations +import http.client import json import os import urllib.error import urllib.parse -import urllib.request from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, Protocol @@ -24,13 +24,13 @@ from wardline.core.errors import FiligreeEmitError from wardline.core.finding import ( FINGERPRINT_SCHEME, - UNANALYZED_RULE_IDS, + INCOMPLETE_ANALYSIS_RULE_IDS, Finding, format_fingerprint, severity_to_filigree, to_filigree_metadata, ) -from wardline.core.http import read_response_text +from wardline.core.http import WeftHttp _SUGGESTION_LIMIT = 10000 _ALLOWED_SCHEMES = ("http", "https") @@ -91,18 +91,18 @@ def build_scan_results_body( ``unseen_in_latest``. Clean files are represented by ``scanned_paths`` so close-on-fixed can reconcile a file whose last finding disappeared. - If any file was discovered but not analyzed, do not run the absent-fingerprint - sweep: a parse/file failure means missing findings are not proof of a fix. + If any file or function was not soundly analyzed, do not run the absent-fingerprint + sweep: missing findings are not proof of a fix. """ findings_wire = [_finding_to_wire(f, language=language) for f in findings] scanned = list(dict.fromkeys(p for p in scanned_paths if p)) - has_unanalyzed = any(f.rule_id in UNANALYZED_RULE_IDS for f in findings) + has_incomplete_analysis = any(f.rule_id in INCOMPLETE_ANALYSIS_RULE_IDS for f in findings) if mark_unseen is None: - mark_unseen = bool(findings_wire or scanned) and not has_unanalyzed + mark_unseen = bool(findings_wire or scanned) and not has_incomplete_analysis body = { "scan_source": scan_source, "fingerprint_scheme": FINGERPRINT_SCHEME, - "mark_unseen": bool(mark_unseen) and not has_unanalyzed, + "mark_unseen": bool(mark_unseen) and not has_incomplete_analysis, "findings": findings_wire, } if scanned: @@ -132,12 +132,46 @@ def filigree_url_project(url: str | None) -> str | None: return None +def redact_url_for_diagnostics(url: str | None) -> str | None: + """Return a URL safe for user-visible diagnostics. + + Filigree URLs may come from operator configuration and can carry credentials in + userinfo, query tokens, or fragments. Keep the origin/path for destination + debugging, but never echo credential-bearing components. + """ + if url is None: + return None + stripped = url.split("?", 1)[0].split("#", 1)[0] + parts = urllib.parse.urlsplit(url) + if parts.netloc: + scheme = parts.scheme + try: + host = parts.hostname or "" + port = parts.port + except ValueError: + return f"{scheme + ':' if scheme else ''}//" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + if port is not None: + host = f"{host}:{port}" + if parts.username is not None or parts.password is not None: + host = f"@{host}" + return urllib.parse.urlunsplit((scheme, host, parts.path, "", "")) + if "@" in stripped: + _, host_path = stripped.rsplit("@", 1) + if host_path: + return f"@{host_path}" + if not parts.scheme: + return stripped + return urllib.parse.urlunsplit((parts.scheme, "", parts.path, "", "")) + + def filigree_destination(url: str | None) -> dict[str, Any]: """The destination echo for the emit status block (N1 / C-10(a)): name where findings were sent so a wrong-project write is visible at the caller instead of reading as success. ``project_pinned`` is False when Filigree will resolve the project itself.""" project = filigree_url_project(url) - return {"url": url, "project": project, "project_pinned": project is not None} + return {"url": redact_url_for_diagnostics(url), "project": project, "project_pinned": project is not None} def filigree_api_base_url(url: str) -> str: @@ -153,7 +187,10 @@ def filigree_api_base_url(url: str) -> str: work-join can never disagree about what one configured URL means (dogfood-4 A3/A4).""" parts = urllib.parse.urlsplit(url) if parts.scheme.lower() not in _ALLOWED_SCHEMES: - raise FiligreeEmitError(f"filigree URL must use http or https; got scheme {parts.scheme!r} in {url!r}") + diagnostic_url = redact_url_for_diagnostics(url) + raise FiligreeEmitError( + f"filigree URL must use http or https; got scheme {parts.scheme!r} in {diagnostic_url!r}" + ) segments = [s for s in parts.path.split("/") if s] base = segments[: segments.index("api") + 1] if "api" in segments else [*segments, "api"] project = filigree_url_project(url) @@ -375,7 +412,7 @@ def _scan_result_chunks( raise ValueError("max_findings_per_request must be at least 1") deduped_scanned_paths = tuple(dict.fromkeys(p for p in scanned_paths if p)) - can_mark_unseen = not force_no_mark_unseen and not any(f.rule_id in UNANALYZED_RULE_IDS for f in findings) + can_mark_unseen = not force_no_mark_unseen and not any(f.rule_id in INCOMPLETE_ANALYSIS_RULE_IDS for f in findings) if len(findings) <= max_findings_per_request: return ( _ScanResultChunk( @@ -502,6 +539,10 @@ def _parse_success_response(resp: Response) -> EmitResult: ) +def _chunk_rejection_detail(layer: str, status: int) -> str: + return f"chunk rejected at {layer} layer ({status})" + + def _record_pending_partial_failures( failures: list[FailedFinding], chunks: Sequence[_ScanResultChunk], @@ -538,7 +579,8 @@ def filigree_disabled_reason( """ if reachable: return None - at = f" at {url}" if url else "" + diagnostic_url = redact_url_for_diagnostics(url) + at = f" at {diagnostic_url}" if diagnostic_url else "" if status in (401, 403): # 403 → token present but lacks access (a token won't help). 401 → split by whether a # token was actually SENT: absent (set one) vs rejected (the value is wrong). The old @@ -563,36 +605,44 @@ def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: . class UrllibTransport: def __init__(self, timeout: float = 30.0) -> None: - self._timeout = timeout + # Two http(s)-gated WeftHttp transports, one per route, so each keeps its OWN + # scheme-error wording (--filigree-url on the POST emit path, "filigree URL" on the + # GET schema-probe path) while sharing the round-trip + bounded-read + HTTPError-to- + # Response discipline. URLError/OSError still propagate to emit()/the probe, which + # apply the federation fail-soft (4xx loud / 5xx + outage soft) — unchanged. + self._post_http = WeftHttp( + timeout=timeout, + allowed_schemes=_ALLOWED_SCHEMES, + scheme_error=lambda scheme, url: FiligreeEmitError( + f"--filigree-url must use http or https; got scheme {scheme!r} in {redact_url_for_diagnostics(url)!r}" + ), + ) + self._get_http = WeftHttp( + timeout=timeout, + allowed_schemes=_ALLOWED_SCHEMES, + scheme_error=lambda scheme, url: FiligreeEmitError( + f"filigree URL must use http or https; got scheme {scheme!r} in {redact_url_for_diagnostics(url)!r}" + ), + ) + + def _invalid_url_error(self, url: str) -> FiligreeEmitError: + diagnostic_url = redact_url_for_diagnostics(url) + return FiligreeEmitError(f"filigree URL is invalid: {diagnostic_url!r}") def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: - # Restrict to http(s): a stray file://, ftp:// or data: URL is a user error, not - # an ingest target — turn it into a clean loud failure (and justify the S310 below). - scheme = urllib.parse.urlsplit(url).scheme.lower() - if scheme not in _ALLOWED_SCHEMES: - raise FiligreeEmitError(f"--filigree-url must use http or https; got scheme {scheme!r} in {url!r}") - request = urllib.request.Request(url, data=body, headers=dict(headers), method="POST") try: - with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310 - return Response(status=resp.status, body=read_response_text(resp)) - except urllib.error.HTTPError as exc: - # An HTTP status reached us — a protocol-level outcome, not an outage. Convert it - # to a Response so emit() classifies by status (4xx loud / 5xx soft), and close - # the underlying socket. - with exc: - return Response(status=exc.code, body=read_response_text(exc)) + result = self._post_http.fetch("POST", url, body=body, headers=headers) + except http.client.InvalidURL: + # A malformed URL (e.g. a bad port) raised inside urllib — redact before surfacing. + raise self._invalid_url_error(url) from None + return Response(status=result.status, body=result.body) def get(self, url: str, headers: Mapping[str, str]) -> Response: - scheme = urllib.parse.urlsplit(url).scheme.lower() - if scheme not in _ALLOWED_SCHEMES: - raise FiligreeEmitError(f"filigree URL must use http or https; got scheme {scheme!r} in {url!r}") - request = urllib.request.Request(url, headers=dict(headers), method="GET") try: - with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310 - return Response(status=resp.status, body=read_response_text(resp)) - except urllib.error.HTTPError as exc: - with exc: - return Response(status=exc.code, body=read_response_text(exc)) + result = self._get_http.fetch("GET", url, headers=headers) + except http.client.InvalidURL: + raise self._invalid_url_error(url) from None + return Response(status=result.status, body=result.body) def _resolve_operator_max_findings_per_request(explicit: int | None) -> int | None: @@ -757,7 +807,7 @@ def emit( # Filigree is present but its opt-in bearer auth is on and refusing us. # Stays SOFT (enrichment unavailable, never exit-2) — but distinguished # as auth so the caller can say the actionable thing. - detail = f"chunk rejected at auth layer ({resp.status}): {resp.body}" + detail = _chunk_rejection_detail("auth", resp.status) _record_pending_partial_failures(failures, chunks, chunk_index - 1, detail=detail) return EmitResult( reachable=False, @@ -772,7 +822,7 @@ def emit( if resp.status >= 500: # Server-side outage (5xx) — the sibling is degraded, not a Wardline # payload bug. Treat like absent (warn + continue), carrying the status. - detail = f"chunk rejected at server layer ({resp.status}): {resp.body}" + detail = _chunk_rejection_detail("server", resp.status) _record_pending_partial_failures(failures, chunks, chunk_index - 1, detail=detail) return EmitResult( reachable=False, @@ -785,16 +835,15 @@ def emit( url=self._url, ) if not 200 <= resp.status < 300: - message = f"Filigree rejected scan-results ({resp.status}) at {self._url}: {resp.body}" + diagnostic_url = redact_url_for_diagnostics(self._url) + message = f"Filigree rejected scan-results ({resp.status}) at {diagnostic_url}: {resp.body}" if self._protocol_errors_loud: raise FiligreeEmitError(message) # Fail-soft: the chunk (and every chunk after it) is un-ingested. PDR-0023 — - # record EACH still-pending finding as a ``partial`` failure carrying the - # rejecting status, so the caller reads "K findings failed because the chunk - # was rejected ()" instead of an opaque count that looks like success - # minus a number. ``partial`` (chunk-wide) is named distinctly from a - # per-finding ``rejected`` because the cause is the request, not the body. - detail = f"chunk rejected at protocol layer ({resp.status}): {resp.body}" + # record EACH still-pending finding as a ``partial`` failure carrying only + # bounded request-status context; the response body stays in one warning + # below instead of being duplicated once per un-ingested finding. + detail = _chunk_rejection_detail("protocol", resp.status) _record_pending_partial_failures(failures, chunks, chunk_index - 1, detail=detail) warnings.append(message) break diff --git a/src/wardline/core/filigree_issue.py b/src/wardline/core/filigree_issue.py index fc5644e5..a0b1ec0a 100644 --- a/src/wardline/core/filigree_issue.py +++ b/src/wardline/core/filigree_issue.py @@ -419,9 +419,7 @@ def attach_loomweave_identity_for_qualname( entity_kind=f"{plugin}:function", ) - legacy = _legacy_locator_binding( - loomweave_client, qualname, fallback_locator=binding.locator or locator, plugin=plugin - ) + legacy = _legacy_locator_binding(loomweave_client, qualname, plugin=plugin) if legacy.entity_id and legacy.content_hash: return filer.attach_entity_association( issue_id=issue_id, @@ -432,23 +430,19 @@ def attach_loomweave_identity_for_qualname( return legacy -def _legacy_locator_binding( - loomweave_client: Any, qualname: str, *, fallback_locator: str, plugin: str = "python" -) -> IdentityAttachResult: - entity_id: str | None = fallback_locator +def _legacy_locator_binding(loomweave_client: Any, qualname: str, *, plugin: str = "python") -> IdentityAttachResult: + entity_id: str | None = None content_hash: str | None = None try: resolved = loomweave_client.resolve([qualname], plugin=plugin) except Exception as exc: return IdentityAttachResult.skipped( f"Loomweave legacy locator resolve failed: {exc}", - entity_id=entity_id, binding_kind="locator", ) if resolved is None: return IdentityAttachResult.skipped( "Loomweave unavailable while resolving legacy locator", - entity_id=entity_id, binding_kind="locator", ) resolved_map = getattr(resolved, "resolved", {}) @@ -456,6 +450,11 @@ def _legacy_locator_binding( resolved_value = resolved_map.get(qualname) if isinstance(resolved_value, str) and resolved_value: entity_id = resolved_value + if entity_id is None: + return IdentityAttachResult.skipped( + "Loomweave did not resolve legacy locator binding; association not attached", + binding_kind="locator", + ) try: fact = loomweave_client.get_taint_fact(qualname) diff --git a/src/wardline/core/finding.py b/src/wardline/core/finding.py index a57664de..23bd7dd5 100644 --- a/src/wardline/core/finding.py +++ b/src/wardline/core/finding.py @@ -51,6 +51,11 @@ } ) +# Rule ids that mean the scan result is not complete enough to reconcile absent +# fingerprints as fixed. This deliberately includes per-function under-analysis +# while leaving ScanSummary.unanalyzed scoped to file/source-root under-scans. +INCOMPLETE_ANALYSIS_RULE_IDS = UNANALYZED_RULE_IDS | {"WLN-ENGINE-FUNCTION-SKIPPED"} + class Severity(StrEnum): CRITICAL = "CRITICAL" @@ -153,14 +158,16 @@ def to_jsonl(self) -> str: # change as the rule suite is extended/refined). # # INVARIANT (enforce at every call site — see tests/golden/identity): ``taint_path`` -# carries ONLY a SOURCE-DERIVED discriminator and exists SOLELY to separate two -# distinct findings that share (rule_id, path, line_start, qualname). A component -# may appear in ``taint_path`` only if it is BOTH (a) derived purely from source -# tokens / lexical position (a sink dotted-name, a callee spelling as written, a -# decorator marker/level token, a call's ``col_offset``) — NOT a resolved -# ``TaintState`` tier and NOT ``via_callee`` — AND (b) load-bearing: actually -# needed to tell two co-located findings apart. A rule that emits at most one -# finding per (rule_id, path, qualname) passes ``taint_path=None``. +# carries ONLY a SOURCE-DERIVED discriminator. A component may appear in +# ``taint_path`` only if it is derived purely from source tokens / lexical position +# (a sink dotted-name, a callee spelling as written, a decorator marker/level token, +# a call's full lexical span, or a singleton entity body discriminator) — NOT a +# resolved ``TaintState`` tier and NOT ``via_callee`` — and is load-bearing. For +# multi-emit rules, it separates two distinct findings that share (rule_id, path, +# qualname). For singleton entity-level rules, it may bind the finding to the +# current source body/signature so a same-qualname redefinition cannot inherit a +# stale suppression. Rules with no additional source discriminator still pass +# ``taint_path=None``. # Resolved tiers belong in ``message``/``properties``, never the join key. # This invariant is no longer convention-only: ``scanner.diagnostics.build_collision_findings`` # enforces it at runtime over the full emitted set (wardline-8fb773a7af) — two DISTINCT @@ -171,8 +178,9 @@ def to_jsonl(self) -> str: # comment above an entity shifts every line below it but is the same source, so it # must not churn the cross-tool join key. Multi-emit rules therefore discriminate # co-located findings with an ENTITY-RELATIVE position — ``node.lineno - -# entity.location.line_start`` plus the call's ``col_offset:end_col_offset`` — which -# is invariant to the whole entity moving (a comment above it). NOTE: it is +# entity.location.line_start`` plus the call's +# ``col_offset:end_lineno-entity.location.line_start:end_col_offset`` — which is +# invariant to the whole entity moving (a comment above it). NOTE: it is # entity-relative, NOT move-stable in the strong sense — a comment inserted INSIDE # the entity above the node still shifts the relative offset (accepted; the contract # is identical-source -> identical-fingerprint, and that edit is not identical source). @@ -198,7 +206,9 @@ def compute_finding_fingerprint( # orphaning every verdict. The IN-MEMORY ``Finding.fingerprint`` stays bare # 64-hex; the prefix is applied only when serialising to a store/wire and # stripped (``parse_fingerprint``) when reading one back. ``wlfp1`` is this -# (line_start-IN) formula; the move-stability rekey will bump it to ``wlfp2``. +# (line_start-IN) formula; ``wlfp2`` is the line_start-OUT core formula. Rule-level +# discriminator changes within the same core formula intentionally fail active for +# old suppressions instead of requiring a global scheme bump for every rule. FINGERPRINT_SCHEME = "wlfp2" _HEX_DIGITS = frozenset("0123456789abcdef") diff --git a/src/wardline/core/gitignore.py b/src/wardline/core/gitignore.py index f114402d..e3cf4a66 100644 --- a/src/wardline/core/gitignore.py +++ b/src/wardline/core/gitignore.py @@ -1,10 +1,11 @@ -"""Stdlib-only ``.gitignore`` matcher for discovery-time directory pruning. +"""Stdlib-only ``.gitignore`` matcher for trusted directory-pruning callers. -This is a *pruning* matcher: ``discover`` consults it to decide whether to descend -into a directory during the ``os.walk``, so a multi-GB third-party tree listed in -``.gitignore`` (``.venv``, ``node_modules``, ``build``…) is never traversed instead of -being walked and post-filtered. It deliberately implements the subset of the -gitignore spec that governs *directory* decisions deterministically: +This is a *pruning* matcher: callers use it to decide whether to descend into a +directory during ``os.walk``. Wardline's normal source discovery deliberately does +not honor repository ``.gitignore`` files, because those files are checkout content +and can hide tracked source. The matcher remains available for explicit trusted +opt-in paths. It implements the subset of the gitignore spec that governs +*directory* decisions deterministically: * blank lines and ``#`` comments are ignored; * a leading ``!`` negates (un-ignores) a later-matched pattern; @@ -16,10 +17,8 @@ Patterns are accumulated per-directory as the top-down walk descends, exactly as git layers nested ``.gitignore`` files. The matcher is intentionally conservative: it is -used ONLY to prune directories, never to drop individual analyzable files from a scan -(file-level exclusion stays the operator's explicit ``exclude`` globs), so a partial -gitignore-grammar gap can only ever UNDER-prune (walk a dir git would skip) — never -silently under-scan tracked source. +used ONLY for directory decisions, never to drop individual analyzable files after +discovery. """ from __future__ import annotations diff --git a/src/wardline/core/http.py b/src/wardline/core/http.py index f33d729a..46907de2 100644 --- a/src/wardline/core/http.py +++ b/src/wardline/core/http.py @@ -2,11 +2,18 @@ from __future__ import annotations +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Protocol MAX_RESPONSE_BODY_BYTES = 64 * 1024 _TRUNCATION_MARKER = "... [truncated]" +_DEFAULT_ALLOWED_SCHEMES = ("http", "https") + class _Readable(Protocol): def read(self, size: int = -1) -> bytes: ... @@ -20,3 +27,95 @@ def read_response_text(stream: _Readable, *, limit: int = MAX_RESPONSE_BODY_BYTE data = data[:limit] text = data.decode("utf-8", "replace") return f"{text}{_TRUNCATION_MARKER}" if truncated else text + + +@dataclass(frozen=True, slots=True) +class HttpResult: + """The status + bounded body of one HTTP round trip. + + An HTTP response — INCLUDING a 4xx/5xx (HTTPError) — yields an HttpResult so the + caller classifies the outcome by status band. A transport-level failure + (URLError / OSError: connection refused / DNS / timeout) is NOT caught here; it + propagates to the caller, whose own fail-soft policy decides what an outage means. + Each federation client wraps this in its own module-local ``Response`` dataclass. + """ + + status: int + body: str + + +class WeftHttp: + """Shared stdlib-urllib transport for the Weft federation clients. + + Encapsulates the one-true round-trip discipline every hand-rolled client repeated: + a parameterized scheme gate, Request construction, ``urlopen`` with a request + timeout, the HTTPError → :class:`HttpResult` conversion (a status reached us, so it + is a protocol outcome, not an outage — the socket is closed), and a bounded body + read (``read_response_text``, the 64 KiB :data:`MAX_RESPONSE_BODY_BYTES` cap). + + Confinement is PARAMETERIZED so each client keeps its exact gating, not unified away: + + * ``allowed_schemes`` — the scheme allow-list (default http/https). The gate is a + THREAT-001-class confinement: a stray ``file://`` / ``ftp://`` / ``data:`` URL is a + loud caller error, never an ingest target (and what justifies the ``urlopen`` S310). + * ``scheme_error`` — a per-call-site builder ``(scheme, url) -> Exception`` so each + client raises its OWN exception type and message (``FiligreeEmitError`` vs + ``LoomweaveError``; ``--filigree-url`` vs ``--loomweave-url`` wording) verbatim. + * ``timeout`` — the per-request ``urlopen`` timeout, unchanged per client. + * ``max_body_bytes`` — the response-body read bound. + + ``URLError`` / ``OSError`` are deliberately NOT swallowed: each client's distinct + policy is applied by the caller, so wire/error semantics stay identical after + migration. filigree_emit and loomweave fail-HARD on a >=400 (``protocol_errors_loud`` + ``FiligreeEmitError`` / ``_require_ok`` ``LoomweaveError``); the dossier fail-SOFTs + every non-2xx and every ``URLError`` / ``OSError`` to an ``unavailable`` section. + + The ``urlopen`` symbol is resolved as a live module attribute at call time (not + bound at import/def), so a test that monkeypatches ``urllib.request.urlopen`` + keeps its injection seam. + """ + + def __init__( + self, + *, + timeout: float = 30.0, + allowed_schemes: tuple[str, ...] = _DEFAULT_ALLOWED_SCHEMES, + scheme_error: Callable[[str, str], Exception] | None = None, + max_body_bytes: int = MAX_RESPONSE_BODY_BYTES, + ) -> None: + self._timeout = timeout + self._allowed_schemes = allowed_schemes + self._scheme_error = scheme_error + self._max_body_bytes = max_body_bytes + + def fetch( + self, + method: str, + url: str, + *, + body: bytes | None = None, + headers: Mapping[str, str] | None = None, + ) -> HttpResult: + """One round trip: scheme-gate, build, ``urlopen``-with-timeout, bounded read. + + Returns an :class:`HttpResult` for any HTTP status (2xx..5xx, via the HTTPError + branch). A ``URLError`` / ``OSError`` propagates — the caller owns that policy. + """ + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in self._allowed_schemes: + raise self._build_scheme_error(scheme, url) + request = urllib.request.Request(url, data=body, headers=dict(headers or {}), method=method) + try: + with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310 + return HttpResult(status=resp.status, body=read_response_text(resp, limit=self._max_body_bytes)) + except urllib.error.HTTPError as exc: + # An HTTP status reached us — a protocol-level outcome, not an outage. Convert + # it to an HttpResult so the caller classifies by status band, and close the + # underlying socket (the ``with exc`` context). + with exc: + return HttpResult(status=exc.code, body=read_response_text(exc, limit=self._max_body_bytes)) + + def _build_scheme_error(self, scheme: str, url: str) -> Exception: + if self._scheme_error is not None: + return self._scheme_error(scheme, url) + return ValueError(f"URL must use one of {self._allowed_schemes}; got scheme {scheme!r} in {url!r}") diff --git a/src/wardline/core/legis.py b/src/wardline/core/legis.py index 87144974..fe23fcb9 100644 --- a/src/wardline/core/legis.py +++ b/src/wardline/core/legis.py @@ -190,6 +190,9 @@ def project_finding(finding: Finding) -> dict[str, Any]: } +_SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") + + def _git_tree_sha(root: Path) -> str | None: """The committed tree object SHA (``git rev-parse HEAD^{tree}``), or None. @@ -199,7 +202,7 @@ def _git_tree_sha(root: Path) -> str | None: """ try: rev = subprocess.run( - ["git", "rev-parse", "HEAD^{tree}"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "HEAD^{tree}"], cwd=root, capture_output=True, text=True, @@ -215,7 +218,7 @@ def _git_repo_root(root: Path) -> Path | None: """The containing git repository root, or None when unavailable.""" try: rev = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "--show-toplevel"], cwd=root, capture_output=True, text=True, diff --git a/src/wardline/core/paths.py b/src/wardline/core/paths.py index 774fba36..6fce93c9 100644 --- a/src/wardline/core/paths.py +++ b/src/wardline/core/paths.py @@ -21,6 +21,7 @@ WEFT_MEMBER = "wardline" WEFT_CONFIG_FILE = "weft.toml" _WEFT_DIR = ".weft" +DEFAULT_ARTIFACT_DIR = ".wardline" def weft_config_path(root: Path) -> Path: @@ -134,3 +135,33 @@ def sibling_state_dir(root: Path, sibling: str) -> Path: def legacy_sibling_dir(root: Path, sibling: str) -> Path: """Legacy pre-consolidation dot-dir for a sibling (transition-window fallback).""" return root / f".{sibling}" + + +def project_root_for(scan_path: Path) -> Path: + """The weft-project root governing a scan of *scan_path* (always resolved). + + enclosing_project_root() returns the nearest STRICT ancestor carrying project + markers, or None when scan_path itself is a root OR no ancestor is one. In both + None cases the governing root is scan_path itself. + """ + return enclosing_project_root(scan_path) or scan_path.resolve() + + +def artifacts_dir(scan_path: Path, artifacts_dir_value: str) -> Path: + """Resolved scan-artifact directory, anchored to project_root_for(scan_path). + + Mirrors weft_state_dir's confinement: a relative value resolves under the project + root; an absolute value is honored only if inside it; any value resolving OUTSIDE + (absolute elsewhere or a ``..`` escape) falls back to the default ``.wardline`` + under the project root. weft.toml is untrusted input, so this denies a malicious + artifacts.dir both a write-redirect and an exit-2 DoS. + """ + project_root = project_root_for(scan_path) # already fully resolved + default = project_root / DEFAULT_ARTIFACT_DIR + candidate = Path(artifacts_dir_value) + resolved = (candidate if candidate.is_absolute() else project_root / candidate).resolve() + try: + resolved.relative_to(project_root) + except ValueError: + return default + return resolved diff --git a/src/wardline/core/rekey.py b/src/wardline/core/rekey.py index b3f30df9..60d6d861 100644 --- a/src/wardline/core/rekey.py +++ b/src/wardline/core/rekey.py @@ -150,18 +150,26 @@ def compute_old_new_fingerprints(findings: Iterable[Finding]) -> list[Fingerprin @dataclass(frozen=True, slots=True) class RekeyCollision: - """Two findings DISTINCT under wlfp1 (different ``old_fp``) that collapse to one - ``new_fp`` under wlfp2. P2/P3 guarantee no two CURRENT findings share a ``new_fp``, - so this can only mean a discriminator bug — it is reported LOUD (shares the - WLN-ENGINE-FINGERPRINT-COLLISION invariant) and BOTH old_fps are orphaned (neither - verdict is carried), but the rest of the migration proceeds. A whole-run abort - would brick a real project permanently, so we never abort.""" + """Ambiguous fingerprint migration that must orphan instead of carrying. - new_fp: str + Collapse: two findings DISTINCT under wlfp1 (different ``old_fp``) collapse to + one ``new_fp`` under wlfp2. Fan-out: one legacy ``old_fp`` reconstructs for two + current findings after a discriminator split. Both are reported LOUD and the + ambiguous old fingerprint(s) are orphaned, but the rest of the migration proceeds. + A whole-run abort would brick a real project permanently, so we never abort.""" + + new_fp: str | None old_fps: tuple[str, ...] + new_fps: tuple[str, ...] = () @property def message(self) -> str: + if self.new_fps: + return ( + f"WLN-ENGINE-FINGERPRINT-FANOUT: pre-rekey fingerprint {self.old_fps[0]} maps to " + f"{len(self.new_fps)} wlfp2 fingerprints ({', '.join(self.new_fps)}); " + "verdict orphaned, not carried." + ) return ( f"WLN-ENGINE-FINGERPRINT-COLLISION: {len(self.old_fps)} pre-rekey fingerprints collapse to " f"{self.new_fp} under wlfp2 ({', '.join(self.old_fps)}); both verdicts orphaned, not carried." @@ -177,20 +185,31 @@ class RemapResult: def build_remap(remaps: Iterable[FingerprintRemap]) -> RemapResult: - """Build the ``old_fp -> new_fp`` map. ``old_fp`` is a function of the finding's - inputs (incl. line_start), so it never maps to two new_fps. The inverse CAN - collide (wlfp2 dropped line_start): if >1 distinct old_fp shares a new_fp, ALL - those old_fps are excluded from the map and recorded as a collision.""" + """Build the ``old_fp -> new_fp`` map. + + The map is carried only for 1:1 keys. The inverse can collide (wlfp2 dropped + line_start): if >1 distinct old_fp shares a new_fp, all those old_fps are + excluded. A later source-discriminator split can also fan one old_fp out to + multiple new_fps; that old_fp is excluded too because choosing a carried verdict + target would be arbitrary. + """ new_to_olds: dict[str, set[str]] = {} - old_to_new: dict[str, str] = {} + old_to_news: dict[str, set[str]] = {} for r in remaps: new_to_olds.setdefault(r.new_fp, set()).add(r.old_fp) - old_to_new[r.old_fp] = r.new_fp - collisions = tuple( + old_to_news.setdefault(r.old_fp, set()).add(r.new_fp) + old_to_new: dict[str, str] = {old: next(iter(news)) for old, news in old_to_news.items() if len(news) == 1} + collapse_collisions = tuple( RekeyCollision(new_fp=nf, old_fps=tuple(sorted(olds))) for nf, olds in sorted(new_to_olds.items()) if len(olds) > 1 ) + fanout_collisions = tuple( + RekeyCollision(new_fp=None, old_fps=(old,), new_fps=tuple(sorted(news))) + for old, news in sorted(old_to_news.items()) + if len(news) > 1 + ) + collisions = collapse_collisions + fanout_collisions for c in collisions: for of in c.old_fps: old_to_new.pop(of, None) @@ -204,6 +223,63 @@ def snapshot_dir(root: Path) -> Path: return paths.weft_state_dir(root) / SNAPSHOT_DIR_NAME +def _has_path_or_symlink(path: Path) -> bool: + return path.exists() or path.is_symlink() + + +def _read_project_store_bytes(root: Path, path: Path) -> bytes | None: + """Read an optional project-local store without following symlinks. + + Missing, non-regular, symlinked, or escaping paths are not snapshot/probe inputs. + """ + if path.is_symlink(): + return None + try: + safe = safe_project_file(root, path, label=path.name) + except WardlineError: + return None + return read_bytes_no_follow(safe) + + +def _read_required_snapshot_bytes(root: Path, path: Path) -> bytes: + """Read a snapshot store that must exist and must be a regular in-project file.""" + try: + if path.is_symlink(): + raise WardlineError(f"{path.name}: refusing to read through a symlink") + safe = safe_project_file(root, path, label=path.name) + except WardlineError as exc: + raise WardlineError(f"non-regular rekey snapshot {path.name} under {path.parent}: {exc}") from exc + data = read_bytes_no_follow(safe) + if data is None: + raise WardlineError(f"non-regular rekey snapshot {path.name} under {path.parent}") + return data + + +def _refuse_preexisting_snapshot_without_journal(root: Path) -> None: + """Fresh rekey runs must create their own snapshot provenance. + + A snapshot without a journal is not resumable state; trusting it would let an + untrusted checkout pre-plant old fingerprints that a fresh run would carry into + live stores. + """ + sdir = snapshot_dir(root) + if sdir.is_symlink(): + raise WardlineError(f"pre-existing rekey snapshot at {sdir} is a symlink; refusing to trust it") + if not sdir.exists(): + return + if not sdir.is_dir(): + raise WardlineError(f"pre-existing rekey snapshot at {sdir} is not a directory; refusing to trust it") + try: + entries = tuple(sdir.iterdir()) + except OSError as exc: + raise WardlineError(f"could not inspect pre-existing rekey snapshot at {sdir}: {exc}") from exc + if entries: + raise WardlineError( + f"pre-existing rekey snapshot at {sdir} has no migration journal; " + "refusing to trust stale or caller-planted provenance." + ) + + def snapshot_stores(root: Path) -> tuple[str, ...]: """Copy each EXISTING YAML store into ``.rekey_snapshot/`` byte-identical. The snapshot is the immutable provenance source the carry legs read — resume NEVER @@ -219,7 +295,7 @@ def snapshot_stores(root: Path) -> tuple[str, ...]: # the repo, and a naive read would copy that target into the in-project snapshot # (arbitrary file disclosure). A symlinked/non-regular/missing store is simply not # snapshot-eligible. - data = read_bytes_no_follow(live) + data = _read_project_store_bytes(root, live) if data is None: continue present.append(name) @@ -247,24 +323,42 @@ def _read_old_store(path: Path) -> dict[str, Any]: """Read an OLD-scheme (wlfp1) store RAW — bypassing the scheme-enforcing loaders, which would (correctly) reject the pre-rekey snapshot. The migration is the one place that reads an old-scheme store on purpose.""" - if not path.is_file(): + data = read_bytes_no_follow(path) + if data is None: return {} + return _load_old_store_bytes(data, path.name) + + +def _load_old_store_bytes(data: bytes, name: str) -> dict[str, Any]: yaml = require_yaml("reading the rekey snapshot") try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + loaded = yaml.safe_load(data.decode("utf-8")) or {} + except UnicodeDecodeError as exc: + raise ConfigError(f"malformed snapshot {name}: not valid UTF-8") from exc except yaml.YAMLError as exc: # pragma: no cover - defensive - raise ConfigError(f"malformed snapshot {path.name}: {exc}") from exc + raise ConfigError(f"malformed snapshot {name}: {exc}") from exc if not isinstance(loaded, dict): - raise ConfigError(f"snapshot {path.name} is not a mapping") + raise ConfigError(f"snapshot {name} is not a mapping") return loaded def _carry_store(snapshot_path: Path, list_key: str, version: int, old_to_new: dict[str, str]) -> CarryResult: + loaded = _read_old_store(snapshot_path) + return _carry_loaded_store(loaded, list_key, version, old_to_new) + + +def _carry_store_bytes( + data: bytes, snapshot_name: str, list_key: str, version: int, old_to_new: dict[str, str] +) -> CarryResult: + loaded = _load_old_store_bytes(data, snapshot_name) + return _carry_loaded_store(loaded, list_key, version, old_to_new) + + +def _carry_loaded_store(loaded: dict[str, Any], list_key: str, version: int, old_to_new: dict[str, str]) -> CarryResult: """Remap one store: swap each entry's ``fingerprint`` old->new while byte-preserving every OTHER field (rationale/reason/expires/rule_id/path/message/...), drop entries whose old_fp is not in the remap (orphans), and re-stamp the wlfp2 scheme header. Deterministic order: (rule_id, path, new fingerprint).""" - loaded = _read_old_store(snapshot_path) raw_entries = loaded.get(list_key) or [] # A snapshot store ALREADY at the live scheme needs no remap: its fingerprints are # wlfp2 keys, and pushing them through the wlfp1->wlfp2 map would orphan every one @@ -306,11 +400,11 @@ def carry_waivers_forward(snapshot_path: Path, old_to_new: dict[str, str]) -> Ca # Legs in apply order: YAML first (gate-critical — baseline restores the local gate), # Filigree last (reconciliation debt, no remap endpoint). LEG_NAMES: tuple[str, ...] = ("baseline", "judged", "waivers", "filigree") -# Maps a YAML leg to (carry fn, snapshot filename, live-store path fn). -_YAML_LEGS: dict[str, tuple[Any, str, Any]] = { - "baseline": (carry_baseline_forward, "baseline.yaml", paths.baseline_path), - "judged": (carry_judged_forward, "judged.yaml", paths.judged_path), - "waivers": (carry_waivers_forward, "waivers.yaml", paths.waivers_path), +# Maps a YAML leg to (snapshot filename, live-store path fn, list key, store version). +_YAML_LEGS: dict[str, tuple[str, Any, str, int]] = { + "baseline": ("baseline.yaml", paths.baseline_path, "entries", BASELINE_VERSION), + "judged": ("judged.yaml", paths.judged_path, "findings", JUDGED_VERSION), + "waivers": ("waivers.yaml", paths.waivers_path, "waivers", WAIVERS_VERSION), } @@ -365,7 +459,14 @@ def journal_to_doc(journal: Journal) -> dict[str, Any]: "fingerprint_scheme_to": journal.fingerprint_scheme_to, "snapshot_prescheme": journal.snapshot_prescheme, "remap": dict(journal.remap), - "collisions": [{"new_fp": c.new_fp, "old_fps": list(c.old_fps)} for c in journal.collisions], + "collisions": [ + { + "new_fp": c.new_fp, + "old_fps": list(c.old_fps), + **({"new_fps": list(c.new_fps)} if c.new_fps else {}), + } + for c in journal.collisions + ], "legs": [ {"name": leg.name, "done": leg.done, "carried": leg.carried, "orphaned": leg.orphaned, "debt": leg.debt} for leg in journal.legs @@ -406,7 +507,11 @@ def load_journal(path: Path) -> Journal: for d in loaded.get("legs") or [] ] collisions = [ - RekeyCollision(new_fp=str(c["new_fp"]), old_fps=tuple(c.get("old_fps") or [])) + RekeyCollision( + new_fp=None if c.get("new_fp") is None else str(c["new_fp"]), + old_fps=tuple(c.get("old_fps") or []), + new_fps=tuple(c.get("new_fps") or []), + ) for c in loaded.get("collisions") or [] ] return Journal( @@ -450,14 +555,15 @@ def apply_pending_legs( _apply_filigree_leg(leg, findings, filigree) write_journal(jpath, journal, root=root) continue - carry_fn, snap_name, live_path_fn = _YAML_LEGS[leg.name] + snap_name, live_path_fn, list_key, version = _YAML_LEGS[leg.name] snap = sdir / snap_name - if not snap.is_file(): + if not _has_path_or_symlink(snap): # The store never existed pre-migration — nothing to carry, create nothing. leg.done = True write_journal(jpath, journal, root=root) continue - result = carry_fn(snap, journal.remap) + snapshot_data = _read_required_snapshot_bytes(root, snap) + result = _carry_store_bytes(snapshot_data, snap_name, list_key, version, journal.remap) _write_store_doc(root, live_path_fn(root), result.document) leg.carried = list(result.carried) leg.orphaned = list(result.orphaned) @@ -529,9 +635,10 @@ def _store_fingerprints(root: Path) -> dict[str, tuple[str | None, set[str]]]: state = paths.weft_state_dir(root) for name, key, _ver in _STORES: p = state / name - if not p.is_file(): + data = _read_project_store_bytes(root, p) + if data is None: continue - loaded = _read_old_store(p) + loaded = _load_old_store_bytes(data, p.name) scheme = loaded.get("fingerprint_scheme") fps = { e["fingerprint"] @@ -543,7 +650,7 @@ def _store_fingerprints(root: Path) -> dict[str, tuple[str | None, set[str]]]: return out -def _dir_has_prescheme_store(dir_path: Path) -> bool: +def _dir_has_prescheme_store(dir_path: Path, *, root: Path | None = None) -> bool: """True iff a store in ``dir_path`` holds entries but carries NO ``fingerprint_scheme`` header — i.e. it predates P1's scheme stamp. Such a store MAY also predate the taint-resolution-drift fix (705acfe), in which case its fingerprints fold resolved-taint @@ -552,9 +659,10 @@ def _dir_has_prescheme_store(dir_path: Path) -> bool: eras, so callers surface the possibility rather than mislabel every orphan a source move.""" for name, key, _ver in _STORES: p = dir_path / name - if not p.is_file(): + data = _read_project_store_bytes(root, p) if root is not None else read_bytes_no_follow(p) + if data is None: continue - loaded = _read_old_store(p) + loaded = _load_old_store_bytes(data, p.name) if loaded.get(key) and not loaded.get("fingerprint_scheme"): return True return False @@ -623,7 +731,7 @@ def probe(root: Path, findings: Sequence[Finding]) -> ProbeReport: # baseline has none, so this never dirties the A7 clean-no-op verdict. collisions=result.collisions, per_store=per_store, - prescheme=_dir_has_prescheme_store(paths.weft_state_dir(root)), + prescheme=_dir_has_prescheme_store(paths.weft_state_dir(root), root=root), current_scheme_stores=tuple(current_scheme_stores), stale=tuple(sorted(stale)), no_op=not migration_pending, @@ -662,11 +770,12 @@ def run_rekey(root: Path, findings: Sequence[Finding], *, filigree: Any = None) "no fingerprint migration is pending; a rekey would only orphan healthy " "verdicts. Nothing to do (run `wardline rekey --probe` for the per-store view)." ) + _refuse_preexisting_snapshot_without_journal(root) snapshot_stores(root) # must precede any store write journal = new_journal(compute_old_new_fingerprints(findings)) # Detect from the immutable snapshot (byte-identical to the pre-migration live stores) # so the caution persists onto the journal for --resume display too. - journal.snapshot_prescheme = _dir_has_prescheme_store(snapshot_dir(root)) + journal.snapshot_prescheme = _dir_has_prescheme_store(snapshot_dir(root), root=root) write_journal(jpath, journal, root=root) return apply_pending_legs(root, journal, findings=findings, filigree=filigree) @@ -695,21 +804,25 @@ def rollback(root: Path) -> RollbackResult: forward run are NOT reversed (no remap endpoint; re-emitting would need the old scan) — the caller warns about that orphan risk.""" sdir = snapshot_dir(root) - snap_files = [name for name, _k, _v in _STORES if (sdir / name).is_file()] + snap_payloads = [ + (name, _read_required_snapshot_bytes(root, sdir / name)) + for name, _k, _v in _STORES + if _has_path_or_symlink(sdir / name) + ] jpath = paths.migration_journal_path(root) - if not snap_files and not jpath.is_file(): + if not snap_payloads and not jpath.is_file(): raise WardlineError(f"no rekey snapshot under {sdir} — nothing to roll back") state = paths.weft_state_dir(root) restored: list[str] = [] - for name in snap_files: + for name, data in snap_payloads: live = safe_project_file(root, state / name, label=name) live.parent.mkdir(parents=True, exist_ok=True) - live.write_bytes((sdir / name).read_bytes()) + live.write_bytes(data) restored.append(name) # Remove the journal, then the snapshot files + dir (best-effort cleanup). jpath.unlink(missing_ok=True) for name, _k, _v in _STORES: (sdir / name).unlink(missing_ok=True) - if sdir.is_dir() and not any(sdir.iterdir()): + if not sdir.is_symlink() and sdir.is_dir() and not any(sdir.iterdir()): sdir.rmdir() return RollbackResult(restored=tuple(restored)) diff --git a/src/wardline/core/run.py b/src/wardline/core/run.py index b9489148..e00beb54 100644 --- a/src/wardline/core/run.py +++ b/src/wardline/core/run.py @@ -440,9 +440,8 @@ def run_scan( rule_id="WLN-ENGINE-NESTED-SCAN-ROOT", message=( f"scan root '{rel.as_posix()}' is a subdirectory of the weft project at " - f"{enclosing}: {qualname_clause}the project's baseline/waivers/judged state " - "is not loaded, and output defaults under the subdirectory. Scan the project " - "root for federation-stable results." + f"{enclosing}: {qualname_clause}and the project's baseline/waivers/judged " + "state is not loaded. Scan the project root for federation-stable results." ), severity=Severity.NONE, kind=Kind.FACT, diff --git a/src/wardline/core/safe_paths.py b/src/wardline/core/safe_paths.py index 2d8c5534..9fe55afc 100644 --- a/src/wardline/core/safe_paths.py +++ b/src/wardline/core/safe_paths.py @@ -42,6 +42,37 @@ def safe_write_text(root: Path, target: Path, content: str, *, label: str | None _write_text_no_follow(safe_path, content, label=label or safe_path.name) +def explicit_output_target(root: Path, target: Path, *, cwd: Path | None = None) -> tuple[Path, Path | None]: + """Return the concrete explicit output target and its project guard root. + + Relative explicit outputs belong to the caller's CWD. If the target is inside + ``root`` or reaches ``root`` through an existing symlink prefix, return the + scan root as a guard so parent symlink escapes are rejected. Explicit outside + targets keep no-follow-only behavior. + """ + base = cwd or Path.cwd() + root_abs = _absolute_no_symlinks(root, base) + target_abs = _absolute_no_symlinks(target, base) + root_resolved = root.resolve() + if ( + _is_relative_to(target_abs, root_abs) + or _is_relative_to(target_abs, root_resolved) + or _path_enters_root(target, root_resolved, cwd=base) + ): + return target_abs, root_resolved + return target, None + + +def write_explicit_output_text(root: Path, target: Path, content: str, *, label: str | None = None) -> None: + """Write explicit command output with project-root guarding when it lands in ``root``.""" + safe_target, safe_root = explicit_output_target(root, target) + name = label or safe_target.name + if safe_root is not None: + safe_write_text(safe_root, safe_target, content, label=name) + else: + write_text_no_follow(safe_target, content, label=name) + + def write_text_no_follow(target: Path, content: str, *, label: str | None = None) -> None: """Write ``content`` without following a final-component symlink.""" target.parent.mkdir(parents=True, exist_ok=True) @@ -62,6 +93,39 @@ def _write_text_no_follow(path: Path, content: str, *, label: str) -> None: handle.write(content) +def _absolute_no_symlinks(path: Path, cwd: Path) -> Path: + candidate = path if path.is_absolute() else cwd / path + return Path(os.path.abspath(os.fspath(candidate))) + + +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _path_enters_root(path: Path, root: Path, *, cwd: Path) -> bool: + if path.is_absolute(): + current = Path(path.anchor) + parts = path.parts[1:] + else: + current = cwd + parts = path.parts + for part in parts: + if part in {"", "."}: + continue + current = current / part + try: + resolved = current.resolve(strict=True) + except (OSError, RuntimeError): + continue + if resolved == root or _is_relative_to(resolved, root): + return True + return False + + def safe_read_text_if_regular( root: Path, target: Path, diff --git a/src/wardline/core/scan_file_workflow.py b/src/wardline/core/scan_file_workflow.py index 041afce4..434ec3b8 100644 --- a/src/wardline/core/scan_file_workflow.py +++ b/src/wardline/core/scan_file_workflow.py @@ -7,7 +7,8 @@ from wardline.core.errors import WardlineError from wardline.core.explain import TaintExplanation, explanation_from_context -from wardline.core.filigree_emit import EmitResult, filigree_disabled_reason +from wardline.core.federation_status import filigree_emit_status +from wardline.core.filigree_emit import EmitResult from wardline.core.filigree_issue import ( FileResult, IdentityAttachResult, @@ -46,35 +47,11 @@ def _finding_base(finding: Finding, explanation: TaintExplanation | None) -> dic def _emit_to_dict(result: EmitResult | None, *, configured: bool) -> dict[str, Any]: - if result is None: - return { - "configured": configured, - "reachable": None, - "created": 0, - "updated": 0, - "failed": 0, - "failures": [], - "warnings": [], - "disabled_reason": None if configured else "not configured", - } - return { - "configured": configured, - "reachable": result.reachable, - "created": result.created, - "updated": result.updated, - "failed": result.failed, - # PDR-0023: per-finding reject reasons so a partial ingest is distinguishable from clean. - "failures": [f.to_wire() for f in result.failures], - "warnings": list(result.warnings), - # Delegate to the shared 401/403-vs-5xx-vs-transport ladder (dogfood #5) instead - # of flattening every soft failure to "filigree unreachable". - "disabled_reason": filigree_disabled_reason( - reachable=result.reachable, - status=result.status, - token_sent=result.token_sent, - url=result.url, - ), - } + # Canonical builder (core/federation_status). The scan_file_findings block is the + # no-destination variant: include_destination=False, and ``configured`` is explicit so + # a configured-but-dry-run (result is None) renders disabled_reason:null, not + # "not configured". + return filigree_emit_status(result, configured=configured, include_destination=False) def _file_to_dict(result: FileResult | None, *, selected: bool, configured: bool) -> dict[str, Any]: diff --git a/src/wardline/core/scan_jobs.py b/src/wardline/core/scan_jobs.py index 1479b3c9..6264e28a 100644 --- a/src/wardline/core/scan_jobs.py +++ b/src/wardline/core/scan_jobs.py @@ -22,15 +22,19 @@ from wardline.core.agent_summary import build_agent_summary from wardline.core.emit import JsonlSink from wardline.core.errors import WardlineError +from wardline.core.federation_status import filigree_emit_status from wardline.core.filigree_emit import ( EmitResult, FiligreeEmitter, - filigree_destination, - filigree_disabled_reason, ) from wardline.core.finding import Severity from wardline.core.run import baseline_migration_hint, gate_decision, run_scan -from wardline.core.safe_paths import safe_project_path, safe_write_text, write_text_no_follow +from wardline.core.safe_paths import ( + explicit_output_target, + safe_project_path, + safe_write_text, + write_text_no_follow, +) from wardline.core.sarif import SarifSink _JOB_ID_RE = re.compile(r"^[0-9a-f]{32}$") @@ -256,35 +260,9 @@ def _normalize_request(request: dict[str, Any]) -> dict[str, Any]: def _filigree_status(result: EmitResult | None) -> dict[str, object]: - if result is None: - return { - "configured": False, - "reachable": None, - "created": 0, - "updated": 0, - "failed": 0, - "failures": [], - "warnings": [], - "disabled_reason": "not configured", - "destination": filigree_destination(None), - } - return { - "configured": True, - "reachable": result.reachable, - "created": result.created, - "updated": result.updated, - "failed": result.failed, - # PDR-0023: per-finding reject reasons so a partial ingest is distinguishable from clean. - "failures": [f.to_wire() for f in result.failures], - "warnings": list(result.warnings), - "disabled_reason": filigree_disabled_reason( - reachable=result.reachable, - status=result.status, - token_sent=result.token_sent, - url=result.url, - ), - "destination": filigree_destination(result.url), - } + # Canonical builder (core/federation_status); the scan-job artifact carries the same + # destination-bearing block as the CLI. configured is derived from result-is-None. + return filigree_emit_status(result, configured=result is not None, include_destination=True) def _write_scan_artifact( @@ -297,7 +275,7 @@ def _write_scan_artifact( filigree_emit: dict[str, Any] | None = None, migration_hint: str | None = None, ) -> None: - sink_root = root if output.is_relative_to(root.resolve()) else None + output, sink_root = explicit_output_target(root, output) if fmt == "sarif": SarifSink(output, root=sink_root).write(result.findings, result.context) return diff --git a/src/wardline/filigree/config.py b/src/wardline/filigree/config.py index 3d9c21ae..69230e2f 100644 --- a/src/wardline/filigree/config.py +++ b/src/wardline/filigree/config.py @@ -29,7 +29,7 @@ import os from pathlib import Path -from wardline.core.safe_paths import safe_project_file +from wardline.core.safe_paths import safe_project_file, safe_read_text_if_regular WEFT_FEDERATION_TOKEN_ENV = "WEFT_FEDERATION_TOKEN" # Deprecated fallback — read after the federation-scoped name so existing @@ -69,11 +69,10 @@ def _read_filigree_mint(root: Path) -> str | None: boot / install / doctor). Missing or unreadable falls through cleanly to None so the emit path degrades to the legacy/off rungs rather than crashing the scan. """ - path = safe_project_file(root, root.joinpath(*_FILIGREE_MINT_RELPATH), label="federation_token") - try: - value = path.read_text(encoding="utf-8").strip() - except OSError: + text = safe_read_text_if_regular(root, root.joinpath(*_FILIGREE_MINT_RELPATH), label="federation_token") + if text is None: return None + value = text.strip() return value or None diff --git a/src/wardline/filigree/dossier_client.py b/src/wardline/filigree/dossier_client.py index 9e43ccbb..2734f110 100644 --- a/src/wardline/filigree/dossier_client.py +++ b/src/wardline/filigree/dossier_client.py @@ -19,7 +19,6 @@ import json import urllib.error import urllib.parse -import urllib.request from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Protocol @@ -27,7 +26,7 @@ from wardline.core.dossier import TicketRef, WorkSection from wardline.core.errors import FiligreeEmitError from wardline.core.filigree_emit import filigree_api_base_url -from wardline.core.http import read_response_text +from wardline.core.http import WeftHttp from wardline.core.identity import ContentStatus, EntityBinding, content_status _ALLOWED_SCHEMES = ("http", "https") @@ -45,23 +44,22 @@ def get(self, url: str, headers: Mapping[str, str]) -> Response: ... class UrllibTransport: def __init__(self, timeout: float = 30.0) -> None: - self._timeout = timeout + # Mirror loomweave/client.UrllibTransport: HTTPError (a URLError subclass) is + # surfaced as a Response carrying its >=400 status rather than collapsing into the + # "unreachable" branch — so a 4xx/5xx is classified, not mistaken for an outage. + # URLError/OSError still propagate to work(), which fail-softs to an honest + # ``unavailable`` section. WeftHttp keeps this client's exact scheme-error wording. + self._http = WeftHttp( + timeout=timeout, + allowed_schemes=_ALLOWED_SCHEMES, + scheme_error=lambda scheme, url: FiligreeEmitError( + f"filigree dossier URL must use http or https; got scheme {scheme!r} in {url!r}" + ), + ) def get(self, url: str, headers: Mapping[str, str]) -> Response: - scheme = urllib.parse.urlsplit(url).scheme.lower() - if scheme not in _ALLOWED_SCHEMES: - raise FiligreeEmitError(f"filigree dossier URL must use http or https; got scheme {scheme!r} in {url!r}") - req = urllib.request.Request(url, headers=dict(headers), method="GET") - try: - with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310 - return Response(status=resp.status, body=read_response_text(resp)) - except urllib.error.HTTPError as exc: - # Mirror loomweave/client.UrllibTransport: surface the HTTP status to the - # caller (a >=400 band) rather than letting HTTPError (a URLError subclass) - # collapse into the "unreachable" branch — so a 4xx/5xx is classified, not - # mistaken for an outage. - with exc: - return Response(status=exc.code, body=read_response_text(exc)) + result = self._http.fetch("GET", url, headers=headers) + return Response(status=result.status, body=result.body) def _rows_of(parsed: Any) -> list[dict[str, Any]]: diff --git a/src/wardline/install/block.py b/src/wardline/install/block.py index 293ac8e1..1c7f412f 100644 --- a/src/wardline/install/block.py +++ b/src/wardline/install/block.py @@ -84,15 +84,18 @@ def _first_real_foreign_block_pos(content: str, search_from: int) -> int: Returns ``len(content)`` when no real foreign block follows (bound at EOF). The namespace match is case-insensitive (C-4 (h)). """ - fences = list(_INSTR_FENCE_RE.finditer(content, search_from)) - for i, m in enumerate(fences): + closes_after: set[str] = set() + boundary: int | None = None + for m in reversed(list(_INSTR_FENCE_RE.finditer(content, search_from))): ns = m.group("ns").lower() - if ns == _OWN_NS or m.group("close"): + is_close = bool(m.group("close")) + if ns == _OWN_NS: continue - # Foreign open: a boundary only if a matching foreign close follows it. - if any(n.group("ns").lower() == ns and n.group("close") for n in fences[i + 1 :]): - return m.start() - return len(content) + if is_close: + closes_after.add(ns) + elif ns in closes_after: + boundary = m.start() + return boundary if boundary is not None else len(content) def _first_own_open_fence_pos(content: str) -> int: diff --git a/src/wardline/install/doctor.py b/src/wardline/install/doctor.py index dcf0fb19..948b1286 100644 --- a/src/wardline/install/doctor.py +++ b/src/wardline/install/doctor.py @@ -6,17 +6,20 @@ import json import os import tomllib +from collections.abc import Sequence from dataclasses import dataclass from importlib import import_module from pathlib import Path from typing import Any from urllib.parse import urlsplit -from wardline.core.config import _filigree_published_url, load +from wardline.core import artifacts as _artifacts +from wardline.core import discovery, paths +from wardline.core.config import ArtifactSettings, _filigree_published_url, filigree_server_scoped_url, load from wardline.core.errors import ConfigError, WardlineError from wardline.core.filigree_emit import FiligreeEmitter, Transport, UrllibTransport from wardline.core.paths import weft_config_path, weft_state_dir -from wardline.core.safe_paths import safe_read_text_if_regular, safe_write_text +from wardline.core.safe_paths import safe_project_path, safe_read_text_if_regular, safe_write_text from wardline.filigree.config import load_filigree_token from wardline.install.block import inject_block from wardline.install.detect import ( @@ -48,6 +51,8 @@ class DoctorCheck: status: str fixed: bool = False message: str | None = None + removed: Sequence[str] = () + review: Sequence[str] = () @property def ok(self) -> bool: @@ -57,6 +62,10 @@ def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = {"id": self.id, "status": self.status, "fixed": self.fixed} if self.message: data["message"] = self.message + if self.removed: + data["removed"] = list(self.removed) + if self.review: + data["review"] = list(self.review) return data @@ -104,6 +113,140 @@ def _ensure_weft_config(root: Path) -> bool: return True +_GITIGNORE_HEADER = "# Wardline scan artifacts" + + +def _artifacts_dir_relname(proj: Path) -> str: + """The project-root-relative dir name to ignore (always in-tree by construction).""" + try: + cfg = load(weft_config_path(proj)) + artifacts_dir_value = cfg.artifacts.dir + except (ConfigError, OSError): + artifacts_dir_value = ArtifactSettings().dir + resolved = paths.artifacts_dir(proj, artifacts_dir_value) + rel = resolved.relative_to(proj.resolve()) + return rel.as_posix() + + +_MANAGED_SUFFIXES = ("findings.jsonl", "findings.sarif", "findings.agent-summary.json", "scan.legis.json") + + +def _is_managed_name(name: str) -> bool: + return any(_artifacts._managed_artifact_pattern(s).match(name) for s in _MANAGED_SUFFIXES) + + +def _sweep_stray_artifacts(proj: Path, *, fix: bool) -> DoctorCheck: + proj = proj.resolve() + # Both the configured artifacts dir AND the default .wardline are standard dirs: + # a subdir scan loads config from the scan path (no weft.toml => default .wardline), + # so it may write to /.wardline/ even when the project root's weft.toml + # configures a custom dir. Both locations are tool-owned and must not be swept. + standard_dirs = { + paths.artifacts_dir(proj, _artifacts_dir_relname(proj)), + paths.artifacts_dir(proj, paths.DEFAULT_ARTIFACT_DIR), + } + removed: list[str] = [] + review: list[str] = [] + emptied_dirs: list[Path] = [] + # topdown=True (the os.walk default) is REQUIRED: the dirnames[:] prune below + # (nested-project-root stop + standard-dir skip) is a no-op under topdown=False. + for dirpath, dirnames, filenames in os.walk(proj, followlinks=False): + here = Path(dirpath) + # prune: hard-skip set, .git, the standard artifacts dirs, and nested project roots + dirnames[:] = [ + d + for d in dirnames + if d not in discovery.WALK_SKIP_DIRS + and (here / d).resolve() not in standard_dirs + and not paths._has_project_markers(here / d) + ] + in_wardline_dir = here.name == ".wardline" and here.resolve() not in standard_dirs + for fname in filenames: + fpath = here / fname + managed = _is_managed_name(fname) # timestamped: 2026...-findings.jsonl + bare = fname in _MANAGED_SUFFIXES and not managed # unstamped: findings.jsonl + if not managed and not bare: + continue + rel = str(fpath.relative_to(proj)) + # ONLY a timestamped (managed) file INSIDE a non-standard .wardline/ dir is + # auto-deletable; bare-managed, or managed outside .wardline/, is REVIEW. + if not (managed and in_wardline_dir): + review.append(rel) + continue + if not _artifacts._is_regular_file_no_follow(fpath): + continue # symlink / non-regular -> skip + if not fix: + removed.append(rel) # would-remove (no unlink) + continue + try: + safe = safe_project_path(proj, fpath, label=fname) + except WardlineError: + continue # escaping entry -> skip, keep sweeping + try: + safe.unlink() + except OSError: + continue + removed.append(rel) + emptied_dirs.append(here) + if fix: + for d in emptied_dirs: + try: + if d.resolve() not in standard_dirs and not d.is_symlink(): + d.rmdir() # os.rmdir only; ENOTEMPTY guards + except OSError: + pass + # ADVISORY status (must-fix from plan review): stray artifacts are cleanup items, not a + # health failure, so status stays "ok" and the sweep never flips machine_readable_doctor's + # all(check.ok) aggregation (which would fail `doctor --fix` / MCP doctor on success). + msg = (f"removed {len(removed)}, review {len(review)}" if fix + else f"{len(removed)} removable, review {len(review)}") + return DoctorCheck( + "stray_artifacts", "ok", fixed=bool(fix and removed), message=msg, removed=removed, review=review + ) + + +def _gitignore_present_entries(text: str) -> set[str]: + out: set[str] = set() + for raw in text.splitlines(): # handles \n, \r\n, \r + line = raw.strip() + if not line or line.startswith("#") or line.startswith("!"): + continue + out.add(line.rstrip("/")) # trailing-slash tolerant + return out + + +def _check_gitignore(proj: Path, *, fix: bool) -> DoctorCheck: + gitignore = proj / ".gitignore" + # Always protect BOTH the configured artifacts dir AND the default .wardline/: + # a subdir scan may write to .wardline/ even when the project root weft.toml + # uses a custom dir, so both locations need gitignore coverage. + dir_entries: set[str] = { + _artifacts_dir_relname(proj) + "/", + paths.DEFAULT_ARTIFACT_DIR + "/", + } + wanted = sorted(dir_entries) + ["findings.jsonl"] + existing = safe_read_text_if_regular(proj, gitignore, label=".gitignore") or "" + present = _gitignore_present_entries(existing) + missing = [w for w in wanted if w.rstrip("/") not in present] + if not missing: + return DoctorCheck("gitignore", "ok", message="present") + if not fix: + # ADVISORY: a missing ignore line must NOT make .ok False — that would flip + # machine_readable_doctor's all(check.ok) and fail `doctor --fix` / MCP doctor. + # Status stays "ok"; the gap is surfaced in the message. + return DoctorCheck("gitignore", "ok", message="missing ignore lines: " + ", ".join(missing) + " (run --repair)") + block = "\n".join([_GITIGNORE_HEADER, *missing]) + "\n" + if existing and not existing.endswith("\n"): + block = "\n" + block # don't concatenate the header onto a no-newline last line + try: + safe_write_text(proj, gitignore, existing + block, label=".gitignore") + except WardlineError: + # A symlinked/escaping .gitignore is an untrusted-repo surface (spec §8). Report a + # single check error rather than letting the raise abort the whole doctor run. + return DoctorCheck("gitignore", "error", message="refused to write through a symlinked .gitignore") + return DoctorCheck("gitignore", "ok", fixed=True, message="added " + ", ".join(missing)) + + def _has_instruction_block(path: Path) -> bool: if not path.is_file(): return False @@ -226,20 +369,29 @@ def _valid_http_url(url: str) -> bool: return parsed.scheme.lower() in {"http", "https"} and bool(parsed.netloc) -def _check_url(root: Path, key: str, *, fixed: bool, effective_url: str | None = None) -> DoctorCheck: +def _check_url( + root: Path, + key: str, + *, + fixed: bool, + effective_url: str | None = None, + effective_url_source: str | None = None, +) -> DoctorCheck: # Doctor must vouch for the EFFECTIVE config of the process answering it # (dogfood-4 B8: it said "not configured" while the same server was launched # with --loomweave-url/--filigree-url and using them successfully). Precedence - # mirrors runtime resolution: the launch flag the caller threads in, then the - # env var. Each verdict names its provenance so two surfaces can never - # silently disagree about WHICH config they describe. Live local discovery - # (.weft//ephemeral.port) is dynamic and not a doctor concern. + # mirrors runtime resolution: a caller-threaded effective URL, then the env + # var. Each verdict names provenance so two surfaces can never silently + # disagree about WHICH config they describe. When the effective URL was + # resolved before the server was constructed, the caller must pass its source + # too so a published-port URL is not mislabeled as a launch flag. env_key = "WARDLINE_LOOMWEAVE_URL" if key == "loomweave" else "WARDLINE_FILIGREE_URL" check_id = f"{key}.url" if effective_url: + source = effective_url_source or f"--{key}-url launch flag" if _valid_http_url(effective_url): - return DoctorCheck(check_id, "ok", fixed=fixed, message=f"from --{key}-url launch flag") - return DoctorCheck(check_id, "error", fixed=False, message=f"invalid URL (launch flag): {effective_url!r}") + return DoctorCheck(check_id, "ok", fixed=fixed, message=f"from {source}") + return DoctorCheck(check_id, "error", fixed=False, message=f"invalid URL ({source}): {effective_url!r}") url = os.environ.get(env_key) if not url: return DoctorCheck(check_id, "ok", fixed=fixed, message="not configured (no launch flag, no env)") @@ -351,26 +503,48 @@ def _mcp_filigree_url(root: Path) -> str | None: return value if isinstance(value, str) else None -def _resolve_probe_url(root: Path, flag: str | None) -> str | None: - """Probe-URL precedence: flag > WARDLINE_FILIGREE_URL env > .mcp.json wardline - --filigree-url arg > Filigree's published port. None when nothing resolves. - - This mirrors the actual emit path (:func:`core.config.resolve_filigree_url`) - exactly: a scan auto-discovers a live Filigree daemon from its published - ``ephemeral.port`` (or the server-mode registry), so a project with a running - Filigree but no pinned ``--filigree-url`` (the common ethereal/per-project case) - *does* emit — and *does* need a valid token. The published-port rung is therefore - included so doctor verifies the credential the scan will really use rather than - reporting "nothing to verify" and leaving a 401 to surface only at emit time. The - rung is read-only and the token is sent only to loopback (the ``_is_loopback`` - guard in :func:`_check_filigree_auth`), and a published port implies a daemon that - bound it, so this still does no speculative network.""" +@dataclass(frozen=True, slots=True) +class _ProbeTarget: + url: str + source: str + token_probe_allowed: bool = True + + +def _resolve_probe_target(root: Path, flag: str | None) -> _ProbeTarget | None: + """Resolve the Filigree auth-probe target while preserving URL provenance.""" if flag: - return flag + return _ProbeTarget(flag, "flag") env = os.environ.get(_FILIGREE_URL_ENV) if env: - return env - return _mcp_filigree_url(root) or _filigree_published_url(root) + return _ProbeTarget(env, "env") + mcp = _mcp_filigree_url(root) + if mcp: + return _ProbeTarget(mcp, "mcp") + scoped = filigree_server_scoped_url(root) + if scoped is not None: + return _ProbeTarget(scoped, "server-registry") + published = _filigree_published_url(root) + if published is not None: + return _ProbeTarget(published, "project-published-port", token_probe_allowed=False) + return None + + +def _resolve_probe_url(root: Path, flag: str | None) -> str | None: + """Probe-URL precedence: flag > WARDLINE_FILIGREE_URL env > .mcp.json wardline + --filigree-url arg > Filigree's server registry. None when nothing safe resolves. + + Compatibility wrapper for callers that only need the URL. The auth-probe path + uses :func:`_resolve_probe_target` so it can distinguish operator-pinned targets + from repository-owned ``ephemeral.port`` discovery before sending a bearer.""" + target = _resolve_probe_target(root, flag) + if target is None or not target.token_probe_allowed: + return None + return target.url + + +def _filigree_auth_probe_would_network(root: Path, flag: str | None) -> bool: + target = _resolve_probe_target(root, flag) + return bool(target and target.token_probe_allowed and _is_loopback(target.url)) def _is_loopback(url: str) -> bool: @@ -436,64 +610,33 @@ def _repair_filigree_auth(root: Path, url: str, transport: Transport) -> DoctorC ) -def _same_target(a: str, b: str) -> bool: - """True when two URLs name the same write target up to host spelling — identical - port and path. A bad literal reads as non-matching rather than crashing the check.""" - try: - pa, pb = urlsplit(a), urlsplit(b) - return (pa.port, pa.path) == (pb.port, pb.path) - except ValueError: - return False - - -def _live_published_url_behind_stale_pin( - root: Path, probed_url: str, transport: Transport, token: str | None -) -> str | None: - """When the probed (pinned) Filigree URL is unreachable, return a DIFFERENT - published-port URL that IS reachable — the signature of a stale ``.mcp.json`` - ``--filigree-url`` pin shadowing a daemon that rotated to a new port (the common - legacy ``.filigree/ephemeral.port`` rung outliving a rotation). ``None`` when there - is no pin, no live published target, or the published target names the same port (a - genuinely-absent daemon — left as the soft "not reachable" result).""" - if _mcp_filigree_url(root) is None: - return None # no pin: the probed URL already IS the published one - published = _filigree_published_url(root) - if published is None or _same_target(probed_url, published) or not _is_loopback(published): - return None - probe = FiligreeEmitter(published, transport=transport, token=token).verify_token() - return published if probe.reachable else None - - def _check_filigree_auth( root: Path, *, repair: bool, filigree_url: str | None = None, transport: Transport | None = None, + probe_target: _ProbeTarget | None = None, ) -> DoctorCheck: """Verify the token wardline would emit is accepted by the configured Filigree daemon. Read-only probe; under *repair*, recover the accepted token from local mints and pin it in .env. The probe targets only loopback origins.""" probe_transport = transport if transport is not None else UrllibTransport(timeout=2.0) - url = _resolve_probe_url(root, filigree_url) - if url is None: + target = probe_target or _resolve_probe_target(root, filigree_url) + if target is None: return DoctorCheck("filigree.auth", "ok", message="filigree not configured; nothing to verify") + url = target.url if not _is_loopback(url): return DoctorCheck("filigree.auth", "ok", message="non-loopback filigree; token not probed") + if not target.token_probe_allowed: + return DoctorCheck( + "filigree.auth", + "ok", + message="filigree resolved from project published port; token not probed", + ) token = load_filigree_token(root) # may be None — probe anyway (the daemon may have auth off) probe = FiligreeEmitter(url, transport=probe_transport, token=token).verify_token() if not probe.reachable: - live = _live_published_url_behind_stale_pin(root, url, probe_transport, token) - if live is not None: - return DoctorCheck( - "filigree.auth", - "error", - message=( - f"configured --filigree-url ({url}) is unreachable, but Filigree is live at " - f"{live} (published port); run `wardline doctor --repair` to drop the stale pin " - "so discovery follows the live port" - ), - ) return DoctorCheck("filigree.auth", "ok", message="filigree daemon not reachable; token not verified") if probe.accepted: return DoctorCheck("filigree.auth", "ok") @@ -520,12 +663,15 @@ def machine_readable_doctor( *, fix: bool = False, filigree_url: str | None = None, + filigree_url_source: str | None = None, loomweave_url: str | None = None, + loomweave_url_source: str | None = None, transport: Transport | None = None, ) -> dict[str, Any]: """Return the shared machine-readable doctor shape, optionally repairing install bindings.""" before = {check.name: check for check in check_install(root)} config_missing_before = not weft_config_path(root).exists() + proj = paths.project_root_for(root) # snapshot BEFORE repair_install plants weft.toml at literal root bindings_fixed = False if fix: repair_install(root) @@ -535,18 +681,36 @@ def machine_readable_doctor( # project scope, so the post-repair value is the URL the agent will actually emit # to — and the one whose auth the filigree-auth check should probe. Without fix, # repair is a no-op and this is just the recorded emit target. - probe_url = _resolve_probe_url(root, filigree_url) + probe_target = _resolve_probe_target(root, filigree_url) checks: list[DoctorCheck] = [] checks.append(_check_config(root, fixed=fix and config_missing_before and weft_config_path(root).exists())) checks.append(_check_mcp_registration(root, before=before)) checks.append(_check_marker_package()) - checks.append(_check_url(root, "loomweave", fixed=bindings_fixed, effective_url=loomweave_url)) - checks.append(_check_url(root, "filigree", fixed=bindings_fixed, effective_url=filigree_url)) + checks.append( + _check_url( + root, + "loomweave", + fixed=bindings_fixed, + effective_url=loomweave_url, + effective_url_source=loomweave_url_source, + ) + ) + checks.append( + _check_url( + root, + "filigree", + fixed=bindings_fixed, + effective_url=filigree_url, + effective_url_source=filigree_url_source, + ) + ) checks.append(_check_decorator_grammar()) checks.append(_check_scan_output_path(root)) checks.append(_check_auth_token(root)) - checks.append(_check_filigree_auth(root, repair=fix, filigree_url=probe_url, transport=transport)) + checks.append(_check_filigree_auth(root, repair=fix, probe_target=probe_target, transport=transport)) + checks.append(_check_gitignore(proj, fix=fix)) + checks.append(_sweep_stray_artifacts(proj, fix=fix)) next_actions = [f"{check.id}: {check.message}" for check in checks if not check.ok and check.message] return { diff --git a/src/wardline/install/mcp_json.py b/src/wardline/install/mcp_json.py index f1374112..2aad2f0b 100644 --- a/src/wardline/install/mcp_json.py +++ b/src/wardline/install/mcp_json.py @@ -12,8 +12,6 @@ from urllib.parse import urlsplit from wardline.core.config import ( - _filigree_published_url, - _loomweave_published_url, filigree_server_scoped_url, ) from wardline.core.errors import WardlineError @@ -41,16 +39,12 @@ def _local_mcp_entry() -> dict[str, object]: return {"type": "stdio", "command": _find_wardline_command(), "args": ["mcp", "--root", "."]} -# Operator-pinned sibling-endpoint flags, reconciled on repair (see -# _desired_sibling_url): -# - a NON-loopback (remote) pin is the operator's deliberate endpoint — preserved. -# - a loopback pin that a live published-port rung can reconstruct is DROPPED, so -# runtime discovery owns the always-current port. A frozen pin to a rotated-away -# port otherwise shadows the live daemon (the legacy .filigree/ephemeral.port rung -# outliving a port rotation is the canonical way this happens). Filigree SERVER -# mode is the exception: an unscoped write fail-closes under a multi-project -# daemon, so a loopback pin is repaired to the live project scope rather than -# dropped. +# Sibling-endpoint flags found in project `.mcp.json`, reconciled on repair (see +# _desired_sibling_url). The file is repository-controlled input, so remote/non-loopback +# pins are not trusted as operator intent and are dropped during canonicalization. +# - a loopback pin is preserved because repair cannot prove a sibling daemon is +# live from repository-owned `.weft//ephemeral.port` alone, unless +# Filigree SERVER mode provides a scoped target from Filigree's home registry. # - a loopback pin with no live daemon is preserved verbatim (cannot be improved). _PRESERVED_ARG_FLAGS = ("--filigree-url", "--loomweave-url") @@ -81,9 +75,10 @@ def _flag_pairs(entry: object) -> list[tuple[str, str]]: def _same_scope_target(a: str, b: str) -> bool: """True when two URLs name the same Filigree write target up to loopback host - spelling — identical port and path (the scope-bearing parts). Lets an already- - correct entry that merely spells the host ``127.0.0.1`` (vs our ``localhost``) be - recognised as correct and preserved verbatim, rather than churned every repair.""" + spelling — identical scheme, port, path, and query (the scope-bearing parts). + Lets an already-correct entry that merely spells the host ``127.0.0.1`` (vs + our ``localhost``) be recognised as correct and preserved verbatim, rather + than churned every repair.""" try: pa, pb = urlsplit(a), urlsplit(b) # .port lazily parses the authority; a malformed literal (http://localhost:notaport) @@ -92,48 +87,47 @@ def _same_scope_target(a: str, b: str) -> bool: a_port, b_port = pa.port, pb.port except ValueError: return False + if pa.scheme.lower() != pb.scheme.lower(): + return False if pa.hostname not in _LOOPBACK_HOSTS or pb.hostname not in _LOOPBACK_HOSTS: return False - return (a_port, pa.path) == (b_port, pb.path) + return (a_port, pa.path, pa.query) == (b_port, pb.path, pb.query) def _is_loopback_url(value: str) -> bool: """True when *value* is a loopback HTTP URL (a default-shaped, locally-rebuildable - target). A non-loopback host is an operator's deliberate remote endpoint and is - never rewritten.""" + target). Non-loopback project-config endpoints are not treated as trusted repair + input.""" try: - host = urlsplit(value).hostname + parsed = urlsplit(value) + # Parse the port inside the guard too; a malformed loopback URL is not a + # trustworthy operator pin and should be repaired or dropped, not preserved. + _port = parsed.port except ValueError: return False - return host in _LOOPBACK_HOSTS + return parsed.scheme.lower() in {"http", "https"} and parsed.hostname in _LOOPBACK_HOSTS def _desired_sibling_url(flag: str, existing: str | None, root: Path) -> str | None: """The value to write for *flag* (``--filigree-url`` / ``--loomweave-url``), or ``None`` to DROP the flag entirely. - A non-loopback (remote) pin is the operator's deliberate endpoint and is always - preserved. A loopback pin is a locally-rebuildable target: when a live published - port exists for that sibling the pin is redundant-or-stale, so it is dropped and - runtime published-port discovery owns the (always-current) port — except in - Filigree server mode, where an unscoped write fail-closes (N1) and the pin is - repaired to the live project scope instead. With no live daemon the pin is left - verbatim (we cannot improve on it). A fresh entry (no pin) only gains a flag in - Filigree server mode, where the scoped target must be baked.""" + Project `.mcp.json` is repo-controlled input. Non-loopback pins found there are + dropped, not preserved, because repair cannot distinguish operator intent from a + committed exfil endpoint. A loopback pin is preserved verbatim unless Filigree + server mode supplies a home-registry-scoped target: repository-owned published + port files are not live/identity proof, so they are not enough to delete or replace + an explicit local pin. A fresh entry (no pin) only gains a flag in Filigree server + mode, where the scoped target must be baked.""" if existing is not None and not _is_loopback_url(existing): - return existing # remote endpoint — operator's deliberate choice, never touched + existing = None # untrusted remote pin from repo config; treat as absent if flag == "--filigree-url": scope = filigree_server_scoped_url(root) if scope is not None: if existing is None: return scope # fresh server-mode install lands a working scoped target return existing if _same_scope_target(existing, scope) else scope - published: str | None = _filigree_published_url(root) - else: - published = _loomweave_published_url(root) - if existing is None: - return None # per-project discovery owns the port; never bake a loopback pin - return None if published is not None else existing + return existing def _desired_sibling_args(entry: object, root: Path) -> list[str]: @@ -177,12 +171,12 @@ def _desired_local_entry(existing: object, root: Path) -> dict[str, object]: def merge_mcp_entry(root: Path) -> str: """Add/replace the `wardline` entry under mcpServers. Returns created|updated|unchanged. - An existing entry's operator-pinned ``--loomweave-url`` and (remote) - ``--filigree-url`` args are preserved (they are the runtime emit/discovery target - when the published-port rung cannot reconstruct them). When Filigree runs in - server mode for *root*, a default-shaped (loopback) or absent ``--filigree-url`` is - set/repaired to the live project scope so a fresh install lands a working, - fail-close-safe emit target out of the box.""" + Existing sibling URL args are reconciled from repository-controlled `.mcp.json` + input: remote/non-loopback values are dropped, and loopback values are preserved + unless Filigree server mode supplies a scoped home-registry target. When Filigree + runs in server mode for *root*, a default-shaped (loopback) or absent + ``--filigree-url`` is set/repaired to the live project scope so a fresh install + lands a working, fail-close-safe emit target out of the box.""" path = safe_project_file(root, root / ".mcp.json", label=".mcp.json") if not path.exists(): payload = {"mcpServers": {"wardline": _desired_local_entry(None, root)}} diff --git a/src/wardline/loomweave/client.py b/src/wardline/loomweave/client.py index 0f3cb10f..494d2faa 100644 --- a/src/wardline/loomweave/client.py +++ b/src/wardline/loomweave/client.py @@ -19,13 +19,12 @@ import secrets import urllib.error import urllib.parse -import urllib.request from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass from typing import Any, Protocol from wardline.core.errors import LoomweaveError -from wardline.core.http import read_response_text +from wardline.core.http import WeftHttp from wardline.loomweave._hmac import sign_request logger = logging.getLogger(__name__) @@ -48,20 +47,22 @@ def request(self, method: str, url: str, body: bytes, headers: Mapping[str, str] class UrllibTransport: def __init__(self, timeout: float = 30.0) -> None: - self._timeout = timeout + # WeftHttp keeps this client's exact scheme-error wording (--loomweave-url) and the + # HTTPError -> Response (status preserved) conversion. URLError/OSError still + # propagate to _send(), which fail-softs (outage -> None). An empty body is sent as + # data=None (no request body) exactly as before — converted at the call site below. + self._http = WeftHttp( + timeout=timeout, + allowed_schemes=_ALLOWED_SCHEMES, + scheme_error=lambda scheme, url: LoomweaveError( + f"--loomweave-url must use http or https; got scheme {scheme!r} in {url!r}" + ), + ) def request(self, method: str, url: str, body: bytes, headers: Mapping[str, str]) -> Response: - scheme = urllib.parse.urlsplit(url).scheme.lower() - if scheme not in _ALLOWED_SCHEMES: - raise LoomweaveError(f"--loomweave-url must use http or https; got scheme {scheme!r} in {url!r}") data = body if body else None - req = urllib.request.Request(url, data=data, headers=dict(headers), method=method) - try: - with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310 - return Response(status=resp.status, body=read_response_text(resp)) - except urllib.error.HTTPError as exc: - with exc: - return Response(status=exc.code, body=read_response_text(exc)) + result = self._http.fetch(method, url, body=data, headers=headers) + return Response(status=result.status, body=result.body) @dataclass(frozen=True, slots=True) diff --git a/src/wardline/mcp/server.py b/src/wardline/mcp/server.py index 0b298a99..7ac914aa 100644 --- a/src/wardline/mcp/server.py +++ b/src/wardline/mcp/server.py @@ -23,7 +23,18 @@ from wardline.core.delta_scope import ScopeParseError, load_affected_scope, parse_affected_scope from wardline.core.errors import WardlineError from wardline.core.explain import explain_taint_result, explanation_from_context, explanation_to_dict -from wardline.core.filigree_emit import FiligreeEmitter, filigree_destination, filigree_disabled_reason +from wardline.core.federation_status import ( + SCAN_FILE_FINDINGS_FILIGREE_EMIT_SCHEMA, + filigree_emit_status_from_block, + filigree_emit_status_schema, + loomweave_write_status_from_block, + loomweave_write_status_schema, +) +from wardline.core.filigree_emit import ( + FiligreeEmitter, + filigree_destination, + redact_url_for_diagnostics, +) from wardline.core.finding import Finding, Severity from wardline.core.finding_query import filter_findings from wardline.core.judge_run import run_judge @@ -118,44 +129,20 @@ def _emit_filigree( "status": er.status, "auth_rejected": er.auth_rejected, "token_sent": er.token_sent, - "url": er.url, + "url": redact_url_for_diagnostics(er.url), # N1 / C-10(a): name where findings went so a wrong-project write is visible. "destination": filigree_destination(er.url), } def _filigree_emit_status(block: dict[str, Any] | None) -> dict[str, Any]: - if block is None: - return { - "configured": False, - "reachable": None, - "created": 0, - "updated": 0, - "failed": 0, - "failures": [], - "warnings": [], - "disabled_reason": "not configured", - "destination": filigree_destination(None), - } - disabled_reason = filigree_disabled_reason( - reachable=bool(block.get("reachable")), - status=block.get("status"), - token_sent=bool(block.get("token_sent")), - url=block.get("url"), - ) - return {"configured": True, "disabled_reason": disabled_reason, **block} + # Canonical builder (core/federation_status): the MCP block-based shape, carrying the + # discriminated transport detail and disabled_reason at position two. + return filigree_emit_status_from_block(block) def _loomweave_write_status(block: dict[str, Any] | None) -> dict[str, Any]: - if block is None: - return { - "configured": False, - "reachable": None, - "written": 0, - "unresolved_qualnames": [], - "disabled_reason": "not configured", - } - return {"configured": True, **block} + return loomweave_write_status_from_block(block) def _file_finding(args: dict[str, Any], root: Path, filer: Any, loomweave: Any = None) -> dict[str, Any]: @@ -406,91 +393,9 @@ def _scan_file_findings( "required": ["tripped", "fail_on", "exit_class", "verdict", "would_trip_at"], "additionalProperties": False, }, - "filigree_emit": { - "type": "object", - "description": "Outcome of bulk-emitting scan findings to Filigree (runs only when findings were selected " - "and an emitter is configured).", - "properties": { - "configured": { - "type": "boolean", - "description": "Whether a Filigree emitter is configured for this server.", - }, - "reachable": { - "type": ["boolean", "null"], - "description": "Whether Filigree was reachable for the emit; null when no emit was attempted.", - }, - "created": {"type": "integer", "description": "Findings newly created in Filigree."}, - "updated": {"type": "integer", "description": "Findings updated in Filigree."}, - "failed": { - "type": "integer", - "description": "Count of findings that did NOT land in Filigree (derived from `failures`). " - "0 here is earned from real per-finding records, not assumed — see `failures` for which and why.", - }, - "failures": { - "type": "array", - "description": "PDR-0023 honesty surface: one record per finding that failed to land, so a " - "PARTIAL ingest ('M of N emitted, K rejected because R') is distinguishable from a clean emit " - "('all N emitted'). Empty on a clean run — but earned, not hardwired.", - "items": { - "type": "object", - "properties": { - "reason": { - "type": "string", - "enum": ["rejected", "validation_error", "scheme_mismatch", "partial"], - "description": "Machine-readable failure case: rejected (Filigree refused this " - "finding), validation_error (malformed body), scheme_mismatch (fingerprint-scheme " - "drift — a join-miss, not a true-negative), partial (the whole chunk was rejected at " - "the protocol layer, so the cause is the request not the body).", - }, - "detail": {"type": "string", "description": "Filigree's per-finding reject explanation."}, - "reason_class": { - "type": "string", - "enum": ["rejected", "scheme_mismatch", "partial"], - "description": "weft-reason (G1): the canonical reason_class this failure maps to " - "(one of the closed 11 in contracts/weft-reason-vocab.json). validation_error maps to " - "rejected; the domain term stays in `reason`/`cause`.", - }, - "cause": { - "type": "string", - "description": "weft-reason carrier `cause`: the why (Filigree's detail, else the " - "domain reason). Always present on a failure (a failure is never clean).", - }, - "fix": { - "type": "string", - "description": "weft-reason carrier `fix` (MANDATORY on a non-clean carrier): the " - "remedial action.", - }, - "fingerprint": { - "type": "string", - "description": "The wardline join key for the failed finding (absent when the " - "failure is chunk-wide and not attributable to one finding).", - }, - }, - "required": ["reason", "detail", "reason_class", "cause", "fix"], - "additionalProperties": False, - }, - }, - "warnings": {"type": "array", "items": {"type": "string"}, "description": "Non-fatal emit warnings."}, - "disabled_reason": { - "type": ["string", "null"], - "description": "Why the emit failed soft — the discriminated 401/403-vs-5xx-vs-transport " - "ladder ('not configured', 'filigree rejected the token (401)...', 'filigree unreachable'). " - "null means success OR no emit was attempted (dry-run / nothing selected) — read `reachable` " - "to tell them apart (null = no attempt).", - }, - }, - "required": [ - "configured", - "reachable", - "created", - "updated", - "failed", - "failures", - "warnings", - "disabled_reason", - ], - "additionalProperties": False, - }, + # ONE schema source (core/federation_status): the no-destination, no-transport-detail + # variant for the self-contained scan_file_findings schema (no $defs to $ref). + "filigree_emit": SCAN_FILE_FINDINGS_FILIGREE_EMIT_SCHEMA, "active_defects": { "type": "array", "description": "Every active (non-suppressed) defect in the scan, each with its per-finding promotion and " @@ -791,20 +696,32 @@ def _scan_job_request(args: dict[str, Any], root: Path, filigree_url: str | None } +def _redact_scan_job_status(status: dict[str, Any]) -> dict[str, Any]: + redacted = dict(status) + request = redacted.get("request") + if isinstance(request, dict): + safe_request = dict(request) + url = safe_request.get("filigree_url") + if isinstance(url, str): + safe_request["filigree_url"] = redact_url_for_diagnostics(url) + redacted["request"] = safe_request + return redacted + + def _scan_job_start(args: dict[str, Any], root: Path, filigree_url: str | None = None) -> dict[str, Any]: path = _path_arg(args, root) request = _scan_job_request(args, root, filigree_url) - return start_scan_job(path, request) + return _redact_scan_job_status(start_scan_job(path, request)) def _scan_job_status(args: dict[str, Any], root: Path) -> dict[str, Any]: job_id = str(_require(args, "job_id")) - return read_scan_job_status(_path_arg(args, root), job_id) + return _redact_scan_job_status(read_scan_job_status(_path_arg(args, root), job_id)) def _scan_job_cancel(args: dict[str, Any], root: Path) -> dict[str, Any]: job_id = str(_require(args, "job_id")) - return cancel_scan_job(_path_arg(args, root), job_id) + return _redact_scan_job_status(cancel_scan_job(_path_arg(args, root), job_id)) def _scan( @@ -1677,61 +1594,10 @@ def _scan( "additionalProperties": False, }, }, - "filigree_emit_status": { - "type": "object", - "description": "Normalized Filigree emit status (always an object; configured:false when no emitter).", - "properties": { - "configured": {"type": "boolean"}, - "reachable": {"type": ["boolean", "null"], "description": "null when not configured."}, - "created": {"type": "integer"}, - "updated": {"type": "integer"}, - "failed": { - "type": "integer", - "description": "Count of un-ingested findings (derived from `failures`); 0 is earned, not assumed.", - }, - "failures": {"$ref": "#/$defs/filigree_emit_failures"}, - "warnings": {"type": "array", "items": {"type": "string"}}, - "disabled_reason": { - "type": ["string", "null"], - "description": "Actionable reason (auth-rejected vs server error vs unreachable vs not " - "configured), or null when reached.", - }, - "destination": {"$ref": "#/$defs/filigree_destination"}, - "status": { - "type": ["integer", "null"], - "description": "HTTP error status for soft failures; absent when not configured.", - }, - "auth_rejected": {"type": "boolean", "description": "Absent when not configured."}, - "token_sent": {"type": "boolean", "description": "Absent when not configured."}, - "url": {"type": ["string", "null"], "description": "Absent when not configured."}, - }, - "required": [ - "configured", - "reachable", - "created", - "updated", - "failed", - "failures", - "warnings", - "disabled_reason", - "destination", - ], - "additionalProperties": False, - }, - "loomweave_write_status": { - "type": "object", - "description": "Normalized Loomweave taint-fact write status (always an object; configured:false when no " - "client).", - "properties": { - "configured": {"type": "boolean"}, - "reachable": {"type": ["boolean", "null"], "description": "null when not configured."}, - "written": {"type": "integer"}, - "unresolved_qualnames": {"type": "array", "items": {"type": "string"}}, - "disabled_reason": {"type": ["string", "null"]}, - }, - "required": ["configured", "reachable", "written", "unresolved_qualnames", "disabled_reason"], - "additionalProperties": False, - }, + # ONE schema source (core/federation_status): the canonical, transport-detailed + # filigree_emit + loomweave_write $defs every scan-output $ref resolves to. + "filigree_emit_status": filigree_emit_status_schema(include_transport_detail=True), + "loomweave_write_status": loomweave_write_status_schema(), "location": { "type": "object", "properties": { @@ -2045,11 +1911,15 @@ def _scan( "output_schema": _SCAN_OUTPUT_SCHEMA, "annotations": { "title": "Trust-boundary scan", - "readOnlyHint": True, + # A bare local scan reads the checkout, but configured Filigree/Loomweave + # integrations can add outbound writes at call time. Advertise the + # conservative superset so MCP clients do not auto-run it as read-only. + "readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, - "openWorldHint": False, + "openWorldHint": True, }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE, ToolCapability.NETWORK}), } @@ -3965,7 +3835,9 @@ def _doctor( *, started_at: float, filigree_url: str | None = None, + filigree_url_source: str | None = None, loomweave_url: str | None = None, + loomweave_url_source: str | None = None, ) -> dict[str, Any]: """The CLI `doctor --fix` envelope over MCP (A2, wardline-2ee1bbda82's sibling): install/federation health checks via the SAME machine_readable_doctor builder, @@ -3982,7 +3854,22 @@ def _doctor( flag = args.get("filigree_url") if flag is not None and not isinstance(flag, str): raise ToolError("filigree_url must be a string") - payload = machine_readable_doctor(root, fix=repair, filigree_url=flag or filigree_url, loomweave_url=loomweave_url) + payload = machine_readable_doctor( + root, + fix=repair, + filigree_url=filigree_url, + filigree_url_source=filigree_url_source, + loomweave_url=loomweave_url, + loomweave_url_source=loomweave_url_source, + ) + if flag is not None: + message = ( + "caller-supplied filigree_url is not accepted over MCP; configure the " + "wardline MCP server launch flag or WARDLINE_FILIGREE_URL instead" + ) + payload["checks"].append({"id": "doctor.filigree_url", "status": "error", "fixed": False, "message": message}) + payload["ok"] = False + payload["next_actions"].append(f"doctor.filigree_url: {message}") return attach_server_identity(payload, root=root, started_at=started_at) @@ -3999,8 +3886,8 @@ def _doctor( "checks": { "type": "array", "description": "Uniform health-check verdicts: wardline.config, mcp.registration, marker_package, " - "loomweave.url, filigree.url, decorator_grammar, scan.output_path, auth.token, filigree.auth, then " - "server.freshness last.", + "loomweave.url, filigree.url, decorator_grammar, scan.output_path, auth.token, filigree.auth, " + "optionally doctor.filigree_url for rejected MCP caller input, then server.freshness last.", "items": { "type": "object", "properties": { @@ -4081,7 +3968,9 @@ def _doctor( "long-lived server predates the on-disk wardline code — its results are " "stale; restart the MCP server. Read-only by default; `repair: true` " "(write-gated) repairs install artifacts and re-pins a rejected " - "federation token.", + "federation token. With repair: true it also deletes stray " + "wardline-managed scan artifacts (timestamped files inside .wardline/ " + "dirs) under the project root.", "input_schema": { "type": "object", "properties": { @@ -4090,13 +3979,14 @@ def _doctor( "description": "Default false (pure probe, writes nothing). true repairs " "install artifacts (CLAUDE.md/AGENTS.md blocks, .claude/.agents skills, " ".mcp.json + Codex registration, .weft state dir) and, when Filigree " - "rejected the emit token, re-pins the accepted local mint in .env.", + "rejected the emit token, re-pins the accepted local mint in .env. " + "Also sweeps stray managed scan artifacts under the project root.", }, "filigree_url": { "type": "string", - "description": "Filigree URL to probe for emit auth (default: the server's " - "configured URL, then WARDLINE_FILIGREE_URL, then the .mcp.json arg). " - "Only loopback origins are ever probed with a token.", + "description": "Deprecated and rejected over MCP: caller-supplied probe URLs " + "are not trusted with Filigree credentials. Configure the server launch " + "flag, WARDLINE_FILIGREE_URL, or the project .mcp.json entry instead.", }, }, }, @@ -4104,13 +3994,24 @@ def _doctor( "annotations": { "title": "Install and federation health check", "readOnlyHint": False, - "destructiveHint": False, + "destructiveHint": True, "idempotentHint": True, "openWorldHint": False, }, } +def _rekey_collision_wire(collision: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "new_fp": collision.new_fp, + "old_fps": list(collision.old_fps), + "message": collision.message, + } + if getattr(collision, "new_fps", ()): + payload["new_fps"] = list(collision.new_fps) + return payload + + def _rekey(args: dict[str, Any], root: Path, filigree: Any = None) -> dict[str, Any]: """Fingerprint-scheme migration over MCP (A3): the same core.rekey the CLI drives — no second migration path. Probe-by-default (read-only: report match/orphans/ @@ -4145,9 +4046,7 @@ def journal_block(journal: Journal) -> dict[str, Any]: "fingerprint_scheme_to": journal.fingerprint_scheme_to, "snapshot_prescheme": journal.snapshot_prescheme, "orphan_cause": ORPHAN_CAUSE, - "collisions": [ - {"new_fp": c.new_fp, "old_fps": list(c.old_fps), "message": c.message} for c in journal.collisions - ], + "collisions": [_rekey_collision_wire(c) for c in journal.collisions], "legs": [ { "name": leg.name, @@ -4211,9 +4110,7 @@ def journal_block(journal: Journal) -> dict[str, Any]: "stale_count": len(report.stale), "stale_sample": list(report.stale[:ORPHAN_SAMPLE_LIMIT]), "stale_cause": STALE_CAUSE, - "collisions": [ - {"new_fp": c.new_fp, "old_fps": list(c.old_fps), "message": c.message} for c in report.collisions - ], + "collisions": [_rekey_collision_wire(c) for c in report.collisions], "per_store": dict(report.per_store), "prescheme": report.prescheme, "current_scheme_stores": list(report.current_scheme_stores), @@ -4278,24 +4175,33 @@ def journal_block(journal: Journal) -> dict[str, Any]: }, "collisions": { "type": "array", - "description": "probe/apply/resume: pre-rekey fingerprints that collapse to the same new fingerprint " - "under the new scheme; all involved old fingerprints are orphaned, not carried.", + "description": "probe/apply/resume: ambiguous rekey mappings. Either multiple pre-rekey fingerprints " + "collapse to one new fingerprint, or one pre-rekey fingerprint fans out to multiple new fingerprints. " + "All involved old fingerprints are orphaned, not carried.", "items": { "type": "object", "properties": { "new_fp": { - "type": "string", - "description": "The new-scheme fingerprint that more than one old fingerprint maps onto.", + "type": ["string", "null"], + "description": "Collapse: the new-scheme fingerprint that more than one old fingerprint maps " + "onto. Fan-out: null, with new_fps carrying the candidate new fingerprints.", + }, + "new_fps": { + "type": "array", + "items": {"type": "string"}, + "description": "Fan-out only: the candidate new-scheme fingerprints one old fingerprint maps " + "onto. Omitted for collapse collisions.", }, "old_fps": { "type": "array", "items": {"type": "string"}, - "description": "The colliding pre-rekey fingerprints (sorted).", + "description": "The ambiguous pre-rekey fingerprints (sorted). Fan-out entries contain one old " + "fingerprint.", }, "message": { "type": "string", - "description": "WLN-ENGINE-FINGERPRINT-COLLISION diagnostic explaining that the colliding " - "verdicts are orphaned.", + "description": "WLN-ENGINE-FINGERPRINT-COLLISION or WLN-ENGINE-FINGERPRINT-FANOUT diagnostic " + "explaining that the verdicts are orphaned.", }, }, "required": ["new_fp", "old_fps", "message"], @@ -4555,13 +4461,17 @@ def __init__( *, root: Path, loomweave_url: str | None = None, + loomweave_url_source: str | None = None, filigree_url: str | None = None, + filigree_url_source: str | None = None, allow_write: bool = True, allow_network: bool = True, ) -> None: self.root = Path(root) self.loomweave_url = loomweave_url + self.loomweave_url_source = loomweave_url_source self.filigree_url = filigree_url + self.filigree_url_source = filigree_url_source # Recorded once at construction: the doctor tool's freshness verdict compares # on-disk source mtimes against this to expose a stale long-lived server. self.started_at = time.time() @@ -4782,9 +4692,10 @@ def _register_tools(self) -> None: handler=lambda args, root: _waiver_add( args, root, - # An entity_symbol needs Loomweave to resolve; an opaque entity_id does - # not. Build the client only when a symbol is present (None otherwise). - self._loomweave_client(_cfg(args, root)) if args.get("entity_symbol") else None, + # entity_id wins over entity_symbol; only L2 symbol binding needs Loomweave. + self._loomweave_client(_cfg(args, root)) + if args.get("entity_symbol") and not args.get("entity_id") + else None, ), ) ) @@ -4802,7 +4713,9 @@ def _register_tools(self) -> None: root, started_at=self.started_at, filigree_url=self.filigree_url, + filigree_url_source=self.filigree_url_source, loomweave_url=self.loomweave_url, + loomweave_url_source=self.loomweave_url_source, ), ) ) @@ -4875,7 +4788,10 @@ def _resolved_filigree_url_for_policy(self, arguments: dict[str, Any]) -> str | ) def _effective_tool_capabilities(self, tool: Tool, arguments: dict[str, Any]) -> frozenset[ToolCapability]: - capabilities = set(tool.capabilities) + # ``scan`` advertises the conservative possible-effects superset in + # tools/list, but hardened runtime policy should still allow a purely + # local scan when no integration URL resolves. + capabilities = {ToolCapability.READ} if tool.name == "scan" else set(tool.capabilities) if tool.name == "scan" and ( self._resolved_loomweave_url_for_policy(arguments) is not None or self._resolved_filigree_url_for_policy(arguments) is not None @@ -4899,18 +4815,25 @@ def _effective_tool_capabilities(self, tool: Tool, arguments: dict[str, Any]) -> capabilities.add(ToolCapability.NETWORK) if tool.name == "judge" and bool(arguments.get("write", False)): capabilities.add(ToolCapability.WRITE) + if ( + tool.name == "waiver_add" + and bool(arguments.get("entity_symbol")) + and not bool(arguments.get("entity_id")) + and self._resolved_loomweave_url_for_policy(arguments) is not None + ): + capabilities.add(ToolCapability.NETWORK) if tool.name == "doctor": if bool(arguments.get("repair", False)): capabilities.add(ToolCapability.WRITE) - from wardline.install.doctor import _resolve_probe_url + from wardline.install.doctor import _filigree_auth_probe_would_network - flag = arguments.get("filigree_url") - probe_url = flag if isinstance(flag, str) and flag else None - if _resolve_probe_url(self.root, probe_url or self.filigree_url) is not None: + if _filigree_auth_probe_would_network(self.root, self.filigree_url): # The filigree-auth probe will touch the (loopback-only) network. capabilities.add(ToolCapability.NETWORK) if tool.name == "rekey": - if any(bool(arguments.get(k, False)) for k in ("apply", "resume", "rollback")): + if bool(arguments.get("cache_dir")) or any( + bool(arguments.get(k, False)) for k in ("apply", "resume", "rollback") + ): capabilities.add(ToolCapability.WRITE) if bool(arguments.get("apply", False)) and self._resolved_filigree_url_for_policy(arguments) is not None: # apply's last leg re-emits the rekeyed findings to Filigree. diff --git a/src/wardline/mcp/tooling.py b/src/wardline/mcp/tooling.py index cf87b57b..ad7c4735 100644 --- a/src/wardline/mcp/tooling.py +++ b/src/wardline/mcp/tooling.py @@ -54,13 +54,11 @@ class Tool: # B1/B2 (wardline-47ff226ebe / wardline-e63204176b): MCP rev 2025-06-18 structured # output + 2025-03-26 display metadata. ``annotations`` is the standard MCP # ToolAnnotations object (title, readOnlyHint, destructiveHint, idempotentHint, - # openWorldHint). CONVENTION: the hints describe the tool's integration-free - # baseline posture — readOnlyHint mirrors the DECLARED capability set, and - # openWorldHint mirrors the declared NETWORK capability. Opt-in federation reach - # (a configured Filigree/Loomweave URL widening scan/dossier/attest at runtime) - # is deliberately NOT reflected here: hints are static, untrusted UX advisories, - # and ToolPolicy + _effective_tool_capabilities remain the enforcement authority - # over what a call may actually do. + # openWorldHint). CONVENTION: the public hints and legacy ``capabilities`` entry may + # conservatively describe possible integration side effects (for example ``scan`` can + # write to Filigree/Loomweave when those URLs resolve). ToolPolicy must enforce the + # actual per-call capability set from _effective_tool_capabilities, not assume this + # static advertisement is the whole runtime truth. title: str | None = None output_schema: dict[str, Any] | None = None annotations: dict[str, Any] | None = None diff --git a/src/wardline/rust/analyzer.py b/src/wardline/rust/analyzer.py index 3490ef14..475d0a8a 100644 --- a/src/wardline/rust/analyzer.py +++ b/src/wardline/rust/analyzer.py @@ -29,7 +29,7 @@ from wardline.rust.context import RustAnalysisContext, RustTriggerContext from wardline.rust.crate_roots import CrateRoots, discover_crate_roots from wardline.rust.dataflow import analyze_command_dataflow -from wardline.rust.index import index_entities +from wardline.rust.index import RustEntity, index_entities from wardline.rust.mounts import MountOverlay, build_mount_overlay from wardline.rust.nodeid import mint_node_ids from wardline.rust.parse import has_errors, parse_rust @@ -103,7 +103,7 @@ def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) sources[file] = file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as exc: read_errors[file] = str(exc) - overlays = _build_overlays(sources, resolved_root, crate_roots) + overlays, overlay_errors = _build_overlays(sources, resolved_root, crate_roots) findings: list[Finding] = [] functions_total = 0 functions_declared = 0 @@ -113,6 +113,9 @@ def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) if file in read_errors: findings.append(_parse_error_finding(relpath, read_errors[file])) continue + if file in overlay_errors: + findings.append(_file_failed_finding(relpath, overlay_errors[file])) + continue source = sources[file] tree = parse_rust(source) if has_errors(tree): @@ -164,17 +167,18 @@ def _analyze_tree(self, tree: Tree, *, module: str, path: str) -> tuple[list[Fin project_taints: dict[str, TaintState] = {} triggers: list[RustTriggerContext] = [] + diagnostics: list[Finding] = [] for entity in callables: # Phase 1b: the index emits the full ten-kind surface; the taint path # judges CALLABLES only (a module/struct/const has no body to seed or # walk — feeding one to taint_for/dataflow would be a category error). try: seed = self._provider.taint_for(entity.node) - except ValueError: - # A typo'd @trusted marker must not abort the scan: fail closed for this fn - # (its findings suppressed). NOTE: a typo is currently swallowed silently — - # surfacing it as an operator-visible diagnostic FACT is tracked backlog - # (rust-bug-hunt-2026-06-09), not yet built. + except ValueError as exc: + # A typo'd @trusted marker must not abort the scan, but it also must not + # silently suppress that fn's findings into a false green. Keep the fn's + # fail-closed tier and emit a gate-eligible diagnostic at the bad callable. + diagnostics.append(_invalid_trust_marker_finding(entity, path, str(exc))) seed = None tier = seed.body_taint if seed is not None else _FAIL_CLOSED project_taints[entity.qualname] = tier @@ -206,7 +210,7 @@ def _analyze_tree(self, tree: Tree, *, module: str, path: str) -> tuple[list[Fin # ACROSS kinds; the id's kind segment is what separates them). entities={q.entity_id(e.kind, e.qualname): e for e in entities}, ) - findings: list[Finding] = [] + findings: list[Finding] = list(diagnostics) for rule in self._rules: findings.extend(rule.check(context)) return findings, context, len(callables) @@ -219,7 +223,11 @@ def _relpath(file: Path, resolved_root: Path) -> str: return resolved.as_posix() -def _build_overlays(sources: dict[Path, str], resolved_root: Path, roots: CrateRoots) -> dict[Path, MountOverlay]: +def _build_overlays( + sources: dict[Path, str], + resolved_root: Path, + roots: CrateRoots, +) -> tuple[dict[Path, MountOverlay], dict[Path, str]]: """One ``#[path]`` mount overlay per crate (ADR-049 Amendment 8), discovered over the scanned IN-SRC sources of that crate (class-2/3 files keep their ``#out`` non-conformance routes — a mount declared outside ``src/`` is outside loomweave's @@ -228,6 +236,7 @@ def _build_overlays(sources: dict[Path, str], resolved_root: Path, roots: CrateR root"). A mount declared in a file outside the scan list is invisible — the overlay is the view of the scanned tree.""" per_crate: dict[Path, tuple[str, dict[str, str]]] = {} + rel_paths: dict[str, Path] = {} for file, source in sources.items(): resolved = file.resolve() crate_dir = roots.crate_dir_for(resolved) @@ -236,15 +245,25 @@ def _build_overlays(sources: dict[Path, str], resolved_root: Path, roots: CrateR continue if not resolved.is_relative_to(resolved_root): continue # defensive: discover confines to root - per_crate.setdefault(crate_dir, (crate_name, {}))[1][resolved.relative_to(resolved_root).as_posix()] = source - return { - crate_dir: build_mount_overlay( + rel = resolved.relative_to(resolved_root).as_posix() + rel_paths[rel] = file + per_crate.setdefault(crate_dir, (crate_name, {}))[1][rel] = source + overlay_errors: dict[Path, str] = {} + overlays: dict[Path, MountOverlay] = {} + for crate_dir, (crate_name, crate_sources) in per_crate.items(): + + def record_overlay_error(relpath: str, exc: Exception) -> None: + source_path = rel_paths.get(relpath) + if source_path is not None: + overlay_errors[source_path] = f"{type(exc).__name__}: {exc}" + + overlays[crate_dir] = build_mount_overlay( crate_sources, crate=crate_name, src_root=(crate_dir / "src").relative_to(resolved_root).as_posix(), + error_callback=record_overlay_error, ) - for crate_dir, (crate_name, crate_sources) in per_crate.items() - } + return overlays, overlay_errors def _module_for(file: Path, resolved_root: Path, roots: CrateRoots, overlays: dict[Path, MountOverlay]) -> str: @@ -320,6 +339,21 @@ def _file_failed_finding(relpath: str, detail: str) -> Finding: return _engine_defect("WLN-ENGINE-FILE-FAILED", f"{relpath}: Rust analysis failed ({detail})", relpath) +def _invalid_trust_marker_finding(entity: RustEntity, relpath: str, detail: str) -> Finding: + rule_id = "WLN-ENGINE-RUST-INVALID-TRUST-MARKER" + line = entity.location.line_start or 1 + return Finding( + rule_id=rule_id, + message=f"{relpath}:{line}: invalid Rust @trusted marker on {entity.qualname} ({detail})", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=relpath, line_start=line, line_end=line), + fingerprint=_fp(rule_id, relpath, entity.qualname), + qualname=entity.qualname, + properties={"lang": "rust"}, + ) + + def _coverage_finding(functions_total: int, functions_declared: int, files_analyzed: int) -> Finding: """A whole-scan METRIC reporting the Rust trust-surface coverage. diff --git a/src/wardline/rust/crate_roots.py b/src/wardline/rust/crate_roots.py index df08363d..6f319dd4 100644 --- a/src/wardline/rust/crate_roots.py +++ b/src/wardline/rust/crate_roots.py @@ -7,7 +7,7 @@ ``toml::Value`` — ADR-049's "read as text" means *not cargo-metadata*, not a hand-rolled scan). ``[package].name`` is taken only if the manifest parses AND the name is a string: ``name.workspace = true`` parses as a table and falls - through; unparseable TOML falls through. + through; unparseable or non-UTF-8 TOML falls through. * **Two-branch registration:** a dir is a crate root iff (a) its manifest yields a string ``[package].name`` -> that name ``-``->``_`` normalised; ELSE (b) ``src/lib.rs`` or ``src/main.rs`` exists -> the directory name normalised. A @@ -90,12 +90,12 @@ def _package_name(manifest: Path) -> str | None: """``[package].name`` iff ``manifest`` parses as TOML and the name is a string. ``name.workspace = true`` parses as a TABLE -> ``None`` (falls through to the - dir-name branch); unparseable/unreadable TOML -> ``None`` likewise. + dir-name branch); unparseable/unreadable/non-UTF-8 TOML -> ``None`` likewise. """ try: with manifest.open("rb") as fh: value = tomllib.load(fh) - except (OSError, tomllib.TOMLDecodeError): + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): return None package = value.get("package") if not isinstance(package, dict): diff --git a/src/wardline/rust/dataflow.py b/src/wardline/rust/dataflow.py index 60b13377..5e93b62b 100644 --- a/src/wardline/rust/dataflow.py +++ b/src/wardline/rust/dataflow.py @@ -8,10 +8,11 @@ Taint model (slice-1, Tier-A): taint flows ONLY from known vocabulary sources and from locals proven tainted by a prior ``let`` — default-clean, because a finding-producer flags *provable* taint, not fail-closed unknowns (that would flood FPs). ``format!`` -contributes the worst taint of its **direct interpolation-arg tokens** only (the captured -``{x}`` form carries no arg token → a documented FN); ``.args`` is an opaque vec; a -sanitizer is invisible (an accepted bounded FP). Intra-function, single-block — nested -control flow is a documented limitation. tree-sitter types are TYPE_CHECKING-only. +contributes the worst taint of its direct interpolation-arg tokens plus simple captured +locals (``format!("{x}")``); ``.args`` introspects literal argument lists but keeps opaque +iterables opaque; a sanitizer is invisible (an accepted bounded FP). Intra-function, +single-block — nested control flow is a documented limitation. tree-sitter types are +TYPE_CHECKING-only. """ from __future__ import annotations @@ -38,7 +39,8 @@ # command beneath is not silently invisible. _WRAPPERS = frozenset({"try_expression", "await_expression", "return_expression"}) _CLEAN = TaintState.ASSURED # the default "not proven tainted" tier (not in RAW_ZONE) -# Format-family macros whose value-taint = worst over their direct interpolation-arg tokens. +# Format-family macros whose value-taint = worst over captured locals plus direct +# interpolation-arg tokens. # `write!`/`writeln!` take a leading WRITER (the destination) before the format string — it is # NOT a value-taint contributor, so it is dropped. `format!`/`format_args!` have no writer. _FORMAT_MACROS = frozenset({"format", "write", "writeln", "format_args"}) @@ -116,19 +118,20 @@ def _let(self, let_node: Node) -> None: def _bind(self, name: str | None, value: Node | None) -> None: """(Re)bind ``name`` to ``value`` — the shared core of ``let`` and assignment. - A fresh binding to a tracked name clears BOTH its prior taint and any stale Command - builder; if the new value is itself a builder, ``_try_command_chain`` re-adds it. This - symmetry is what keeps a shadowing/reassignment from stranding a dead constructor.""" + Rust evaluates a shadowing ``let`` initializer before the new binding takes effect, so + the RHS must still be able to see the previous local/Command builder. Once the new value + is classified, non-Command bindings clear stale builders and taints.""" if value is None: return - if name is not None: - self._local_taints.pop(name, None) - self._commands.pop(name, None) call = _unwrap_to_call(value) if call is not None and self._try_command_chain(call, bound_name=name): + if name is not None: + self._local_taints.pop(name, None) return # a Command builder bound to `name` (or terminated inline) if name is not None: taint = self._expr_taint(value) # taint over the ORIGINAL value (wrappers and all) + self._local_taints.pop(name, None) + self._commands.pop(name, None) if taint != _CLEAN: # record only proven taint self._local_taints[name] = taint @@ -138,14 +141,23 @@ def _try_command_chain(self, call_node: Node, *, bound_name: str | None) -> bool if base.type == "call_expression" and self._is_command_new(base): accum = self._accum_from_new(base) self._apply_steps(accum, steps) - if bound_name is not None and not any(m in _TERMINALS for m, _ in steps): - self._commands[bound_name] = accum # a live builder bound to a local + if bound_name is not None: + if any(m in _TERMINALS for m, _ in steps): + self._commands.pop(bound_name, None) + else: + self._commands[bound_name] = accum # a live builder bound to a local return True if base.type == "identifier": - tracked = self._commands.get(_text(base)) + base_name = _text(base) + tracked = self._commands.get(base_name) if tracked is None: return False # not a tracked command local self._apply_steps(tracked, steps) + if bound_name is not None: + if any(m in _TERMINALS for m, _ in steps): + self._commands.pop(bound_name, None) + else: + self._commands[bound_name] = tracked return True return False @@ -154,11 +166,10 @@ def _apply_steps(self, accum: _CmdAccum, steps: list[tuple[str, Node]]) -> None: if method == "arg": arg = _first_arg(call_node) if arg is not None: - if arg.type == "string_literal" and _string_value(arg).lower() in _SHELL_FLAGS: - accum.shell_flag_seen = True - accum.arg_taints.append((self._nmap.node_id(arg), self._expr_taint(arg))) + self._record_arg(accum, arg) elif method == "args": - continue # an opaque vec — not introspected in slice 1 + for arg in _literal_args(_first_arg(call_node)): + self._record_arg(accum, arg) elif method in _TERMINALS: self._triggers.append( CommandTrigger( @@ -172,6 +183,11 @@ def _apply_steps(self, accum: _CmdAccum, steps: list[tuple[str, Node]]) -> None: ) ) + def _record_arg(self, accum: _CmdAccum, arg: Node) -> None: + if arg.type == "string_literal" and _string_value(arg).lower() in _SHELL_FLAGS: + accum.shell_flag_seen = True + accum.arg_taints.append((self._nmap.node_id(arg), self._expr_taint(arg))) + def _accum_from_new(self, new_call: Node) -> _CmdAccum: prog = _first_arg(new_call) literal = _string_value(prog) if prog is not None and prog.type == "string_literal" else None @@ -216,9 +232,13 @@ def _format_taint(self, macro_node: Node) -> TaintState: # write!/writeln! lead with a WRITER (the destination) — drop it; only the # subsequent format string + interpolation args contribute value-taint. A simple # `dst` identifier writer is one named child; a compound writer (`&mut s`) may leave - # a stray token (a bounded slice-1 limitation, like the captured-`{x}` FN). + # a stray token (a bounded slice-1 limitation). children = children[1:] worst = _CLEAN + fmt = next((child for child in children if child.type == "string_literal"), None) + if fmt is not None: + for captured in _format_captures(_string_value(fmt)): + worst = least_trusted(worst, self._local_taints.get(captured, _CLEAN)) for child in children: if child.type == "string_literal": continue # the format string (and any literal arg) is clean @@ -293,11 +313,61 @@ def _first_arg(call_node: Node) -> Node | None: return args.named_children[0] if args.named_children else None +def _literal_args(node: Node | None) -> tuple[Node, ...]: + """Literal argv elements from ``.args([...])`` / ``.args(&[...])`` / ``.args(vec![...])``. + + Opaque iterables stay opaque: without their element syntax we cannot prove where shell + flags or tainted command strings sit in argv. + """ + if node is None: + return () + if node.type == "array_expression": + return tuple(node.named_children) + if node.type == "reference_expression" and node.named_children: + return _literal_args(node.named_children[0]) + if node.type == "macro_invocation": + name = node.child_by_field_name("macro") + if name is not None and _text(name) == "vec": + tree = next((c for c in node.named_children if c.type == "token_tree"), None) + if tree is not None: + return tuple(tree.named_children) + return () + + def _string_value(node: Node) -> str: content = next((c for c in node.named_children if c.type == "string_content"), None) return _text(content) if content is not None else "" +def _format_captures(fmt: str) -> tuple[str, ...]: + captures: list[str] = [] + i = 0 + while i < len(fmt): + char = fmt[i] + if char == "{" and i + 1 < len(fmt) and fmt[i + 1] == "{": + i += 2 + continue + if char != "{": + i += 1 + continue + end = fmt.find("}", i + 1) + if end == -1: + break + inner = fmt[i + 1 : end].strip() + name = inner.partition(":")[0].strip() + if _is_simple_identifier(name): + captures.append(name) + i = end + 1 + return tuple(captures) + + +def _is_simple_identifier(value: str) -> bool: + if not value: + return False + first = value[0] + return (first == "_" or first.isalpha()) and all(ch == "_" or ch.isalnum() for ch in value[1:]) + + def _name_of(node: Node | None) -> str | None: return _text(node) if node is not None and node.type == "identifier" else None diff --git a/src/wardline/rust/index.py b/src/wardline/rust/index.py index 0fb230cf..03b91049 100644 --- a/src/wardline/rust/index.py +++ b/src/wardline/rust/index.py @@ -101,6 +101,17 @@ class RustEntity: parent: str | None +@dataclass(slots=True) +class _ScopeFrame: + module: str + items: list[tuple[Node, list[str]]] + twin_counts: Counter[tuple[str, str]] + final_impl_quals: dict[int, str] + method_twin_counts: Counter[tuple[str, str]] + seen_impl_quals: set[str] + next_index: int = 0 + + def index_entities(tree: Tree, nmap: NodeIdMap, *, module: str, path: str = "") -> list[RustEntity]: """Emit the entities of an already-parsed ``tree`` under its ``nmap``. @@ -133,6 +144,53 @@ def _walk_scope( entities: list[RustEntity], path: str, ) -> None: + stack = [_scope_frame(child_nodes, module)] + while stack: + frame = stack.pop() + while frame.next_index < len(frame.items): + node, cfgs = frame.items[frame.next_index] + frame.next_index += 1 + + if node.type == "mod_item": + body = node.child_by_field_name("body") + if body is None: # `mod foo;` (external) has no body to descend + continue + name = _name(node) + nested = f"{frame.module}.{name}" + if cfgs and frame.twin_counts[("module", name)] > 1: + nested += q.cfg_discriminant(cfgs) + # The inline-mod entity is emitted AT its source position, BEFORE its + # members (corpus nested_inline_mod row order; extract.rs inline-mod arm). + entities.append(_entity(nested, "module", node, nmap, path, parent=frame.module)) + stack.append(frame) + stack.append(_scope_frame(body.children, nested)) + break + if node.type == "impl_item": + impl_qualname = frame.final_impl_quals.get(node.id) + if impl_qualname is None: + continue + if impl_qualname not in frame.seen_impl_quals: + frame.seen_impl_quals.add(impl_qualname) + entities.append(_entity(impl_qualname, "impl", node, nmap, path, parent=frame.module)) + _emit_impl_methods(node, impl_qualname, nmap, entities, path, frame.method_twin_counts) + continue + + kind = _LEAF_KINDS[node.type] + name = _name(node) + if kind == "const" and name == "_": + # ADR-049 Amendment 9: `const _` is NOT an entity — skipped + # UNCONDITIONALLY on `ident == "_"` (skip-only-when-twinned would make + # the emitted set sibling-dependent and churn SEI; nothing can ever name + # the item, so no discriminant can rescue it). No entity, no containment; + # a finding inside one attributes to the enclosing module by line. + continue + qualname = f"{frame.module}.{name}" + if cfgs and frame.twin_counts[(kind, name)] > 1: + qualname += q.cfg_discriminant(cfgs) + entities.append(_entity(qualname, kind, node, nmap, path, parent=frame.module)) + + +def _scope_frame(child_nodes: Iterable[Node], module: str) -> _ScopeFrame: # Attributes are *preceding siblings* of the item they decorate, so accumulate # every pending cfg predicate RAW onto the next item (mirrors extract.rs # `cfg_predicates` — ALL stacked #[cfg]s feed the discriminant, normalisation @@ -222,48 +280,17 @@ def _walk_scope( for method, _mcfgs in _impl_methods_with_cfgs(node): method_twin_counts[(final_qual, _name(method))] += 1 - # First block with a given (cfg-augmented) impl qualname emits the ONE merged impl - # entity; later same-key blocks only append methods (extract.rs `seen_impl_ids`). - # The set is PER-INVOCATION (each nested scope gets a fresh one); that is sound - # because the impl qualname embeds the full module path, so two impls in different - # scopes can never share a key — dedup only ever needs to see one scope at a time. - seen_impl_quals: set[str] = set() - - for node, cfgs in items: - if node.type == "mod_item": - body = node.child_by_field_name("body") - if body is None: # `mod foo;` (external) has no body to descend - continue - name = _name(node) - nested = f"{module}.{name}" - if cfgs and twin_counts[("module", name)] > 1: - nested += q.cfg_discriminant(cfgs) - # The inline-mod entity is emitted AT its source position, BEFORE its - # members (corpus nested_inline_mod row order; extract.rs inline-mod arm). - entities.append(_entity(nested, "module", node, nmap, path, parent=module)) - _walk_scope(body.children, nested, nmap, entities, path) - elif node.type == "impl_item": - impl_qualname = final_impl_quals.get(node.id) - if impl_qualname is None: - continue - if impl_qualname not in seen_impl_quals: - seen_impl_quals.add(impl_qualname) - entities.append(_entity(impl_qualname, "impl", node, nmap, path, parent=module)) - _emit_impl_methods(node, impl_qualname, nmap, entities, path, method_twin_counts) - else: - kind = _LEAF_KINDS[node.type] - name = _name(node) - if kind == "const" and name == "_": - # ADR-049 Amendment 9: `const _` is NOT an entity — skipped - # UNCONDITIONALLY on `ident == "_"` (skip-only-when-twinned would make - # the emitted set sibling-dependent and churn SEI; nothing can ever name - # the item, so no discriminant can rescue it). No entity, no containment; - # a finding inside one attributes to the enclosing module by line. - continue - qualname = f"{module}.{name}" - if cfgs and twin_counts[(kind, name)] > 1: - qualname += q.cfg_discriminant(cfgs) - entities.append(_entity(qualname, kind, node, nmap, path, parent=module)) + return _ScopeFrame( + module=module, + items=items, + twin_counts=twin_counts, + final_impl_quals=final_impl_quals, + method_twin_counts=method_twin_counts, + # First block with a given (cfg-augmented) impl qualname emits the ONE merged + # impl entity; later same-key blocks only append methods (extract.rs + # `seen_impl_ids`). This stays per-scope because the frame owns the set. + seen_impl_quals=set(), + ) def _named_item_key(node: Node) -> tuple[str, str] | None: diff --git a/src/wardline/rust/mounts.py b/src/wardline/rust/mounts.py index 8405e8f0..eed6017f 100644 --- a/src/wardline/rust/mounts.py +++ b/src/wardline/rust/mounts.py @@ -54,7 +54,7 @@ from wardline.rust.parse import has_errors, parse_rust if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Callable, Iterable, Mapping from tree_sitter import Node @@ -149,23 +149,38 @@ def _fs_route(self, file: str) -> str: return q.rust_module_route(crate=self._crate, src_root=self._src_root, file=file) -def build_mount_overlay(sources: Mapping[str, str], *, crate: str, src_root: str) -> MountOverlay: +def build_mount_overlay( + sources: Mapping[str, str], + *, + crate: str, + src_root: str, + error_callback: Callable[[str, Exception], None] | None = None, +) -> MountOverlay: """Discover every literal ``#[path]`` mount across ``sources`` (path -> source text, paths project-root-relative posix) and build the crate's routing overlay.""" mounts: list[_Mount] = [] for file in sorted(sources): if not file.endswith(".rs"): continue - tree = parse_rust(sources[file]) - if has_errors(tree): - continue # fail-closed: no routing derived from a file we refuse to analyze - file_dir = posixpath.dirname(file) - # rustc's relative-path rule: a top-level #[path] resolves against the declaring - # FILE's directory; one declared inside inline mods resolves against the would-be - # directory of the nesting — anchored at the file's own dir for a mod-rs file - # (lib.rs/main.rs/mod.rs), at the file's stem directory otherwise. - stem_base = file_dir if posixpath.basename(file) in _ROOT_BASENAMES else file[: -len(".rs")] - _collect_mounts(tree.root_node.children, file, attr_dir=file_dir, nest_base=stem_base, prefix=(), out=mounts) + try: + tree = parse_rust(sources[file]) + if has_errors(tree): + continue # fail-closed: no routing derived from a file we refuse to analyze + file_dir = posixpath.dirname(file) + # rustc's relative-path rule: a top-level #[path] resolves against the declaring + # FILE's directory; one declared inside inline mods resolves against the would-be + # directory of the nesting — anchored at the file's own dir for a mod-rs file + # (lib.rs/main.rs/mod.rs), at the file's stem directory otherwise. + stem_base = file_dir if posixpath.basename(file) in _ROOT_BASENAMES else file[: -len(".rs")] + file_mounts: list[_Mount] = [] + _collect_mounts( + tree.root_node.children, file, attr_dir=file_dir, nest_base=stem_base, prefix=(), out=file_mounts + ) + mounts.extend(file_mounts) + except Exception as exc: # noqa: BLE001 - hostile source must not crash the scan + if error_callback is None: + raise + error_callback(file, exc) return MountOverlay(mounts, crate=crate, src_root=src_root) diff --git a/src/wardline/rust/qualname.py b/src/wardline/rust/qualname.py index 437ef263..b422320e 100644 --- a/src/wardline/rust/qualname.py +++ b/src/wardline/rust/qualname.py @@ -116,33 +116,113 @@ def rust_module_route(*, crate: str, src_root: str, file: str) -> str: def normalize_cfg_predicate(text: str) -> str: - """Canonicalise a ``cfg(...)`` predicate, mirroring loomweave ``qualname.rs`` - ``normalise_pred`` BYTE-FOR-BYTE (the @cfg discriminant is a parity surface). + """Canonicalise a ``cfg(...)`` predicate for a stable ``@cfg`` discriminant. ``text`` is the RAW argument token-tree text, with or without its outer parens (``"(unix)"`` / ``"unix"`` / ``"(any(windows, unix))"``); the outer cfg-argument - parens (if present) are stripped to the bare predicate, then — in the oracle's - exact order: all whitespace removed; every reserved entity-id char escaped + parens (if present) are stripped to the bare predicate and all whitespace is + removed. Top-level ``any(...)``/``all(...)`` predicates recursively normalise and + sort their arguments using only top-level commas, so nested predicates cannot + collide by being split apart. Leaf predicates escape reserved entity-id chars (``_escape_reserved``: ``%`` -> ``%25`` first, then ``:`` -> ``%3A`` — injective, - so ``feature="a:b"`` and a literal source ``feature="a%3Ab"`` stay distinct); and - a single top-level ``any(...)``/``all(...)`` wrapper's args sorted by a **naive** - ``split(',')`` (NOT paren-aware — this is exactly the oracle's algorithm; deeper - nesting is left as the deterministic stripped string, even though that mangles, - because the contract is byte-equality with the oracle, not a "nicer" canonical - form). The escape runs on the whole stripped predicate BEFORE the any()/all() - split, exactly as in ``normalise_pred``. + so ``feature="a:b"`` and a literal source ``feature="a%3Ab"`` stay distinct). """ + return _normalize_cfg_predicate(text, depth=0) + + +_MAX_CFG_NORMALIZE_DEPTH = 128 + + +def _normalize_cfg_predicate(text: str, *, depth: int) -> str: stripped = "".join(text.split()) - if stripped.startswith("(") and stripped.endswith(")"): - stripped = stripped[1:-1] - stripped = _escape_reserved(stripped) + stripped = _strip_wrapping_parens(stripped) + if depth >= _MAX_CFG_NORMALIZE_DEPTH: + return _escape_reserved(stripped) for fn in ("any", "all"): - prefix = fn + "(" - if stripped.startswith(prefix) and stripped.endswith(")"): - inner = stripped[len(prefix) : -1] - parts = sorted(inner.split(",")) # naive split — matches the oracle verbatim + inner = _call_inner(stripped, fn) + if inner is not None: + split = _split_top_level_commas(inner) + if split is None: + return _escape_reserved(stripped) + parts = sorted(_normalize_cfg_predicate(part, depth=depth + 1) for part in split) return f"{fn}({','.join(parts)})" - return stripped + return _escape_reserved(stripped) + + +def _strip_wrapping_parens(text: str) -> str: + if not (text.startswith("(") and text.endswith(")")): + return text + if _matching_rparen(text, 0) != len(text) - 1: + return text + return text[1:-1] + + +def _call_inner(text: str, fn: str) -> str | None: + prefix = fn + "(" + if not text.startswith(prefix): + return None + open_index = len(fn) + if _matching_rparen(text, open_index) != len(text) - 1: + return None + return text[len(prefix) : -1] + + +def _matching_rparen(text: str, open_index: int) -> int | None: + depth = 0 + in_string = False + escaped = False + for index, char in enumerate(text[open_index:], start=open_index): + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return index + if depth < 0: + return None + return None + + +def _split_top_level_commas(text: str) -> list[str] | None: + parts: list[str] = [] + start = 0 + depth = 0 + in_string = False + escaped = False + for index, char in enumerate(text): + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth < 0: + return None + elif char == "," and depth == 0: + parts.append(text[start:index]) + start = index + 1 + if in_string or depth != 0: + return None + parts.append(text[start:]) + return parts def _escape_reserved(s: str) -> str: diff --git a/src/wardline/scanner/analyzer.py b/src/wardline/scanner/analyzer.py index a8420454..66a0ea5c 100644 --- a/src/wardline/scanner/analyzer.py +++ b/src/wardline/scanner/analyzer.py @@ -38,7 +38,7 @@ from wardline.scanner.taint.module_summariser import collect_module_global_raw_seeds, own_scope_global_names from wardline.scanner.taint.project_resolver import resolve_project_taints from wardline.scanner.taint.provider import TaintSourceProvider -from wardline.scanner.taint.variable_level import attribute_write_recording, project_attribute_writes +from wardline.scanner.taint.variable_level import L2BudgetExceeded, attribute_write_recording, project_attribute_writes if TYPE_CHECKING: from collections.abc import Sequence @@ -74,9 +74,10 @@ def _fp(*parts: str) -> str: dict[str, dict[str, TaintState]], ] -# Above this many candidate-key probes, fall back to the full (unpruned) per-function -# taint map — a sound, slower path for a single pathologically token-dense function. -_CANDIDATE_KEY_BUDGET = 50_000 +# Above this many candidate-key probes, stop the function-level L2 run and emit a +# loud function-skip finding. Falling back to the full project taint map would +# re-open the super-linear path this budget is meant to bound. +_CANDIDATE_KEY_BUDGET = 250_000 def _pruned_method_taint_map( @@ -132,9 +133,11 @@ def _pruned_method_taint_map( if module_prefix: forms.add(f"{module_prefix}.{chain}") if len(forms) * (len(attrs) + 1) > _CANDIDATE_KEY_BUDGET: - merged = dict(call_tm) - merged.update(project_return_taints) - return merged + raise L2BudgetExceeded( + budget=_CANDIDATE_KEY_BUDGET, + attempted=len(forms) * (len(attrs) + 1), + operation="candidate_key_probe", + ) tm: dict[str, TaintState] = {} @@ -601,24 +604,50 @@ def _record_file_failure(relpath: str, ent: Entity, exc: Exception) -> None: ) ) - def _record_l2_recursion(ent: Entity, *, reason: str = "recursion_limit") -> None: + def _record_l2_skip( + ent: Entity, + *, + reason: str, + message_detail: str, + budget_error: L2BudgetExceeded | None = None, + ) -> None: l2_failed.add(ent.qualname) if ent.qualname in function_skip_recorded: return function_skip_recorded.add(ent.qualname) + properties: dict[str, object] = {"reason": reason} + if budget_error is not None: + properties.update( + { + "budget": budget_error.budget, + "attempted": budget_error.attempted, + "operation": budget_error.operation, + } + ) func_skip_findings.append( Finding( rule_id="WLN-ENGINE-FUNCTION-SKIPPED", - message=f"{ent.qualname}: skipped L2 — expression too deep to analyze safely", + message=f"{ent.qualname}: skipped L2 — {message_detail}", severity=Severity.ERROR, kind=Kind.DEFECT, location=ent.location, fingerprint=_fp("WLN-ENGINE-FUNCTION-SKIPPED", ent.qualname), qualname=ent.qualname, - properties={"reason": reason}, + properties=properties, ) ) + def _record_l2_recursion(ent: Entity, *, reason: str = "recursion_limit") -> None: + _record_l2_skip(ent, reason=reason, message_detail="expression too deep to analyze safely") + + def _record_l2_budget(ent: Entity, exc: L2BudgetExceeded) -> None: + _record_l2_skip( + ent, + reason="taint_budget_exceeded", + message_detail="function too large to analyze soundly", + budget_error=exc, + ) + for parsed in file_meta: module = parsed.module entities = parsed.entities @@ -678,6 +707,16 @@ def _record_l2_recursion(ent: Entity, *, reason: str = "recursion_limit") -> Non writes = project_attribute_writes( recorded_writes, all_classes, enclosing_class if is_method else None ) + except L2BudgetExceeded as exc: + _record_l2_budget(ent, exc) + call_sites, call_args, var_taints, ret_taint, ret_callee = ( + {}, + {}, + {}, + TaintState.UNKNOWN_RAW, + None, + ) + writes = {} except RecursionError: _record_l2_recursion(ent) call_sites, call_args, var_taints, ret_taint, ret_callee = ( @@ -816,6 +855,16 @@ def _l2_nested_def_overlay() -> dict[str, dict[str, TaintState]]: writes = project_attribute_writes( recorded_writes, all_classes, enclosing_class if is_method else None ) + except L2BudgetExceeded as exc: + _record_l2_budget(ent, exc) + call_sites, call_args, var_taints, ret_taint, ret_callee, writes = ( + {}, + {}, + {}, + TaintState.UNKNOWN_RAW, + None, + {}, + ) except RecursionError: _record_l2_recursion(ent, reason="fixpoint_recursion") call_sites, call_args, var_taints, ret_taint, ret_callee, writes = ( diff --git a/src/wardline/scanner/pipeline.py b/src/wardline/scanner/pipeline.py index ed8ac58d..560cc2c2 100644 --- a/src/wardline/scanner/pipeline.py +++ b/src/wardline/scanner/pipeline.py @@ -127,8 +127,8 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu continue try: - source = path.read_text(encoding="utf-8") - source_bytes = source.encode("utf-8") + source_bytes = path.read_bytes() + source = source_bytes.decode("utf-8") source_sha256 = hashlib.sha256(source_bytes).hexdigest() from wardline.core.ruleset import ruleset_hash diff --git a/src/wardline/scanner/rules/_fingerprint.py b/src/wardline/scanner/rules/_fingerprint.py new file mode 100644 index 00000000..45724293 --- /dev/null +++ b/src/wardline/scanner/rules/_fingerprint.py @@ -0,0 +1,18 @@ +"""Fingerprint discriminators shared by rule implementations.""" + +from __future__ import annotations + +import ast +import hashlib + + +def entity_source_fingerprint(node: ast.AST) -> str: + """Line-independent discriminator for singleton findings tied to an entity body. + + ``ast.dump(..., include_attributes=False)`` keeps source semantics that affect the + entity while excluding absolute line/column positions, so a whole-entity move or + comment-only edit is stable but a same-qualname body or signature change is not. + """ + digest = hashlib.sha256() + digest.update(ast.dump(node, include_attributes=False).encode("utf-8")) + return f"entity:{digest.hexdigest()}" diff --git a/src/wardline/scanner/rules/_sink_helpers.py b/src/wardline/scanner/rules/_sink_helpers.py index 5cb5d6bf..36f44a63 100644 --- a/src/wardline/scanner/rules/_sink_helpers.py +++ b/src/wardline/scanner/rules/_sink_helpers.py @@ -48,6 +48,7 @@ "collect_sink_bindings", "dotted_name", "enclosing_declared_tier", + "entity_relative_span", "module_alias_map", "module_for_qualname", "receiver_ctor_call", @@ -134,6 +135,21 @@ def _own_calls(node: ast.AST) -> Iterator[ast.Call]: yield from _own_calls(child) +def entity_relative_span(node: ast.AST, entity_line_start: int | None) -> str: + """Source span discriminator relative to the containing entity's first line. + + CPython's ``end_col_offset`` is relative to ``end_lineno``. A multiline inner + and outer call can share start line/column and ending column while differing only + by end line, so the full ``start:col:end:end_col`` span is the call-site key. + """ + start_line = getattr(node, "lineno", 0) + end_line = getattr(node, "end_lineno", start_line) + return ( + f"{start_line - (entity_line_start or 0)}:{getattr(node, 'col_offset', 0)}:" + f"{end_line - (entity_line_start or 0)}:{getattr(node, 'end_col_offset', 0)}" + ) + + def _direct_sink_fqn( call: ast.Call, sink_names: frozenset[str], @@ -682,8 +698,9 @@ def build_sink_finding( offset (call line - the enclosing def's line, invariant to a comment ABOVE the function: wlfp2/wardline-8654423823) plus the call's full lexical SPAN and the sink dotted-name. The span (start:end), not the start column alone, separates - the outer/inner calls of a chain (``a.sink(x).sink(y)``), which share a start - column. Never the resolved arg taint (it drifts across builds: weft-4a9d0f863c). + the outer/inner calls of a multiline chain (``a.sink(x).sink(y)``), which may + share a start line/column and ending column. Never the resolved arg taint (it + drifts across builds: weft-4a9d0f863c). *message* overrides the standard message text only — fingerprint and properties stay uniform (PathTraversal's receiver-anchored explanations). @@ -706,7 +723,7 @@ def build_sink_finding( rule_id=rule_id, path=entity.location.path, qualname=qualname, - taint_path=f"{line - (entity.location.line_start or 0)}:{call.col_offset}:{call.end_col_offset}:{dotted}", + taint_path=f"{entity_relative_span(call, entity.location.line_start)}:{dotted}", ), # OLD (wlfp1) taint_path, byte-exact, for `wardline rekey` (P4). taint_path_v0=f"{dotted}@{call.col_offset}:{call.end_col_offset}", diff --git a/src/wardline/scanner/rules/assert_only_boundary.py b/src/wardline/scanner/rules/assert_only_boundary.py index b7ee6f5d..7f10ce86 100644 --- a/src/wardline/scanner/rules/assert_only_boundary.py +++ b/src/wardline/scanner/rules/assert_only_boundary.py @@ -39,6 +39,7 @@ has_real_rejection, rejecting_helper_calls, ) +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules._sink_helpers import module_alias_map from wardline.scanner.rules.metadata import RuleMetadata @@ -106,10 +107,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, - # so (rule, path, line, qualname) is already unique. body/return tiers are - # resolved values that drift as the suite is extended — keep them off the key. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/boundary_without_rejection.py b/src/wardline/scanner/rules/boundary_without_rejection.py index a5c1c20c..bed0056c 100644 --- a/src/wardline/scanner/rules/boundary_without_rejection.py +++ b/src/wardline/scanner/rules/boundary_without_rejection.py @@ -40,6 +40,7 @@ is_degenerate_boundary, rejecting_helper_calls, ) +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules._sink_helpers import module_alias_map from wardline.scanner.rules.metadata import RuleMetadata @@ -110,10 +111,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, - # so (rule, path, line, qualname) is already unique. body/return tiers are - # resolved values that drift as the suite is extended — keep them off the key. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/contradictory_trust.py b/src/wardline/scanner/rules/contradictory_trust.py index cb49f6fc..87ed6c9c 100644 --- a/src/wardline/scanner/rules/contradictory_trust.py +++ b/src/wardline/scanner/rules/contradictory_trust.py @@ -25,6 +25,7 @@ from wardline.core.finding import Finding, Kind, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.scanner.grammar import BUILTIN_BOUNDARY_TYPES +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules.metadata import RuleMetadata from wardline.scanner.taint.decorator_provider import _is_builtin_decorator_fqn @@ -130,10 +131,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, so - # (rule, path, line, qualname) is already unique; the marker set is source-derived - # but not load-bearing for the join key. It stays in message/properties only. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"markers": markers_label}, diff --git a/src/wardline/scanner/rules/degenerate_boundary.py b/src/wardline/scanner/rules/degenerate_boundary.py index 3f72b86e..497ba414 100644 --- a/src/wardline/scanner/rules/degenerate_boundary.py +++ b/src/wardline/scanner/rules/degenerate_boundary.py @@ -21,6 +21,7 @@ from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import TRUST_RANK from wardline.scanner.rules._ast_helpers import is_degenerate_boundary +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: @@ -76,10 +77,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, - # so (rule, path, line, qualname) is already unique. body/return tiers are - # resolved values that drift as the suite is extended — keep them off the key. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/failopen_boundary.py b/src/wardline/scanner/rules/failopen_boundary.py index fb71c1fe..7893d11f 100644 --- a/src/wardline/scanner/rules/failopen_boundary.py +++ b/src/wardline/scanner/rules/failopen_boundary.py @@ -77,6 +77,7 @@ def v(p): rejecting_helper_calls, returned_var_names, ) +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules._sink_helpers import module_alias_map from wardline.scanner.rules.metadata import RuleMetadata @@ -165,10 +166,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, - # so (rule, path, line, qualname) is already unique. body/return tiers are - # resolved values that drift as the suite is extended — keep them off the key. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/metadata.py b/src/wardline/scanner/rules/metadata.py index d2c3c352..a2e66f4e 100644 --- a/src/wardline/scanner/rules/metadata.py +++ b/src/wardline/scanner/rules/metadata.py @@ -25,7 +25,8 @@ class RuleMetadata: # carry a source-derived entity-relative discriminator in ``taint_path`` (a col span # or PY-WL-114's ordinal), since ``line_start`` no longer separates co-located # findings (wlfp2, wardline-8654423823). A singleton (<=1 finding per qualname) - # passes ``taint_path=None``. Default is the conservative SINGLETON; the - # ``test_discriminator_shape`` source-AST lint enforces multi_emit <-> taint_path - # so a multi-emit rule cannot silently ship a colliding ``None``. + # may use the line-independent source-body discriminator so a different body + # or signature under the same qualname cannot inherit a stale suppression. Default is the + # conservative SINGLETON; the ``test_discriminator_shape`` source-AST lint enforces + # that multi_emit rules do not use singleton discriminators. multi_emit: bool = False diff --git a/src/wardline/scanner/rules/none_leak.py b/src/wardline/scanner/rules/none_leak.py index 26961e38..0f902b1b 100644 --- a/src/wardline/scanner/rules/none_leak.py +++ b/src/wardline/scanner/rules/none_leak.py @@ -28,6 +28,7 @@ from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import RAW_ZONE, TRUST_RANK from wardline.scanner.rules._ast_helpers import _own_statements +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules._sink_helpers import module_for_qualname from wardline.scanner.rules.metadata import RuleMetadata @@ -244,10 +245,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, - # so (rule, path, line, qualname) is already unique. The declared tier is a - # resolved value that drifts as the suite is extended — keep it off the join key. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. The declared tier remains out of the key. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"declared_return": declared.value}, diff --git a/src/wardline/scanner/rules/stored_taint.py b/src/wardline/scanner/rules/stored_taint.py index 1137f1b4..08bb891f 100644 --- a/src/wardline/scanner/rules/stored_taint.py +++ b/src/wardline/scanner/rules/stored_taint.py @@ -40,6 +40,7 @@ SinkBindings, collect_sink_bindings, dotted_name, + entity_relative_span, module_alias_map, resolve_bound_call_fqn, worst_arg_taint, @@ -243,12 +244,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # >1 return per function is possible. Discriminate ENTITY-RELATIVE - # (return line - def line, invariant to a comment ABOVE the function: - # wlfp2/wardline-8654423823) + the return's lexical span + a ``return`` - # token. The ``:return`` token keeps this DISJOINT from the call-arg - # site below (which ends in a callee name), so the two never collide. - taint_path=f"{node.lineno - (entity.location.line_start or 0)}:{node.col_offset}:{node.end_col_offset}:return", # noqa: E501 + # >1 return per function is possible. Discriminate with the + # ENTITY-RELATIVE full lexical span + a ``return`` token. The + # ``:return`` token keeps this DISJOINT from the call-arg site below + # (which ends in a callee name), so the two never collide. + taint_path=f"{entity_relative_span(node, entity.location.line_start)}:return", ), qualname=qualname, properties={"return_taint": ret_taint.value}, @@ -315,8 +315,9 @@ def check(self, context: AnalysisContext) -> list[Finding]: # offset (call line - def line, invariant to a comment ABOVE the # function: wlfp2/wardline-8654423823) + the call's full lexical # SPAN + the callee spelling AS WRITTEN. Never the RESOLVED callee - # qualname (drifts). The span separates a chain's outer/inner calls. - taint_path=f"{node.lineno - (entity.location.line_start or 0)}:{node.col_offset}:{node.end_col_offset}:{dotted_name(node.func)}", # noqa: E501 + # qualname (drifts). The span separates multiline chain calls that + # differ only by end line. + taint_path=f"{entity_relative_span(node, entity.location.line_start)}:{dotted_name(node.func)}", # noqa: E501 ), # OLD (wlfp1) taint_path, byte-exact, for `wardline rekey` (P4). taint_path_v0=f"{dotted_name(node.func)}@{node.col_offset}:{node.end_col_offset}", diff --git a/src/wardline/scanner/rules/untrusted_reaches_trusted.py b/src/wardline/scanner/rules/untrusted_reaches_trusted.py index 1478c07c..fbd92fe5 100644 --- a/src/wardline/scanner/rules/untrusted_reaches_trusted.py +++ b/src/wardline/scanner/rules/untrusted_reaches_trusted.py @@ -28,6 +28,7 @@ from wardline.core.finding import Finding, Kind, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import RAW_ZONE, TRUST_RANK +from wardline.scanner.rules._fingerprint import entity_source_fingerprint from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: @@ -119,11 +120,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: rule_id=self.rule_id, path=entity.location.path, qualname=qualname, - # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, - # so (rule, path, line, qualname) is already unique. actual/declared tiers and - # via_callee are resolved values that drift across builds for identical source - # (the reported bug) — they live in message/properties, never the join key. - taint_path=None, + # Line-independent source-body discriminator: one finding per anchored + # qualname, but a different same-qualname entity body must not inherit + # an old suppression. Resolved tiers and via_callee remain out of the key. + taint_path=entity_source_fingerprint(entity.node), ), qualname=qualname, properties={"declared_return": declared.value, "actual_return": actual.value}, diff --git a/src/wardline/scanner/rules/untrusted_to_trusted_callee.py b/src/wardline/scanner/rules/untrusted_to_trusted_callee.py index d9dfcbb0..da6c7690 100644 --- a/src/wardline/scanner/rules/untrusted_to_trusted_callee.py +++ b/src/wardline/scanner/rules/untrusted_to_trusted_callee.py @@ -38,6 +38,7 @@ RAW_ZONE, _own_calls, dotted_name, + entity_relative_span, resolved_arg_taints, ) from wardline.scanner.rules.metadata import RuleMetadata @@ -174,8 +175,8 @@ def check(self, context: AnalysisContext) -> list[Finding]: # offset (call line - def line, invariant to a comment ABOVE the function: # wlfp2/wardline-8654423823) + the call's full lexical SPAN + the callee spelling # AS WRITTEN. Never the resolved arg taint or resolved callee qualname (both - # drift). The span (start:end) separates a chain's outer/inner calls. - taint_path=f"{line - (entity.location.line_start or 0)}:{call.col_offset}:{call.end_col_offset}:{dotted_name(call.func)}", # noqa: E501 + # drift). The span separates multiline chain calls that differ only by end line. + taint_path=f"{entity_relative_span(call, entity.location.line_start)}:{dotted_name(call.func)}", # noqa: E501 ), # OLD (wlfp1) taint_path, byte-exact, for `wardline rekey` (P4). taint_path_v0=f"{dotted_name(call.func)}@{call.col_offset}:{call.end_col_offset}", diff --git a/src/wardline/scanner/taint/call_taint_map.py b/src/wardline/scanner/taint/call_taint_map.py index 698c3f26..f89f7700 100644 --- a/src/wardline/scanner/taint/call_taint_map.py +++ b/src/wardline/scanner/taint/call_taint_map.py @@ -23,7 +23,10 @@ ``{"urllib": "urllib"}``; the call is written ``urllib.request.urlopen``), ``import urllib.request as ur``, and ``from urllib.request import urlopen``. -Project entries take precedence over stdlib (``setdefault`` for stdlib). +Project entries take precedence over stdlib (``setdefault`` for stdlib), except +for submodule expansion below a stdlib root that the project itself does not own. +That keeps path-only modules such as ``os/path.py`` from spoofing runtime +``import os``. Residual known gap: an aliased serialisation sink NOT in the stdlib table (e.g. ``import pickle as p`` when pickle is uncurated) has no taint_map entry and the literal sink check misses the alias, so it falls back to the function taint — @@ -32,6 +35,7 @@ from __future__ import annotations +import sys from collections.abc import Mapping from typing import TYPE_CHECKING @@ -62,6 +66,18 @@ def _match_config_item(item: str, alias_map: dict[str, str]) -> list[str]: return keys +def _top_level_module(dotted: str) -> str: + return dotted.partition(".")[0] + + +def _can_trust_project_import_target( + target: str, + project_by_module: Mapping[str, Mapping[str, TaintState]], +) -> bool: + root = _top_level_module(target) + return root not in sys.stdlib_module_names or root in project_by_module + + def build_call_taint_map( *, module_path: str, @@ -86,24 +102,28 @@ def build_call_taint_map( # (b)+(c) Imported project symbols, via the file's alias map. for local, target in alias_map.items(): - bucket = project_by_module.get(target) - if bucket is not None: - # module import: dotted ``local.func`` calls - for func_name, taint in bucket.items(): - tm[f"{local}.{func_name}"] = taint - for module, module_bucket in project_by_module.items(): - if module.startswith(target + "."): - # ``import pkg.sub`` collapses the alias to ``pkg``; the call is - # written ``local..fn`` just like multi-component - # stdlib imports. - remainder = module[len(target) + 1 :] - for func_name, taint in module_bucket.items(): - tm[f"{local}.{remainder}.{func_name}"] = taint + if _can_trust_project_import_target(target, project_by_module): + bucket = project_by_module.get(target) + if bucket is not None: + # module import: dotted ``local.func`` calls + for func_name, taint in bucket.items(): + tm[f"{local}.{func_name}"] = taint + for module, module_bucket in project_by_module.items(): + if module.startswith(target + "."): + # ``import pkg.sub`` collapses the alias to ``pkg``; the call is + # written ``local..fn`` just like multi-component + # stdlib imports. Do not apply this beneath a stdlib root unless the + # project owns that root package/module; a path-only ``os/path.py`` + # must not spoof runtime ``import os``. + remainder = module[len(target) + 1 :] + for func_name, taint in module_bucket.items(): + tm[f"{local}.{remainder}.{func_name}"] = taint # from-import of a project function: target == "module.func_name" mod, _, leaf = target.rpartition(".") - mod_bucket = project_by_module.get(mod) - if mod_bucket is not None and leaf in mod_bucket: - tm[local] = mod_bucket[leaf] + if _can_trust_project_import_target(mod, project_by_module): + mod_bucket = project_by_module.get(mod) + if mod_bucket is not None and leaf in mod_bucket: + tm[local] = mod_bucket[leaf] # (d) stdlib_taint with the serialisation-sink override. stdlib = load_stdlib_taint() diff --git a/src/wardline/scanner/taint/summary.py b/src/wardline/scanner/taint/summary.py index 81bff89b..059a4ee7 100644 --- a/src/wardline/scanner/taint/summary.py +++ b/src/wardline/scanner/taint/summary.py @@ -85,11 +85,9 @@ def compute_cache_key( Each component is length-prefixed before hashing so distinct inputs cannot collide (without it, ``(b"ab", "c")`` and ``(b"a", "bc")`` would hash alike). - CRLF in ``source_bytes`` is rejected so Linux/Windows checkouts of the same - commit produce identical keys. + ``source_bytes`` are hashed exactly as read from disk so downstream freshness + checks compare the same bytes the scanner analyzed. """ - if source_bytes.find(b"\r\n") != -1: - raise ValueError("CRLF bytes in source — normalise to LF before hashing") hasher = hashlib.sha256() _write_len_prefixed(hasher, module_path.encode("utf-8")) _write_len_prefixed(hasher, source_bytes) diff --git a/src/wardline/scanner/taint/variable_level.py b/src/wardline/scanner/taint/variable_level.py index 056c083c..25cc75c5 100644 --- a/src/wardline/scanner/taint/variable_level.py +++ b/src/wardline/scanner/taint/variable_level.py @@ -57,6 +57,8 @@ "yaml.load", "yaml.safe_load_all", "yaml.load_all", + "yaml.unsafe_load", + "yaml.full_load", "marshal.dumps", "marshal.dump", "marshal.loads", @@ -216,6 +218,45 @@ "_CURRENT_MODULE_PREFIX", default=None ) +# Per-function L2 work ceiling. The counter is deliberately coarse but tied to +# the attacker-amplifiable operations: statement snapshots, branch candidate +# copies/merges, and loop fixpoint state work. +L2_WORK_BUDGET = 50_000_000 + + +class L2BudgetExceeded(RuntimeError): + """Raised when one function's L2 taint walk exceeds the work budget.""" + + def __init__(self, *, budget: int, attempted: int, operation: str) -> None: + super().__init__(f"L2 work budget exceeded during {operation}: {attempted}>{budget}") + self.budget = budget + self.attempted = attempted + self.operation = operation + + +@dataclass(slots=True) +class _L2WorkBudget: + budget: int + used: int = 0 + + +_CURRENT_L2_WORK_BUDGET: contextvars.ContextVar[_L2WorkBudget | None] = contextvars.ContextVar( + "_CURRENT_L2_WORK_BUDGET", default=None +) + + +def _charge_l2_work(amount: int, operation: str) -> None: + if amount <= 0: + return + budget = _CURRENT_L2_WORK_BUDGET.get() + if budget is None or budget.budget <= 0: + return + attempted = budget.used + amount + if attempted > budget.budget: + raise L2BudgetExceeded(budget=budget.budget, attempted=attempted, operation=operation) + budget.used = attempted + + _CONTEXT_ENCODERS: frozenset[str] = frozenset( { "html.escape", @@ -607,6 +648,7 @@ def compute_variable_taints( token = None token_args = None token_clash = None + token_budget = _CURRENT_L2_WORK_BUDGET.set(_L2WorkBudget(L2_WORK_BUDGET)) if provenance_clash is not None: token_clash = _PROVENANCE_CLASH.set(provenance_clash) token_types = _CURRENT_VAR_TYPES.set({}) @@ -641,6 +683,7 @@ def compute_variable_taints( _CURRENT_ALIAS_MAP.reset(token) if token_args is not None: _CURRENT_CALL_SITE_ARG_TAINTS.reset(token_args) + _CURRENT_L2_WORK_BUDGET.reset(token_budget) def _seed_parameters( @@ -1232,10 +1275,12 @@ def _process_stmt( Uses isinstance dispatch rather than match/case to avoid PY-WL-003 structural-gate findings at ASSURED taint (UNCONDITIONAL severity). """ + _charge_l2_work(1, "statement") if call_site_taints is not None: # Flow-sensitive snapshot: var taints AS THEY ARE before this statement # executes (after all prior siblings; branch-local inside if/try/match arms). # A sink rule reads this for a sink call's enclosing statement. + _charge_l2_work(len(var_taints), "statement_snapshot") call_site_taints[id(stmt)] = dict(var_taints) if isinstance(stmt, ast.Assign): @@ -1615,6 +1660,8 @@ def _branch_copy(parent: dict[str, list[ast.Lambda]] | None) -> dict[str, list[a (wardline-36016d26f3), mirroring how ``var_taints`` is copied per arm. The candidate LISTS are copied too, so an arm's rebind/removal cannot mutate the parent's or a sibling's set in place (wardline-383f83fafe).""" + if parent is not None: + _charge_l2_work(sum(1 + len(lams) for lams in parent.values()), "lambda_branch_copy") return {name: list(lams) for name, lams in parent.items()} if parent is not None else None @@ -1623,6 +1670,8 @@ def _types_branch_copy(parent: dict[str, list[str]] | None) -> dict[str, list[st (``None`` when types are not being tracked). Candidate LISTS are copied too, so an arm's strong update cannot mutate the parent's or a sibling's set in place — branch-local exactly like ``var_taints`` (wardline-b369c7d06c).""" + if parent is not None: + _charge_l2_work(sum(1 + len(types) for types in parent.values()), "type_branch_copy") return {name: list(types) for name, types in parent.items()} if parent is not None else None @@ -1655,14 +1704,24 @@ def _merge_branch_types( if parent is None: return merged: dict[str, list[str]] = {} + # Dedup FQNs (strings) via a per-name equality-set, NOT a nested ``fqn not in + # bucket`` scan: a chain of one-armed ``if flagK: x = ClsK()`` rebinds grows the + # candidate set to N, and the linear rescan made each merge O(bucket**2) → + # O(N**3) cumulative, the same DoS class as the lambda-binding merge + # (wardline-c797baf28b). The set is O(1) per insert and preserves the exact + # candidate set and first-seen insertion order the nested scan produced. + seen_fqns: dict[str, set[str]] = {} tracked_arms = [arm for arm in arms if arm is not None] for arm in tracked_arms: for name, types in arm.items(): if not types: continue # uphold the absent-or-non-empty invariant + _charge_l2_work(1 + len(types), "type_branch_merge") bucket = merged.setdefault(name, []) + seen = seen_fqns.setdefault(name, set()) for fqn in types: - if fqn not in bucket: + if fqn not in seen: + seen.add(fqn) bucket.append(fqn) for name, bucket in merged.items(): if UNTYPED_ARM_CANDIDATE not in bucket and any(not arm.get(name) for arm in tracked_arms): @@ -1729,17 +1788,28 @@ def _merge_branch_bindings( if parent is None: return merged: dict[str, list[ast.Lambda]] = {} + # Dedup by identity (ast nodes don't define __eq__) via a per-name id-set, NOT a + # nested ``any(... is ...)`` scan of the growing bucket: a chain of one-armed + # branches rebinding the same name grows the candidate set to N, and the linear + # rescan made each merge O(bucket**2), so an attacker-authored file with ~1100 + # such branches drove a DEFAULT-gate scan to O(N**3) / ~15s (wardline-c797baf28b). + # The id-set is O(1) per insert → O(bucket) per merge, and preserves the exact + # candidate set and first-seen insertion order the nested scan produced. ast nodes + # are live for the whole analysis (held by the tree), so an id can't be reused + # mid-merge — identity membership is sound. + seen_ids: dict[str, set[int]] = {} for arm in arms: if arm is None: continue for name, lams in arm.items(): if not lams: continue # uphold the empty-list-never-stored invariant (see docstring) + _charge_l2_work(1 + len(lams), "lambda_branch_merge") bucket = merged.setdefault(name, []) + seen = seen_ids.setdefault(name, set()) for lam in lams: - # Dedup by identity (ast nodes don't define __eq__): the same lambda - # carried unchanged through several arms should be resolved once. - if not any(lam is seen for seen in bucket): + if id(lam) not in seen: + seen.add(id(lam)) bucket.append(lam) parent.clear() parent.update(merged) @@ -1757,6 +1827,7 @@ def _handle_if( _resolve_expr(stmt.test, function_taint, taint_map, var_taints) # Snapshot before branches. + _charge_l2_work(len(var_taints), "branch_snapshot") pre_if = dict(var_taints) parent_lambdas = _CURRENT_LAMBDA_BINDINGS.get() parent_types = _CURRENT_VAR_TYPES.get() @@ -1764,6 +1835,7 @@ def _handle_if( # Walk the if-body with arm-local lambda-bindings and receiver-type copies — # branch-local like var_taints, so a lambda bound or class rebound here cannot # leak into the else arm. + _charge_l2_work(len(var_taints), "branch_var_copy") if_taints = dict(var_taints) if_lambdas = _branch_copy(parent_lambdas) if_types = _types_branch_copy(parent_types) @@ -1771,6 +1843,7 @@ def _handle_if( if stmt.orelse: # Walk the else-body on its own arm-local copies. + _charge_l2_work(len(var_taints), "branch_var_copy") else_taints = dict(var_taints) else_lambdas = _branch_copy(parent_lambdas) else_types = _types_branch_copy(parent_types) @@ -1792,6 +1865,7 @@ def _handle_if( # not clash to MIXED_RAW; a raw branch still propagates (least_trusted keeps # its rank). all_vars = set(if_taints) | set(else_taints) + _charge_l2_work(len(all_vars), "branch_merge") for var in all_vars: if_val = if_taints.get(var) else_val = else_taints.get(var) @@ -1819,6 +1893,7 @@ def _handle_for( ) # Snapshot pre-loop. + _charge_l2_work(len(var_taints), "loop_pre_state") pre_loop = dict(var_taints) # Lambda bindings are mutated in place on the shared map across iterations (no per-arm @@ -1852,13 +1927,16 @@ def _handle_for( # practice safety net (finite monotone lattice over a fixed local-name set guarantees it). iterations = 0 while True: + _charge_l2_work(1 + len(var_taints), "loop_iteration") current_state = dict(var_taints) # Assign the loop variable. _assign_target(stmt.target, iter_taint, var_taints) # Walk body. _walk_body(stmt.body, function_taint, taint_map, var_taints, call_site_taints) # Merge body state with pre-loop - for var in set(var_taints) | set(pre_loop): + merge_vars = set(var_taints) | set(pre_loop) + _charge_l2_work(len(merge_vars), "loop_merge") + for var in merge_vars: var_taints[var] = combine(var_taints.get(var, function_taint), pre_loop.get(var, function_taint)) iterations += 1 if var_taints == current_state: @@ -1895,6 +1973,7 @@ def _handle_while( call_site_taints: dict[int, dict[str, TaintState]] | None = None, ) -> None: """Handle while loops — body merges with pre-loop state.""" + _charge_l2_work(len(var_taints), "loop_pre_state") pre_loop = dict(var_taints) # The body is a conditionally-executed arm (a reachable 0-iteration path), so snapshot @@ -1909,10 +1988,13 @@ def _handle_while( # (wardline-e04db6e656). iterations = 0 while True: + _charge_l2_work(1 + len(var_taints), "loop_iteration") current_state = dict(var_taints) _resolve_expr(stmt.test, function_taint, taint_map, var_taints) _walk_body(stmt.body, function_taint, taint_map, var_taints, call_site_taints) - for var in set(var_taints) | set(pre_loop): + merge_vars = set(var_taints) | set(pre_loop) + _charge_l2_work(len(merge_vars), "loop_merge") + for var in merge_vars: var_taints[var] = combine(var_taints.get(var, function_taint), pre_loop.get(var, function_taint)) iterations += 1 if var_taints == current_state: diff --git a/tests/conformance/fixtures/filigree_federation_token_contract.json b/tests/conformance/fixtures/filigree_federation_token_contract.json new file mode 100644 index 00000000..a7825e9f --- /dev/null +++ b/tests/conformance/fixtures/filigree_federation_token_contract.json @@ -0,0 +1,50 @@ +{ + "contract": "weft/filigree-federation-bearer-token", + "_doc": "Wardline-AUTHORED vendored contract for filigree's WEFT_FEDERATION_TOKEN inbound bearer-token auth gate. Filigree is the PRODUCER/authority for this contract (it gates /api/weft/* + classic federation-write aliases + the dashboard /mcp transport); wardline is a CONSUMER (it RESOLVES a federation token and PRESENTS it as 'Authorization: Bearer ' on emit + verify_token). Filigree publishes NO machine-readable contract fixture: the contract lives in filigree source CONSTANTS (federation_token.py) + middleware LOGIC (dashboard_auth.py) + ADR-018. This file is wardline's vendored, byte-pinned restatement of the values + behaviour wardline depends on. The Layer-1 byte-pin (VENDORED_BLOB_SHA in the oracle test) freezes these bytes; the Layer-2 filigree_token_drift recheck re-reads the sibling filigree source and asserts the SUBSTANTIVE values below still match (it is intentionally NOT byte-exact against a sibling fixture, because filigree has none).", + "_provenance": { + "source_repo": "filigree", + "source_commit": "f59e423", + "source_files": [ + "src/filigree/federation_token.py (git blob 6b90251071f8c9eff56e9a35972044d3fdfcd664)", + "src/filigree/dashboard_auth.py", + "docs/architecture/decisions/ADR-018-weft-bearer-token-auth.md" + ], + "vendored_on": "2026-06-25", + "vendored_by": "wardline (consumer side restatement; filigree is the authority)" + }, + "env": { + "canonical_env_var": "WEFT_FEDERATION_TOKEN", + "deprecated_aliases": ["FILIGREE_FEDERATION_API_TOKEN", "FILIGREE_API_TOKEN"], + "read_order": ["WEFT_FEDERATION_TOKEN", "FILIGREE_FEDERATION_API_TOKEN", "FILIGREE_API_TOKEN"], + "empty_is_off": true, + "_note": "An env var set but blank/whitespace is skipped (federation auth not enabled from it). First non-empty wins, canonical first." + }, + "token_file": { + "filename": "federation_token", + "project_store_relpath": ".weft/filigree/federation_token", + "home_store_relpath": ".config/filigree/federation_token", + "_note": "Tier-2 auto-minted persistence. A sibling on the same host reads the file back from the .weft/ subtree; cross-host deployments use the env var (tier 1). The file is the only locally-readable mint wardline probes during doctor --repair." + }, + "header": { + "name": "Authorization", + "scheme": "Bearer", + "scheme_case_insensitive": true, + "format": "Bearer ", + "split_rule": "partition on the first single space; scheme compared case-insensitively; empty token after the scheme is rejected", + "example": "Authorization: Bearer " + }, + "status_ladder": { + "_doc": "Auth runs in middleware BEFORE body validation, so the status discriminates auth outcome independent of payload. This is the exact ladder wardline's FiligreeEmitter.verify_token() consumes.", + "missing_header_401": "no Authorization header, or a non-Bearer scheme, or an empty token => 401 PERMISSION + WWW-Authenticate: Bearer (token MISSING/MALFORMED)", + "wrong_token_401": "a syntactically-valid Bearer whose value does not match the resolved token => 401 PERMISSION + WWW-Authenticate: Bearer (token REJECTED)", + "forbidden_403": "token present but lacks access / blocked => 403 (a token won't help)", + "authed_bad_body_400": "auth PASSED then body validation rejected a deliberately-incomplete sentinel body (e.g. b'{}') => 400 (proves the bearer was accepted)", + "authed_ok_2xx": "auth PASSED and the body was accepted => 2xx" + }, + "consumer_verdict": { + "_doc": "How a wardline CONSUMER (FiligreeEmitter.verify_token / emit) MUST interpret the status ladder. accepted == bearer passed the middleware auth check.", + "accepted_statuses": [400, 200, 201, 202, 204], + "rejected_statuses": [401, 403], + "inconclusive_statuses_unreachable": "any other status (5xx outage, 404 wrong-route, 3xx redirect) did NOT exercise the bearer check => reachable=False, accepted=False (never pin a token on an unverified probe)" + } +} diff --git a/tests/conformance/fixtures/wardline-finding-identity-wire.golden.json b/tests/conformance/fixtures/wardline-finding-identity-wire.golden.json new file mode 100644 index 00000000..4eec05f0 --- /dev/null +++ b/tests/conformance/fixtures/wardline-finding-identity-wire.golden.json @@ -0,0 +1,141 @@ +{ + "contract": "weft/wardline-finding-identity-wire", + "fingerprint_scheme": "wlfp2", + "vectors": { + "collision_pair_a": { + "bare_fingerprint": "0216b7ecd449bd36bdeaeb677d879edcecfed1377917329c794c391b057ab16c", + "inputs": { + "location": { + "col_end": 20, + "col_start": 8, + "line_end": 8, + "line_start": 8, + "path": "src/app/db.py" + }, + "path": "src/app/db.py", + "qualname": "app.db.run", + "rule_id": "PY-WL-118", + "taint_path": "execute@8:20" + }, + "spans": { + "col_end": 20, + "col_start": 8, + "line_end": 8, + "line_start": 8, + "path": "src/app/db.py" + }, + "stamped_fingerprint": "wlfp2:0216b7ecd449bd36bdeaeb677d879edcecfed1377917329c794c391b057ab16c", + "wire_fingerprint": "0216b7ecd449bd36bdeaeb677d879edcecfed1377917329c794c391b057ab16c", + "wire_jsonl_qualname": "app.db.run", + "wire_qualname": "app.db.run" + }, + "collision_pair_b": { + "bare_fingerprint": "769311da39324ea164f0159f684724318cb3dc45450b53c13c122ade4ba98d73", + "inputs": { + "location": { + "col_end": 42, + "col_start": 30, + "line_end": 8, + "line_start": 8, + "path": "src/app/db.py" + }, + "path": "src/app/db.py", + "qualname": "app.db.run", + "rule_id": "PY-WL-118", + "taint_path": "execute@30:42" + }, + "spans": { + "col_end": 42, + "col_start": 30, + "line_end": 8, + "line_start": 8, + "path": "src/app/db.py" + }, + "stamped_fingerprint": "wlfp2:769311da39324ea164f0159f684724318cb3dc45450b53c13c122ade4ba98d73", + "wire_fingerprint": "769311da39324ea164f0159f684724318cb3dc45450b53c13c122ade4ba98d73", + "wire_jsonl_qualname": "app.db.run", + "wire_qualname": "app.db.run" + }, + "property_setter_qualname": { + "bare_fingerprint": "65f22bbcb2e90af0597f5384024d8cde9d78a9ad58a436fdf9f9da1898d8d188", + "inputs": { + "location": { + "col_end": 18, + "col_start": 4, + "line_end": 23, + "line_start": 21, + "path": "src/app/model.py" + }, + "path": "src/app/model.py", + "qualname": "app.model.Config.value:setter", + "rule_id": "PY-WL-105", + "taint_path": null + }, + "spans": { + "col_end": 18, + "col_start": 4, + "line_end": 23, + "line_start": 21, + "path": "src/app/model.py" + }, + "stamped_fingerprint": "wlfp2:65f22bbcb2e90af0597f5384024d8cde9d78a9ad58a436fdf9f9da1898d8d188", + "wire_fingerprint": "65f22bbcb2e90af0597f5384024d8cde9d78a9ad58a436fdf9f9da1898d8d188", + "wire_jsonl_qualname": "app.model.Config.value:setter", + "wire_qualname": "app.model.Config.value" + }, + "rust_qualname_span": { + "bare_fingerprint": "23cb46bd9151fd832a715c53e7cd643a7e6dbc062b3c904288b816ac6c530f35", + "inputs": { + "location": { + "col_end": null, + "col_start": null, + "line_end": 42, + "line_start": 42, + "path": "src/main.rs" + }, + "path": "src/main.rs", + "qualname": "main::run", + "rule_id": "PY-WL-101", + "taint_path": null + }, + "spans": { + "col_end": null, + "col_start": null, + "line_end": 42, + "line_start": 42, + "path": "src/main.rs" + }, + "stamped_fingerprint": "wlfp2:23cb46bd9151fd832a715c53e7cd643a7e6dbc062b3c904288b816ac6c530f35", + "wire_fingerprint": "23cb46bd9151fd832a715c53e7cd643a7e6dbc062b3c904288b816ac6c530f35", + "wire_jsonl_qualname": "main::run", + "wire_qualname": "main::run" + }, + "singleton_no_taint_path": { + "bare_fingerprint": "a2cde44a864042c85eb0993d0ef38d5bd8812da892aed2e4688dd8566693d142", + "inputs": { + "location": { + "col_end": 20, + "col_start": 4, + "line_end": 14, + "line_start": 12, + "path": "src/app/handler.py" + }, + "path": "src/app/handler.py", + "qualname": "app.handler.handle", + "rule_id": "PY-WL-101", + "taint_path": null + }, + "spans": { + "col_end": 20, + "col_start": 4, + "line_end": 14, + "line_start": 12, + "path": "src/app/handler.py" + }, + "stamped_fingerprint": "wlfp2:a2cde44a864042c85eb0993d0ef38d5bd8812da892aed2e4688dd8566693d142", + "wire_fingerprint": "a2cde44a864042c85eb0993d0ef38d5bd8812da892aed2e4688dd8566693d142", + "wire_jsonl_qualname": "app.handler.handle", + "wire_qualname": "app.handler.handle" + } + } +} diff --git a/tests/conformance/fixtures/wardline-scan-results-wire.golden.json b/tests/conformance/fixtures/wardline-scan-results-wire.golden.json new file mode 100644 index 00000000..164404be --- /dev/null +++ b/tests/conformance/fixtures/wardline-scan-results-wire.golden.json @@ -0,0 +1,136 @@ +{ + "findings": [ + { + "fingerprint": "wlfp2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "language": "python", + "line_end": 14, + "line_start": 12, + "message": "untrusted value reaches trusted sink", + "metadata": { + "wardline": { + "confidence": 0.9, + "fingerprint": "wlfp2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "internal_severity": "ERROR", + "kind": "defect", + "properties": { + "sink": "os.system", + "tier": "untrusted" + }, + "qualname": "app.handler.handle", + "related_entities": [ + "python:function:app.handler.read_raw" + ] + } + }, + "path": "src/app/handler.py", + "rule_id": "PY-WL-101", + "severity": "high", + "suggestion": "validate at the boundary before the sink" + }, + { + "fingerprint": "wlfp2:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "language": "rust", + "line_end": 42, + "line_start": 42, + "message": "tainted data flows to command execution", + "metadata": { + "wardline": { + "fingerprint": "wlfp2:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "internal_severity": "CRITICAL", + "kind": "defect", + "qualname": "main::run" + } + }, + "path": "src/main.rs", + "rule_id": "RS-WL-108", + "severity": "critical" + }, + { + "fingerprint": "wlfp2:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "language": "python", + "line_end": 3, + "line_start": 3, + "message": "external boundary detected", + "metadata": { + "wardline": { + "fingerprint": "wlfp2:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "internal_severity": "NONE", + "kind": "fact", + "qualname": "app.io.fetch" + } + }, + "path": "src/app/io.py", + "rule_id": "WLN-BOUNDARY-FACT", + "severity": "info" + }, + { + "fingerprint": "wlfp2:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "language": "python", + "line_end": 7, + "line_start": 7, + "message": "boundary classification", + "metadata": { + "wardline": { + "fingerprint": "wlfp2:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "internal_severity": "INFO", + "kind": "classification", + "qualname": "app.io.fetch", + "suppression_reason": "reviewed false positive", + "suppression_state": "waived" + } + }, + "path": "src/app/io.py", + "rule_id": "PY-WL-120", + "severity": "low" + }, + { + "fingerprint": "wlfp2:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "language": "python", + "line_end": null, + "line_start": 1, + "message": "decorator coverage 80%", + "metadata": { + "wardline": { + "fingerprint": "wlfp2:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "internal_severity": "NONE", + "kind": "metric", + "properties": { + "coverage": 0.8 + } + } + }, + "path": "src/app/io.py", + "rule_id": "WLN-METRIC-COVERAGE", + "severity": "info" + }, + { + "fingerprint": "wlfp2:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "language": "python", + "line_end": 33, + "line_start": 30, + "message": "consider narrowing the boundary", + "metadata": { + "wardline": { + "fingerprint": "wlfp2:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "internal_severity": "WARN", + "kind": "suggestion", + "qualname": "app.util.helper", + "suppression_state": "baselined" + } + }, + "path": "src/app/util.py", + "rule_id": "PY-WL-126", + "severity": "medium", + "suggestion": "annotate @external_boundary" + } + ], + "fingerprint_scheme": "wlfp2", + "mark_unseen": true, + "scan_source": "wardline", + "scanned_paths": [ + "src/app/handler.py", + "src/main.rs", + "src/app/io.py", + "src/app/util.py" + ] +} diff --git a/tests/conformance/fixtures/wardline-taint-fact-wire.golden.json b/tests/conformance/fixtures/wardline-taint-fact-wire.golden.json new file mode 100644 index 00000000..297ea60e --- /dev/null +++ b/tests/conformance/fixtures/wardline-taint-fact-wire.golden.json @@ -0,0 +1,85 @@ +[ + { + "content_hash_at_compute": "beb500c7a64f99d4cfb1e96b5f003b05c35326f2b1d73d7b2bf6225ab1307875", + "qualname": "svc.read_raw", + "wardline_json": { + "content_hash_at_compute": "beb500c7a64f99d4cfb1e96b5f003b05c35326f2b1d73d7b2bf6225ab1307875", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [], + "qualname": "svc.read_raw", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "EXTERNAL_RAW", + "resolved_call_count": 0, + "source": "anchored", + "unresolved_call_count": 0 + } + } + }, + { + "content_hash_at_compute": "beb500c7a64f99d4cfb1e96b5f003b05c35326f2b1d73d7b2bf6225ab1307875", + "qualname": "svc.helper", + "wardline_json": { + "content_hash_at_compute": "beb500c7a64f99d4cfb1e96b5f003b05c35326f2b1d73d7b2bf6225ab1307875", + "dead_code_root": { + "is_root": false, + "reason": null, + "source": null, + "tags": [] + }, + "findings": [], + "qualname": "svc.helper", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "UNKNOWN_RAW", + "contributing_callee_qualname": null, + "declared_return": "UNKNOWN_RAW", + "resolved_call_count": 0, + "source": "fallback", + "unresolved_call_count": 0 + } + } + }, + { + "content_hash_at_compute": "beb500c7a64f99d4cfb1e96b5f003b05c35326f2b1d73d7b2bf6225ab1307875", + "qualname": "svc.leaky", + "wardline_json": { + "content_hash_at_compute": "beb500c7a64f99d4cfb1e96b5f003b05c35326f2b1d73d7b2bf6225ab1307875", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "242d8565123394582c282d7356cd18a1ddcbe2a4dca9d51bce9d5afaec70230a", + "line_start": 14, + "path": "svc.py", + "rule_id": "PY-WL-101" + } + ], + "qualname": "svc.leaky", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": "svc.read_raw", + "declared_return": "INTEGRAL", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 0 + } + } + } +] diff --git a/tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml b/tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml new file mode 100644 index 00000000..f5ad8d23 --- /dev/null +++ b/tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml @@ -0,0 +1,14 @@ +schema: wardline.vocabulary/v1 +version: wardline-generic-2 +entries: +- canonical_name: external_boundary + group: 1 + attrs: {} +- canonical_name: trust_boundary + group: 1 + attrs: + _wardline_to_level: TaintState +- canonical_name: trusted + group: 1 + attrs: + _wardline_level: TaintState diff --git a/tests/conformance/fixtures/warpline_contract/mcp-response-reverify.json b/tests/conformance/fixtures/warpline_contract/mcp-response-reverify.json new file mode 100644 index 00000000..dabfc654 --- /dev/null +++ b/tests/conformance/fixtures/warpline_contract/mcp-response-reverify.json @@ -0,0 +1,61 @@ +{ + "schema": "warpline.reverify_worklist.v1", + "ok": true, + "query": { + "repo": "/abs/path", + "tool": "warpline_reverify_worklist_get", + "arguments": {"rev_range": "HEAD~1..HEAD", "changed_entity_key_ids": [1], "depth": 2}, + "filters": {}, + "sort": {"by": "priority", "order": "asc"}, + "page": {"limit": 100, "cursor": null} + }, + "data": { + "completeness": "NO_SNAPSHOT", + "staleness": {"snapshot_commit": null, "commits_behind": null}, + "resolved": [ + { + "ref": {"kind": "warpline_entity_key_id", "value": 1}, + "entity_key_id": 1, + "sei": "loomweave:eid:0123456789abcdef0123456789abcdef", + "locator": "python:function:src/pkg/mod.py::fn" + } + ], + "unresolved": [], + "items": [ + { + "entity": { + "locator": "python:function:src/pkg/mod.py::fn", + "sei": "loomweave:eid:0123456789abcdef0123456789abcdef" + }, + "priority": "unknown", + "reason": "changed", + "depth": 0, + "why": [], + "suggested_verification": [ + {"kind": "test", "command": "run tests touching this entity if known"}, + {"kind": "inspection", "command": "inspect callers and behavior at this boundary"} + ], + "enrichment": {"work": [], "risk": [], "governance": [], "requirements": []} + } + ], + "next_actions": {"filigree": []}, + "page": {"limit": 100, "next_cursor": null, "has_more": false} + }, + "warnings": [ + "NO_SNAPSHOT: downstream traversal unavailable; changed set only" + ], + "next_actions": {"filigree": []}, + "enrichment": { + "sei": "absent", + "edges": "absent", + "work": "unavailable", + "risk": "unavailable", + "governance": "unavailable", + "requirements": "unavailable" + }, + "meta": { + "producer": {"tool": "warpline", "version": "0.1.0"}, + "local_only": true, + "peer_side_effects": [] + } +} diff --git a/tests/conformance/fixtures/weft-reason-vocab.json b/tests/conformance/fixtures/weft-reason-vocab.json new file mode 100644 index 00000000..948f1d4b --- /dev/null +++ b/tests/conformance/fixtures/weft-reason-vocab.json @@ -0,0 +1,22 @@ +{ + "$comment": "Canonical weft-reason vocabulary (G1). Source of truth doc: pm/2026-06-15-weft-reason-contract-G1.md. CONTRACT: every federation member's emitted reason_class values MUST be a subset of reason_classes below; every non-clean carrier MUST include cause + fix; a clean carrier omits cause + fix. Members stay independent repos (no shared runtime dep) and conform by a per-member conformance TEST that asserts its reason surface against this list. Domain-specific terms (e.g. legis key_absent) map to a canonical reason_class and keep the domain term in cause/detail.", + "version": 1, + "carrier": { + "fields": ["reason_class", "cause", "fix"], + "required_on_non_clean": ["reason_class", "cause", "fix"], + "clean_omits": ["cause", "fix"] + }, + "reason_classes": { + "clean": { "trust": true, "meaning": "earned, complete true-negative" }, + "disabled": { "trust": false, "meaning": "capability not configured / not on" }, + "unresolved_input": { "trust": false, "meaning": "a supplied ref/id did not resolve" }, + "rejected": { "trust": false, "meaning": "peer reached, refused the item" }, + "dead_path": { "trust": false, "meaning": "handler / code path unwired" }, + "unreachable": { "trust": false, "meaning": "peer / dependency not reached" }, + "misrouted": { "trust": false, "meaning": "went to the wrong destination" }, + "error": { "trust": false, "meaning": "unexpected internal failure (loud catch-all, never silent)" }, + "scheme_mismatch": { "trust": false, "meaning": "identity / fingerprint scheme drift (silent-corruption risk)" }, + "stale": { "trust": "qualified", "meaning": "real but older than the live anchor" }, + "partial": { "trust": "qualified", "meaning": "bounded / capped / some-failed" } + } +} diff --git a/tests/conformance/mcp_output_schemas.golden.json b/tests/conformance/mcp_output_schemas.golden.json new file mode 100644 index 00000000..8341a594 --- /dev/null +++ b/tests/conformance/mcp_output_schemas.golden.json @@ -0,0 +1,4158 @@ +{ + "assure": { + "additionalProperties": false, + "description": "Trust-surface coverage posture: how many declared trust boundaries got a definite verdict vs. how many are honestly unknown, plus waiver debt. Identical to the CLI `assure` JSON.", + "properties": { + "baselined_total": { + "description": "Findings suppressed by the baseline in this scan.", + "type": "integer" + }, + "boundaries_total": { + "description": "Denominator: count of anchored (trust-declared) entities. proven + defect_total + len(unknown) == boundaries_total.", + "type": "integer" + }, + "coverage_pct": { + "description": "Share of known boundaries with a definite verdict over known boundaries plus unanalyzed files, rounded to 1 decimal. null when there are no boundaries and no unanalyzed files.", + "type": [ + "number", + "null" + ] + }, + "defect_total": { + "description": "Boundaries with a definite defect verdict (a defect counts as COVERED \u2014 the engine reached a verdict).", + "type": "integer" + }, + "engine_limited": { + "description": "Under-scan pressure: entity-level unknowns with an engine reason plus unanalyzed files.", + "type": "integer" + }, + "judged_total": { + "description": "Findings suppressed by judge verdicts in this scan.", + "type": "integer" + }, + "proven": { + "description": "Boundaries with a definite clean verdict.", + "type": "integer" + }, + "unanalyzed_rule_ids": { + "description": "Distinct under-scan rule ids present in the scan findings, sorted lexicographically.", + "items": { + "type": "string" + }, + "type": "array" + }, + "unanalyzed_total": { + "description": "Files discovered but never analyzed. Each counts as at least one uncovered surface item in coverage_pct.", + "type": "integer" + }, + "unknown": { + "description": "The honesty gap: anchored entities whose trust could not be proven either way, sorted by qualname.", + "items": { + "additionalProperties": false, + "properties": { + "location": { + "additionalProperties": false, + "description": "Where the entity is declared; both fields null when no location is known.", + "properties": { + "line": { + "type": [ + "integer", + "null" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "path", + "line" + ], + "type": "object" + }, + "qualname": { + "description": "Qualified name of the anchored entity.", + "type": "string" + }, + "reason": { + "description": "Engine under-scan FACT message when the body was not analysed (parse/recursion skip), else null (undeclared / unprovable).", + "type": [ + "string", + "null" + ] + }, + "tier": { + "description": "Declared trust tier, or null if undeclared.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "qualname", + "tier", + "location", + "reason" + ], + "type": "object" + }, + "type": "array" + }, + "waiver_debt": { + "description": "Configured waivers with days-to-expiry, sorted by fingerprint. Populated even when nothing was analysable.", + "items": { + "additionalProperties": false, + "properties": { + "days_left": { + "description": "Days until expiry; may be NEGATIVE for a lapsed waiver; null for no expiry.", + "type": [ + "integer", + "null" + ] + }, + "expires": { + "description": "ISO date the waiver expires, or null for no expiry.", + "type": [ + "string", + "null" + ] + }, + "fingerprint": { + "description": "Finding fingerprint the waiver covers.", + "type": "string" + }, + "reason": { + "description": "The waiver's recorded justification.", + "type": "string" + } + }, + "required": [ + "fingerprint", + "expires", + "days_left", + "reason" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "boundaries_total", + "proven", + "defect_total", + "unknown", + "engine_limited", + "coverage_pct", + "unanalyzed_total", + "unanalyzed_rule_ids", + "waiver_debt", + "baselined_total", + "judged_total" + ], + "type": "object" + }, + "attest": { + "additionalProperties": false, + "description": "Signed, reproducible evidence bundle: a deterministic payload plus an HMAC-SHA256 signature under the shared project key (tamper-evidence within the key-holding trust domain, not asymmetric proof).", + "properties": { + "payload": { + "additionalProperties": false, + "description": "The signed, deterministic attestation payload (canonical compact key-sorted JSON is the reproducibility target).", + "properties": { + "attested_at": { + "description": "ISO date the bundle was built; re-derivation on verify uses this as `today`.", + "type": "string" + }, + "boundaries": { + "description": "Per-boundary trust verdicts, sorted by qualname; empty when nothing was analysable.", + "items": { + "additionalProperties": false, + "properties": { + "qualname": { + "description": "Qualified name of the anchored entity.", + "type": "string" + }, + "sei": { + "description": "Loomweave SEI resolved at build time; null without a Loomweave client or when unresolvable.", + "type": [ + "string", + "null" + ] + }, + "tier": { + "description": "Declared trust tier, or null if undeclared.", + "type": [ + "string", + "null" + ] + }, + "verdict": { + "description": "The entity's trust verdict from the single source of truth (classify_entity_trust).", + "enum": [ + "clean", + "defect", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "qualname", + "sei", + "verdict", + "tier" + ], + "type": "object" + }, + "type": "array" + }, + "commit": { + "description": "`git rev-parse HEAD` of the tree, or null for a non-git tree / missing git.", + "type": [ + "string", + "null" + ] + }, + "dirty": { + "description": "True iff the working tree had uncommitted changes (MCP refuses dirty trees unless allow_dirty=true).", + "type": "boolean" + }, + "posture": { + "additionalProperties": false, + "description": "The trust-surface coverage posture at attestation time (same shape as the `assure` tool result).", + "properties": { + "baselined_total": { + "type": "integer" + }, + "boundaries_total": { + "type": "integer" + }, + "coverage_pct": { + "description": "null when there are no boundaries and no unanalyzed files.", + "type": [ + "number", + "null" + ] + }, + "defect_total": { + "type": "integer" + }, + "engine_limited": { + "type": "integer" + }, + "judged_total": { + "type": "integer" + }, + "proven": { + "type": "integer" + }, + "unanalyzed_rule_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "unanalyzed_total": { + "type": "integer" + }, + "unknown": { + "description": "Anchored entities with no definite verdict, sorted by qualname.", + "items": { + "additionalProperties": false, + "properties": { + "location": { + "additionalProperties": false, + "properties": { + "line": { + "type": [ + "integer", + "null" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "path", + "line" + ], + "type": "object" + }, + "qualname": { + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "tier": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "qualname", + "tier", + "location", + "reason" + ], + "type": "object" + }, + "type": "array" + }, + "waiver_debt": { + "description": "Configured waivers with days-to-expiry (the only date-sensitive payload field), sorted by fingerprint.", + "items": { + "additionalProperties": false, + "properties": { + "days_left": { + "type": [ + "integer", + "null" + ] + }, + "expires": { + "type": [ + "string", + "null" + ] + }, + "fingerprint": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "fingerprint", + "expires", + "days_left", + "reason" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "boundaries_total", + "proven", + "defect_total", + "unknown", + "engine_limited", + "coverage_pct", + "unanalyzed_total", + "unanalyzed_rule_ids", + "waiver_debt", + "baselined_total", + "judged_total" + ], + "type": "object" + }, + "ruleset_hash": { + "description": "Deterministic 'sha256:' over the effective scan policy.", + "type": "string" + }, + "sei_source": { + "description": "'loomweave' iff a client was supplied AND at least one SEI resolved; else 'unavailable'.", + "enum": [ + "loomweave", + "unavailable" + ], + "type": "string" + }, + "wardline_version": { + "description": "Wardline version that produced the bundle.", + "type": "string" + } + }, + "required": [ + "wardline_version", + "attested_at", + "commit", + "dirty", + "ruleset_hash", + "posture", + "boundaries", + "sei_source" + ], + "type": "object" + }, + "schema": { + "description": "Wire-contract tag; bound into the HMAC so a relabel cannot verify.", + "enum": [ + "wardline-attest-1" + ], + "type": "string" + }, + "signature": { + "additionalProperties": false, + "description": "HMAC-SHA256 over the canonical {schema, payload} envelope bytes.", + "properties": { + "alg": { + "enum": [ + "HMAC-SHA256" + ], + "type": "string" + }, + "key_id": { + "description": "Non-secret 8-hex short id of the signing key (distinguishes keys without revealing them).", + "type": "string" + }, + "value": { + "description": "Hex HMAC digest.", + "type": "string" + } + }, + "required": [ + "alg", + "value", + "key_id" + ], + "type": "object" + } + }, + "required": [ + "schema", + "payload", + "signature" + ], + "type": "object" + }, + "baseline": { + "additionalProperties": false, + "description": "Success payload of the wardline MCP `baseline` tool: records the result of generating (or finding an existing) suppression baseline for the project.", + "properties": { + "already_exists": { + "description": "Only present when overwrite was not requested: true if a baseline file already existed (nothing was written; counts reflect the existing file), false if a new baseline was created. Absent when overwrite=true succeeds.", + "type": "boolean" + }, + "baselined_count": { + "description": "Number of finding fingerprints in the baseline. On a fresh generation this is the count just written; when the baseline already existed (already_exists=true) it is the count in the existing file.", + "type": "integer" + }, + "path": { + "description": "Absolute path of the baseline file.", + "type": "string" + }, + "reason": { + "description": "Caller-supplied reason for creating the baseline; null when not provided.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "baselined_count", + "path", + "reason" + ], + "type": "object" + }, + "decorator_coverage": { + "additionalProperties": false, + "description": "Row-level inventory of every trust-decorated entity under the project (the row-level sibling of assure): each declared trust-surface entity with its current verdict, findings, identity binding, and open-work state, plus a rollup summary.", + "properties": { + "rows": { + "description": "One row per declared trust-decorated entity, sorted by qualname. Empty when the scan produced no analysis context.", + "items": { + "additionalProperties": false, + "properties": { + "active_finding_fingerprints": { + "description": "Sorted fingerprints of active (non-suppressed) defect findings on the entity.", + "items": { + "type": "string" + }, + "type": "array" + }, + "actual_tier": { + "description": "Engine-computed actual return taint; null when not computed.", + "type": [ + "string", + "null" + ] + }, + "declared_tier": { + "description": "Declared return trust tier; null when undeclared.", + "type": [ + "string", + "null" + ] + }, + "decorators": { + "description": "Decorators as declared, each prefixed with '@'.", + "items": { + "type": "string" + }, + "type": "array" + }, + "finding_state": { + "description": "defect when active findings exist; suppressed when only accepted findings exist; unknown when the verdict is unknown; else clean.", + "enum": [ + "defect", + "suppressed", + "unknown", + "clean" + ], + "type": "string" + }, + "identity": { + "additionalProperties": false, + "description": "Cross-tool identity binding for the row. Degrades to available=false with a reason when Loomweave is not configured / unreachable / resolved no SEI.", + "properties": { + "available": { + "description": "True only when an SEI was resolved for the entity.", + "type": "boolean" + }, + "content_hash": { + "description": "Current content hash from the binding, when available.", + "type": [ + "string", + "null" + ] + }, + "content_status": { + "description": "Content axis: has the entity's code changed?", + "enum": [ + "fresh", + "stale", + "unknown" + ], + "type": "string" + }, + "identity_status": { + "description": "Identity axis: is this the same entity?", + "enum": [ + "alive", + "orphaned", + "unavailable" + ], + "type": "string" + }, + "locator": { + "description": "The Loomweave-style locator, e.g. python:function:pkg.mod.func.", + "type": "string" + }, + "reason": { + "description": "Why identity is unavailable (e.g. 'loomweave not configured', 'no SEI resolved'); null when an SEI was resolved.", + "type": [ + "string", + "null" + ] + }, + "sei": { + "description": "Opaque stable entity identifier; null when not resolved.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "available", + "locator", + "sei", + "identity_status", + "content_status", + "content_hash", + "reason" + ], + "type": "object" + }, + "line": { + "description": "Entity start line; null when the declared qualname has no scanned entity.", + "type": [ + "integer", + "null" + ] + }, + "path": { + "description": "Source file path; null when the declared qualname has no scanned entity.", + "type": [ + "string", + "null" + ] + }, + "qualname": { + "description": "The entity's qualified name, minted relative to the scan root.", + "type": "string" + }, + "suppressed_finding_fingerprints": { + "description": "Sorted fingerprints of accepted (baselined/waived/judged) defect findings.", + "items": { + "type": "string" + }, + "type": "array" + }, + "verdict": { + "description": "Three-valued, fail-closed trust verdict for the entity.", + "enum": [ + "defect", + "clean", + "unknown" + ], + "type": "string" + }, + "work": { + "additionalProperties": false, + "description": "Open work from Filigree, keyed on the SEI. available=false with a reason when Filigree is not configured / unreachable / there is no SEI binding.", + "properties": { + "available": { + "type": "boolean" + }, + "content_status": { + "description": "stale when any ticket binding drifted; unknown when a compare was impossible.", + "enum": [ + "fresh", + "stale", + "unknown" + ], + "type": "string" + }, + "identity_status": { + "enum": [ + "alive", + "orphaned", + "unavailable" + ], + "type": "string" + }, + "reason": { + "description": "Why the section is unavailable; null when available.", + "type": [ + "string", + "null" + ] + }, + "tickets": { + "description": "Filigree issues bound to the entity.", + "items": { + "additionalProperties": false, + "properties": { + "drift": { + "description": "True when the issue was bound to a PRIOR version of the entity (content hash at attach no longer matches).", + "type": "boolean" + }, + "issue_id": { + "type": "string" + }, + "priority": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "issue_id", + "status", + "priority", + "title", + "drift" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "available", + "tickets", + "identity_status", + "content_status", + "reason" + ], + "type": "object" + } + }, + "required": [ + "qualname", + "path", + "line", + "decorators", + "declared_tier", + "actual_tier", + "verdict", + "finding_state", + "active_finding_fingerprints", + "suppressed_finding_fingerprints", + "identity", + "work" + ], + "type": "object" + }, + "type": "array" + }, + "summary": { + "additionalProperties": false, + "description": "Rollup counts over rows by finding_state.", + "properties": { + "clean": { + "description": "Rows with finding_state == clean.", + "type": "integer" + }, + "defect": { + "description": "Rows with finding_state == defect.", + "type": "integer" + }, + "suppressed": { + "description": "Rows with finding_state == suppressed.", + "type": "integer" + }, + "total": { + "description": "Total declared trust-surface entities.", + "type": "integer" + }, + "unknown": { + "description": "Rows with finding_state == unknown.", + "type": "integer" + } + }, + "required": [ + "total", + "clean", + "defect", + "unknown", + "suppressed" + ], + "type": "object" + } + }, + "required": [ + "summary", + "rows" + ], + "type": "object" + }, + "doctor": { + "additionalProperties": false, + "description": "Doctor success payload: the CLI `doctor --fix` machine-readable envelope (install/federation health checks) plus the running MCP server's self-identification block and a `server.freshness` check appended to the check list.", + "properties": { + "checks": { + "description": "Uniform health-check verdicts: wardline.config, mcp.registration, marker_package, loomweave.url, filigree.url, decorator_grammar, scan.output_path, auth.token, filigree.auth, optionally doctor.filigree_url for rejected MCP caller input, then server.freshness last.", + "items": { + "additionalProperties": false, + "properties": { + "fixed": { + "description": "True when this run's repair (repair: true) corrected the underlying condition.", + "type": "boolean" + }, + "id": { + "description": "Stable check identifier, e.g. 'wardline.config' or 'server.freshness'.", + "type": "string" + }, + "message": { + "description": "Human/agent-readable detail. Present only when the check produced a non-empty message (always on errors; sometimes on informational ok results).", + "type": "string" + }, + "status": { + "description": "Check verdict.", + "enum": [ + "ok", + "error" + ], + "type": "string" + } + }, + "required": [ + "id", + "status", + "fixed" + ], + "type": "object" + }, + "type": "array" + }, + "next_actions": { + "description": "One ': ' action line per failed check that carries a message; empty when everything is healthy.", + "items": { + "type": "string" + }, + "type": "array" + }, + "ok": { + "description": "True only when every check (including the appended server.freshness check) passed.", + "type": "boolean" + }, + "server": { + "additionalProperties": false, + "description": "The running MCP server's self-identification: detects a stale long-lived server (source on disk newer than process start).", + "properties": { + "fresh": { + "description": "False when on-disk package source changed after this process started \u2014 the server is serving OLD code and must be restarted.", + "type": "boolean" + }, + "package_version": { + "description": "Installed wardline package version.", + "type": "string" + }, + "pid": { + "description": "Server process id.", + "type": "integer" + }, + "project_root": { + "description": "Absolute project root this server is confined to.", + "type": "string" + }, + "source_latest_mtime": { + "description": "ISO-8601 UTC mtime of the newest *.py file under the imported wardline package, or null when nothing was statable.", + "type": [ + "string", + "null" + ] + }, + "source_latest_path": { + "description": "Package-relative path of that newest source file, or null.", + "type": [ + "string", + "null" + ] + }, + "started_at": { + "description": "ISO-8601 UTC timestamp of server process start.", + "type": "string" + } + }, + "required": [ + "package_version", + "pid", + "project_root", + "started_at", + "source_latest_mtime", + "source_latest_path", + "fresh" + ], + "type": "object" + } + }, + "required": [ + "ok", + "checks", + "next_actions", + "server" + ], + "type": "object" + }, + "dossier": { + "additionalProperties": false, + "description": "One-call entity dossier envelope: Wardline's own trust posture (always re-derived fresh) plus Loomweave linkages and Filigree open work, each cross-tool section degrading to an honest unavailable shape (available=false + reason) when its source is absent. Token-bounded with an explicit truncation marker.", + "properties": { + "identity": { + "additionalProperties": false, + "description": "Who the entity is plus its two-axis freshness (identity axis / content axis). Never trimmed by the budgeter.", + "properties": { + "content_hash": { + "description": "Current content hash from the binding, when available.", + "type": [ + "string", + "null" + ] + }, + "content_status": { + "description": "Content axis: has the entity's code changed? Never inferred from identity.", + "enum": [ + "fresh", + "stale", + "unknown" + ], + "type": "string" + }, + "identity_status": { + "description": "Identity axis: is this the same entity? Never inferred from content.", + "enum": [ + "alive", + "orphaned", + "unavailable" + ], + "type": "string" + }, + "keyed_on_sei": { + "description": "True when the cross-tool sections were keyed on the SEI rather than a locator.", + "type": "boolean" + }, + "kind": { + "description": "Entity kind (e.g. function); null when unknown.", + "type": [ + "string", + "null" + ] + }, + "line_end": { + "type": [ + "integer", + "null" + ] + }, + "line_start": { + "type": [ + "integer", + "null" + ] + }, + "path": { + "description": "Source file path of the entity.", + "type": [ + "string", + "null" + ] + }, + "qualname": { + "description": "The entity's qualified name, minted relative to the scan root.", + "type": "string" + }, + "sei": { + "description": "Opaque stable entity identifier (the cross-tool binding key); null when no Loomweave binding was resolved.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "qualname", + "kind", + "path", + "line_start", + "line_end", + "sei", + "keyed_on_sei", + "identity_status", + "content_status", + "content_hash" + ], + "type": "object" + }, + "linkages": { + "additionalProperties": false, + "description": "Call-graph neighbourhood from Loomweave. available=false with a reason when Loomweave is not configured / unreachable / serves no HTTP linkages.", + "properties": { + "available": { + "type": "boolean" + }, + "callees": { + "description": "Callee entity locators; empty when unavailable. May be trimmed by the token budgeter.", + "items": { + "type": "string" + }, + "type": "array" + }, + "callers": { + "description": "Caller entity locators; empty when unavailable. May be trimmed by the token budgeter.", + "items": { + "type": "string" + }, + "type": "array" + }, + "content_status": { + "enum": [ + "fresh", + "stale", + "unknown" + ], + "type": "string" + }, + "identity_status": { + "enum": [ + "alive", + "orphaned", + "unavailable" + ], + "type": "string" + }, + "reason": { + "description": "Why the section is unavailable or degraded (e.g. one-sided linkage failure); null when fully available.", + "type": [ + "string", + "null" + ] + }, + "scc_peers": { + "description": "Strongly-connected-component peers (currently always empty: SCC membership is not served over HTTP).", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "available", + "callers", + "callees", + "scc_peers", + "identity_status", + "content_status", + "reason" + ], + "type": "object" + }, + "shape": { + "additionalProperties": false, + "description": "Signature and decorators as declared in source.", + "properties": { + "decorators": { + "description": "Decorators as declared, each prefixed with '@'.", + "items": { + "type": "string" + }, + "type": "array" + }, + "signature": { + "description": "Rendered signature, e.g. \"(p) -> str\".", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "signature", + "decorators" + ], + "type": "object" + }, + "synthesis": { + "description": "Best-effort one-paragraph actionable join of all sections; null when dropped to fit the token budget.", + "type": [ + "string", + "null" + ] + }, + "truncation": { + "additionalProperties": false, + "description": "Elision-honest truncation marker: truncated=false on a complete envelope; when true, elided names every trimmed list with shown-of-total counts.", + "properties": { + "elided": { + "items": { + "additionalProperties": false, + "properties": { + "section": { + "description": "Dotted list path that was trimmed, e.g. \"linkages.callers\" or \"trust.active_findings\".", + "type": "string" + }, + "shown": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "section", + "shown", + "total" + ], + "type": "object" + }, + "type": "array" + }, + "note": { + "description": "Human-readable trim summary; null when not truncated.", + "type": [ + "string", + "null" + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "truncated", + "elided", + "note" + ], + "type": "object" + }, + "trust": { + "additionalProperties": false, + "description": "Wardline's OWN trust posture, re-derived from a live scan (fresh by construction).", + "properties": { + "active_findings": { + "description": "Active (non-suppressed) defect findings on the entity. May be trimmed by the token budgeter (see truncation.elided).", + "items": { + "additionalProperties": false, + "properties": { + "line": { + "type": [ + "integer", + "null" + ] + }, + "message": { + "type": "string" + }, + "rule_id": { + "type": "string" + }, + "severity": { + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE" + ], + "type": "string" + } + }, + "required": [ + "rule_id", + "severity", + "message", + "line" + ], + "type": "object" + }, + "type": "array" + }, + "actual_return": { + "description": "Engine-computed actual return taint; null when not computed.", + "type": [ + "string", + "null" + ] + }, + "declared_return": { + "description": "Declared return trust tier; null when undeclared.", + "type": [ + "string", + "null" + ] + }, + "freshness": { + "description": "Constant: the trust section is re-derived on demand, never stale.", + "enum": [ + "fresh_by_construction" + ], + "type": "string" + }, + "gate_verdict": { + "description": "Three-valued, fail-closed verdict: defect (active findings), clean (declared posture that conforms), unknown (undeclared/unprovable/under-scanned).", + "enum": [ + "defect", + "clean", + "unknown" + ], + "type": "string" + }, + "suppressed_findings": { + "description": "Count of accepted (baselined/waived/judged) defects \u2014 known debt a clean verdict must not hide.", + "type": "integer" + }, + "unanalyzed_reason": { + "description": "Engine under-scan fact (parse error / recursion skip) when the body was not analysed; else null.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "declared_return", + "actual_return", + "gate_verdict", + "active_findings", + "suppressed_findings", + "unanalyzed_reason", + "freshness" + ], + "type": "object" + }, + "work": { + "additionalProperties": false, + "description": "Open work from Filigree, keyed on the SEI. available=false with a reason when Filigree is not configured / unreachable / there is no binding.", + "properties": { + "available": { + "type": "boolean" + }, + "content_status": { + "description": "stale when any ticket binding drifted; unknown when a compare was impossible.", + "enum": [ + "fresh", + "stale", + "unknown" + ], + "type": "string" + }, + "identity_status": { + "enum": [ + "alive", + "orphaned", + "unavailable" + ], + "type": "string" + }, + "reason": { + "description": "Why the section is unavailable; null when available.", + "type": [ + "string", + "null" + ] + }, + "tickets": { + "description": "Filigree issues bound to the entity. May be trimmed by the token budgeter.", + "items": { + "additionalProperties": false, + "properties": { + "drift": { + "description": "True when the issue was bound to a PRIOR version of the entity (content hash at attach no longer matches).", + "type": "boolean" + }, + "issue_id": { + "type": "string" + }, + "priority": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "issue_id", + "status", + "priority", + "title", + "drift" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "available", + "tickets", + "identity_status", + "content_status", + "reason" + ], + "type": "object" + } + }, + "required": [ + "identity", + "shape", + "trust", + "linkages", + "work", + "synthesis", + "truncation" + ], + "type": "object" + }, + "explain_taint": { + "additionalProperties": false, + "description": "Success payload of the explain_taint tool: the taint provenance slice for one finding (single source: core/explain.explain_taint_result, shared with the CLI `wardline explain-taint`). Served either from a fresh Loomweave store fact (no re-scan) or from an SP8 re-run; both paths produce this same key set. With chain=true and a configured Loomweave store, a `chain` block is additionally attached.", + "properties": { + "chain": { + "additionalProperties": false, + "description": "Full N-hop taint chain from the sink to the originating boundary, walked from the Loomweave store. Present whenever the call passed chain=true: status 'walked' carries the hops; status 'unavailable' is the explicit C-10(c) degrade marker (no Loomweave store configured, or no sink qualname to anchor on) naming the missing capability and its enablement path \u2014 the walk never degrades silently.", + "properties": { + "enablement": { + "description": "unavailable only: how to enable the missing capability; null when walked.", + "type": [ + "string", + "null" + ] + }, + "hops": { + "description": "Ordered hops from the sink toward the boundary leaf. The walk stops cleanly at a boundary (contributing_callee_qualname null on the last hop) or truncates explicitly (see truncated_at).", + "items": { + "additionalProperties": false, + "properties": { + "contributing_callee_qualname": { + "description": "Next hop toward the boundary; null at the boundary leaf (clean finish).", + "type": [ + "string", + "null" + ] + }, + "qualname": { + "description": "Qualified name of the function at this hop.", + "type": "string" + }, + "tier_in": { + "description": "Actual trust tier arriving at this hop (from the stored fact; null when absent).", + "type": [ + "string", + "null" + ] + }, + "tier_out": { + "description": "Trust tier this hop declares it returns (from the stored fact; null when absent).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "qualname", + "tier_in", + "tier_out", + "contributing_callee_qualname" + ], + "type": "object" + }, + "type": "array" + }, + "missing_capability": { + "description": "unavailable only: what the walk lacked ('loomweave_taint_store' or 'sink_qualname'); null when walked.", + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "walked: the store walk ran (hops below). unavailable: the walk could not run; see missing_capability/enablement.", + "enum": [ + "walked", + "unavailable" + ], + "type": "string" + }, + "truncated_at": { + "description": "Qualified name of the next hop the walk could NOT take (stale/absent fact, read error, cycle, or max_hops reached) \u2014 truncation is always explicit; null means the chain reached the boundary cleanly.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status", + "hops", + "truncated_at", + "missing_capability", + "enablement" + ], + "type": "object" + }, + "fingerprint": { + "description": "Stable finding fingerprint. May be the empty string on the store-served path when the entity blob carries no per-finding rows (the entity is known, the specific finding is not).", + "type": "string" + }, + "immediate_tainted_callee": { + "description": "Bare trailing name of the call that introduced the untrusted return into the sink (null when unresolved).", + "type": [ + "string", + "null" + ] + }, + "location": { + "additionalProperties": false, + "description": "Source location of the finding. path may be empty and line null on the store-served path when the blob has no per-finding rows.", + "properties": { + "line": { + "description": "1-based start line of the finding (null when unknown).", + "type": [ + "integer", + "null" + ] + }, + "path": { + "description": "Root-relative posix path of the finding's file (empty string when unknown on the store-served path).", + "type": "string" + } + }, + "required": [ + "path", + "line" + ], + "type": "object" + }, + "remediation": { + "additionalProperties": false, + "description": "Advisory fix-at-the-boundary hint derived from the explanation; never replaces the factual taint fields above.", + "properties": { + "caveat": { + "description": "Standing warning against blind decorator insertion / over-trusting the hint.", + "type": "string" + }, + "kind": { + "description": "boundary_placement for PY-WL-101 (place/repair a @trust_boundary at the validating function); sink_hygiene for the dangerous-sink family (rule-specific fix guidance naming the source and sink); review_required for rules with no automated hint.", + "enum": [ + "boundary_placement", + "sink_hygiene", + "review_required" + ], + "type": "string" + }, + "rule_id": { + "description": "Rule the hint applies to (echoes the top-level rule_id).", + "type": "string" + }, + "sink_qualname": { + "description": "Sink the hint refers to (null when the finding has no qualname).", + "type": [ + "string", + "null" + ] + }, + "source_qualname": { + "description": "Taint source the hint refers to: the resolved boundary, else the immediate tainted callee, else null when unresolved.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Human/agent-readable remediation guidance sentence.", + "type": "string" + } + }, + "required": [ + "kind", + "rule_id", + "summary", + "sink_qualname", + "source_qualname", + "caveat" + ], + "type": "object" + }, + "resolved_call_count": { + "description": "Number of calls inside the sink the engine resolved during taint computation.", + "type": "integer" + }, + "rule_id": { + "description": "Rule that produced the finding (e.g. PY-WL-101). May be the empty string on the store-served path when no per-finding row matched.", + "type": "string" + }, + "sink_qualname": { + "description": "Qualified name of the sink function the tainted value reaches (null when the engine has no qualname for the finding).", + "type": [ + "string", + "null" + ] + }, + "source_boundary_qualname": { + "description": "Originating boundary resolved one hop from the sink: qualified name of the boundary function the taint came from (null when not resolvable in one hop). On the store-served path this is the blob's contributing_callee_qualname.", + "type": [ + "string", + "null" + ] + }, + "source_resolution": { + "additionalProperties": false, + "description": "C-10(c) honesty block: whether the taint source is named above, and when it is NOT, why and what capability would resolve it further \u2014 an explicit degrade marker, never nulls that read as a complete-but-empty answer.", + "properties": { + "enablement": { + "description": "unresolved only: how to enable the missing capability; null otherwise.", + "type": [ + "string", + "null" + ] + }, + "missing_capability": { + "description": "unresolved only: capability that could resolve further \u2014 'loomweave_taint_store' when no store is configured; null when resolved or when nothing more would help.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "unresolved only: why wardline's own single-scan analysis could not name the source; null when resolved.", + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "resolved when immediate_tainted_callee or source_boundary_qualname is named; unresolved otherwise.", + "enum": [ + "resolved", + "unresolved" + ], + "type": "string" + } + }, + "required": [ + "status", + "reason", + "missing_capability", + "enablement" + ], + "type": "object" + }, + "tier_in": { + "description": "Actual (untrusted) trust tier arriving at the sink, e.g. EXTERNAL_RAW (null when the engine recorded none).", + "type": [ + "string", + "null" + ] + }, + "tier_out": { + "description": "Trust tier the sink declares it returns, e.g. INTEGRAL (null when the engine recorded none).", + "type": [ + "string", + "null" + ] + }, + "unresolved_call_count": { + "description": "Number of calls inside the sink the engine could NOT resolve (residual uncertainty in the explanation).", + "type": "integer" + } + }, + "required": [ + "fingerprint", + "rule_id", + "sink_qualname", + "location", + "tier_in", + "tier_out", + "immediate_tainted_callee", + "source_boundary_qualname", + "source_resolution", + "resolved_call_count", + "unresolved_call_count", + "remediation" + ], + "type": "object" + }, + "file_finding": { + "additionalProperties": false, + "description": "Success payload of the file_finding tool: the outcome of promoting ONE finding (by fingerprint) into a tracked Filigree issue, fail-soft on reachability.", + "properties": { + "created": { + "description": "True when the promote created a NEW issue (vs returning an existing one).", + "type": "boolean" + }, + "disabled_reason": { + "description": "Why enrichment was unavailable (e.g. 'filigree unreachable', 'filigree 503'); null on success.", + "type": [ + "string", + "null" + ] + }, + "fingerprint": { + "description": "The fingerprint that was filed (echoed from the request).", + "type": "string" + }, + "identity_attach": { + "additionalProperties": false, + "description": "Present only when attach_loomweave_identity=true was requested: the outcome of binding the finding's Loomweave entity identity to the filed issue.", + "properties": { + "attached": { + "description": "Whether the entity association was successfully attached to the Filigree issue.", + "type": "boolean" + }, + "attempted": { + "description": "Whether an identity attach was attempted at all (false when there is no issue_id or no Loomweave URL configured).", + "type": "boolean" + }, + "binding_kind": { + "description": "Whether the binding used a rename-stable SEI or a legacy locator; null when no binding was attempted.", + "enum": [ + "sei", + "locator", + null + ], + "type": [ + "string", + "null" + ] + }, + "content_hash": { + "description": "The entity content hash captured at attach time (drift-detection anchor); null when unresolved.", + "type": [ + "string", + "null" + ] + }, + "entity_id": { + "description": "The entity identifier used for the binding \u2014 a 'loomweave:eid:...' SEI or a legacy '{plugin}:function:{qualname}' locator.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "Human-readable reason when not attempted or skipped; null on success.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "attempted", + "attached", + "entity_id", + "content_hash", + "binding_kind", + "reason" + ], + "type": "object" + }, + "issue_id": { + "description": "The Filigree issue id the fingerprint was promoted into; null when unreachable or the fingerprint was not found.", + "type": [ + "string", + "null" + ] + }, + "not_found": { + "description": "True when Filigree was reachable but the fingerprint is unknown to it (404 \u2014 emit findings to Filigree first).", + "type": "boolean" + }, + "reachable": { + "description": "Whether Filigree's promote route was reachable. False on transport failure, 5xx outage, or 401/403 auth refusal (all soft).", + "type": "boolean" + } + }, + "required": [ + "reachable", + "issue_id", + "created", + "not_found", + "fingerprint", + "disabled_reason" + ], + "type": "object" + }, + "fix": { + "additionalProperties": false, + "description": "Success payload of the wardline MCP `fix` tool: mechanical autofix results (PY-WL-111 assert-at-boundary rewrites), either previewed (default / dry_run) or applied in-place (apply=true).", + "properties": { + "applied": { + "description": "True when fixes were written to disk (apply=true and not dry_run), false when this was a preview. Absent on the early-return path where the scan found no fixable findings.", + "type": "boolean" + }, + "fixed": { + "additionalProperties": { + "items": { + "description": "Description of one fix in this file.", + "type": "string" + }, + "type": "array" + }, + "description": "Open map of file path (relative to the scanned `path` argument, NOT the project root) -> list of human-readable descriptions of the fixes previewed/applied in that file. Empty object when no fixable findings were found or no fixes could be produced.", + "type": "object" + }, + "message": { + "description": "Human-readable summary, e.g. 'No fixable findings found.', 'Previewed fixes for N files.' or 'Applied fixes for N files.'", + "type": "string" + } + }, + "required": [ + "fixed", + "message" + ], + "type": "object" + }, + "judge": { + "additionalProperties": false, + "description": "Success payload of the judge tool: per-finding LLM triage verdicts plus the persistence outcome of FALSE_POSITIVE records.", + "properties": { + "held_back": { + "description": "FALSE_POSITIVE verdicts NOT persisted because their confidence fell below the write floor.", + "type": "integer" + }, + "verdicts": { + "description": "One flattened verdict per triaged finding.", + "items": { + "additionalProperties": false, + "properties": { + "confidence": { + "description": "The judge's calibrated confidence in the verdict, 0.0 to 1.0.", + "type": "number" + }, + "fingerprint": { + "description": "Stable fingerprint of the judged finding.", + "type": "string" + }, + "label": { + "description": "The judge's verdict: a real defect vs an analyzer over-approximation.", + "enum": [ + "TRUE_POSITIVE", + "FALSE_POSITIVE" + ], + "type": "string" + }, + "line": { + "description": "1-based start line; null when unknown.", + "type": [ + "integer", + "null" + ] + }, + "path": { + "description": "Repo-relative file path of the finding.", + "type": "string" + }, + "rationale": { + "description": "The model's verbatim reasoning (the audit primitive); always a non-empty string.", + "type": "string" + }, + "rule_id": { + "description": "Rule that produced the finding.", + "type": "string" + } + }, + "required": [ + "fingerprint", + "rule_id", + "path", + "line", + "label", + "confidence", + "rationale" + ], + "type": "object" + }, + "type": "array" + }, + "wrote": { + "description": "FALSE_POSITIVE verdicts persisted to .wardline/judged.yaml (0 unless write=true).", + "type": "integer" + } + }, + "required": [ + "verdicts", + "wrote", + "held_back" + ], + "type": "object" + }, + "rekey": { + "additionalProperties": false, + "description": "Rekey success payload. Four shapes share the 'mode' discriminator: 'probe' (default, read-only dry run), 'apply' and 'resume' (journal block reporting the migration's per-leg state), and 'rollback' ({mode, restored, note}). All non-'mode' fields are mode-specific.", + "properties": { + "clean": { + "description": "probe only: true when there are no orphans and no collisions \u2014 an apply would carry every stored verdict (or, when no_op, there is simply nothing to migrate).", + "type": "boolean" + }, + "collisions": { + "description": "probe/apply/resume: ambiguous rekey mappings. Either multiple pre-rekey fingerprints collapse to one new fingerprint, or one pre-rekey fingerprint fans out to multiple new fingerprints. All involved old fingerprints are orphaned, not carried.", + "items": { + "additionalProperties": false, + "properties": { + "message": { + "description": "WLN-ENGINE-FINGERPRINT-COLLISION or WLN-ENGINE-FINGERPRINT-FANOUT diagnostic explaining that the verdicts are orphaned.", + "type": "string" + }, + "new_fp": { + "description": "Collapse: the new-scheme fingerprint that more than one old fingerprint maps onto. Fan-out: null, with new_fps carrying the candidate new fingerprints.", + "type": [ + "string", + "null" + ] + }, + "new_fps": { + "description": "Fan-out only: the candidate new-scheme fingerprints one old fingerprint maps onto. Omitted for collapse collisions.", + "items": { + "type": "string" + }, + "type": "array" + }, + "old_fps": { + "description": "The ambiguous pre-rekey fingerprints (sorted). Fan-out entries contain one old fingerprint.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "new_fp", + "old_fps", + "message" + ], + "type": "object" + }, + "type": "array" + }, + "complete": { + "description": "apply/resume: true when every migration leg is done.", + "type": "boolean" + }, + "current_scheme_stores": { + "description": "probe only: store file names ALREADY at the live fingerprint scheme \u2014 a rekey is a no-op for them; their entries were matched against the current scan's fingerprints.", + "items": { + "type": "string" + }, + "type": "array" + }, + "fingerprint_scheme_from": { + "description": "apply/resume: the fingerprint scheme being migrated from (e.g. 'wlfp1').", + "type": "string" + }, + "fingerprint_scheme_to": { + "description": "apply/resume: the fingerprint scheme being migrated to (e.g. 'wlfp2').", + "type": "string" + }, + "legs": { + "description": "apply/resume: per-leg migration state, in application order (baseline, judged, waivers, filigree).", + "items": { + "additionalProperties": false, + "properties": { + "carried_count": { + "description": "Number of stored verdicts re-keyed and carried forward by this leg (count only; can be the whole store).", + "type": "integer" + }, + "debt": { + "description": "Filigree leg only: recorded reconciliation debt when the leg soft-failed (re-run with apply to retry); null otherwise.", + "type": [ + "string", + "null" + ] + }, + "done": { + "description": "True when this leg has been applied and persisted.", + "type": "boolean" + }, + "name": { + "description": "Leg name: one of 'baseline', 'judged', 'waivers', 'filigree' in a journal this code wrote.", + "type": "string" + }, + "orphaned_count": { + "description": "Number of stored verdicts this leg dropped \u2014 never silent; the full verbatim list is recorded in the on-disk migration journal.", + "type": "integer" + }, + "orphaned_sample": { + "description": "Bounded sample of the old fingerprints this leg dropped (counts are authoritative; full list in the migration journal).", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "done", + "carried_count", + "orphaned_count", + "orphaned_sample", + "debt" + ], + "type": "object" + }, + "type": "array" + }, + "matched": { + "description": "probe only: count of distinct stored fingerprints that map to a current finding \u2014 each store is judged against ITS OWN scheme (a wlfp1 store via the migration remap, a store already at the live scheme via the current fingerprints).", + "type": "integer" + }, + "mode": { + "description": "Which rekey operation ran. 'probe' is the read-only default; 'apply'/'resume'/'rollback' are explicit, mutually exclusive write modes.", + "enum": [ + "probe", + "apply", + "resume", + "rollback" + ], + "type": "string" + }, + "next_action": { + "description": "apply/resume, only when the migration is INCOMPLETE: instruction to re-run the rekey tool to finish pending leg(s).", + "type": "string" + }, + "next_pending_leg": { + "description": "apply/resume: name of the first not-done leg, or null when the migration is complete.", + "type": [ + "string", + "null" + ] + }, + "no_op": { + "description": "probe only: true when every populated store already carries the live scheme \u2014 no fingerprint migration is pending and an apply would be refused.", + "type": "boolean" + }, + "note": { + "description": "rollback only: caveat that Filigree associations from the forward run are NOT reversed (no remap endpoint); reconcile manually if needed.", + "type": "string" + }, + "orphan_cause": { + "description": "probe/apply/resume: fixed explanation string for why a stored fingerprint can orphan (source moved/deleted, or a custom multi-emit rule not surfacing taint_path_v0).", + "type": "string" + }, + "orphaned_count": { + "description": "probe only: number of stored OLD-SCHEME fingerprints with no current finding \u2014 these verdicts would be dropped by an apply. Stores already at the live scheme never orphan (see stale_*).", + "type": "integer" + }, + "orphaned_sample": { + "description": "probe only: bounded sorted sample of the orphaned fingerprints (counts are authoritative; an apply records the full list verbatim in the migration journal).", + "items": { + "type": "string" + }, + "type": "array" + }, + "per_store": { + "additionalProperties": { + "type": "integer" + }, + "description": "probe only: open map of store file name (e.g. 'baseline.yaml') -> count of its stored fingerprints with no current finding. Only stores with orphans appear.", + "type": "object" + }, + "prescheme": { + "description": "probe only: true when a live store predates the fingerprint-scheme stamp, so orphans MAY be a fingerprint-formula change rather than source churn.", + "type": "boolean" + }, + "restored": { + "description": "rollback only: store file names restored from the pre-migration snapshot.", + "items": { + "type": "string" + }, + "type": "array" + }, + "scanned_findings": { + "description": "probe only: number of findings the suppression-free scan produced (each carries an old/new fingerprint pair).", + "type": "integer" + }, + "snapshot_prescheme": { + "description": "apply/resume: true when the snapshotted stores carried no scheme stamp (pre-scheme), so orphans may be a formula change \u2014 surfaced as a caution.", + "type": "boolean" + }, + "stale_cause": { + "description": "probe only: fixed explanation string for why a current-scheme entry can be stale.", + "type": "string" + }, + "stale_count": { + "description": "probe only: number of CURRENT-scheme store entries matching no current finding \u2014 baseline drift, NOT migration orphans; a rekey would not touch them and they do not dirty the probe.", + "type": "integer" + }, + "stale_sample": { + "description": "probe only: bounded sorted sample of the stale fingerprints.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "scan": { + "$defs": { + "active_defect_entry": { + "additionalProperties": false, + "description": "One active defect, with explain availability and suggested next tool calls; explanation is inlined only under scan(explain:true) for findings with a qualname, up to the cap.", + "properties": { + "explain": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "reason": { + "description": "Why explain is unavailable (no qualname), or null.", + "type": [ + "string", + "null" + ] + }, + "suggested_call": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "arguments": { + "additionalProperties": false, + "properties": { + "fingerprint": { + "type": "string" + } + }, + "required": [ + "fingerprint" + ], + "type": "object" + }, + "tool": { + "enum": [ + "explain_taint" + ], + "type": "string" + } + }, + "required": [ + "tool", + "arguments" + ], + "type": "object" + } + ] + } + }, + "required": [ + "available", + "reason", + "suggested_call" + ], + "type": "object" + }, + "explanation": { + "$ref": "#/$defs/explanation" + }, + "fingerprint": { + "type": "string" + }, + "kind": { + "enum": [ + "defect", + "fact", + "classification", + "metric", + "suggestion" + ], + "type": "string" + }, + "location": { + "$ref": "#/$defs/location" + }, + "message": { + "type": "string" + }, + "next_tool_calls": { + "items": { + "additionalProperties": false, + "properties": { + "arguments": { + "additionalProperties": false, + "properties": { + "fingerprint": { + "type": "string" + } + }, + "required": [ + "fingerprint" + ], + "type": "object" + }, + "tool": { + "enum": [ + "explain_taint", + "file_finding" + ], + "type": "string" + } + }, + "required": [ + "tool", + "arguments" + ], + "type": "object" + }, + "type": "array" + }, + "qualname": { + "type": [ + "string", + "null" + ] + }, + "rule_id": { + "type": "string" + }, + "severity": { + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE" + ], + "type": "string" + }, + "suppression_reason": { + "type": [ + "string", + "null" + ] + }, + "suppression_state": { + "enum": [ + "active", + "baselined", + "waived", + "judged" + ], + "type": "string" + } + }, + "required": [ + "fingerprint", + "rule_id", + "severity", + "kind", + "qualname", + "location", + "message", + "suppression_state", + "suppression_reason", + "explain", + "next_tool_calls" + ], + "type": "object" + }, + "explanation": { + "additionalProperties": false, + "description": "Inlined taint provenance (same shape as the explanation slice of explain_taint).", + "properties": { + "immediate_tainted_callee": { + "type": [ + "string", + "null" + ] + }, + "remediation": { + "additionalProperties": false, + "properties": { + "caveat": { + "type": "string" + }, + "kind": { + "enum": [ + "boundary_placement", + "sink_hygiene", + "review_required" + ], + "type": "string" + }, + "rule_id": { + "type": "string" + }, + "sink_qualname": { + "type": [ + "string", + "null" + ] + }, + "source_qualname": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": "string" + } + }, + "required": [ + "kind", + "rule_id", + "summary", + "sink_qualname", + "source_qualname", + "caveat" + ], + "type": "object" + }, + "resolved_call_count": { + "type": "integer" + }, + "source_boundary_qualname": { + "type": [ + "string", + "null" + ] + }, + "source_resolution": { + "additionalProperties": false, + "description": "C-10(c) honesty block: explicit resolved/unresolved verdict on the taint source, with reason + missing capability + enablement when unresolved.", + "properties": { + "enablement": { + "type": [ + "string", + "null" + ] + }, + "missing_capability": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "status": { + "enum": [ + "resolved", + "unresolved" + ], + "type": "string" + } + }, + "required": [ + "status", + "reason", + "missing_capability", + "enablement" + ], + "type": "object" + }, + "tier_in": { + "description": "Actual (untrusted) tier arriving at the sink.", + "type": [ + "string", + "null" + ] + }, + "tier_out": { + "description": "Tier the sink declares it returns.", + "type": [ + "string", + "null" + ] + }, + "unresolved_call_count": { + "type": "integer" + } + }, + "required": [ + "tier_in", + "tier_out", + "immediate_tainted_callee", + "source_boundary_qualname", + "source_resolution", + "resolved_call_count", + "unresolved_call_count", + "remediation" + ], + "type": "object" + }, + "filigree_destination": { + "additionalProperties": false, + "description": "Where findings were (or would be) sent, so a wrong-project write is visible.", + "properties": { + "project": { + "description": "The project pinned in the URL, or null when Filigree resolves it server-side.", + "type": [ + "string", + "null" + ] + }, + "project_pinned": { + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "url", + "project", + "project_pinned" + ], + "type": "object" + }, + "filigree_emit_failures": { + "description": "PDR-0023 honesty surface: one record per finding that did NOT land in Filigree, so a PARTIAL ingest ('M of N emitted, K rejected because R') is byte-distinguishable from a clean emit. Empty on a clean run \u2014 but earned from real per-finding records, not hardwired.", + "items": { + "additionalProperties": false, + "properties": { + "cause": { + "description": "weft-reason carrier `cause`: the why (Filigree's detail, else the domain reason). Always present on a failure.", + "type": "string" + }, + "detail": { + "description": "Filigree's per-finding reject explanation.", + "type": "string" + }, + "fingerprint": { + "description": "Wardline join key for the failed finding (absent when chunk-wide).", + "type": "string" + }, + "fix": { + "description": "weft-reason carrier `fix` (MANDATORY on a non-clean carrier): the remedy.", + "type": "string" + }, + "reason": { + "description": "Machine-readable failure case: rejected (Filigree refused this finding), validation_error (malformed body), scheme_mismatch (fingerprint-scheme drift \u2014 a join-miss, not a true-negative), partial (whole chunk rejected at the protocol layer; cause is the request, not the body).", + "enum": [ + "rejected", + "validation_error", + "scheme_mismatch", + "partial" + ], + "type": "string" + }, + "reason_class": { + "description": "weft-reason (G1): the canonical reason_class this failure maps to (one of the closed 11 in contracts/weft-reason-vocab.json). validation_error maps to rejected; the domain term stays in `reason`/`cause`.", + "enum": [ + "rejected", + "scheme_mismatch", + "partial" + ], + "type": "string" + } + }, + "required": [ + "reason", + "detail", + "reason_class", + "cause", + "fix" + ], + "type": "object" + }, + "type": "array" + }, + "filigree_emit_status": { + "additionalProperties": false, + "description": "Normalized Filigree emit status (always an object; configured:false when no emitter).", + "properties": { + "auth_rejected": { + "description": "Absent when not configured.", + "type": "boolean" + }, + "configured": { + "type": "boolean" + }, + "created": { + "type": "integer" + }, + "destination": { + "$ref": "#/$defs/filigree_destination" + }, + "disabled_reason": { + "description": "Actionable reason (auth-rejected vs server error vs unreachable vs not configured), or null when reached.", + "type": [ + "string", + "null" + ] + }, + "failed": { + "description": "Count of un-ingested findings (derived from `failures`); 0 is earned, not assumed.", + "type": "integer" + }, + "failures": { + "$ref": "#/$defs/filigree_emit_failures" + }, + "reachable": { + "description": "null when not configured.", + "type": [ + "boolean", + "null" + ] + }, + "status": { + "description": "HTTP error status for soft failures; absent when not configured.", + "type": [ + "integer", + "null" + ] + }, + "token_sent": { + "description": "Absent when not configured.", + "type": "boolean" + }, + "updated": { + "type": "integer" + }, + "url": { + "description": "Absent when not configured.", + "type": [ + "string", + "null" + ] + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason", + "destination" + ], + "type": "object" + }, + "finding_entry": { + "additionalProperties": false, + "description": "One finding (suppressed / engine-fact / informational display arrays).", + "properties": { + "fingerprint": { + "type": "string" + }, + "kind": { + "enum": [ + "defect", + "fact", + "classification", + "metric", + "suggestion" + ], + "type": "string" + }, + "location": { + "$ref": "#/$defs/location" + }, + "message": { + "type": "string" + }, + "qualname": { + "type": [ + "string", + "null" + ] + }, + "rule_id": { + "type": "string" + }, + "severity": { + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE" + ], + "type": "string" + }, + "suppression_reason": { + "type": [ + "string", + "null" + ] + }, + "suppression_state": { + "enum": [ + "active", + "baselined", + "waived", + "judged" + ], + "type": "string" + } + }, + "required": [ + "fingerprint", + "rule_id", + "severity", + "kind", + "qualname", + "location", + "message", + "suppression_state", + "suppression_reason" + ], + "type": "object" + }, + "location": { + "additionalProperties": false, + "properties": { + "line_end": { + "type": [ + "integer", + "null" + ] + }, + "line_start": { + "type": [ + "integer", + "null" + ] + }, + "path": { + "description": "Repo-relative POSIX path.", + "type": "string" + } + }, + "required": [ + "path", + "line_start", + "line_end" + ], + "type": "object" + }, + "loomweave_write_status": { + "additionalProperties": false, + "description": "Normalized Loomweave taint-fact write status (always an object; configured:false when no client).", + "properties": { + "configured": { + "type": "boolean" + }, + "disabled_reason": { + "type": [ + "string", + "null" + ] + }, + "reachable": { + "description": "null when not configured.", + "type": [ + "boolean", + "null" + ] + }, + "unresolved_qualnames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "written": { + "type": "integer" + } + }, + "required": [ + "configured", + "reachable", + "written", + "unresolved_qualnames", + "disabled_reason" + ], + "type": "object" + } + }, + "additionalProperties": false, + "description": "Success payload of the wardline MCP `scan` tool (the dict _scan returns, served verbatim as structuredContent).", + "properties": { + "agent_summary": { + "additionalProperties": false, + "description": "The stable agent-oriented handoff block (schema wardline-agent-summary-1): active defects first, suppressed debt visible, integration status explicit, suggested next tool calls.", + "properties": { + "active_defects": { + "description": "Non-suppressed defects in the displayed page (severity-sorted). Each entry carries explain/next_tool_calls hints; with explain:true an inlined explanation (capped \u2014 see truncation.explanations_truncated).", + "items": { + "$ref": "#/$defs/active_defect_entry" + }, + "type": "array" + }, + "engine_facts": { + "description": "Engine diagnostic facts (WLN-ENGINE-*) in the displayed page.", + "items": { + "$ref": "#/$defs/finding_entry" + }, + "type": "array" + }, + "gate": { + "additionalProperties": false, + "description": "Gate echo inside the agent summary (no sub-gate attribution flags here; see the top-level gate block for those).", + "properties": { + "evaluated": { + "type": [ + "string", + "null" + ] + }, + "exit_class": { + "enum": [ + 0, + 1 + ], + "type": "integer" + }, + "fail_on": { + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE", + null + ] + }, + "migration_hint": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": "string" + }, + "tripped": { + "type": "boolean" + }, + "verdict": { + "enum": [ + "NOT_EVALUATED", + "PASSED", + "FAILED" + ], + "type": "string" + }, + "would_trip_at": { + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + null + ] + } + }, + "required": [ + "tripped", + "fail_on", + "exit_class", + "verdict", + "would_trip_at", + "reason", + "evaluated", + "migration_hint" + ], + "type": "object" + }, + "informational": { + "description": "Non-defect, non-engine-fact findings (metrics, classifications, suggestions) in the displayed page.", + "items": { + "$ref": "#/$defs/finding_entry" + }, + "type": "array" + }, + "integrations": { + "additionalProperties": false, + "properties": { + "filigree_emit": { + "$ref": "#/$defs/filigree_emit_status" + }, + "loomweave_write": { + "$ref": "#/$defs/loomweave_write_status" + } + }, + "required": [ + "filigree_emit", + "loomweave_write" + ], + "type": "object" + }, + "next_actions": { + "description": "Gate-aware suggested next tool calls, driven by the whole-project active count (not the displayed slice).", + "items": { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "tool": { + "enum": [ + "explain_taint", + "file_finding", + "scan" + ], + "type": "string" + } + }, + "required": [ + "tool", + "reason" + ], + "type": "object" + }, + "type": "array" + }, + "schema": { + "enum": [ + "wardline-agent-summary-1" + ], + "type": "string" + }, + "summary": { + "additionalProperties": false, + "description": "Whole-project counts (never affected by where/pagination filters).", + "properties": { + "active_defects": { + "type": "integer" + }, + "baselined": { + "type": "integer" + }, + "engine_facts": { + "description": "Kind.FACT findings with a WLN-ENGINE-* rule_id.", + "type": "integer" + }, + "files_scanned": { + "type": "integer" + }, + "informational": { + "description": "ALL non-defect findings (engine facts included; the display array below excludes them).", + "type": "integer" + }, + "judged": { + "type": "integer" + }, + "suppressed_findings": { + "type": "integer" + }, + "total_findings": { + "type": "integer" + }, + "unanalyzed": { + "type": "integer" + }, + "waived": { + "type": "integer" + } + }, + "required": [ + "files_scanned", + "total_findings", + "active_defects", + "suppressed_findings", + "engine_facts", + "baselined", + "waived", + "judged", + "informational", + "unanalyzed" + ], + "type": "object" + }, + "suppressed_findings": { + "description": "Suppressed (baselined/waived/judged) defects in the displayed page.", + "items": { + "$ref": "#/$defs/finding_entry" + }, + "type": "array" + }, + "truncation": { + "additionalProperties": false, + "description": "Single pagination descriptor for the four display arrays (one ordered union: active, suppressed, engine facts, informational).", + "properties": { + "explanations_truncated": { + "description": "True when explain:true hit the inlining cap before covering every shown active defect.", + "type": "boolean" + }, + "findings_returned": { + "type": "integer" + }, + "findings_total": { + "description": "Size of the displayed union before paging.", + "type": "integer" + }, + "findings_truncated": { + "type": "boolean" + }, + "include_suppressed": { + "type": "boolean" + }, + "max_findings": { + "description": "Effective page size; null means uncapped (full:true).", + "type": [ + "integer", + "null" + ] + }, + "next_offset": { + "description": "Pass as offset to fetch the next page; null when complete.", + "type": [ + "integer", + "null" + ] + }, + "offset": { + "type": "integer" + }, + "summary_only": { + "type": "boolean" + } + }, + "required": [ + "summary_only", + "include_suppressed", + "max_findings", + "offset", + "findings_total", + "findings_returned", + "next_offset", + "findings_truncated", + "explanations_truncated" + ], + "type": "object" + } + }, + "required": [ + "schema", + "summary", + "gate", + "integrations", + "active_defects", + "suppressed_findings", + "engine_facts", + "informational", + "truncation", + "next_actions" + ], + "type": "object" + }, + "files_scanned": { + "description": "Number of files discovered (see scope.files_analyzed for the delta-mode analyzed count).", + "type": "integer" + }, + "filigree": { + "description": "Raw Filigree emit result; null when no emitter is configured.", + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "auth_rejected": { + "description": "True when the emit was refused with 401/403.", + "type": "boolean" + }, + "created": { + "type": "integer" + }, + "destination": { + "$ref": "#/$defs/filigree_destination" + }, + "failed": { + "description": "Count of un-ingested findings (derived from `failures`); 0 is earned from real records, not assumed.", + "type": "integer" + }, + "failures": { + "$ref": "#/$defs/filigree_emit_failures" + }, + "reachable": { + "type": "boolean" + }, + "status": { + "description": "HTTP error status (401/403/5xx) for soft failures; null on success or transport failure.", + "type": [ + "integer", + "null" + ] + }, + "token_sent": { + "description": "Whether a bearer token was actually sent (splits a 401 into absent vs rejected).", + "type": "boolean" + }, + "updated": { + "type": "integer" + }, + "url": { + "description": "The endpoint attempted.", + "type": [ + "string", + "null" + ] + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "status", + "auth_rejected", + "token_sent", + "url", + "destination" + ], + "type": "object" + } + ] + }, + "filigree_emit": { + "$ref": "#/$defs/filigree_emit_status" + }, + "gate": { + "additionalProperties": false, + "description": "The pass/fail gate decision (a trip is data, not an error).", + "properties": { + "evaluated": { + "description": "Which population the gate judged (unsuppressed default vs suppression-honoring).", + "type": [ + "string", + "null" + ] + }, + "exit_class": { + "description": "0 clean, 1 gate tripped (mirrors tripped).", + "enum": [ + 0, + 1 + ], + "type": "integer" + }, + "fail_on": { + "description": "The severity threshold evaluated, or null when no --fail-on ran.", + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE", + null + ] + }, + "fail_on_unanalyzed": { + "description": "Whether the unanalyzed sub-gate knob was set.", + "type": "boolean" + }, + "migration_hint": { + "description": "Secure-gate-default rollout hint, or null.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "Human-readable verdict naming the count/class of defects that decided it.", + "type": "string" + }, + "severity_tripped": { + "description": "Sub-gate attribution: the severity threshold tripped.", + "type": "boolean" + }, + "tripped": { + "type": "boolean" + }, + "unanalyzed_tripped": { + "description": "Sub-gate attribution: the unanalyzed gate tripped.", + "type": "boolean" + }, + "verdict": { + "description": "NOT_EVALUATED when no authoritative severity gate judged the full gate population; FAILED iff tripped.", + "enum": [ + "NOT_EVALUATED", + "PASSED", + "FAILED" + ], + "type": "string" + }, + "would_trip_at": { + "description": "Highest severity at which the gate WOULD trip on the evaluated population, or null if nothing would.", + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + null + ] + } + }, + "required": [ + "tripped", + "fail_on", + "fail_on_unanalyzed", + "exit_class", + "verdict", + "severity_tripped", + "unanalyzed_tripped", + "would_trip_at", + "reason", + "evaluated", + "migration_hint" + ], + "type": "object" + }, + "legis_artifact": { + "additionalProperties": false, + "description": "OPTIONAL: the verbatim-postable signed scan object for legis POST /wardline/scan-results. Present only when a WARDLINE_LEGIS_ARTIFACT_KEY is provisioned or legis_artifact:true was passed, AND building it did not fail (a signing refusal omits it). Suppressed under summary_only:true unless legis_artifact:true is passed explicitly \u2014 summary_only promises the smallest gate payload.", + "properties": { + "artifact_signature": { + "description": "hmac-sha256:v2: over the canonical scan-minus-signature; present only on the signed (key + clean tree) path.", + "type": "string" + }, + "commit_sha": { + "description": "Present when provenance was readable (always on the signed path).", + "type": "string" + }, + "dirty": { + "description": "Present (true) only when the working tree was dirty (unsigned dev artifact).", + "type": "boolean" + }, + "findings": { + "description": "The gate population projected onto legis's accepted vocabulary.", + "items": { + "additionalProperties": false, + "properties": { + "fingerprint": { + "type": "string" + }, + "kind": { + "enum": [ + "defect", + "fact", + "classification", + "metric", + "suggestion" + ], + "type": "string" + }, + "message": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "type": "string" + }, + "description": "Trust-tier-valued properties only (plus suppression_reason proof on non-active defects); diagnostics dropped.", + "type": "object" + }, + "qualname": { + "type": [ + "string", + "null" + ] + }, + "rule_id": { + "type": "string" + }, + "severity": { + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE" + ], + "type": "string" + }, + "suppression_state": { + "enum": [ + "active", + "waived", + "suppressed" + ], + "type": "string" + } + }, + "required": [ + "rule_id", + "message", + "severity", + "kind", + "fingerprint", + "qualname", + "properties", + "suppression_state" + ], + "type": "object" + }, + "type": "array" + }, + "fingerprint_scheme": { + "type": "string" + }, + "rule_set_version": { + "description": "Hash of the effective ruleset.", + "type": "string" + }, + "scan_scope": { + "additionalProperties": false, + "description": "Signed scope binding: scan root, configured/resolved source roots, and realized files.", + "properties": { + "is_git_root": { + "description": "True only when the scan root is the containing git repository root.", + "type": "boolean" + }, + "resolved_source_roots": { + "description": "Configured source roots resolved relative to the signed scope base.", + "items": { + "type": "string" + }, + "type": "array" + }, + "scan_root": { + "description": "Scan root relative to the git repository root when in git; otherwise '.'.", + "type": "string" + }, + "scanned_paths": { + "description": "Files actually discovered and analyzed, relative to the scan root.", + "items": { + "type": "string" + }, + "type": "array" + }, + "schema": { + "enum": [ + "wardline-legis-scan-scope-1" + ], + "type": "string" + }, + "source_roots": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "schema", + "scan_root", + "is_git_root", + "source_roots", + "resolved_source_roots", + "scanned_paths" + ], + "type": "object" + }, + "scanner_identity": { + "description": "wardline@.", + "type": "string" + }, + "tree_sha": { + "description": "Committed tree SHA; present on the signed path and best-effort otherwise.", + "type": "string" + } + }, + "required": [ + "scanner_identity", + "rule_set_version", + "fingerprint_scheme", + "findings", + "scan_scope" + ], + "type": "object" + }, + "legis_artifact_status": { + "additionalProperties": false, + "description": "OPTIONAL: signed/dirty status of the legis artifact attempt. Present whenever the legis block was activated (key provisioned or legis_artifact:true), including when signing was refused and legis_artifact itself is absent.", + "properties": { + "configured": { + "enum": [ + true + ], + "type": "boolean" + }, + "dirty": { + "description": "Present only when the artifact was actually built (absent on a build refusal).", + "type": "boolean" + }, + "key_id": { + "description": "Non-secret short id of the HMAC key (first 8 hex of sha256), or null when unkeyed.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "Refusal/unverified reason (e.g. dirty-tree refusal), or null.", + "type": [ + "string", + "null" + ] + }, + "signed": { + "type": "boolean" + } + }, + "required": [ + "configured", + "signed", + "key_id", + "reason" + ], + "type": "object" + }, + "loomweave": { + "description": "Raw Loomweave taint-fact write result; null when no Loomweave client is configured.", + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "disabled_reason": { + "type": [ + "string", + "null" + ] + }, + "reachable": { + "type": "boolean" + }, + "unresolved_qualnames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "written": { + "description": "Entity taint blobs written.", + "type": "integer" + } + }, + "required": [ + "reachable", + "written", + "unresolved_qualnames", + "disabled_reason" + ], + "type": "object" + } + ] + }, + "loomweave_write": { + "$ref": "#/$defs/loomweave_write_status" + }, + "scope": { + "additionalProperties": false, + "description": "OPTIONAL: the delta-scan (--affected) honesty/provenance block. Present only when an `affected` scope was supplied; absent on a full scan. Declares whether the run analyzed a scoped subset (mode='delta', gate_authority='advisory') or fell back to a full scan (mode='full-fallback', gate_authority='gate-of-record'). In delta mode, only scoped files were analyzed; a clean delta is advisory and not a gate-of-record pass.", + "properties": { + "boundary_caveat": { + "description": "The fixed honesty caveat naming the in-scope-correctness hazard (cross-file taint outside the analyzed set is not computed).", + "type": "string" + }, + "entities_requested": { + "description": "Number of input items in the supplied affected scope.", + "type": "integer" + }, + "fell_back_count": { + "description": "Entities resolved via the spoofable qualname-locator path rather than an authoritative SEI.", + "type": "integer" + }, + "files_analyzed": { + "description": "Files actually analyzed; the scoped subset in delta mode, == files_discovered in full-fallback.", + "type": "integer" + }, + "files_discovered": { + "description": "Files discovered (== top-level files_scanned).", + "type": "integer" + }, + "gate_authority": { + "description": "Machine-readable companion to mode: advisory in delta mode (a clean delta is not a full-tree pass), gate-of-record in full-fallback.", + "enum": [ + "advisory", + "gate-of-record" + ], + "type": "string" + }, + "in_scope_findings": { + "description": "Displayed (post-filter) finding count for the affected entities.", + "type": "integer" + }, + "loomweave_used": { + "description": "Whether the authoritative SEI (loomweave) resolution path fired for any entity.", + "type": "boolean" + }, + "mode": { + "description": "delta = a scoped file subset was analyzed; full-fallback = the scope resolved zero files (empty / all-unresolvable) so a full scan ran (fail-closed honesty).", + "enum": [ + "delta", + "full-fallback" + ], + "type": "string" + }, + "stale_sei_count": { + "description": "Entities whose SEI resolved to a now-absent qualname (loomweave stale vs working tree).", + "type": "integer" + }, + "unresolved_entities": { + "description": "Entities that did not resolve to any file even in delta mode (scanned subset, not fallback).", + "items": { + "additionalProperties": false, + "properties": { + "locator": { + "type": [ + "string", + "null" + ] + }, + "sei": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "mode", + "gate_authority", + "entities_requested", + "files_discovered", + "files_analyzed", + "in_scope_findings", + "fell_back_count", + "stale_sei_count", + "unresolved_entities", + "loomweave_used", + "boundary_caveat" + ], + "type": "object" + }, + "summary": { + "additionalProperties": false, + "description": "Whole-project finding counts. active+baselined+waived+judged+informational == total; unanalyzed is an overlay, not a partition member.", + "properties": { + "active": { + "description": "Non-suppressed defects.", + "type": "integer" + }, + "baselined": { + "type": "integer" + }, + "informational": { + "description": "All non-defect findings (facts, metrics, classifications).", + "type": "integer" + }, + "judged": { + "type": "integer" + }, + "total": { + "description": "Every finding (defects + facts/metrics).", + "type": "integer" + }, + "unanalyzed": { + "description": "Files discovered but never analysed (parse errors / too-deep / missing source roots); overlay count.", + "type": "integer" + }, + "waived": { + "type": "integer" + } + }, + "required": [ + "total", + "active", + "baselined", + "waived", + "judged", + "informational", + "unanalyzed" + ], + "type": "object" + } + }, + "required": [ + "files_scanned", + "summary", + "gate", + "loomweave", + "filigree", + "loomweave_write", + "filigree_emit", + "agent_summary" + ], + "type": "object" + }, + "scan_file_findings": { + "additionalProperties": false, + "description": "Success payload of the scan_file_findings tool: one-shot scan -> (optionally) emit findings to Filigree -> (optionally) promote selected active defects into tracked issues.", + "properties": { + "active_defects": { + "description": "Every active (non-suppressed) defect in the scan, each with its per-finding promotion and identity-attach outcome.", + "items": { + "additionalProperties": false, + "properties": { + "explanation": { + "additionalProperties": false, + "description": "One-hop taint provenance slice. Present only when the finding has a qualname AND the scan produced an analysis context.", + "properties": { + "immediate_tainted_callee": { + "description": "The directly-called function that contributed the taint, if resolved.", + "type": [ + "string", + "null" + ] + }, + "resolved_call_count": { + "description": "Calls inside the function the analyzer resolved.", + "type": "integer" + }, + "source_boundary_qualname": { + "description": "Qualname of the boundary function the taint originated from (one hop only).", + "type": [ + "string", + "null" + ] + }, + "tier_in": { + "description": "Actual (untrusted) trust tier arriving at the sink.", + "type": [ + "string", + "null" + ] + }, + "tier_out": { + "description": "Trust tier the sink declares it returns.", + "type": [ + "string", + "null" + ] + }, + "unresolved_call_count": { + "description": "Calls the analyzer could not resolve.", + "type": "integer" + } + }, + "required": [ + "tier_in", + "tier_out", + "immediate_tainted_callee", + "source_boundary_qualname", + "resolved_call_count", + "unresolved_call_count" + ], + "type": "object" + }, + "fingerprint": { + "description": "Stable finding fingerprint (the promotion join key).", + "type": "string" + }, + "identity_attach": { + "additionalProperties": false, + "description": "Outcome of binding the finding's Loomweave entity identity to the promoted issue.", + "properties": { + "attached": { + "description": "Whether the entity association was successfully attached.", + "type": "boolean" + }, + "attempted": { + "description": "Whether an identity attach was attempted at all.", + "type": "boolean" + }, + "binding_kind": { + "description": "Whether the binding used a rename-stable SEI or a legacy locator; null when no binding was attempted.", + "enum": [ + "sei", + "locator", + null + ], + "type": [ + "string", + "null" + ] + }, + "content_hash": { + "description": "Entity content hash captured at attach time; null when unresolved.", + "type": [ + "string", + "null" + ] + }, + "entity_id": { + "description": "The entity identifier used \u2014 a 'loomweave:eid:...' SEI or a legacy locator.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "Why the attach was not attempted or was skipped; null on success.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "attempted", + "attached", + "entity_id", + "content_hash", + "binding_kind", + "reason" + ], + "type": "object" + }, + "line": { + "description": "1-based start line; null when unknown.", + "type": [ + "integer", + "null" + ] + }, + "message": { + "description": "Human-readable finding message.", + "type": "string" + }, + "path": { + "description": "Repo-relative file path of the finding.", + "type": "string" + }, + "promotion": { + "additionalProperties": false, + "description": "Per-finding Filigree promote outcome for this defect.", + "properties": { + "attempted": { + "description": "Whether a promote was actually attempted (false in dry_run, when unselected, or when no filer is configured).", + "type": "boolean" + }, + "created": { + "description": "True when the promote created a NEW issue.", + "type": "boolean" + }, + "disabled_reason": { + "description": "Why the promote did not happen or failed soft; null on success.", + "type": [ + "string", + "null" + ] + }, + "issue_id": { + "description": "The Filigree issue id; null when not attempted, unreachable, or not found.", + "type": [ + "string", + "null" + ] + }, + "not_found": { + "description": "True when Filigree was reachable but the fingerprint is unknown to it (404).", + "type": "boolean" + }, + "reachable": { + "description": "Whether Filigree was reachable for the promote; null when not attempted.", + "type": [ + "boolean", + "null" + ] + }, + "selected": { + "description": "Whether this finding was in the selection set.", + "type": "boolean" + } + }, + "required": [ + "selected", + "attempted", + "reachable", + "issue_id", + "created", + "not_found", + "disabled_reason" + ], + "type": "object" + }, + "qualname": { + "description": "Dotted module-qualified name of the enclosing callable; null when the finding has no callable anchor.", + "type": [ + "string", + "null" + ] + }, + "rule_id": { + "description": "Rule that produced the finding (e.g. PY-WL-101).", + "type": "string" + }, + "severity": { + "description": "Finding severity.", + "enum": [ + "CRITICAL", + "ERROR", + "WARN", + "INFO", + "NONE" + ], + "type": "string" + } + }, + "required": [ + "fingerprint", + "rule_id", + "severity", + "message", + "qualname", + "path", + "line", + "promotion", + "identity_attach" + ], + "type": "object" + }, + "type": "array" + }, + "files_scanned": { + "description": "Number of files the scan analyzed.", + "type": "integer" + }, + "filigree_emit": { + "additionalProperties": false, + "description": "Outcome of bulk-emitting scan findings to Filigree (runs only when findings were selected and an emitter is configured).", + "properties": { + "configured": { + "description": "Whether a Filigree emitter is configured for this server.", + "type": "boolean" + }, + "created": { + "description": "Findings newly created in Filigree.", + "type": "integer" + }, + "disabled_reason": { + "description": "Why the emit failed soft \u2014 the discriminated 401/403-vs-5xx-vs-transport ladder ('not configured', 'filigree rejected the token (401)...', 'filigree unreachable'). null means success OR no emit was attempted (dry-run / nothing selected) \u2014 read `reachable` to tell them apart (null = no attempt).", + "type": [ + "string", + "null" + ] + }, + "failed": { + "description": "Count of findings that did NOT land in Filigree (derived from `failures`). 0 here is earned from real per-finding records, not assumed \u2014 see `failures` for which and why.", + "type": "integer" + }, + "failures": { + "description": "PDR-0023 honesty surface: one record per finding that failed to land, so a PARTIAL ingest ('M of N emitted, K rejected because R') is distinguishable from a clean emit ('all N emitted'). Empty on a clean run \u2014 but earned, not hardwired.", + "items": { + "additionalProperties": false, + "properties": { + "cause": { + "description": "weft-reason carrier `cause`: the why (Filigree's detail, else the domain reason). Always present on a failure (a failure is never clean).", + "type": "string" + }, + "detail": { + "description": "Filigree's per-finding reject explanation.", + "type": "string" + }, + "fingerprint": { + "description": "The wardline join key for the failed finding (absent when the failure is chunk-wide and not attributable to one finding).", + "type": "string" + }, + "fix": { + "description": "weft-reason carrier `fix` (MANDATORY on a non-clean carrier): the remedial action.", + "type": "string" + }, + "reason": { + "description": "Machine-readable failure case: rejected (Filigree refused this finding), validation_error (malformed body), scheme_mismatch (fingerprint-scheme drift \u2014 a join-miss, not a true-negative), partial (the whole chunk was rejected at the protocol layer, so the cause is the request not the body).", + "enum": [ + "rejected", + "validation_error", + "scheme_mismatch", + "partial" + ], + "type": "string" + }, + "reason_class": { + "description": "weft-reason (G1): the canonical reason_class this failure maps to (one of the closed 11 in contracts/weft-reason-vocab.json). validation_error maps to rejected; the domain term stays in `reason`/`cause`.", + "enum": [ + "rejected", + "scheme_mismatch", + "partial" + ], + "type": "string" + } + }, + "required": [ + "reason", + "detail", + "reason_class", + "cause", + "fix" + ], + "type": "object" + }, + "type": "array" + }, + "reachable": { + "description": "Whether Filigree was reachable for the emit; null when no emit was attempted.", + "type": [ + "boolean", + "null" + ] + }, + "updated": { + "description": "Findings updated in Filigree.", + "type": "integer" + }, + "warnings": { + "description": "Non-fatal emit warnings.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason" + ], + "type": "object" + }, + "gate": { + "additionalProperties": false, + "description": "The pass/fail gate decision for this scan (a trip is data, not an error).", + "properties": { + "exit_class": { + "description": "CLI-equivalent exit class: 0 clean, 1 gate tripped (2 is reserved for tool errors and never appears here).", + "type": "integer" + }, + "fail_on": { + "description": "The severity threshold the gate evaluated (e.g. 'ERROR'); null when no threshold was given.", + "type": [ + "string", + "null" + ] + }, + "tripped": { + "description": "Whether the gate tripped.", + "type": "boolean" + }, + "verdict": { + "description": "NOT_EVALUATED = no authoritative severity threshold judged the full gate population; PASSED/FAILED = configured gate(s) judged authoritatively. Never reads a bare scan or advisory delta as a clean pass.", + "enum": [ + "NOT_EVALUATED", + "PASSED", + "FAILED" + ], + "type": "string" + }, + "would_trip_at": { + "description": "Highest severity at which the gate WOULD trip on the evaluated population; null when nothing would trip.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "tripped", + "fail_on", + "exit_class", + "verdict", + "would_trip_at" + ], + "type": "object" + }, + "mode": { + "description": "Selection mode that ran: dry_run (nothing promoted), all_active (every active defect selected), or fingerprints (explicit selection).", + "enum": [ + "dry_run", + "all_active", + "fingerprints" + ], + "type": "string" + }, + "selected_count": { + "description": "How many selected fingerprints matched known active defects.", + "type": "integer" + }, + "summary": { + "additionalProperties": false, + "description": "Finding counts by suppression class for the whole scan.", + "properties": { + "active": { + "description": "Non-suppressed defects.", + "type": "integer" + }, + "baselined": { + "description": "Defects suppressed by the baseline.", + "type": "integer" + }, + "informational": { + "description": "Informational (non-gating) findings.", + "type": "integer" + }, + "judged": { + "description": "Defects suppressed by judge FALSE_POSITIVE records.", + "type": "integer" + }, + "total": { + "description": "Every finding (defects + facts/metrics).", + "type": "integer" + }, + "unanalyzed": { + "description": "Files discovered but never analyzed (benign no-module skips excluded).", + "type": "integer" + }, + "waived": { + "description": "Defects suppressed by waivers.", + "type": "integer" + } + }, + "required": [ + "total", + "active", + "baselined", + "waived", + "judged", + "informational", + "unanalyzed" + ], + "type": "object" + }, + "unknown_fingerprints": { + "description": "Explicitly-requested fingerprints that are not among the scan's active defects.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "mode", + "files_scanned", + "summary", + "gate", + "filigree_emit", + "active_defects", + "selected_count", + "unknown_fingerprints" + ], + "type": "object" + }, + "scan_job_cancel": { + "additionalProperties": true, + "description": "File-backed scan-job status. Non-terminal jobs include heartbeat/pid/progress; terminal jobs include summary/gate/artifacts when the worker reached them.", + "properties": { + "artifacts": { + "type": "object" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "failure_kind": { + "type": [ + "string", + "null" + ] + }, + "heartbeat": { + "type": "string" + }, + "job_id": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "pid": { + "type": "integer" + }, + "progress": { + "type": "object" + }, + "request": { + "type": "object" + }, + "status": { + "enum": [ + "queued", + "running", + "running_stale", + "completed", + "completed_with_enrichment_failure", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "job_id", + "status", + "phase", + "progress", + "heartbeat", + "artifacts", + "failure_kind", + "error", + "request" + ], + "type": "object" + }, + "scan_job_start": { + "additionalProperties": true, + "description": "File-backed scan-job status. Non-terminal jobs include heartbeat/pid/progress; terminal jobs include summary/gate/artifacts when the worker reached them.", + "properties": { + "artifacts": { + "type": "object" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "failure_kind": { + "type": [ + "string", + "null" + ] + }, + "heartbeat": { + "type": "string" + }, + "job_id": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "pid": { + "type": "integer" + }, + "progress": { + "type": "object" + }, + "request": { + "type": "object" + }, + "status": { + "enum": [ + "queued", + "running", + "running_stale", + "completed", + "completed_with_enrichment_failure", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "job_id", + "status", + "phase", + "progress", + "heartbeat", + "artifacts", + "failure_kind", + "error", + "request" + ], + "type": "object" + }, + "scan_job_status": { + "additionalProperties": true, + "description": "File-backed scan-job status. Non-terminal jobs include heartbeat/pid/progress; terminal jobs include summary/gate/artifacts when the worker reached them.", + "properties": { + "artifacts": { + "type": "object" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "failure_kind": { + "type": [ + "string", + "null" + ] + }, + "heartbeat": { + "type": "string" + }, + "job_id": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "pid": { + "type": "integer" + }, + "progress": { + "type": "object" + }, + "request": { + "type": "object" + }, + "status": { + "enum": [ + "queued", + "running", + "running_stale", + "completed", + "completed_with_enrichment_failure", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "job_id", + "status", + "phase", + "progress", + "heartbeat", + "artifacts", + "failure_kind", + "error", + "request" + ], + "type": "object" + }, + "verify_attestation": { + "additionalProperties": false, + "description": "Result of verifying an attestation bundle: signature check (always, offline) plus optional reproducibility re-derivation against the current tree.", + "properties": { + "mismatches": { + "description": "Top-level payload keys that differ between the recorded and re-derived payloads. Empty unless reproduced is false.", + "items": { + "type": "string" + }, + "type": "array" + }, + "note": { + "description": "Fixed caveat: reproducibility holds against the RECORDED commit; a mismatch may mean the tree moved, not tamper.", + "type": "string" + }, + "reproduced": { + "description": "null when reproduce was not requested (or no root); true when the re-derived payload's canonical bytes equal the recorded payload's; false otherwise. A false may mean the tree moved on, not tamper.", + "type": [ + "boolean", + "null" + ] + }, + "signature_valid": { + "description": "True iff the recomputed HMAC over the recorded payload matches the stored signature (schema tag, alg, and key_id must all match too).", + "type": "boolean" + } + }, + "required": [ + "signature_valid", + "reproduced", + "mismatches", + "note" + ], + "type": "object" + }, + "waiver_add": { + "additionalProperties": false, + "description": "Success payload of the wardline MCP `waiver_add` tool: the waiver that now covers the fingerprint (newly added, or the pre-existing one when a waiver for the fingerprint was already present). When an inline entity_symbol was supplied but did NOT resolve to a SEI, the waiver is NOT written and only {fingerprint, created:false, unresolved_input} is returned (never an unbound-but-looks-bound record).", + "properties": { + "already_exists": { + "description": "True when a waiver for this fingerprint already existed and the returned fields describe that existing waiver; false when this call added a new waiver. Absent on an unresolved_input refusal.", + "type": "boolean" + }, + "binding_kind": { + "description": "Whether the stored binding is a rename-stable SEI or a legacy locator; null when no entity binding was supplied.", + "enum": [ + "sei", + "locator", + null + ], + "type": [ + "string", + "null" + ] + }, + "created": { + "description": "Present only on an unresolved_input refusal: always false (the waiver was NOT written).", + "type": "boolean" + }, + "entity_locator": { + "description": "Human-readable locator for the bound entity (e.g. 'python:function:pkg.mod.fn'); null when no inline entity binding was supplied.", + "type": [ + "string", + "null" + ] + }, + "entity_sei": { + "description": "The rename-surviving Loomweave SEI bound to this waiver's code entity, or the opaque entity_id supplied verbatim; null when no inline entity binding was supplied.", + "type": [ + "string", + "null" + ] + }, + "expires": { + "description": "Waiver expiry as an ISO date (YYYY-MM-DD). Null only when a pre-existing waiver loaded from the waivers file carries no expiry (the tool itself requires expires on new waivers). Absent on an unresolved_input refusal.", + "type": [ + "string", + "null" + ] + }, + "fingerprint": { + "description": "The finding fingerprint the waiver covers.", + "type": "string" + }, + "reason": { + "description": "The waiver's reason. When already_exists=true this is the EXISTING waiver's reason, which may differ from the one passed in this call. Absent on an unresolved_input refusal (nothing was written).", + "type": "string" + }, + "unresolved_input": { + "additionalProperties": false, + "description": "Present only when an inline entity_symbol was supplied but did not resolve to a SEI. The waiver was NOT written. Canonical weft-reason carrier.", + "properties": { + "cause": { + "description": "Why the supplied entity reference did not resolve.", + "type": "string" + }, + "fix": { + "description": "The actionable next step to make it resolve.", + "type": "string" + }, + "reason_class": { + "enum": [ + "unresolved_input" + ], + "type": "string" + } + }, + "required": [ + "reason_class", + "cause", + "fix" + ], + "type": "object" + } + }, + "required": [ + "fingerprint" + ], + "type": "object" + } +} diff --git a/tests/conformance/seam_registry.json b/tests/conformance/seam_registry.json new file mode 100644 index 00000000..a49990ea --- /dev/null +++ b/tests/conformance/seam_registry.json @@ -0,0 +1,774 @@ +[ + { + "seam": "SEI conformance (loomweave producer \u2194 wardline consumer)", + "authority": "loomweave", + "consumer_or_second_producer": "wardline", + "wire": "tests/conformance/fixtures/sei-conformance-oracle.json (vendored scenario fixture; SEI token opaque)", + "two_sided": true, + "oracle_shape": "scenario", + "oracle_test": "tests/conformance/test_sei_oracle.py", + "marker": "loomweave_e2e", + "drift_alarm": "sei_drift", + "drift_test": "tests/conformance/test_sei_oracle.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_sei_oracle.py:173-180", + "tests/conformance/test_sei_oracle.py:204-205", + "tests/conformance/test_sei_oracle.py:95-157", + "tests/e2e/test_loomweave_live.py:261-302", + "tests/conformance/fixtures/sei-conformance-oracle.json", + "src/wardline/loomweave/identity.py:125-167", + "src/wardline/_live_oracle.py:6-21" + ] + }, + { + "seam": "SEI conformance (loomweave producer \u2194 legis consumer)", + "authority": "loomweave", + "consumer_or_second_producer": "legis", + "wire": "SEI token over loomweave HTTP identity routes (legis-side consumption \u2014 not exercised in this repo)", + "two_sided": true, + "oracle_shape": "scenario", + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "peer_conformant", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Conformant in peer repos (wardline not a party). loomweave AUTHORS sei-conformance-oracle.json; legis (consumer) vendors it byte-identical (blob 0ea577025d94c028a0f682b7d29765079455718c) + Layer-1 byte-pin + drives the REAL IdentityResolver/find_orphan_gaps/AuditStore over all 6 \u00a78 scenarios. legis@6a750cc tests/conformance/test_sei_oracle.py + test_sei_oracle_byte_pin.py." + }, + { + "seam": "SEI consumer (loomweave producer \u2194 charter consumer): charter consumes opaque SEI from loomweave for rename-resilient requirement\u2194code traceability (clarion_entity --satisfies--> requirement_version)", + "authority": "loomweave", + "consumer_or_second_producer": "charter", + "wire": "opaque SEI token (charter-side consumption \u2014 not exercised in this repo)", + "two_sided": true, + "oracle_shape": "scenario", + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "Filigree entity-associations (SEI/locator opaque binding) producer GET /api/entity-associations reverse-lookup wire to loomweave drift-consumer (parse_entity_associations_response)", + "authority": "filigree", + "consumer_or_second_producer": "loomweave", + "wire": "GET /api/entity-associations (SEI/locator opaque binding + content_hash drift)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "loomweave HTTP Read API (file resolution, call-graph linkages, capability discovery) \u2014 peer pair loomweave (producer) \u2194 filigree (consumer, ADR-014 registry backend).", + "authority": "loomweave", + "consumer_or_second_producer": "wardline", + "wire": "loomweave HTTP Read API (file resolution, call-graph linkages, capability discovery)", + "two_sided": false, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "loomweave HMAC/Bearer inbound auth for HTTP Read API (producer: /home/john/loomweave, consumer: /home/john/legis)", + "authority": "loomweave", + "consumer_or_second_producer": "legis", + "wire": "loomweave HMAC/Bearer canonical request signing (auth.rs <-> weft_signing.py; ADR-034/042)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "peer_conformant", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Conformant in peer repos (wardline not a party). legis (consumer) REAL signer (sign_loomweave_request) agrees with loomweave's REAL Rust HMAC verifier (component_hmac_hex/canonical_hmac_message) \u2014 live-gated 200-vs-401 proof + skip-clean Layer-2 recheck of loomweave's canonical-message template. The SEI-semantics + legis-own-HMAC axes were already subsumed by the SEI oracle; only the cross-impl agreement was uncovered. legis@6a750cc tests/conformance/test_loomweave_hmac_wire_conformance.py." + }, + { + "seam": "loomweave\u2192legis SEI wire transport (HTTP identity routes): legis (consumer) sends optionally-HMAC-signed (X-Weft-Component) requests to loomweave's /api/v1/_capabilities, /identity/resolve, /identity/sei/:sei, /identity/lineage/:sei; loomweave (producer) serves them as its dossier/audit-spine participation surface. Responses unsigned, integrity rests on TLS.", + "authority": "loomweave", + "consumer_or_second_producer": "legis", + "wire": "loomweave HTTP identity routes (resolve / sei/:sei / lineage) \u2014 legis consumer (not exercised in this repo)", + "two_sided": true, + "oracle_shape": "scenario", + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Partially covered, NOT two-sided at the bar (wardline not a party). Honest split: the SEI carry/orphan/capability SEMANTICS are pinned by legis SEI oracle over the \u00a78 fixture BUT via an in-process FakeLoomweave that BYPASSES the HTTP wire; the HMAC auth/transport is proven live-gated (legis@6a750cc test_loomweave_hmac_wire_conformance.py). GAP: no default-suite test freezes a golden of loomweave identity-route RESPONSE SHAPES ({sei,current_locator,content_hash,alive} / lineage) driving legis real _decode_json_response \u2014 the wire-shape axis is unfrozen. To close: vendor a loomweave identity-route response golden + legis consumer oracle (legis is the consumer; loomweave the producer/authority)." + }, + { + "seam": "Wardline\u2192loomweave taint-fact store (HMAC write + read-by-SEI, blake3 freshness, wardline-taint-1 blob) \u2014 SP9/T3.4", + "authority": "wardline", + "consumer_or_second_producer": "loomweave", + "wire": "tests/conformance/fixtures/wardline-taint-fact-wire.golden.json (the per-entity wardline-taint-1 taint-fact write payloads wardline produces: the opaque wardline_json blob + the dotted qualname + the top-level blake3 content_hash_at_compute). Wardline IS the AUTHORITY: it OWNS the taint-fact blob via wardline.loomweave.facts.build_taint_facts and loomweave stores the wardline_json blob VERBATIM (opaque to it), so the vendored golden is a copy of wardline's OWN derived bytes and the Layer-1 UPSTREAM_BLOB_SHA byte-pin (test_loomweave_taint_fact_wire_golden.py) is wardline-pins-wardline. The circular oracle is broken by a PRODUCER-SOURCE recheck (self_authored_producer=true): test_golden_matches_live_producer scans the SAME fixed source through the LIVE wardline.loomweave.facts.build_taint_facts and asserts (INLINE) the regenerated fact list == the frozen golden, so a blob-shape / taint-projection / dead-code-root / finding-field / blake3-freshness drift reds even though the byte-pin still passes. The seam-registry gate REQUIRES that producer-source recheck (an in-process wardline runtime import + an equality assert tying an imported wardline symbol to the golden) for self_authored_producer at_bar rows, so a deleted/byte-pin-only drift_test reds the gate. SCOPE: this golden pins the PER-FACT projection (the list build_taint_facts emits, each item's {qualname, content_hash_at_compute, wardline_json} blob loomweave stores verbatim). It deliberately does NOT pin (a) the outer HTTP write envelope {\"project\": , \"facts\": [...]} POSTed to POST /api/wardline/taint-facts, which is applied AFTER this projection in client.write_taint_facts and carries a runtime/config-derived project id (not a deterministic byte to freeze); nor (b) the HMAC write signature (canonical request message embeds a non-deterministic timestamp+nonce). The HMAC deterministic core is pinned byte-exactly against loomweave's auth.rs verifier in tests/unit/loomweave/test_hmac.py. The loomweave (Rust) consumer side that reads the blob back by SEI is a follow-on (lives in the loomweave repo, not this one).", + "two_sided": true, + "self_authored_producer": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_loomweave_taint_fact_wire_golden.py", + "marker": null, + "drift_alarm": null, + "drift_test": "tests/conformance/test_loomweave_taint_fact_wire_golden.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_loomweave_taint_fact_wire_golden.py", + "tests/conformance/fixtures/wardline-taint-fact-wire.golden.json", + "tests/unit/loomweave/test_facts.py:45-53", + "tests/unit/loomweave/test_hmac.py:19-68", + "src/wardline/loomweave/_hmac.py:25-42", + "src/wardline/loomweave/facts.py:55-142", + "tests/e2e/test_loomweave_live.py:208-399" + ] + }, + { + "seam": "Wardline qualname normalization / parity (Python + Rust corpus) \u2014 producer wardline, consumer loomweave", + "authority": "loomweave", + "consumer_or_second_producer": "wardline", + "wire": "vendored qualname corpora (Rust: tests/conformance/qualnames_rust.json; Python: tests/conformance/loomweave_qualname_parity.json); ADR-049 second-producer parity. BOTH axes carry a Layer-1 vendored byte-pin that runs in the default suite \u2014 Rust UPSTREAM_BLOB_SHA (test_loomweave_rust_qualname_parity.py:119, the gate's drift_test) and Python VENDORED_BLOB_SHA (test_loomweave_qualname_parity.py, listed under additional_drift_tests so the gate byte-pin-checks it too) \u2014 plus a Layer-2 loomweave_drift live recheck per axis (Python's compares substantive content, ignoring the vendored _wardline_provenance/$comment metadata). The seam-registry gate enforces BOTH axes' byte-pins (drift_test + every additional_drift_tests entry); neither is review-only.", + "two_sided": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_loomweave_rust_qualname_parity.py", + "marker": "loomweave_e2e", + "drift_alarm": "loomweave_drift", + "drift_test": "tests/conformance/test_loomweave_rust_qualname_parity.py", + "additional_drift_tests": [ + "tests/conformance/test_loomweave_qualname_parity.py" + ], + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_loomweave_rust_qualname_parity.py:119", + "tests/conformance/test_loomweave_rust_qualname_parity.py:193-209", + "tests/conformance/test_loomweave_rust_qualname_parity.py:255-294", + "tests/conformance/qualnames_rust.json", + "tests/conformance/loomweave_qualname_parity.json", + "tests/conformance/test_loomweave_qualname_parity.py", + "tests/e2e/test_loomweave_live.py:410-466", + "src/wardline/_live_oracle.py:6-21" + ] + }, + { + "seam": "Wardline\u2192legis scan-artifact wire (HMAC-signed findings, G1) \u2014 producer wardline (/home/john/wardline), consumer legis (/home/john/legis)", + "authority": "wardline", + "consumer_or_second_producer": "legis", + "wire": "tests/conformance/vectors/wardline_scan_artifact.v1.json \u2014 the SINGLE shared cross-member vector, AUTHORED by legis (legis/tests/contract/weft/vectors/wardline_scan_artifact.v1.json) and vendored byte-identical into wardline. legis (consumer) drives its real active_defects/verify over it; wardline (producer) byte-pins it (VENDORED_BLOB_SHA fd4b21be\u2026) and proves its real sign_artifact reproduces the byte-exact expected_signature + its real project_finding emits the vector finding wire. Both sides now load the SAME bytes \u2014 closing the two-independent-mirrors gap that was the 2026-06-10 G1 incident (root cause #2). The older wardline-only legis_scan_wire.golden.json + test_legis_scan_wire_golden.py remain as wardline's live-emit superset freeze.", + "two_sided": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_wardline_scan_artifact_shared_vector.py", + "marker": "legis_scan_artifact_drift", + "drift_alarm": "legis_scan_artifact_drift", + "drift_test": "tests/conformance/test_wardline_scan_artifact_shared_vector.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "peer_conformance": "legis (consumer) at_bar in-repo: legis/tests/contract/weft/test_wardline_scan_artifact_contract.py drives real ingest over the byte-identical vector (legis blob fd4b21be6f8df15fda37606c65df73fd464b9aea).", + "evidence_paths": [ + "tests/conformance/test_wardline_scan_artifact_shared_vector.py", + "tests/conformance/vectors/wardline_scan_artifact.v1.json", + "tests/conformance/test_legis_scan_wire_golden.py:102-162", + "tests/conformance/test_legis_artifact_contract_freeze.py", + "tests/conformance/test_legis_intake_contract.py" + ] + }, + { + "seam": "Wardline\u2192Filigree scan-results emission/intake (POST /api/weft/scan-results, classic /api/v1/scan-results alias)", + "authority": "wardline", + "consumer_or_second_producer": "filigree", + "wire": "POST /api/weft/scan-results (classic scan-results emission/intake body)", + "two_sided": true, + "self_authored_producer": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_filigree_scan_results_wire_golden.py", + "marker": null, + "drift_alarm": null, + "drift_test": "tests/conformance/test_filigree_scan_results_wire_golden.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_filigree_scan_results_wire_golden.py", + "tests/conformance/fixtures/wardline-scan-results-wire.golden.json", + "tests/unit/core/test_filigree_emit.py:1-47", + "src/wardline/core/filigree_emit.py:79-109" + ] + }, + { + "seam": "Wardline suppression-state filter vocabulary (wardline SuppressionState enum -> filigree /api/weft/findings + MCP finding_list filter grammar)", + "authority": "wardline", + "consumer_or_second_producer": "filigree", + "wire": "tests/conformance/filigree_suppression_filter_contract.json (SuppressionState enum -> filigree filter values). Wardline IS the AUTHORITY: it OWNS the suppression_state vocabulary via its SuppressionState enum, so the vendored contract is a copy of wardline's OWN bytes and the Layer-1 UPSTREAM_BLOB_SHA byte-pin (test_filigree_suppression_filter_contract.py) is wardline-pins-wardline. The circular oracle is broken by a PRODUCER-SOURCE recheck (self_authored_producer=true): test_vector_matches_suppression_state_enum imports the LIVE wardline.core.finding.SuppressionState enum and asserts its member values == the frozen contract's suppression_states, so an enum drift reds even though the byte-pin still passes. The seam-registry gate REQUIRES that producer-source recheck (an in-process wardline runtime import + an equality/membership assert tying an imported wardline symbol to the contract) for self_authored_producer at_bar rows, so a deleted/byte-pin-only drift_test reds the gate.", + "two_sided": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_filigree_suppression_filter_contract.py", + "marker": null, + "drift_alarm": null, + "drift_test": "tests/conformance/test_filigree_suppression_filter_contract.py", + "self_authored_producer": true, + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_filigree_suppression_filter_contract.py:24-36", + "tests/conformance/filigree_suppression_filter_contract.json:1-20", + "src/wardline/core/finding.py:71-75", + "filigree (sibling repo): tests/conformance/test_wardline_suppression_filter_contract.py \u2014 filigree consumer-side oracle of this same contract (referenced as evidence; lives in the filigree repo, not this one)" + ] + }, + { + "seam": "Finding identity & wire contract (fingerprint/qualname/spans) \u2014 wardline (producer) \u2194 filigree (consumer)", + "authority": "wardline", + "consumer_or_second_producer": "filigree", + "wire": "tests/conformance/fixtures/wardline-finding-identity-wire.golden.json (finding-identity vectors: for fixed deterministic inputs, the {fingerprint, qualname, spans} the producer emits). Wardline IS the AUTHORITY: it OWNS finding identity via compute_finding_fingerprint / format_fingerprint / FINGERPRINT_SCHEME / _to_wire_qualname (src/wardline/core/finding.py) and Finding.to_jsonl, so the vendored golden is a copy of wardline's OWN derived bytes and the Layer-1 UPSTREAM_BLOB_SHA byte-pin (test_filigree_finding_identity_wire_golden.py) is wardline-pins-wardline. The circular oracle is broken by a PRODUCER-SOURCE recheck (self_authored_producer=true): test_golden_matches_live_identity_producer imports the LIVE wardline identity producers and asserts, for each vector, that re-deriving the identity from the SAME fixed inputs reproduces the frozen golden (so a hash-formula / scheme / qualname-normalization / span-projection drift reds even though the byte-pin still passes). The collision_pair_* vectors additionally pin the join-key soundness property: two findings sharing (rule_id, path, qualname) that differ only in the source-derived taint_path produce DISTINCT fingerprints. The seam-registry gate REQUIRES that producer-source recheck (an in-process wardline runtime import + an equality assert tying an imported wardline symbol to the golden) for self_authored_producer at_bar rows, so a deleted/byte-pin-only drift_test reds the gate. NOTE: the filigree consumer side (keys issues on (scan_source, fingerprint)) is a follow-on; this row pins the producer-authored identity bytes.", + "two_sided": true, + "self_authored_producer": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_filigree_finding_identity_wire_golden.py", + "marker": null, + "drift_alarm": null, + "drift_test": "tests/conformance/test_filigree_finding_identity_wire_golden.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_filigree_finding_identity_wire_golden.py", + "tests/conformance/fixtures/wardline-finding-identity-wire.golden.json", + "tests/golden/identity/test_identity_parity.py:48-54", + "tests/golden/identity/corpus/META.json", + "src/wardline/core/finding.py:180-190", + "src/wardline/core/finding.py:207-213", + "src/wardline/core/finding.py:271-298", + "docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md:21", + "filigree (sibling repo): keys issues on (scan_source, fingerprint) \u2014 consumer-side oracle of this identity contract is a follow-on (lives in the filigree repo, not this one)" + ] + }, + { + "seam": "Vocabulary descriptor (cross-product trust-vocab contract) \u2014 wardline (producer) \u2192 loomweave (consumer)", + "authority": "wardline", + "consumer_or_second_producer": "loomweave", + "wire": "tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml (the NG-25 trust-vocabulary descriptor wardline emits to .weft/wardline/vocabulary.yaml: {schema: wardline.vocabulary/v1, version, entries[{canonical_name, group, attrs}]}). Wardline IS the AUTHORITY: it OWNS the trust-vocabulary via wardline.core.registry.REGISTRY and serializes it through wardline.core.descriptor.build_vocabulary_descriptor / descriptor_to_yaml, so the vendored golden is a copy of wardline's OWN derived bytes and the Layer-1 UPSTREAM_BLOB_SHA byte-pin (test_vocabulary_descriptor_wire_golden.py) is wardline-pins-wardline. The circular oracle is broken by a PRODUCER-SOURCE recheck (self_authored_producer=true): test_golden_matches_live_descriptor_producer imports the LIVE wardline.core.descriptor.descriptor_to_yaml and asserts its emitted bytes == the frozen golden (and the build_vocabulary_descriptor dict == the parsed golden), so a vocabulary drift (a decorator added/removed, a group/attr changed, schema/version bumped) reds even though the byte-pin still passes. The seam-registry gate REQUIRES that producer-source recheck (an in-process wardline runtime import + an equality assert tying an imported wardline symbol to the golden) for self_authored_producer at_bar rows, so a deleted/byte-pin-only drift_test reds the gate. The loomweave (Python plugin) consumer reads these exact bytes and GATES ON version (asserts the descriptor version == its EXPECTED_DESCRIPTOR_VERSION, currently wardline-generic-2 == wardline's REGISTRY_VERSION \u2014 a real cross-tool coupling: bump one without the other and loomweave flags version_skew), PARSES entries (canonical_name/group/attrs) into WardlineVocabulary for tag emission, and TOLERATES the schema field without acting on it (its schema-format-version handling is deferred to loomweave's own Task B). Its consumer-side oracle lives in the loomweave repo (referenced as evidence) \u2014 this row pins the producer-authored descriptor bytes that make the two sides agree.", + "two_sided": true, + "self_authored_producer": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_vocabulary_descriptor_wire_golden.py", + "marker": null, + "drift_alarm": null, + "drift_test": "tests/conformance/test_vocabulary_descriptor_wire_golden.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_vocabulary_descriptor_wire_golden.py", + "tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml", + "tests/unit/core/test_descriptor.py:57-71", + "tests/unit/core/test_descriptor.py:74-85", + "src/wardline/core/descriptor.py:42-68", + "src/wardline/core/vocabulary.yaml:1", + "loomweave (sibling repo): plugins/python/src/loomweave_plugin_python/wardline_descriptor.py \u2014 reads .weft/wardline/vocabulary.yaml BYTES, parses schema/version/entries into WardlineVocabulary, emits wardline:external_boundary / wardline:trusted entity_tags (consumer-side oracle of this descriptor; lives in the loomweave repo, not this one)" + ] + }, + { + "seam": "Weft canonical reason-vocabulary conformance (weft hub contract \u2192 filigree + legis + wardline emit/ingest)", + "authority": "weft-hub", + "consumer_or_second_producer": "wardline", + "wire": "weft canonical reason-vocabulary (weft hub contract -> filigree + legis + wardline); vendored byte-for-byte at tests/conformance/fixtures/weft-reason-vocab.json with a Layer-1 UPSTREAM_BLOB_SHA byte-pin (test_weft_reason_vocab_conformance.py:110) that runs in the default suite, plus a Layer-2 reason_vocab_drift live recheck against the weft hub source.", + "two_sided": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_weft_reason_vocab_conformance.py", + "marker": "reason_vocab_drift", + "drift_alarm": "reason_vocab_drift", + "drift_test": "tests/conformance/test_weft_reason_vocab_conformance.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_weft_reason_vocab_conformance.py:113", + "tests/conformance/test_weft_reason_vocab_conformance.py:146", + "tests/conformance/test_weft_reason_vocab_conformance.py:131", + "tests/conformance/test_weft_reason_vocab_conformance.py:193", + "tests/conformance/fixtures/weft-reason-vocab.json", + "src/wardline/core/filigree_emit.py:183-275" + ] + }, + { + "seam": "WEFT_FEDERATION_TOKEN bearer-token auth contract (filigree producer \u2194 wardline consumer)", + "authority": "filigree", + "consumer_or_second_producer": "wardline", + "wire": "WEFT_FEDERATION_TOKEN bearer-token auth contract (filigree producer <-> wardline consumer): canonical env var WEFT_FEDERATION_TOKEN (+ deprecated FILIGREE_FEDERATION_API_TOKEN/FILIGREE_API_TOKEN aliases, read-order), .weft/filigree/federation_token tier-2 file, Authorization: Bearer , 401/403-reject / 400-authed-bad-body / 2xx-authed ladder. Wardline AUTHORS the vendored restatement (filigree ships no fixture), so the Layer-1 byte-pin is wardline-pins-wardline (self_authored_restatement=true); the circular oracle is broken by the Layer-2 filigree_token_drift SUBSTANTIVE recheck that re-derives the contract from the sibling filigree source. The seam-registry gate REQUIRES that substantive authority-side recheck (not merely a registered drift marker) for self_authored_restatement at_bar rows, so a deleted/no-op recheck reds the gate.", + "two_sided": true, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_filigree_federation_token_contract.py", + "marker": "filigree_e2e", + "drift_alarm": "filigree_token_drift", + "drift_test": "tests/conformance/test_filigree_federation_token_contract.py", + "self_authored_restatement": true, + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_filigree_federation_token_contract.py:95-107", + "tests/conformance/test_filigree_federation_token_contract.py:185-217", + "tests/conformance/test_filigree_federation_token_contract.py:224-252", + "tests/conformance/fixtures/filigree_federation_token_contract.json", + "tests/unit/core/test_filigree_verify_token.py:23-57", + "src/wardline/core/filigree_emit.py:817-842", + "src/wardline/install/doctor.py:392-411", + "src/wardline/_live_oracle.py:6-21" + ] + }, + { + "seam": "Filigree ephemeral-port discovery convention (producer: filigree dashboard publishes /.weft/filigree/ephemeral.port; consumers: loomweave + wardline read it to override configured base_url, enrich-only, fail-soft)", + "authority": "filigree", + "consumer_or_second_producer": "wardline", + "wire": "ephemeral-port file (.weft/filigree/ephemeral.port: plain ASCII integer) \u2014 one producer (filigree ephemeral.write_port_file = write_atomic(port_file, str(port))), two consumers. STAYS GAP \u2014 assessed 2026-06-25, deliberately NOT advanced. Wardline DOES have a real reader (core/config._read_published_port + install/detect._filigree_url_from_project: read .weft/filigree/ephemeral.port, strip, isdigit-guard, int(), range-check 1..65535), but the 'contract' is an unframeable bare integer: the producer convention is str(port) and the consumer is int(text.strip()), so there is NO freezable contract literal to byte-pin \u2014 a golden of '8542' pins nothing about the format, and the format itself is runtime behavior, not a constant. The ONLY string literal common to both sides is the filename 'ephemeral.port'; a Layer-2 recheck against filigree source could at best assert 'ephemeral.port' in filigree_src, which is near-tautological (it proves a filename string exists, not that any contract is conformed to) \u2014 writing it to flip the row would be exactly the circular/tautological oracle the gate exists to prevent. The wire is also enrich-only / fail-soft by design (detect.py: 'we do NOT write any config'; fail-soft falls through on every defect), with no strict format contract either side conforms to. An honest at_bar is not constructible here; it correctly stays gap.", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "src/wardline/core/config.py:299-320", + "src/wardline/install/detect.py:78-100", + "filigree (sibling repo): src/filigree/ephemeral.py:509-513 write_port_file = write_atomic(port_file, str(port)) \u2014 the bare-integer producer convention; no freezable format contract to conform to" + ] + }, + { + "seam": "loomweave\u2192Filigree scan-results intake (analyze Phase 8 finding emission): loomweave analyze Phase 8 POSTs persisted findings to Filigree POST /api/v1/scan-results, gated by integrations.filigree.{enabled,emit_findings} (default false) + WEFT_FEDERATION_TOKEN bearer; enrich-only degrade on Filigree absent.", + "authority": "loomweave", + "consumer_or_second_producer": "filigree", + "wire": "POST /api/weft/scan-results (loomweave analyze Phase 8 finding emission -> filigree intake; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "loomweave\u2192Filigree issue-detail enrichment: GET /api/weft/issues/{issue_id} consumed by loomweave's issues_for per-match `issue` block (title/status/priority); enrich-only, degrades to issue:null on 404/unreachable.", + "authority": "filigree", + "consumer_or_second_producer": "loomweave", + "wire": "GET /api/weft/issues/{issue_id} (issue-detail enrichment; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "loomweave\u2192Filigree wardline-finding reconciliation (Flow B read-time): loomweave consumes GET /api/weft/files?scan_source=wardline and GET /api/weft/findings?scan_source=wardline from Filigree, then matches metadata.wardline.qualname byte-exact against the entity_id segment-3 canonical_qualified_name. Producer = Filigree (passthrough store of an opaque metadata blob), consumer = loomweave. Enrich-only: degrades to result_kind:\"unavailable\" on absence/truncation.", + "authority": "loomweave", + "consumer_or_second_producer": "filigree", + "wire": "Flow B read-time wardline-finding reconciliation (not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "loomweave\u2192Filigree clean-stale retention sweep (POST /api/weft/findings/clean-stale, loomweave analyze --prune-unseen)", + "authority": "loomweave", + "consumer_or_second_producer": "filigree", + "wire": "POST /api/weft/findings/clean-stale (retention sweep; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "legis git-rename provider for loomweave SEI matcher (GET /git/renames flat array + GET /git/rename-feed object; consumed by loomweave LegisGitRenameSource, unioned with shell --cached working-tree renames)", + "authority": "legis", + "consumer_or_second_producer": "loomweave", + "wire": "GET /git/renames (flat array) + GET single \u2014 legis producer, loomweave consumer (not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "peer_conformant", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Conformant in peer repos (wardline not a party). Shared golden git_renames.v1.json (blob 74f69ee81b030a31f9fdd37dc1c49543fbe389c2) frozen from legis's REAL GET /git/renames; legis producer oracle (legis@6a750cc tests/contract/weft/test_git_rename_wire_conformance.py) + loomweave consumer drives the REAL parse_legis_rename_json over the byte-identical copy (loomweave@9c30ce0 crates/loomweave-cli/src/sei_git.rs, tests/fixtures/weft/git_renames.v1.json). AGREE; both byte-pins negative-probed." + }, + { + "seam": "legis\u2192Filigree governed sign-off binding", + "authority": "legis", + "consumer_or_second_producer": "filigree", + "wire": "governed sign-off binding (legis -> filigree; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "peer_conformant", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Conformant in peer repos (wardline not a party). Distinct wire (not subsumed by entity-associations): the governed POST body carries the signoff_seq+signature extension. Shared golden (blob 8796aeb5b8d7d067c82af17e361aa45fe5007b4e); legis producer drives the REAL HttpFiligreeClient (legis@6a750cc tests/contract/weft/test_signoff_binding_wire_conformance.py) + filigree consumer drives the REAL ASGI entity-associations route, persists the extension + flips to governed (filigree@59a75a9 tests/federation/test_signoff_binding_wire_conformance_oracle.py). AGREE; real-client key-rename negative-probed." + }, + { + "seam": "Warpline reverify-worklist (warpline.reverify_worklist.v1): producer warpline (/home/john/warpline) \u2192 two consumers, wardline delta-scan scope (/home/john/wardline) and filigree warpline_worklist_ingest (/home/john/filigree)", + "authority": "warpline", + "consumer_or_second_producer": "wardline", + "wire": "warpline.reverify_worklist.v1 worklist envelope (warpline reverify | wardline scan --affected -)", + "two_sided": true, + "oracle_shape": "scenario", + "oracle_test": "tests/conformance/test_warpline_delta_scope.py", + "marker": "warpline_e2e", + "drift_alarm": "worklist_drift", + "drift_test": "tests/conformance/test_warpline_worklist_drift.py", + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_warpline_delta_scope.py:1-248", + "tests/conformance/test_warpline_worklist_drift.py", + "tests/conformance/test_warpline_e2e.py:1-175", + "tests/conformance/fixtures/warpline_contract/mcp-response-reverify.json", + "src/wardline/core/delta_scope.py:71-96", + "src/wardline/_live_oracle.py:6-21" + ] + }, + { + "seam": "Wardline\u2192warpline attest / change-impact contract (wardline-attest-2): a planned versioned attest surface where wardline publishes a signed, full-scan, commit-pinned evidence bundle whose per-boundary entries carry a content_hash binding key + 3-valued clean/defect/unknown verdict, and warpline pulls it to do mechanical (commit, content_hash) equality to decide \"proven clean at commit X\". Wardline owns the trust verdict; warpline never declares clean.", + "authority": "wardline", + "consumer_or_second_producer": "warpline", + "wire": "wardline-attest-2 attest / change-impact contract (planned; not yet emitted)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "src/wardline/core/attest.py:62", + "src/wardline/core/attest.py:215-222", + "docs/superpowers/plans/2026-06-24-wardline-warpline-integration-p1.md:7" + ] + }, + { + "seam": "legis\u2192warpline preflight_impact advisory read (warpline.preflight_impact.v1 envelope = warpline_impact_radius_get; consumer legis, producer warpline; reverify_worklist consumption ruled OUT per steward decision 2026-06-26)", + "authority": "warpline", + "consumer_or_second_producer": "legis", + "wire": "warpline.preflight_impact.v1 advisory impact set, surfaced via warpline_impact_radius_get (MCP/CLI success envelope: data.affected[], data.completeness, data.staleness, meta.local_only). legis consumes as advisory preflight context, never gates governance (SEAM 4 section 4A / GV-LG-1). reverify_worklist consumption ruled OUT per steward decision 2026-06-26.", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "src/wardline/core/delta_scope.py:71-196", + "/home/john/weft/pm/2026-06-13-warpline-interface-lock.md:540-549", + "/home/john/weft/pm/2026-06-13-warpline-interface-lock.md:587-589", + "legis@6f50a33 tests/warpline_preflight/test_warpline_preflight_oracle.py" + ], + "peer_conformance": "STEWARD DECISION 2026-06-26 (weft seam steward = wardline). Contract basis: interface-lock SEAM 4 section 4A freezes warpline.preflight_impact.v1 at the SAME wire shape as warpline_impact_radius_get -- the MCP/CLI success envelope (data.affected[], data.completeness, data.staleness, meta.local_only) -- NOT a flat HTTP body; GV-LG-3 requires legis to read meta.local_only/peer_side_effects, which a flat {affected,count} body lacks. RULING: legis conforms DOWN to warpline's frozen envelope (consume data.affected, verify meta per GV-LG-3); warpline ships NOTHING -- a flat HTTP producer was the explicitly-REJECTED branch (a new non-MCP surface contradicting warpline's local-only/MCP-first identity). No lock amendment needed; this is the lock's status quo. REVERIFY RULED OUT (option b): section 4A sanctions ONLY impact_radius for legis; legis's reverify_worklist call is unsanctioned (named reverify consumers are filigree section 2A + wardline section 3), so legis DROPS it and relies on preflight_impact alone -- no new seam minted. STILL GAP (not at_bar): legis's current consumer oracle (legis@6f50a33 byte-pin 44bb515d528fdaca5b12703a896f55cd96c2483b) is a FALSE FREEZE -- it pins the flat {affected,count}/{entries,count} shape warpline never serves (G1-class: a mirror frozen to nothing real). Closeable once legis re-freezes its consumer against warpline's REAL warpline_impact_radius_get envelope (data.affected + meta) over a shared vendored golden; the Layer-2 recheck arms then. SIBLING SWEEP CLEAN (2026-06-26): wardline (delta_scope.py: data.items envelope) and filigree (warpline_consumer.py: data.get('items')) both consume the envelope correctly, plainweave has zero warpline refs -- legis is the SOLE offender, so the 'legis copied filigree's shape' theory is DISPROVEN (filigree reads the envelope). legis code fix dispatched in parallel (not wardline-resident)." + }, + { + "seam": "legis per-SEI attestation read for warpline verification (attestation_get)", + "authority": "warpline", + "consumer_or_second_producer": "legis", + "wire": "attestation_get per-SEI read (legis consumer of warpline; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Legis producer frozen, warpline consumer unwired (wardline not a party; warpline mid-flight). legis PRODUCER frozen (legis@6f50a33 tests/conformance/test_warpline_attestation_oracle.py, REAL attestation_get MCP wire, byte-pin ad894cc0d93cb9e8f5631bfccacbdeacbbae4994, SEI-keyed/rename-stable, fail-closed unavailable discriminant). OBLIGATION: warpline's LegisClient.governance_for_sei is 'disabled' (unwired) \u2014 must read attestation_get and map status->posture (unavailable != empty, the asymmetric-error security rule). Not yet two-sided \u2014 honest gap." + }, + { + "seam": "Charter\u2192Filigree issue/work traceability (filigree_issue --implements_work_for--> requirement_version; filigree_issue --resolves_gap--> gap)", + "authority": "charter", + "consumer_or_second_producer": "filigree", + "wire": "filigree_issue --implements_work_for--> requirement trace-link (not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "Charter\u2192Wardline finding violations (wardline_finding --violates--> acceptance_criterion). Producer: /home/john/wardline. Consumer: /home/john/charter.", + "authority": "charter", + "consumer_or_second_producer": "wardline", + "wire": "wardline_finding --violates--> acceptance_criterion trace-link (not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/grammar/golden/builtin_findings.jsonl" + ] + }, + { + "seam": "Charter\u2192legis preflight facts envelope (weft.charter.preflight_facts.v1)", + "authority": "charter", + "consumer_or_second_producer": "legis", + "wire": "weft.charter.preflight_facts.v1 envelope (not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [], + "peer_conformance": "Producer frozen, consumer genuinely absent (wardline not a party). plainweave PRODUCES the envelope and has frozen its golden (plainweave tests/fixtures/contracts/legis/preflight-facts.json blob 10506f0359317da614237df3694f038bc141009e). legis has ZERO references to it in src/tests \u2014 no ingest path exists; legis/service/preflight.py consumes WARPLINE's wire, not this one. An honest no-contract on the consumer side: a test would be a tautology. Closeable only once legis grows a real preflight_facts ingest." + }, + { + "seam": "Charter stable requirement identity (charter:req:PROJECT_KEY:NNNN opaque ID), peer_pair charter->filigree (consumer_repo in metadata says loomweave; neither resolves it)", + "authority": "charter", + "consumer_or_second_producer": "filigree", + "wire": "charter:req:PROJECT_KEY:NNNN opaque requirement ID (no consumer resolves it yet)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "Charter outward JSON-envelope + trace-link ontology contract", + "authority": "charter", + "consumer_or_second_producer": "filigree", + "wire": "charter outward JSON-envelope + trace-link ontology (no external consumer yet)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "Charter requirement-dossier peer_facts section (producer: /home/john/charter; declared peer_pair charter<->loomweave, but consumer side is charter itself and no loomweave code references it)", + "authority": "charter", + "consumer_or_second_producer": "filigree", + "wire": "requirement-dossier peer_facts section (charter producer; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "Charter\u2194Filigree shared actor/attestation authority registry", + "authority": "charter", + "consumer_or_second_producer": "filigree", + "wire": "shared actor/attestation authority registry (producer-side aspiration only; no binding exists)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "weft.toml shared federation config contract (producer/authority: filigree proposes schema; peers wardline + filigree + loomweave + legis). Two-sided federation cross-read of sibling [X].url keys.", + "authority": "filigree", + "consumer_or_second_producer": "wardline", + "wire": "weft.toml shared federation config (sibling [X].url cross-read) \u2014 schema DRAFT/unmerged at the weft hub", + "two_sided": true, + "oracle_shape": null, + "oracle_test": "tests/unit/core/test_config_toml.py", + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "deferred", + "deferred_reason": "weft.toml sibling-URL cross-read schema is still DRAFT (unmerged at the weft hub; loomweave-009's four open questions unresolved). Both sides reserve-not-implement the cross-reader (wardline config.py:457-506 refuses sibling-URL keys), so the bytes both peers would target are not yet frozen. Conformance is deferred until the schema lands.", + "wire_change": "none", + "evidence_paths": [ + "tests/unit/core/test_config_toml.py:41-90", + "src/wardline/core/config.py:149-292", + "src/wardline/core/config.py:457-506", + "src/wardline/core/config_schema.py", + "docs/guides/weft.md:182-195" + ] + }, + { + "seam": "loomweave.yaml federation configuration (loomweave-local) \u2014 loomweave reads its own loomweave.yaml for federation config governing every outbound seam to filigree/legis/wardline (Filigree base_url + emit_findings gate, serve.http.wardline_taint_write, token/identity env-vars). Single-reader artifact: loomweave is the sole consumer/producer.", + "authority": "loomweave", + "consumer_or_second_producer": "loomweave", + "wire": "loomweave.yaml federation configuration \u2014 single-reader (loomweave is sole producer AND consumer)", + "two_sided": false, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "one_sided_na", + "deferred_reason": "Deliberately single-reader: producer_repo == consumer_repo == loomweave; filigree only WRITES the file to drive a spawned `loomweave serve` (launcher/operator), never deserializes it as a contract. Shared-corpus + drift-alarm is N/A, not failed \u2014 two-sided landing does not apply.", + "wire_change": "none", + "evidence_paths": [] + }, + { + "seam": "Wardline federation status envelope (cross-surface emission status block: filigree_emit / loomweave_write reported across MCP scan, CLI scan, and scan-job result paths)", + "authority": "wardline", + "consumer_or_second_producer": "wardline", + "wire": "federation status envelope (filigree_emit/loomweave_write status across MCP/CLI/scan-job) \u2014 Wardline-internal, agent-consumed", + "two_sided": false, + "oracle_shape": null, + "oracle_test": "tests/conformance/test_mcp_structured_output.py", + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "one_sided_na", + "deferred_reason": "Not a two-sided cross-repo seam: filigree never reads this envelope (zero matches for disabled_reason/auth_rejected/emit_status in filigree src+tests). The consumer is the AGENT reading MCP structuredContent / CLI JSON / scan-job result blocks; the real risk is intra-repo three-surface drift, not cross-peer landing.", + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_mcp_structured_output.py:102-112", + "src/wardline/cli/scan.py:660-690", + "src/wardline/core/scan_jobs.py:258-287", + "src/wardline/mcp/server.py:1175-1227" + ] + }, + { + "seam": "Wardline shared HTTP transport (WeftHttp client consolidation)", + "authority": "wardline", + "consumer_or_second_producer": "wardline", + "wire": "WeftHttp shared in-process urllib transport consolidation (not wire-observable to any peer)", + "two_sided": false, + "oracle_shape": null, + "oracle_test": "tests/unit/core/test_http.py", + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "one_sided_na", + "deferred_reason": "Not a two-sided cross-repo wire seam \u2014 internal Wardline plumbing. The consolidated in-process transport is not wire-observable; the actual wires each client speaks are SEPARATE seams with their own contracts. The refactor is required to be byte-wire-invariant, so no peer-repo landing coordination applies.", + "wire_change": "none", + "evidence_paths": [ + "tests/unit/core/test_http.py" + ] + }, + { + "seam": "Wardline MCP tool/resource contracts (structured output B1/B2)", + "authority": "wardline", + "consumer_or_second_producer": "mcp-client", + "wire": "Wardline MCP tool/resource outputSchema surface (18 tools) frozen to a committed golden tests/conformance/mcp_output_schemas.golden.json with a Layer-1 VENDORED_BLOB_SHA byte-pin in the default suite; one-sided (wardline is sole authority of its own schema), so no peer drift_test \u2014 the golden freeze breaks the old circular self-validation oracle.", + "two_sided": false, + "oracle_shape": "byte_golden_corpus", + "oracle_test": "tests/conformance/test_mcp_output_schema_golden.py", + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "at_bar", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [ + "tests/conformance/test_mcp_output_schema_golden.py", + "tests/conformance/mcp_output_schemas.golden.json", + "tests/conformance/test_mcp_structured_output.py" + ] + }, + { + "seam": "Charter\u2194peers MCP read-only tool/resource inventory co-registration (peer_pair charter\u2194filigree; producer & consumer both /home/john/charter)", + "authority": "charter", + "consumer_or_second_producer": "filigree", + "wire": "MCP read-only tool/resource inventory co-registration (charter <-> peers; not exercised in this repo)", + "two_sided": true, + "oracle_shape": null, + "oracle_test": null, + "marker": null, + "drift_alarm": null, + "drift_test": null, + "bar_verdict": "gap", + "deferred_reason": null, + "wire_change": "none", + "evidence_paths": [] + } +] diff --git a/tests/conformance/test_federation_status_envelope_parity.py b/tests/conformance/test_federation_status_envelope_parity.py new file mode 100644 index 00000000..657383b5 --- /dev/null +++ b/tests/conformance/test_federation_status_envelope_parity.py @@ -0,0 +1,490 @@ +"""Federation-status envelope parity: ONE builder + ONE schema source. + +The ``{"filigree_emit": , "loomweave_write": }`` blocks (and their +JSON-schema ``$defs``) were hand-duplicated across cli/scan, core/scan_jobs, +core/scan_file_workflow, core/agent_summary, and mcp/server. They are now sourced +from ``core/federation_status``. This test pins that consolidation: + +1. Every surface delegates to the canonical builder (its bytes ARE the builder's + bytes) — proven by string equality on representative inputs. +2. The MCP ``$defs`` (and the self-contained scan_file_findings schema) equal the + ONE schema-source. Drift either side reds. +3. The shared required-key contract holds across MCP / CLI / scan-job surfaces. + +Surfaces legitimately differ in CONTEXT — the MCP block is WIDER (discriminated +transport detail; disabled_reason second) and the scan_file block has no +``destination`` and no ``loomweave_write``. Those differences are preserved, not +collapsed, so the assertions below pin the achievable contract (shared keys + same +source), never false cross-surface key-set equality. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +import jsonschema +import pytest + +from wardline.core import federation_status as fs +from wardline.core import scan_file_workflow as sfw +from wardline.core import scan_jobs +from wardline.core.filigree_emit import EmitResult, FailedFinding, filigree_destination +from wardline.loomweave.client import WriteResult +from wardline.mcp import server +from wardline.mcp.server import WardlineMCPServer + +# --- representative emit results spanning the soft-failure ladder ----------- +_OK = EmitResult(reachable=True, created=2, updated=1) +_AUTH = EmitResult(reachable=False, status=401, token_sent=True, url="http://x/api?project=p") +_PARTIAL = EmitResult( + reachable=False, + status=422, + token_sent=True, + url="http://x", + failures=(FailedFinding(reason="rejected", detail="nope", fingerprint="abc"),), +) + + +def _mcp_block(er: EmitResult) -> dict[str, Any]: + """The raw block the MCP scan path (``_emit_filigree``) hands the status builder.""" + return { + "reachable": er.reachable, + "created": er.created, + "updated": er.updated, + "failed": er.failed, + "failures": [f.to_wire() for f in er.failures], + "warnings": list(er.warnings), + "status": er.status, + "auth_rejected": er.auth_rejected, + "token_sent": er.token_sent, + "url": er.url, + "destination": filigree_destination(er.url), + } + + +def _eq(a: dict[str, Any], b: dict[str, Any]) -> bool: + """Byte-identity in the only sense the wire cares about: same JSON (key order included).""" + return json.dumps(a, sort_keys=False) == json.dumps(b, sort_keys=False) + + +# --------------------------------------------------------------------------- +# 1. Every surface IS the canonical builder (byte-for-byte) +# --------------------------------------------------------------------------- + + +def test_cli_filigree_status_is_canonical_builder() -> None: + from wardline.cli import scan as cliscan + + for er in (None, _OK, _AUTH, _PARTIAL): + expected = fs.filigree_emit_status(er, configured=er is not None, include_destination=True) + assert _eq(cliscan._filigree_status(er), expected) + + +def test_scan_job_filigree_status_is_canonical_builder() -> None: + for er in (None, _OK, _AUTH, _PARTIAL): + expected = fs.filigree_emit_status(er, configured=er is not None, include_destination=True) + assert _eq(scan_jobs._filigree_status(er), expected) + + +def test_scan_file_filigree_status_is_canonical_builder() -> None: + # scan_file is the no-destination variant; configured is explicit (dry-run keeps it on). + def canon(er: EmitResult | None, *, configured: bool) -> dict[str, Any]: + return fs.filigree_emit_status(er, configured=configured, include_destination=False) + + assert _eq(sfw._emit_to_dict(None, configured=False), canon(None, configured=False)) + assert _eq(sfw._emit_to_dict(None, configured=True), canon(None, configured=True)) + for er in (_OK, _AUTH, _PARTIAL): + assert _eq(sfw._emit_to_dict(er, configured=True), canon(er, configured=True)) + + +def test_mcp_filigree_status_is_canonical_builder() -> None: + assert _eq(server._filigree_emit_status(None), fs.filigree_emit_status_from_block(None)) + for er in (_OK, _AUTH, _PARTIAL): + block = _mcp_block(er) + assert _eq(server._filigree_emit_status(block), fs.filigree_emit_status_from_block(block)) + + +def test_loomweave_status_is_canonical_builder_across_surfaces() -> None: + from wardline.cli import scan as cliscan + + class WR: + reachable = True + written = 3 + unresolved_qualnames = ("a.b",) + disabled_reason = None + + block = {"reachable": True, "written": 3, "unresolved_qualnames": ["a.b"], "disabled_reason": None} + # not-configured default is shared by every surface + assert _eq(cliscan._loomweave_status(None), fs.default_loomweave_write_status()) + assert _eq(server._loomweave_write_status(None), fs.default_loomweave_write_status()) + # configured CLI (result) and MCP (block) builders agree byte-for-byte + assert _eq(cliscan._loomweave_status(WR()), fs.loomweave_write_status(WR())) + assert _eq(server._loomweave_write_status(block), fs.loomweave_write_status_from_block(block)) + assert _eq(cliscan._loomweave_status(WR()), server._loomweave_write_status(block)) + + +def test_agent_summary_defaults_are_canonical_builder() -> None: + from wardline.core import agent_summary as asm + + assert _eq(asm._default_filigree_status(), fs.default_filigree_emit_status(include_destination=False)) + assert _eq(asm._default_loomweave_status(), fs.default_loomweave_write_status()) + + +# --------------------------------------------------------------------------- +# 2. The MCP $defs / scan_file schema equal the ONE schema-source +# --------------------------------------------------------------------------- + + +def test_mcp_defs_equal_schema_source() -> None: + defs = server._SCAN_OUTPUT_SCHEMA["$defs"] + assert _eq(defs["filigree_emit_status"], fs.filigree_emit_status_schema(include_transport_detail=True)) + assert _eq(defs["loomweave_write_status"], fs.loomweave_write_status_schema()) + + +def test_scan_file_schema_equals_schema_source() -> None: + block = server._SCAN_FILE_FINDINGS_OUTPUT_SCHEMA["properties"]["filigree_emit"] + assert _eq(block, fs.SCAN_FILE_FINDINGS_FILIGREE_EMIT_SCHEMA) + + +def test_scan_file_schema_is_the_no_transport_detail_shape() -> None: + # The scan_file block omits the discriminated transport detail and destination — its + # required/property contract matches include_transport_detail=False. + narrow = fs.filigree_emit_status_schema(include_transport_detail=False) + block = fs.SCAN_FILE_FINDINGS_FILIGREE_EMIT_SCHEMA + assert set(block["properties"]) == set(narrow["properties"]) + assert block["required"] == narrow["required"] + assert "destination" not in block["properties"] + assert "status" not in block["properties"] + + +# --------------------------------------------------------------------------- +# 3. Shared required-key contract across surfaces +# --------------------------------------------------------------------------- + +_SHARED_FILIGREE_KEYS = { + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason", +} + + +def test_shared_filigree_required_keys_present_on_every_surface() -> None: + from wardline.cli import scan as cliscan + + surfaces = [ + cliscan._filigree_status(_OK), + scan_jobs._filigree_status(_OK), + sfw._emit_to_dict(_OK, configured=True), + server._filigree_emit_status(_mcp_block(_OK)), + ] + for block in surfaces: + assert set(block) >= _SHARED_FILIGREE_KEYS, block + # The $defs contract names exactly the shared keys as required (plus destination on the + # transport-detailed variant); the narrow variant requires exactly the shared keys. + narrow_required = set(fs.filigree_emit_status_schema(include_transport_detail=False)["required"]) + assert narrow_required == _SHARED_FILIGREE_KEYS + + +def test_mcp_filigree_block_is_wider_but_shares_the_contract() -> None: + # MCP carries the transport detail; CLI/scan-job/scan_file do not. The shared keys are a + # subset of the MCP block — pinning that the divergence is additive, never a key rename. + mcp = server._filigree_emit_status(_mcp_block(_AUTH)) + assert set(mcp) >= _SHARED_FILIGREE_KEYS + assert set(mcp) >= {"status", "auth_rejected", "token_sent", "url", "destination"} + + +# --------------------------------------------------------------------------- +# 4. The REAL configured MCP producer -> builder -> schema chain (W2-HIGH) +# +# The block-builder parity above feeds the builder a TEST-LOCAL ``_mcp_block`` hand-copy +# and asserts ``server._filigree_emit_status`` (a one-line delegate) == the builder — a +# tautology that cannot catch a field the REAL producer (``_emit_filigree`` / the inline +# loomweave block) adds, nor a `{**block}` passthrough propagating an unknown key past the +# canonical schema's ``additionalProperties: False``. This section drives a CONFIGURED scan +# end-to-end — real ``_emit_filigree`` and real inline loomweave producer run, their output +# lands in ``structuredContent``, and we ``jsonschema.validate`` it against the scan tool's +# OWN advertised outputSchema. A stray producer key now REDS here (it violates the schema). +# --------------------------------------------------------------------------- + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + + +def _leaky_project(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +class _FakeEmitter: + """Duck-typed FiligreeEmitter: ``_emit_filigree`` only calls ``.emit(...)``.""" + + def __init__(self, result: EmitResult) -> None: + self._result = result + + def emit(self, findings: Any, **kwargs: Any) -> EmitResult: + return self._result + + +def _scan_structured(server_obj: WardlineMCPServer, arguments: dict[str, Any]) -> dict[str, Any]: + """tools/call scan -> structuredContent validated against the scan tool's OWN advertised + outputSchema (the dual-emission text block is byte-identical). Mirrors the ``_validated`` + helper in test_mcp_structured_output.py without touching that file.""" + resp = server_obj.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "scan", "arguments": arguments}, + } + ) + assert "error" not in resp, resp + result: dict[str, Any] = resp["result"] + assert result.get("isError") is not True, result + listed = server_obj.rpc.dispatch({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + schema = {t["name"]: t for t in listed["result"]["tools"]}["scan"]["outputSchema"] + structured: dict[str, Any] = result["structuredContent"] + jsonschema.validate(structured, schema) + assert json.loads(result["content"][0]["text"]) == structured, "scan: dual emission diverged" + return structured + + +# Emit results spanning the soft-failure ladder, each exercising the configured filigree_emit +# block: OK (created/updated), AUTH (401, token rejected), PARTIAL (422 + a per-finding failure +# that drives the failures-array `$def` on the REAL configured path — the first test to do so). +_CONFIGURED_EMIT_RESULTS = ( + ("ok", _OK), + ("auth", _AUTH), + ("partial", _PARTIAL), +) + + +@pytest.mark.parametrize("label,emit_result", _CONFIGURED_EMIT_RESULTS, ids=[c[0] for c in _CONFIGURED_EMIT_RESULTS]) +def test_configured_filigree_emit_real_producer_validates_against_schema( + tmp_path: Path, label: str, emit_result: EmitResult +) -> None: + """The widest, most drift-prone surface: a CONFIGURED MCP scan whose REAL ``_emit_filigree`` + producer ran. The resulting ``filigree_emit`` carries the transport detail and MUST validate + against the canonical (transport-detailed) ``$def`` — a stray producer key reds here.""" + server_obj = WardlineMCPServer(root=_leaky_project(tmp_path)) + # Inject through the SAME seam the registered scan lambda resolves the emitter through, so the + # real _emit_filigree runs against our fake. _loomweave_client stays None (separate twin below). + server_obj._filigree_emitter = lambda *a, **k: _FakeEmitter(emit_result) # type: ignore[method-assign] + out = _scan_structured(server_obj, {}) + emit = out["filigree_emit"] + assert emit["configured"] is True + # The transport-detail keys are PRESENT (this is the wide MCP block, not the narrow one). + assert set(emit) >= {"status", "auth_rejected", "token_sent", "url", "destination"} + if label == "ok": + assert emit["reachable"] is True + assert emit["created"] == 2 and emit["updated"] == 1 + assert emit["disabled_reason"] is None + elif label == "auth": + assert emit["auth_rejected"] is True + assert emit["status"] == 401 + else: # partial + assert emit["failed"] == 1 + # The failures array on the REAL configured path validated against the `$def` above. + assert emit["failures"][0]["reason"] == "rejected" + assert emit["failures"][0]["fingerprint"] == "abc" + + +def test_configured_loomweave_write_real_producer_validates_against_schema( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The loomweave twin: a CONFIGURED scan whose REAL inline loomweave producer ran. We patch the + write call (not the builder) to a populated WriteResult so ``written`` is non-zero, then validate + the whole scan envelope — the ``loomweave_write`` block must satisfy the canonical schema.""" + populated = WriteResult(reachable=True, written=3, unresolved_qualnames=("pkg.mod.fn",), disabled_reason=None) + monkeypatch.setattr("wardline.loomweave.write.write_facts_to_loomweave", lambda result, root, client: populated) + server_obj = WardlineMCPServer(root=_leaky_project(tmp_path)) + # Non-None client trips the `if loomweave is not None` producer guard; the patched write + # supplies the populated result the inline block maps into loomweave_write. + server_obj._loomweave_client = lambda *a, **k: object() # type: ignore[method-assign] + out = _scan_structured(server_obj, {}) + lw = out["loomweave_write"] + assert lw["configured"] is True + assert lw["reachable"] is True + assert lw["written"] == 3 + assert lw["unresolved_qualnames"] == ["pkg.mod.fn"] + assert lw["disabled_reason"] is None + + +# --------------------------------------------------------------------------- +# 5. The RAW filigree/loomweave $defs cannot drift from the canonical field schemas +# (W2-MEDIUM — second surviving hand-enumerated schema pair) +# +# The raw debug-echo `filigree`/`loomweave` $defs (server.py) hand-enumerate the SAME field +# semantics the canonical filigree_emit_status_schema()/loomweave_write_status_schema() own. +# They are a live second source of truth (both raw and normalized blocks ship in one scan +# response). The two legitimately differ in DESCRIPTION prose (the raw blocks carry their own +# debug-echo wording) and the raw block, existing only when CONFIGURED, types ``reachable`` as a +# plain boolean (no null) — so we pin STRUCTURE (type/enum/$ref), description-insensitive, on the +# overlapping fields. A future type/enum/ref edit to one now reds against the other. +# --------------------------------------------------------------------------- + + +def _strip_descriptions(node: Any) -> Any: + """A deep copy of a JSON-schema node with every ``description`` removed, so structural + comparison (type/enum/$ref/required) ignores legitimately-divergent prose.""" + if isinstance(node, dict): + return {k: _strip_descriptions(v) for k, v in node.items() if k != "description"} + if isinstance(node, list): + return [_strip_descriptions(v) for v in node] + return node + + +def _raw_object_schema(prop_name: str) -> dict[str, Any]: + """The non-null arm of a raw debug-echo ``oneOf`` property (the object schema). The raw + ``filigree``/``loomweave`` debug-echo blocks live under the scan output schema's + ``properties`` (a nullable ``oneOf``), distinct from the normalized ``$defs`` $refs.""" + one_of = server._SCAN_OUTPUT_SCHEMA["properties"][prop_name]["oneOf"] + obj = next(arm for arm in one_of if arm.get("type") == "object") + return copy.deepcopy(obj) + + +def test_raw_filigree_def_shares_structure_with_canonical() -> None: + raw = _raw_object_schema("filigree") + canonical = fs.filigree_emit_status_schema(include_transport_detail=True) + raw_props = raw["properties"] + canon_props = canonical["properties"] + # Every field the raw debug-echo block declares (it omits `configured`/`disabled_reason`, which + # the NORMALIZED block adds) must structurally equal its canonical counterpart. + shared = {"created", "updated", "failed", "failures", "warnings", "status", "auth_rejected", "token_sent", "url", + "destination"} + assert shared <= set(raw_props), f"raw filigree $def lost a field: {shared - set(raw_props)}" + for field in shared: + assert _strip_descriptions(raw_props[field]) == _strip_descriptions(canon_props[field]), ( + f"raw vs canonical filigree drift on {field!r}" + ) + # `reachable` legitimately differs in nullability (raw exists only when configured -> non-null), + # so we pin only that BOTH declare it, not that the types match. + assert "reachable" in raw_props and "reachable" in canon_props + + +def test_raw_loomweave_def_shares_structure_with_canonical() -> None: + raw = _raw_object_schema("loomweave") + canonical = fs.loomweave_write_status_schema() + raw_props = raw["properties"] + canon_props = canonical["properties"] + shared = {"written", "unresolved_qualnames", "disabled_reason"} + assert shared <= set(raw_props) + for field in shared: + assert _strip_descriptions(raw_props[field]) == _strip_descriptions(canon_props[field]), ( + f"raw vs canonical loomweave drift on {field!r}" + ) + # `reachable` again differs only in nullability (raw block is configured-only). + assert "reachable" in raw_props and "reachable" in canon_props + + +# --------------------------------------------------------------------------- +# 6. Frozen golden snapshots of the canonical builders (W2-LOW) +# +# Sections 1-2 pin "each surface tracks the builder" and "$defs track the schema source" — but a +# future edit that changed a surface helper AND the shared builder in LOCKSTEP (a reordered key, a +# renamed `disabled_reason`) would keep every relative assertion green while the WIRE bytes drift. +# These HAND-FROZEN literals (never re-derived from the builder) convert "surface tracks builder" +# into "builder bytes are frozen": the runtime envelope now has an absolute snapshot on this side, +# the way test_mcp_structured_output.py absolutely pins the MCP schema side. +# --------------------------------------------------------------------------- + +_GOLDEN_FILIGREE_NONE: dict[str, Any] = { + "configured": False, + "reachable": None, + "created": 0, + "updated": 0, + "failed": 0, + "failures": [], + "warnings": [], + "disabled_reason": "not configured", + "destination": {"url": None, "project": None, "project_pinned": False}, +} + +_GOLDEN_FILIGREE_OK: dict[str, Any] = { + "configured": True, + "reachable": True, + "created": 2, + "updated": 1, + "failed": 0, + "failures": [], + "warnings": [], + "disabled_reason": None, + "destination": {"url": None, "project": None, "project_pinned": False}, +} + +_GOLDEN_FILIGREE_FROM_BLOCK_AUTH: dict[str, Any] = { + "configured": True, + "disabled_reason": "filigree rejected the token (401) at http://x/api; a token WAS sent but " + "its value is wrong — align WEFT_FEDERATION_TOKEN (env or .env) to the canonical federation token", + "reachable": False, + "created": 0, + "updated": 0, + "failed": 0, + "failures": [], + "warnings": [], + "status": 401, + "auth_rejected": True, + "token_sent": True, + "url": "http://x/api?project=p", + "destination": {"url": "http://x/api", "project": "p", "project_pinned": True}, +} + +# The richest, most drift-prone shape: the PARTIAL block's failures array carries the full +# weft-reason carrier triple (reason_class/cause/fix). Bytes pulled from the builder once and +# frozen here verbatim (NOT re-derived at call time), so a lockstep edit to the builder reds. +_GOLDEN_FILIGREE_FROM_BLOCK_PARTIAL: dict[str, Any] = { + "configured": True, + "disabled_reason": "filigree server error (422) at http://x", + "reachable": False, + "created": 0, + "updated": 0, + "failed": 1, + "failures": [ + { + "reason": "rejected", + "detail": "nope", + "reason_class": "rejected", + "cause": "nope", + "fix": "inspect the per-finding reject cause in Filigree's report and re-emit once the " + "finding is acceptable", + "fingerprint": "abc", + } + ], + "warnings": [], + "status": 422, + "auth_rejected": False, + "token_sent": True, + "url": "http://x", + "destination": {"url": "http://x", "project": None, "project_pinned": False}, +} + +_GOLDEN_LOOMWEAVE_DEFAULT: dict[str, Any] = { + "configured": False, + "reachable": None, + "written": 0, + "unresolved_qualnames": [], + "disabled_reason": "not configured", +} + + +def test_filigree_builder_bytes_are_frozen() -> None: + assert _eq(fs.filigree_emit_status(None, configured=False, include_destination=True), _GOLDEN_FILIGREE_NONE) + assert _eq(fs.filigree_emit_status(_OK, configured=True, include_destination=True), _GOLDEN_FILIGREE_OK) + assert _eq(fs.filigree_emit_status_from_block(_mcp_block(_AUTH)), _GOLDEN_FILIGREE_FROM_BLOCK_AUTH) + assert _eq(fs.filigree_emit_status_from_block(_mcp_block(_PARTIAL)), _GOLDEN_FILIGREE_FROM_BLOCK_PARTIAL) + + +def test_loomweave_builder_bytes_are_frozen() -> None: + assert _eq(fs.default_loomweave_write_status(), _GOLDEN_LOOMWEAVE_DEFAULT) diff --git a/tests/conformance/test_filigree_federation_token_contract.py b/tests/conformance/test_filigree_federation_token_contract.py new file mode 100644 index 00000000..3bf413e7 --- /dev/null +++ b/tests/conformance/test_filigree_federation_token_contract.py @@ -0,0 +1,264 @@ +"""WEFT_FEDERATION_TOKEN bearer-token auth contract — filigree producer ↔ wardline consumer. + +Filigree is the AUTHORITY for the inbound federation bearer-token gate +(``/api/weft/*`` + the classic federation-write aliases + the dashboard ``/mcp`` +transport). Wardline is a CONSUMER: it resolves a federation token (env var or the +auto-minted ``.weft/filigree/federation_token`` file) and presents it as +``Authorization: Bearer ``, then reads back the auth-status ladder in +``FiligreeEmitter.verify_token`` (401/403 → rejected; 400/2xx → accepted). + +Unlike the SEI and qualname seams, filigree publishes NO machine-readable contract +fixture — the contract lives in filigree SOURCE CONSTANTS +(``federation_token.py``) + middleware LOGIC (``dashboard_auth.py``) + ADR-018. So +this seam follows the ``UPSTREAM_BLOB_SHA`` branch of the kit: wardline AUTHORS the +vendored contract restatement (``fixtures/filigree_federation_token_contract.json``) +and pins it. + +Drift alarm (two layers): + 1. Layer-1 byte-pin (DEFAULT suite, this file): ``UPSTREAM_BLOB_SHA`` pins the + git-blob hash of the wardline-authored contract file. ANY byte change reds + the default PR suite — a re-vendor is deliberate and bumps the constant in + the same commit. + 2. Layer-2 substantive recheck (opt-in, ``-m filigree_token_drift``): re-reads + the SIBLING filigree source (``federation_token.py`` / ``dashboard_auth.py``) + and asserts the contract VALUES still match. It is SUBSTANTIVE, not + byte-exact (filigree ships no fixture to byte-compare) — the same sanctioned + shape as the Python qualname axis's substantive Layer-2. Skips clean when the + sibling checkout is absent (CI). + +A live ``filigree_e2e`` token round-trip (``test_live_token_round_trip``, bottom) +exercises the seam end-to-end against a real filigree: a wrong token → +``accepted=False`` (401), a good token → ``accepted=True`` — so the marker is bound +to a test that actually drives the token contract, not merely the promote flow. + +RE-VENDOR PROCEDURE (release-gate; run ``pytest -m filigree_token_drift -v`` before +every release, or on a deliberate filigree contract bump): + 1. Reconcile the contract VALUES in + ``fixtures/filigree_federation_token_contract.json`` against the sibling + filigree source (env-var names + read order in ``federation_token.py``; the + Bearer/status semantics in ``dashboard_auth.py`` + ADR-018). NEVER let the + vendored copy drift silently — Layer-2 is the alarm. + 2. Recompute the blob hash (``git hash-object`` of the fixture, equivalently + ``hashlib.sha1(b"blob %d\\0" % len(data) + data)``) and update + ``UPSTREAM_BLOB_SHA`` in the SAME commit; refresh the ``_provenance`` block. + 3. Re-run conformance and CONFORM the consumer + (``wardline.core.filigree_emit`` / ``wardline.install.doctor``) until green; + never weaken the assertions. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any + +import pytest + +from wardline.core.filigree_emit import FiligreeEmitter, Response + +CONTRACT_PATH = Path(__file__).parent / "fixtures" / "filigree_federation_token_contract.json" + +# The git blob hash of the wardline-AUTHORED vendored contract restatement. Named +# ``UPSTREAM_BLOB_SHA`` to match the kit's canonical Layer-1 pin constant (the same +# name the SEI consumer seam uses when it vendors an upstream authority's contract): +# "upstream" here is filigree's contract (the authority), restated into bytes wardline +# owns because filigree publishes no fixture to vendor verbatim. The Layer-1 byte-pin +# below runs in the DEFAULT PR suite, so ANY byte change without a matching re-pin reds +# the suite — the fail-closed protection that lets the Layer-2 substantive recheck skip +# clean when the sibling filigree checkout is absent. Re-vendors bump this in the SAME +# commit as the bytes. +UPSTREAM_BLOB_SHA = "a7825e9f17ab3db9b5bb94c56e8cbf03c783d96f" + + +def _contract() -> dict[str, Any]: + return json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) + + +def _filigree_repo() -> Path | None: + """The sibling filigree checkout root. Env takes EXCLUSIVE precedence + (first-configured, not first-existing): when ``WARDLINE_FILIGREE_REPO`` is set, + resolve the sibling ONLY from it and skip clean if the federation-token source is + absent under that root — the local-dev ``../filigree`` convenience checkout is + consulted ONLY when the env var is unset. This shares ONE resolution contract with + the other ``_drift`` rechecks (see test_loomweave_qualname_parity.py:150): an + operator who points the release-gate env var at a specific checkout that lacks the + file gets a clean skip, never a silent compare against the local convenience + sibling. None when absent (CI runners lack the sibling — Layer-2 skips clean).""" + marker = ("src", "filigree", "federation_token.py") + if env := os.environ.get("WARDLINE_FILIGREE_REPO"): + root = Path(env) + return root if root.joinpath(*marker).is_file() else None + root = Path(__file__).resolve().parents[3] / "filigree" + return root if root.joinpath(*marker).is_file() else None + + +# --------------------------------------------------------------------------- # +# Layer-1 byte-pin + structural self-tests — DEFAULT suite (no marker). +# --------------------------------------------------------------------------- # + + +def test_vendored_contract_matches_blob_pin() -> None: + """Layer-1 (default suite): the wardline-authored contract byte-pins to its git + blob hash. ANY edit without a matching re-pin reds the default PR suite.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = CONTRACT_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored federation-token contract changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit and re-run " + "conformance (see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_contract_shape_is_well_formed() -> None: + contract = _contract() + assert contract["contract"] == "weft/filigree-federation-bearer-token" + for key in ("env", "token_file", "header", "status_ladder", "consumer_verdict", "_provenance"): + assert key in contract, f"vendored contract is missing the '{key}' section" + env = contract["env"] + assert env["canonical_env_var"] == "WEFT_FEDERATION_TOKEN" + assert env["read_order"][0] == env["canonical_env_var"] + assert env["read_order"] == [env["canonical_env_var"], *env["deprecated_aliases"]] + assert contract["header"]["scheme"] == "Bearer" + # The accepted/rejected status partitions must be disjoint and non-empty. + accepted = set(contract["consumer_verdict"]["accepted_statuses"]) + rejected = set(contract["consumer_verdict"]["rejected_statuses"]) + assert accepted and rejected and not (accepted & rejected) + + +# --------------------------------------------------------------------------- # +# Consumer-binding — wardline's FiligreeEmitter / doctor MUST conform to the +# vendored contract values. Default suite (no live filigree needed). +# --------------------------------------------------------------------------- # + + +class _FakeTransport: + """Records the last POST and returns a canned status.""" + + def __init__(self, status: int) -> None: + self._status = status + self.calls: list[tuple[str, bytes, dict[str, str]]] = [] + + def post(self, url: str, body: bytes, headers: dict[str, str]) -> Response: + self.calls.append((url, body, dict(headers))) + return Response(status=self._status, body="") + + +def test_consumer_sends_contract_bearer_header() -> None: + """The consumer presents exactly the vendored header form ``Bearer ``.""" + contract = _contract() + t = _FakeTransport(status=400) + FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="tok").verify_token() + _, _, headers = t.calls[0] + assert headers[contract["header"]["name"]] == f"{contract['header']['scheme']} tok" + + +def test_consumer_verdict_matches_contract_status_ladder() -> None: + """``verify_token`` partitions statuses EXACTLY as the vendored contract dictates: + rejected_statuses → accepted=False; accepted_statuses → accepted=True.""" + contract = _contract() + url = "http://127.0.0.1:8749/api/weft/scan-results" + for status in contract["consumer_verdict"]["rejected_statuses"]: + probe = FiligreeEmitter(url, transport=_FakeTransport(status), token="t").verify_token() + assert probe.reachable is True and probe.accepted is False, f"status {status} must be REJECTED" + assert probe.status == status + for status in contract["consumer_verdict"]["accepted_statuses"]: + probe = FiligreeEmitter(url, transport=_FakeTransport(status), token="t").verify_token() + assert probe.accepted is True, f"status {status} must be ACCEPTED (auth passed before body validation)" + + +def test_consumer_env_var_name_matches_contract() -> None: + """Wardline's env-var reader (doctor .env pin path) uses the SAME canonical + ``WEFT_FEDERATION_TOKEN`` name the contract declares — bind both ends.""" + contract = _contract() + from wardline.install import doctor + + source = Path(doctor.__file__).read_text(encoding="utf-8") + assert contract["env"]["canonical_env_var"] in source, ( + "wardline.install.doctor does not reference the canonical env var the contract declares" + ) + # And the token-file relpath the consumer probes matches the contract. + assert contract["token_file"]["project_store_relpath"] == ".weft/filigree/federation_token" + assert all(part in source for part in (".weft", "filigree", "federation_token")) + + +# --------------------------------------------------------------------------- # +# Layer-2 substantive drift recheck (opt-in, -m filigree_token_drift). +# --------------------------------------------------------------------------- # + + +@pytest.mark.filigree_token_drift +def test_vendored_contract_matches_sibling_filigree_source() -> None: + """Layer-2 (opt-in, ``-m filigree_token_drift``): the SUBSTANTIVE contract values + must still match the sibling filigree source. Filigree ships no fixture to + byte-compare, so this parses the live source CONSTANTS and asserts agreement + (the same sanctioned shape as the Python qualname axis's substantive Layer-2). + Absent sibling checkout (CI) skips clean; drift FAILS.""" + repo = _filigree_repo() + if repo is None: + pytest.skip("no sibling filigree checkout (set WARDLINE_FILIGREE_REPO to enable the drift recheck)") + contract = _contract() + token_src = (repo / "src" / "filigree" / "federation_token.py").read_text(encoding="utf-8") + auth_src = (repo / "src" / "filigree" / "dashboard_auth.py").read_text(encoding="utf-8") + + env = contract["env"] + # Canonical env var + deprecated aliases + read order are filigree CONSTANTS. + assert f'WEFT_FEDERATION_ENV_VAR = "{env["canonical_env_var"]}"' in token_src, ( + "filigree's canonical federation env var drifted from the vendored contract" + ) + for alias in env["deprecated_aliases"]: + assert alias in token_src, f"filigree dropped/renamed deprecated alias {alias!r} the contract still lists" + assert f'FEDERATION_TOKEN_FILENAME = "{contract["token_file"]["filename"]}"' in token_src, ( + "filigree's persisted token filename drifted from the vendored contract" + ) + # The Bearer scheme + case-insensitive match live in dashboard_auth.py. + assert '!= "bearer"' in auth_src, "filigree's case-insensitive Bearer scheme check drifted" + assert "401" in auth_src and "WWW-Authenticate" in auth_src, ( + "filigree's 401 + WWW-Authenticate: Bearer envelope drifted from the contract" + ) + + +# --------------------------------------------------------------------------- # +# Live token round-trip (opt-in, -m filigree_e2e) — binds the marker to a test +# that actually drives the token contract end-to-end against a real filigree. +# --------------------------------------------------------------------------- # + +_URL = os.environ.get("WARDLINE_FILIGREE_URL") +_GOOD_TOKEN = os.environ.get("WARDLINE_FILIGREE_TOKEN") + + +@pytest.mark.filigree_e2e +@pytest.mark.skipif( + not (_URL and _GOOD_TOKEN), + reason="set WARDLINE_FILIGREE_URL + WARDLINE_FILIGREE_TOKEN (a real federation token) to run the live round-trip", +) +def test_live_token_round_trip() -> None: + """Live filigree, real bearer gate: a WRONG token is rejected (401 → accepted=False); + the GOOD token is accepted (400/2xx → accepted=True). Exercises the exact ladder + the vendored contract pins, end-to-end, via the consumer's verify_token().""" + assert _URL is not None and _GOOD_TOKEN is not None # narrowed by skipif + + bad = FiligreeEmitter(_URL, token="definitely-not-the-token").verify_token() + assert bad.reachable is True, "live filigree must be reachable to run this oracle" + if bad.accepted: + # The contract's tier-3 graceful-degrade: with NO federation token configured + # the daemon does not install the auth middleware, so even a wrong token reaches + # body validation (400 authed-bad-body). Such a daemon cannot exercise the REJECT + # path — skip rather than fail (the oracle needs an auth-ENFORCING filigree). + pytest.skip( + f"live filigree at {_URL} is not enforcing federation auth (a wrong token was accepted, " + f"status={bad.status}) — set WEFT_FEDERATION_TOKEN on the daemon to run the reject-path oracle" + ) + assert bad.accepted is False and bad.status in (401, 403), ( + f"a wrong token must be rejected per the contract ladder, got status={bad.status}" + ) + + good = FiligreeEmitter(_URL, token=_GOOD_TOKEN).verify_token() + assert good.reachable is True + assert good.accepted is True, ( + f"the good token must pass the bearer gate (400 authed-bad-body or 2xx), got status={good.status}" + ) diff --git a/tests/conformance/test_filigree_finding_identity_wire_golden.py b/tests/conformance/test_filigree_finding_identity_wire_golden.py new file mode 100644 index 00000000..75444f26 --- /dev/null +++ b/tests/conformance/test_filigree_finding_identity_wire_golden.py @@ -0,0 +1,208 @@ +"""Wardline-authored finding IDENTITY (fingerprint/qualname/spans) frozen to a +vendored byte golden. + +``wardline-finding-identity-wire.golden.json`` is the representative set of +finding-identity vectors wardline produces: for fixed deterministic inputs +(rule_id / path / qualname / taint_path / location), the +``{fingerprint, qualname, spans}`` the producer emits. It is the cross-tool JOIN +KEY contract — Filigree keys issues on ``(scan_source, fingerprint)`` and the +baseline / waiver stores key on the fingerprint, so a silent change to how that +identity is DERIVED (the hash formula, the scheme stamp, the qualname +normalization, the span projection) would silently re-key every downstream +verdict. This corpus reds on any such change. + +WHY THIS SEAM IS DISTINCT from the scan-results wire golden +(``test_filigree_scan_results_wire_golden.py``): that golden uses CANNED +fingerprints (``"a"*64`` …) and drops columns, so it never exercises the +fingerprint *derivation* at all. THIS corpus pins the derivation itself — +``compute_finding_fingerprint`` run on fixed inputs, the ``wlfp2`` scheme stamp +via ``format_fingerprint``, the ``_to_wire_qualname`` property-accessor +normalization, and the full ``Location``/``to_jsonl`` span projection incl. +columns. The ``collision_pair_*`` vectors pin the soundness property the join +key rests on: two findings sharing ``(rule_id, path, qualname)`` that differ ONLY +in the source-derived ``taint_path`` discriminator MUST produce DISTINCT +fingerprints (else one is silently dropped on the Filigree join). + +WARDLINE IS THE AUTHORITY for this seam — it OWNS finding identity via +``wardline.core.finding.{compute_finding_fingerprint, format_fingerprint, +FINGERPRINT_SCHEME, _to_wire_qualname}`` and ``Finding.to_jsonl``. That makes the +two-sided protection a two-layer affair (mirroring the scan-results / +suppression-filter contracts): + +* Layer-1 (``test_golden_matches_blob_pin``): a git-blob byte-pin on the vendored + golden, so any silent edit to the shared identity corpus reds the default PR + suite. On its OWN this is CIRCULAR — wardline pins wardline's own bytes. +* Producer-source recheck (``test_golden_matches_live_identity_producer``): the + non-circular break. It imports wardline's LIVE runtime identity producers and + asserts, for each vector, that re-deriving the identity from the SAME fixed + inputs reproduces the frozen golden values. The frozen bytes are tied to the + live producers, so if the hash formula / scheme / qualname-normalization / span + projection drifts from the golden without a re-vendor, it reds even though the + byte-pin still passes. + +RE-VENDOR PROCEDURE: if you deliberately change finding identity (e.g. bump the +fingerprint scheme wlfp2 -> wlfp3, or add a span field), regenerate the golden +from the producers with the SAME inputs below, recompute the blob SHA and update +``UPSTREAM_BLOB_SHA`` in the SAME commit — the producer-source recheck will +otherwise red. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +# Module-qualified import (``from wardline.core import finding``) — NOT individual +# names. This keeps the import on ONE short physical line so the seam-registry gate's +# ``_imported_wardline_symbols`` regex (``^...$`` anchored per physical line) captures +# the ``finding`` module symbol; the producer-source recheck then names ``finding`` on +# each ``assert finding.(...) == ...`` line, satisfying the gate's +# ``_has_producer_source_recheck`` while staying ruff-isort clean (a names-list import +# of all 8 symbols exceeds the 120-col limit and isort would split it across lines, +# hiding the symbols from the gate's per-line regex). +from wardline.core import finding + +GOLDEN_PATH = Path(__file__).parent / "fixtures" / "wardline-finding-identity-wire.golden.json" + +# Layer-1 byte-pin: the git-blob SHA-1 of wardline-finding-identity-wire.golden.json. +# Recomputed below as hashlib.sha1(b"blob %d\0" % len(data) + data). Any edit to the +# vendored golden without a matching re-pin reds the default PR suite. +UPSTREAM_BLOB_SHA = "4eec05f0c53b301cb433331092731c567a7754db" + + +def _golden() -> dict: + return json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) + + +def test_golden_matches_blob_pin() -> None: + """Layer-1 (default suite): the wardline-authored identity golden byte-pins to its + git blob hash. ANY edit without a matching re-pin reds the default PR suite. On its + own this pin is wardline-pins-wardline (circular); the non-circular protection is + ``test_golden_matches_live_identity_producer`` below, which regenerates each vector's + identity from the LIVE producers.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored finding-identity wire golden changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, regenerate the golden from the identity producers with the " + "SAME inputs recorded in each vector, update UPSTREAM_BLOB_SHA in the same commit, and re-run " + "conformance (see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_golden_scheme_matches_live_scheme() -> None: + """The corpus records the scheme it was captured under; tie it to the LIVE + ``FINGERPRINT_SCHEME`` so a scheme bump (wlfp2 -> wlfp3) is a visible, accountable + corpus delta rather than a silent re-key.""" + golden = _golden() + assert golden["fingerprint_scheme"] == finding.FINGERPRINT_SCHEME + + +def test_fingerprint_derivation_ties_to_live_producer() -> None: + """Anchor the producer-source recheck to the HEADLINE producer + (``compute_finding_fingerprint``) — the hash derivation that distinguishes this seam + from the canned-fingerprint scan-results golden. The live producer is called INLINE + on the assert line (named ``finding`` symbol + ``==`` on one physical line) so the + seam-registry gate's ``_has_producer_source_recheck`` is satisfied by the fingerprint + derivation itself, not only by the ancillary scheme/qualname rechecks. Kept on one + line (short locals; the keyword-only args otherwise blow the 120-col limit and ruff + would wrap it, hiding the symbol from the gate's per-line regex).""" + i = _golden()["vectors"]["singleton_no_taint_path"]["inputs"] + rid, pth, qn, tp = i["rule_id"], i["path"], i["qualname"], i["taint_path"] + expected = _golden()["vectors"]["singleton_no_taint_path"]["bare_fingerprint"] + assert finding.compute_finding_fingerprint(rule_id=rid, path=pth, qualname=qn, taint_path=tp) == expected + + +def test_golden_matches_live_identity_producer() -> None: + """PRODUCER-SOURCE recheck (non-circular): for each frozen vector, re-derive the + finding identity from wardline's LIVE runtime producers with the SAME fixed inputs + and assert each producer's output EQUALS the frozen golden value. This ties the + byte-pinned golden to the real producers, so a derivation drift (the hash formula, + the ``wlfp2`` scheme stamp, the ``_to_wire_qualname`` normalization, or the + ``Location``/``to_jsonl`` span projection) without a re-vendor reds even though the + byte-pin still passes. + + Each ``assert`` calls a producer INLINE and names the imported wardline symbol on the + assertion line (not a pre-computed local), so the equality is tied to the live runtime + rather than to the golden restating itself.""" + golden = _golden() + vectors = golden["vectors"] + assert vectors, "identity golden carries no vectors — a vacuous corpus must not pass" + + for name, vec in vectors.items(): + inp = vec["inputs"] + loc = inp["location"] + + # (1) fingerprint DERIVATION: the bare 64-hex digest from the live hash formula. + # The live producer is called INLINE (not a pre-computed local) so the equality is + # tied to the runtime, not to the golden restating itself. + live_bare = finding.compute_finding_fingerprint( + rule_id=inp["rule_id"], path=inp["path"], qualname=inp["qualname"], taint_path=inp["taint_path"] + ) + assert live_bare == vec["bare_fingerprint"], f"{name}: bare fingerprint drift" + + # (2) the wlfp2 SCHEME STAMP applied to that digest for the wire/store. + live_stamped = finding.format_fingerprint(finding.FINGERPRINT_SCHEME, vec["bare_fingerprint"]) + assert live_stamped == vec["stamped_fingerprint"], f"{name}: stamped fingerprint drift" + + # (3) the cross-tool reconciliation QUALNAME (property-accessor normalization). + # Call the live producer INLINE on the assert line (``finding._to_wire_qualname``) + # so the equality is tied to the runtime symbol — this is the producer-source + # recheck the seam-registry gate detects (``finding`` + ``==`` on one physical line). + qn = inp["qualname"] + if qn is None: + assert vec["wire_qualname"] is None, f"{name}: wire qualname drift (expected null)" + else: + assert finding._to_wire_qualname(qn) == vec["wire_qualname"], f"{name}: wire qualname drift" + + # (4) the wire SPAN / identity projection emitted by Finding.to_jsonl. Build the + # finding from the recorded inputs and assert its jsonl identity record reproduces + # the frozen fingerprint, qualname, and full span (incl. columns). + live = finding.Finding( + rule_id=inp["rule_id"], + message="identity vector", + severity=finding.Severity.ERROR, + kind=finding.Kind.DEFECT, + location=finding.Location( + path=loc["path"], + line_start=loc["line_start"], + line_end=loc["line_end"], + col_start=loc["col_start"], + col_end=loc["col_end"], + ), + fingerprint=vec["bare_fingerprint"], + qualname=inp["qualname"], + ) + rec = json.loads(live.to_jsonl()) + assert rec["fingerprint"] == vec["wire_fingerprint"], f"{name}: to_jsonl fingerprint drift" + assert rec["qualname"] == vec["wire_jsonl_qualname"], f"{name}: to_jsonl qualname drift" + assert rec["location"] == vec["spans"], f"{name}: to_jsonl span projection drift" + + +def test_collision_pair_fingerprints_are_distinct() -> None: + """The soundness property the join key rests on: two findings sharing + ``(rule_id, path, qualname)`` that differ ONLY in the source-derived ``taint_path`` + discriminator MUST produce DISTINCT fingerprints. If they collided, one would be + silently dropped on the Filigree ``(scan_source, fingerprint)`` join. Re-derive both + from the live producer and assert non-collision (so a hash-formula change that + dropped ``taint_path`` from the digest reds here, not just on the byte-pin).""" + golden = _golden() + a = golden["vectors"]["collision_pair_a"]["inputs"] + b = golden["vectors"]["collision_pair_b"]["inputs"] + + assert (a["rule_id"], a["path"], a["qualname"]) == (b["rule_id"], b["path"], b["qualname"]), ( + "collision-pair vectors must share (rule_id, path, qualname) so the test is non-vacuous" + ) + assert a["taint_path"] != b["taint_path"], "collision-pair vectors must differ in taint_path" + + fp_a = finding.compute_finding_fingerprint( + rule_id=a["rule_id"], path=a["path"], qualname=a["qualname"], taint_path=a["taint_path"] + ) + fp_b = finding.compute_finding_fingerprint( + rule_id=b["rule_id"], path=b["path"], qualname=b["qualname"], taint_path=b["taint_path"] + ) + assert fp_a != fp_b, "distinct findings collapsed to one fingerprint — the join key is unsound" diff --git a/tests/conformance/test_filigree_scan_results_wire_golden.py b/tests/conformance/test_filigree_scan_results_wire_golden.py new file mode 100644 index 00000000..4ec492c3 --- /dev/null +++ b/tests/conformance/test_filigree_scan_results_wire_golden.py @@ -0,0 +1,145 @@ +"""Wardline-authored scan-results wire frozen to a vendored byte golden. + +``wardline-scan-results-wire.golden.json`` is the representative +``POST /api/weft/scan-results`` body wardline produces, frozen so the consumer +(Filigree) can vendor the SAME bytes and drive its real intake against them. It +covers every finding ``Kind`` (defect / fact / classification / metric / +suggestion), every severity mapping, both languages (python / rust), every +suppression state surfaced in metadata, and the scanned-paths reconciliation +list — so a silent change to ANY of those wire shapes reds. + +WARDLINE IS THE AUTHORITY for this seam — it OWNS the scan-results body via +``wardline.core.filigree_emit.build_scan_results_body``. That makes the two-sided +protection a two-layer affair (mirroring the suppression-filter contract): + +* Layer-1 (``test_golden_matches_blob_pin``): a git-blob byte-pin on the vendored + golden, so any silent edit to the shared wire reds the default PR suite. On its + OWN this is CIRCULAR — wardline pins wardline's own bytes. +* Producer-source recheck (``test_golden_matches_live_producer``): the non-circular + break. It imports wardline's LIVE runtime ``build_scan_results_body`` and asserts + the body it regenerates from the SAME fixed inputs EQUALS the frozen golden. The + frozen bytes are tied to the live producer, so if the producer's wire shape drifts + from the golden (a key renamed/added/dropped, a mapping changed) without a + re-vendor, it reds even though the byte-pin still passes. + +RE-VENDOR PROCEDURE: if you deliberately change the wire (e.g. add a finding wire +field), regenerate the golden from the producer with the SAME inputs below, +recompute the blob SHA and update ``UPSTREAM_BLOB_SHA`` in the SAME commit — the +producer-source recheck will otherwise red. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from wardline.core.filigree_emit import build_scan_results_body +from wardline.core.finding import Finding, Kind, Location, Maturity, Severity, SuppressionState + +GOLDEN_PATH = Path(__file__).parent / "fixtures" / "wardline-scan-results-wire.golden.json" + +# Layer-1 byte-pin: the git-blob SHA-1 of wardline-scan-results-wire.golden.json. +# Recomputed below as hashlib.sha1(b"blob %d\0" % len(data) + data). Any edit to the +# vendored golden without a matching re-pin reds the default PR suite. +UPSTREAM_BLOB_SHA = "164404bea8a8c29eec9814156441c38a098b9fc8" + +# The fixed, deterministic inputs the golden is generated from. Held here so the +# producer-source recheck regenerates the EXACT same body. Covers every Kind, +# severity, language, and suppression state. +FINDINGS = ( + Finding( + rule_id="PY-WL-101", + message="untrusted value reaches trusted sink", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="src/app/handler.py", line_start=12, line_end=14, col_start=4, col_end=20), + fingerprint="a" * 64, + suggestion="validate at the boundary before the sink", + qualname="app.handler.handle", + confidence=0.9, + related_entities=("python:function:app.handler.read_raw",), + properties={"sink": "os.system", "tier": "untrusted"}, + ), + Finding( + rule_id="RS-WL-108", + message="tainted data flows to command execution", + severity=Severity.CRITICAL, + kind=Kind.DEFECT, + location=Location(path="src/main.rs", line_start=42, line_end=42), + fingerprint="b" * 64, + qualname="main::run", + ), + Finding( + rule_id="WLN-BOUNDARY-FACT", + message="external boundary detected", + severity=Severity.NONE, + kind=Kind.FACT, + location=Location(path="src/app/io.py", line_start=3, line_end=3), + fingerprint="c" * 64, + qualname="app.io.fetch", + ), + Finding( + rule_id="PY-WL-120", + message="boundary classification", + severity=Severity.INFO, + kind=Kind.CLASSIFICATION, + location=Location(path="src/app/io.py", line_start=7, line_end=7), + fingerprint="d" * 64, + qualname="app.io.fetch", + suppressed=SuppressionState.WAIVED, + suppression_reason="reviewed false positive", + ), + Finding( + rule_id="WLN-METRIC-COVERAGE", + message="decorator coverage 80%", + severity=Severity.NONE, + kind=Kind.METRIC, + location=Location(path="src/app/io.py", line_start=1), + fingerprint="e" * 64, + properties={"coverage": 0.8}, + ), + Finding( + rule_id="PY-WL-126", + message="consider narrowing the boundary", + severity=Severity.WARN, + kind=Kind.SUGGESTION, + location=Location(path="src/app/util.py", line_start=30, line_end=33), + fingerprint="f" * 64, + suggestion="annotate @external_boundary", + qualname="app.util.helper", + suppressed=SuppressionState.BASELINED, + maturity=Maturity.PREVIEW, + ), +) + +SCANNED_PATHS = ("src/app/handler.py", "src/main.rs", "src/app/io.py", "src/app/util.py") + + +def test_golden_matches_blob_pin() -> None: + """Layer-1 (default suite): the wardline-authored golden byte-pins to its git + blob hash. ANY edit without a matching re-pin reds the default PR suite. On its + own this pin is wardline-pins-wardline (circular); the non-circular protection is + ``test_golden_matches_live_producer`` below, which regenerates the body from the + LIVE producer.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored scan-results wire golden changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, regenerate the golden from build_scan_results_body with the " + "FINDINGS/SCANNED_PATHS in this module, update UPSTREAM_BLOB_SHA in the same commit, and re-run " + "conformance (see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_golden_matches_live_producer() -> None: + """PRODUCER-SOURCE recheck (non-circular): regenerate the body from wardline's + LIVE runtime ``build_scan_results_body`` with the SAME fixed inputs and assert it + EQUALS the frozen golden. This ties the byte-pinned golden to the real producer, + so a wire-shape drift (a key renamed/added/dropped, a mapping changed) without a + re-vendor reds even though the byte-pin still passes.""" + golden = json.loads(GOLDEN_PATH.read_text("utf-8")) + assert build_scan_results_body(FINDINGS, scan_source="wardline", scanned_paths=SCANNED_PATHS) == golden diff --git a/tests/conformance/test_filigree_suppression_filter_contract.py b/tests/conformance/test_filigree_suppression_filter_contract.py index 7451ed74..2cc57bde 100644 --- a/tests/conformance/test_filigree_suppression_filter_contract.py +++ b/tests/conformance/test_filigree_suppression_filter_contract.py @@ -5,10 +5,29 @@ contract file is the producer-side anchor: if Wardline adds a suppression state, the shared vector must change in the same commit and Filigree's consumer test will fail until its filter grammar follows. + +WARDLINE IS THE AUTHORITY for this seam — it OWNS the ``suppression_state`` +vocabulary via ``wardline.core.finding.SuppressionState``. That makes the +two-sided protection a two-layer affair: + +* Layer-1 (``test_vendored_contract_matches_blob_pin``): a git-blob byte-pin on + the vendored contract, so any silent edit to the shared vector reds the default + PR suite. On its OWN this is CIRCULAR — wardline pins wardline's own bytes. +* Producer-source recheck (``test_vector_matches_suppression_state_enum``): the + non-circular break. It imports wardline's LIVE runtime ``SuppressionState`` enum + and asserts its member values EQUAL the frozen contract's ``suppression_states``. + The frozen bytes are tied to the live producer enum, so if the enum drifts from + the contract (a member added/removed/renamed without re-vendoring), it reds. + +RE-VENDOR PROCEDURE: if you deliberately change the contract bytes (e.g. add a +suppression state), recompute the blob SHA and update ``UPSTREAM_BLOB_SHA`` in the +SAME commit, and keep the enum in lockstep — the producer-source recheck will +otherwise red. """ from __future__ import annotations +import hashlib import json from pathlib import Path @@ -16,12 +35,41 @@ VECTOR_PATH = Path(__file__).parent / "filigree_suppression_filter_contract.json" +# Layer-1 byte-pin: the git-blob SHA-1 of filigree_suppression_filter_contract.json. +# Recomputed below as hashlib.sha1(b"blob %d\0" % len(data) + data). Any edit to the +# vendored contract without a matching re-pin reds the default PR suite. +UPSTREAM_BLOB_SHA = "7bcb6993553e920438fe3854a8a62409362accb9" + def _vector() -> dict: return json.loads(VECTOR_PATH.read_text(encoding="utf-8")) +def test_vendored_contract_matches_blob_pin() -> None: + """Layer-1 (default suite): the wardline-authored contract byte-pins to its git + blob hash. ANY edit without a matching re-pin reds the default PR suite. On its + own this pin is wardline-pins-wardline (circular); the non-circular protection is + ``test_vector_matches_suppression_state_enum`` below, which rechecks the frozen + bytes against the LIVE producer enum.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = VECTOR_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored suppression-filter contract changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit, keep " + "SuppressionState in lockstep, and re-run conformance (see the RE-VENDOR PROCEDURE at the " + "top of this module); if not, revert the edit." + ) + + def test_vector_matches_suppression_state_enum() -> None: + """PRODUCER-SOURCE recheck (non-circular): import wardline's LIVE runtime + ``SuppressionState`` enum and assert its member values EQUAL the frozen + contract's ``suppression_states``. This ties the byte-pinned contract to the + real producer enum, so an enum drift (member added/removed/renamed) without a + re-vendor reds even though the byte-pin still passes.""" vector = _vector() assert vector["contract"] == "weft/wardline-filigree-suppression-filter" diff --git a/tests/conformance/test_legis_scan_wire_golden.py b/tests/conformance/test_legis_scan_wire_golden.py index 92693848..f52002f9 100644 --- a/tests/conformance/test_legis_scan_wire_golden.py +++ b/tests/conformance/test_legis_scan_wire_golden.py @@ -1,12 +1,17 @@ -"""Shared cross-member golden vector for the wardline → legis scan wire (G1). - -``legis_scan_wire.golden.json`` is the ONE concrete instance of the legis scan -artifact that BOTH wardline (producer) and legis (consumer) load and assert -against — replacing the two *independent vendored mirrors* -(``test_legis_artifact_contract_freeze.py`` here, legis's own vendored ``from_wire``) -whose hand-copied drift is the federation-interface-audit **G1 / seam-S8** finding: -wardline could rename ``findings`` and stay green while legis silently governs an -EMPTY scan under a ``verified`` status (the consumer reads ``scan.get("findings", [])``). +"""wardline's live-emit superset freeze for the wardline → legis scan wire (G1). + +``legis_scan_wire.golden.json`` is wardline's OWN signed golden: it pins wardline's +live ``build_legis_artifact`` emit (the full superset including ``scan_scope`` / +``fingerprint_scheme``) to a concrete instance and verifies it offline under +:data:`GOLDEN_KEY`. It is NOT the cross-member shared vector — the SINGLE byte-identical +vector both repos load is ``wardline_scan_artifact.v1.json`` (authored by legis, vendored ++ byte-pinned into wardline by ``test_wardline_scan_artifact_shared_vector.py``, and driven +through legis's real ingest in ``legis/tests/contract/weft/test_wardline_scan_artifact_contract.py``). +This file complements that shared-vector oracle: the shared vector pins the byte-exact wire + +HMAC across the two repos, while this golden freezes wardline's broader live-emit key-set so a +producer-side rename of ``findings`` cannot stay green (the federation-interface-audit +**G1 / seam-S8** finding: legis reads ``scan.get("findings", [])`` and would silently govern +an EMPTY scan under a ``verified`` status). The vector is a deterministic, self-consistent **signed** artifact: its volatile fields (``scanner_identity``, ``rule_set_version``, ``commit_sha``, ``tree_sha``) are diff --git a/tests/conformance/test_loomweave_qualname_parity.py b/tests/conformance/test_loomweave_qualname_parity.py index df2df2c3..134c2f32 100644 --- a/tests/conformance/test_loomweave_qualname_parity.py +++ b/tests/conformance/test_loomweave_qualname_parity.py @@ -5,11 +5,30 @@ producer byte-equality from assumption to a committed CI test. Wardline returns ``None`` where Loomweave returns ``""`` for a top-level ``__init__.py`` — the ``None <-> ""`` mapping below is the documented, semantically-equivalent boundary. + +Drift alarm (two layers — the Python axis of the qualname conformance seam): + 1. Byte-pin (default suite, ``test_vendored_fixture_matches_blob_pin``): + ``VENDORED_BLOB_SHA`` below pins the *vendored* fixture's git blob hash. ANY + byte change to the vendored copy fails loudly. NOTE the asymmetry with the + Rust corpus: the vendored Python fixture is NOT byte-identical to upstream — + it adds a repo-local ``_wardline_provenance`` wrapper key and carries a + SHORTER ``$comment`` than upstream (the upstream ``$comment`` accrues an + integration-TODO note that is documentation, not normalization content). So + this pin is the *vendored* blob (``82cf10e5…``), which deliberately differs + from the upstream blob; Layer 2 rechecks the SUBSTANTIVE content vs upstream. + 2. Live recheck (opt-in, ``-m loomweave_drift``, + ``test_vendored_fixture_matches_live_sibling_substantive``): compares the + SUBSTANTIVE normalization content (``rules_source`` + both vector arrays) + against the sibling loomweave checkout's upstream fixture, IGNORING the + non-substantive ``_wardline_provenance`` / ``$comment`` metadata that + legitimately diverges; skips when the checkout is absent (CI). """ from __future__ import annotations +import hashlib import json +import os from pathlib import Path from typing import Any @@ -17,7 +36,23 @@ from wardline.core.qualname import module_dotted_name -_FIXTURE = json.loads((Path(__file__).parent / "loomweave_qualname_parity.json").read_text("utf-8")) +_FIXTURE_PATH = Path(__file__).parent / "loomweave_qualname_parity.json" +_FIXTURE = json.loads(_FIXTURE_PATH.read_text("utf-8")) + +# The git blob hash of the VENDORED fixture (tests/conformance/loomweave_qualname_parity.json). +# Unlike the Rust corpus, this is NOT byte-identical to upstream: the vendored copy adds a +# ``_wardline_provenance`` wrapper key and a repo-local (shorter) ``$comment``, so this SHA +# (the vendored blob) deliberately differs from the upstream blob. Re-vendors update this +# constant in the SAME commit as the new bytes; Layer 2 below rechecks the substantive +# content against upstream and ignores the diverging metadata. +VENDORED_BLOB_SHA = "82cf10e586fc38d252734b48f30adbe58f38c440" + +# Top-level fixture keys that carry the SUBSTANTIVE normalization contract (the part both +# the vendored copy and upstream MUST agree on). The complement — ``_wardline_provenance`` +# (vendored-only wrapper) and ``$comment`` (documentation that legitimately diverges from +# upstream) — is excluded from the Layer-2 recheck by construction below. +_SUBSTANTIVE_KEYS = frozenset({"rules_source", "module_normalization_vectors", "qualified_name_vectors"}) +_NON_SUBSTANTIVE_KEYS = frozenset({"_wardline_provenance", "$comment"}) @pytest.mark.parametrize("vec", _FIXTURE["module_normalization_vectors"], ids=lambda v: v["file_path"]) @@ -55,3 +90,75 @@ def test_module_kind_vector_prefix_matches() -> None: assert module_vecs # guard: the fixture must contain at least one module vector for vec in module_vecs: assert module_dotted_name(vec["file_path"]) == vec["expected_qualified_name"] + + +# --------------------------------------------------------------------------- # +# Corpus drift alarm — the PYTHON axis of the qualname conformance seam. Layer 1 +# runs in the default suite (fail-closed byte-pin); Layer 2 is the opt-in live +# recheck against the sibling loomweave checkout. +# --------------------------------------------------------------------------- # + + +def test_vendored_fixture_matches_blob_pin() -> None: + """Layer 1 (default suite): the VENDORED fixture byte-pins to its git blob hash. + + This always runs (no marker) and fails closed: any one-byte change to the + vendored copy reds this test. The pin is the *vendored* blob — which deliberately + differs from upstream by the ``_wardline_provenance`` wrapper + repo-local + ``$comment`` (see the module header); upstream parity is Layer 2's job.""" + assert len(VENDORED_BLOB_SHA) == 40 and set(VENDORED_BLOB_SHA) <= set("0123456789abcdef"), ( + f"VENDORED_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {VENDORED_BLOB_SHA!r}" + ) + data = _FIXTURE_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == VENDORED_BLOB_SHA, ( + f"the vendored fixture changed (git blob {actual}, pinned {VENDORED_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update VENDORED_BLOB_SHA in the same commit and " + "re-run conformance; if not, someone edited the vendored copy (the upstream fixture is " + "the only author of the substantive content — re-vendor it verbatim and re-add the " + "_wardline_provenance wrapper, never hand-edit the vectors)" + ) + + +def _substantive_view(doc: dict[str, Any]) -> dict[str, Any]: + """Project a fixture document down to its SUBSTANTIVE normalization content — + the keys both the vendored copy and upstream must agree on byte-for-byte. The + blacklist form (drop the non-substantive metadata, keep EVERYTHING else) is + deliberate: it fails CLOSED if upstream grows a NEW substantive section, where a + whitelist of ``_SUBSTANTIVE_KEYS`` would silently ignore it. We also assert the + expected substantive keys are present so a renamed/dropped section reds rather + than comparing two empty views.""" + view = {k: v for k, v in doc.items() if k not in _NON_SUBSTANTIVE_KEYS} + missing = _SUBSTANTIVE_KEYS - view.keys() + assert not missing, f"fixture is missing substantive section(s): {sorted(missing)}" + return view + + +@pytest.mark.loomweave_drift +def test_vendored_fixture_matches_live_sibling_substantive() -> None: + """Layer 2 (opt-in, ``-m loomweave_drift``): the sibling loomweave checkout's + upstream fixture must match the vendored copy on SUBSTANTIVE content — the + release-gate drift alarm for the Python axis. Absent checkout (CI) skips; + substantive divergence FAILS. + + A raw byte-compare would always fail here: the vendored copy adds a + ``_wardline_provenance`` wrapper and carries a repo-local (shorter) ``$comment`` + than upstream — both are documentation metadata, NOT normalization content. + ``$comment`` is excluded deliberately (upstream accrues an integration-TODO note + that the vendored copy intentionally does not mirror). The compared view keeps + every OTHER key, so a new upstream substantive section would still red.""" + repo = Path(os.environ.get("WARDLINE_LOOMWEAVE_REPO", "/home/john/loomweave")) + upstream = repo / "docs" / "federation" / "fixtures" / "wardline-qualname-normalization.json" + if not upstream.is_file(): + pytest.skip(f"no loomweave sibling checkout at {repo} (override via WARDLINE_LOOMWEAVE_REPO)") + upstream_doc = json.loads(upstream.read_text("utf-8")) + vendored_doc = json.loads(_FIXTURE_PATH.read_text("utf-8")) + if _substantive_view(vendored_doc) != _substantive_view(upstream_doc): + pytest.fail( + f"upstream {upstream} has drifted from the vendored " + "tests/conformance/loomweave_qualname_parity.json on SUBSTANTIVE content " + "(rules_source / module_normalization_vectors / qualified_name_vectors, or a new " + "upstream section) — re-vendor the substantive content verbatim, bump " + "VENDORED_BLOB_SHA in the same commit, and conform core/qualname.py until green " + "(never edit the vectors to match a broken producer)" + ) diff --git a/tests/conformance/test_loomweave_taint_fact_wire_golden.py b/tests/conformance/test_loomweave_taint_fact_wire_golden.py new file mode 100644 index 00000000..174eb63c --- /dev/null +++ b/tests/conformance/test_loomweave_taint_fact_wire_golden.py @@ -0,0 +1,182 @@ +"""Wardline-authored loomweave taint-fact wire (the ``wardline-taint-1`` blob) +frozen to a vendored byte golden. + +``wardline-taint-fact-wire.golden.json`` is the representative set of taint-fact +write payloads wardline produces for the SP9/T3.4 seam: wardline WRITES per-entity +``wardline-taint-1`` taint-fact blobs into loomweave's taint store, and loomweave +stores the ``wardline_json`` blob VERBATIM (opaque to it) and keys/freshness-gates +on the top-level ``content_hash_at_compute`` column. The wire here is the list of +fact payloads ``build_taint_facts`` emits — one per function entity — each carrying +the opaque blob, the composed dotted ``qualname``, and the top-level +``content_hash_at_compute`` (blake3 of the analyzed file, whole-file raw bytes). + +The corpus is generated from ONE fixed, deterministic source module (``TAINT_SOURCE`` +below) and covers the wire shapes that matter: + +* a trust-decorated root entity (``@external_boundary``) with an empty ``findings`` + list and a ``dead_code_root.is_root: true`` signal; +* an undecorated non-root entity (``dead_code_root.is_root: false``) with a + ``fallback`` taint source; +* a ``@trusted`` sink entity whose ``actual_return`` is the laundered ``EXTERNAL_RAW`` + with a resolved ``contributing_callee_qualname`` AND a real PY-WL-101 finding (rule_id + / fingerprint / path / line_start) in its ``findings`` list. + +So a silent change to ANY of those wire shapes — the ``schema_version`` stamp, the +``dead_code_root`` projection, the ``taint`` sub-object keys, the per-finding fields, +or the blake3 ``content_hash_at_compute`` derivation — reds. + +WARDLINE IS THE AUTHORITY for this seam — it OWNS the taint-fact blob via +``wardline.loomweave.facts.build_taint_facts`` (loomweave stores the blob opaquely). +That makes the two-sided protection a two-layer affair (mirroring the scan-results / +finding-identity / suppression-filter contracts): + +* Layer-1 (``test_golden_matches_blob_pin``): a git-blob byte-pin on the vendored + golden, so any silent edit to the shared taint-fact corpus reds the default PR + suite. On its OWN this is CIRCULAR — wardline pins wardline's own bytes. +* Producer-source recheck (``test_golden_matches_live_producer``): the non-circular + break. It scans the SAME fixed source through wardline's LIVE runtime + ``build_taint_facts`` and asserts the regenerated fact list EQUALS the frozen golden. + The frozen bytes are tied to the live producer, so if the blob shape, the taint + projection, the dead-code-root signal, the finding fields, or the blake3 freshness + derivation drifts from the golden without a re-vendor, it reds even though the + byte-pin still passes. The assert calls ``build_taint_facts`` INLINE (the imported + symbol + ``==`` on one physical line) so the equality is tied to the live runtime, + not to the golden restating itself. + +SCOPE — what this golden pins and what it deliberately does NOT: + +* PINNED: the per-fact projection — the list ``build_taint_facts`` emits, each item's + ``{qualname, content_hash_at_compute, wardline_json}`` (and the opaque blob loomweave + stores verbatim). This is the load-bearing payload the consumer keys and freshness-gates on. +* NOT pinned (and why): the outer HTTP write envelope ``{"project": , "facts": [...]}`` + POSTed to ``POST /api/wardline/taint-facts`` (in 2000-item chunks) is applied in + ``wardline.loomweave.client.LoomweaveClient.write_taint_facts``, AFTER this projection. Its + ``project`` value is runtime/config-derived (not a deterministic byte to freeze), so the + envelope is out of scope for a byte-golden; the ``facts`` array it wraps IS this golden. +* NOT pinned (and why): the HMAC request signature wardline computes to WRITE these facts is + non-deterministic at runtime (the signed message embeds a fresh timestamp + nonce). Its + deterministic core (the five-field canonical message + the lowercase-hex HMAC-SHA256 over + fixed inputs) is pinned byte-exactly in ``tests/unit/loomweave/test_hmac.py`` against + loomweave's verifier (``auth.rs`` ``canonical_hmac_message``). + +RE-VENDOR PROCEDURE: if you deliberately change the taint-fact wire (e.g. add a blob +field or a taint sub-key), regenerate the golden from ``build_taint_facts`` with the +SAME ``TAINT_SOURCE`` below, recompute the blob SHA and update ``UPSTREAM_BLOB_SHA`` +in the SAME commit — the producer-source recheck will otherwise red. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from wardline.core.run import run_scan +from wardline.loomweave.facts import build_taint_facts + +GOLDEN_PATH = Path(__file__).parent / "fixtures" / "wardline-taint-fact-wire.golden.json" + +# The fixed, deterministic source the golden is generated from. Held here so the +# producer-source recheck scans the EXACT same bytes — the blake3 +# ``content_hash_at_compute`` is whole-file raw bytes, so this string must be +# byte-for-byte the source the golden was vendored from. +TAINT_SOURCE = ( + "from wardline.decorators import external_boundary, trusted\n" + "\n" + "\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" + "\n" + "\n" + "def helper(p):\n" + " return p\n" + "\n" + "\n" + "@trusted\n" + "def leaky(p):\n" + " return read_raw(p)\n" +) + +# Layer-1 byte-pin: the git-blob SHA-1 of wardline-taint-fact-wire.golden.json. +# Recomputed below as hashlib.sha1(b"blob %d\0" % len(data) + data). Any edit to the +# vendored golden without a matching re-pin reds the default PR suite. +UPSTREAM_BLOB_SHA = "297ea60e99db857097dd0f38938ded713fed7a9b" + + +def _scan_fixed_source(tmp_path: Path) -> tuple[Path, object]: + """Scan the fixed TAINT_SOURCE under a temp project root. Returns (root, ScanResult) + so the recheck can call build_taint_facts(result, root) INLINE on the assert line.""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(TAINT_SOURCE, encoding="utf-8") + return proj, run_scan(proj) + + +def test_golden_matches_blob_pin() -> None: + """Layer-1 (default suite): the wardline-authored taint-fact golden byte-pins to its + git blob hash. ANY edit without a matching re-pin reds the default PR suite. On its + own this pin is wardline-pins-wardline (circular); the non-circular protection is + ``test_golden_matches_live_producer`` below, which regenerates the fact list from the + LIVE producer.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored taint-fact wire golden changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, regenerate the golden from build_taint_facts with the " + "TAINT_SOURCE in this module, update UPSTREAM_BLOB_SHA in the same commit, and re-run " + "conformance (see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_golden_matches_live_producer(tmp_path: Path) -> None: + """PRODUCER-SOURCE recheck (non-circular): scan the SAME fixed source through wardline's + LIVE runtime ``build_taint_facts`` and assert the regenerated fact list EQUALS the frozen + golden. This ties the byte-pinned golden to the real producer, so a blob-shape drift (a + taint sub-key renamed/added/dropped, the dead-code-root signal changed, a finding field + altered, the blake3 freshness derivation changed) without a re-vendor reds even though the + byte-pin still passes. + + The producer is called INLINE on the assert line (the imported ``build_taint_facts`` symbol + + ``==`` on one physical line) so the equality is tied to the live runtime, not to the golden + restating itself — this is the producer-source recheck the seam-registry gate detects.""" + golden = json.loads(GOLDEN_PATH.read_text("utf-8")) + assert golden, "taint-fact golden carries no facts — a vacuous corpus must not pass" + proj, result = _scan_fixed_source(tmp_path) + assert build_taint_facts(result, proj) == golden + + +def test_golden_covers_the_load_bearing_wire_shapes() -> None: + """Guard the corpus against silently shrinking to a vacuous/uninteresting set: assert the + frozen golden still exercises the wire shapes the seam's value rests on (root + non-root + dead-code signal, anchored + fallback taint, a resolved contributing callee, and a real + finding with the SP9 blob fields). Pure on-the-golden assertions — no producer call — so a + re-vendor that drops one of these shapes is caught here even before the recheck runs.""" + golden = json.loads(GOLDEN_PATH.read_text("utf-8")) + by_qualname = {f["qualname"]: f for f in golden} + assert {"svc.read_raw", "svc.helper", "svc.leaky"} <= set(by_qualname) + + # Every fact carries the SP9 envelope: the opaque blob, the dotted qualname, and the + # top-level freshness column repeated inside the blob. + for qualname, fact in by_qualname.items(): + blob = fact["wardline_json"] + assert blob["schema_version"] == "wardline-taint-1" + assert blob["qualname"] == qualname + assert fact["content_hash_at_compute"] == blob["content_hash_at_compute"] + assert len(fact["content_hash_at_compute"]) == 64 # blake3 hex + + # A trust-decorated root entity (dead-code-root signal on) vs an undecorated non-root. + assert by_qualname["svc.read_raw"]["wardline_json"]["dead_code_root"]["is_root"] is True + assert by_qualname["svc.helper"]["wardline_json"]["dead_code_root"]["is_root"] is False + + # The @trusted sink: laundered EXTERNAL_RAW with a resolved contributing callee AND a + # real PY-WL-101 finding carrying the per-finding wire fields. + leaky_blob = by_qualname["svc.leaky"]["wardline_json"] + assert leaky_blob["taint"]["actual_return"] == "EXTERNAL_RAW" + assert leaky_blob["taint"]["contributing_callee_qualname"] == "svc.read_raw" + finding = next(f for f in leaky_blob["findings"] if f["rule_id"] == "PY-WL-101") + assert set(finding) == {"rule_id", "fingerprint", "path", "line_start"} + assert len(finding["fingerprint"]) == 64 diff --git a/tests/conformance/test_mcp_output_schema_golden.py b/tests/conformance/test_mcp_output_schema_golden.py new file mode 100644 index 00000000..e1c4ad4b --- /dev/null +++ b/tests/conformance/test_mcp_output_schema_golden.py @@ -0,0 +1,150 @@ +"""B1/B2 freeze: the MCP tool ``outputSchema`` surface is pinned to a COMMITTED golden. + +The sibling oracle (``test_mcp_structured_output.py``) validates each tool's live +``structuredContent`` against *that same tool's live ``outputSchema``* — a CIRCULAR +gate. It proves the emission is internally consistent with whatever schema the code +currently declares, but it cannot catch a schema that silently DRIFTS: loosen +``_SCAN_OUTPUT_SCHEMA`` (drop a required field, widen a type, add a property) and the +circular oracle stays green because the payload is re-validated against the loosened +copy. + +This module breaks the circle. The 18 declared tool output schemas are frozen to a +committed golden file; any change to the live in-code schema — intended or not — is +forced to land as a deliberate, reviewable re-vendor of the golden bytes. + +Two layers, both in the DEFAULT suite (one-sided seam — Wardline is the sole producer +of its own outputSchema, so there is no upstream peer to drift-check against; the golden +IS the contract): + +1. BYTE-PIN — ``VENDORED_BLOB_SHA`` pins the golden file's git blob hash. ANY byte + change to ``mcp_output_schemas.golden.json`` fails loudly. The pin is updated in the + SAME commit as a deliberate re-freeze. +2. LIVE-EQUALS-GOLDEN — the live ``tools/list`` ``outputSchema`` map must equal the + committed golden exactly. This is the non-circular assertion: the schemas are + compared against a frozen artifact, not against themselves. + +RE-FREEZE PROCEDURE — when a tool's outputSchema legitimately changes: + 1. Regenerate the golden from the live surface (canonical JSON: ``json.dumps( + schemas, indent=2, sort_keys=True) + "\n"``). + 2. Update ``VENDORED_BLOB_SHA`` to ``git hash-object + tests/conformance/mcp_output_schemas.golden.json`` in the SAME commit. + 3. Re-run conformance and confirm the sibling structured-output oracle stays green. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from wardline.mcp.server import WardlineMCPServer + +_GOLDEN_PATH = Path(__file__).parent / "mcp_output_schemas.golden.json" +_GOLDEN: dict[str, Any] = json.loads(_GOLDEN_PATH.read_text("utf-8")) + +# git blob hash of the committed golden (``git hash-object``). A deliberate re-freeze +# updates this constant in the SAME commit as the new bytes — see the RE-FREEZE +# PROCEDURE in this module's header. +VENDORED_BLOB_SHA = "8341a594e22755adfe7f3dfdb7e345086d7378e8" + +# The published 18-tool surface (advertisement order), pinned independently of the +# sibling oracle so a surface change is caught here too. +EXPECTED_TOOLS = ( + "scan", + "scan_job_start", + "scan_job_status", + "scan_job_cancel", + "explain_taint", + "dossier", + "assure", + "decorator_coverage", + "attest", + "verify_attestation", + "file_finding", + "scan_file_findings", + "judge", + "baseline", + "waiver_add", + "fix", + "doctor", + "rekey", +) + +_FIXTURE = Path("tests/fixtures/sample_project") + + +def _live_output_schemas() -> dict[str, Any]: + """The live ``outputSchema`` map keyed by tool name, straight off ``tools/list``.""" + server = WardlineMCPServer(root=_FIXTURE) + resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + assert "error" not in resp, resp + tools = resp["result"]["tools"] + return {t["name"]: t["outputSchema"] for t in tools} + + +# --------------------------------------------------------------------------- # +# Structural self-tests on the golden — a malformed / truncated re-freeze fails loudly. +# --------------------------------------------------------------------------- # + + +def test_golden_covers_exactly_the_published_surface() -> None: + # The golden is canonical-sorted JSON (sort_keys), so compare the surface as a SET; + # advertisement order is pinned separately by the live-bytes / byte-pin layers. + assert set(_GOLDEN) == set(EXPECTED_TOOLS) + + +def test_golden_schemas_are_object_typed() -> None: + # Every frozen schema is a non-empty object schema (the 2025-06-18 structured-output + # contract) — a golden that froze a ``None`` or scalar would be vacuous. + for name, schema in _GOLDEN.items(): + assert isinstance(schema, dict) and schema, f"{name}: golden schema is not a non-empty object" + assert schema["type"] == "object", f"{name}: golden schema is not object-typed" + + +# --------------------------------------------------------------------------- # +# Layer 1 — byte-pin the committed golden. +# --------------------------------------------------------------------------- # + + +def test_golden_matches_vendored_blob_pin() -> None: + assert len(VENDORED_BLOB_SHA) == 40 and set(VENDORED_BLOB_SHA) <= set("0123456789abcdef"), ( + f"VENDORED_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {VENDORED_BLOB_SHA!r}" + ) + data = _GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == VENDORED_BLOB_SHA, ( + f"the frozen golden changed (git blob {actual}, pinned {VENDORED_BLOB_SHA}) — " + "if this was a deliberate re-freeze, update VENDORED_BLOB_SHA in the same commit " + "and re-run conformance; if not, someone edited the golden by hand (forbidden — " + "regenerate it from the live surface; see the RE-FREEZE PROCEDURE in this module's header)" + ) + + +# --------------------------------------------------------------------------- # +# Layer 2 — the live in-code schemas must EQUAL the frozen golden (breaks the +# circular self-validation in test_mcp_structured_output.py). +# --------------------------------------------------------------------------- # + + +def test_live_output_schemas_equal_the_frozen_golden() -> None: + live = _live_output_schemas() + assert live == _GOLDEN, ( + "the live MCP tool outputSchema surface has drifted from the committed golden " + "(tests/conformance/mcp_output_schemas.golden.json) — a schema was added, removed, " + "or changed. If this is a deliberate, reviewed change, re-freeze the golden and bump " + "VENDORED_BLOB_SHA in the same commit (see the RE-FREEZE PROCEDURE); otherwise revert " + "the schema change." + ) + + +def test_live_canonical_bytes_match_the_golden_file_bytes() -> None: + # Stronger than dict-equality: the canonical serialization of the live surface must + # be byte-identical to the committed file, so the byte-pin above genuinely tracks the + # live schemas (no whitespace / ordering escape hatch). + live = _live_output_schemas() + canonical = json.dumps(live, indent=2, sort_keys=True) + "\n" + assert canonical.encode("utf-8") == _GOLDEN_PATH.read_bytes(), ( + "the canonical serialization of the live outputSchema surface is not byte-identical " + "to the committed golden — re-freeze per the RE-FREEZE PROCEDURE." + ) diff --git a/tests/conformance/test_mcp_structured_output.py b/tests/conformance/test_mcp_structured_output.py index 00c8d8fd..182d6924 100644 --- a/tests/conformance/test_mcp_structured_output.py +++ b/tests/conformance/test_mcp_structured_output.py @@ -53,10 +53,12 @@ "rekey", ) -# B2 acceptance: the pure-read surface advertises readOnlyHint: true. +# B2 acceptance: the pure-read surface advertises readOnlyHint: true. ``scan`` is +# excluded even though a local-only scan reads the checkout: configured +# Filigree/Loomweave integrations can add outbound writes at call time, so its +# tools/list hints must be conservative. READ_ONLY_TOOLS = frozenset( { - "scan", "scan_job_status", "explain_taint", "dossier", diff --git a/tests/conformance/test_seam_registry.py b/tests/conformance/test_seam_registry.py new file mode 100644 index 00000000..cf76c14a --- /dev/null +++ b/tests/conformance/test_seam_registry.py @@ -0,0 +1,822 @@ +"""Fail-closed lie detector for the weft-seam conformance registry. + +``tests/conformance/seam_registry.json`` is the program ledger: one row per +cross-product (or one-sided) seam, each carrying a ``bar_verdict`` that claims +how far that seam has been pinned. This test refuses to take any claim on +trust. It parses the THREE real marker sources (never a hardcoded mirror) and +re-derives, from the filesystem, whether each row's claim is backed by an +artifact that actually exists and actually fails closed. + +A row that claims ``at_bar`` with a fabricated ``oracle_test`` path, an +unregistered pytest marker, or (when two-sided) a ``drift_test`` that lacks a +Layer-1 byte-pin must turn this suite RED. Green therefore comes from an HONEST +registry — never from weakening an assertion here. It is FINE for the initial +registry to carry zero ``at_bar`` rows; that is the true starting state this +program exists to fix. + +This module carries NO pytest marker, so it runs in the DEFAULT PR suite and +fails closed (malformed JSON, a fabricated oracle path, or an unregistered +marker errors the default run — it never skips). + +Marker taxonomy (resolve ``_e2e`` vs ``_drift`` explicitly): + +* An ``_e2e`` (live-oracle) marker must appear in ALL THREE real sources: + ``pyproject [tool.pytest.ini_options].markers``, the ``addopts`` ``-m 'not + ...'`` exclusion, AND ``wardline._live_oracle.LIVE_ORACLE_MARKERS`` (so an + armed ``WARDLINE_LIVE_ORACLE_REQUIRED=1`` run fails it closed instead of + skipping clean). +* A ``_drift`` (Layer-2 live recheck) marker must appear in pyproject markers + + the addopts exclusion. It is intentionally NOT required to be in + ``LIVE_ORACLE_MARKERS``: the default-suite fail-closed protection for that + two-sided seam comes instead from the Layer-1 byte-pin (an unmarked test + pinning the vendored fixture hash, which always runs). + +Strengthening notes (what this module verifies vs what it cannot): + +* ``oracle_test`` / ``drift_test`` must be a REAL test file — ``tests/``-rooted, + ``test_*.py`` basename, existing — not merely any file that happens to exist. +* A live-oracle marker declared on a ``partial`` (or ``at_bar``) row must be + APPLIED (``@pytest.mark.`` / a ``pytestmark`` assignment) in the cited + ``oracle_test`` OR in a file listed under ``evidence_paths`` — not merely + registered in pyproject. RESIDUAL (documented, not registry-verifiable): this + proves a marked test is *cited by* the seam; it does NOT prove that test + actually *exercises* this specific seam. That relevance check is semantic and + left to review. +* A two-sided ``at_bar`` drift alarm is checked per ``oracle_shape``: a + ``shared_signed_vector`` seam (the gold G1 mechanism) couples the two sides via + a named-constant signing key + an offline signature round-trip and is accepted + on that basis (it has no vendored blob to pin); all other shapes require the + Layer-1 vendored byte-pin in ``drift_test``. +* A ``shared_signed_vector`` ``at_bar`` seam needs NO live-oracle marker: its + fail-closed protection is the offline round-trip that runs in the DEFAULT suite + (the same reasoning that exempts ``_drift`` seams from ``LIVE_ORACLE_MARKERS``). + In exchange, the oracle_test is asserted to carry NO addopts-excluded marker, so + that "runs offline in the default suite" is proven, not assumed. Without this + exemption the marker requirement would structurally wall G1 out of ``at_bar``. +* A ``self_authored_producer`` ``at_bar`` seam — wardline IS the AUTHORITY for the + seam vocabulary, and the vendored contract is a copy of wardline's OWN frozen + bytes — has the same circular-oracle problem as ``self_authored_restatement`` but + resolved against a DIFFERENT source. ``self_authored_restatement`` rechecks + against a SIBLING authority's repo source; ``self_authored_producer`` rechecks + against wardline's OWN runtime source (the producing enum / constant), because + wardline owns the authority. The two flags are mutually distinct (a row may set + at most one). For such a row the Layer-1 byte-pin alone is wardline-pins-wardline; + the gate ADDITIONALLY requires the drift_test to carry a PRODUCER-SOURCE recheck: + an in-process import of a wardline runtime source (``from wardline. ...`` / + ``import wardline``) AND an equality/membership assertion that ties an IMPORTED + wardline runtime symbol to the vendored contract value. A self_authored_producer + row whose drift_test only byte-pins (no runtime-source recheck) FAILS the gate. + A ``byte_golden_corpus`` self_authored_producer row is marker-exempt (its + fail-closed protection is the default-suite byte-pin), but ONLY because the + producer-source recheck is independently required — the exemption is never a free + pass. Because a producer↔consumer seam is inherently two-sided, the schema test + enforces ``self_authored_producer ⇒ two_sided``, so the recheck requirement (which + lives in the two-sided at_bar branch) can never be bypassed via a one-sided route. + +KNOWN INCONSISTENCY (out of this module's reach — needs a one-line ``src/`` fix): +``rust_e2e`` is registered in pyproject markers + the addopts exclusion but is +ABSENT from ``wardline._live_oracle.LIVE_ORACLE_MARKERS``. ``rust_e2e`` IS a live +subprocess oracle (``wardline scan --lang rust``) that SHOULD fail closed under +an armed ``WARDLINE_LIVE_ORACLE_REQUIRED=1`` run, so the taxonomy rule ("every +non-``_drift`` marker must be in ``LIVE_ORACLE_MARKERS``") is correct and the +SOURCE is the bug. The honest fix is to add ``"rust_e2e"`` to +``LIVE_ORACLE_MARKERS`` in ``src/wardline/_live_oracle.py`` (a production-source +edit, intentionally not made here). This module does NOT carve out an exception: +no row uses ``rust_e2e`` today, so there is zero current fail-open, and weakening +the (sound, fail-closed-favouring) taxonomy rule from the test side would be the +wrong direction. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path +from typing import Any + +from wardline._live_oracle import LIVE_ORACLE_MARKERS + +_HERE = Path(__file__).parent +_REPO_ROOT = _HERE.parent.parent +_REGISTRY_PATH = _HERE / "seam_registry.json" +_PYPROJECT_PATH = _REPO_ROOT / "pyproject.toml" + +_VALID_VERDICTS = frozenset({"at_bar", "partial", "deferred", "one_sided_na", "gap", "peer_conformant"}) +_VALID_ORACLE_SHAPES = frozenset({"scenario", "byte_golden_corpus", "shared_signed_vector"}) + +# Required keys that must be a non-empty string. +_REQUIRED_STR_KEYS = ("seam", "authority", "consumer_or_second_producer", "wire", "wire_change") +# Required keys that must be either a string or null. +_STR_OR_NULL_KEYS = ("oracle_shape", "oracle_test", "marker", "drift_alarm", "drift_test", "deferred_reason") + + +# --------------------------------------------------------------------------- # +# Registry loading +# --------------------------------------------------------------------------- # + + +def _load_registry() -> list[dict[str, Any]]: + rows = json.loads(_REGISTRY_PATH.read_text("utf-8")) + assert isinstance(rows, list) and rows, "seam_registry.json must be a non-empty JSON array of seam rows" + return rows + + +# --------------------------------------------------------------------------- # +# LIVE marker-source parsing (read the REAL sources, never a hardcoded mirror). +# --------------------------------------------------------------------------- # + + +def _load_pyproject() -> dict[str, Any]: + with _PYPROJECT_PATH.open("rb") as fh: + return tomllib.load(fh) + + +def _registered_marker_names(pyproject: dict[str, Any]) -> set[str]: + """Marker NAMES from ``[tool.pytest.ini_options].markers`` — take each + ``"name: description"`` entry's name (split on the first ``:``).""" + raw = pyproject["tool"]["pytest"]["ini_options"]["markers"] + return {entry.split(":", 1)[0].strip() for entry in raw} + + +def _addopts_excluded_markers(pyproject: dict[str, Any]) -> set[str]: + """Tokenize the single quoted ``addopts`` ``-m 'not A and not B ...'`` + expression into the SET of excluded marker names. + + Slice the substring *inside* the single quotes first, THEN split on + ``' and not '``, THEN strip a leading ``not `` and any stray quotes. (The + addopts is ONE string holding a ``-m`` expression, not a list; splitting the + raw addopts would carry the ``-m '`` prefix into the first token.) + """ + addopts = pyproject["tool"]["pytest"]["ini_options"]["addopts"] + assert isinstance(addopts, str), "addopts must be a single -m expression string" + + first = addopts.index("'") + last = addopts.rindex("'") + assert last > first, f"could not locate the quoted -m expression in addopts: {addopts!r}" + expr = addopts[first + 1 : last] + + excluded: set[str] = set() + for token in expr.split(" and not "): + name = token.strip() + if name.startswith("not "): + name = name[len("not ") :] + name = name.strip().strip("'\"").strip() + if name: + excluded.add(name) + return excluded + + +_ROWS = _load_registry() +_PYPROJECT = _load_pyproject() +_REGISTERED_MARKERS = _registered_marker_names(_PYPROJECT) +_EXCLUDED_MARKERS = _addopts_excluded_markers(_PYPROJECT) + + +# --------------------------------------------------------------------------- # +# Self-checks of the parsers — guard against a silently-empty parse. +# --------------------------------------------------------------------------- # + + +def test_parsed_marker_sources_are_non_empty() -> None: + assert _REGISTERED_MARKERS, "parsed pyproject markers set is empty — the parser is broken" + assert _EXCLUDED_MARKERS, "parsed addopts-excluded set is empty — the parser is broken" + assert LIVE_ORACLE_MARKERS, "LIVE_ORACLE_MARKERS is empty — fail-closed protection is gone" + + +def test_addopts_excludes_only_registered_markers() -> None: + # Every excluded marker must be a registered marker (proves the tokenizer + # extracted real names, and catches a typo'd exclusion). + unregistered = _EXCLUDED_MARKERS - _REGISTERED_MARKERS + assert not unregistered, ( + f"addopts excludes markers that are not declared in [markers]: {sorted(unregistered)}" + ) + + +def test_live_oracle_markers_are_registered_and_excluded() -> None: + # Every live-oracle marker must be registered AND excluded from the default + # suite, else it would run hermetically and skip clean. + for name in LIVE_ORACLE_MARKERS: + assert name in _REGISTERED_MARKERS, f"LIVE_ORACLE_MARKERS member {name!r} is not registered" + assert name in _EXCLUDED_MARKERS, ( + f"LIVE_ORACLE_MARKERS member {name!r} is not in the addopts exclusion — it would leak " + "into the hermetic default suite" + ) + + +# --------------------------------------------------------------------------- # +# Schema / enum validity for EVERY row, any verdict. +# --------------------------------------------------------------------------- # + + +def test_registry_schema_is_valid() -> None: + for i, row in enumerate(_ROWS): + ctx = f"row[{i}] seam={row.get('seam')!r}" + assert isinstance(row, dict), f"{ctx}: row is not an object" + + for key in _REQUIRED_STR_KEYS: + assert key in row, f"{ctx}: missing required key {key!r}" + assert isinstance(row[key], str) and row[key].strip(), f"{ctx}: {key!r} must be a non-empty string" + + for key in _STR_OR_NULL_KEYS: + assert key in row, f"{ctx}: missing required key {key!r}" + assert row[key] is None or isinstance(row[key], str), f"{ctx}: {key!r} must be a string or null" + + assert "two_sided" in row and isinstance(row["two_sided"], bool), f"{ctx}: 'two_sided' must be a bool" + + assert "evidence_paths" in row and isinstance(row["evidence_paths"], list), ( + f"{ctx}: 'evidence_paths' must be a list" + ) + assert all(isinstance(p, str) for p in row["evidence_paths"]), f"{ctx}: evidence_paths entries must be strings" + + assert "bar_verdict" in row, f"{ctx}: missing required key 'bar_verdict'" + assert row["bar_verdict"] in _VALID_VERDICTS, ( + f"{ctx}: invalid bar_verdict {row['bar_verdict']!r} (allowed: {sorted(_VALID_VERDICTS)})" + ) + if row["oracle_shape"] is not None: + assert row["oracle_shape"] in _VALID_ORACLE_SHAPES, ( + f"{ctx}: invalid oracle_shape {row['oracle_shape']!r} (allowed: {sorted(_VALID_ORACLE_SHAPES)})" + ) + + # Optional multi-axis / self-authored fields — validate their TYPE when + # present so they are load-bearing, not decorative. (The at_bar gate in + # _assert_at_bar_two_sided_fail_closed enforces their SEMANTICS.) + if "additional_drift_tests" in row: + extra = row["additional_drift_tests"] + assert isinstance(extra, list) and all(isinstance(p, str) and p.strip() for p in extra), ( + f"{ctx}: 'additional_drift_tests' must be a list of non-empty strings" + ) + if "self_authored_restatement" in row: + assert isinstance(row["self_authored_restatement"], bool), ( + f"{ctx}: 'self_authored_restatement' must be a bool" + ) + if "self_authored_producer" in row: + assert isinstance(row["self_authored_producer"], bool), ( + f"{ctx}: 'self_authored_producer' must be a bool" + ) + + # self_authored_producer and self_authored_restatement are MUTUALLY DISTINCT: + # a producer row rechecks vs wardline's OWN runtime source (wardline is the + # authority); a restatement row rechecks vs a SIBLING authority's repo source. + # A single row cannot be both. + assert not (row.get("self_authored_producer") and row.get("self_authored_restatement")), ( + f"{ctx}: a row cannot set BOTH self_authored_producer and self_authored_restatement " + "(producer = wardline IS the authority; restatement = wardline restates a SIBLING authority)" + ) + + # A producer↔consumer seam wardline authors is inherently TWO-SIDED. Enforcing + # this closes the one-sided fail-open: a self_authored_producer + byte_golden_corpus + # row is marker-exempt, so without this guard a two_sided=false row would be routed to + # the one-sided golden branch (byte-pin only) and skip the producer-source recheck — + # a circular free pass. The recheck lives in the two-sided at_bar branch, so force + # self_authored_producer rows down it. + if row.get("self_authored_producer"): + assert row["two_sided"] is True, ( + f"{ctx}: a self_authored_producer row must be two_sided " + "(producer↔consumer is inherently two-sided; this routes it through the " + "two-sided at_bar branch where the producer-source recheck is enforced)" + ) + + +# --------------------------------------------------------------------------- # +# Per-verdict lie detector — claims must be backed by real artifacts on disk. +# --------------------------------------------------------------------------- # + + +# A registry path may carry a trailing ``:NN`` or ``:NN-MM`` line/range suffix +# (e.g. ``tests/e2e/test_x.py:261-302``). Strip it to recover the file path. +_LINE_SUFFIX_RE = re.compile(r":\d+(?:-\d+)?$") + + +def _strip_line_suffix(path_str: str) -> str: + return _LINE_SUFFIX_RE.sub("", path_str) + + +def _is_real_test_file(path_str: str) -> bool: + """True iff ``path_str`` (line-suffix already stripped) names a real test + file: rooted under ``tests/`` AND its basename matches ``test_*.py`` AND it + exists on disk. This kills the LIE where a non-test file (``pyproject.toml``) + is accepted as an ``oracle_test`` merely because ``is_file()`` is True.""" + p = Path(path_str) + if p.parts[:1] != ("tests",): + return False + if not (p.name.startswith("test_") and p.name.endswith(".py")): + return False + return (_REPO_ROOT / p).is_file() + + +# A pytest marker is APPLIED to a test module/function via ``@pytest.mark.`` +# or a module-level ``pytestmark = ......``. We require the mark FORM (not a +# bare substring) so a comment / string literal mentioning the marker name does +# not satisfy the binding (the same lesson the byte-pin needle hardening applies). +def _marker_is_applied_in_file(marker: str, path_str: str) -> bool: + file_path = _REPO_ROOT / _strip_line_suffix(path_str) + if not file_path.is_file(): + return False + text = file_path.read_text("utf-8") + name = re.escape(marker) + decorator = re.search(rf"@pytest\.mark\.{name}\b", text) + # ``pytestmark`` collects marks at module scope; require the marker name to + # appear on a ``pytestmark`` assignment line (handles single + list forms). + module_mark = any( + re.search(r"\bpytestmark\b", line) and re.search(rf"\.{name}\b", line) + for line in text.splitlines() + ) + return bool(decorator) or module_mark + + +def _marker_applied_in_row_evidence(row: dict[str, Any], marker: str) -> bool: + """True iff ``marker`` is APPLIED (mark form) in the row's cited + ``oracle_test`` OR in any file listed under ``evidence_paths``. This binds a + declared live-oracle marker to a real marked test the row itself cites, + closing the LIE where a row declares a registered-but-unapplied marker. + + CAVEAT (semantic residual, not registry-verifiable): this proves a marked + test file is cited by the seam — NOT that that test actually exercises this + specific seam. The achievable bar here is real-marked-test-is-cited; full + coverage relevance is left to review.""" + candidates: list[str] = [] + if row["oracle_test"] is not None: + candidates.append(row["oracle_test"]) + candidates.extend(row["evidence_paths"]) + return any(_marker_is_applied_in_file(marker, c) for c in candidates) + + +def _has_layer1_byte_pin(text: str) -> bool: + """True if a drift_test file carries a Layer-1 byte-pin that runs in the + default suite — an actual PIN, not a mere mention. Requires either a 40-hex + blob-SHA pinned-constant assignment or a live ``git hash-object`` invocation, + so a comment or string literal merely naming ``blob_sha`` / ``git blob`` does + NOT satisfy the check. The old loose needles (``blob %d``, ``git blob``, bare + ``blob_sha``) are intentionally dropped as too weak. + + The pinned-constant needle accepts both the ``UPSTREAM_BLOB_SHA`` name (the + canonical name for a blob byte-copied from the producing authority) and the + ``VENDORED_BLOB_SHA`` name (the Python qualname axis's pin, which deliberately + differs from upstream by a repo-local provenance wrapper — see + test_loomweave_qualname_parity.py). Both are real fail-closed byte-pins held + against a recomputed git-blob hash; the name difference is documentation, not + strength. A bare ``hashlib.sha1(b"blob ...")`` recompute is NOT accepted on its + own — the pin is the 40-hex CONSTANT the recompute is asserted against.""" + # The pinned-constant form: an assignment to a 40-char lowercase-hex SHA-1. + if re.search(r"(?:UPSTREAM|VENDORED)_BLOB_SHA\s*=\s*[\"'][0-9a-f]{40}[\"']", text): + return True + # A live `git hash-object` recomputation of the vendored file's blob SHA. + return bool(re.search(r"hash-object\b", text)) + + +def _has_substantive_sibling_source_recheck(text: str) -> bool: + """True if a drift_test file carries a SUBSTANTIVE authority-side recheck: it + reads the SIBLING authority's real source (via a ``WARDLINE_*_REPO`` env-keyed + repo locator) and asserts parsed source constants against the vendored + restatement. This is the circular-oracle break a SELF-AUTHORED at_bar seam + needs — the byte-pin alone pins wardline's OWN bytes (wardline-pins-wardline), + so the gate additionally requires the test that re-derives the contract from + the producing authority's REAL source to exist and be substantive. + + Required shape (all three): a ``WARDLINE_*_REPO`` env locator (the sibling repo + root), a ``.read_text(`` of a sibling source file, AND an ``assert ... in + `` membership check that ties a vendored contract value to a literal + found in that sibling source. A registered-but-no-op ``_drift`` marker (a + ``pass``-bodied recheck) does NOT satisfy this — the substantive shape must be + present. + + CAVEAT (semantic residual, same class as every other needle here): this is a + TEXT match for the substantive recheck's shape — it proves the source-parsing + code is PRESENT, not that it is reachable at runtime (e.g. a ``return`` above + dead asserts would still match). The realistic regression this catches is the + finding's named one: the recheck deleted or gutted to a no-op. Full + reachability is left to review, as with the byte-pin needle.""" + has_env_locator = re.search(r"WARDLINE_[A-Z]+_REPO\b", text) is not None + has_source_read = re.search(r"\.read_text\(", text) is not None + # An ``assert in `` membership check (the source-grep form the + # filigree_token Layer-2 uses: ``assert f'...' in token_src``). + has_membership_assert = re.search(r"\bassert\b[^\n]*\bin\b\s+\w*src\w*", text) is not None + return has_env_locator and has_source_read and has_membership_assert + + +def _imported_wardline_symbols(text: str) -> set[str]: + """Collect the in-process wardline runtime symbols imported by a drift_test. + + Handles both forms: + + * ``from wardline.core.finding import SuppressionState`` — yields the imported + NAMES (``SuppressionState``), honouring ``a as b`` (binds ``b``) and + comma-separated lists. + * ``import wardline`` / ``import wardline.core.finding as wf`` — yields the bound + module name (``wardline`` or the ``as`` alias) as the usable symbol. + + These are the symbols a producer-source recheck must reference on its assertion + line, so the equality/membership check is tied to the LIVE wardline runtime — not + merely to the byte-pin's own ``actual == UPSTREAM_BLOB_SHA`` (that constant is not + an imported wardline symbol, so it cannot satisfy the recheck).""" + symbols: set[str] = set() + # from wardline... import A, B as C + for m in re.finditer(r"^\s*from\s+wardline(?:\.[\w.]+)?\s+import\s+(.+)$", text, re.MULTILINE): + for part in m.group(1).split(","): + name = part.strip().strip("()").strip() + if not name: + continue + if " as " in name: + name = name.split(" as ", 1)[1].strip() + name = name.split()[0].strip() if name.split() else name + if name and name != "*": + symbols.add(name) + # import wardline[.x.y][ as alias] + for m in re.finditer(r"^\s*import\s+(wardline(?:\.[\w.]+)?)(?:\s+as\s+(\w+))?\s*$", text, re.MULTILINE): + alias = m.group(2) + symbols.add(alias if alias else "wardline") + return symbols + + +def _has_producer_source_recheck(text: str) -> bool: + """True if a drift_test carries a non-circular PRODUCER-SOURCE recheck — the + break a ``self_authored_producer`` at_bar seam needs. + + Wardline IS the authority for such a seam, so the vendored contract is a copy of + wardline's OWN bytes; the Layer-1 byte-pin alone is wardline-pins-wardline. The + non-circular recheck imports wardline's LIVE runtime source and asserts a runtime + value EQUALS / is a member of the frozen contract value. Required shape (BOTH): + + * an in-process import of a wardline runtime source (``from wardline. ...`` or + ``import wardline``); AND + * an ``assert`` line carrying an equality (``==``) or membership (`` in ``) check + whose text references one of the IMPORTED wardline symbols. + + The imported-symbol tie is what makes this tight: the byte-pin's own + ``assert actual == UPSTREAM_BLOB_SHA`` references no imported wardline symbol, so a + drift_test that ONLY byte-pins (even one that happens to import wardline) does NOT + satisfy this — closing the fail-open the advisor flagged. + + CAVEAT (semantic residual, same class as every other needle here): this is a TEXT + match for the recheck's shape — it proves the runtime-vs-contract assertion is + PRESENT, not that it is reachable at runtime. Full reachability is left to review.""" + symbols = _imported_wardline_symbols(text) + if not symbols: + return False + sym_alt = "|".join(re.escape(s) for s in symbols) + # An assert line that performs an == or membership check AND names an imported + # wardline symbol (in either operand). One regex per line so the symbol and the + # operator co-occur on the SAME assertion. + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("assert "): + continue + if not (re.search(r"==", stripped) or re.search(r"\bin\b", stripped)): + continue + if re.search(rf"\b(?:{sym_alt})\b", stripped): + return True + return False + + +def _has_shared_vector_pin(text: str) -> bool: + """True if a ``shared_signed_vector`` oracle carries the shared-vector drift + alarm: a named-constant signing-key binding plus an offline signature + round-trip over the vector (so a wire-key rename reds without a vendored + blob copy). This is the §3b/§4 second drift mechanism — the one G1 uses.""" + has_golden_key = re.search(r"\bGOLDEN_KEY\b", text) is not None + has_round_trip = re.search(r"\bsign_artifact\s*\(", text) is not None + # A ``*_FIELD`` named-constant key check ties literal wire keys to constants. + has_field_constant = re.search(r"\b[A-Z][A-Z0-9_]*_FIELD\b", text) is not None + return has_golden_key and has_round_trip and has_field_constant + + +def _assert_at_bar_marker(row: dict[str, Any], ctx: str) -> None: + # A ``shared_signed_vector`` seam needs NO live-oracle marker: its fail-closed + # protection is the offline signature round-trip that runs in the DEFAULT + # suite (asserted separately in _assert_at_bar_two_sided_fail_closed), not a + # live oracle — directly analogous to a _drift seam's exemption from + # LIVE_ORACLE_MARKERS. Demanding a live-oracle marker here would structurally + # wall the gold G1 mechanism out of at_bar. + if row["oracle_shape"] == "shared_signed_vector": + return + # A one-sided (no-peer) ``byte_golden_corpus`` seam — wardline freezing its OWN + # produced schema (e.g. the MCP outputSchema surface) to a committed golden — has + # no live PEER wire to assert, so it needs no live-oracle marker; its fail-closed + # protection is the default-suite golden byte-pin, asserted in + # _assert_at_bar_one_sided_golden_fail_closed. + if not row["two_sided"] and row["oracle_shape"] == "byte_golden_corpus": + return + # A ``self_authored_producer`` ``byte_golden_corpus`` seam — wardline IS the + # authority and freezes its OWN vocabulary to a vendored byte-corpus — needs no + # live-oracle marker either: its default-suite fail-closed protection is the + # byte-pin PLUS the producer-source recheck (both asserted in + # _assert_at_bar_two_sided_fail_closed). This exemption is NEVER a free pass — it is + # paired with the mandatory producer-source recheck there; without that recheck the + # row reds. (self_authored_producer ⇒ two_sided, enforced in the schema test, so this + # row always reaches the two-sided branch where the recheck is required.) + if row.get("self_authored_producer") and row["oracle_shape"] == "byte_golden_corpus": + return + + marker = row["marker"] + assert marker is not None, f"{ctx}: an at_bar row must declare a marker satisfying the taxonomy" + if marker.endswith("_drift"): + assert marker in _REGISTERED_MARKERS, f"{ctx}: _drift marker {marker!r} not declared in pyproject" + assert marker in _EXCLUDED_MARKERS, f"{ctx}: _drift marker {marker!r} not in the addopts exclusion" + else: + # Live-oracle (_e2e-class) marker: must appear in ALL THREE sources. + assert marker in _REGISTERED_MARKERS, f"{ctx}: live-oracle marker {marker!r} not declared in pyproject" + assert marker in _EXCLUDED_MARKERS, f"{ctx}: live-oracle marker {marker!r} not in the addopts exclusion" + assert marker in LIVE_ORACLE_MARKERS, ( + f"{ctx}: live-oracle marker {marker!r} not in LIVE_ORACLE_MARKERS " + "(would skip clean instead of failing closed under an armed oracle run)" + ) + # Bind the marker to the seam: it must be APPLIED in the cited oracle_test + # OR a file under evidence_paths — not merely registered globally. This + # kills the LIE where an at_bar row pairs a registered marker with an + # unrelated seam/oracle (e.g. filigree_e2e on a loomweave seam). + assert _marker_applied_in_row_evidence(row, marker), ( + f"{ctx}: at_bar live-oracle marker {marker!r} is not APPLIED " + "(@pytest.mark / pytestmark) in oracle_test or any evidence_paths file — " + "the marker is not bound to this seam's evidence" + ) + + +def _assert_at_bar_two_sided_fail_closed(row: dict[str, Any], ctx: str) -> None: + """A two-sided ``at_bar`` seam must carry a drift alarm that fails CLOSED in + the default (sibling-less) suite. The kit sanctions TWO drift mechanisms, so + branch on ``oracle_shape`` instead of forcing every seam through the + vendored-corpus byte-pin: + + * ``shared_signed_vector`` — the shared cross-member vector couples the two + sides via a named-constant signing key + an offline signature round-trip + (the gold G1 mechanism). It has NO vendored blob to pin; demanding one + would be test theater. The alarm lives in the ``oracle_test`` itself. + * everything else (``byte_golden_corpus`` / scenario corpora) — require the + ``drift_test`` to carry a Layer-1 vendored byte-pin. + """ + if row["oracle_shape"] == "shared_signed_vector": + # The shared-vector coupling IS the drift alarm; assert it lives in the + # oracle_test (a drift_test is not required for this mechanism). + oracle = row["oracle_test"] + assert oracle is not None, f"{ctx}: shared_signed_vector at_bar requires an oracle_test carrying the vector pin" + oracle_path = _REPO_ROOT / oracle + assert oracle_path.is_file(), f"{ctx}: shared_signed_vector at_bar oracle_test does not exist: {oracle}" + assert _has_shared_vector_pin(oracle_path.read_text("utf-8")), ( + f"{ctx}: shared_signed_vector at_bar oracle_test lacks the shared-vector drift alarm " + "(a GOLDEN_KEY-bound sign_artifact() round-trip + a *_FIELD named-constant key check); " + "it would not fail closed against a silent wire-key rename" + ) + # The "fails closed offline" claim is only true if the oracle actually + # RUNS in the default suite. Since this seam is exempt from the live-oracle + # marker requirement, prove the oracle is NOT excluded by any addopts + # marker (else a row could claim shared_signed_vector while citing an + # oracle marked e.g. filigree_e2e → never runs by default → the claim lies). + for excluded in _EXCLUDED_MARKERS: + assert not _marker_is_applied_in_file(excluded, oracle), ( + f"{ctx}: shared_signed_vector oracle_test carries excluded marker {excluded!r} " + "— it would be skipped in the default suite, so the offline fail-closed claim is false" + ) + return + + # Vendored-corpus / scenario seams: require the Layer-1 byte-pin in drift_test. + assert row["drift_test"] is not None, f"{ctx}: two_sided at_bar requires a non-null drift_test" + drift_path = _REPO_ROOT / row["drift_test"] + assert drift_path.is_file(), f"{ctx}: at_bar drift_test does not exist: {row['drift_test']}" + drift_text = drift_path.read_text("utf-8") + assert _has_layer1_byte_pin(drift_text), ( + f"{ctx}: two_sided at_bar drift_test lacks a Layer-1 byte-pin " + "(an (UPSTREAM|VENDORED)_BLOB_SHA = \"<40-hex>\" assignment or a git hash-object / " + "hashlib.sha1(b\"blob ...\") recomputation); it would not fail closed in the default suite" + ) + + # Multi-axis enforcement: every axis a row declares must be gate-pinned, not + # just the single ``drift_test``. A multi-axis at_bar row (e.g. the qualname + # seam, Rust + Python) lists its secondary axes under ``additional_drift_tests``; + # each MUST be a real test file carrying its own Layer-1 byte-pin, so deleting + # or loosening a secondary-axis pin reds this gate (closing the "second axis + # rests on prose + an unenforced evidence_path" hole). + for extra in row.get("additional_drift_tests") or []: + assert _is_real_test_file(extra), ( + f"{ctx}: additional_drift_tests entry is not a real test file " + f"(must be tests/-rooted, match test_*.py, and exist): {extra}" + ) + extra_text = (_REPO_ROOT / extra).read_text("utf-8") + assert _has_layer1_byte_pin(extra_text), ( + f"{ctx}: multi-axis at_bar additional_drift_test {extra!r} lacks a Layer-1 byte-pin " + "((UPSTREAM|VENDORED)_BLOB_SHA = \"<40-hex>\" or a git hash-object / " + "hashlib.sha1(b\"blob ...\") recomputation); the second axis is not gate-protected" + ) + + # Self-authored restatement: when the vendored blob pins WARDLINE's OWN bytes + # (the producing authority ships no fixture to byte-copy), the Layer-1 byte-pin + # is wardline-pins-wardline — it cannot break the circular oracle on its own. + # Such a row must declare ``self_authored_restatement: true`` AND its drift_test + # must carry a SUBSTANTIVE authority-side recheck that re-derives the contract + # from the sibling authority's REAL source (so a registered-but-no-op _drift + # marker can NOT carry the at_bar claim). + if row.get("self_authored_restatement"): + assert _has_substantive_sibling_source_recheck(drift_text), ( + f"{ctx}: self_authored_restatement at_bar drift_test lacks a SUBSTANTIVE " + "authority-side recheck (a WARDLINE_*_REPO-keyed read of the sibling authority " + "source + an `assert in <...src>` membership check). A self-authored " + "byte-pin pins wardline's own bytes; the circular oracle is only broken by " + "re-deriving the contract from the producing authority's real source" + ) + + # Self-authored PRODUCER: wardline IS the authority for this seam's vocabulary, so + # the vendored contract is a copy of wardline's OWN frozen bytes — the Layer-1 + # byte-pin is wardline-pins-wardline. The non-circular break is a PRODUCER-SOURCE + # recheck in the drift_test: an in-process import of a wardline RUNTIME source plus + # an equality/membership assertion tying an imported wardline symbol to the vendored + # contract value. A self_authored_producer row whose drift_test only byte-pins (no + # runtime-source recheck) FAILS here — the byte-pin alone cannot break the circle. + if row.get("self_authored_producer"): + assert _has_producer_source_recheck(drift_text), ( + f"{ctx}: self_authored_producer at_bar drift_test lacks a PRODUCER-SOURCE recheck " + "(an in-process `from wardline. ...` / `import wardline` import AND an " + "`assert == / in ` check). A self-authored " + "byte-pin pins wardline's own bytes; the circular oracle is only broken by re-deriving " + "the contract from wardline's LIVE runtime source (the producing enum/constant)" + ) + + # A self_authored_producer + byte_golden_corpus row is EXEMPT from the + # live-oracle marker requirement (line 508): both fail-closed legs — the + # Layer-1 byte-pin AND the producer-source recheck above — rest entirely on + # the protective test file RUNNING in the default suite. That "runs offline + # by default" claim is only true if the file carries NO addopts-excluded + # marker (else a one-line `pytestmark = pytest.mark.` edit would + # silently DESELECT both legs in the default run while this gate keeps + # certifying the row at_bar). Mirror the guard the sibling marker-exempt + # branches use (lines 565-569, 653-657) so the exemption is paid for, not + # assumed: assert the drift_test (and the oracle_test, when distinct) carry + # no excluded marker. Scoped to this exempt case ONLY — scenario / + # byte_golden seams that DO declare an excluded live-oracle marker + # (e.g. SEI / qualname-parity / warpline) legitimately apply it in the same + # file as their default-suite byte-pin and must not be tripped here. + if row["oracle_shape"] == "byte_golden_corpus": + protective_paths = {row["drift_test"]} + if row["oracle_test"] is not None: + protective_paths.add(row["oracle_test"]) + for protective in protective_paths: + for excluded in _EXCLUDED_MARKERS: + assert not _marker_is_applied_in_file(excluded, protective), ( + f"{ctx}: self_authored_producer byte_golden_corpus protective test " + f"{protective!r} carries addopts-excluded marker {excluded!r} — it would be " + "DESELECTED in the default suite, falsifying the default-suite fail-closed " + "claim that the marker exemption (line 508) rests on. Both the Layer-1 " + "byte-pin and the producer-source recheck must run by default; an excluded " + "marker on this file silently disables them" + ) + + +def _assert_at_bar_one_sided_golden_fail_closed(row: dict[str, Any], ctx: str) -> None: + """A one-sided (no-peer) ``byte_golden_corpus`` at_bar seam — wardline freezing its + OWN produced schema to a committed golden — must carry a Layer-1 byte-pin in its + oracle_test that runs in the DEFAULT suite, so a silent schema change fails closed. + There is no upstream peer, hence no drift_test / live-oracle marker; the golden + byte-pin IS the fail-closed oracle (analogous to the shared_signed_vector exemption).""" + oracle = row["oracle_test"] + assert oracle is not None, f"{ctx}: one-sided byte_golden_corpus at_bar requires an oracle_test" + oracle_path = _REPO_ROOT / oracle + assert oracle_path.is_file(), f"{ctx}: one-sided at_bar oracle_test does not exist: {oracle}" + text = oracle_path.read_text("utf-8") + assert _has_layer1_byte_pin(text), ( + f"{ctx}: one-sided byte_golden_corpus at_bar oracle_test lacks a Layer-1 byte-pin " + "((UPSTREAM|VENDORED)_BLOB_SHA = \"<40-hex>\" or a git hash-object / " + "hashlib.sha1(b\"blob ...\") recomputation); a silent schema change would not fail " + "closed in the default suite" + ) + # The golden freeze only fails closed if it RUNS by default — reject an oracle + # carrying any addopts-excluded marker (else it would be skipped and the claim lies). + for excluded in _EXCLUDED_MARKERS: + assert not _marker_is_applied_in_file(excluded, oracle), ( + f"{ctx}: one-sided golden oracle_test carries excluded marker {excluded!r} " + "— it would be skipped in the default suite, so the fail-closed claim is false" + ) + + +def test_registry_verdicts_are_backed_by_real_artifacts() -> None: + for i, row in enumerate(_ROWS): + verdict = row["bar_verdict"] + ctx = f"row[{i}] seam={row['seam']!r} verdict={verdict}" + + if verdict == "at_bar": + assert row["oracle_test"] is not None, f"{ctx}: at_bar requires a non-null oracle_test" + # An oracle_test must be a REAL test file (tests/-rooted, test_*.py), + # not merely any existing file — a TOML/config path is not an oracle. + assert _is_real_test_file(row["oracle_test"]), ( + f"{ctx}: at_bar oracle_test is not a real test file (must be tests/-rooted, " + f"match test_*.py, and exist): {row['oracle_test']}" + ) + _assert_at_bar_marker(row, ctx) + if row["two_sided"]: + _assert_at_bar_two_sided_fail_closed(row, ctx) + elif row["oracle_shape"] == "byte_golden_corpus": + _assert_at_bar_one_sided_golden_fail_closed(row, ctx) + + elif verdict == "partial": + assert row["oracle_test"] is not None, ( + f"{ctx}: partial requires a non-null oracle_test (re-grade to gap honestly otherwise)" + ) + assert _is_real_test_file(row["oracle_test"]), ( + f"{ctx}: partial oracle_test is not a real test file (must be tests/-rooted, " + f"match test_*.py, and exist): {row['oracle_test']} " + "(re-grade to gap honestly instead of claiming an oracle that is not there)" + ) + # If the partial row declares a live-oracle marker, that marker must be + # APPLIED (mark form) in the cited oracle_test OR in a file named under + # evidence_paths — so a row can't declare a registered-but-unapplied + # marker against an irrelevant file. (Residual: this proves a marked + # test is cited by the seam, not that it exercises THIS seam.) + marker = row["marker"] + if marker is not None and not marker.endswith("_drift"): + assert _marker_applied_in_row_evidence(row, marker), ( + f"{ctx}: partial declares live-oracle marker {marker!r} but it is not APPLIED " + "(@pytest.mark / pytestmark) in oracle_test or any evidence_paths file — " + "cite the file that actually carries the marker" + ) + + elif verdict in ("deferred", "one_sided_na"): + reason = row["deferred_reason"] + assert isinstance(reason, str) and reason.strip(), ( + f"{ctx}: {verdict} requires a non-empty deferred_reason" + ) + + elif verdict == "peer_conformant": + # A seam wardline is NOT a party to (a pure peer-to-peer interface, e.g. + # loomweave↔legis or legis↔filigree). The wardline-rooted gate CANNOT run an + # oracle for it — its conformance lives in the peer repos' own suites. This + # verdict is a documented disposition, not a gate-enforced one; it exists so a + # genuinely-conformant peer seam stops reading as a bare ``gap`` (which would + # imply undone work). To keep it from laundering a wardline-PARTY seam that + # should be at_bar, the gate requires three things: + # (a) wardline is genuinely not a party (neither authority nor the + # consumer/second-producer is wardline) — a wardline-party seam with a + # real oracle is at_bar, and without one it is gap/partial, never this; + # (b) a non-empty ``peer_conformance`` evidence string (peer repo + commit + + # test path) so the claim is auditable, not a bare assertion; + # (c) no wardline ``oracle_test`` — there cannot be one for a seam wardline + # does not participate in (cite the peer test in ``peer_conformance``). + assert row["authority"] != "wardline" and row["consumer_or_second_producer"] != "wardline", ( + f"{ctx}: peer_conformant requires wardline to NOT be a party (authority or " + "consumer/second-producer). A wardline-party seam with a real oracle is at_bar; " + "without one it is gap or partial — never peer_conformant." + ) + evidence = row.get("peer_conformance") + assert isinstance(evidence, str) and evidence.strip(), ( + f"{ctx}: peer_conformant requires a non-empty 'peer_conformance' evidence string " + "(name the peer repo(s) + commit(s) + test path(s) proving the seam conforms in " + "its own repos; the wardline gate cannot run that oracle)." + ) + assert row["oracle_test"] is None, ( + f"{ctx}: peer_conformant row must have a null oracle_test — wardline has no in-repo " + "oracle for a seam it is not party to. Cite the peer test in 'peer_conformance'." + ) + + elif verdict == "gap": + # No artifact requirement — honest under-claiming is allowed. But a gap + # row must NOT dangle a real oracle_test: a cited oracle on a gap row is + # either mis-graded (should be partial) or noise. Force the author to + # null it out or re-grade honestly. + assert row["oracle_test"] is None, ( + f"{ctx}: gap row carries a non-null oracle_test ({row['oracle_test']}) — " + "a gap claims no oracle. Null it out, or re-grade to partial if the oracle is real " + "(cite supporting paths under evidence_paths instead)." + ) + + else: # pragma: no cover - guarded by the schema enum test + raise AssertionError(f"{ctx}: unhandled bar_verdict {verdict!r}") + + +# --------------------------------------------------------------------------- # +# Taxonomy guard for EVERY marked row, regardless of verdict. +# --------------------------------------------------------------------------- # + + +def test_marker_taxonomy_guard_for_every_marked_row() -> None: + """For every row carrying a non-null ``marker`` and/or ``drift_alarm``, + classify each by suffix and enforce the taxonomy: + + * a non-``_drift`` (``_e2e``-class) live-oracle marker NOT in + ``LIVE_ORACLE_MARKERS`` FAILS — this closes the fail-open for live oracles; + * a ``_drift`` marker simply must be in pyproject markers + the addopts + exclusion. + + The kind can be carried by ``marker`` OR by ``drift_alarm`` (e.g. a row with + ``marker=null`` but ``drift_alarm="loomweave_drift"``), so both fields are + gathered and classified. + """ + for i, row in enumerate(_ROWS): + ctx = f"row[{i}] seam={row['seam']!r}" + for marker in (m for m in (row["marker"], row["drift_alarm"]) if m is not None): + if marker.endswith("_drift"): + assert marker in _REGISTERED_MARKERS, ( + f"{ctx}: _drift marker {marker!r} not declared in pyproject markers" + ) + assert marker in _EXCLUDED_MARKERS, ( + f"{ctx}: _drift marker {marker!r} not in the addopts exclusion " + "(would run in the default PR suite without the sibling)" + ) + else: + assert marker in _REGISTERED_MARKERS, ( + f"{ctx}: live-oracle marker {marker!r} not declared in pyproject markers" + ) + assert marker in _EXCLUDED_MARKERS, ( + f"{ctx}: live-oracle marker {marker!r} not in the addopts exclusion" + ) + assert marker in LIVE_ORACLE_MARKERS, ( + f"{ctx}: live-oracle marker {marker!r} not in LIVE_ORACLE_MARKERS " + "(an armed WARDLINE_LIVE_ORACLE_REQUIRED=1 run would skip it clean " + "instead of failing closed — fail-open)" + ) diff --git a/tests/conformance/test_sei_oracle.py b/tests/conformance/test_sei_oracle.py index 6db99d42..8f995a81 100644 --- a/tests/conformance/test_sei_oracle.py +++ b/tests/conformance/test_sei_oracle.py @@ -8,6 +8,7 @@ from __future__ import annotations +import hashlib import json import os from pathlib import Path @@ -19,6 +20,24 @@ ORACLE_PATH = Path(__file__).parent / "fixtures" / "sei-conformance-oracle.json" +# The git blob hash of the vendored SEI conformance oracle as authored upstream by +# Loomweave (docs/federation/fixtures/sei-conformance-oracle.json). Loomweave is the +# PRODUCER/authority for the six-scenario §8 oracle; Wardline is the CONSUMER and +# VENDORS the fixture byte-verbatim. This Layer-1 byte-pin runs in the DEFAULT PR +# suite, so ANY byte change to the vendored copy fails loudly — re-vendors are +# deliberate and update this constant in the SAME commit as the new bytes. +# +# RE-VENDOR PROCEDURE (a release-gate item — run ``pytest -m sei_drift -v`` before +# every release; on drift, or on a deliberate upstream oracle bump): +# 1. Copy ``$WARDLINE_LOOMWEAVE_REPO/docs/federation/fixtures/sei-conformance-oracle.json`` +# byte-verbatim over the vendored copy. NEVER hand-edit the vendored fixture; +# Loomweave's oracle (cargo gate ``sei_conformance_oracle``) is the only author. +# 2. Update ``UPSTREAM_BLOB_SHA`` to ``git hash-object`` of the vendored file +# (equivalently ``hashlib.sha1(b"blob %d\0" % len(data) + data)``) — same commit. +# 3. Re-run conformance and CONFORM the consumer (``wardline.loomweave.identity``) +# until green; never weaken the assertions. +UPSTREAM_BLOB_SHA = "0ea577025d94c028a0f682b7d29765079455718c" + def _load_oracle() -> dict[str, Any]: return json.loads(ORACLE_PATH.read_text(encoding="utf-8")) @@ -32,18 +51,24 @@ def _scenario(scenario_id: str) -> dict[str, Any]: def _loomweave_oracle_source() -> Path | None: - candidates: list[Path] = [] - if env := os.environ.get("LOOMWEAVE_REPO"): - candidates.append(Path(env) / "docs" / "federation" / "fixtures" / "sei-conformance-oracle.json") - candidates.append( - Path(__file__).resolve().parents[3] - / "loomweave" - / "docs" - / "federation" - / "fixtures" - / "sei-conformance-oracle.json" - ) - return next((path for path in candidates if path.exists()), None) + # Env takes EXCLUSIVE precedence (first-configured, not first-existing): if + # ``WARDLINE_LOOMWEAVE_REPO`` (or the legacy unnamespaced ``LOOMWEAVE_REPO``) + # is set, resolve the sibling ONLY from it and skip clean if the oracle is + # absent under it — the parents[3] local-dev convenience checkout is consulted + # ONLY when no env var is set. This shares ONE resolution contract with the + # other ``_drift`` rechecks (test_loomweave_qualname_parity.py:150): an + # operator who points the release-gate env var at a specific checkout that + # lacks the file gets a clean skip, never a silent compare against the local + # convenience sibling. CI runners (env unset, no parents[3] sibling) skip + # clean — the documented basis for the clean skip is the sibling's ABSENCE, + # not a guarantee independent of runner layout. + subpath = ("docs", "federation", "fixtures", "sei-conformance-oracle.json") + for var in ("WARDLINE_LOOMWEAVE_REPO", "LOOMWEAVE_REPO"): + if env := os.environ.get(var): + path = Path(env).joinpath(*subpath) + return path if path.exists() else None + path = Path(__file__).resolve().parents[3] / "loomweave" / Path(*subpath) + return path if path.exists() else None COVERED_SCENARIOS = { @@ -80,11 +105,47 @@ def resolve_sei(self, sei: str) -> dict[str, Any] | None: return self._resolve_sei +def test_vendored_oracle_matches_upstream_blob_pin() -> None: + """Layer 1 (default suite): the vendored SEI oracle byte-pins to the upstream + git blob hash. ANY edit to the vendored fixture without a matching re-pin reds + the default PR suite — the fail-closed protection that lets the Layer-2 drift + recheck skip clean when the sibling checkout is absent.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = ORACLE_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored SEI oracle changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit and " + "re-run conformance; if not, someone edited the vendored copy (forbidden — Loomweave's " + "oracle is the only author; see the RE-VENDOR PROCEDURE at the top of this module)" + ) + + +@pytest.mark.sei_drift def test_vendored_oracle_matches_loomweave_source() -> None: + """Layer 2 (opt-in, ``-m sei_drift``): the sibling loomweave checkout's + authoritative oracle must be BYTE-IDENTICAL to the vendored copy — the + release-gate drift alarm. Absent checkout (CI/default suite) skips clean; + divergence FAILS. + + Byte-exact (not JSON-semantic) by design: the RE-VENDOR PROCEDURE mandates a + byte-verbatim copy and the Layer-1 ``UPSTREAM_BLOB_SHA`` pins the git blob, so + a copy that is reordered/reformatted (JSON-equal but byte-different) would leave + the blob-pin silently stale yet pass a parsed-dict compare. Comparing raw bytes + enforces the same byte-verbatim invariant Layer-1 assumes, matching the + loomweave_drift precedent (test_loomweave_rust_qualname_parity.py).""" source = _loomweave_oracle_source() if source is None: - pytest.skip("Loomweave repo not found; set LOOMWEAVE_REPO to enable drift check") - assert _load_oracle() == json.loads(source.read_text(encoding="utf-8")) + pytest.skip("Loomweave repo not found; set WARDLINE_LOOMWEAVE_REPO to enable drift check") + if ORACLE_PATH.read_bytes() != source.read_bytes(): + pytest.fail( + f"upstream {source} has drifted from the vendored " + "tests/conformance/fixtures/sei-conformance-oracle.json — re-vendor + conform: follow the " + "RE-VENDOR PROCEDURE at the top of this module (byte-verbatim copy, bump UPSTREAM_BLOB_SHA " + "in the same commit, re-run conformance)" + ) def test_every_oracle_scenario_is_covered() -> None: diff --git a/tests/conformance/test_vocabulary_descriptor_wire_golden.py b/tests/conformance/test_vocabulary_descriptor_wire_golden.py new file mode 100644 index 00000000..9bad2709 --- /dev/null +++ b/tests/conformance/test_vocabulary_descriptor_wire_golden.py @@ -0,0 +1,109 @@ +"""Wardline-authored NG-25 trust-vocabulary descriptor frozen to a vendored byte golden. + +``wardline-vocabulary-descriptor.golden.yaml`` is the descriptor wardline emits +to ``.weft/wardline/vocabulary.yaml`` (and ships in the wheel as +``wardline/core/vocabulary.yaml``). Loomweave's Python plugin is the real +consumer: ``loomweave_plugin_python.wardline_descriptor.load_wardline_descriptor`` +reads this file's BYTES (it never imports wardline). It GATES ON ``version`` +(asserting the descriptor version equals its ``EXPECTED_DESCRIPTOR_VERSION`` — +currently ``wardline-generic-2``, matching wardline's ``REGISTRY_VERSION``, so a +one-sided bump trips loomweave's ``version_skew`` path), PARSES ``entries`` +(``canonical_name`` / ``group`` / ``attrs``) into a ``WardlineVocabulary``, and +TOLERATES the ``schema`` field without acting on it (its schema-format-version +handling is deferred to loomweave's own Task B). It then threads the vocabulary +into the extractor to emit ``wardline:external_boundary`` / +``wardline:trusted`` ``entity_tags`` (which seed loomweave's dead-code +reachability roots in ``crates/loomweave-mcp/src/catalogue/shortcuts.rs``). So +this seam is a genuine two-sided producer↔consumer wire, not a one-sided internal +export. + +WARDLINE IS THE AUTHORITY for this seam — it OWNS the trust-vocabulary via +``wardline.core.registry.REGISTRY`` and serializes it through +``wardline.core.descriptor.build_vocabulary_descriptor`` / +``descriptor_to_yaml``. That makes the protection a two-layer affair (mirroring +the suppression-filter and scan-results-wire contracts): + +* Layer-1 (``test_golden_matches_blob_pin``): a git-blob byte-pin on the vendored + golden, so any silent edit to the descriptor wire reds the default PR suite. On + its OWN this is CIRCULAR — wardline pins wardline's own bytes. +* Producer-source recheck (``test_golden_matches_live_descriptor_producer``): the + non-circular break. It imports wardline's LIVE runtime ``descriptor_to_yaml`` / + ``build_vocabulary_descriptor`` and asserts the bytes / dict they regenerate + EQUAL the frozen golden. The frozen bytes are tied to the live producer, so if + REGISTRY (or the serializer) drifts from the golden — a decorator added/removed, + a group/attr changed, the schema or version bumped — it reds even though the + byte-pin still passes. + +The consumer-side oracle lives in the loomweave repo (its python plugin parses +these bytes); it is cited as prose evidence — this conformance row pins the +producer-authored descriptor bytes, which is what makes the two sides agree. + +RE-VENDOR PROCEDURE: if you deliberately change the vocabulary (e.g. add a +decorator) or the descriptor format, regenerate the golden from the producer +(``.venv/bin/wardline vocab > tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml``, +equivalently ``descriptor_to_yaml()``), recompute the blob SHA and update +``UPSTREAM_BLOB_SHA`` in the SAME commit — the producer-source recheck will +otherwise red. Keep ``src/wardline/core/vocabulary.yaml`` in lockstep (its own +byte-identity test in tests/unit/core/test_descriptor.py guards that). +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from wardline.core.descriptor import build_vocabulary_descriptor, descriptor_to_yaml + +GOLDEN_PATH = Path(__file__).parent / "fixtures" / "wardline-vocabulary-descriptor.golden.yaml" + +# Layer-1 byte-pin: the git-blob SHA-1 of wardline-vocabulary-descriptor.golden.yaml. +# Recomputed below as hashlib.sha1(b"blob %d\0" % len(data) + data). Any edit to the +# vendored golden without a matching re-pin reds the default PR suite. +UPSTREAM_BLOB_SHA = "f5ad8d2346ffb6ea75aa469e423c6c7cfd16d40a" + + +def test_golden_matches_blob_pin() -> None: + """Layer-1 (default suite): the wardline-authored descriptor golden byte-pins to + its git blob hash. ANY edit without a matching re-pin reds the default PR suite. + On its OWN this pin is wardline-pins-wardline (circular); the non-circular + protection is ``test_golden_matches_live_descriptor_producer`` below, which + regenerates the bytes from the LIVE producer.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored vocabulary-descriptor golden changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, regenerate the golden from descriptor_to_yaml() " + "(`.venv/bin/wardline vocab > tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml`), " + "update UPSTREAM_BLOB_SHA in the same commit, keep src/wardline/core/vocabulary.yaml in lockstep, " + "and re-run conformance (see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_golden_matches_live_descriptor_producer() -> None: + """PRODUCER-SOURCE recheck (non-circular): regenerate the descriptor from + wardline's LIVE runtime ``descriptor_to_yaml`` and assert the bytes EQUAL the + frozen golden. This ties the byte-pinned golden to the real producer (and + through it to REGISTRY), so a vocabulary/format drift — a decorator + added/removed, a group/attr changed, the schema or version bumped — without a + re-vendor reds even though the byte-pin still passes. + + The loomweave python-plugin consumer reads these exact bytes + (``.weft/wardline/vocabulary.yaml``) via ``yaml.safe_load`` and gates on + ``version`` / ``entries``, so freezing the producer bytes is what holds the two + sides in agreement.""" + golden_text = GOLDEN_PATH.read_text("utf-8") + assert descriptor_to_yaml() == golden_text + + +def test_golden_dict_matches_live_descriptor_producer() -> None: + """Companion structured recheck: the live ``build_vocabulary_descriptor`` dict + must equal the parsed golden. Catches a same-bytes-different-semantics drift the + YAML compare alone could miss (it asserts on the structured envelope the + consumer actually parses: schema / version / entries).""" + import yaml + + golden = yaml.safe_load(GOLDEN_PATH.read_text("utf-8")) + assert build_vocabulary_descriptor() == golden diff --git a/tests/conformance/test_wardline_scan_artifact_shared_vector.py b/tests/conformance/test_wardline_scan_artifact_shared_vector.py new file mode 100644 index 00000000..d1f2b872 --- /dev/null +++ b/tests/conformance/test_wardline_scan_artifact_shared_vector.py @@ -0,0 +1,147 @@ +"""G1 cross-member shared vector — the wardline (producer) half, byte-pinned to legis. + +legis AUTHORS the canonical Weft conformance vector for the wardline→legis signed +scan-artifact wire at ``legis/tests/contract/weft/vectors/wardline_scan_artifact.v1.json`` +and drives its REAL ingest (``active_defects`` / ``verify_wardline_artifact``) over it +(``legis/tests/contract/weft/test_wardline_scan_artifact_contract.py``). This file is the +PRODUCER half: wardline vendors that vector **byte-identical** (the Layer-1 byte-pin +below) and proves wardline's REAL signer (:func:`wl_legis.sign_artifact`) reproduces the +vector's ``expected_signature`` and wardline's REAL projection +(:func:`wl_legis.project_finding`) emits the vector's finding wire shape. + +Why this closes G1 (federation-interface audit / Weft incident 2026-06-10, root cause #2): +before this, wardline pinned its own ``legis_scan_wire.golden.json`` and legis pinned its +own ``wardline_scan_artifact.v1.json`` — two INDEPENDENT vendored mirrors that agreed only +by hand. A canonical-JSON or HMAC drift on either side re-signed cleanly and broke the +other invisibly. Now BOTH sides load the SAME bytes: the byte-pin ties wardline's vendored +copy to legis's authored blob, and wardline's live signer must reproduce the byte-exact +``expected_signature`` legis baked in. A divergence stops reproducing the signature HERE, +in CI, instead of routing zero defects under a green ``verified`` status in production. + +Two-layer drift discipline (the conformance kit): the byte-pin + signer reproduction run +in the DEFAULT suite (fail-closed offline, no legis checkout needed); the ``Layer-2`` +recheck (:func:`test_vendored_vector_matches_legis_source`, marked +``legis_scan_artifact_drift`` and excluded from the default suite) re-compares the vendored +copy to legis's LIVE source so an intentional legis-side vector change is caught at the +release gate rather than silently diverging. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from wardline.core import legis as wl_legis +from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState + +_VECTOR_PATH = Path(__file__).parent / "vectors" / "wardline_scan_artifact.v1.json" + +# Layer-1 byte-pin: the git blob sha1 of legis's canonical vector +# (legis/tests/contract/weft/vectors/wardline_scan_artifact.v1.json @ a clean tree). +# wardline's vendored copy MUST hash identical — that is the proof the two repos load the +# SAME bytes, not two mirrors that drifted apart. Re-pin ONLY in lockstep with an +# intentional legis-side vector change (and re-vendor the bytes in the same commit). +VENDORED_BLOB_SHA = "fd4b21be6f8df15fda37606c65df73fd464b9aea" + + +def _git_blob_sha1(data: bytes) -> str: + """The git blob object id of *data* (``sha1("blob \\0" + data)``).""" + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def _vector() -> dict: + return json.loads(_VECTOR_PATH.read_text(encoding="utf-8")) + + +def _signing_key() -> bytes: + return _vector()["signing"]["key_utf8"].encode("utf-8") + + +def test_vendored_vector_is_byte_identical_to_legis_blob_pin() -> None: + # Runs in the DEFAULT suite: a vendored copy that drifts from legis's authored blob + # (a hand edit, an accidental reformat) fails closed here without any legis checkout. + actual = _git_blob_sha1(_VECTOR_PATH.read_bytes()) + assert actual == VENDORED_BLOB_SHA, ( + f"vendored wardline_scan_artifact.v1.json blob {actual} != pinned {VENDORED_BLOB_SHA}; " + "the wardline copy drifted from legis's canonical vector. Re-vendor byte-identical and " + "re-pin ONLY in lockstep with the legis hub." + ) + + +@pytest.mark.parametrize( + "case", + [c for c in _vector()["valid"] if "expected_signature" in c], + ids=lambda c: c["name"], +) +def test_real_signer_reproduces_legis_expected_signature(case: dict) -> None: + # The non-circular producer-source recheck AND the canonicalization-drift detector: + # wardline's REAL signer must reproduce the byte-exact cross-impl signature legis baked + # into the vector. If wardline's canonical-JSON/HMAC formula ever diverges from legis's + # (enforcement.signing.sign over canonical.py), this stops reproducing — the G1 incident + # class, caught in CI rather than re-signed clean and broken in the field. + assert wl_legis.sign_artifact(case["artifact"], _signing_key()) == case["expected_signature"] + + +def test_real_projection_emits_the_vector_finding_wire_shape() -> None: + # Tie wardline's REAL projection to the vector's golden finding: a Finding equivalent to + # the vector's one active defect must project onto exactly the vector's finding wire + # key-set, with the legis-routing-critical kind/suppression_state values. A per-finding + # key rename on the producer (the route-to-a-defaulted-key risk) reds here. + golden = next(c for c in _vector()["valid"] if c["name"] == "golden_single_active_defect") + vector_finding = golden["artifact"]["findings"][0] + finding = Finding( + rule_id=vector_finding["rule_id"], + message=vector_finding["message"], + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="svc.py", line_start=1, line_end=1, col_start=0, col_end=0), + fingerprint=vector_finding["fingerprint"], + qualname=vector_finding["qualname"], + properties=dict(vector_finding["properties"]), + suppressed=SuppressionState.ACTIVE, + ) + projected = wl_legis.project_finding(finding) + assert set(projected) == set(vector_finding), ( + "wardline's projected finding key-set drifted from the shared vector's finding wire" + ) + assert projected["kind"] == vector_finding["kind"] == "defect" + assert projected["suppression_state"] == vector_finding["suppression_state"] == "active" + assert projected["rule_id"] == vector_finding["rule_id"] + assert projected["fingerprint"] == vector_finding["fingerprint"] + # The vector's properties are trust-tier-valued, so wardline's tier filter keeps them. + assert projected["properties"] == vector_finding["properties"] + + +def _legis_source_vector() -> Path | None: + candidates: list[Path] = [] + if env := os.environ.get("LEGIS_REPO"): + candidates.append(Path(env) / "tests" / "contract" / "weft" / "vectors" / "wardline_scan_artifact.v1.json") + candidates.append( + Path(__file__).resolve().parents[3] + / "legis" + / "tests" + / "contract" + / "weft" + / "vectors" + / "wardline_scan_artifact.v1.json" + ) + return next((path for path in candidates if path.exists()), None) + + +@pytest.mark.legis_scan_artifact_drift +def test_vendored_vector_matches_legis_source() -> None: + # Layer-2 (release-gate) drift alarm — excluded from the default suite. When a legis + # checkout is present, the vendored copy must be byte-identical to legis's LIVE source, + # so an intentional legis-side vector change is caught here rather than silently + # diverging behind a stale byte-pin. + source = _legis_source_vector() + if source is None: + pytest.skip("legis repo not found; set LEGIS_REPO to enable the cross-repo drift recheck") + assert _VECTOR_PATH.read_bytes() == source.read_bytes(), ( + "vendored wardline_scan_artifact.v1.json diverged from legis's live source; " + "re-vendor byte-identical and re-pin VENDORED_BLOB_SHA in the same commit" + ) diff --git a/tests/conformance/test_warpline_worklist_drift.py b/tests/conformance/test_warpline_worklist_drift.py new file mode 100644 index 00000000..0b0441fe --- /dev/null +++ b/tests/conformance/test_warpline_worklist_drift.py @@ -0,0 +1,146 @@ +"""Warpline ``reverify_worklist.v1`` wire byte-pin + drift alarm — Wardline as consumer. + +This is the two-layer drift alarm for the **Warpline reverify-worklist** seam +(``warpline.reverify_worklist.v1``). Warpline is the PRODUCER/authority for the +worklist envelope; Wardline is a CONSUMER (the ``wardline scan --affected -`` delta +scope, parsed by :func:`wardline.core.delta_scope.parse_affected_scope`). Warpline +freezes the canonical envelope as a committed contract golden vector +(``tests/fixtures/contracts/warpline/mcp-response-reverify.json``, locked by warpline's +own ``tests/contracts/test_warpline_contract_fixtures.py:: +test_reverify_response_fixture_carries_honesty_fields``). Wardline VENDORS that vector +byte-verbatim and pins it here. + +This is the same two-layer shape as ``test_sei_oracle.py`` (loomweave SEI) and +``test_loomweave_rust_qualname_parity.py`` (rust qualname corpus): + +* **Layer 1 (default suite)** — ``UPSTREAM_BLOB_SHA`` below byte-pins the vendored + copy's git blob hash. ANY byte change to the vendored wire reds the default PR + suite loudly. This is the fail-closed protection that lets the Layer-2 recheck skip + clean when the warpline sibling checkout is absent. +* **Layer 2 (opt-in, ``-m worklist_drift``)** — byte-compares the vendored copy against + warpline's authoritative source (``WARDLINE_WARPLINE_REPO``, default + ``/home/john/warpline``); skips clean when the sibling is absent (CI), FAILS on drift. + +Beyond the pin, one behavior assertion proves Wardline *accepts* this exact wire (not +merely that the bytes match): the vendored envelope parses to ``source_kind= +"reverify_worklist_v1"`` and the load-bearing ``items[].entity.{locator, sei}`` fields +resolve to the expected affected entity. A pure blob pin would lock the bytes without +demonstrating consumption; this ties the pin to the seam it protects. + +The hermetic delta-scope golden (``test_warpline_delta_scope.py``) vendors a faithful +worklist *shape* and pins the seven delta-scope behavior axes; this module pins the +exact upstream-authored *wire bytes* + the drift alarm against the real producer. + +RE-VENDOR PROCEDURE (a release-gate item — run ``pytest -m worklist_drift -v`` before +every release; on drift, or on a deliberate upstream contract bump): + 1. Copy ``$WARDLINE_WARPLINE_REPO/tests/fixtures/contracts/warpline/mcp-response-reverify.json`` + byte-verbatim over the vendored copy. NEVER hand-edit the vendored fixture; + warpline's contract test is the only author. + 2. Update ``UPSTREAM_BLOB_SHA`` to ``git hash-object`` of the vendored file + (equivalently ``hashlib.sha1(b"blob %d\0" % len(data) + data)``) — same commit. + 3. Re-run conformance and CONFORM the consumer + (``wardline.core.delta_scope.parse_affected_scope``) until green; never weaken the + assertions. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from wardline.core.delta_scope import parse_affected_scope + +VENDORED_WIRE = Path(__file__).parent / "fixtures" / "warpline_contract" / "mcp-response-reverify.json" + +# The git blob hash of the vendored reverify-worklist wire as authored upstream by +# warpline (tests/fixtures/contracts/warpline/mcp-response-reverify.json). Warpline is +# the PRODUCER/authority for the ``warpline.reverify_worklist.v1`` envelope; Wardline is +# the CONSUMER and VENDORS the fixture byte-verbatim. This Layer-1 byte-pin runs in the +# DEFAULT PR suite, so ANY byte change to the vendored copy fails loudly — re-vendors are +# deliberate and update this constant in the SAME commit as the new bytes. +UPSTREAM_BLOB_SHA = "dabfc65451f4e9ceab6a5029bd25b6748a73af07" + + +def _warpline_wire_source() -> Path | None: + """Locate warpline's authoritative reverify-wire fixture for the Layer-2 recheck. + + Env takes EXCLUSIVE precedence (first-configured, not first-existing): when + ``WARDLINE_WARPLINE_REPO`` (the sibling-relocation env var, mirroring the + ``WARDLINE_LOOMWEAVE_REPO`` precedent) is set, resolve the fixture ONLY from it and + skip clean if it is absent under that root — the ``parents[3]`` local-dev convenience + checkout (``../warpline`` from the repo root) is consulted ONLY when the env var is + unset. This shares ONE resolution contract with the other ``_drift`` rechecks (see + test_loomweave_qualname_parity.py:150): an operator who points the release-gate env + var at a specific checkout that lacks the file gets a clean skip, never a silent + compare against the local convenience sibling. CI runners (env unset, no sibling) + skip clean — the documented basis for the clean skip is the sibling's ABSENCE on + runners, not a guarantee independent of runner layout.""" + subpath = ("tests", "fixtures", "contracts", "warpline", "mcp-response-reverify.json") + if env := os.environ.get("WARDLINE_WARPLINE_REPO"): + path = Path(env).joinpath(*subpath) + return path if path.exists() else None + path = Path(__file__).resolve().parents[3] / "warpline" / Path(*subpath) + return path if path.exists() else None + + +def test_vendored_wire_matches_upstream_blob_pin() -> None: + """Layer 1 (default suite): the vendored reverify-worklist wire byte-pins to the + upstream git blob hash. ANY edit to the vendored fixture without a matching re-pin + reds the default PR suite — the fail-closed protection that lets the Layer-2 drift + recheck skip clean when the warpline sibling checkout is absent.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = VENDORED_WIRE.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored reverify-worklist wire changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit and re-run " + "conformance; if not, someone edited the vendored copy (forbidden — warpline's contract test " + "is the only author; see the RE-VENDOR PROCEDURE at the top of this module)" + ) + + +def test_vendored_wire_is_accepted_by_the_consumer() -> None: + """The pinned wire is the one Wardline's delta-scope consumer actually accepts: the + vendored ``warpline.reverify_worklist.v1`` envelope parses to ``source_kind= + "reverify_worklist_v1"`` and the load-bearing ``items[].entity.{locator, sei}`` fields + resolve to the affected entity. Ties the byte-pin to consumption, not just bytes.""" + payload = json.loads(VENDORED_WIRE.read_text(encoding="utf-8")) + scope = parse_affected_scope(payload) + + assert scope.source_kind == "reverify_worklist_v1" + assert scope.item_count == 1 + assert len(scope.entities) == 1 + (entity,) = tuple(scope.entities) + assert entity.locator == "python:function:src/pkg/mod.py::fn" + assert entity.sei == "loomweave:eid:0123456789abcdef0123456789abcdef" + + +@pytest.mark.worklist_drift +def test_vendored_wire_matches_warpline_source() -> None: + """Layer 2 (opt-in, ``-m worklist_drift``): the sibling warpline checkout's + authoritative reverify-wire fixture must be BYTE-IDENTICAL to the vendored copy — the + release-gate drift alarm. Absent checkout (CI/default suite) skips clean; divergence + FAILS. + + Byte-exact (not JSON-semantic) by design: the RE-VENDOR PROCEDURE mandates a + byte-verbatim copy and the Layer-1 ``UPSTREAM_BLOB_SHA`` pins the git blob, so a copy + that is reordered/reformatted (JSON-equal but byte-different) would leave the blob-pin + silently stale yet pass a parsed-dict compare. Comparing raw bytes enforces the same + byte-verbatim invariant Layer-1 assumes, matching the loomweave_drift / sei_drift + precedent.""" + source = _warpline_wire_source() + if source is None: + pytest.skip("warpline repo not found; set WARDLINE_WARPLINE_REPO to enable the drift check") + if VENDORED_WIRE.read_bytes() != source.read_bytes(): + pytest.fail( + f"upstream {source} has drifted from the vendored " + "tests/conformance/fixtures/warpline_contract/mcp-response-reverify.json — re-vendor + " + "conform: follow the RE-VENDOR PROCEDURE at the top of this module (byte-verbatim copy, " + "bump UPSTREAM_BLOB_SHA in the same commit, re-run conformance)" + ) diff --git a/tests/conformance/test_weft_reason_vocab_conformance.py b/tests/conformance/test_weft_reason_vocab_conformance.py index 4a45b3c4..f53bc9b3 100644 --- a/tests/conformance/test_weft_reason_vocab_conformance.py +++ b/tests/conformance/test_weft_reason_vocab_conformance.py @@ -21,14 +21,34 @@ * a domain reason maps to a class outside the canonical 11, OR * a FailedFinding stops carrying reason_class / cause / fix on its wire (carrier rule). -The canonical 11 are vendored below (faithful copy of the hub contract) so the guard is -hermetic; when the hub contract is reachable on disk, an extra assertion pins the vendored -copy to it so a hub-side change to the closed set surfaces here too. +The hub contract is VENDORED byte-for-byte at ``tests/conformance/fixtures/weft-reason-vocab.json`` +so the guard is hermetic AND byte-pinned. The conformance shape is the same +``byte_golden_corpus`` consumer pattern as the SEI oracle / Rust qualname corpus: + + * LAYER 1 (default suite, always runs): ``UPSTREAM_BLOB_SHA`` byte-pins the vendored copy + to the upstream git blob hash. ANY edit to the vendored contract without a matching re-pin + reds the default PR suite — the fail-closed protection that lets the Layer-2 drift recheck + skip clean when the hub checkout is absent. The inline canonical-11 frozenset is pinned to + this vendored JSON (always) AND to the live hub when present. + * LAYER 2 (opt-in, ``-m reason_vocab_drift``): byte-compares the vendored copy against the + hub-authoritative contract (``WARDLINE_WEFT_REPO``, default ``/home/john/weft``); skips + clean when the hub checkout is absent (CI), FAILS on drift — the release-gate drift alarm. + +RE-VENDOR PROCEDURE (a release-gate item — run ``pytest -m reason_vocab_drift -v`` before +every release; on drift, or on a deliberate upstream contract bump): + 1. ``cp $WARDLINE_WEFT_REPO/contracts/weft-reason-vocab.json + tests/conformance/fixtures/weft-reason-vocab.json`` — byte-verbatim. NEVER hand-edit the + vendored copy; the weft hub is the only author. + 2. Update ``UPSTREAM_BLOB_SHA`` to ``git hash-object`` of the vendored file in the SAME commit. + 3. Reconcile ``CANONICAL_REASON_CLASSES`` (and the wardline mapping) with the new contract and + re-run conformance until byte-green — conform the member, never weaken the comparison. """ from __future__ import annotations +import hashlib import json +import os from pathlib import Path import pytest @@ -58,12 +78,40 @@ } ) -# The hub contract path (the suite source of truth). Resolved relative to this member's repo -# parent (../../../../weft/contracts/...) so a co-located ``weft`` checkout is found; the -# vendored copy above keeps the guard hermetic when the hub is not on disk. -# this file: //wardline/tests/conformance/ -# parents: 0=conformance 1=tests 2=wardline 3= -> sibling weft/ lives at parents[3]/weft -_HUB_CONTRACT = Path(__file__).resolve().parents[3] / "weft" / "contracts" / "weft-reason-vocab.json" +# The vendored hub contract (byte-for-byte copy of contracts/weft-reason-vocab.json). The +# Layer-1 byte-pin below freezes this file's git blob so any edit reds the default suite. +_VENDORED_CONTRACT = Path(__file__).parent / "fixtures" / "weft-reason-vocab.json" + +# The git blob hash of the vendored contract as committed upstream (weft hub +# contracts/weft-reason-vocab.json, version 1). Re-vendors update this constant in the SAME +# commit as the new bytes — see the RE-VENDOR PROCEDURE in this module's header. +UPSTREAM_BLOB_SHA = "948f1d4b334fcedebd40449aa10b750bd3eed216" + + +def _hub_contract_source() -> Path | None: + """The hub-authoritative contract path for the Layer-2 drift recheck, or None when no hub + checkout is reachable. Honors ``WARDLINE_WEFT_REPO`` first (the sibling-relocation env var, + mirroring the ``WARDLINE_LOOMWEAVE_REPO`` precedent), then the conventional ``/home/john/weft`` + absolute, then the local-dev ``../weft`` sibling relative to this repo root. + this file: //wardline/tests/conformance/ + parents: 0=conformance 1=tests 2=wardline 3= -> sibling weft/ lives at parents[3]/weft + """ + # Env takes EXCLUSIVE precedence (first-configured, not first-existing): when + # ``WARDLINE_WEFT_REPO`` is set, resolve the hub contract ONLY from it and skip + # clean if it is absent under that root — the conventional ``/home/john/weft`` + # and the local-dev ``../weft`` convenience checkouts are consulted ONLY when + # the env var is unset. This shares ONE resolution contract with the other + # ``_drift`` rechecks (see test_loomweave_qualname_parity.py:150): an operator + # who points the release-gate env var at a specific checkout that lacks the + # file gets a clean skip, never a silent compare against a convenience sibling. + if env := os.environ.get("WARDLINE_WEFT_REPO"): + path = Path(env) / "contracts" / "weft-reason-vocab.json" + return path if path.is_file() else None + fallbacks = ( + Path("/home/john/weft") / "contracts" / "weft-reason-vocab.json", + Path(__file__).resolve().parents[3] / "weft" / "contracts" / "weft-reason-vocab.json", + ) + return next((path for path in fallbacks if path.is_file()), None) def test_vendored_canonical_set_is_exactly_eleven() -> None: @@ -72,22 +120,61 @@ def test_vendored_canonical_set_is_exactly_eleven() -> None: assert len(CANONICAL_REASON_CLASSES) == 11 -def test_vendored_set_matches_hub_contract_when_present() -> None: - # When the suite hub is checked out alongside this member, pin the vendored copy to the - # real contract so a hub-side change to the closed set fails here instead of going unnoticed. - if not _HUB_CONTRACT.exists(): - pytest.skip(f"hub contract not present at {_HUB_CONTRACT}; vendored copy is authoritative") - contract = json.loads(_HUB_CONTRACT.read_text("utf-8")) - hub_classes = frozenset(contract["reason_classes"]) - assert hub_classes == CANONICAL_REASON_CLASSES, ( - "vendored canonical reason_class set has drifted from the hub contract " - f"({_HUB_CONTRACT}); reconcile this test with the contract." +def test_vendored_contract_matches_upstream_blob_pin() -> None: + # Layer 1 (default suite, always runs): the vendored contract byte-pins to the upstream git + # blob hash. ANY edit to the vendored copy without a matching re-pin reds the default PR + # suite — the fail-closed protection that lets the Layer-2 drift recheck skip clean when the + # hub checkout is absent. + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = _VENDORED_CONTRACT.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored weft-reason contract changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit and re-run " + "conformance; if not, someone edited the vendored copy (forbidden — the weft hub is the only " + "author; see the RE-VENDOR PROCEDURE at the top of this module)" + ) + + +def test_inline_set_matches_vendored_contract() -> None: + # Always-runs hermetic pin: the inline canonical-11 frozenset must match the vendored JSON's + # reason_classes exactly, and the carrier rule it carries. A hub-side bump re-vendored into + # the JSON without reconciling the inline mirror reds here even on a bare checkout (no hub). + contract = json.loads(_VENDORED_CONTRACT.read_text("utf-8")) + assert frozenset(contract["reason_classes"]) == CANONICAL_REASON_CLASSES, ( + "inline CANONICAL_REASON_CLASSES drifted from the vendored " + f"{_VENDORED_CONTRACT.name}; reconcile per the RE-VENDOR PROCEDURE." ) carrier = contract["carrier"] assert set(carrier["required_on_non_clean"]) == {"reason_class", "cause", "fix"} assert set(carrier["clean_omits"]) == {"cause", "fix"} +@pytest.mark.reason_vocab_drift +def test_vendored_contract_matches_hub_source() -> None: + # Layer 2 (opt-in, ``-m reason_vocab_drift``): the hub-authoritative contract must be + # BYTE-IDENTICAL to the vendored copy — the release-gate drift alarm. Absent hub checkout + # (CI/default suite) skips clean; divergence FAILS. + # + # Byte-exact (not JSON-semantic) by design: the RE-VENDOR PROCEDURE mandates a byte-verbatim + # copy and Layer-1 pins the git blob, so a reordered/reformatted (JSON-equal byte-different) + # copy would leave the blob-pin silently stale yet pass a parsed-dict compare. Raw-byte + # comparison enforces the same invariant Layer-1 assumes (the sei_drift/loomweave_drift + # precedent). + source = _hub_contract_source() + if source is None: + pytest.skip("weft hub contract not found; set WARDLINE_WEFT_REPO to enable the drift check") + if _VENDORED_CONTRACT.read_bytes() != source.read_bytes(): + pytest.fail( + f"upstream {source} has drifted from the vendored " + "tests/conformance/fixtures/weft-reason-vocab.json — re-vendor + conform: follow the " + "RE-VENDOR PROCEDURE at the top of this module (byte-verbatim copy, bump UPSTREAM_BLOB_SHA " + "in the same commit, re-run conformance)" + ) + + def test_every_shipped_reason_maps_to_a_canonical_class() -> None: # The member's actual reason vocabulary is the shipped closed set ``_FAILURE_REASONS``. # Every member of it MUST have a canonical mapping, and every mapped class MUST be one of diff --git a/tests/conformance/vectors/wardline_scan_artifact.v1.json b/tests/conformance/vectors/wardline_scan_artifact.v1.json new file mode 100644 index 00000000..fd4b21be --- /dev/null +++ b/tests/conformance/vectors/wardline_scan_artifact.v1.json @@ -0,0 +1,107 @@ +{ + "contract": "weft/wardline-scan-artifact", + "version": 1, + "description": "Shared Weft conformance vector for the Wardline->legis signed scan-artifact wire contract. The PRODUCER (wardline core/legis.py) and every CONSUMER (legis wardline/ingest.py) load this SAME file in CI. A rename on either side that drifts the wire shape fails a vector here instead of silently breaking the cross-member defect flow. Root cause #2 of the Weft incident 2026-06-10: hand-transcribed contracts with no shared test. Covers G1 (findings-key presence) and the G1 twin (kind-value vocabulary).", + "findings_key": "findings", + "known_kinds": ["defect", "fact", "classification", "metric", "suggestion"], + "defect_kind": "defect", + "signing": { + "key_utf8": "test-shared-secret-key", + "scheme": "hmac-sha256:v2", + "covers": "canonical_json(artifact MINUS the 'artifact_signature' key) — ensure_ascii=False, sorted keys, compact (\",\",\":\") separators, then HMAC-SHA256 hex prefixed 'hmac-sha256:v2:'", + "note": "expected_signature pins the byte-exact cross-impl HMAC. If either side's canonical-JSON+HMAC formula diverges, the signature stops reproducing here — caught in CI, never in prod. The hex is identical to wardline's golden (wardline/tests/unit/core/test_legis_artifact.py)." + }, + "valid": [ + { + "name": "golden_single_active_defect", + "description": "The authoritative signed golden: one active defect. A consumer's signer MUST reproduce expected_signature byte-for-byte; active_defects selects exactly this finding.", + "artifact": { + "scanner_identity": "wardline@1.0.0rc1", + "rule_set_version": "sha256:deadbeef", + "commit_sha": "cccccccccccccccccccccccccccccccccccccccc", + "tree_sha": "tttttttttttttttttttttttttttttttttttttttt", + "findings": [ + { + "rule_id": "PY-WL-101", + "message": "leak", + "severity": "ERROR", + "kind": "defect", + "fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "qualname": "svc.leaky", + "properties": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW"}, + "suppression_state": "active" + } + ] + }, + "expected_signature": "hmac-sha256:v2:2b2cf09548572b58fd01c359d1b6a16c3c1181f1cbfe8e4f5ada6fcd21f35ac4", + "expected_active_fingerprints": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + }, + { + "name": "clean_scan_empty_findings", + "description": "G1 over-correction guard: a genuinely clean scan carries findings:[] (key PRESENT, list empty) and ingests cleanly with zero active defects — it must NOT be confused with the absent-key drift case.", + "artifact": {"findings": []}, + "expected_active_fingerprints": [] + }, + { + "name": "known_non_defect_kinds_are_excluded", + "description": "G1 twin over-correction guard: every known NON-defect kind is legitimately not in the gate population — skipped, never rejected.", + "artifact": { + "findings": [ + {"rule_id": "WLN-M1", "message": "telemetry", "severity": "NONE", "kind": "metric", "fingerprint": "m1", "suppression_state": "active"}, + {"rule_id": "WLN-F1", "message": "engine fact", "severity": "NONE", "kind": "fact", "fingerprint": "f1", "suppression_state": "active"}, + {"rule_id": "WLN-C1", "message": "classification", "severity": "NONE", "kind": "classification", "fingerprint": "c1", "suppression_state": "active"}, + {"rule_id": "WLN-S1", "message": "hint", "severity": "INFO", "kind": "suggestion", "fingerprint": "s1", "suppression_state": "active"} + ] + }, + "expected_active_fingerprints": [] + } + ], + "invalid": [ + { + "name": "absent_findings_key", + "description": "G1: no findings key at all is drift/tamper (a rename leaves it absent). Must reject — never read as zero defects under a green status.", + "artifact": {"scanner_identity": "wardline@1.0.0rc1"}, + "reject_match": "findings" + }, + { + "name": "renamed_findings_key", + "description": "G1: a real CRITICAL defect arrives under a renamed batch key. The consumer must reject the payload, not route zero defects.", + "artifact": { + "findings_list": [ + {"rule_id": "PY-WL-900", "message": "sqli", "severity": "CRITICAL", "kind": "defect", "fingerprint": "sqli", "suppression_state": "active"} + ] + }, + "reject_match": "findings" + }, + { + "name": "drifted_defect_kind_value", + "description": "G1 twin (value axis): a defect whose kind token drifted out of the Wardline vocabulary (e.g. 'defect'->'vulnerability', re-signed HMAC-clean) must be LOUD, never silently skipped out of the gate population.", + "artifact": { + "findings": [ + {"rule_id": "PY-WL-901", "message": "rce", "severity": "CRITICAL", "kind": "vulnerability", "fingerprint": "rce", "suppression_state": "active"} + ] + }, + "reject_match": "kind" + }, + { + "name": "unknown_suppression_state", + "description": "A defect carrying an out-of-vocabulary suppression_state is malformed and rejected.", + "artifact": { + "findings": [ + {"rule_id": "PY-WL-902", "message": "m", "severity": "ERROR", "kind": "defect", "fingerprint": "x", "suppression_state": "haunted"} + ] + }, + "reject_match": "unsupported suppression state" + }, + { + "name": "waived_defect_without_proof", + "description": "An agent-initiated waiver with no suppression proof anywhere is rejected — an agent must not be able to silently dismiss a defect.", + "artifact": { + "findings": [ + {"rule_id": "PY-WL-903", "message": "m", "severity": "ERROR", "kind": "defect", "fingerprint": "w", "suppression_state": "waived"} + ] + }, + "reject_match": "suppression proof" + } + ] +} diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index 6db5b0ad..0eed03b9 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -34,43 +34,43 @@ ("src/wardline/core/run.py", 110, "gate_findings:"), ("src/wardline/core/run.py", 152, "class GateDecision"), ("src/wardline/core/run.py", 162, "verdict: str"), - ("src/wardline/core/run.py", 486, "Baseline(frozenset())"), - ("src/wardline/core/run.py", 496, "def apply_delta_scope"), - ("src/wardline/core/run.py", 551, "active=sum"), - ("src/wardline/core/run.py", 643, "honors_suppressions"), + ("src/wardline/core/run.py", 485, "Baseline(frozenset())"), + ("src/wardline/core/run.py", 495, "def apply_delta_scope"), + ("src/wardline/core/run.py", 550, "active=sum"), + ("src/wardline/core/run.py", 642, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 565, "suppressed"), - ("src/wardline/cli/scan.py", 566, "{s.active} active"), - ("src/wardline/cli/scan.py", 618, "gate: FAILED"), + ("src/wardline/cli/scan.py", 568, "suppressed"), + ("src/wardline/cli/scan.py", 569, "{s.active} active"), + ("src/wardline/cli/scan.py", 621, "gate: FAILED"), # src/wardline/mcp/server.py — MCP scan summary + gate block - ("src/wardline/mcp/server.py", 1009, '"total": result.summary.total'), - ("src/wardline/mcp/server.py", 1010, '"active": result.summary.active'), - ("src/wardline/mcp/server.py", 1011, '"baselined": result.summary.baselined'), - ("src/wardline/mcp/server.py", 1012, '"waived": result.summary.waived'), - ("src/wardline/mcp/server.py", 1013, '"judged": result.summary.judged'), - ("src/wardline/mcp/server.py", 1018, '"informational": result.summary.informational'), - ("src/wardline/mcp/server.py", 1022, '"unanalyzed": result.summary.unanalyzed'), - ("src/wardline/mcp/server.py", 1024, '"gate": {'), - ("src/wardline/mcp/server.py", 1025, '"tripped": decision.tripped'), - ("src/wardline/mcp/server.py", 1029, '"verdict": decision.verdict'), + ("src/wardline/mcp/server.py", 926, '"total": result.summary.total'), + ("src/wardline/mcp/server.py", 927, '"active": result.summary.active'), + ("src/wardline/mcp/server.py", 928, '"baselined": result.summary.baselined'), + ("src/wardline/mcp/server.py", 929, '"waived": result.summary.waived'), + ("src/wardline/mcp/server.py", 930, '"judged": result.summary.judged'), + ("src/wardline/mcp/server.py", 935, '"informational": result.summary.informational'), + ("src/wardline/mcp/server.py", 939, '"unanalyzed": result.summary.unanalyzed'), + ("src/wardline/mcp/server.py", 941, '"gate": {'), + ("src/wardline/mcp/server.py", 942, '"tripped": decision.tripped'), + ("src/wardline/mcp/server.py", 946, '"verdict": decision.verdict'), # src/wardline/core/agent_summary.py — agent-summary JSON keys - ("src/wardline/core/agent_summary.py", 139, '"total_findings"'), - ("src/wardline/core/agent_summary.py", 140, '"active_defects"'), - ("src/wardline/core/agent_summary.py", 141, '"suppressed_findings"'), - ("src/wardline/core/agent_summary.py", 143, '"baselined"'), - ("src/wardline/core/agent_summary.py", 144, '"waived"'), - ("src/wardline/core/agent_summary.py", 145, '"judged"'), - ("src/wardline/core/agent_summary.py", 151, '"informational"'), - ("src/wardline/core/agent_summary.py", 152, '"unanalyzed"'), - ("src/wardline/core/agent_summary.py", 155, '"tripped": self.gate.tripped'), - ("src/wardline/core/agent_summary.py", 158, '"verdict": self.gate.verdict'), + ("src/wardline/core/agent_summary.py", 128, '"total_findings"'), + ("src/wardline/core/agent_summary.py", 129, '"active_defects"'), + ("src/wardline/core/agent_summary.py", 130, '"suppressed_findings"'), + ("src/wardline/core/agent_summary.py", 132, '"baselined"'), + ("src/wardline/core/agent_summary.py", 133, '"waived"'), + ("src/wardline/core/agent_summary.py", 134, '"judged"'), + ("src/wardline/core/agent_summary.py", 140, '"informational"'), + ("src/wardline/core/agent_summary.py", 141, '"unanalyzed"'), + ("src/wardline/core/agent_summary.py", 144, '"tripped": self.gate.tripped'), + ("src/wardline/core/agent_summary.py", 147, '"verdict": self.gate.verdict'), # informational display array (new, W3 residual fix) - ("src/wardline/core/agent_summary.py", 176, '"informational": informational'), + ("src/wardline/core/agent_summary.py", 165, '"informational": informational'), # per-finding suppression_state output key (renamed from `suppressed`, weft-f506e5f845) - ("src/wardline/core/finding.py", 140, '"suppression_state"'), - ("src/wardline/core/finding.py", 295, 'wardline["suppression_state"]'), + ("src/wardline/core/finding.py", 145, '"suppression_state"'), + ("src/wardline/core/finding.py", 305, 'wardline["suppression_state"]'), # stable-file anchors (lower churn, but locked for free) - ("src/wardline/core/finding.py", 72, 'ACTIVE = "active"'), + ("src/wardline/core/finding.py", 77, 'ACTIVE = "active"'), ("src/wardline/core/suppression.py", 24, "SuppressionState.BASELINED"), ) diff --git a/tests/e2e/test_loomweave_live.py b/tests/e2e/test_loomweave_live.py index 0525a6e4..2d5a739e 100644 --- a/tests/e2e/test_loomweave_live.py +++ b/tests/e2e/test_loomweave_live.py @@ -68,8 +68,16 @@ def _write_loomweave_config(config: Path) -> None: ) +# Loomweave's tracing layer emits ANSI SGR colour codes around the structured +# ``bind=127.0.0.1:PORT`` field (e.g. ``bind\x1b[0m\x1b[2m=\x1b[0m127.0.0.1:40299`` +# in loomweave >=1.3), which split the literal ``bind=`` apart. Strip ANSI escape +# sequences before matching so the port parse survives colourised output. +_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;]*m") + + def _base_url_from_loomweave_log(text: str) -> str | None: - match = re.search(r"\bbind=127\.0\.0\.1:(?P[1-9][0-9]*)\b", text) + clean = _ANSI_SGR_RE.sub("", text) + match = re.search(r"\bbind=127\.0\.0\.1:(?P[1-9][0-9]*)\b", clean) return f"http://127.0.0.1:{match.group('port')}" if match else None @@ -399,6 +407,74 @@ def test_taint_read_by_sei_against_live_loomweave(loomweave_server: tuple[Path, assert bogus[0].exists is False +def test_qualname_parity_against_live_loomweave(loomweave_server: tuple[Path, str]) -> None: + """Fail-closed LIVE qualname-parity oracle (the seam's missing leg). + + The hermetic byte-frozen corpus + the ``loomweave_drift`` byte-pin recheck catch + *frozen-corpus* drift; they cannot catch a LIVE loomweave whose qualname dialect + has moved out from under the vendored fixture. This asserts that the qualname + Wardline MINTS from its OWN producer path (``module_dotted_name`` + the symbol's + ``__qualname__``) is exactly what a real ``loomweave serve`` accepts and echoes + back as the canonical segment-3 of its locator — so the skip-clean drift recheck + is backed by a fail-closed live assertion at the release gate. + + Coverage scope — what IS and is NOT exercised live: only the MODULE half of the + qualname is computed by Wardline's normalization (``module_dotted_name("svc.py")`` + — the src-strip / .py-drop / __init__-collapse dialect, the part most likely to + drift). The symbol-suffix half (``read_raw`` / ``leaky``) is a hardcoded literal, + trivial-by-construction, NOT minted from a symbol's ``__qualname__``; the + ```` / nested-class ``reconstruct_qualname`` machinery is therefore NOT + exercised by this live oracle (it is pinned offline by the vendored corpus + vectors). ``resolve`` must report the composed qualnames RESOLVED + (``unresolved == []``) — the entities provably exist in the analyzed fixture, so + an unresolved qualname is live module-dialect drift and FAILS (never a fall-back + to a literal). The byte-equality of segment-3 is the belt-and-suspenders leg in + case loomweave ever resolved fuzzily; for the symbol suffix it is trivially true + by construction (same literal on both sides) — the load-bearing live signal is + the module-dialect half.""" + proj, url = loomweave_server + from wardline.core.qualname import module_dotted_name + from wardline.loomweave.client import LoomweaveClient + from wardline.loomweave.config import load_loomweave_token, resolve_project_name + + client = LoomweaveClient(url, secret=load_loomweave_token(proj), project=resolve_project_name(proj)) + + # Wardline MINTS the qualnames from its own producer path. svc.py defines two + # top-level functions; their qualname is f"{module}.{__qualname__}", and the + # module dialect (one src/-strip, .py-drop, __init__-collapse) is computed here. + module = module_dotted_name("svc.py") + assert module == "svc" # the producer dialect, exercised — not assumed + wardline_qualnames = [f"{module}.read_raw", f"{module}.leaky"] + + rr = client.resolve(wardline_qualnames) + # Reachable (sibling is up by fixture contract); a None here would be an outage, + # which under the loomweave_e2e marker fails closed via the conftest skip-arm only + # if it skipped — here it must be a live answer, so assert non-None outright. + assert rr is not None, "live loomweave resolve was unreachable mid-fixture" + + # The parity signal: EVERY Wardline-minted qualname resolves. A populated + # `unresolved` is the live dialect drift alarm firing — fail closed, do not skip. + assert rr.unresolved == [], ( + f"live loomweave did not resolve Wardline-minted qualnames {rr.unresolved!r} — " + "the qualname dialects have drifted (Wardline producer vs live loomweave authority)" + ) + assert set(rr.resolved) == set(wardline_qualnames), ( + f"resolved keys {sorted(rr.resolved)} != minted qualnames {sorted(wardline_qualnames)}" + ) + + # Belt-and-suspenders: loomweave's locator carries its AUTHORITATIVE canonical + # qualified name in segment-3 ({plugin}:{kind}:{qualified_name}); it must byte-equal + # the Wardline-minted qualname, proving the dialects agree character-for-character. + for qn in wardline_qualnames: + locator = rr.resolved[qn] + parts = locator.split(":", 2) + assert len(parts) == 3, f"unexpected locator shape for {qn!r}: {locator!r}" + assert parts[2] == qn, ( + f"loomweave canonical qualname {parts[2]!r} (segment-3 of {locator!r}) " + f"!= Wardline-minted qualname {qn!r} — live qualname-dialect drift" + ) + + def test_published_ephemeral_port_resolves_live_url(loomweave_server: tuple[Path, str]) -> None: """ADR-044 (consumer half, live wire): a running serve publishes ``.loomweave/ephemeral.port``, and ``resolve_loomweave_url`` self-heals to it. diff --git a/tests/golden/identity/corpus/META.json b/tests/golden/identity/corpus/META.json index 1ee7f124..5378e6b9 100644 --- a/tests/golden/identity/corpus/META.json +++ b/tests/golden/identity/corpus/META.json @@ -1,5 +1,5 @@ { - "corpus_version": 4, + "corpus_version": 6, "fingerprint_scheme": "wlfp2", - "reason": "record sink call spans in finding locations" + "reason": "call-site full-span discriminator" } diff --git a/tests/golden/identity/corpus/sampleapp.json b/tests/golden/identity/corpus/sampleapp.json index 7b2673b5..02a3c4dc 100644 --- a/tests/golden/identity/corpus/sampleapp.json +++ b/tests/golden/identity/corpus/sampleapp.json @@ -543,7 +543,7 @@ ], "explain": { "explanation": { - "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f", + "fingerprint": "543d44851d3cf30d567d1a9c8a995be6ae3a56eb7c28110ec4c4d6c26ef807c1", "immediate_tainted_callee": "read_raw_username", "line": 41, "path": "trust_flow.py", @@ -556,7 +556,7 @@ "tier_out": "ASSURED", "unresolved_call_count": 0 }, - "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f" + "fingerprint": "543d44851d3cf30d567d1a9c8a995be6ae3a56eb7c28110ec4c4d6c26ef807c1" }, "facts": [ { @@ -1826,7 +1826,7 @@ }, "findings": [ { - "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f", + "fingerprint": "543d44851d3cf30d567d1a9c8a995be6ae3a56eb7c28110ec4c4d6c26ef807c1", "line_start": 41, "path": "trust_flow.py", "rule_id": "PY-WL-101" @@ -1874,7 +1874,7 @@ "findings": [ { "confidence": null, - "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f", + "fingerprint": "543d44851d3cf30d567d1a9c8a995be6ae3a56eb7c28110ec4c4d6c26ef807c1", "kind": "defect", "location": { "col_end": 34, @@ -1972,7 +1972,7 @@ "text": "trust_flow.unsafe_account_key declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f" + "wardlineFingerprint/v2": "543d44851d3cf30d567d1a9c8a995be6ae3a56eb7c28110ec4c4d6c26ef807c1" }, "properties": { "internalSeverity": "ERROR", diff --git a/tests/golden/identity/corpus/sinks.json b/tests/golden/identity/corpus/sinks.json index 0545104e..4e4e67e2 100644 --- a/tests/golden/identity/corpus/sinks.json +++ b/tests/golden/identity/corpus/sinks.json @@ -123,7 +123,7 @@ ], "explain": { "explanation": { - "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190", + "fingerprint": "7140795575283f8b575692cd6e9af4b72cd80425aa756c2b30fe283808154c29", "immediate_tainted_callee": "pickle.loads", "line": 31, "path": "wardline_sinks.py", @@ -136,7 +136,7 @@ "tier_out": "ASSURED", "unresolved_call_count": 2 }, - "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190" + "fingerprint": "7140795575283f8b575692cd6e9af4b72cd80425aa756c2b30fe283808154c29" }, "facts": [ { @@ -154,7 +154,7 @@ }, "findings": [ { - "fingerprint": "98f171afa38cd679d03c22f6e6e3666a58ded53adc51db37e91405c6798cdbd0", + "fingerprint": "9f280c91ad5a5ee16090245c78b79a79ba667a6048b5d0003d1656eab4debfef", "line_start": 60, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" @@ -187,7 +187,7 @@ }, "findings": [ { - "fingerprint": "4c9d679f189c499a382f5765820958e5ae1c0875064c0b0bd800a26559af7a9c", + "fingerprint": "1aeda691782ea37618b889b63fb75e18db41b1530d11e05de9cad8191c9c2538", "line_start": 65, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" @@ -220,19 +220,19 @@ }, "findings": [ { - "fingerprint": "2b283fa70a2a9d37ed414ef8f1140a60571eae6b0c79d373c6bd50764ed914eb", + "fingerprint": "ecd0660fc2f531f755ef1ca46bc8456cdfb48af392744aa2ee2ae2583de67068", "line_start": 88, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "1f9174a59ff725857a5574eb57a4f1b31f7212ece7589d1b46721f2fd31edc9d", + "fingerprint": "1f8fdcf9972ad1748bb5ee8ce8ba9496186f811d5f2d1891f7783b7bdaf79e2c", "line_start": 95, "path": "wardline_sinks.py", "rule_id": "PY-WL-118" }, { - "fingerprint": "41d07bddca738298bd5d85c8db731766f44812033f01af7bc7d83941b2073759", + "fingerprint": "a2de51062f5d833bbf3954abcdd1e3fc84b2875ff7c7be035191130d09cecd99", "line_start": 95, "path": "wardline_sinks.py", "rule_id": "PY-WL-118" @@ -265,19 +265,19 @@ }, "findings": [ { - "fingerprint": "411c3b7a79ca5aa3df4d8abfcacfcc987ffc12a3797c8c70340ccd90c88eef24", + "fingerprint": "a0f9157dd198ae576148bb5b5b1f175a456f14ff782ce62057d0508734bfa2a6", "line_start": 79, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "3d8057c1001d1a882d3c85dbe04379d2a25bc6a4b5fca842497b19fddc4609fa", + "fingerprint": "007ba30e97b15fd8055ac7c652be4b2021adda2642d1d3fa3cafc61ac1778d5e", "line_start": 84, "path": "wardline_sinks.py", "rule_id": "PY-WL-106" }, { - "fingerprint": "8b74d4819f60cc2f30acecedbf63b69dd512f00178f19e3e43ee4d4b6026444f", + "fingerprint": "4786a059c48494884be9adf0724a74c3151c58993afd127be1deec71be3035cd", "line_start": 84, "path": "wardline_sinks.py", "rule_id": "PY-WL-106" @@ -310,25 +310,25 @@ }, "findings": [ { - "fingerprint": "5b727e8b47a7e7216468308658740001d5062d4292ce012f03a50ef96e523cbb", + "fingerprint": "27ec1873b1ae31dbe18a43165431a450058d15c76cbb45f598e232dc177eb6a6", "line_start": 75, "path": "wardline_sinks.py", "rule_id": "PY-WL-105" }, { - "fingerprint": "a94cef80695a44e753a0f5dc71d632fb3d329a10da3acc62dc2f4151d8856179", + "fingerprint": "73b99d4b3e6ff105346ab83c64d5fa6de278d375bee80beea31875ffa269ef4b", "line_start": 75, "path": "wardline_sinks.py", "rule_id": "PY-WL-105" }, { - "fingerprint": "272b6408714850250d4890e81911e3db808b0aa8f894db916d5a0f73a4670215", + "fingerprint": "65f131f25419060a668c645a576dff8111d2999e229e8e99e08bd51293288c15", "line_start": 75, "path": "wardline_sinks.py", "rule_id": "PY-WL-120" }, { - "fingerprint": "8d45425ca46245baa235e857f42513c1e77cd3e7b2bb0b19fc65ab3687f25e8a", + "fingerprint": "a815a406d510a2ff878803617c98d642f6ab7b39e06dbb372435918919288f21", "line_start": 75, "path": "wardline_sinks.py", "rule_id": "PY-WL-120" @@ -361,13 +361,13 @@ }, "findings": [ { - "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190", + "fingerprint": "7140795575283f8b575692cd6e9af4b72cd80425aa756c2b30fe283808154c29", "line_start": 31, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "5ee6a352fd2713e30f8d3a8d5d2f374dfe486b5dc2a0299e77975bf4c9ea14bc", + "fingerprint": "a8d833be3f2852428f0d7ef8bb89918f47dc4ce69f58166c98678cc79f5be15f", "line_start": 34, "path": "wardline_sinks.py", "rule_id": "PY-WL-106" @@ -400,13 +400,13 @@ }, "findings": [ { - "fingerprint": "9840134f9529e2230a0f32f55afd1b7c47cdf9fc0fcb1a46348b9bd6059e8963", + "fingerprint": "19a4c988c03a8a87c1742001812a14d2e17b4e915aadfe88ad5a63f781147d71", "line_start": 45, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "0dcfc65cd0c5381cbd62bc4646ea5b34f358be072431e3877213362e587cf8e9", + "fingerprint": "dcb49d57938f6ac07d9203c095c0ed610c2033dde6e63b7cf75a6562fb49cffd", "line_start": 48, "path": "wardline_sinks.py", "rule_id": "PY-WL-115" @@ -439,19 +439,19 @@ }, "findings": [ { - "fingerprint": "954c291d52c66289019d668b49fd63a3fca2f43219278855b892174bb0a97582", + "fingerprint": "56dfc9315ec8189ee2c34139e7c6242e41f2794ad24d7eda29c3cd46ffea4957", "line_start": 111, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "5d3200b5f9d4418398cda8f3f830f716794dae3c1e0a3ec97af0f526735dbee9", + "fingerprint": "027bb1beb78ab6aa09620acc62e39055e56324cd3af714724ed1bac2fb7bf751", "line_start": 118, "path": "wardline_sinks.py", "rule_id": "PY-WL-118" }, { - "fingerprint": "b85f44e814c9271c6cd3129c2146bdf9b50e87c312cf862b9aa62c5b3f5c5768", + "fingerprint": "81ae07ca0f1b9be894a643637901e3b8fcc434b1cc086b309d7b6e2fed1efea6", "line_start": 119, "path": "wardline_sinks.py", "rule_id": "PY-WL-120" @@ -484,19 +484,19 @@ }, "findings": [ { - "fingerprint": "5426cf7f120d932b6d6f5e6894068278aed09df543f91602d568a850b121a972", + "fingerprint": "5d30b5aad08e35d0a6414f2e513c8eaab6b37e0da29c77ccfe08c0e70aa2844f", "line_start": 52, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "9284a27b61ef23301fa372ac36c6385ec711fd26ed22a455eeea993981d5002e", + "fingerprint": "0649daf6e7c1fd3b491a513862f1f3c55be239ff2426f7199c7c72ac7963f791", "line_start": 55, "path": "wardline_sinks.py", "rule_id": "PY-WL-116" }, { - "fingerprint": "1840d80d73b71ae6d77a5399229adb8b644508a7e8d4f400236a8a698a60a364", + "fingerprint": "b8d104e6587abeec86d889d391c0c6a67bfb6b79b7807dbacb94ab755246acce", "line_start": 56, "path": "wardline_sinks.py", "rule_id": "PY-WL-120" @@ -555,13 +555,13 @@ }, "findings": [ { - "fingerprint": "235565113b5cbb65f508f16155d4ac0bba3624076b23d08fb7357bdc1738a847", + "fingerprint": "6f20c968eac6b5cb7c53ec43dbd9825ed1bb42c6aac31dd1ce7ebaa0eeec49e9", "line_start": 38, "path": "wardline_sinks.py", "rule_id": "PY-WL-101" }, { - "fingerprint": "13c25150c1e15425e6ae5f9d99f67ec09dd5ffb4b60ac49e20367a0ad6240c6f", + "fingerprint": "7b306b0603f82ac94b0174ccaa82e305032e7a79382d9979bd27eaebf7406d93", "line_start": 41, "path": "wardline_sinks.py", "rule_id": "PY-WL-112" @@ -620,7 +620,7 @@ "findings": [ { "confidence": null, - "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190", + "fingerprint": "7140795575283f8b575692cd6e9af4b72cd80425aa756c2b30fe283808154c29", "kind": "defect", "location": { "col_end": 29, @@ -645,7 +645,7 @@ }, { "confidence": null, - "fingerprint": "5ee6a352fd2713e30f8d3a8d5d2f374dfe486b5dc2a0299e77975bf4c9ea14bc", + "fingerprint": "a8d833be3f2852428f0d7ef8bb89918f47dc4ce69f58166c98678cc79f5be15f", "kind": "defect", "location": { "col_end": 29, @@ -671,7 +671,7 @@ }, { "confidence": null, - "fingerprint": "235565113b5cbb65f508f16155d4ac0bba3624076b23d08fb7357bdc1738a847", + "fingerprint": "6f20c968eac6b5cb7c53ec43dbd9825ed1bb42c6aac31dd1ce7ebaa0eeec49e9", "kind": "defect", "location": { "col_end": 55, @@ -696,7 +696,7 @@ }, { "confidence": null, - "fingerprint": "13c25150c1e15425e6ae5f9d99f67ec09dd5ffb4b60ac49e20367a0ad6240c6f", + "fingerprint": "7b306b0603f82ac94b0174ccaa82e305032e7a79382d9979bd27eaebf7406d93", "kind": "defect", "location": { "col_end": 55, @@ -722,7 +722,7 @@ }, { "confidence": null, - "fingerprint": "9840134f9529e2230a0f32f55afd1b7c47cdf9fc0fcb1a46348b9bd6059e8963", + "fingerprint": "19a4c988c03a8a87c1742001812a14d2e17b4e915aadfe88ad5a63f781147d71", "kind": "defect", "location": { "col_end": 40, @@ -747,7 +747,7 @@ }, { "confidence": null, - "fingerprint": "0dcfc65cd0c5381cbd62bc4646ea5b34f358be072431e3877213362e587cf8e9", + "fingerprint": "dcb49d57938f6ac07d9203c095c0ed610c2033dde6e63b7cf75a6562fb49cffd", "kind": "defect", "location": { "col_end": 40, @@ -773,7 +773,7 @@ }, { "confidence": null, - "fingerprint": "5426cf7f120d932b6d6f5e6894068278aed09df543f91602d568a850b121a972", + "fingerprint": "5d30b5aad08e35d0a6414f2e513c8eaab6b37e0da29c77ccfe08c0e70aa2844f", "kind": "defect", "location": { "col_end": 24, @@ -798,7 +798,7 @@ }, { "confidence": null, - "fingerprint": "9284a27b61ef23301fa372ac36c6385ec711fd26ed22a455eeea993981d5002e", + "fingerprint": "0649daf6e7c1fd3b491a513862f1f3c55be239ff2426f7199c7c72ac7963f791", "kind": "defect", "location": { "col_end": 37, @@ -824,7 +824,7 @@ }, { "confidence": null, - "fingerprint": "1840d80d73b71ae6d77a5399229adb8b644508a7e8d4f400236a8a698a60a364", + "fingerprint": "b8d104e6587abeec86d889d391c0c6a67bfb6b79b7807dbacb94ab755246acce", "kind": "defect", "location": { "col_end": null, @@ -848,7 +848,7 @@ }, { "confidence": null, - "fingerprint": "98f171afa38cd679d03c22f6e6e3666a58ded53adc51db37e91405c6798cdbd0", + "fingerprint": "9f280c91ad5a5ee16090245c78b79a79ba667a6048b5d0003d1656eab4debfef", "kind": "defect", "location": { "col_end": 12, @@ -873,7 +873,7 @@ }, { "confidence": null, - "fingerprint": "4c9d679f189c499a382f5765820958e5ae1c0875064c0b0bd800a26559af7a9c", + "fingerprint": "1aeda691782ea37618b889b63fb75e18db41b1530d11e05de9cad8191c9c2538", "kind": "defect", "location": { "col_end": 12, @@ -898,7 +898,7 @@ }, { "confidence": null, - "fingerprint": "5b727e8b47a7e7216468308658740001d5062d4292ce012f03a50ef96e523cbb", + "fingerprint": "27ec1873b1ae31dbe18a43165431a450058d15c76cbb45f598e232dc177eb6a6", "kind": "defect", "location": { "col_end": null, @@ -924,7 +924,7 @@ }, { "confidence": null, - "fingerprint": "a94cef80695a44e753a0f5dc71d632fb3d329a10da3acc62dc2f4151d8856179", + "fingerprint": "73b99d4b3e6ff105346ab83c64d5fa6de278d375bee80beea31875ffa269ef4b", "kind": "defect", "location": { "col_end": null, @@ -950,7 +950,7 @@ }, { "confidence": null, - "fingerprint": "272b6408714850250d4890e81911e3db808b0aa8f894db916d5a0f73a4670215", + "fingerprint": "65f131f25419060a668c645a576dff8111d2999e229e8e99e08bd51293288c15", "kind": "defect", "location": { "col_end": null, @@ -975,7 +975,7 @@ }, { "confidence": null, - "fingerprint": "8d45425ca46245baa235e857f42513c1e77cd3e7b2bb0b19fc65ab3687f25e8a", + "fingerprint": "a815a406d510a2ff878803617c98d642f6ab7b39e06dbb372435918919288f21", "kind": "defect", "location": { "col_end": null, @@ -1000,7 +1000,7 @@ }, { "confidence": null, - "fingerprint": "411c3b7a79ca5aa3df4d8abfcacfcc987ffc12a3797c8c70340ccd90c88eef24", + "fingerprint": "a0f9157dd198ae576148bb5b5b1f175a456f14ff782ce62057d0508734bfa2a6", "kind": "defect", "location": { "col_end": 49, @@ -1025,7 +1025,7 @@ }, { "confidence": null, - "fingerprint": "3d8057c1001d1a882d3c85dbe04379d2a25bc6a4b5fca842497b19fddc4609fa", + "fingerprint": "007ba30e97b15fd8055ac7c652be4b2021adda2642d1d3fa3cafc61ac1778d5e", "kind": "defect", "location": { "col_end": 29, @@ -1051,7 +1051,7 @@ }, { "confidence": null, - "fingerprint": "8b74d4819f60cc2f30acecedbf63b69dd512f00178f19e3e43ee4d4b6026444f", + "fingerprint": "4786a059c48494884be9adf0724a74c3151c58993afd127be1deec71be3035cd", "kind": "defect", "location": { "col_end": 49, @@ -1077,7 +1077,7 @@ }, { "confidence": null, - "fingerprint": "2b283fa70a2a9d37ed414ef8f1140a60571eae6b0c79d373c6bd50764ed914eb", + "fingerprint": "ecd0660fc2f531f755ef1ca46bc8456cdfb48af392744aa2ee2ae2583de67068", "kind": "defect", "location": { "col_end": 76, @@ -1102,10 +1102,10 @@ }, { "confidence": null, - "fingerprint": "1f9174a59ff725857a5574eb57a4f1b31f7212ece7589d1b46721f2fd31edc9d", + "fingerprint": "1f8fdcf9972ad1748bb5ee8ce8ba9496186f811d5f2d1891f7783b7bdaf79e2c", "kind": "defect", "location": { - "col_end": 76, + "col_end": 50, "col_start": 11, "line_end": 95, "line_start": 95, @@ -1128,10 +1128,10 @@ }, { "confidence": null, - "fingerprint": "41d07bddca738298bd5d85c8db731766f44812033f01af7bc7d83941b2073759", + "fingerprint": "a2de51062f5d833bbf3954abcdd1e3fc84b2875ff7c7be035191130d09cecd99", "kind": "defect", "location": { - "col_end": 50, + "col_end": 76, "col_start": 11, "line_end": 95, "line_start": 95, @@ -1204,7 +1204,7 @@ }, { "confidence": null, - "fingerprint": "954c291d52c66289019d668b49fd63a3fca2f43219278855b892174bb0a97582", + "fingerprint": "56dfc9315ec8189ee2c34139e7c6242e41f2794ad24d7eda29c3cd46ffea4957", "kind": "defect", "location": { "col_end": 25, @@ -1229,7 +1229,7 @@ }, { "confidence": null, - "fingerprint": "5d3200b5f9d4418398cda8f3f830f716794dae3c1e0a3ec97af0f526735dbee9", + "fingerprint": "027bb1beb78ab6aa09620acc62e39055e56324cd3af714724ed1bac2fb7bf751", "kind": "defect", "location": { "col_end": 63, @@ -1255,7 +1255,7 @@ }, { "confidence": null, - "fingerprint": "b85f44e814c9271c6cd3129c2146bdf9b50e87c312cf862b9aa62c5b3f5c5768", + "fingerprint": "81ae07ca0f1b9be894a643637901e3b8fcc434b1cc086b309d7b6e2fed1efea6", "kind": "defect", "location": { "col_end": null, @@ -1333,7 +1333,7 @@ "text": "wardline_sinks.import_catalog_blob declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190" + "wardlineFingerprint/v2": "7140795575283f8b575692cd6e9af4b72cd80425aa756c2b30fe283808154c29" }, "properties": { "internalSeverity": "ERROR", @@ -1396,7 +1396,7 @@ "text": "wardline_sinks.import_catalog_blob: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 34" }, "partialFingerprints": { - "wardlineFingerprint/v2": "5ee6a352fd2713e30f8d3a8d5d2f374dfe486b5dc2a0299e77975bf4c9ea14bc" + "wardlineFingerprint/v2": "a8d833be3f2852428f0d7ef8bb89918f47dc4ce69f58166c98678cc79f5be15f" }, "properties": { "internalSeverity": "WARN", @@ -1460,7 +1460,7 @@ "text": "wardline_sinks.run_export declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "235565113b5cbb65f508f16155d4ac0bba3624076b23d08fb7357bdc1738a847" + "wardlineFingerprint/v2": "6f20c968eac6b5cb7c53ec43dbd9825ed1bb42c6aac31dd1ce7ebaa0eeec49e9" }, "properties": { "internalSeverity": "ERROR", @@ -1542,7 +1542,7 @@ "text": "wardline_sinks.run_export: EXTERNAL_RAW (untrusted) data reaches the shell=True subprocess sink subprocess.run() at line 41" }, "partialFingerprints": { - "wardlineFingerprint/v2": "13c25150c1e15425e6ae5f9d99f67ec09dd5ffb4b60ac49e20367a0ad6240c6f" + "wardlineFingerprint/v2": "7b306b0603f82ac94b0174ccaa82e305032e7a79382d9979bd27eaebf7406d93" }, "properties": { "internalSeverity": "ERROR", @@ -1606,7 +1606,7 @@ "text": "wardline_sinks.load_report_plugin declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "9840134f9529e2230a0f32f55afd1b7c47cdf9fc0fcb1a46348b9bd6059e8963" + "wardlineFingerprint/v2": "19a4c988c03a8a87c1742001812a14d2e17b4e915aadfe88ad5a63f781147d71" }, "properties": { "internalSeverity": "ERROR", @@ -1688,7 +1688,7 @@ "text": "wardline_sinks.load_report_plugin: EXTERNAL_RAW (untrusted) data reaches the dynamic import sink importlib.import_module() at line 48" }, "partialFingerprints": { - "wardlineFingerprint/v2": "0dcfc65cd0c5381cbd62bc4646ea5b34f358be072431e3877213362e587cf8e9" + "wardlineFingerprint/v2": "dcb49d57938f6ac07d9203c095c0ed610c2033dde6e63b7cf75a6562fb49cffd" }, "properties": { "internalSeverity": "WARN", @@ -1752,7 +1752,7 @@ "text": "wardline_sinks.open_catalog_file declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "5426cf7f120d932b6d6f5e6894068278aed09df543f91602d568a850b121a972" + "wardlineFingerprint/v2": "5d30b5aad08e35d0a6414f2e513c8eaab6b37e0da29c77ccfe08c0e70aa2844f" }, "properties": { "internalSeverity": "ERROR", @@ -1834,7 +1834,7 @@ "text": "wardline_sinks.open_catalog_file: EXTERNAL_RAW (untrusted) data reaches the path-traversal sink open() at line 55" }, "partialFingerprints": { - "wardlineFingerprint/v2": "9284a27b61ef23301fa372ac36c6385ec711fd26ed22a455eeea993981d5002e" + "wardlineFingerprint/v2": "0649daf6e7c1fd3b491a513862f1f3c55be239ff2426f7199c7c72ac7963f791" }, "properties": { "internalSeverity": "WARN", @@ -1892,7 +1892,7 @@ "text": "wardline_sinks.open_catalog_file returns stored/persisted data (EXTERNAL_RAW) without validation at line 56" }, "partialFingerprints": { - "wardlineFingerprint/v2": "1840d80d73b71ae6d77a5399229adb8b644508a7e8d4f400236a8a698a60a364" + "wardlineFingerprint/v2": "b8d104e6587abeec86d889d391c0c6a67bfb6b79b7807dbacb94ab755246acce" }, "properties": { "internalSeverity": "ERROR", @@ -1925,7 +1925,7 @@ "text": "wardline_sinks._refine_a declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "98f171afa38cd679d03c22f6e6e3666a58ded53adc51db37e91405c6798cdbd0" + "wardlineFingerprint/v2": "9f280c91ad5a5ee16090245c78b79a79ba667a6048b5d0003d1656eab4debfef" }, "properties": { "internalSeverity": "ERROR", @@ -1959,7 +1959,7 @@ "text": "wardline_sinks._refine_b declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "4c9d679f189c499a382f5765820958e5ae1c0875064c0b0bd800a26559af7a9c" + "wardlineFingerprint/v2": "1aeda691782ea37618b889b63fb75e18db41b1530d11e05de9cad8191c9c2538" }, "properties": { "internalSeverity": "ERROR", @@ -2016,7 +2016,7 @@ "text": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_b() at line 75" }, "partialFingerprints": { - "wardlineFingerprint/v2": "5b727e8b47a7e7216468308658740001d5062d4292ce012f03a50ef96e523cbb" + "wardlineFingerprint/v2": "27ec1873b1ae31dbe18a43165431a450058d15c76cbb45f598e232dc177eb6a6" }, "properties": { "internalSeverity": "ERROR", @@ -2074,7 +2074,7 @@ "text": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_a() at line 75" }, "partialFingerprints": { - "wardlineFingerprint/v2": "a94cef80695a44e753a0f5dc71d632fb3d329a10da3acc62dc2f4151d8856179" + "wardlineFingerprint/v2": "73b99d4b3e6ff105346ab83c64d5fa6de278d375bee80beea31875ffa269ef4b" }, "properties": { "internalSeverity": "ERROR", @@ -2132,7 +2132,7 @@ "text": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_b without validation at line 75" }, "partialFingerprints": { - "wardlineFingerprint/v2": "272b6408714850250d4890e81911e3db808b0aa8f894db916d5a0f73a4670215" + "wardlineFingerprint/v2": "65f131f25419060a668c645a576dff8111d2999e229e8e99e08bd51293288c15" }, "properties": { "internalSeverity": "ERROR", @@ -2189,7 +2189,7 @@ "text": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_a without validation at line 75" }, "partialFingerprints": { - "wardlineFingerprint/v2": "8d45425ca46245baa235e857f42513c1e77cd3e7b2bb0b19fc65ab3687f25e8a" + "wardlineFingerprint/v2": "a815a406d510a2ff878803617c98d642f6ab7b39e06dbb372435918919288f21" }, "properties": { "internalSeverity": "ERROR", @@ -2223,7 +2223,7 @@ "text": "wardline_sinks.double_deserialize declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "411c3b7a79ca5aa3df4d8abfcacfcc987ffc12a3797c8c70340ccd90c88eef24" + "wardlineFingerprint/v2": "a0f9157dd198ae576148bb5b5b1f175a456f14ff782ce62057d0508734bfa2a6" }, "properties": { "internalSeverity": "ERROR", @@ -2257,7 +2257,7 @@ "text": "wardline_sinks.double_deserialize: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 84" }, "partialFingerprints": { - "wardlineFingerprint/v2": "3d8057c1001d1a882d3c85dbe04379d2a25bc6a4b5fca842497b19fddc4609fa" + "wardlineFingerprint/v2": "007ba30e97b15fd8055ac7c652be4b2021adda2642d1d3fa3cafc61ac1778d5e" }, "properties": { "internalSeverity": "WARN", @@ -2292,7 +2292,7 @@ "text": "wardline_sinks.double_deserialize: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 84" }, "partialFingerprints": { - "wardlineFingerprint/v2": "8b74d4819f60cc2f30acecedbf63b69dd512f00178f19e3e43ee4d4b6026444f" + "wardlineFingerprint/v2": "4786a059c48494884be9adf0724a74c3151c58993afd127be1deec71be3035cd" }, "properties": { "internalSeverity": "WARN", @@ -2327,7 +2327,7 @@ "text": "wardline_sinks.chained_queries declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "2b283fa70a2a9d37ed414ef8f1140a60571eae6b0c79d373c6bd50764ed914eb" + "wardlineFingerprint/v2": "ecd0660fc2f531f755ef1ca46bc8456cdfb48af392744aa2ee2ae2583de67068" }, "properties": { "internalSeverity": "ERROR", @@ -2349,7 +2349,7 @@ "uri": "wardline_sinks.py" }, "region": { - "endColumn": 76, + "endColumn": 50, "endLine": 95, "startColumn": 11, "startLine": 95 @@ -2361,7 +2361,7 @@ "text": "wardline_sinks.chained_queries: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 95" }, "partialFingerprints": { - "wardlineFingerprint/v2": "1f9174a59ff725857a5574eb57a4f1b31f7212ece7589d1b46721f2fd31edc9d" + "wardlineFingerprint/v2": "1f8fdcf9972ad1748bb5ee8ce8ba9496186f811d5f2d1891f7783b7bdaf79e2c" }, "properties": { "internalSeverity": "ERROR", @@ -2384,7 +2384,7 @@ "uri": "wardline_sinks.py" }, "region": { - "endColumn": 50, + "endColumn": 76, "endLine": 95, "startColumn": 11, "startLine": 95 @@ -2396,7 +2396,7 @@ "text": "wardline_sinks.chained_queries: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 95" }, "partialFingerprints": { - "wardlineFingerprint/v2": "41d07bddca738298bd5d85c8db731766f44812033f01af7bc7d83941b2073759" + "wardlineFingerprint/v2": "a2de51062f5d833bbf3954abcdd1e3fc84b2875ff7c7be035191130d09cecd99" }, "properties": { "internalSeverity": "ERROR", @@ -2528,7 +2528,7 @@ "text": "wardline_sinks.lookup_member declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "954c291d52c66289019d668b49fd63a3fca2f43219278855b892174bb0a97582" + "wardlineFingerprint/v2": "56dfc9315ec8189ee2c34139e7c6242e41f2794ad24d7eda29c3cd46ffea4957" }, "properties": { "internalSeverity": "ERROR", @@ -2591,7 +2591,7 @@ "text": "wardline_sinks.lookup_member: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 118" }, "partialFingerprints": { - "wardlineFingerprint/v2": "5d3200b5f9d4418398cda8f3f830f716794dae3c1e0a3ec97af0f526735dbee9" + "wardlineFingerprint/v2": "027bb1beb78ab6aa09620acc62e39055e56324cd3af714724ed1bac2fb7bf751" }, "properties": { "internalSeverity": "ERROR", @@ -2649,7 +2649,7 @@ "text": "wardline_sinks.lookup_member returns stored/persisted data (EXTERNAL_RAW) without validation at line 119" }, "partialFingerprints": { - "wardlineFingerprint/v2": "b85f44e814c9271c6cd3129c2146bdf9b50e87c312cf862b9aa62c5b3f5c5768" + "wardlineFingerprint/v2": "81ae07ca0f1b9be894a643637901e3b8fcc434b1cc086b309d7b6e2fed1efea6" }, "properties": { "internalSeverity": "ERROR", diff --git a/tests/golden/identity/corpus/stress.json b/tests/golden/identity/corpus/stress.json index 9c467e9b..7c46250a 100644 --- a/tests/golden/identity/corpus/stress.json +++ b/tests/golden/identity/corpus/stress.json @@ -133,7 +133,7 @@ ], "explain": { "explanation": { - "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4", + "fingerprint": "26832120b7d5db3a8177800c56827e88087af068aca73070330051a5bd83897f", "immediate_tainted_callee": null, "line": 43, "path": "identity_stress.py", @@ -146,7 +146,7 @@ "tier_out": null, "unresolved_call_count": 0 }, - "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4" + "fingerprint": "26832120b7d5db3a8177800c56827e88087af068aca73070330051a5bd83897f" }, "facts": [ { @@ -212,7 +212,7 @@ }, "findings": [ { - "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4", + "fingerprint": "26832120b7d5db3a8177800c56827e88087af068aca73070330051a5bd83897f", "line_start": 43, "path": "identity_stress.py", "rule_id": "PY-WL-111" @@ -467,7 +467,7 @@ }, "findings": [ { - "fingerprint": "7b49218a9f624f5d345f4947ce59746393d3d71bf98588abe823af6d789a945a", + "fingerprint": "e648fd8f2e1f027aa5b854180eea9dbc79dfe211e9ce45fdbfa20dbdd1d2312e", "line_start": 56, "path": "identity_stress.py", "rule_id": "PY-WL-101" @@ -489,7 +489,7 @@ "findings": [ { "confidence": null, - "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4", + "fingerprint": "26832120b7d5db3a8177800c56827e88087af068aca73070330051a5bd83897f", "kind": "defect", "location": { "col_end": 16, @@ -514,7 +514,7 @@ }, { "confidence": null, - "fingerprint": "7b49218a9f624f5d345f4947ce59746393d3d71bf98588abe823af6d789a945a", + "fingerprint": "e648fd8f2e1f027aa5b854180eea9dbc79dfe211e9ce45fdbfa20dbdd1d2312e", "kind": "defect", "location": { "col_end": 22, @@ -564,7 +564,7 @@ "text": "identity_stress.assert_only_boundary declares a trust boundary (EXTERNAL_RAW -> ASSURED) but its only rejection path is assert — stripped under python -O, so the validation vanishes in production" }, "partialFingerprints": { - "wardlineFingerprint/v2": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4" + "wardlineFingerprint/v2": "26832120b7d5db3a8177800c56827e88087af068aca73070330051a5bd83897f" }, "properties": { "internalSeverity": "ERROR", @@ -646,7 +646,7 @@ "text": "identity_stress.untrusted_reaches_trusted declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v2": "7b49218a9f624f5d345f4947ce59746393d3d71bf98588abe823af6d789a945a" + "wardlineFingerprint/v2": "e648fd8f2e1f027aa5b854180eea9dbc79dfe211e9ce45fdbfa20dbdd1d2312e" }, "properties": { "internalSeverity": "ERROR", diff --git a/tests/golden/identity/regen.py b/tests/golden/identity/regen.py index 01a41a0d..2ff10e9a 100644 --- a/tests/golden/identity/regen.py +++ b/tests/golden/identity/regen.py @@ -30,7 +30,13 @@ # 3->4: P3 value-rekey (wardline-8654423823) — line_start dropped from the hash + # move-stable entity-relative discriminators, so every PY-WL-*/RS-WL-* fingerprint # VALUE changes and META.fingerprint_scheme advances wlfp1->wlfp2. -CORPUS_VERSION = 4 +# 4->5: singleton rule value-rekey (wardline-31540f8492) — singleton entity-level +# findings fold a line-independent source-body discriminator into taint_path so old +# same-qualname suppressions cannot apply to a different body or signature. +# 5->6: call-site span value-rekey (wardline-a1bcb70c15) — multi-emit call-site +# findings fold entity-relative end_lineno into taint_path so multiline chained calls +# that share start line/column and end column cannot collide. +CORPUS_VERSION = 6 def main() -> None: diff --git a/tests/golden/identity/rust/_capture.py b/tests/golden/identity/rust/_capture.py index 72172a61..bfb41d68 100644 --- a/tests/golden/identity/rust/_capture.py +++ b/tests/golden/identity/rust/_capture.py @@ -75,9 +75,11 @@ def _parsed_files(root: Path) -> list[RustParsedFile]: crate_roots = discover_crate_roots(resolved_root) sources = {file: file.read_text(encoding="utf-8") for file in files} # Same Amendment-8 pre-pass as the analyzer: per-crate #[path] mount overlays. - overlays = _build_overlays(sources, resolved_root, crate_roots) + overlays, overlay_errors = _build_overlays(sources, resolved_root, crate_roots) parsed: list[RustParsedFile] = [] for file in files: + if file in overlay_errors: + continue module = _module_for(file, resolved_root, crate_roots, overlays) relpath = file.resolve().relative_to(resolved_root).as_posix() parsed.append(index_rust_file(source=sources[file], module=module, path=relpath)) diff --git a/tests/golden/identity/test_identity_parity.py b/tests/golden/identity/test_identity_parity.py index 14010b74..7823e562 100644 --- a/tests/golden/identity/test_identity_parity.py +++ b/tests/golden/identity/test_identity_parity.py @@ -115,13 +115,13 @@ def test_corpus_fingerprints_are_collision_free(name: str) -> None: # only stay distinct via the per-call source discriminator, so this gate is # non-vacuous for every call-site-anchored rule: # - PY-WL-118 ``chained_queries``: a CHAINED ``execute(a).execute(b)`` whose two - # calls share a start column — distinct ONLY via the full span end-column, so - # this line guards specifically against a span -> start-column-only regression. + # calls share a start column — distinct ONLY via the full span, so this line + # guards specifically against a span -> start-column-only regression. # - PY-WL-106 ``double_deserialize`` and PY-WL-105/PY-WL-120 ``fan_out_stored``: # sibling calls on one line — guard against a discriminator -> None regression. - # All four call-site rules share the identical ``@{col_offset}:{end_col_offset}`` - # pattern, so the chained PY-WL-118 line is representative of the span requirement - # for the family. + # The call-site rules share the identical entity-relative + # ``start:col:end:end_col`` span pattern, so the chained PY-WL-118 line is + # representative of the span requirement for the family. findings = _capture(name)["findings"] fps = [f["fingerprint"] for f in findings] dupes = {fp for fp in fps if fps.count(fp) > 1} diff --git a/tests/grammar/golden/builtin_findings.jsonl b/tests/grammar/golden/builtin_findings.jsonl index 2e78c311..51e04504 100644 --- a/tests/grammar/golden/builtin_findings.jsonl +++ b/tests/grammar/golden/builtin_findings.jsonl @@ -5,32 +5,32 @@ {"confidence": null, "fingerprint": "3eeafba571222e05ca13a57f17c5e7b7d797794c26100a924c7369bb5b8d544f", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.match_arms.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} {"confidence": null, "fingerprint": "d1c0002b30f0e4f8a0506e830c6f05364e691b946120b92cacf80fff80c28b94", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.more_shapes.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} {"confidence": null, "fingerprint": "1f79bbef4441f05d6c0795f8bb1c29c67611cc3cdc495a6fc34a95cd982230ef", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.validators.has_rejection has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "6538bf6c3e76ab83c38754d032c8ace87fac06c5cd157c39496e8c4e746115a0", "kind": "defect", "location": {"col_end": 28, "col_start": 0, "line_end": 13, "line_start": 12, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.aliased_sink declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.aliased_sink", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "235f99c070c0362f7a5c80494d7059843a12086a27035cd9213a150e542d4758", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 19, "line_start": 17, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.indirect_return declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.indirect_return", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "7bdc8a2f82080dd7ea07e0e3159b6a4b4327d80b37f6b04bed94f9d1c63bb668", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 23, "line_start": 20, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.if_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.if_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "b5ebcee770eefd21f57f87e070112a8174275c2d8c0522c8f75fff63e8f71c2c", "kind": "defect", "location": {"col_end": 26, "col_start": 0, "line_end": 31, "line_start": 27, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.try_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.try_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "4113a2676ccaea6f25d097281c4b9010d693b90400e3fa484c088de944f96575", "kind": "defect", "location": {"col_end": 19, "col_start": 0, "line_end": 17, "line_start": 13, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "4284ec8847342158d3e3f0ad5ce16834176b2abee8d8e4111d50304e599b0c69", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 26, "line_start": 21, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "1b15d5f9fa1213cad2b9414f837db7cc4783fd73c060353ab0ebb2c080fbdfcb", "kind": "defect", "location": {"col_end": 44, "col_start": 0, "line_end": 34, "line_start": 30, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.narrow_logged declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.narrow_logged", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "df9ffd51b8b982a203654d2a3589dd989203d564987550612e8356fea9ddf080", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 26, "line_start": 20, "path": "tests/corpus/fixtures/match_arms.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.match_arms.match_arm_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.match_arms.match_arm_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "dcd5ada1c54e1b0a068f736a784a01d206f4fc6bf8f2ddf6487f0ee4dde2b122", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 22, "line_start": 21, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.direct_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.direct_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "2fa762ccb85504025885b0d9bc9849cdcde22583896b76541ac6eb73874bf461", "kind": "defect", "location": {"col_end": 53, "col_start": 0, "line_end": 27, "line_start": 26, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.dict_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.dict_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "04e42f1735123d50541ec5930b836a0656728518c3ca323d9a58f56e37eda36e", "kind": "defect", "location": {"col_end": 27, "col_start": 0, "line_end": 32, "line_start": 31, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.str_wrapped_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.str_wrapped_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "d152a65a239f55609188ad4e4f7ef6b2051cf7727d3c44f040f8f50c64643f97", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 39, "line_start": 36, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.chained_indirection declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.chained_indirection", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "9b4c4306347a4cf91818b996287659b4b646d3ec4fe95b3edcf42e61e657d4a2", "kind": "defect", "location": {"col_end": 25, "col_start": 0, "line_end": 45, "line_start": 43, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.fstring_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.fstring_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "b2469a2f40d36d013829539b3ed90854a3949c87c4c850c47d3488a74fa87462", "kind": "defect", "location": {"col_end": 37, "col_start": 0, "line_end": 50, "line_start": 49, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.list_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.list_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "feb63a22babf3e084187866a7d54170c61d22baa44470a347fa93a990a7ff778", "kind": "defect", "location": {"col_end": 14, "col_start": 0, "line_end": 57, "line_start": 54, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.augassign_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.augassign_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "48db6ff8bfe3be3a2ffe31918ae202123b7f25f7ed1554b3c5897a9a3bf777ac", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 65, "line_start": 61, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "988c22d25a99f03ed8e75f487e0743fdc5a9faf2d32d785cb2992ad9977dc28c", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 71, "line_start": 69, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.passthrough_no_check declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.passthrough_no_check", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "b780ea20891c98e4a01af2d3e4dd22c9977d2a1f266efeca818d3c9e820d46ba", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 10, "line_start": 8, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "c0c5caa3685eef1f73f8e2f97605e9806a0ad444170317d5c5137c3f8469e8ee", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 16, "line_start": 14, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection_guarded declares a trust boundary (EXTERNAL_RAW -> GUARDED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "GUARDED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection_guarded", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "a0e074b0d883169d6a904861d8cad96b0bb990ad26f1c669b1bfdd2302dd2edc", "kind": "defect", "location": {"col_end": 28, "col_start": 0, "line_end": 13, "line_start": 12, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.aliased_sink declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.aliased_sink", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "e4f5a2d11d1c5654abebbc73a644499b276ad883fac3d070b9ed31f12526952e", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 19, "line_start": 17, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.indirect_return declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.indirect_return", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "e4eaff26fe1f860faf04cfdec464408412bd748fd756cf54ebfe2475d28f26e2", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 23, "line_start": 20, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.if_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.if_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "83380025c041d421a69573573a3527bccd0c614e28e38aa36a9aca215603d6b0", "kind": "defect", "location": {"col_end": 26, "col_start": 0, "line_end": 31, "line_start": 27, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.try_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.try_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "e0923dc8b50badaa7ccdf97ccafff147377ed203a5193be56e1e646a6c6fa669", "kind": "defect", "location": {"col_end": 19, "col_start": 0, "line_end": 17, "line_start": 13, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "6c684ae40ba95624edd04bd0288fb838bc5f66f56f444a12f6666d149fe12817", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 26, "line_start": 21, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "d1ef1a87a2d71edb4a02bff26ba73690a40ef8289207787d93ec8f48965ddef0", "kind": "defect", "location": {"col_end": 44, "col_start": 0, "line_end": 34, "line_start": 30, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.narrow_logged declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.narrow_logged", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "2a65989cf416f74119410c72ae7b29104474e352a6badcbc85b7f108f8fa67ac", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 26, "line_start": 20, "path": "tests/corpus/fixtures/match_arms.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.match_arms.match_arm_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.match_arms.match_arm_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "c6101761cf4a23bb3ae057a0a5e9250b522f5bd3254e23e1db87b1b0871eaaa9", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 22, "line_start": 21, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.direct_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.direct_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "a0493d83723b21bd6c0a477c78cad42378bd129d1adb436d06d1d41659f8597c", "kind": "defect", "location": {"col_end": 53, "col_start": 0, "line_end": 27, "line_start": 26, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.dict_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.dict_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "c477ccafe4aaca395e0d8136bd17c1a4b8f305c4067f654f2e98728544784b50", "kind": "defect", "location": {"col_end": 27, "col_start": 0, "line_end": 32, "line_start": 31, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.str_wrapped_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.str_wrapped_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "baaad1f90ca22e906c0bcfbbfb06c6869b0530c3f8c3a87374adc69d38f47d98", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 39, "line_start": 36, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.chained_indirection declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.chained_indirection", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "2206b28582c7d1bbdc642bf01ee9a6b7da5351eab5857dd7d43a1698c144dadf", "kind": "defect", "location": {"col_end": 25, "col_start": 0, "line_end": 45, "line_start": 43, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.fstring_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.fstring_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "c28d8b23241fb6d34835dcecc94a76d44896146d2660cada9d624c924cc7b3f0", "kind": "defect", "location": {"col_end": 37, "col_start": 0, "line_end": 50, "line_start": 49, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.list_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.list_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "d13526870eba135e22c3880dcdd23f53028562cdaf6fe97dad978e695df0e656", "kind": "defect", "location": {"col_end": 14, "col_start": 0, "line_end": 57, "line_start": 54, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.augassign_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.augassign_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "7576b555b8a03ba221f058c292e89249f2c8ead26c9b987b87e51f2171c0269d", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 65, "line_start": 61, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "e60ed3e0b69558cd0bb21016b0734e0596e06d518747fddbd3fc47e4a211bef8", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 71, "line_start": 69, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.passthrough_no_check declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.passthrough_no_check", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "42df21c0a8f70aa924acbd0dffd9eadfd84c8c16cacb3a538e3635d25dcac6ac", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 10, "line_start": 8, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "002c99731ce90fd2611683de7b941419af96093bf9c1529f9d1efa9e2c9b04c4", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 16, "line_start": 14, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection_guarded declares a trust boundary (EXTERNAL_RAW -> GUARDED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "GUARDED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection_guarded", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} {"confidence": null, "fingerprint": "c965eff4deb1719dd123d7a179cba25ae6590e324b0cb31637cf04f540cd8816", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler: broad exception handler at line 16", "properties": {"tier": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-103", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} {"confidence": null, "fingerprint": "c50617814cd7cac9b17ce5e07647a1bf3429d2ffd9d50e98481a128cb0037fad", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 24, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler: exception silently swallowed at line 24", "properties": {"tier": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-104", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "6c2e8485d5076b983edf80ad5081109f3f58e204e5bd5e8fbd50a109e54da101", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 8, "line_start": 7, "path": "tests/corpus/fixtures/contradictory.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.contradictory.conflicting carries contradictory trust markers (external_boundary+trusted); the engine resolves the clash to the least-trusted seed, silently ignoring the rest", "properties": {"markers": "external_boundary+trusted"}, "qualname": "tests.corpus.fixtures.contradictory.conflicting", "related_entities": [], "rule_id": "PY-WL-110", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "72439c3d8c31d8d4ccece6f134368a8927a75d138b5adc18acbd293fcc9a9f98", "kind": "defect", "location": {"col_end": 10, "col_start": 0, "line_end": 9, "line_start": 6, "path": "tests/corpus/fixtures/none_leak.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.none_leak.maybe_none declares trusted return ASSURED but a path returns None (bare return / return None) — None leaks from a trusted producer", "properties": {"declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.none_leak.maybe_none", "related_entities": [], "rule_id": "PY-WL-109", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "b480326767381cab9c1995241e94f6cb9dc6308281f2842f10a9f036d1e0760e", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/trusted_callee.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.trusted_callee.handler: EXTERNAL_RAW (untrusted) data passed to trusted producer tests.corpus.fixtures.trusted_callee.store() at line 16", "properties": {"arg_taint": "EXTERNAL_RAW", "callee": "tests.corpus.fixtures.trusted_callee.store", "callee_body": "ASSURED"}, "qualname": "tests.corpus.fixtures.trusted_callee.handler", "related_entities": [], "rule_id": "PY-WL-105", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "d666c4d2616986f1f3aeb1d743ed5badb0d67848b94b51b028322f714d684148", "kind": "defect", "location": {"col_end": 19, "col_start": 4, "line_end": 15, "line_start": 15, "path": "tests/corpus/fixtures/deser_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.deser_sink.loads_untrusted: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "pickle.loads", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.deser_sink.loads_untrusted", "related_entities": [], "rule_id": "PY-WL-106", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "090abfc382d13eda6255b2948426fca197c6d5fbe177845860a9368a50a6178e", "kind": "defect", "location": {"col_end": 13, "col_start": 4, "line_end": 13, "line_start": 13, "path": "tests/corpus/fixtures/exec_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exec_sink.evals_untrusted: EXTERNAL_RAW (untrusted) data reaches the dynamic-code-execution sink eval() at line 13", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "eval", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.exec_sink.evals_untrusted", "related_entities": [], "rule_id": "PY-WL-107", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "89495235b260639eb40e0aef10c4c40dee8d0fd3ab3767fde1f65ff7cc0f00ac", "kind": "defect", "location": {"col_end": 18, "col_start": 4, "line_end": 15, "line_start": 15, "path": "tests/corpus/fixtures/command_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.command_sink.runs_untrusted: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.command_sink.runs_untrusted", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "d0431eaf7203c0e40e57e855c8e9820062f75146a50e371f04021b34be50489d", "kind": "defect", "location": {"col_end": 18, "col_start": 4, "line_end": 24, "line_start": 24, "path": "tests/corpus/fixtures/shadow_launder.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.shadow_launder.shadowed_sink: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 24", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.shadow_launder.shadowed_sink", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} -{"confidence": null, "fingerprint": "db0b0dc75b64818b2d48960f83a2c457f2d433d40277fc0e724bc806ad7c84a6", "kind": "defect", "location": {"col_end": 18, "col_start": 4, "line_end": 21, "line_start": 21, "path": "tests/corpus/fixtures/shadow_launder_bare.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.shadow_launder_bare.bare_shadow_sink: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 21", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.shadow_launder_bare.bare_shadow_sink", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} \ No newline at end of file +{"confidence": null, "fingerprint": "c785258b2c38a85d61a42fd3ee0b65040c659a6781c01889fc5f273bf036c593", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 8, "line_start": 7, "path": "tests/corpus/fixtures/contradictory.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.contradictory.conflicting carries contradictory trust markers (external_boundary+trusted); the engine resolves the clash to the least-trusted seed, silently ignoring the rest", "properties": {"markers": "external_boundary+trusted"}, "qualname": "tests.corpus.fixtures.contradictory.conflicting", "related_entities": [], "rule_id": "PY-WL-110", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "504c7f770a9f23242534fcaf1abcdeafbef9aa1ab676f16ebb0030f762f1318a", "kind": "defect", "location": {"col_end": 10, "col_start": 0, "line_end": 9, "line_start": 6, "path": "tests/corpus/fixtures/none_leak.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.none_leak.maybe_none declares trusted return ASSURED but a path returns None (bare return / return None) — None leaks from a trusted producer", "properties": {"declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.none_leak.maybe_none", "related_entities": [], "rule_id": "PY-WL-109", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "775c1aa47b9a1bdad1ee8dbb768fac32c9cb03bac8b09ba381de7c4e847c2380", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/trusted_callee.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.trusted_callee.handler: EXTERNAL_RAW (untrusted) data passed to trusted producer tests.corpus.fixtures.trusted_callee.store() at line 16", "properties": {"arg_taint": "EXTERNAL_RAW", "callee": "tests.corpus.fixtures.trusted_callee.store", "callee_body": "ASSURED"}, "qualname": "tests.corpus.fixtures.trusted_callee.handler", "related_entities": [], "rule_id": "PY-WL-105", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "45de6fcd0e4dad7ab978307137a445e46095e860dab88890bae6166e2227992d", "kind": "defect", "location": {"col_end": 19, "col_start": 4, "line_end": 15, "line_start": 15, "path": "tests/corpus/fixtures/deser_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.deser_sink.loads_untrusted: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "pickle.loads", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.deser_sink.loads_untrusted", "related_entities": [], "rule_id": "PY-WL-106", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "413b90e34137643ace767b4b80bd092edeadc90e6fef98358269937279ddcf96", "kind": "defect", "location": {"col_end": 13, "col_start": 4, "line_end": 13, "line_start": 13, "path": "tests/corpus/fixtures/exec_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exec_sink.evals_untrusted: EXTERNAL_RAW (untrusted) data reaches the dynamic-code-execution sink eval() at line 13", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "eval", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.exec_sink.evals_untrusted", "related_entities": [], "rule_id": "PY-WL-107", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "757831ed2b564daaf5587398073896daf596e287e43549df827857342d13f228", "kind": "defect", "location": {"col_end": 18, "col_start": 4, "line_end": 15, "line_start": 15, "path": "tests/corpus/fixtures/command_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.command_sink.runs_untrusted: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.command_sink.runs_untrusted", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "6214f86e2955d09e4013acc17c381f963e510ac6f8f6ed87d17fce099c08406a", "kind": "defect", "location": {"col_end": 18, "col_start": 4, "line_end": 24, "line_start": 24, "path": "tests/corpus/fixtures/shadow_launder.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.shadow_launder.shadowed_sink: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 24", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.shadow_launder.shadowed_sink", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "63dc5c9a58608e219d01bbf003282a217e30177d586d187cf92bc8068f5037c4", "kind": "defect", "location": {"col_end": 18, "col_start": 4, "line_end": 21, "line_start": 21, "path": "tests/corpus/fixtures/shadow_launder_bare.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.shadow_launder_bare.bare_shadow_sink: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 21", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.shadow_launder_bare.bare_shadow_sink", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} \ No newline at end of file diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 9070c0e4..eec7023a 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -1287,6 +1287,72 @@ def test_scan_loomweave_soft_outage_redacts_url_secrets(tmp_path, monkeypatch) - assert "#frag" not in result.output +def test_scan_filigree_agent_summary_redacts_url_secrets(tmp_path, monkeypatch) -> None: + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + secret_url = "https://user:secret@filigree.example/api/p/demo/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.example/api/p/demo/weft/scan-results" + + class _AuthRejectedEmitter: + def __init__(self, url, **kw): + self.url = url + + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): + from wardline.core.filigree_emit import EmitResult + + return EmitResult(reachable=False, status=401, token_sent=True, url=self.url) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _AuthRejectedEmitter) + out = tmp_path / "summary.json" + + result = CliRunner().invoke( + scan, + [str(proj), "--format", "agent-summary", "--output", str(out), "--filigree-url", secret_url], + ) + + assert result.exit_code == 0, result.output + payload = _json.loads(out.read_text(encoding="utf-8")) + filigree = payload["integrations"]["filigree_emit"] + exposed = _json.dumps(filigree) + assert filigree["destination"]["url"] == redacted + assert redacted in filigree["disabled_reason"] + assert "user:secret" not in result.output + assert "user:secret" not in exposed + assert "token=abc" not in result.output + assert "token=abc" not in exposed + assert "#frag" not in result.output + assert "#frag" not in exposed + + +def test_scan_filigree_soft_outage_redacts_url_secrets(tmp_path, monkeypatch) -> None: + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + secret_url = "https://user:secret@filigree.example/api/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.example/api/weft/scan-results" + + class _AbsentEmitter: + def __init__(self, url, **kw): + pass + + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): + from wardline.core.filigree_emit import EmitResult + + return EmitResult(reachable=False) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _AbsentEmitter) + out = tmp_path / "f.jsonl" + + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--filigree-url", secret_url]) + + assert result.exit_code == 0, result.output + assert redacted in result.output + assert "user:secret" not in result.output + assert "token=abc" not in result.output + assert "#frag" not in result.output + + def test_scan_reports_filigree_success_and_loomweave_unreachable_independently(tmp_path, monkeypatch) -> None: from wardline.loomweave.client import WriteResult diff --git a/tests/unit/cli/test_doctor.py b/tests/unit/cli/test_doctor.py index f927ae06..874c2300 100644 --- a/tests/unit/cli/test_doctor.py +++ b/tests/unit/cli/test_doctor.py @@ -220,3 +220,95 @@ def test_doctor_fix_json_includes_filigree_auth_check(tmp_path: Path, monkeypatc payload = _json.loads(result.output) ids = [c["id"] for c in payload["checks"]] assert "filigree.auth" in ids + + +STAMP = "20260624T111539Z" + + +def test_check_only_exits_0_when_only_gap_is_gitignore_and_stray(tmp_path: Path, monkeypatch) -> None: + """Advisory checks (gitignore gap + stray present) must NOT make wardline doctor exit 1.""" + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_LOOMWEAVE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + # Repair first to make the mandatory checks pass. + repair = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert repair.exit_code == 0, repair.output + + # Delete the gitignore created by repair, plant a stray artifact. + (tmp_path / ".gitignore").unlink(missing_ok=True) + stray_dir = tmp_path / "src" / ".wardline" + stray_dir.mkdir(parents=True, exist_ok=True) + (stray_dir / f"{STAMP}-findings.jsonl").write_text("{}\n", encoding="utf-8") + + # Check-only with ONLY advisory gaps: must exit 0. + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + # The advisory lines are rendered as informational output. + assert "gitignore" in result.output + assert "stray artifacts" in result.output + + +def test_repair_exits_nonzero_on_symlinked_gitignore(tmp_path: Path, monkeypatch) -> None: + """A symlinked .gitignore is a write-refusal (status=error); --repair must exit 1. + + Finding 2: the --repair branch excluded the gitignore error from its SystemExit(1) + condition, so a symlinked .gitignore (genuine write refusal) exited 0 inconsistently + with the JSON/MCP surface (which uses all(check.ok) and treats status=error as not-ok). + """ + import os as _os + + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_LOOMWEAVE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + # Repair first so all mandatory checks pass. + repair = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert repair.exit_code == 0, repair.output + + # Replace the real .gitignore with a symlink pointing outside the project. + # _check_gitignore will refuse to write through it -> status="error". + target = tmp_path.parent / "evil_gitignore" + target.write_text("", encoding="utf-8") + (tmp_path / ".gitignore").unlink() + _os.symlink(target, tmp_path / ".gitignore") + + # --repair with a symlinked .gitignore must exit non-zero (write refusal IS a failure). + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert result.exit_code != 0, f"expected non-zero exit but got 0; output: {result.output}" + assert "gitignore" in result.output + + +def test_repair_exits_zero_on_missing_gitignore_line(tmp_path: Path, monkeypatch) -> None: + """A missing gitignore line (status=ok, gap advisory) must NOT make --repair exit 1. + + Finding 2 complement: only the error status (write refusal) drives non-zero exit; + a plain missing-line gap that is successfully added exits 0. + """ + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_LOOMWEAVE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + # Repair first so all mandatory checks pass. + repair = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert repair.exit_code == 0, repair.output + + # Delete the gitignore so the repair will add lines (gap, not error). + (tmp_path / ".gitignore").unlink(missing_ok=True) + + # --repair that adds the missing lines must exit 0 (successfully fixed). + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert result.exit_code == 0, f"expected exit 0 but got {result.exit_code}; output: {result.output}" + assert (tmp_path / ".gitignore").exists() diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py index 6373a22d..72cec923 100644 --- a/tests/unit/cli/test_install.py +++ b/tests/unit/cli/test_install.py @@ -45,12 +45,16 @@ def __init__( *, root: Path, loomweave_url: str | None = None, + loomweave_url_source: str | None = None, filigree_url: str | None = None, + filigree_url_source: str | None = None, allow_write: bool = True, allow_network: bool = True, ) -> None: captured["loomweave_url"] = loomweave_url + captured["loomweave_url_source"] = loomweave_url_source captured["filigree_url"] = filigree_url + captured["filigree_url_source"] = filigree_url_source captured["allow_write"] = allow_write captured["allow_network"] = allow_network self.rpc = self @@ -62,6 +66,7 @@ def run_stdio(self) -> None: result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)]) assert result.exit_code == 0, result.output assert captured["loomweave_url"] == "http://localhost:9000/configured-loomweave" + assert captured["loomweave_url_source"] == "env WARDLINE_LOOMWEAVE_URL" def test_install_writes_all_artifacts(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/unit/cli/test_mcp_cli.py b/tests/unit/cli/test_mcp_cli.py index 04fbf50e..62bb6561 100644 --- a/tests/unit/cli/test_mcp_cli.py +++ b/tests/unit/cli/test_mcp_cli.py @@ -1,9 +1,18 @@ import io import json +from click.testing import CliRunner + +from wardline.install.doctor import DoctorCheck from wardline.mcp.server import WardlineMCPServer +def _mcp_doctor_payload(result_output: str) -> dict: + lines = [json.loads(ln) for ln in result_output.splitlines() if ln.strip()] + response = lines[-1] + return json.loads(response["result"]["content"][0]["text"]) + + def test_stdio_loop_handles_initialize_then_tools_list(tmp_path) -> None: server = WardlineMCPServer(root=tmp_path) stdin = io.StringIO( @@ -31,8 +40,6 @@ def test_stdio_loop_handles_initialize_then_tools_list(tmp_path) -> None: def test_mcp_command_is_registered() -> None: - from click.testing import CliRunner - from wardline.cli.main import cli result = CliRunner().invoke(cli, ["mcp", "--help"]) @@ -43,8 +50,6 @@ def test_mcp_command_is_registered() -> None: def test_mcp_command_passes_policy_flags(tmp_path, monkeypatch) -> None: - from click.testing import CliRunner - import wardline.cli.mcp as mcp_cli from wardline.cli.main import cli @@ -60,7 +65,9 @@ def __init__( *, root, loomweave_url=None, + loomweave_url_source=None, filigree_url=None, + filigree_url_source=None, allow_write=True, allow_network=True, ) -> None: @@ -68,7 +75,9 @@ def __init__( { "root": root, "loomweave_url": loomweave_url, + "loomweave_url_source": loomweave_url_source, "filigree_url": filigree_url, + "filigree_url_source": filigree_url_source, "allow_write": allow_write, "allow_network": allow_network, } @@ -95,18 +104,60 @@ def __init__( assert result.exit_code == 0, result.output assert captured["root"] == tmp_path assert captured["loomweave_url"] == "http://localhost:9100" + assert captured["loomweave_url_source"] == "--loomweave-url launch flag" assert captured["filigree_url"] == "http://localhost:8628/api/weft/scan-results" + assert captured["filigree_url_source"] == "--filigree-url launch flag" assert captured["allow_write"] is False assert captured["allow_network"] is False assert captured["ran"] is True +def test_mcp_doctor_preserves_env_url_provenance(tmp_path, monkeypatch) -> None: + from wardline.cli.main import cli + + monkeypatch.setenv("WARDLINE_LOOMWEAVE_URL", "http://localhost:9100") + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://localhost:8628/api/weft/scan-results") + monkeypatch.setattr( + "wardline.install.doctor._check_filigree_auth", + lambda *args, **kwargs: DoctorCheck("filigree.auth", "ok"), + ) + + request = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "doctor"}}) + "\n" + result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)], input=request) + + assert result.exit_code == 0, result.output + by_id = {c["id"]: c for c in _mcp_doctor_payload(result.output)["checks"]} + assert by_id["loomweave.url"]["message"] == "from env WARDLINE_LOOMWEAVE_URL" + assert by_id["filigree.url"]["message"] == "from env WARDLINE_FILIGREE_URL" + + +def test_mcp_doctor_preserves_published_port_url_provenance(tmp_path, monkeypatch) -> None: + from wardline.cli.main import cli + + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + (tmp_path / ".weft" / "loomweave").mkdir(parents=True) + (tmp_path / ".weft" / "loomweave" / "ephemeral.port").write_text("9100", encoding="ascii") + (tmp_path / ".weft" / "filigree").mkdir(parents=True) + (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("8628", encoding="ascii") + monkeypatch.setattr( + "wardline.install.doctor._check_filigree_auth", + lambda *args, **kwargs: DoctorCheck("filigree.auth", "ok"), + ) + + request = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "doctor"}}) + "\n" + result = CliRunner().invoke(cli, ["mcp", "--root", str(tmp_path)], input=request) + + assert result.exit_code == 0, result.output + by_id = {c["id"]: c for c in _mcp_doctor_payload(result.output)["checks"]} + assert by_id["loomweave.url"]["message"] == "from published .weft/loomweave/ephemeral.port" + assert by_id["filigree.url"]["message"] == "from published .weft/filigree/ephemeral.port" + + def test_mcp_command_runs_stdio_end_to_end(tmp_path) -> None: """Drive the real `mcp` command through click, exercising `WardlineMCPServer(root=root).rpc.run_stdio()` for real. Using --root tmp_path proves the option is wired (not a hardcoded path).""" - from click.testing import CliRunner - from wardline.cli.main import cli stdin = ( diff --git a/tests/unit/cli/test_scan_artifacts.py b/tests/unit/cli/test_scan_artifacts.py new file mode 100644 index 00000000..57c4f271 --- /dev/null +++ b/tests/unit/cli/test_scan_artifacts.py @@ -0,0 +1,171 @@ +"""End-to-end CLI tests: default scan artifact anchoring to the weft-project root. + +Covers the behaviour introduced by Tasks 1-3 of the weft-seam-conformance program: +``wardline scan `` must write its artifact at ``/.wardline/``, +not under the scanned subdirectory. + +Harness pattern: ``CliRunner().invoke(cli, ["scan", str(path)])`` — same as +``tests/unit/cli/test_cli.py``; no custom fixtures needed. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from click.testing import CliRunner + +from wardline.cli.main import cli +from wardline.mcp.server import _scan as mcp_scan + +_STAMPED_JSONL_RE = re.compile(r"^\d{8}T\d{6}Z(-\d{3})?-findings\.jsonl$") + + +def _scan_artifacts_jsonl(project: Path) -> list[Path]: + return sorted((project / ".wardline").glob("*-findings.jsonl")) + + +def _only_scan_artifact(project: Path) -> Path: + paths = _scan_artifacts_jsonl(project) + assert len(paths) == 1, f"expected exactly 1 artifact, got {paths}" + return paths[0] + + +# --------------------------------------------------------------------------- +# Test 1: subdir scan writes artifact at /.wardline/, NOT under sub +# --------------------------------------------------------------------------- + +def test_cli_subdir_scan_writes_artifact_at_project_root(tmp_path: Path) -> None: + """``wardline scan src/pkg`` (weft project at tmp_path) → artifact at + ``tmp_path/.wardline/``, NOT at ``tmp_path/src/pkg/.wardline/``.""" + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + (sub / "m.py").write_text("x = 1\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(sub)]) + + assert result.exit_code == 0, result.output + # Positive: artifact appeared at project root + artifacts_dir = tmp_path / ".wardline" + assert artifacts_dir.exists(), "expected .wardline/ at project root" + jsonl_files = list(artifacts_dir.glob("*-findings.jsonl")) + assert any(_STAMPED_JSONL_RE.match(p.name) for p in jsonl_files), ( + f"no timestamped findings artifact in {artifacts_dir!s}: {[p.name for p in jsonl_files]}" + ) + # Negative: NO artifact written under the scanned subdirectory + assert not (sub / ".wardline").exists(), ( + "artifact was written under the subdir — anchoring is broken" + ) + + +# --------------------------------------------------------------------------- +# Test 2: true-root scan writes artifact at /.wardline/ +# --------------------------------------------------------------------------- + +def test_cli_true_root_scan_writes_artifact_at_root(tmp_path: Path) -> None: + """``wardline scan `` where root carries ``weft.toml`` → artifact at + ``/.wardline/``.""" + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 0, result.output + artifact = _only_scan_artifact(tmp_path) + assert artifact.parent == tmp_path / ".wardline" + assert _STAMPED_JSONL_RE.match(artifact.name) + assert str(artifact) in result.output + + +# --------------------------------------------------------------------------- +# Test 3: unfederated tree (no weft.toml up the chain) → fallback to /.wardline/ +# --------------------------------------------------------------------------- + +def test_cli_unfederated_tree_falls_back_to_scan_path(tmp_path: Path) -> None: + """No ``weft.toml`` anywhere in the ancestry → ``project_root_for`` returns + ``scan_path`` itself, so the artifact lands at ``/.wardline/``.""" + # tmp_path is deeply nested under /tmp — well outside any weft project root. + # Do NOT create a weft.toml. + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 0, result.output + artifact = _only_scan_artifact(tmp_path) + assert artifact.parent == tmp_path / ".wardline" + assert _STAMPED_JSONL_RE.match(artifact.name) + + +# --------------------------------------------------------------------------- +# Test 4: custom artifacts.dir anchors to /out/wl +# --------------------------------------------------------------------------- + +def test_cli_custom_artifacts_dir_anchors_to_project_root(tmp_path: Path) -> None: + """``[wardline.artifacts] dir = "out/wl"`` in weft.toml (root scan) → artifact at + ``/out/wl/``, NOT at ``/.wardline/``. + + Subdir scans don't load the project root's config (by design — the docstring and + task 3 message both say subdir scans don't load project policy). Root scans do. + """ + (tmp_path / "weft.toml").write_text( + '[wardline]\nsource_roots = ["."]\n\n[wardline.artifacts]\ndir = "out/wl"\n', + encoding="utf-8", + ) + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 0, result.output + artifact_dir = tmp_path / "out" / "wl" + assert artifact_dir.exists(), f"expected artifact dir at {artifact_dir}" + jsonl_files = sorted(artifact_dir.glob("*-findings.jsonl")) + assert jsonl_files, f"no artifacts in {artifact_dir}" + assert _STAMPED_JSONL_RE.match(jsonl_files[0].name) + # No default .wardline at project root (custom dir takes over) + assert not (tmp_path / ".wardline").exists() + + +# --------------------------------------------------------------------------- +# Test 5: explicit --output is unaffected (verbatim) and no .wardline/ is created +# --------------------------------------------------------------------------- + +def test_cli_explicit_output_unaffected(tmp_path: Path) -> None: + """``--output path/to/findings.jsonl`` writes exactly there and NEVER creates a + ``.wardline/`` directory under the project root.""" + (tmp_path / "weft.toml").write_text('[wardline]\n', encoding="utf-8") + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + out_dir = tmp_path / "ci" + out_dir.mkdir() + out = out_dir / "findings.jsonl" + + result = CliRunner().invoke(cli, ["scan", str(tmp_path), "--output", str(out)]) + + assert result.exit_code == 0, result.output + assert out.exists(), f"expected output at {out}" + # No automatic .wardline/ when --output is explicit + assert not (tmp_path / ".wardline").exists(), ( + ".wardline/ was created even though --output was explicit" + ) + + +# --------------------------------------------------------------------------- +# Test 6: MCP _scan() writes NO disk artifact (regression guard) +# --------------------------------------------------------------------------- + +def test_mcp_scan_writes_no_disk_artifact(tmp_path: Path) -> None: + """The MCP ``scan`` tool must NEVER write a ``.wardline/`` disk artifact. + + The MCP surface returns findings in-band over JSON-RPC; disk writes are the + CLI's job. This is a regression guard: any change that wires write_scan_artifact + into the MCP path would be caught here.""" + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + + result = mcp_scan(args={}, root=tmp_path) + + # Sanity check: scan actually ran and returned a result dict + assert isinstance(result, dict), f"expected dict result, got {type(result)}" + # No .wardline/ may exist after an MCP scan + assert not (tmp_path / ".wardline").exists(), ( + "MCP _scan() created a .wardline/ directory — disk writes must be CLI-only" + ) diff --git a/tests/unit/cli/test_scan_job.py b/tests/unit/cli/test_scan_job.py index 4d6e0b6b..a03cce00 100644 --- a/tests/unit/cli/test_scan_job.py +++ b/tests/unit/cli/test_scan_job.py @@ -257,6 +257,79 @@ def test_scan_job_start_allows_timeout_opt_out(tmp_path: Path) -> None: assert payload["request"]["timeout_seconds"] == 0.0 +def test_scan_job_start_redacts_filigree_url_in_stdout_request(tmp_path: Path, monkeypatch) -> None: + project = tmp_path / "proj" + project.mkdir() + secret_url = "https://user:secret@filigree.example/api/p/demo/weft/scan-results?token=abc#frag" + redacted_url = "https://@filigree.example/api/p/demo/weft/scan-results" + captured_requests: list[dict[str, object]] = [] + + def fake_start_scan_job(root: Path, request: dict[str, object], *, foreground: bool = False) -> dict[str, object]: + captured_requests.append(dict(request)) + payload = _base_job("c" * 32) + payload["request"] = dict(request) + return payload + + monkeypatch.setattr("wardline.cli.scan_job.start_scan_job", fake_start_scan_job) + + result = CliRunner().invoke(cli, ["scan-job", "start", str(project), "--filigree-url", secret_url]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert captured_requests[0]["filigree_url"] == secret_url + assert payload["request"]["filigree_url"] == redacted_url + assert "user:secret" not in result.output + assert "token=abc" not in result.output + assert "#frag" not in result.output + + +def test_scan_job_status_redacts_filigree_url_in_stdout_request(tmp_path: Path, monkeypatch) -> None: + project = tmp_path / "proj" + project.mkdir() + secret_url = "https://user:secret@filigree.example/api/p/demo/weft/scan-results?token=abc#frag" + redacted_url = "https://@filigree.example/api/p/demo/weft/scan-results" + + def fake_read_scan_job_status(root: Path, job_id: str) -> dict[str, object]: + payload = _base_job(job_id) + payload["request"] = {"filigree_url": secret_url} + return payload + + monkeypatch.setattr("wardline.cli.scan_job.read_scan_job_status", fake_read_scan_job_status) + + result = CliRunner().invoke(cli, ["scan-job", "status", "d" * 32, "--path", str(project)]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["request"]["filigree_url"] == redacted_url + assert "user:secret" not in result.output + assert "token=abc" not in result.output + assert "#frag" not in result.output + + +def test_scan_job_cancel_redacts_filigree_url_in_stdout_request(tmp_path: Path, monkeypatch) -> None: + project = tmp_path / "proj" + project.mkdir() + secret_url = "https://user:secret@filigree.example/api/p/demo/weft/scan-results?token=abc#frag" + redacted_url = "https://@filigree.example/api/p/demo/weft/scan-results" + + def fake_cancel_scan_job(root: Path, job_id: str) -> dict[str, object]: + payload = _base_job(job_id) + payload["status"] = "cancelled" + payload["request"] = {"filigree_url": secret_url} + return payload + + monkeypatch.setattr("wardline.cli.scan_job.cancel_scan_job", fake_cancel_scan_job) + + result = CliRunner().invoke(cli, ["scan-job", "cancel", "e" * 32, "--path", str(project)]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["request"]["filigree_url"] == redacted_url + assert "user:secret" not in result.output + assert "token=abc" not in result.output + assert "#frag" not in result.output + + def test_scan_job_background_worker_does_not_run_from_untrusted_root(tmp_path: Path, monkeypatch) -> None: project = tmp_path / "proj" project.mkdir() diff --git a/tests/unit/core/test_artifacts.py b/tests/unit/core/test_artifacts.py new file mode 100644 index 00000000..1ce200ab --- /dev/null +++ b/tests/unit/core/test_artifacts.py @@ -0,0 +1,85 @@ +"""Tests for scan artifact path anchoring and retention.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +from wardline.core import artifacts +from wardline.core.config import ArtifactSettings, WardlineConfig + + +def _project(tmp_path: Path) -> None: + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + + +def test_subdir_scan_anchors_artifact_to_project_root(tmp_path: Path) -> None: + _project(tmp_path) + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + out = artifacts.write_scan_artifact(sub, "jsonl", WardlineConfig(), "{}\n") + assert out.parent == (tmp_path.resolve() / ".wardline") + assert out.read_text(encoding="utf-8") == "{}\n" + + +def test_root_scan_unchanged(tmp_path: Path) -> None: + _project(tmp_path) + out = artifacts.write_scan_artifact(tmp_path, "jsonl", WardlineConfig(), "{}\n") + assert out.parent == (tmp_path.resolve() / ".wardline") + + +def test_unfederated_scan_writes_at_scan_path(tmp_path: Path) -> None: + sub = tmp_path / "loose" + sub.mkdir() + out = artifacts.write_scan_artifact(sub, "jsonl", WardlineConfig(), "{}\n") + assert out.parent == (sub.resolve() / ".wardline") + + +def test_escaping_artifacts_dir_falls_back_under_project_root(tmp_path: Path) -> None: + _project(tmp_path) + cfg = WardlineConfig(artifacts=ArtifactSettings(dir="../../etc")) + out = artifacts.write_scan_artifact(tmp_path, "jsonl", cfg, "{}\n") + assert out.parent == (tmp_path.resolve() / ".wardline") + + +# --- Existing retention / collision tests --- + +def test_retention_prunes_oldest(tmp_path: Path) -> None: + """prune_scan_artifacts removes the oldest artifact when retain=2 and 2 exist.""" + _project(tmp_path) + artifact_dir = tmp_path / ".wardline" + artifact_dir.mkdir(parents=True, exist_ok=True) + + # Create two "old" artifacts manually. + old1 = artifact_dir / "20240101T000000Z-findings.jsonl" + old2 = artifact_dir / "20240101T000001Z-findings.jsonl" + old1.write_text("old1\n", encoding="utf-8") + old2.write_text("old2\n", encoding="utf-8") + + # Write a new artifact — with retain=2 the oldest should be pruned. + cfg = WardlineConfig(artifacts=ArtifactSettings(retain=2)) + out = artifacts.write_scan_artifact(tmp_path, "jsonl", cfg, "new\n") + + remaining = sorted(artifact_dir.iterdir(), key=lambda p: p.name) + names = [p.name for p in remaining] + assert out.name in names + # Exactly one of the two old files should survive (the newer one). + assert old2.name in names + assert old1.name not in names + + +def test_collision_handled(tmp_path: Path) -> None: + """write_scan_artifact allocates a unique name if the first candidate exists.""" + _project(tmp_path) + artifact_dir = tmp_path / ".wardline" + artifact_dir.mkdir(parents=True, exist_ok=True) + + # Occupy a large number of candidates for the current second. + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + (artifact_dir / f"{stamp}-findings.jsonl").write_text("occupied\n", encoding="utf-8") + for i in range(1, 5): + (artifact_dir / f"{stamp}-{i:03d}-findings.jsonl").write_text(f"occupied-{i}\n", encoding="utf-8") + + out = artifacts.write_scan_artifact(tmp_path, "jsonl", WardlineConfig(), "new\n") + assert out.exists() + assert out.read_text(encoding="utf-8") == "new\n" diff --git a/tests/unit/core/test_delta.py b/tests/unit/core/test_delta.py index 5b16c7af..9d0ca7ba 100644 --- a/tests/unit/core/test_delta.py +++ b/tests/unit/core/test_delta.py @@ -43,8 +43,24 @@ def run_dispatch(args, **kwargs): res = get_changed_files_since("HEAD~1", root) assert res == {"foo.py", "bar.py", "baz.py"} - assert mock_run.call_args_list[1].args[0] == ["git", "rev-parse", "--verify", "--end-of-options", "HEAD~1"] - assert mock_run.call_args_list[2].args[0] == ["git", "diff", "--name-only", "abc123", "--"] + assert mock_run.call_args_list[1].args[0] == [ + "git", + "-c", + "core.fsmonitor=false", + "rev-parse", + "--verify", + "--end-of-options", + "HEAD~1", + ] + assert mock_run.call_args_list[2].args[0] == [ + "git", + "-c", + "core.fsmonitor=false", + "diff", + "--name-only", + "abc123", + "--", + ] @patch("subprocess.run") diff --git a/tests/unit/core/test_delta_resolve.py b/tests/unit/core/test_delta_resolve.py index 9a9177e5..dc4ba7b9 100644 --- a/tests/unit/core/test_delta_resolve.py +++ b/tests/unit/core/test_delta_resolve.py @@ -14,6 +14,7 @@ from typing import Any from wardline.core.delta_resolve import ( + QualnameIndex, build_qualname_index, canonical_qualname, resolve_affected_scope, @@ -60,6 +61,16 @@ def _scope(*entities: AffectedEntity, source_kind: str = "entity_list") -> Affec return AffectedScope(frozenset(entities), source_kind, len(entities)) +class CountingEntities(dict[str, str]): + def __init__(self, items: dict[str, str]) -> None: + super().__init__(items) + self.iterations = 0 + + def __iter__(self): # type: ignore[override] + self.iterations += 1 + return super().__iter__() + + # --- canonicalization helper ------------------------------------------------- @@ -321,6 +332,24 @@ def test_caller_closure_does_not_expand_from_unrelated_entities_in_same_file(tmp assert resolved.files == frozenset({"a.py", "b.py"}) +def test_caller_closure_exact_locators_do_not_scan_every_entity_per_locator() -> None: + # Exact function/method locators should seed the reverse-call closure directly. The + # class-prefix expansion is the only path that needs a full entity scan; doing that + # for every exact locator makes large worklists O(N affected * N indexed entities). + entities = CountingEntities({f"pkg.f{i}": f"f{i}.py" for i in range(50)}) + index = QualnameIndex( + by_qualname=dict(entities), + project_edges={}, + entities=entities, # type: ignore[arg-type] + ) + scope = _scope(*(AffectedEntity(sei=None, locator=f"python:function:pkg.f{i}") for i in range(50))) + + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + + assert len(resolved.files) == 50 + assert entities.iterations <= 1 + + def test_caller_closure_self_method(tmp_path: Path) -> None: src = ( "class Svc:\n def sink(self):\n return self.source()\n def source(self):\n return input()\n" diff --git a/tests/unit/core/test_discovery.py b/tests/unit/core/test_discovery.py index dbc35a09..33f35afd 100644 --- a/tests/unit/core/test_discovery.py +++ b/tests/unit/core/test_discovery.py @@ -76,26 +76,51 @@ def test_confine_excludes_symlink_escaping_root(tmp_path: Path) -> None: def test_discover_rust_suffix(tmp_path: Path) -> None: # The suffix parameter routes discovery to a different language's files: a - # `.rs` sweep finds `a.rs`, never `a.py`; `target/` (cargo build output) is - # skipped; and the default (no `suffixes`) call is byte-unchanged Python-only. + # `.rs` sweep finds `a.rs`, never `a.py`; and the default (no `suffixes`) call + # is byte-unchanged Python-only. root = tmp_path / "root" src = root / "src" src.mkdir(parents=True) (src / "a.rs").write_text("fn main() {}\n", encoding="utf-8") (src / "a.py").write_text("x = 1\n", encoding="utf-8") - built = src / "target" - built.mkdir() - (built / "built.rs").write_text("fn x() {}\n", encoding="utf-8") cfg = WardlineConfig(source_roots=("src",)) rust_files = discover(root, cfg, suffixes=frozenset({".rs"})) - assert sorted(p.name for p in rust_files) == ["a.rs"] # a.rs only; not a.py, not target/ + assert sorted(p.name for p in rust_files) == ["a.rs"] # a.rs only; not a.py default_files = discover(root, cfg) # default suffixes -> Python only assert sorted(p.name for p in default_files) == ["a.py"] +def test_rust_discovery_prunes_only_project_root_target_dir(tmp_path: Path) -> None: + root = tmp_path / "root" + (root / "src" / "target").mkdir(parents=True) + (root / "src" / "target" / "mod.rs").write_text("pub fn legitimate() {}\n", encoding="utf-8") + (root / "target" / "debug").mkdir(parents=True) + (root / "target" / "debug" / "generated.rs").write_text("fn generated() {}\n", encoding="utf-8") + + files = discover(root, WardlineConfig(source_roots=(".",)), suffixes=frozenset({".rs"})) + + assert [p.relative_to(root).as_posix() for p in files] == ["src/target/mod.rs"] + + +def test_mixed_suffix_discovery_keeps_nested_target_packages(tmp_path: Path) -> None: + root = tmp_path / "root" + pkg = root / "src" / "target" + pkg.mkdir(parents=True) + (pkg / "mod.rs").write_text("pub fn legitimate() {}\n", encoding="utf-8") + (pkg / "service.py").write_text("x = 1\n", encoding="utf-8") + (root / "target").mkdir() + (root / "target" / "generated.py").write_text("y = 2\n", encoding="utf-8") + (root / "target" / "generated.rs").write_text("fn generated() {}\n", encoding="utf-8") + + files = discover(root, WardlineConfig(source_roots=(".",)), suffixes=frozenset({".py", ".rs"})) + + rel = [p.relative_to(root).as_posix() for p in files] + assert rel == ["src/target/mod.rs", "src/target/service.py"] + + def test_target_dir_is_not_skipped_for_python(tmp_path: Path) -> None: # `target` is a cargo build dir, skipped only in `.rs` mode. It is a perfectly # legitimate Python package name, so a default `.py` scan must NOT silently @@ -112,6 +137,22 @@ def test_target_dir_is_not_skipped_for_python(tmp_path: Path) -> None: assert [p.name for p in files] == ["m.py"] +def test_repo_gitignore_cannot_hide_source_from_discovery(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("pkg/\nsrc/generated/\n", encoding="utf-8") + (tmp_path / "app.py").write_text("x = 1\n", encoding="utf-8") + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "hidden.py").write_text("y = 2\n", encoding="utf-8") + generated = tmp_path / "src" / "generated" + generated.mkdir(parents=True) + (generated / "handler.py").write_text("z = 3\n", encoding="utf-8") + + files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + + rel = sorted(p.relative_to(tmp_path).as_posix() for p in files) + assert rel == ["app.py", "pkg/hidden.py", "src/generated/handler.py"] + + def test_discover_rust_symlink_confined(tmp_path: Path) -> None: # The THREAT-001 confinement invariant holds for `.rs` discovery too: a `.rs` # file-symlink inside a legitimate source_root pointing OUTSIDE the root is @@ -140,17 +181,16 @@ def test_discover_rust_symlink_confined(tmp_path: Path) -> None: assert all(p.name != "evil.rs" for p in files) -def test_gitignored_dir_is_not_scanned(tmp_path: Path) -> None: - # The owner's top blocker: discover() must consult .gitignore and PRUNE matched - # directories during the walk, never descending a multi-GB gitignored third-party - # tree. A dir listed in the project-root .gitignore yields none of its files. +def test_respect_gitignore_prunes_dir_when_explicitly_enabled(tmp_path: Path) -> None: + # .gitignore is repository-controlled and is not the default scan boundary, but + # trusted callers may still opt into Git-like pruning for bulk third-party trees. (tmp_path / ".gitignore").write_text("third_party/\n", encoding="utf-8") (tmp_path / "app.py").write_text("x = 1\n", encoding="utf-8") vendored = tmp_path / "third_party" / "huge" / "deep" vendored.mkdir(parents=True) (vendored / "lib.py").write_text("y = 2\n", encoding="utf-8") - files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + files = discover(tmp_path, WardlineConfig(source_roots=(".",)), respect_gitignore=True) rel = [p.relative_to(tmp_path).as_posix() for p in files] assert rel == ["app.py"] @@ -158,9 +198,8 @@ def test_gitignored_dir_is_not_scanned(tmp_path: Path) -> None: def test_gitignored_walk_does_not_descend_ignored_dir(tmp_path: Path, monkeypatch) -> None: - # Pruning must happen DURING the walk (dirnames[:] in place), so os.walk never - # even enters the ignored subtree — not merely a post-filter. Assert the ignored - # directory is never yielded by the walk. + # When the trusted opt-in is enabled, pruning must happen DURING the walk + # (dirnames[:] in place), so os.walk never enters the ignored subtree. import wardline.core.discovery as discovery_mod (tmp_path / ".gitignore").write_text(".venv-vendor/\n", encoding="utf-8") @@ -178,7 +217,7 @@ def spy_walk(top, *args, **kwargs): yield dirpath, dirnames, filenames monkeypatch.setattr(discovery_mod.os, "walk", spy_walk) - discover(tmp_path, WardlineConfig(source_roots=(".",))) + discover(tmp_path, WardlineConfig(source_roots=(".",)), respect_gitignore=True) assert ".venv-vendor" not in walked assert "pkg" not in walked @@ -197,7 +236,7 @@ def test_negated_gitignore_pattern_keeps_dir(tmp_path: Path) -> None: (keep / "k.py").write_text("x = 1\n", encoding="utf-8") (drop / "d.py").write_text("y = 2\n", encoding="utf-8") - files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + files = discover(tmp_path, WardlineConfig(source_roots=(".",)), respect_gitignore=True) rel = [p.relative_to(tmp_path).as_posix() for p in files] assert rel == ["build-keep/k.py"] @@ -247,7 +286,7 @@ def test_nested_gitignore_layers(tmp_path: Path) -> None: other.mkdir() (other / "o.py").write_text("z = 3\n", encoding="utf-8") - files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + files = discover(tmp_path, WardlineConfig(source_roots=(".",)), respect_gitignore=True) rel = sorted(p.relative_to(tmp_path).as_posix() for p in files) assert rel == ["generated/o.py", "pkg/real.py"] @@ -264,7 +303,7 @@ def test_nested_anchored_gitignore_pattern_is_relative_to_its_own_directory(tmp_ sibling.mkdir() (sibling / "keep.py").write_text("z = 3\n", encoding="utf-8") - files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + files = discover(tmp_path, WardlineConfig(source_roots=(".",)), respect_gitignore=True) rel = sorted(p.relative_to(tmp_path).as_posix() for p in files) assert rel == ["generated/keep.py", "pkg/real.py"] @@ -283,7 +322,7 @@ def test_gitignore_does_not_prune_outside_root(tmp_path: Path) -> None: (data / "keep.py").write_text("x = 1\n", encoding="utf-8") cfg = WardlineConfig(source_roots=("../sibling",)) - files = discover(root, cfg) + files = discover(root, cfg, respect_gitignore=True) # The root's `data/` rule must not reach into the out-of-root sibling tree. rel = sorted(p.name for p in files) diff --git a/tests/unit/core/test_filigree_destination.py b/tests/unit/core/test_filigree_destination.py index 86e02db7..c8e57c9c 100644 --- a/tests/unit/core/test_filigree_destination.py +++ b/tests/unit/core/test_filigree_destination.py @@ -24,7 +24,7 @@ def test_url_project_none_when_unpinned() -> None: def test_destination_pinned() -> None: d = filigree_destination("http://127.0.0.1:8749/api/weft/scan-results?project=lacuna") assert d == { - "url": "http://127.0.0.1:8749/api/weft/scan-results?project=lacuna", + "url": "http://127.0.0.1:8749/api/weft/scan-results", "project": "lacuna", "project_pinned": True, } diff --git a/tests/unit/core/test_filigree_emit.py b/tests/unit/core/test_filigree_emit.py index b423bc3e..3b6f5ce9 100644 --- a/tests/unit/core/test_filigree_emit.py +++ b/tests/unit/core/test_filigree_emit.py @@ -11,6 +11,7 @@ FiligreeEmitter, Response, build_scan_results_body, + filigree_destination, filigree_disabled_reason, ) from wardline.core.finding import ( @@ -84,6 +85,24 @@ def test_scan_results_body_disables_mark_unseen_when_scan_is_unanalyzed() -> Non assert body["findings"][0]["rule_id"] == "WLN-ENGINE-PARSE-ERROR" +def test_scan_results_body_disables_mark_unseen_for_function_skip() -> None: + function_skip = _f( + rule_id="WLN-ENGINE-FUNCTION-SKIPPED", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="src/m.py", line_start=7), + fingerprint="c" * 64, + qualname="pkg.m.leaky", + properties={"reason": "taint_budget_exceeded"}, + ) + + body = build_scan_results_body([function_skip], scanned_paths=("src/m.py",)) + + assert body["mark_unseen"] is False + assert body["scanned_paths"] == ["src/m.py"] + assert body["findings"][0]["rule_id"] == "WLN-ENGINE-FUNCTION-SKIPPED" + + def test_finding_uses_path_not_file_path() -> None: wire = build_scan_results_body([_f()])["findings"][0] assert wire["path"] == "src/m.py" @@ -481,6 +500,44 @@ def test_disabled_reason_403_and_unreachable_unchanged_in_shape() -> None: assert filigree_disabled_reason(reachable=True, status=None) is None +def test_diagnostic_url_redaction_removes_credentials_query_and_fragment() -> None: + url = "https://user:secret@filigree.example:8443/api/p/demo/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.example:8443/api/p/demo/weft/scan-results" + + reason = filigree_disabled_reason(reachable=False, status=401, token_sent=True, url=url) + destination = filigree_destination(url) + + assert reason is not None + assert redacted in reason + assert destination == {"url": redacted, "project": "demo", "project_pinned": True} + exposed = json.dumps({"reason": reason, "destination": destination}) + assert "user:secret" not in exposed + assert "token=abc" not in exposed + assert "#frag" not in exposed + + +def test_diagnostic_url_redaction_handles_scheme_relative_userinfo() -> None: + from wardline.core.filigree_emit import redact_url_for_diagnostics + + redacted = redact_url_for_diagnostics("//user:secret@filigree.example/api/weft/scan-results?token=abc#frag") + + assert redacted == "//@filigree.example/api/weft/scan-results" + assert "user:secret" not in redacted + assert "token=abc" not in redacted + assert "#frag" not in redacted + + +def test_diagnostic_url_redaction_handles_bare_userinfo_like_value() -> None: + from wardline.core.filigree_emit import redact_url_for_diagnostics + + redacted = redact_url_for_diagnostics("user:secret@filigree.example/api/weft/scan-results?token=abc#frag") + + assert redacted == "@filigree.example/api/weft/scan-results" + assert "user:secret" not in redacted + assert "token=abc" not in redacted + assert "#frag" not in redacted + + def test_2xx_with_unparseable_body_warns_not_crashes() -> None: # POST accepted (2xx) but the body is not a JSON object -> surface a warning, # reachable=True, zeroed stats; must NOT raise. @@ -582,6 +639,41 @@ def test_protocol_reject_fail_soft_records_each_pending_finding_as_partial() -> assert all(f.fingerprint == _PREFIXED_A for f in res.failures) +def test_protocol_reject_fail_soft_does_not_duplicate_response_body_per_failure() -> None: + response_body = "remote rejection detail: " + ("x" * 4096) + findings = [ + _f( + fingerprint=f"{i:064x}", + location=Location(path=f"src/{i}.py", line_start=1), + ) + for i in range(12) + ] + t = _FakeTransport(response=Response(status=422, body=response_body)) + + res = FiligreeEmitter("http://x", transport=t, protocol_errors_loud=False).emit(findings) + + assert res.failed == len(findings) + assert all(f.reason == "partial" for f in res.failures) + assert all("422" in f.detail for f in res.failures) + assert all(response_body not in f.detail for f in res.failures) + assert json.dumps([f.to_wire() for f in res.failures]).count(response_body) == 0 + assert json.dumps(res.warnings).count(response_body) == 1 + + +def test_protocol_reject_warning_redacts_url_secrets() -> None: + url = "https://user:secret@filigree.example/api/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.example/api/weft/scan-results" + t = _FakeTransport(response=Response(status=422, body="payload rejected")) + + res = FiligreeEmitter(url, transport=t, protocol_errors_loud=False).emit([_f()]) + + assert res.warnings + assert redacted in res.warnings[0] + assert "user:secret" not in res.warnings[0] + assert "token=abc" not in res.warnings[0] + assert "#frag" not in res.warnings[0] + + def test_failed_count_is_derived_from_failures_and_cannot_disagree() -> None: # The count is a property over failures — there is no setter to hardwire a contradictory # failed=0 while failures is non-empty (the confident-empty defect the invariant forbids). @@ -683,6 +775,35 @@ def test_urllib_transport_rejects_non_http_scheme() -> None: UrllibTransport().post("file:///etc/passwd", b"{}", {}) +@pytest.mark.parametrize("method", ["get", "post"]) +def test_urllib_transport_redacts_invalid_url_exceptions(monkeypatch, method: str) -> None: + import http.client + import urllib.request + + from wardline.core.filigree_emit import UrllibTransport + + url = "https://user:secret@filigree.example/api/weft/scan-results?token=abc#frag" + + def _raise_invalid_url(req, timeout=None): # noqa: ARG001 + raise http.client.InvalidURL("nonnumeric port: 'secret@filigree.example'") + + monkeypatch.setattr(urllib.request, "urlopen", _raise_invalid_url) + transport = UrllibTransport() + + with pytest.raises(FiligreeEmitError) as exc: + if method == "get": + transport.get(url, {}) + else: + transport.post(url, b"{}", {"Content-Type": "application/json"}) + + message = str(exc.value) + assert "https://@filigree.example/api/weft/scan-results" in message + assert "user:secret" not in message + assert "secret@filigree.example" not in message + assert "token=abc" not in message + assert "#frag" not in message + + def test_judged_finding_carries_suppression_metadata() -> None: wire = build_scan_results_body([_f(suppressed=SuppressionState.JUDGED, suppression_reason="over-taint floor")])[ "findings" diff --git a/tests/unit/core/test_filigree_issue.py b/tests/unit/core/test_filigree_issue.py index dcc767f5..37604742 100644 --- a/tests/unit/core/test_filigree_issue.py +++ b/tests/unit/core/test_filigree_issue.py @@ -14,6 +14,7 @@ Response, api_base_url_from_weft, attach_loomweave_identity_for_finding, + attach_loomweave_identity_for_qualname, build_promote_body, promote_url_from_weft, ) @@ -456,6 +457,45 @@ def get_taint_fact(self, qualname): assert transport.calls[0]["body"]["entity_kind"] == "rust:function" +def test_attach_refuses_unresolved_hinted_legacy_locator_even_with_taint_fact(): + class HintedRejectedLegacyLoomweave: + def __init__(self): + self.plugin_hints = [] + self.fact_calls = [] + + def capabilities(self): + return None # pre-SEI -> the legacy locator path + + def resolve(self, qualnames, *, plugin=None): + self.plugin_hints.append(plugin) + return SimpleNamespace(resolved={}, unresolved=list(qualnames)) + + def get_taint_fact(self, qualname): + self.fact_calls.append(qualname) + return SimpleNamespace(current_content_hash="wrong-plugin-hash") + + client = HintedRejectedLegacyLoomweave() + transport = RecordingTransport() + filer = FiligreeIssueFiler("http://f/api/weft/scan-results", transport=transport) + + res = attach_loomweave_identity_for_qualname( + qualname="demo.m.leaky", + issue_id="wardline-1", + filer=filer, + loomweave_client=client, + plugin="rust", + ) + + assert client.plugin_hints == ["rust"] + assert client.fact_calls == [] + assert res.attempted is True + assert res.attached is False + assert res.entity_id is None + assert res.content_hash is None + assert res.reason == "Loomweave did not resolve legacy locator binding; association not attached" + assert transport.calls == [] + + def test_file_2xx_non_string_issue_id_is_normalized_to_none(): # Filigree's promote response is external input: a non-string issue_id (e.g. an # integer) must not flow verbatim into tool payloads that publish issue_id as diff --git a/tests/unit/core/test_fingerprint_stability.py b/tests/unit/core/test_fingerprint_stability.py index 8409e38b..13cfc796 100644 --- a/tests/unit/core/test_fingerprint_stability.py +++ b/tests/unit/core/test_fingerprint_stability.py @@ -6,8 +6,13 @@ an ENTITY-RELATIVE discriminator in ``taint_path`` (``node.lineno - entity.location.line_start`` + the lexical span). So the contract is: - * **Anchor-preserving edits** — rename a local, add a trailing comment, edit - code *below* the finding — keep the fingerprint byte-identical. + * **Anchor-preserving edits** — add a trailing comment or edit code *below* the + finding — keep the fingerprint byte-identical. + * **Singleton body/signature edits** — rename a local or parameter, change the + entity body shape, or otherwise alter source semantics — change the + fingerprint. Singleton entity-level rules use a line-independent source + discriminator so a same-qualname redefinition cannot inherit stale + suppressions. * **Whole-entity moves** — inserting a blank line / comment ABOVE the flagged ``def`` shifts every absolute line but keeps the fingerprint, because the discriminator is relative to the enclosing entity. This is the churn fix @@ -16,7 +21,7 @@ ABOVE a multi-emit node (a sink call), DOES change that finding's fingerprint, because the node's offset relative to its def moved. This is the accepted limitation: entity-relative, not move-stable in the strong sense. - (A def-anchored singleton, taint_path=None, is immune to in-function edits.) + (A def-anchored singleton is line-independent, but not body-independent.) """ from __future__ import annotations @@ -50,12 +55,19 @@ def v(p): _DECL_ANCHOR_PRESERVING = """ @trust_boundary(to_level='ASSURED') def v(p): - payload = p # renamed local + trailing comment - return payload + data = p # trailing comment + return data def added_below(): return 1 """ +_DECL_LOCAL_RENAME = """ +@trust_boundary(to_level='ASSURED') +def v(p): + payload = p + return payload +""" + _DECL_LINE_SHIFTING = """ @trust_boundary(to_level='ASSURED') @@ -71,11 +83,17 @@ def test_declaration_anchor_preserving_edits_keep_fingerprint(tmp_path: Path) -> assert base and base == preserved +def test_declaration_local_rename_changes_fingerprint(tmp_path: Path) -> None: + base = _fingerprints(tmp_path, _DECL_BASE, "PY-WL-102") + renamed = _fingerprints(tmp_path, _DECL_LOCAL_RENAME, "PY-WL-102") + assert base and renamed and base != renamed + + def test_declaration_whole_entity_move_keeps_fingerprint(tmp_path: Path) -> None: # A blank line ABOVE the def moves the whole entity down. Under wlfp2 the - # def-anchored singleton (taint_path=None, qualname-keyed) is invariant to that - # — the churn fix (wardline-8654423823): a benign edit above a function no - # longer rekeys its baseline/waiver/Filigree join. + # def-anchored singleton discriminator is invariant to that — the churn fix + # (wardline-8654423823): a benign edit above a function no longer rekeys its + # baseline/waiver/Filigree join. base = _fingerprints(tmp_path, _DECL_BASE, "PY-WL-102") shifted = _fingerprints(tmp_path, _DECL_LINE_SHIFTING, "PY-WL-102") assert base and shifted and base == shifted diff --git a/tests/unit/core/test_gitignore.py b/tests/unit/core/test_gitignore.py index 8d8df24f..1b2a94d5 100644 --- a/tests/unit/core/test_gitignore.py +++ b/tests/unit/core/test_gitignore.py @@ -1,5 +1,9 @@ +import shutil +import subprocess from pathlib import Path +import pytest + from wardline.core.gitignore import GitignoreMatcher @@ -102,3 +106,46 @@ def test_character_class() -> None: m = GitignoreMatcher.from_text("build[0-9]/\n") assert m.match("build3", is_dir=True) assert not m.match("buildX", is_dir=True) + + +def test_repo_gitignore_tracks_wardline_suppression_state() -> None: + if shutil.which("git") is None: + pytest.skip("git is required to validate repository ignore policy") + repo = Path(__file__).resolve().parents[3] + in_worktree = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--is-inside-work-tree"], + check=False, + capture_output=True, + text=True, + ) + if in_worktree.returncode != 0: + pytest.skip("repository ignore policy test requires a git checkout") + + def check_ignore(path: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), "check-ignore", "--no-index", "-v", path], + check=False, + capture_output=True, + text=True, + ) + + for state_file in ( + ".weft/wardline/baseline.yaml", + ".weft/wardline/waivers.yaml", + ".weft/wardline/judged.yaml", + ): + result = check_ignore(state_file) + assert result.returncode == 1, result.stdout + result.stderr + + for sibling_store_file in ( + ".weft/filigree/federation_token", + ".weft/loomweave/loomweave.db", + ".weft/warpline/warpline.db", + ): + result = check_ignore(sibling_store_file) + assert result.returncode == 0, result.stdout + result.stderr + + for port_file in (".weft/new-sibling/ephemeral.port",): + result = check_ignore(port_file) + assert result.returncode == 0, result.stdout + result.stderr + assert ".weft/*/ephemeral.port" in result.stdout diff --git a/tests/unit/core/test_http.py b/tests/unit/core/test_http.py index d390c3b4..195d25ed 100644 --- a/tests/unit/core/test_http.py +++ b/tests/unit/core/test_http.py @@ -1,6 +1,12 @@ from __future__ import annotations -from wardline.core.http import MAX_RESPONSE_BODY_BYTES, read_response_text +import io +import urllib.error +import urllib.request + +import pytest + +from wardline.core.http import MAX_RESPONSE_BODY_BYTES, HttpResult, WeftHttp, read_response_text class _HugeStream: @@ -20,3 +26,148 @@ def test_read_response_text_reads_at_most_limit_plus_sentinel() -> None: assert stream.requested_size == MAX_RESPONSE_BODY_BYTES + 1 assert len(text) < MAX_RESPONSE_BODY_BYTES + 128 assert text.endswith("[truncated]") + + +# --- WeftHttp shared transport ---------------------------------------------- + + +class _Resp(io.BytesIO): + """A urlopen() return value: a context-managed body stream with a ``status``.""" + + def __init__(self, data: bytes, status: int = 200) -> None: + super().__init__(data) + self.status = status + + def __enter__(self) -> _Resp: + return self + + def __exit__(self, *a: object) -> None: + self.close() + + +def test_fetch_round_trips_status_and_body(monkeypatch) -> None: + seen: dict[str, object] = {} + + def _urlopen(req, timeout=None): + seen["url"] = req.full_url + seen["method"] = req.get_method() + seen["timeout"] = timeout + seen["body"] = req.data + seen["headers"] = dict(req.header_items()) + return _Resp(b'{"ok":true}', status=200) + + monkeypatch.setattr(urllib.request, "urlopen", _urlopen) + http = WeftHttp(timeout=12.5) + result = http.fetch("POST", "http://h/api", body=b"payload", headers={"Content-Type": "application/json"}) + + assert isinstance(result, HttpResult) + assert result.status == 200 + assert result.body == '{"ok":true}' + # timeout is threaded through to urlopen unchanged + assert seen["timeout"] == 12.5 + assert seen["method"] == "POST" + assert seen["body"] == b"payload" + # header is carried through Request construction (urllib title-cases the key) + assert seen["headers"].get("Content-type") == "application/json" + + +def test_fetch_surfaces_http_error_as_result_not_raise(monkeypatch) -> None: + # an HTTP 4xx/5xx (HTTPError, a URLError subclass) is converted to an HttpResult + # carrying its status — never re-raised as an outage, so callers classify by band. + def _raise(req, timeout=None): # noqa: ARG001 + raise urllib.error.HTTPError("http://h", 503, "down", {}, io.BytesIO(b"boom")) + + monkeypatch.setattr(urllib.request, "urlopen", _raise) + result = WeftHttp().fetch("GET", "http://h/api") + assert result.status == 503 + assert result.body == "boom" + + +def test_fetch_does_not_swallow_urlerror(monkeypatch) -> None: + # URLError (a transport outage, NOT an HTTP status) propagates to the caller, whose + # own fail-soft policy decides what an outage means. WeftHttp must not catch it. + def _raise(req, timeout=None): # noqa: ARG001 + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(urllib.request, "urlopen", _raise) + with pytest.raises(urllib.error.URLError): + WeftHttp().fetch("GET", "http://h/api") + + +def test_fetch_does_not_swallow_oserror(monkeypatch) -> None: + def _raise(req, timeout=None): # noqa: ARG001 + raise OSError("timed out") + + monkeypatch.setattr(urllib.request, "urlopen", _raise) + with pytest.raises(OSError, match="timed out"): + WeftHttp().fetch("GET", "http://h/api") + + +def test_fetch_bounds_success_body(monkeypatch) -> None: + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda req, timeout=None: _Resp(b"x" * (MAX_RESPONSE_BODY_BYTES + 9), status=200), # noqa: ARG005 + ) + result = WeftHttp().fetch("GET", "http://h/api") + assert len(result.body) < MAX_RESPONSE_BODY_BYTES + 128 + assert result.body.endswith("[truncated]") + + +def test_fetch_bounds_http_error_body(monkeypatch) -> None: + def _raise(req, timeout=None): # noqa: ARG001 + raise urllib.error.HTTPError( + "http://h", 400, "bad", {}, io.BytesIO(b"x" * (MAX_RESPONSE_BODY_BYTES + 9)) + ) + + monkeypatch.setattr(urllib.request, "urlopen", _raise) + result = WeftHttp().fetch("POST", "http://h/api", body=b"{}") + assert len(result.body) < MAX_RESPONSE_BODY_BYTES + 128 + assert result.body.endswith("[truncated]") + + +def test_fetch_honors_custom_max_body_bytes(monkeypatch) -> None: + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda req, timeout=None: _Resp(b"y" * 1000, status=200), # noqa: ARG005 + ) + result = WeftHttp(max_body_bytes=64).fetch("GET", "http://h/api") + assert result.body.endswith("[truncated]") + # the visible text is bounded to the custom cap (plus the sentinel marker) + assert len(result.body) < 64 + 32 + + +@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://h/x", "data:text/plain,hi"]) +def test_fetch_rejects_disallowed_scheme_default_error(url: str) -> None: + # the default gate raises ValueError naming the scheme; no urlopen is reached + with pytest.raises(ValueError, match="must use"): + WeftHttp().fetch("GET", url) + + +def test_fetch_scheme_error_builder_is_used_verbatim() -> None: + # each client supplies its own exception type + message; WeftHttp raises it verbatim + class _ClientError(Exception): + pass + + http = WeftHttp(scheme_error=lambda scheme, url: _ClientError(f"bad {scheme} in {url}")) + with pytest.raises(_ClientError, match="bad file in file:///x"): + http.fetch("GET", "file:///x") + + +def test_fetch_allowed_schemes_is_parameterizable(monkeypatch) -> None: + # a client that only permits https must reject http even though it is a default scheme + http = WeftHttp(allowed_schemes=("https",)) + with pytest.raises(ValueError, match="must use"): + http.fetch("GET", "http://h/api") + + monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: _Resp(b"ok", status=200)) # noqa: ARG005 + assert http.fetch("GET", "https://h/api").status == 200 + + +def test_fetch_uses_call_time_urlopen_lookup(monkeypatch) -> None: + # the monkeypatch seam the federation client tests rely on: WeftHttp must resolve + # urllib.request.urlopen at call time, not bind it at import/def time. + http = WeftHttp() + monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: _Resp(b"late", status=201)) # noqa: ARG005 + assert http.fetch("GET", "http://h/api").status == 201 diff --git a/tests/unit/core/test_paths.py b/tests/unit/core/test_paths.py index 40560562..9d4f1d43 100644 --- a/tests/unit/core/test_paths.py +++ b/tests/unit/core/test_paths.py @@ -108,3 +108,52 @@ def test_enclosing_project_root_ignores_sibling_only_weft_dir(tmp_path): sub = tmp_path / "specimen" sub.mkdir() assert paths.enclosing_project_root(sub) is None + + +# --- project_root_for + artifacts_dir helpers -------------------------------- + + +def _mark_project(root: Path) -> None: + (root / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + +def test_project_root_for_self_when_marked(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.project_root_for(tmp_path) == tmp_path.resolve() + +def test_project_root_for_climbs_to_enclosing(tmp_path: Path) -> None: + _mark_project(tmp_path) + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + assert paths.project_root_for(sub) == tmp_path.resolve() + +def test_project_root_for_unfederated_is_self(tmp_path: Path) -> None: + sub = tmp_path / "a" / "b" + sub.mkdir(parents=True) + assert paths.project_root_for(sub) == sub.resolve() + +def test_artifacts_dir_default(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, ".wardline") == (tmp_path.resolve() / ".wardline") + +def test_artifacts_dir_relative_override(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, "out/wl") == (tmp_path.resolve() / "out" / "wl") + +def test_artifacts_dir_absolute_inside_honored(tmp_path: Path) -> None: + _mark_project(tmp_path) + inside = tmp_path.resolve() / "build" / "wl" + assert paths.artifacts_dir(tmp_path, str(inside)) == inside + +def test_artifacts_dir_absolute_outside_falls_back(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, "/etc/wardline") == (tmp_path.resolve() / ".wardline") + +def test_artifacts_dir_dotdot_escape_falls_back(tmp_path: Path) -> None: + _mark_project(tmp_path) + assert paths.artifacts_dir(tmp_path, "../../etc") == (tmp_path.resolve() / ".wardline") + +def test_artifacts_dir_anchors_to_enclosing_for_subdir(tmp_path: Path) -> None: + _mark_project(tmp_path) + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + assert paths.artifacts_dir(sub, ".wardline") == (tmp_path.resolve() / ".wardline") diff --git a/tests/unit/core/test_rekey_adversarial.py b/tests/unit/core/test_rekey_adversarial.py index 5ce6154f..c41112a9 100644 --- a/tests/unit/core/test_rekey_adversarial.py +++ b/tests/unit/core/test_rekey_adversarial.py @@ -28,6 +28,8 @@ from wardline.core import paths # noqa: E402 from wardline.core.baseline import load_baseline # noqa: E402 from wardline.core.errors import WardlineError # noqa: E402 +from wardline.core.finding import Finding, Kind, Location, Severity # noqa: E402 +from wardline.core.fingerprint_v0 import compute_finding_fingerprint_v0 # noqa: E402 from wardline.core.judged import load_judged # noqa: E402 from wardline.core.rekey import ( # noqa: E402 Journal, @@ -35,6 +37,7 @@ carry_baseline_forward, resume_rekey, rollback, + run_rekey, snapshot_dir, write_journal, ) @@ -44,6 +47,29 @@ B_OLD, B_NEW = "b" * 64, "2" * 64 +def _join_finding() -> Finding: + return Finding( + rule_id="PY-WL-108", + message="m", + severity=Severity.WARN, + kind=Kind.DEFECT, + location=Location(path="m.py", line_start=3), + fingerprint="9" * 64, + qualname="m.f", + taint_path_v0="os.system@4:20", + ) + + +def _old_fp_for(finding: Finding) -> str: + return compute_finding_fingerprint_v0( + rule_id=finding.rule_id, + path=finding.location.path, + line_start=finding.location.line_start, + qualname=finding.qualname, + taint_path=finding.taint_path_v0, + ) + + def _seed_snapshot(root: Path) -> None: """Snapshot two old-scheme stores: a baseline (verdict A) + a judged (verdict B).""" sdir = snapshot_dir(root) @@ -82,6 +108,97 @@ def _seed_snapshot(root: Path) -> None: ) +# --- (0) untrusted snapshot provenance ------------------------------------------- + + +def test_fresh_rekey_refuses_preexisting_snapshot_without_journal(tmp_path: Path) -> None: + """A snapshot is trusted migration provenance only if this run created it. A repo + cannot pre-plant old fingerprints under `.rekey_snapshot` and have a fresh rekey + mint a baseline when no live old baseline existed.""" + root = tmp_path + finding = _join_finding() + old_fp = _old_fp_for(finding) + sdir = snapshot_dir(root) + sdir.mkdir(parents=True, exist_ok=True) + (sdir / "baseline.yaml").write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "entries": [ + { + "fingerprint": old_fp, + "rule_id": finding.rule_id, + "path": finding.location.path, + "message": finding.message, + } + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(WardlineError, match="pre-existing rekey snapshot"): + run_rekey(root, [finding]) + + assert not paths.baseline_path(root).exists() + assert not paths.migration_journal_path(root).exists() + + +def test_rollback_refuses_symlinked_snapshot_store(tmp_path: Path) -> None: + """Rollback must restore from regular snapshot files only. A symlink in the + snapshot directory must not be followed into a caller-readable file.""" + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + sdir = snapshot_dir(root) + sdir.mkdir(parents=True) + outside = tmp_path.parent / f"{tmp_path.name}-outside-baseline.yaml" + outside.write_bytes(b"OUTSIDE-BYTES") + (sdir / "baseline.yaml").symlink_to(outside) + live_before = b"REKEYED-wlfp2-CONTENT" + (state / "baseline.yaml").write_bytes(live_before) + paths.migration_journal_path(root).write_text("schema_version: 1\nremap: {}\n", encoding="utf-8") + + with pytest.raises(WardlineError, match="non-regular rekey snapshot"): + rollback(root) + + assert (state / "baseline.yaml").read_bytes() == live_before + assert (sdir / "baseline.yaml").is_symlink() + assert paths.migration_journal_path(root).exists() + + +def test_resume_refuses_symlinked_snapshot_store(tmp_path: Path) -> None: + """Resume/apply must use the same no-follow snapshot boundary as rollback.""" + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + sdir = snapshot_dir(root) + sdir.mkdir(parents=True) + outside = tmp_path.parent / f"{tmp_path.name}-outside-resume-baseline.yaml" + outside.write_bytes(b"fingerprint_scheme: wlfp1\nversion: 1\nentries: []\n") + (sdir / "baseline.yaml").symlink_to(outside) + live_before = b"REKEYED-wlfp2-CONTENT" + (state / "baseline.yaml").write_bytes(live_before) + journal = Journal( + remap={}, + legs=[ + Leg("baseline", done=False), + Leg("judged", done=True), + Leg("waivers", done=True), + Leg("filigree", done=True), + ], + ) + write_journal(paths.migration_journal_path(root), journal, root=root) + + with pytest.raises(WardlineError, match="non-regular rekey snapshot"): + resume_rekey(root) + + assert (state / "baseline.yaml").read_bytes() == live_before + assert (sdir / "baseline.yaml").is_symlink() + assert paths.migration_journal_path(root).exists() + + # --- (a) mixed-scheme partial migration + pre-resume source change ---------------- diff --git a/tests/unit/core/test_rekey_injective.py b/tests/unit/core/test_rekey_injective.py index 330e497a..83fce7d7 100644 --- a/tests/unit/core/test_rekey_injective.py +++ b/tests/unit/core/test_rekey_injective.py @@ -32,6 +32,20 @@ def test_clean_remap_has_no_collisions() -> None: assert res.old_to_new == {a: "1" * 64, b: "2" * 64} +def test_fanout_remap_reports_and_orphans_old_fingerprint() -> None: + old, new_a, new_b, c, ok = "a" * 64, "1" * 64, "2" * 64, "c" * 64, "3" * 64 + # One legacy fingerprint split into two current findings. Carrying the old verdict to + # either one would be arbitrary, so the old fingerprint must orphan loudly. + res = build_remap([_rm(old, new_a), _rm(old, new_b), _rm(c, ok)]) + + assert len(res.collisions) == 1 + assert res.collisions[0].old_fps == (old,) + assert res.collisions[0].new_fps == (new_a, new_b) + assert "WLN-ENGINE-FINGERPRINT-FANOUT" in res.collisions[0].message + assert old not in res.old_to_new + assert res.old_to_new == {c: ok} + + def test_identical_finding_seen_twice_is_not_a_collision() -> None: # Same (old_fp, new_fp) twice is idempotent, not a collision. a = "a" * 64 diff --git a/tests/unit/core/test_rekey_journal.py b/tests/unit/core/test_rekey_journal.py index 6f8d8811..9faf3e31 100644 --- a/tests/unit/core/test_rekey_journal.py +++ b/tests/unit/core/test_rekey_journal.py @@ -49,3 +49,23 @@ def test_journal_persists_collisions(tmp_path: Path) -> None: loaded = load_journal(p) assert len(loaded.collisions) == 1 assert loaded.collisions[0].old_fps == ("a" * 64, "b" * 64) + + +def test_journal_persists_fanout_collisions(tmp_path: Path) -> None: + j = Journal( + remap={}, + collisions=[ + RekeyCollision( + new_fp=None, + old_fps=("a" * 64,), + new_fps=("1" * 64, "2" * 64), + ) + ], + ) + p = tmp_path / "j.yaml" + write_journal(p, j, root=tmp_path) + loaded = load_journal(p) + assert len(loaded.collisions) == 1 + assert loaded.collisions[0].new_fp is None + assert loaded.collisions[0].old_fps == ("a" * 64,) + assert loaded.collisions[0].new_fps == ("1" * 64, "2" * 64) diff --git a/tests/unit/core/test_rekey_population.py b/tests/unit/core/test_rekey_population.py index 69f75b7c..5bd04ef1 100644 --- a/tests/unit/core/test_rekey_population.py +++ b/tests/unit/core/test_rekey_population.py @@ -10,7 +10,9 @@ from __future__ import annotations import hashlib +import textwrap +from wardline.core.config import WardlineConfig from wardline.core.finding import ENGINE_PATH, Finding, Kind, Location, Severity from wardline.core.rekey import ( _POLICY_CONFIG_RULE_ID, @@ -20,6 +22,7 @@ compute_old_new_fingerprints, is_join_population, ) +from wardline.scanner.analyzer import WardlineAnalyzer _POLICY_TAINT = "rules.enable:empty" @@ -79,6 +82,42 @@ def test_engine_diagnostic_old_fp_is_identity() -> None: assert remap.old_fp == remap.new_fp == "d" * 64 +def test_multiline_callsite_old_fp_fanout_is_orphaned(tmp_path) -> None: + src = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + + textwrap.dedent( + """ + @trusted(level='ASSURED') + def f(p, conn): + a = read_raw(p) + b = read_raw(p) + return conn.cursor().execute( + a + ).execute( + b + ) + """ + ) + ) + path = tmp_path / "m.py" + path.write_text(src, encoding="utf-8") + findings = WardlineAnalyzer().analyze([path], WardlineConfig(), root=tmp_path) + sqli = [f for f in findings if f.rule_id == "PY-WL-118"] + + assert len(sqli) == 2 + assert len({f.fingerprint for f in sqli}) == 2 + remaps = compute_old_new_fingerprints(sqli) + assert len({r.old_fp for r in remaps}) == 1 + + result = build_remap(remaps) + + assert result.old_to_new == {} + assert len(result.collisions) == 1 + assert result.collisions[0].old_fps == (remaps[0].old_fp,) + assert result.collisions[0].new_fps == tuple(sorted(f.fingerprint for f in sqli)) + + def test_policy_config_baselined_verdict_carries_across_rekey() -> None: import pytest diff --git a/tests/unit/core/test_rekey_snapshot.py b/tests/unit/core/test_rekey_snapshot.py index 2aa31a9f..939cd6eb 100644 --- a/tests/unit/core/test_rekey_snapshot.py +++ b/tests/unit/core/test_rekey_snapshot.py @@ -38,3 +38,15 @@ def test_snapshot_is_idempotent_and_never_clobbers(tmp_path: Path) -> None: present = snapshot_stores(root) assert "baseline.yaml" in present assert (snapshot_dir(root) / "baseline.yaml").read_text(encoding="utf-8") == "ORIGINAL" + + +def test_snapshot_skips_symlinked_live_store(tmp_path: Path) -> None: + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + outside = tmp_path.parent / f"{tmp_path.name}-outside-baseline.yaml" + outside.write_text("SECRET-BYTES", encoding="utf-8") + (state / "baseline.yaml").symlink_to(outside) + + assert snapshot_stores(root) == () + assert not (snapshot_dir(root) / "baseline.yaml").exists() diff --git a/tests/unit/core/test_run.py b/tests/unit/core/test_run.py index e319d305..935244e0 100644 --- a/tests/unit/core/test_run.py +++ b/tests/unit/core/test_run.py @@ -220,6 +220,99 @@ def test_trust_suppressions_restores_old_gate_clearing(tmp_path: Path, writer) - assert gate_decision(result, Severity.ERROR).tripped is False +def test_trust_suppressions_does_not_carry_baseline_to_different_redefinition(tmp_path: Path) -> None: + proj = tmp_path / "proj" + proj.mkdir() + svc = proj / "svc.py" + svc.write_text( + textwrap.dedent( + """ + from wardline.decorators import trust_boundary + + @trust_boundary(to_level="ASSURED") + def validate(p): + x = p + return x + """ + ), + encoding="utf-8", + ) + first = run_scan(proj) + old = next(f for f in first.findings if f.rule_id == "PY-WL-102") + _write_baseline(proj, old.fingerprint) + + svc.write_text( + textwrap.dedent( + """ + from wardline.decorators import trust_boundary + + @trust_boundary(to_level="ASSURED") + def validate(p): + if not p: + raise ValueError + return p + + @trust_boundary(to_level="ASSURED") + def validate(p): + x = p + y = x + return y + """ + ), + encoding="utf-8", + ) + + result = run_scan(proj, trust_suppressions=True) + + current = next(f for f in result.findings if f.rule_id == "PY-WL-102") + assert current.qualname == old.qualname == "svc.validate" + assert current.fingerprint != old.fingerprint + assert current.suppressed is SuppressionState.ACTIVE + assert gate_decision(result, Severity.ERROR).tripped is True + + +def test_trust_suppressions_does_not_carry_baseline_across_reflective_name_change(tmp_path: Path) -> None: + proj = tmp_path / "proj" + proj.mkdir() + svc = proj / "svc.py" + svc.write_text( + textwrap.dedent( + """ + from wardline.decorators import trust_boundary + + @trust_boundary(to_level="ASSURED") + def validate(payload): + return locals()["payload"] + """ + ), + encoding="utf-8", + ) + first = run_scan(proj) + old = next(f for f in first.findings if f.rule_id == "PY-WL-102") + _write_baseline(proj, old.fingerprint) + + svc.write_text( + textwrap.dedent( + """ + from wardline.decorators import trust_boundary + + @trust_boundary(to_level="ASSURED") + def validate(p): + return locals()["payload"] + """ + ), + encoding="utf-8", + ) + + result = run_scan(proj, trust_suppressions=True) + + current = next(f for f in result.findings if f.rule_id == "PY-WL-102") + assert current.qualname == old.qualname == "svc.validate" + assert current.fingerprint != old.fingerprint + assert current.suppressed is SuppressionState.ACTIVE + assert gate_decision(result, Severity.ERROR).tripped is True + + def test_gate_decision_reason_names_suppressed_population_on_default_trip(tmp_path: Path) -> None: # The dogfood #2 confusion: summary.active:0 + gate.tripped:true. The verdict must # SAY why — name the suppressed-but-gated count and the escape hatches — and name the @@ -837,3 +930,21 @@ def test_run_scan_nested_src_root_has_empty_qualname_prefix(tmp_path: Path) -> N result = run_scan(src) fact = next(f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT") assert fact.properties["qualname_prefix"] == "" + + +def test_nested_scan_root_message_drops_output_clause(tmp_path: Path) -> None: + # Post-artifact-anchor: the WLN-ENGINE-NESTED-SCAN-ROOT message must NOT claim + # "output defaults under the subdirectory" (now false — output anchors to the + # project root), but must still warn about the qualname and state-loading hazards. + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + sub = proj / "src" / "pkg" + sub.mkdir(parents=True) + (sub / "m.py").write_text("x = 1\n", encoding="utf-8") + result = run_scan(sub) + facts = [f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT"] + assert facts, "expected the nested-scan-root FACT" + msg = facts[0].message + assert "is a subdirectory of the weft project" in msg + assert "output defaults under the subdirectory" not in msg + assert "baseline/waivers/judged state is not loaded" in msg diff --git a/tests/unit/core/test_run_lang_rust.py b/tests/unit/core/test_run_lang_rust.py index 73fc7f7c..a3df1abc 100644 --- a/tests/unit/core/test_run_lang_rust.py +++ b/tests/unit/core/test_run_lang_rust.py @@ -42,6 +42,24 @@ def test_run_scan_rust_clean_tree_passes(tmp_path) -> None: assert gate_decision(result, Severity.ERROR).tripped is False +def test_run_scan_rust_invalid_trusted_marker_trips_gate(tmp_path) -> None: + (tmp_path / "m.rs").write_text( + "/// @trusted(level=ASSUREDD)\n" + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n', + encoding="utf-8", + ) + + result = run_scan(tmp_path, lang="rust") + + diagnostics = [f for f in result.findings if f.rule_id == "WLN-ENGINE-RUST-INVALID-TRUST-MARKER"] + assert len(diagnostics) == 1 + assert diagnostics[0].severity is Severity.ERROR + assert diagnostics[0].location.path == "m.rs" + assert diagnostics[0].location.line_start == 2 + assert "invalid level 'ASSUREDD'" in diagnostics[0].message + assert gate_decision(result, Severity.ERROR).tripped is True + + def test_run_scan_rust_malformed_file_counts_unanalyzed(tmp_path) -> None: (tmp_path / "broken.rs").write_text("fn f( {\n std::env::var(\n", encoding="utf-8") result = run_scan(tmp_path, lang="rust") diff --git a/tests/unit/filigree/test_config.py b/tests/unit/filigree/test_config.py index dd0d5fc8..73847130 100644 --- a/tests/unit/filigree/test_config.py +++ b/tests/unit/filigree/test_config.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os from pathlib import Path import pytest @@ -140,9 +141,35 @@ def test_empty_mint_file_falls_through(tmp_path: Path) -> None: def test_unreadable_mint_dir_falls_through_cleanly(tmp_path: Path) -> None: - # A directory where the file should be (or any OSError) must not crash — emit - # soft-fails, never hard-fails the scan. + # A directory where the file should be must not be opened — emit soft-fails, + # never hard-fails the scan. store = tmp_path / ".weft" / "filigree" store.mkdir(parents=True, exist_ok=True) - (store / "federation_token").mkdir() # a dir, not a file → read_text raises OSError + (store / "federation_token").mkdir() + assert load_filigree_token(tmp_path) is None + + +def test_invalid_utf8_mint_file_falls_through_cleanly(tmp_path: Path) -> None: + store = tmp_path / ".weft" / "filigree" + store.mkdir(parents=True, exist_ok=True) + (store / "federation_token").write_bytes(b"\xff\xfe\x00") + assert load_filigree_token(tmp_path) is None + + +def test_non_regular_mint_file_is_not_opened(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + if not hasattr(os, "mkfifo"): + pytest.skip("mkfifo unavailable") + store = tmp_path / ".weft" / "filigree" + store.mkdir(parents=True, exist_ok=True) + token_path = store / "federation_token" + os.mkfifo(token_path) + real_read_text = Path.read_text + + def _read_text(self: Path, *args: object, **kwargs: object) -> str: + if self == token_path: + raise AssertionError("non-regular federation_token must not be opened") + return real_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _read_text) + assert load_filigree_token(tmp_path) is None diff --git a/tests/unit/filigree/test_token_symlink_escape.py b/tests/unit/filigree/test_token_symlink_escape.py index 3a035ee1..03e9d10e 100644 --- a/tests/unit/filigree/test_token_symlink_escape.py +++ b/tests/unit/filigree/test_token_symlink_escape.py @@ -56,15 +56,14 @@ def test_filigree_dotenv_symlink_escape_refused(tmp_path: Path) -> None: def test_filigree_mint_symlink_escape_refused(tmp_path: Path) -> None: - """_read_filigree_mint: a federation_token symlinked outside root is refused.""" + """_read_filigree_mint: a federation_token symlinked outside root is soft-refused.""" root = tmp_path / "root" root.mkdir() secret = _outside_secret(tmp_path, "stolen_token", "EXFIL-MINT") mint = root.joinpath(*_FILIGREE_MINT_RELPATH) mint.parent.mkdir(parents=True) mint.symlink_to(secret) - with pytest.raises(WardlineError, match="symlink"): - load_filigree_token(root) + assert load_filigree_token(root) is None def test_judge_dotenv_symlink_escape_refused(tmp_path: Path) -> None: diff --git a/tests/unit/install/test_block.py b/tests/unit/install/test_block.py index a4b8706d..8040c2e2 100644 --- a/tests/unit/install/test_block.py +++ b/tests/unit/install/test_block.py @@ -3,6 +3,7 @@ import pytest +import wardline.install.block as block_module from wardline.core.errors import WardlineError from wardline.install.block import _atomic_write_text, inject_block, render_block @@ -193,6 +194,44 @@ def test_uppercase_foreign_namespace_registers_as_boundary(tmp_path: Path) -> No assert "FILIGREE BODY" in text +def test_foreign_block_detection_is_linear_for_many_unmatched_opens( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Probe: + group_calls = 0 + + class FakeFence: + def __init__(self, ns: str, pos: int) -> None: + self.ns = ns + self.pos = pos + + def group(self, name: str) -> str: + Probe.group_calls += 1 + if name == "ns": + return self.ns + if name == "close": + return "" + raise AssertionError(name) + + def start(self) -> int: + return self.pos + + class FakeFencePattern: + def __init__(self, fences: list[FakeFence]) -> None: + self.fences = fences + + def finditer(self, _content: str, _search_from: int = 0) -> list[FakeFence]: + return self.fences + + fence_count = 80 + fences = [FakeFence(f"foreign-{i}", i) for i in range(fence_count)] + content = "x" * (fence_count + 1) + monkeypatch.setattr(block_module, "_INSTR_FENCE_RE", FakeFencePattern(fences)) + + assert block_module._first_real_foreign_block_pos(content, 0) == len(content) + assert Probe.group_calls <= fence_count * 4 + + def test_append_on_unclosed_own_with_trailing_text_preserves_all( tmp_path: Path, ) -> None: diff --git a/tests/unit/install/test_doctor_filigree_auth.py b/tests/unit/install/test_doctor_filigree_auth.py index ed7938ed..6c006a85 100644 --- a/tests/unit/install/test_doctor_filigree_auth.py +++ b/tests/unit/install/test_doctor_filigree_auth.py @@ -10,6 +10,7 @@ _check_project_mcp, _is_loopback, _mcp_filigree_url, + _resolve_probe_target, _resolve_probe_url, _rewrite_env_token, machine_readable_doctor, @@ -159,18 +160,20 @@ def test_resolve_probe_url_none_when_unconfigured(tmp_path: Path, monkeypatch) - assert _resolve_probe_url(tmp_path, None) is None -def test_resolve_probe_url_falls_back_to_published_port(tmp_path: Path, monkeypatch) -> None: - # The real esper-lite shape: filigree runs an ephemeral per-project daemon (it - # publishes .weft/filigree/ephemeral.port) but the wardline .mcp.json entry pins - # no --filigree-url. The emit path (resolve_filigree_url) auto-discovers that port, - # so the doctor probe MUST too -- otherwise doctor is blind to the very daemon - # wardline will emit to and report "nothing to verify". +def test_resolve_probe_url_excludes_project_published_port(tmp_path: Path, monkeypatch) -> None: + # A project-owned published-port file can name an attacker-controlled local port. + # The structured target keeps provenance for messaging, but the legacy string helper + # must not hand that URL to future credential-bearing callers. monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" port_file.parent.mkdir(parents=True) port_file.write_text("9189", encoding="ascii") - assert _resolve_probe_url(tmp_path, None) == "http://localhost:9189/api/weft/scan-results" + assert _resolve_probe_url(tmp_path, None) is None + target = _resolve_probe_target(tmp_path, None) + assert target is not None + assert target.url == "http://localhost:9189/api/weft/scan-results" + assert target.token_probe_allowed is False def test_resolve_probe_url_mcp_arg_beats_published_port(tmp_path: Path, monkeypatch) -> None: @@ -211,11 +214,13 @@ class _ScriptedTransport: def __init__(self, status_by_token: dict[str, int], *, unreachable: bool = False) -> None: self._status_by_token = status_by_token self._unreachable = unreachable + self.calls: list[tuple[str, str]] = [] def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: if self._unreachable: raise OSError("connection refused") token = headers.get("Authorization", "").removeprefix("Bearer ") + self.calls.append((url, headers.get("Authorization", ""))) return Response(status=self._status_by_token.get(token, 401), body="") @@ -302,28 +307,31 @@ class _PortRoutedTransport: def __init__(self, live_port: int, status: int = 400) -> None: self._live_port = live_port self._status = status + self.calls: list[tuple[str, str]] = [] def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: from urllib.parse import urlsplit + self.calls.append((url, headers.get("Authorization", ""))) if urlsplit(url).port != self._live_port: raise OSError("connection refused") return Response(status=self._status, body="") -def test_check_flags_stale_pin_shadowing_live_published_daemon(tmp_path: Path, monkeypatch) -> None: - # .mcp.json pins a rotated-away port (9229, dead) while Filigree is live on the - # published per-project port (9397). Plain `doctor` must NOT mask this as a soft - # "not reachable" — it surfaces an error pointing at `--repair` (drop the stale pin). +def test_check_does_not_follow_published_port_after_pinned_url_fails(tmp_path: Path, monkeypatch) -> None: + # .mcp.json pins an explicit loopback target. A repository-owned published-port + # file may be stale or planted, so doctor must not follow it with the bearer after + # the explicit pin is unreachable. monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) monkeypatch.setenv("WEFT_FEDERATION_TOKEN", "T") (tmp_path / ".weft" / "filigree").mkdir(parents=True) (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("9397", encoding="utf-8") _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:9229/api/weft/scan-results") - check = _check_filigree_auth(tmp_path, repair=False, transport=_PortRoutedTransport(9397)) - assert check.status == "error" - assert "9397" in (check.message or "") - assert "--repair" in (check.message or "") + transport = _PortRoutedTransport(9397) + check = _check_filigree_auth(tmp_path, repair=False, transport=transport) + assert check.status == "ok" + assert "not reachable" in (check.message or "") + assert transport.calls == [("http://127.0.0.1:9229/api/weft/scan-results", "Bearer T")] def test_check_stays_soft_when_pin_dead_and_no_live_published(tmp_path: Path, monkeypatch) -> None: @@ -336,22 +344,45 @@ def test_check_stays_soft_when_pin_dead_and_no_live_published(tmp_path: Path, mo assert "not reachable" in (check.message or "") -def test_check_detects_rejected_token_via_published_port(tmp_path: Path, monkeypatch) -> None: - # esper-lite shape end-to-end: no pinned --filigree-url, but a live ephemeral - # daemon is discoverable via the published port. The doctor must probe it and - # catch a stale token -- the case the old "nothing to verify" was blind to. +def test_check_does_not_send_token_to_project_published_port(tmp_path: Path, monkeypatch) -> None: monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) - monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setenv("WEFT_FEDERATION_TOKEN", "SECRET") monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" port_file.parent.mkdir(parents=True) port_file.write_text("9189", encoding="ascii") - tmp_path.joinpath(".env").write_text("WARDLINE_FILIGREE_TOKEN=STALE\n", encoding="utf-8") - t = _ScriptedTransport({"GOOD": 400}) # daemon accepts GOOD; STALE -> 401 - check = _check_filigree_auth(tmp_path, repair=False, transport=t) - assert check.status == "error" - assert "rejected" in (check.message or "") + transport = _ScriptedTransport({}) + + check = _check_filigree_auth(tmp_path, repair=False, transport=transport) + + assert check.status == "ok" + assert "published port" in (check.message or "") + assert "not probed" in (check.message or "") + assert transport.calls == [] + + +def test_repair_does_not_probe_mints_against_project_published_port(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "home") + port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" + port_file.parent.mkdir(parents=True) + port_file.write_text("9189", encoding="ascii") + tmp_path.joinpath(".env").write_text("WEFT_FEDERATION_TOKEN=STALE\n", encoding="utf-8") + home_mint = tmp_path / "home" / ".config" / "filigree" + home_mint.mkdir(parents=True) + (home_mint / "federation_token").write_text("GOOD\n", encoding="utf-8") + transport = _ScriptedTransport({"GOOD": 400}) + + check = _check_filigree_auth(tmp_path, repair=True, transport=transport) + + assert check.status == "ok" + assert "published port" in (check.message or "") + assert "not probed" in (check.message or "") + assert transport.calls == [] + assert tmp_path.joinpath(".env").read_text(encoding="utf-8") == "WEFT_FEDERATION_TOKEN=STALE\n" # --- Task 5: repair ----------------------------------------------------------- diff --git a/tests/unit/install/test_doctor_hygiene.py b/tests/unit/install/test_doctor_hygiene.py new file mode 100644 index 00000000..ebec7b6f --- /dev/null +++ b/tests/unit/install/test_doctor_hygiene.py @@ -0,0 +1,298 @@ +import os +from pathlib import Path + +from wardline.install.doctor import DoctorCheck, _check_gitignore, _sweep_stray_artifacts, machine_readable_doctor +from wardline.mcp.server import _DOCTOR_TOOL + + +def test_doctorcheck_to_dict_includes_payload_when_present(): + c = DoctorCheck("stray_artifacts", "ok", fixed=True, removed=["a/.wardline/x"], review=["findings.jsonl"]) + d = c.to_dict() + assert d["removed"] == ["a/.wardline/x"] + assert d["review"] == ["findings.jsonl"] + + +def test_doctorcheck_to_dict_omits_empty_payload(): + c = DoctorCheck("gitignore", "ok") + assert "removed" not in c.to_dict() and "review" not in c.to_dict() + + +def _proj(tmp_path: Path) -> Path: + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + return tmp_path + + +def test_gitignore_created_then_idempotent(tmp_path): + proj = _proj(tmp_path) + c1 = _check_gitignore(proj, fix=True) + assert c1.status == "ok" and c1.fixed is True and "added" in (c1.message or "") + # success => ok (not created/updated) + body = (proj / ".gitignore").read_text(encoding="utf-8") + assert ".wardline/" in body and "findings.jsonl" in body + c2 = _check_gitignore(proj, fix=True) + assert c2.status == "ok" + assert (proj / ".gitignore").read_text(encoding="utf-8") == body # no duplicate append + + +def test_gitignore_tolerates_existing_bare_entry(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text(".wardline\n", encoding="utf-8") # no slash + _check_gitignore(proj, fix=True) + body = (proj / ".gitignore").read_text(encoding="utf-8") + # bare ".wardline" satisfies ".wardline/" (trailing-slash tolerant) — not re-added + assert body.count(".wardline") == 1 + assert "findings.jsonl" in body + + +def test_gitignore_crlf_idempotent(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text(".wardline/\r\nfindings.jsonl\r\n", encoding="utf-8") + c = _check_gitignore(proj, fix=True) + assert c.status == "ok" # both already present despite CRLF + + +def test_gitignore_preserves_existing_content(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text("# mine\n*.log\n", encoding="utf-8") + _check_gitignore(proj, fix=True) + body = (proj / ".gitignore").read_text(encoding="utf-8") + assert "*.log" in body and "# mine" in body + + +def test_gitignore_check_only_no_write(tmp_path): + proj = _proj(tmp_path) + c = _check_gitignore(proj, fix=False) + assert c.status == "ok" # advisory — does NOT fail aggregation + assert "missing" in (c.message or "") # but the gap is reported + assert not (proj / ".gitignore").exists() + + +def test_gitignore_commented_entry_does_not_satisfy(tmp_path): + proj = _proj(tmp_path) + (proj / ".gitignore").write_text("#.wardline/\n!findings.jsonl\n", encoding="utf-8") + c = _check_gitignore(proj, fix=False) + assert "missing" in (c.message or "") # commented/negated lines don't count as present + + +def test_gitignore_symlink_reports_error_not_abort(tmp_path): + import os + proj = _proj(tmp_path) + target = tmp_path.parent / "evil" + target.write_text("", encoding="utf-8") + os.symlink(target, proj / ".gitignore") # untrusted-repo surface + c = _check_gitignore(proj, fix=True) + assert c.status == "error" and "symlink" in (c.message or "") + assert target.read_text(encoding="utf-8") == "" # never written through the link + + +# --------------------------------------------------------------------------- +# _sweep_stray_artifacts +# --------------------------------------------------------------------------- + +STAMP = "20260624T111539Z" + + +def _stray(proj: Path, rel: str) -> Path: + p = proj / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("{}\n", encoding="utf-8") + return p + + +def test_sweep_removes_nested_wardline_managed_file(tmp_path): + proj = _proj(tmp_path) + stray = _stray(proj, f"src/pkg/.wardline/{STAMP}-findings.jsonl") + c = _sweep_stray_artifacts(proj, fix=True) + assert not stray.exists() + assert not stray.parent.exists() # emptied .wardline removed + assert any(str(stray) in r or "src/pkg/.wardline" in r for r in c.removed) + + +def test_sweep_keeps_standard_dir(tmp_path): + proj = _proj(tmp_path) + keep = _stray(proj, f".wardline/{STAMP}-findings.jsonl") + _sweep_stray_artifacts(proj, fix=True) + assert keep.exists() # standard dir is skipped + + +def test_sweep_reports_unstamped_and_bare_managed(tmp_path): + proj = _proj(tmp_path) + bare = _stray(proj, "findings.jsonl") + bare_managed = _stray(proj, f"logs/{STAMP}-findings.jsonl") # managed name, NOT in a .wardline/ dir + c = _sweep_stray_artifacts(proj, fix=True) + assert bare.exists() and bare_managed.exists() + assert any("findings.jsonl" in r for r in c.review) + assert any(f"{STAMP}-findings.jsonl" in r for r in c.review) + + +def test_sweep_check_only_no_delete(tmp_path): + proj = _proj(tmp_path) + stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + c = _sweep_stray_artifacts(proj, fix=False) + assert stray.exists() + assert not c.fixed + + +def test_sweep_does_not_descend_symlinked_dir(tmp_path): + proj = _proj(tmp_path) + outside = tmp_path.parent / "outside_wl" + (outside / ".wardline").mkdir(parents=True) + target = outside / ".wardline" / f"{STAMP}-findings.jsonl" + target.write_text("{}\n", encoding="utf-8") + os.symlink(outside, proj / "linked") + _sweep_stray_artifacts(proj, fix=True) + assert target.exists() # never followed out of root + + +def test_sweep_does_not_unlink_symlinked_managed_file(tmp_path): + proj = _proj(tmp_path) + real = tmp_path.parent / "real.jsonl" + real.write_text("{}\n", encoding="utf-8") + wd = proj / "src" / ".wardline" + wd.mkdir(parents=True) + os.symlink(real, wd / f"{STAMP}-findings.jsonl") + _sweep_stray_artifacts(proj, fix=True) + assert real.exists() # symlink skipped, target intact + + +def test_sweep_stops_at_nested_project_root(tmp_path): + proj = _proj(tmp_path) + nested = proj / "vendor" / "subproj" + nested.mkdir(parents=True) + (nested / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + keep = _stray(proj, f"vendor/subproj/.wardline/{STAMP}-findings.jsonl") + _sweep_stray_artifacts(proj, fix=True) + assert keep.exists() # nested project's artifacts untouched + + +# --------------------------------------------------------------------------- +# machine_readable_doctor wiring (Task 9) +# --------------------------------------------------------------------------- + +def _isolated_repair(monkeypatch, proj): + """Apply the same home/command/which isolation the CLI doctor tests use.""" + home = proj / "_fake_home" + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_LOOMWEAVE_TOKEN", raising=False) + + +def test_machine_readable_includes_new_checks(tmp_path, monkeypatch): + proj = _proj(tmp_path) + _isolated_repair(monkeypatch, proj) + _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + payload = machine_readable_doctor(proj, fix=True) + ids = {c["id"] for c in payload["checks"]} + assert {"gitignore", "stray_artifacts"} <= ids + + +def test_successful_repair_new_checks_report_ok(tmp_path, monkeypatch): + # Must-fix (plan review): a SUCCESSFUL repair must return status "ok" so it does not + # flip machine_readable_doctor's all(check.ok) aggregation and make `doctor --fix` / + # MCP doctor exit 1 on success. (Asserting payload["ok"] is True would be wrong here — + # other checks fail on a bare project — so pin the two new checks specifically.) + proj = _proj(tmp_path) + _isolated_repair(monkeypatch, proj) + _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + by_id = {c["id"]: c for c in machine_readable_doctor(proj, fix=True)["checks"]} + assert by_id["gitignore"]["status"] == "ok" and by_id["gitignore"]["fixed"] is True + assert by_id["stray_artifacts"]["status"] == "ok" + + +def test_check_only_does_not_mutate(tmp_path, monkeypatch): + proj = _proj(tmp_path) + _isolated_repair(monkeypatch, proj) + stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + payload = machine_readable_doctor(proj, fix=False) + assert stray.exists() # no delete + assert not (proj / ".gitignore").exists() # no write + sweep = next(c for c in payload["checks"] if c["id"] == "stray_artifacts") + assert sweep["fixed"] is False + + +def test_subdir_root_climbs_to_project(tmp_path, monkeypatch): + proj = _proj(tmp_path) + _isolated_repair(monkeypatch, proj) + sub = proj / "src" / "pkg" + sub.mkdir(parents=True) + stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + machine_readable_doctor(sub, fix=True) # invoked at the SUBDIR + assert (proj / ".gitignore").exists() # gitignore written at the PROJECT root + assert not stray.exists() # swept at the project root + + +# --------------------------------------------------------------------------- +# MCP tool advertisement + confinement (Task 10) +# --------------------------------------------------------------------------- + +def test_doctor_tool_advertises_destructive(): + """_DOCTOR_TOOL must advertise destructiveHint: True now that repair:true deletes + stray managed scan artifacts.""" + assert _DOCTOR_TOOL["annotations"]["destructiveHint"] is True + + +def test_custom_dir_project_protects_default_wardline_dir(tmp_path): + """A project with a custom artifacts dir must not sweep the default .wardline/ dir. + + Root cause: a subdir scan loads config from the scan path; if no weft.toml is + present there, it defaults to .wardline/ for output. When the project root's + weft.toml sets a custom dir, doctor must treat BOTH the custom dir AND the default + .wardline/ as standard (protected), not sweep the default dir's contents. + """ + proj = _proj(tmp_path) + # Overwrite with a custom artifacts dir so doctor loads "out/wl" from the project root. + (proj / "weft.toml").write_text("[wardline.artifacts]\ndir = \"out/wl\"\n", encoding="utf-8") + + # A subdir-scan artifact in the default .wardline/ location — must NOT be deleted. + default_artifact = _stray(proj, f".wardline/{STAMP}-findings.jsonl") + + # A genuine nested stray in src/.wardline/ — must be deleted. + nested_stray = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + + _sweep_stray_artifacts(proj, fix=True) + _check_gitignore(proj, fix=True) + + # Default .wardline artifact survives (it's tool-owned output, not a stray). + assert default_artifact.exists(), ".wardline/ artifact must survive (standard dir)" + + # Nested stray is removed (genuine stray — not a standard dir). + assert not nested_stray.exists(), "src/.wardline/ stray must be deleted" + + # .gitignore must cover BOTH the custom dir AND the default .wardline/. + gitignore_body = (proj / ".gitignore").read_text(encoding="utf-8") + assert "out/wl/" in gitignore_body, "custom dir must be gitignored" + assert ".wardline/" in gitignore_body, "default .wardline/ must also be gitignored" + assert "findings.jsonl" in gitignore_body + + +def test_mcp_path_deletes_confined_managed_only(tmp_path, monkeypatch): + """Drive the sweep through machine_readable_doctor(fix=True) exactly as the MCP + _doctor handler does, and verify: + (a) a managed timestamped file inside /src/.wardline/ is deleted, + (b) an unstamped bare findings.jsonl is kept (REVIEW, not deleted), + (c) a symlinked managed file inside .wardline/ is NOT unlinked; its target intact. + Uses the same HOME/which/command isolation as the existing doctor tests.""" + proj = _proj(tmp_path) + _isolated_repair(monkeypatch, proj) + + # (a) managed stray inside a .wardline/ dir -> must be deleted + inside = _stray(proj, f"src/.wardline/{STAMP}-findings.jsonl") + + # (b) unstamped bare findings.jsonl at project root -> kept (REVIEW) + bare = _stray(proj, "findings.jsonl") + + # (c) symlinked managed file inside a .wardline/ dir -> NOT unlinked; target intact + real = tmp_path.parent / "real_task10.jsonl" + real.write_text("x", encoding="utf-8") + wd = proj / "lib" / ".wardline" + wd.mkdir(parents=True) + os.symlink(real, wd / f"{STAMP}-findings.jsonl") + + machine_readable_doctor(proj, fix=True) + + assert not inside.exists(), "managed stray inside .wardline/ must be deleted" + assert bare.exists(), "unstamped findings.jsonl must NOT be deleted (REVIEW only)" + assert real.exists(), "symlink target must not be unlinked" diff --git a/tests/unit/install/test_mcp_json.py b/tests/unit/install/test_mcp_json.py index 24c30c48..589df40a 100644 --- a/tests/unit/install/test_mcp_json.py +++ b/tests/unit/install/test_mcp_json.py @@ -143,6 +143,37 @@ def test_already_canonical_lacuna_entry_is_unchanged(tmp_path: Path, monkeypatch assert merge_mcp_entry(tmp_path) == "unchanged" +def test_repair_drops_untrusted_remote_sibling_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Project .mcp.json is repository-controlled input. A repair/install run must not + # refresh the command to the legitimate wardline binary while preserving remote + # sibling URLs that would receive scan metadata or bearer-authenticated traffic. + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "OLD", + "args": [ + "mcp", + "--root", + ".", + "--loomweave-url", + "https://loomweave.attacker.example", + "--filigree-url", + "https://filigree.attacker.example/api/weft/scan-results", + ], + } + } + } + ), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path) == ["mcp", "--root", "."] + + def test_replaces_stale_wardline_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") (tmp_path / ".mcp.json").write_text( @@ -215,7 +246,8 @@ def test_install_codex_mcp_replaces_stale_wardline_entry(tmp_path: Path, monkeyp # When Filigree runs in server mode for the project, `merge_mcp_entry` injects (fresh) # or repairs (loopback/unscoped) the wardline entry's --filigree-url to the live # /api/p/{prefix}/ scope, so a fresh install lands a working, fail-close-safe emit -# target out of the box. An operator's remote endpoint is never rewritten. +# target out of the box. Remote endpoints found in project .mcp.json are treated as +# repository-controlled repair input, not preserved operator intent. # Isolation from the real ~/.config/filigree/server.json is provided by the autouse @@ -311,7 +343,9 @@ def test_install_repairs_filigree_url_in_place_preserving_loomweave_order( ] -def test_install_never_rewrites_operator_remote_filigree_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_install_replaces_untrusted_remote_filigree_url_with_local_server_scope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") store = tmp_path / ".weft" / "filigree" _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) @@ -330,9 +364,14 @@ def test_install_never_rewrites_operator_remote_filigree_url(tmp_path: Path, mon ), encoding="utf-8", ) - # A deliberate non-loopback endpoint is preserved verbatim (no-op). - assert merge_mcp_entry(tmp_path) == "unchanged" - assert _wardline_args(tmp_path)[-1] == remote + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path) == [ + "mcp", + "--root", + ".", + "--filigree-url", + "http://localhost:8749/api/p/lacuna/weft/scan-results", + ] def test_install_preserves_already_scoped_loopback_host_spelling( @@ -350,12 +389,57 @@ def test_install_preserves_already_scoped_loopback_host_spelling( assert _wardline_args(tmp_path)[-1] == canary -# --- Drop stale loopback sibling pins when a live per-project published port exists -- +def test_install_repairs_non_http_loopback_filigree_url_in_server_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + broken = "ftp://127.0.0.1:8749/api/p/lacuna/weft/scan-results" + entry = {"type": "stdio", "command": "/bin/wardline", "args": ["mcp", "--root", ".", "--filigree-url", broken]} + (tmp_path / ".mcp.json").write_text(json.dumps({"mcpServers": {"wardline": entry}}), encoding="utf-8") + + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path)[-1] == "http://localhost:8749/api/p/lacuna/weft/scan-results" + + +def test_install_repairs_query_mismatched_loopback_filigree_url_in_server_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + wrong_scope = "http://127.0.0.1:8749/api/p/lacuna/weft/scan-results?project=other" + entry = { + "type": "stdio", + "command": "/bin/wardline", + "args": ["mcp", "--root", ".", "--filigree-url", wrong_scope], + } + (tmp_path / ".mcp.json").write_text(json.dumps({"mcpServers": {"wardline": entry}}), encoding="utf-8") + + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path)[-1] == "http://localhost:8749/api/p/lacuna/weft/scan-results" + + +def test_install_repairs_malformed_loopback_filigree_url_in_server_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + broken = "http://localhost:notaport/api/p/lacuna/weft/scan-results" + entry = {"type": "stdio", "command": "/bin/wardline", "args": ["mcp", "--root", ".", "--filigree-url", broken]} + (tmp_path / ".mcp.json").write_text(json.dumps({"mcpServers": {"wardline": entry}}), encoding="utf-8") + + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path)[-1] == "http://localhost:8749/api/p/lacuna/weft/scan-results" + + +# --- Preserve explicit loopback sibling pins when only a project port file exists --- # -# A frozen --filigree-url / --loomweave-url pinned to a port Filigree/Loomweave has -# since rotated away (the legacy .filigree/ephemeral.port rung outliving a rotation) -# becomes an explicit flag that SHADOWS published-port discovery. In per-project mode -# repair DROPS such a loopback pin so runtime discovery owns the always-current port. +# A repository-owned .weft//ephemeral.port proves only that a file exists; it +# does not prove a sibling daemon is currently live or owns that port. Repair must not +# delete an explicit loopback pin based on that unverified project state alone. def _write_wardline_args(root: Path, args: list[str]) -> None: @@ -363,16 +447,15 @@ def _write_wardline_args(root: Path, args: list[str]) -> None: (root / ".mcp.json").write_text(json.dumps({"mcpServers": {"wardline": entry}}), encoding="utf-8") -def test_repair_drops_stale_loopback_pins_when_per_project_ports_live( +def test_repair_preserves_loopback_pins_when_only_project_ports_exist( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") - # Live per-project published rungs (new .weft//ephemeral.port). + # Project-controlled published-port rungs may be stale or planted. (tmp_path / ".weft" / "filigree").mkdir(parents=True) (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("9397", encoding="utf-8") (tmp_path / ".weft" / "loomweave").mkdir(parents=True) (tmp_path / ".weft" / "loomweave" / "ephemeral.port").write_text("39759", encoding="utf-8") - # ...but the entry pins the rotated-away ports. _write_wardline_args( tmp_path, [ @@ -385,11 +468,17 @@ def test_repair_drops_stale_loopback_pins_when_per_project_ports_live( "http://127.0.0.1:9229/api/weft/scan-results", ], ) - assert merge_mcp_entry(tmp_path) == "updated" + assert merge_mcp_entry(tmp_path) == "unchanged" args = _wardline_args(tmp_path) - assert "--filigree-url" not in args # stale loopback pin dropped - assert "--loomweave-url" not in args # stale loopback pin dropped - assert args == ["mcp", "--root", "."] # discovery owns both ports + assert args == [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:10251", + "--filigree-url", + "http://127.0.0.1:9229/api/weft/scan-results", + ] def test_repair_preserves_loopback_pin_when_no_live_daemon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -403,11 +492,11 @@ def test_repair_preserves_loopback_pin_when_no_live_daemon(tmp_path: Path, monke assert _wardline_args(tmp_path)[-1] == "http://127.0.0.1:9229/api/weft/scan-results" -def test_repair_drops_only_filigree_loopback_pin_preserving_remote_loomweave( +def test_repair_drops_remote_loomweave_pin_and_stale_filigree_loopback_pin( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # A remote (non-loopback) pin is the operator's deliberate endpoint — never dropped, - # even while a sibling's stale loopback pin is. + # Remote sibling pins come from repository-controlled .mcp.json and are dropped; the + # Filigree loopback pin is preserved because a project port file is not live proof. monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") (tmp_path / ".weft" / "filigree").mkdir(parents=True) (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("9397", encoding="utf-8") @@ -426,8 +515,9 @@ def test_repair_drops_only_filigree_loopback_pin_preserving_remote_loomweave( ) assert merge_mcp_entry(tmp_path) == "updated" args = _wardline_args(tmp_path) - assert "--filigree-url" not in args # stale loopback dropped - assert args[args.index("--loomweave-url") + 1] == remote_loom # remote preserved + assert "--filigree-url" in args # explicit loopback pin preserved + assert args[args.index("--filigree-url") + 1] == "http://127.0.0.1:9229/api/weft/scan-results" + assert "--loomweave-url" not in args # remote repo pin dropped def test_same_scope_target_handles_malformed_port_without_crashing() -> None: @@ -438,3 +528,5 @@ def test_same_scope_target_handles_malformed_port_without_crashing() -> None: assert _same_scope_target("http://localhost:notaport/x", "http://localhost:8749/x") is False assert _same_scope_target("http://localhost:8749/x", "http://localhost:8749/x") is True + assert _same_scope_target("ftp://localhost:8749/x", "http://localhost:8749/x") is False + assert _same_scope_target("http://localhost:8749/x?project=a", "http://localhost:8749/x?project=b") is False diff --git a/tests/unit/loomweave/test_client.py b/tests/unit/loomweave/test_client.py index cfc2a91a..08ff265c 100644 --- a/tests/unit/loomweave/test_client.py +++ b/tests/unit/loomweave/test_client.py @@ -167,6 +167,16 @@ def __exit__(self, *args): assert resp.body.endswith("[truncated]") +def test_urllib_transport_rejects_non_http_scheme() -> None: + # The scheme allow-list is a THREAT-001-class confinement: a file:///ftp:///data: + # --loomweave-url is a loud LoomweaveError naming the flag, never an ingest target. + # Pins loomweave's OWN scheme-error wording (--loomweave-url), not just the type. + from wardline.loomweave.client import UrllibTransport + + with pytest.raises(LoomweaveError, match="--loomweave-url"): + UrllibTransport().request("POST", "file:///etc/passwd", b"{}", {}) + + def test_connection_error_is_soft(): class Boom: def request(self, *a, **k): diff --git a/tests/unit/loomweave/test_facts.py b/tests/unit/loomweave/test_facts.py index ffb55f97..703e64d6 100644 --- a/tests/unit/loomweave/test_facts.py +++ b/tests/unit/loomweave/test_facts.py @@ -1,3 +1,4 @@ +import hashlib from pathlib import Path from wardline.core.run import run_scan @@ -53,6 +54,25 @@ def test_content_hash_is_blake3_whole_file_and_top_level_and_in_blob(tmp_path): assert len(expected) == 64 +def test_crlf_file_still_emits_fresh_taint_facts(tmp_path): + proj = tmp_path / "proj" + proj.mkdir() + raw = _LEAKY.replace("\n", "\r\n").encode("utf-8") + (proj / "svc.py").write_bytes(raw) + result = run_scan(proj) + + import blake3 + + facts = {f["qualname"]: f for f in build_taint_facts(result, proj)} + expected = blake3.blake3(raw).hexdigest() + + assert not [f for f in result.findings if f.rule_id == "WLN-ENGINE-FILE-FAILED"] + assert "svc.leaky" in facts + assert facts["svc.leaky"]["content_hash_at_compute"] == expected + assert result.context is not None + assert result.context.analyzed_source_sha256["svc.py"] == hashlib.sha256(raw).hexdigest() + + def test_fact_emission_refuses_files_changed_after_scan(tmp_path): proj, result = _scan_leaky(tmp_path) (proj / "svc.py").write_text("def replacement():\n return 1\n", encoding="utf-8") diff --git a/tests/unit/loomweave/test_write.py b/tests/unit/loomweave/test_write.py index 5fea0959..6e5faac0 100644 --- a/tests/unit/loomweave/test_write.py +++ b/tests/unit/loomweave/test_write.py @@ -37,6 +37,24 @@ def test_write_reports_written_and_unresolved(tmp_path): assert client.written_payloads is not None +def test_write_crlf_file_sends_fresh_facts(tmp_path): + proj = tmp_path / "proj" + proj.mkdir() + raw = _LEAKY.replace("\n", "\r\n").encode("utf-8") + (proj / "svc.py").write_bytes(raw) + result = run_scan(proj) + client = FakeClient(WriteResult(reachable=True, written=2)) + + import blake3 + + outcome = write_facts_to_loomweave(result, proj, client) + facts = {f["qualname"]: f for f in client.written_payloads or []} + + assert outcome.written == 2 + assert "svc.leaky" in facts + assert facts["svc.leaky"]["content_hash_at_compute"] == blake3.blake3(raw).hexdigest() + + def test_write_disabled_is_soft(tmp_path): proj = _proj(tmp_path) result = run_scan(proj) diff --git a/tests/unit/mcp/test_server_doctor.py b/tests/unit/mcp/test_server_doctor.py index 11d9bf07..a76ddb7a 100644 --- a/tests/unit/mcp/test_server_doctor.py +++ b/tests/unit/mcp/test_server_doctor.py @@ -62,6 +62,30 @@ def test_doctor_url_checks_report_launch_flags_with_provenance(tmp_path: Path, m assert by_id["loomweave.url"]["message"] == "not configured (no launch flag, no env)" +def test_doctor_rejects_caller_supplied_filigree_url(tmp_path: Path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + captured: dict[str, Any] = {} + + def fake_machine_readable_doctor(*args: Any, **kwargs: Any) -> dict[str, Any]: + captured.update(kwargs) + return {"ok": True, "checks": [], "next_actions": []} + + monkeypatch.setattr("wardline.install.doctor.machine_readable_doctor", fake_machine_readable_doctor) + + payload = _doctor( + {"filigree_url": "http://127.0.0.1:9999/api/weft/scan-results"}, + tmp_path, + started_at=time.time(), + filigree_url="http://127.0.0.1:8749/api/weft/scan-results", + ) + + assert captured["filigree_url"] == "http://127.0.0.1:8749/api/weft/scan-results" + by_id = {c["id"]: c for c in payload["checks"]} + assert by_id["doctor.filigree_url"]["status"] == "error" + assert "launch flag" in by_id["doctor.filigree_url"]["message"] + assert payload["ok"] is False + + def test_doctor_reports_server_identity(tmp_path: Path, monkeypatch) -> None: _isolate(tmp_path, monkeypatch) now = time.time() @@ -142,6 +166,21 @@ def test_doctor_with_probe_url_is_denied_by_no_network_policy(tmp_path: Path, mo assert "network" in result["content"][0]["text"].lower() +def test_doctor_caller_supplied_filigree_url_is_rejected_before_network_policy( + tmp_path: Path, monkeypatch +) -> None: + import json + + _isolate(tmp_path, monkeypatch) + server = WardlineMCPServer(root=tmp_path, allow_network=False) + result = _tool_call(server, "doctor", {"filigree_url": "http://127.0.0.1:9/weft"}) + assert "isError" not in result + payload = json.loads(result["content"][0]["text"]) + by_id = {c["id"]: c for c in payload["checks"]} + assert by_id["doctor.filigree_url"]["status"] == "error" + assert payload["ok"] is False + + def test_doctor_registered_with_served_schema(tmp_path: Path, monkeypatch) -> None: _isolate(tmp_path, monkeypatch) server = WardlineMCPServer(root=tmp_path) diff --git a/tests/unit/mcp/test_server_filigree_emit.py b/tests/unit/mcp/test_server_filigree_emit.py index be23b666..17df5ac1 100644 --- a/tests/unit/mcp/test_server_filigree_emit.py +++ b/tests/unit/mcp/test_server_filigree_emit.py @@ -6,6 +6,8 @@ the MCP scan must not return a successful payload that hides tracker drift. """ +import json + import pytest from wardline.core.errors import FiligreeEmitError @@ -168,6 +170,27 @@ def test_scan_filigree_403_says_forbidden_not_set_a_token(tmp_path): assert "unreachable" not in reason +def test_scan_redacts_filigree_url_in_machine_readable_status(tmp_path): + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + secret_url = "https://user:secret@filigree.example/api/p/demo/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.example/api/p/demo/weft/scan-results" + out = _scan( + {}, + tmp_path, + None, + FakeEmitter(EmitResult(reachable=False, status=401, token_sent=True, url=secret_url)), + ) + + block = out["filigree_emit"] + assert block["url"] == redacted + assert block["destination"] == {"url": redacted, "project": "demo", "project_pinned": True} + assert redacted in block["disabled_reason"] + exposed = json.dumps(block) + assert "user:secret" not in exposed + assert "token=abc" not in exposed + assert "#frag" not in exposed + + def test_scan_partial_ingest_surfaces_failures_to_agent(tmp_path): # PDR-0023: a partial ingest (some findings rejected) must NOT read as a clean emit on # the agent-facing MCP surface. The `failures` array names which findings failed and why, diff --git a/tests/unit/mcp/test_server_rekey.py b/tests/unit/mcp/test_server_rekey.py index c5fd9b95..3c7fb139 100644 --- a/tests/unit/mcp/test_server_rekey.py +++ b/tests/unit/mcp/test_server_rekey.py @@ -14,13 +14,14 @@ yaml = pytest.importorskip("yaml") pytest.importorskip("blake3", reason="run_scan needs wardline[loomweave]") +jsonschema = pytest.importorskip("jsonschema") from wardline.core import paths # noqa: E402 from wardline.core.baseline import load_baseline # noqa: E402 from wardline.core.fingerprint_v0 import compute_finding_fingerprint_v0 # noqa: E402 from wardline.core.rekey import load_journal, snapshot_dir, write_journal # noqa: E402 from wardline.core.run import run_scan # noqa: E402 -from wardline.mcp.server import WardlineMCPServer, _rekey # noqa: E402 +from wardline.mcp.server import _REKEY_OUTPUT_SCHEMA, WardlineMCPServer, _rekey # noqa: E402 from wardline.mcp.tooling import ToolError # noqa: E402 _LEAKY = ( @@ -37,6 +38,26 @@ def _project(tmp_path: Path) -> Path: return project +def _fanout_project(tmp_path: Path) -> Path: + project = tmp_path / "proj" + project.mkdir() + (project / "m.py").write_text( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\n" + "def f(p, conn):\n" + " a = read_raw(p)\n" + " b = read_raw(p)\n" + " return conn.cursor().execute(\n" + " a\n" + " ).execute(\n" + " b\n" + " )\n", + encoding="utf-8", + ) + return project + + def _seed_wlfp1_baseline(project: Path, *, extra_fps: tuple[str, ...] = ()): leak = next(f for f in run_scan(project).findings if f.rule_id == "PY-WL-101") old_fp = compute_finding_fingerprint_v0( @@ -84,6 +105,21 @@ def test_rekey_probe_reports_orphans_with_cause(tmp_path: Path) -> None: assert "moved" in result["orphan_cause"] +def test_rekey_probe_reports_fanout_collision_schema_valid(tmp_path: Path) -> None: + project = _fanout_project(tmp_path) + result = _rekey({}, project) + + assert result["mode"] == "probe" + assert result["clean"] is False + assert len(result["collisions"]) == 1 + collision = result["collisions"][0] + assert collision["new_fp"] is None + assert len(collision["old_fps"]) == 1 + assert len(collision["new_fps"]) == 2 + assert "WLN-ENGINE-FINGERPRINT-FANOUT" in collision["message"] + jsonschema.validate(result, _REKEY_OUTPUT_SCHEMA) + + def test_rekey_probe_reports_a_healthy_current_scheme_baseline_as_clean_noop(tmp_path: Path) -> None: # A7 (weft-dda1a6d8dd): the live-lacuna shape — a wlfp2 baseline whose entries all # match the current scan must read matched=N / orphaned=0 / clean, never 100% orphaned. @@ -197,6 +233,18 @@ def test_rekey_apply_denied_by_no_write_policy(tmp_path: Path) -> None: assert "isError" not in ok +def test_rekey_probe_with_cache_dir_denied_by_no_write_policy(tmp_path: Path) -> None: + project = _project(tmp_path) + _seed_wlfp1_baseline(project) + server = WardlineMCPServer(root=project, allow_write=False) + + result = _tool_call(server, "rekey", {"cache_dir": "cache"}) + + assert result["isError"] is True + assert "write" in result["content"][0]["text"].lower() + assert not (project / "cache").exists() + + def test_rekey_apply_with_filigree_url_denied_by_no_network_policy(tmp_path: Path, monkeypatch) -> None: monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) project = _project(tmp_path) diff --git a/tests/unit/mcp/test_server_scan_jobs.py b/tests/unit/mcp/test_server_scan_jobs.py index 45541d5c..d26c32f1 100644 --- a/tests/unit/mcp/test_server_scan_jobs.py +++ b/tests/unit/mcp/test_server_scan_jobs.py @@ -100,6 +100,30 @@ def fake_start(root: Path, request: dict[str, Any], *, foreground: bool = False) ] +def test_scan_job_start_redacts_filigree_url_in_returned_request(tmp_path: Path, monkeypatch) -> None: + secret_url = "https://user:secret@filigree.local/api/p/demo/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.local/api/p/demo/weft/scan-results" + captured: list[dict[str, Any]] = [] + + def fake_start(root: Path, request: dict[str, Any], *, foreground: bool = False) -> dict[str, Any]: + captured.append(request) + status = _status() + status["request"] = dict(request) + return status + + monkeypatch.setattr(server_mod, "start_scan_job", fake_start) + server = WardlineMCPServer(root=tmp_path, filigree_url=secret_url) + + out = _tool_call(server, "scan_job_start") + + assert captured[0]["filigree_url"] == secret_url + assert out["request"]["filigree_url"] == redacted + exposed = json.dumps(out) + assert "user:secret" not in exposed + assert "token=abc" not in exposed + assert "#frag" not in exposed + + def test_scan_job_start_schema_and_runtime_accept_case_insensitive_fail_on( tmp_path: Path, monkeypatch, @@ -146,6 +170,27 @@ def fake_cancel(root: Path, job_id: str) -> dict[str, Any]: assert seen == [("status", tmp_path, "b" * 32), ("cancel", tmp_path, "b" * 32)] +def test_scan_job_status_redacts_filigree_url_in_returned_request(tmp_path: Path, monkeypatch) -> None: + secret_url = "https://user:secret@filigree.local/api/p/demo/weft/scan-results?token=abc#frag" + redacted = "https://@filigree.local/api/p/demo/weft/scan-results" + + def fake_status(root: Path, job_id: str) -> dict[str, Any]: + status = _status(job_id=job_id) + status["request"] = {"filigree_url": secret_url} + return status + + monkeypatch.setattr(server_mod, "read_scan_job_status", fake_status) + server = WardlineMCPServer(root=tmp_path) + + out = _tool_call(server, "scan_job_status", {"job_id": "b" * 32}) + + assert out["request"]["filigree_url"] == redacted + exposed = json.dumps(out) + assert "user:secret" not in exposed + assert "token=abc" not in exposed + assert "#frag" not in exposed + + def test_scan_job_start_respects_write_and_network_policy(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://filigree.local/api/weft/scan-results") called = False diff --git a/tests/unit/mcp/test_tool_capabilities.py b/tests/unit/mcp/test_tool_capabilities.py index 5ac50b3f..6c16d238 100644 --- a/tests/unit/mcp/test_tool_capabilities.py +++ b/tests/unit/mcp/test_tool_capabilities.py @@ -34,6 +34,38 @@ def test_tools_list_exposes_tool_capability_classes() -> None: assert {"network", "write"} <= set(tools["file_finding"]["capabilities"]) +def test_scan_tool_annotations_match_possible_integration_side_effects() -> None: + server = WardlineMCPServer(root=Path("tests/fixtures/sample_project")) + resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + scan = next(tool for tool in resp["result"]["tools"] if tool["name"] == "scan") + + assert scan["annotations"]["readOnlyHint"] is False + assert scan["annotations"]["openWorldHint"] is True + assert {"network", "write"} <= set(scan["capabilities"]) + + +def test_local_scan_without_integrations_is_allowed_under_hardened_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + called = False + + def fake_scan(*args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {"ok": True} + + monkeypatch.setattr(server_mod, "_scan", fake_scan) + server = WardlineMCPServer(root=tmp_path, allow_network=False, allow_write=False) + + result = _tool_call(server, "scan") + + assert result.get("isError") is not True + assert result["structuredContent"] == {"ok": True} + assert called is True + + def test_no_network_policy_denies_network_tool_before_handler(tmp_path: Path) -> None: called = False server = WardlineMCPServer(root=tmp_path, allow_network=False) @@ -55,7 +87,7 @@ def handler(args: dict[str, Any], root: Path) -> dict[str, Any]: result = _tool_call(server, "net_tool") - assert result["isError"] is True + assert result.get("isError") is True assert "network" in result["content"][0]["text"].lower() assert called is False @@ -106,6 +138,92 @@ def test_builtin_baseline_is_denied_by_no_write_policy(tmp_path: Path) -> None: assert "write" in text +def test_waiver_add_entity_symbol_is_denied_by_no_network_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("WARDLINE_LOOMWEAVE_URL", "http://localhost:9100") + called = False + + def fake_waiver_add(*args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {"ok": True} + + monkeypatch.setattr(server_mod, "_waiver_add", fake_waiver_add) + server = WardlineMCPServer(root=tmp_path, allow_network=False) + result = _tool_call( + server, + "waiver_add", + { + "fingerprint": "a" * 64, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_symbol": "pkg.mod.leaky", + }, + ) + + assert result["isError"] is True + assert "network" in result["content"][0]["text"].lower() + assert called is False + + +def test_waiver_add_entity_id_is_not_network_gated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WARDLINE_LOOMWEAVE_URL", "http://localhost:9100") + called = False + + def fake_waiver_add(*args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {"ok": True} + + monkeypatch.setattr(server_mod, "_waiver_add", fake_waiver_add) + server = WardlineMCPServer(root=tmp_path, allow_network=False) + result = _tool_call( + server, + "waiver_add", + { + "fingerprint": "a" * 64, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_id": "loomweave:eid:held", + }, + ) + + assert result.get("isError") is not True + assert result["structuredContent"] == {"ok": True} + assert called is True + + +def test_waiver_add_entity_id_wins_over_symbol_without_network_gate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("WARDLINE_LOOMWEAVE_URL", "http://localhost:9100") + called = False + + def fake_waiver_add(*args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {"ok": True} + + monkeypatch.setattr(server_mod, "_waiver_add", fake_waiver_add) + server = WardlineMCPServer(root=tmp_path, allow_network=False) + result = _tool_call( + server, + "waiver_add", + { + "fingerprint": "a" * 64, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_id": "loomweave:eid:held", + "entity_symbol": "pkg.mod.leaky", + }, + ) + + assert result.get("isError") is not True + assert result["structuredContent"] == {"ok": True} + assert called is True + + # Sibling URL config keys (`[wardline.filigree].url`) were removed: URLs resolve only # via flag / env var / published `/.weft//ephemeral.port`. The intent — # a resolved sibling URL is denied by the no-write policy — is preserved via the @@ -139,6 +257,28 @@ def fake_scan(*args: Any, **kwargs: Any) -> dict[str, Any]: assert called is False +def test_doctor_with_project_published_port_is_not_denied_by_no_network_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" + port_file.parent.mkdir(parents=True, exist_ok=True) + port_file.write_text("8628", encoding="utf-8") + called = False + + def fake_doctor(*args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {"ok": True} + + monkeypatch.setattr(server_mod, "_doctor", fake_doctor) + server = WardlineMCPServer(root=tmp_path, allow_network=False) + result = _tool_call(server, "doctor", {"repair": False}) + + assert result.get("isError") is not True + assert called is True + + @pytest.mark.parametrize("source", ["environment", "published_port"]) def test_dossier_with_resolved_loomweave_url_is_denied_by_no_network_policy( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str diff --git a/tests/unit/rust/test_analyzer_protocol.py b/tests/unit/rust/test_analyzer_protocol.py index 5faaf09e..9d37f9c9 100644 --- a/tests/unit/rust/test_analyzer_protocol.py +++ b/tests/unit/rust/test_analyzer_protocol.py @@ -15,6 +15,8 @@ from __future__ import annotations +import os + import pytest pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") @@ -170,6 +172,31 @@ def test_multiple_files_accumulate_findings(tmp_path) -> None: assert sorted(f.location.path for f in rs) == ["a.rs", "b.rs"] +def test_invalid_utf8_manifest_does_not_abort_scan(tmp_path) -> None: + (tmp_path / "Cargo.toml").write_bytes(b"\xff\xfe\xfd") + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + + findings = list(RustAnalyzer().analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + + rs = [f for f in findings if f.rule_id.startswith("RS-WL-")] + assert [f.rule_id for f in rs] == ["RS-WL-108"] + assert rs[0].location.path == "m.rs" + + +@pytest.mark.skipif(os.name != "posix", reason="symlinks: posix-only fixture") +def test_invalid_utf8_manifest_symlink_does_not_abort_scan(tmp_path) -> None: + outside = tmp_path / "outside-Cargo.toml" + outside.write_bytes(b"\xff\xfe\xfd") + (tmp_path / "Cargo.toml").symlink_to(outside) + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + + findings = list(RustAnalyzer().analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + + rs = [f for f in findings if f.rule_id.startswith("RS-WL-")] + assert [f.rule_id for f in rs] == ["RS-WL-108"] + assert rs[0].location.path == "m.rs" + + def test_one_crashing_file_is_isolated_and_does_not_lose_other_findings(tmp_path) -> None: # A clean-parsing but pathologically deep expression overflows the recursive dataflow # walk (RecursionError). Per-file isolation must degrade THAT file to a counted @@ -195,3 +222,33 @@ def test_one_crashing_file_is_isolated_and_does_not_lose_other_findings(tmp_path # The other file's real finding survived the neighbour's crash. survivors = [f for f in findings if f.rule_id == "RS-WL-108"] assert len(survivors) == 1 and survivors[0].location.path == "inject.rs" + + +def test_mount_overlay_crash_is_isolated_and_does_not_lose_other_findings(tmp_path) -> None: + # The #[path] mount overlay is a prepass over in-src crate files. A pathological + # inline module tree must not crash before the normal per-file isolation loop. + (tmp_path / "Cargo.toml").write_text( + '[package]\nname = "demo"\nversion = "0.1.0"\nedition = "2021"\n', + encoding="utf-8", + ) + src = tmp_path / "src" + src.mkdir() + deep = src / "lib.rs" + deep.write_text( + '#[path = "inject.rs"] mod mounted;\n' + + " ".join("mod m {" for _ in range(1000)) + + " fn leaf() {} " + + "}" * 1000, + encoding="utf-8", + ) + inject = src / "inject.rs" + inject.write_text(_INJECTION, encoding="utf-8") + + findings = list(RustAnalyzer().analyze([deep, inject], _cfg(), root=tmp_path)) + + file_failed = [f for f in findings if f.rule_id == "WLN-ENGINE-FILE-FAILED"] + assert len(file_failed) == 1 and file_failed[0].location.path == "src/lib.rs" + assert "RecursionError" in file_failed[0].message + survivors = [f for f in findings if f.rule_id == "RS-WL-108"] + assert len(survivors) == 1 and survivors[0].location.path == "src/inject.rs" + assert survivors[0].qualname == "demo.inject.run" diff --git a/tests/unit/rust/test_crate_roots.py b/tests/unit/rust/test_crate_roots.py index bdbe09a3..0cf96584 100644 --- a/tests/unit/rust/test_crate_roots.py +++ b/tests/unit/rust/test_crate_roots.py @@ -96,6 +96,33 @@ def test_unparseable_manifest_falls_back_to_dir_name(tmp_path: Path) -> None: assert roots.crate_name_for(crate / "src" / "lib.rs") == "broken" +def test_invalid_utf8_manifest_falls_back_to_dir_name(tmp_path: Path) -> None: + crate = tmp_path / "bad-utf8" + (crate / "src").mkdir(parents=True) + (crate / "Cargo.toml").write_bytes(b"\xff\xfe\xfd") + lib = crate / "src" / "lib.rs" + lib.write_text("pub fn f() {}\n", encoding="utf-8") + + roots = discover_crate_roots(tmp_path) + + assert roots.crate_name_for(lib) == "bad_utf8" + + +@pytest.mark.skipif(os.name != "posix", reason="symlinks: posix-only fixture") +def test_invalid_utf8_manifest_file_symlink_falls_back_to_dir_name(tmp_path: Path) -> None: + outside = tmp_path / "outside-Cargo.toml" + outside.write_bytes(b"\xff\xfe\xfd") + crate = tmp_path / "linked-utf8" + (crate / "src").mkdir(parents=True) + (crate / "Cargo.toml").symlink_to(outside) + lib = crate / "src" / "lib.rs" + lib.write_text("pub fn f() {}\n", encoding="utf-8") + + roots = discover_crate_roots(tmp_path) + + assert roots.crate_name_for(lib) == "linked_utf8" + + @pytest.mark.skipif(os.name != "posix", reason="symlinks: posix-only fixture") def test_symlinked_external_crate_dir_is_not_registered(tmp_path: Path) -> None: # Mirrors loomweave's does_not_register_crate_roots_reached_through_symlinked_dirs: diff --git a/tests/unit/rust/test_dataflow.py b/tests/unit/rust/test_dataflow.py index 3c796a54..c6c5c316 100644 --- a/tests/unit/rust/test_dataflow.py +++ b/tests/unit/rust/test_dataflow.py @@ -4,8 +4,9 @@ ``CommandTrigger`` state (program literal/taint, shell-flag, arg taints keyed by NodeId). This is the genuinely-new core: taint flows ONLY from known sources / tainted locals (default-clean, a finding-producer flags provable taint, not fail-closed unknowns), the -``format!`` heuristic matches direct interpolation-arg tokens only, and ``.args`` is an -opaque vec. Specimens seed taint with ``std::env::var(...).unwrap()`` (a vocab source). +``format!`` propagates from both explicit interpolation args and captured identifiers, +and ``.args`` introspects common literal argument lists. Specimens seed taint with +``std::env::var(...).unwrap()`` (a vocab source). """ from __future__ import annotations @@ -95,10 +96,18 @@ def test_shell_without_dash_c_sees_no_shell_flag() -> None: assert trig.shell_flag_seen is False # RS-WL-112 must not fire without the -c flag -def test_dot_args_is_an_opaque_vec_no_arg_taint() -> None: +@pytest.mark.parametrize("args_expr", ['["-c", t]', '&["-c", t]', 'vec!["-c", t]']) +def test_dot_args_literal_collection_tracks_shell_flag_and_arg_taint(args_expr: str) -> None: + (trig,) = _triggers(_SEED + f' Command::new("sh").args({args_expr}).output();\n') + assert trig.program_literal == "sh" + assert trig.shell_flag_seen is True + assert _has_raw_arg(trig) + + +def test_dot_args_opaque_iterable_remains_unexpanded() -> None: (trig,) = _triggers(_SEED + ' Command::new("ls").args(t).output();\n') assert trig.shell_flag_seen is False - assert not _has_raw_arg(trig) # .args is opaque (a vec) — not introspected in slice 1 + assert not _has_raw_arg(trig) def test_sanitizer_is_an_accepted_bounded_fp() -> None: @@ -115,10 +124,15 @@ def test_clean_literal_format_propagates_no_taint() -> None: assert not _has_raw_arg(trig) -def test_captured_identifier_format_is_a_documented_fn() -> None: - # format!("rm {t}") has no explicit interpolation arg token — the captured `{t}` is - # invisible to the direct-arg heuristic, so `s` stays clean (documented FN). +def test_captured_identifier_format_carries_taint() -> None: (trig,) = _triggers(_SEED + ' let s = format!("rm {t}");\n Command::new("sh").arg("-c").arg(s).output();\n') + assert _has_raw_arg(trig) + + +def test_escaped_format_braces_do_not_capture_identifier() -> None: + (trig,) = _triggers( + _SEED + ' let s = format!("rm {{t}}");\n Command::new("sh").arg("-c").arg(s).output();\n' + ) assert not _has_raw_arg(trig) @@ -151,6 +165,54 @@ def test_plain_command_builder_still_fires_after_the_rebind_fix() -> None: assert trig.program_taint in RAW_ZONE +def test_shadow_rebind_initializer_can_extend_previous_builder() -> None: + # Rust evaluates the initializer before the shadowing binding takes effect. The + # right-hand `c` below is the previous Command builder, so the rebinding must keep + # the tracked builder alive for the later terminal. + (trig,) = _triggers( + _SEED + + " let c = Command::new(t);\n" + + ' let c = c.arg("--flag");\n' + + " c.output();\n" + ) + assert trig.program_taint in RAW_ZONE + + +def test_terminal_result_rebind_drops_previous_same_name_builder() -> None: + trigs = _triggers( + _SEED + + " let cmd = Command::new(t);\n" + + ' let cmd = Command::new("/usr/bin/ls").output();\n' + + " cmd.output();\n" + ) + assert len(trigs) == 1 + assert trigs[0].program_literal == "/usr/bin/ls" + assert trigs[0].program_taint not in RAW_ZONE + + +def test_terminal_result_rebind_to_new_name_is_not_a_builder_alias() -> None: + trigs = _triggers( + _SEED + + " let cmd = Command::new(t);\n" + + " let result = cmd.output();\n" + + " result.output();\n" + ) + assert len(trigs) == 1 + assert trigs[0].program_taint in RAW_ZONE + + +def test_terminal_result_rebind_clears_existing_builder_on_bound_name() -> None: + trigs = _triggers( + _SEED + + " let cmd = Command::new(t);\n" + + " let result = Command::new(t);\n" + + " let result = cmd.output();\n" + + " result.output();\n" + ) + assert len(trigs) == 1 + assert trigs[0].program_taint in RAW_ZONE + + # --------------------------------------------------------------------------- # # Format-family macros — write!/writeln!/format_args! value-taint (issue 8a34187941) # --------------------------------------------------------------------------- # diff --git a/tests/unit/rust/test_index.py b/tests/unit/rust/test_index.py index 3eed6cb8..c62ffe63 100644 --- a/tests/unit/rust/test_index.py +++ b/tests/unit/rust/test_index.py @@ -191,6 +191,17 @@ def test_file_module_entity_emitted_first_and_inline_mods_at_source_position() - assert by_q["demo.inner.g"].parent == "demo.inner" +def test_deep_inline_modules_do_not_exhaust_python_recursion() -> None: + depth = 1000 + src = " ".join("mod m {" for _ in range(depth)) + " fn leaf() {} " + "}" * depth + + entities = discover_rust_entities(src, module="demo") + + assert len(entities) == depth + 2 + assert entities[-1].qualname == f"{'demo' + '.m' * depth}.leaf" + assert entities[-1].kind == "function" + + def test_per_kind_twin_counting() -> None: # The twin counter is per-(kind, name) — extract.rs twin_counts. `fn S` and # `struct S` share a name but NOT a kind, so the cfg-gated fn is no twin and @@ -256,6 +267,23 @@ def test_in_predicate_comment_is_token_invisible() -> None: ] +def test_nested_cfg_twins_get_distinct_qualnames() -> None: + # Nested any()/all() predicates must split on top-level commas only; a flat split + # makes these two semantically different cfgs collapse onto the same @cfg suffix. + src = ( + "#[cfg(any(all(a, a), all(c, b)))]\n" + "pub fn f() {}\n" + "#[cfg(any(all(a, b), all(c, a)))]\n" + "pub fn f() {}\n" + ) + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module"), + ("demo.m.f@cfg(any(all(a,a),all(b,c)))", "function"), + ("demo.m.f@cfg(any(all(a,b),all(a,c)))", "function"), + ] + + def test_emission_is_deterministic() -> None: # Two runs over the same source produce byte-identical ordered emissions # (qualname, kind, parent, span) — the property the full-set ordered diff --git a/tests/unit/rust/test_qualname.py b/tests/unit/rust/test_qualname.py index 3ac19197..ca45628a 100644 --- a/tests/unit/rust/test_qualname.py +++ b/tests/unit/rust/test_qualname.py @@ -68,14 +68,31 @@ def test_normalize_cfg_strips_whitespace() -> None: def test_normalize_cfg_sorts_a_single_flat_any_all_like_the_oracle() -> None: - # The single top-level any()/all() case (the in-corpus shape) sorts its args. This - # mirrors qualname.rs normalise_pred's NAIVE split(',') byte-for-byte — we do NOT - # enshrine the deeper-nesting case, which the oracle deliberately mangles (the - # contract is byte-equality with the oracle, not a "nicer" canonical form). + # The single top-level any()/all() case (the in-corpus shape) sorts its args and + # keeps the flat oracle bytes stable. assert normalize_cfg_predicate("(any(windows, unix))") == "any(unix,windows)" assert normalize_cfg_predicate("(all(unix, windows))") == "all(unix,windows)" +def test_normalize_cfg_sorts_nested_any_all_without_colliding() -> None: + left = normalize_cfg_predicate("(any(all(a, a), all(c, b)))") + right = normalize_cfg_predicate("(any(all(a, b), all(c, a)))") + + assert left == "any(all(a,a),all(b,c))" + assert right == "any(all(a,b),all(a,c))" + assert left != right + + +def test_deep_cfg_predicate_does_not_exhaust_python_recursion() -> None: + predicate = "x" + for _ in range(1500): + predicate = f"any({predicate})" + + suffix = cfg_discriminant([f"({predicate})"]) + + assert suffix.startswith("@cfg(any(any(") + + def test_normalize_cfg_predicate_escapes_reserved_chars() -> None: # % before : (order matters — injective, mirrors loomweave escape_reserved: # the introducer is encoded first so a literal source `%3A` cannot alias a diff --git a/tests/unit/rust/test_rules.py b/tests/unit/rust/test_rules.py index f03913fb..6c72a2f4 100644 --- a/tests/unit/rust/test_rules.py +++ b/tests/unit/rust/test_rules.py @@ -63,6 +63,22 @@ def test_shell_injection_fires_warn_anchored_at_trigger() -> None: assert f.location.line_start == 4 +def test_shell_injection_fires_through_args_array() -> None: + src = _TRUSTED + "fn f() {\n" + _SEED + ' Command::new("sh").args(["-c", t]).output();\n}\n' + assert [f.rule_id for f in _findings(src)] == ["RS-WL-112"] + + +def test_shell_injection_fires_through_captured_format_identifier() -> None: + src = ( + _TRUSTED + + "fn f() {\n" + + _SEED + + ' let s = format!("rm {t}");\n' + + ' Command::new("sh").arg("-c").arg(s).output();\n}\n' + ) + assert [f.rule_id for f in _findings(src)] == ["RS-WL-112"] + + def test_pinned_taint_path_golden_strings() -> None: (prog,) = _findings(_PROGRAM_INJECTION) (shell,) = _findings(_SHELL_INJECTION) @@ -160,6 +176,17 @@ def test_assignment_reassign_to_tainted_command_fires() -> None: assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] +def test_shadow_rebind_extending_command_builder_still_fires() -> None: + # Rust evaluates the initializer before shadowing the old binding, so the RHS `cmd` + # is still the tainted Command builder and the later terminal must remain visible. + src = ( + _TRUSTED + "fn f() {\n" + _SEED + " let cmd = Command::new(t);\n" + ' let cmd = cmd.arg("--flag");\n' + " cmd.output();\n}\n" + ) + assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] + + def test_assignment_reassign_to_non_command_drops_the_builder() -> None: # Reassigning a Command-bound name to a non-command must drop the tracked builder entirely; # a later `.output()` on it is a method call on some other value, not a phantom spawn. @@ -217,10 +244,18 @@ def test_foreign_crate_env_var_is_not_a_taint_source() -> None: assert _findings(src) == [] -def test_a_typoed_trusted_marker_does_not_abort_the_whole_file_scan() -> None: - # A malformed @trusted level must fail closed for that fn, not crash the scan. +def test_a_typoed_trusted_marker_emits_gate_eligible_diagnostic() -> None: + # A malformed @trusted level must fail closed for that fn, not crash the scan, and + # must not disappear silently: otherwise a typo can turn a trusted sink green. src = "/// @trusted(level=BOGUS)\nfn f() {\n" + _SEED + " Command::new(t).output();\n}\n" - assert _findings(src) == [] # fail-closed, no exception + (diag,) = _findings(src) + assert diag.rule_id == "WLN-ENGINE-RUST-INVALID-TRUST-MARKER" + assert diag.severity is Severity.ERROR + assert diag.kind is Kind.DEFECT + assert diag.location.path == "src/m.rs" + assert diag.location.line_start == 2 + assert diag.qualname == "demo.m.f" + assert "invalid level 'BOGUS'" in diag.message def test_two_commands_on_one_line_get_distinct_fingerprints() -> None: diff --git a/tests/unit/scanner/rules/test_discriminator_shape.py b/tests/unit/scanner/rules/test_discriminator_shape.py index 8ec3d755..1223d4f1 100644 --- a/tests/unit/scanner/rules/test_discriminator_shape.py +++ b/tests/unit/scanner/rules/test_discriminator_shape.py @@ -3,11 +3,12 @@ Source-AST guardrail (wardline-8654423823). Since wlfp2 dropped ``line_start`` from the hash, a rule that can emit >1 finding per (rule_id, qualname) MUST carry a source-derived entity-relative discriminator in ``taint_path`` (a col span or an -ordinal); a singleton passes ``taint_path=None``. ``RuleMetadata.multi_emit`` is the -declared source of truth. This lint enforces the correspondence at AUTHORING time — -the gap the runtime collision guard (P2) and the frozen corpus only close once a -colliding pair is actually planted in a fixture. ``taint_path`` is a hash input that -is never persisted, so the check MUST be over source, not runtime. +ordinal); a singleton may use ``entity_source_fingerprint(entity.node)`` to avoid +carrying stale suppressions across same-qualname body changes. ``RuleMetadata.multi_emit`` +is the declared source of truth. This lint enforces the correspondence at AUTHORING +time — the gap the runtime collision guard (P2) and the frozen corpus only close once a +colliding pair is actually planted in a fixture. ``taint_path`` is a hash input that is +never persisted, so the check MUST be over source, not runtime. """ from __future__ import annotations @@ -20,20 +21,26 @@ from wardline.scanner.rules._sink_helpers import TaintedSinkRule _FP_NAMES = {"_fp", "compute_finding_fingerprint"} +_SINGLETON_DISCRIMINATOR = "entity_source_fingerprint" -def _taint_path_none_flags(source_file: str) -> list[bool]: +def _taint_path_shapes(source_file: str) -> list[str]: """For every ``_fp``/``compute_finding_fingerprint`` call in ``source_file``, whether - its ``taint_path`` kwarg is the literal ``None`` (True) or a real discriminator - (False). Asserts the kwarg is present at all (a missing taint_path is itself a bug).""" + its ``taint_path`` kwarg is absent, singleton-scoped, or multi-emit-scoped. + Asserts the kwarg is present at all (a missing taint_path is itself a bug).""" tree = ast.parse(Path(source_file).read_text(encoding="utf-8")) - flags: list[bool] = [] + shapes: list[str] = [] for node in ast.walk(tree): if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in _FP_NAMES: tp = next((kw for kw in node.keywords if kw.arg == "taint_path"), None) assert tp is not None, f"{source_file}: a fingerprint call omits the taint_path kwarg" - flags.append(isinstance(tp.value, ast.Constant) and tp.value.value is None) - return flags + if isinstance(tp.value, ast.Constant) and tp.value.value is None: + shapes.append("none") + elif isinstance(tp.value, ast.Call) and isinstance(tp.value.func, ast.Name): + shapes.append("singleton" if tp.value.func.id == _SINGLETON_DISCRIMINATOR else "multi") + else: + shapes.append("multi") + return shapes def test_taintedsinkrule_base_carries_a_discriminator() -> None: @@ -42,17 +49,19 @@ def test_taintedsinkrule_base_carries_a_discriminator() -> None: # build_sink_finding (the 2026-06-10 consolidation restored this single-call # property; the former mixins satisfied it only transitively). It must carry a # (non-None) span so every subclass is covered. - flags = _taint_path_none_flags(inspect.getfile(TaintedSinkRule)) - assert flags, "expected the TaintedSinkRule base to build a fingerprint" - assert not any(flags), "the shared sink-rule fingerprint must carry a discriminator (never taint_path=None)" + shapes = _taint_path_shapes(inspect.getfile(TaintedSinkRule)) + assert shapes, "expected the TaintedSinkRule base to build a fingerprint" + assert all(shape == "multi" for shape in shapes), ( + "the shared sink-rule fingerprint must carry a per-trigger discriminator" + ) def test_every_rule_multi_emit_matches_its_taint_path_shape() -> None: seen_multi = seen_singleton = False for cls in BUILTIN_RULE_CLASSES: multi_emit = cls.metadata.multi_emit - flags = _taint_path_none_flags(inspect.getfile(cls)) - if not flags: + shapes = _taint_path_shapes(inspect.getfile(cls)) + if not shapes: # No local fingerprint call => a TaintedSinkRule subclass, covered by the base # (asserted above). Such a rule is inherently multi-emit; the flag must say so. assert issubclass(cls, TaintedSinkRule), f"{cls.__name__}: no local _fp call but not a sink subclass" @@ -61,16 +70,18 @@ def test_every_rule_multi_emit_matches_its_taint_path_shape() -> None: continue if multi_emit: seen_multi = True - assert not any(flags), ( - f"{cls.__name__} is multi_emit but a fingerprint call passes taint_path=None — co-located " - f"findings would COLLIDE now that line_start left the hash (wlfp2). Give it an entity-relative " - f"span/ordinal discriminator, or set multi_emit=False if it truly emits <=1 per qualname." + assert all(shape == "multi" for shape in shapes), ( + f"{cls.__name__} is multi_emit but a fingerprint call uses a singleton discriminator — " + f"co-located findings would COLLIDE now that line_start left the hash (wlfp2). Give it an " + f"entity-relative span/ordinal discriminator, or set multi_emit=False if it truly emits <=1 " + f"per qualname." ) else: seen_singleton = True - assert all(flags), ( - f"{cls.__name__} is a singleton (multi_emit=False) but a fingerprint call passes a non-None " - f"taint_path. Either drop the discriminator (taint_path=None) or set multi_emit=True." + assert all(shape == "singleton" for shape in shapes), ( + f"{cls.__name__} is a singleton (multi_emit=False) but a fingerprint call does not use " + f"{_SINGLETON_DISCRIMINATOR}(). Either use the singleton source-body discriminator or " + f"set multi_emit=True." ) # Non-vacuity: the corpus of rules actually exercises both arms. assert seen_multi and seen_singleton, "expected both multi_emit and singleton rules in the builtin set" diff --git a/tests/unit/scanner/rules/test_sql_injection_expansion.py b/tests/unit/scanner/rules/test_sql_injection_expansion.py index 4a6f9f21..1a6f0774 100644 --- a/tests/unit/scanner/rules/test_sql_injection_expansion.py +++ b/tests/unit/scanner/rules/test_sql_injection_expansion.py @@ -378,6 +378,32 @@ def f(p, cursor): assert sqli[0].severity is Severity.ERROR +def test_multiline_chained_execute_calls_have_distinct_fingerprints(tmp_path: Path) -> None: + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, conn): + a = read_raw(p) + b = read_raw(p) + return conn.cursor().execute( + a + ).execute( + b + ) + """ + }, + ) + + sqli = _sqli(findings) + assert len(sqli) == 2 + line_ends = [f.location.line_end for f in sqli] + assert all(line is not None for line in line_ends) + assert sorted(line for line in line_ends if line is not None) == [12, 14] + assert len({f.fingerprint for f in sqli}) == 2 + + def test_118_undecorated_is_suppressed(tmp_path: Path) -> None: # Matrix slot (wardline-e159060db7): SQLInjection overrides check(), so its # tier-gate branch needs its own undecorated (UNKNOWN_RAW freedom-zone) diff --git a/tests/unit/scanner/taint/test_call_taint_map.py b/tests/unit/scanner/taint/test_call_taint_map.py index 5b401f40..2f837817 100644 --- a/tests/unit/scanner/taint/test_call_taint_map.py +++ b/tests/unit/scanner/taint/test_call_taint_map.py @@ -157,6 +157,74 @@ def test_l2_project_plain_submodule_import_carries_return_taint_end_to_end() -> assert out["x"] == T.EXTERNAL_RAW +def test_stdlib_plain_import_does_not_trust_project_submodule_spoof_end_to_end() -> None: + src = "import os\ndef f(p):\n x = os.path.normpath(p)\n" + func = ast.parse(src).body[1] + assert isinstance(func, ast.FunctionDef) + aliases = build_import_alias_map(ast.parse(src), module_path="m") + tm = build_call_taint_map( + module_path="m", + alias_map=aliases, + project_by_module={"os.path": {"normpath": T.ASSURED}}, + ) + assert "os.path.normpath" not in tm + + out = compute_variable_taints(func, T.INTEGRAL, dict(tm), alias_map=aliases) + + assert out["x"] == T.UNKNOWN_RAW + + +def test_stdlib_alias_import_does_not_trust_project_submodule_spoof_end_to_end() -> None: + src = "import os.path as op\ndef f(p):\n x = op.normpath(p)\n" + func = ast.parse(src).body[1] + assert isinstance(func, ast.FunctionDef) + aliases = build_import_alias_map(ast.parse(src), module_path="m") + tm = build_call_taint_map( + module_path="m", + alias_map=aliases, + project_by_module={"os.path": {"normpath": T.ASSURED}}, + ) + assert "op.normpath" not in tm + + out = compute_variable_taints(func, T.INTEGRAL, dict(tm), alias_map=aliases) + + assert out["x"] == T.UNKNOWN_RAW + + +def test_stdlib_from_import_parent_does_not_trust_project_submodule_spoof_end_to_end() -> None: + src = "from os import path\ndef f(p):\n x = path.normpath(p)\n" + func = ast.parse(src).body[1] + assert isinstance(func, ast.FunctionDef) + aliases = build_import_alias_map(ast.parse(src), module_path="m") + tm = build_call_taint_map( + module_path="m", + alias_map=aliases, + project_by_module={"os.path": {"normpath": T.ASSURED}}, + ) + assert "path.normpath" not in tm + + out = compute_variable_taints(func, T.INTEGRAL, dict(tm), alias_map=aliases) + + assert out["x"] == T.UNKNOWN_RAW + + +def test_stdlib_from_import_function_does_not_trust_project_spoof_end_to_end() -> None: + src = "from os.path import normpath\ndef f(p):\n x = normpath(p)\n" + func = ast.parse(src).body[1] + assert isinstance(func, ast.FunctionDef) + aliases = build_import_alias_map(ast.parse(src), module_path="m") + tm = build_call_taint_map( + module_path="m", + alias_map=aliases, + project_by_module={"os.path": {"normpath": T.ASSURED}}, + ) + assert "normpath" not in tm + + out = compute_variable_taints(func, T.INTEGRAL, dict(tm), alias_map=aliases) + + assert out["x"] == T.UNKNOWN_RAW + + # ── PART D: aliased serialisation sinks NOT in stdlib_taint resolve to UNKNOWN_RAW ── # # json.dumps/json.dump are in _SERIALISATION_SINKS but ABSENT from stdlib_taint diff --git a/tests/unit/scanner/taint/test_engine_precision.py b/tests/unit/scanner/taint/test_engine_precision.py index 6026718d..4ee2a663 100644 --- a/tests/unit/scanner/taint/test_engine_precision.py +++ b/tests/unit/scanner/taint/test_engine_precision.py @@ -28,6 +28,7 @@ import pytest +import wardline.scanner.taint.variable_level as variable_level from wardline.core.config import WardlineConfig from wardline.core.finding import Kind from wardline.core.taints import TaintState @@ -63,6 +64,12 @@ def _vt( return compute_variable_taints(func, function_taint, taint_map or {}, alias_map=alias_map, param_meets=param_meets) +def _func(src: str) -> ast.FunctionDef | ast.AsyncFunctionDef: + func = ast.parse(src).body[0] + assert isinstance(func, ast.FunctionDef | ast.AsyncFunctionDef) + return func + + def _defects(tmp_path: Path, src: str) -> list[tuple[str, int | None]]: p = tmp_path / "m.py" p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") @@ -70,6 +77,56 @@ def _defects(tmp_path: Path, src: str) -> list[tuple[str, int | None]]: return [(f.rule_id, f.location.line_start) for f in findings if f.kind is Kind.DEFECT] +# ── L2 work budget bounds attacker-authored super-linear inputs ───────────── + + +def test_l2_work_budget_bounds_per_statement_snapshots(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(variable_level, "L2_WORK_BUDGET", 8) + call_site_taints: dict[int, dict[str, TaintState]] = {} + + with pytest.raises(variable_level.L2BudgetExceeded) as exc: + compute_variable_taints( + _func("def f(p):\n v0 = read_raw(p)\n v1 = read_raw(p)\n v2 = read_raw(p)\n"), + T.INTEGRAL, + _RAW_TM, + call_site_taints=call_site_taints, + ) + + assert exc.value.operation == "statement_snapshot" + assert exc.value.attempted > exc.value.budget + + +def test_l2_work_budget_bounds_loop_fixpoint_iterations(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(variable_level, "L2_WORK_BUDGET", 18) + + with pytest.raises(variable_level.L2BudgetExceeded) as exc: + compute_variable_taints( + _func( + "def f(flag, raw):\n" + " x2 = raw\n" + " x1 = 'safe'\n" + " x0 = 'safe'\n" + " while flag:\n" + " x0 = x1\n" + " x1 = x2\n" + ), + T.INTEGRAL, + {}, + ) + + assert exc.value.operation in {"loop_iteration", "loop_merge"} + + +def test_l2_work_budget_bounds_branch_candidate_copies(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(variable_level, "L2_WORK_BUDGET", 30) + body = "\n".join(f" if flag{i}:\n cb = lambda v: sink{i}(v)" for i in range(8)) + + with pytest.raises(variable_level.L2BudgetExceeded) as exc: + compute_variable_taints(_func(f"def f(p):\n{body}\n cb(p)\n"), T.INTEGRAL, {}) + + assert exc.value.operation in {"lambda_branch_copy", "lambda_branch_merge"} + + # ── (1) container-conversion builtins propagate argument taint ────────────── diff --git a/tests/unit/scanner/taint/test_summary.py b/tests/unit/scanner/taint/test_summary.py index ef42e9c2..6807ddfb 100644 --- a/tests/unit/scanner/taint/test_summary.py +++ b/tests/unit/scanner/taint/test_summary.py @@ -45,9 +45,10 @@ def test_cache_key_includes_module_identity() -> None: assert _key(module_path="pkg.a") != _key(module_path="pkg.b") -def test_cache_key_rejects_crlf_source() -> None: - with pytest.raises(ValueError, match="CRLF"): - _key(source_bytes=b"def f():\r\n pass\r\n") +def test_cache_key_hashes_crlf_source_bytes() -> None: + crlf_key = _key(source_bytes=b"def f():\r\n pass\r\n") + assert crlf_key == _key(source_bytes=b"def f():\r\n pass\r\n") + assert crlf_key != _key(source_bytes=b"def f():\n pass\n") def test_cache_key_length_prefixed_no_collision() -> None: diff --git a/tests/unit/scanner/taint/test_variable_level.py b/tests/unit/scanner/taint/test_variable_level.py index 2afd0973..3c1793a5 100644 --- a/tests/unit/scanner/taint/test_variable_level.py +++ b/tests/unit/scanner/taint/test_variable_level.py @@ -1,6 +1,8 @@ from __future__ import annotations import ast +import time +from collections.abc import Callable import pytest @@ -473,6 +475,84 @@ def test_benign_rebind_after_loop_replaces_lambda_candidate() -> None: assert _lambda_body_sink_arg(src) == T.INTEGRAL +def _time_chained_rebind_analysis(make_src: Callable[[int], str]) -> tuple[float, float]: + """Analyze a chained-rebind source (built by *make_src(n)*) at N=700 and N=2000; + return ``(t_small, t_big)`` wall-clock seconds. + + The ``t_big / t_small`` ratio discriminates the merge's complexity CLASS + independent of hardware speed — a uniform slow-CI factor cancels in the ratio. + The eliminated nested-scan dedup was O(N**3): ~(2000/700)**3 ≈ 23x from small to + big. The set-based dedup is O(N**2): ~(2000/700)**2 ≈ 8x. A reversion to cubic + lands well above the 16x gate below on any box; first-run cold-cache noise only + inflates ``t_small`` (shrinks the ratio — the safe direction for an upper bound).""" + + def run(n: int) -> float: + func = ast.parse(make_src(n)).body[0] + assert isinstance(func, ast.FunctionDef) + start = time.perf_counter() + compute_variable_taints(func, T.UNKNOWN_RAW, {}, call_site_taints={}, alias_map={}, call_site_arg_taints={}) + return time.perf_counter() - start + + return run(700), run(2000) + + +def test_lambda_candidate_merge_is_not_cubic_on_chained_rebinds() -> None: + # wardline-c797baf28b (DoS): a sequence of one-armed branches each rebinding the + # SAME name to a fresh lambda grows the candidate set to N, and the per-merge + # identity dedup was a nested linear scan — O(bucket) per insert, O(bucket**2) + # per merge, O(N**3) cumulative. At ~1100 branches a single attacker-authored + # file made a DEFAULT-gate scan take ~15s. The merge dedups via an identity set + # now (O(bucket) per merge, O(N**2) cumulative). The primary guard is the + # hardware-independent scaling ratio (see _time_chained_rebind_analysis); the + # absolute backstop only trips on absurd slowness (nominal ~0.26s at N=2000). + def make_src(n: int) -> str: + lines = ["def f(raw):", " cb = lambda c: noop(c)"] + for i in range(n): + lines.append(f" if flag{i}:") + lines.append(f" cb = lambda c: sink{i}(c)") + lines.append(" cb(raw)") + return "\n".join(lines) + + t_small, t_big = _time_chained_rebind_analysis(make_src) + assert t_big / t_small < 16.0 # ~8x quadratic vs ~23x cubic — catches a cubic reversion on any box + assert t_big < 8.0 + + +def test_var_type_candidate_merge_is_not_cubic_on_chained_rebinds() -> None: + # wardline-c797baf28b sibling: ``_merge_branch_types`` unions receiver-type FQN + # candidate sets with the IDENTICAL nested-linear-scan dedup, so a chain of + # one-armed ``if flagK: x = ClsK()`` rebinds had the same O(N**3) blowup. The + # equality-set dedup makes it O(N**2). Same ratio + backstop guards. + def make_src(n: int) -> str: + lines = ["def f(raw):", " x = Base()"] + for i in range(n): + lines.append(f" if flag{i}:") + lines.append(f" x = Cls{i}()") + lines.append(" x.method(raw)") + return "\n".join(lines) + + t_small, t_big = _time_chained_rebind_analysis(make_src) + assert t_big / t_small < 16.0 + assert t_big < 8.0 + + +def test_chained_one_armed_rebinds_keep_every_lambda_candidate() -> None: + # wardline-c797baf28b SOUNDNESS lock (NOT the cubic-regression guard — this also + # passed pre-fix, where distinct lambdas were never wrongly coalesced). It pins + # the two ways the set-based dedup could silently change behavior: (1) a candidate + # CAP being introduced, or (2) the id-set coalescing distinct lambda bodies. A + # sink-lambda bound in the FIRST of many one-armed branches must remain a live + # candidate at the post-branch ``cb(raw)`` even though 39 later branches rebind the + # same name to benign lambdas — ``cb`` MAY still hold the first body on the path + # where only ``f0`` was taken, so the full union must survive (sink arg EXTERNAL_RAW). + lines = ["def handler(raw):", " if f0:", " cb = lambda c: sink(c)"] + for i in range(1, 40): + lines.append(f" if f{i}:") + lines.append(f" cb = lambda c: noop{i}(c)") + lines.append(" cb(raw)") + assert _lambda_body_sink_arg("\n".join(lines)) == T.EXTERNAL_RAW + + def test_compute_return_taint_all_shapes() -> None: import ast import textwrap diff --git a/tests/unit/scanner/test_analyzer_isolation.py b/tests/unit/scanner/test_analyzer_isolation.py index d8bfce78..d25810a9 100644 --- a/tests/unit/scanner/test_analyzer_isolation.py +++ b/tests/unit/scanner/test_analyzer_isolation.py @@ -128,6 +128,79 @@ def test_recursion_error_still_yields_function_skip_fact(tmp_path, monkeypatch) assert not any(f.rule_id == "WLN-ENGINE-FILE-FAILED" for f in findings) +def test_l2_budget_exceeded_emits_gate_eligible_function_skip(tmp_path, monkeypatch) -> None: + import wardline.scanner.taint.variable_level as variable_level + + monkeypatch.setattr(variable_level, "L2_WORK_BUDGET", 7) + _write( + tmp_path, + "svc.py", + """ + from wardline.decorators import external_boundary, trusted + + @external_boundary + def read_raw(p): + return p + + @trusted(level='ASSURED') + def leaky(p): + v0 = read_raw(p) + v1 = read_raw(p) + eval(v1) + """, + ) + + result = run_scan(tmp_path) + + skipped = [f for f in result.findings if f.rule_id == "WLN-ENGINE-FUNCTION-SKIPPED"] + assert len(skipped) == 1 + assert skipped[0].kind is Kind.DEFECT + assert skipped[0].severity is Severity.ERROR + assert skipped[0].qualname == "svc.leaky" + assert skipped[0].location.path == "svc.py" + assert skipped[0].location.line_start is not None + assert skipped[0].properties["reason"] == "taint_budget_exceeded" + assert skipped[0].properties["budget"] == 7 + assert skipped[0].properties["attempted"] > 7 + assert gate_decision(result, Severity.ERROR).tripped is True + + ctx = result.context + assert ctx is not None + assert ctx.function_var_taints["svc.leaky"] == {} + assert ctx.function_return_taints["svc.leaky"] == TaintState.UNKNOWN_RAW + + +def test_candidate_key_budget_exceeded_emits_function_skip(tmp_path, monkeypatch) -> None: + import wardline.scanner.analyzer as analyzer_mod + + monkeypatch.setattr(analyzer_mod, "_CANDIDATE_KEY_BUDGET", 4) + _write( + tmp_path, + "svc.py", + """ + from wardline.decorators import trusted + + @trusted(level='ASSURED') + def wide(p, obj): + obj.a.b.c.d + return p + """, + ) + + result = run_scan(tmp_path) + + skipped = [f for f in result.findings if f.rule_id == "WLN-ENGINE-FUNCTION-SKIPPED"] + assert len(skipped) == 1 + assert skipped[0].qualname == "svc.wide" + assert skipped[0].kind is Kind.DEFECT + assert skipped[0].severity is Severity.ERROR + assert skipped[0].properties["reason"] == "taint_budget_exceeded" + assert skipped[0].properties["operation"] == "candidate_key_probe" + assert skipped[0].properties["budget"] == 4 + assert skipped[0].properties["attempted"] > 4 + assert gate_decision(result, Severity.ERROR).tripped is True + + def test_fixpoint_recursion_error_yields_function_skip_and_unknown_return(tmp_path, monkeypatch) -> None: # The fixpoint rerun must not silently retain pass-1 results. It surfaces the same # function-level skip diagnostic and overwrites the affected function with UNKNOWN_RAW. diff --git a/tests/unit/security/test_symlink_toctou_hardening.py b/tests/unit/security/test_symlink_toctou_hardening.py index 4cd79c38..4575a403 100644 --- a/tests/unit/security/test_symlink_toctou_hardening.py +++ b/tests/unit/security/test_symlink_toctou_hardening.py @@ -166,6 +166,129 @@ def test_explicit_agent_summary_output_refuses_symlink(tmp_path: Path) -> None: assert victim.read_text(encoding="utf-8") == "KEEP\n" # target untouched +@pytest.mark.parametrize( + ("fmt", "filename"), + [ + ("jsonl", "findings.jsonl"), + ("sarif", "findings.sarif"), + ("legis", "scan.legis.json"), + ], +) +def test_explicit_relative_scan_output_refuses_parent_symlink_escape( + tmp_path: Path, monkeypatch, fmt: str, filename: str +) -> None: + # `wardline scan . --output reports/` runs inside an untrusted checkout. + # A repo-controlled symlinked parent must not redirect the explicit artifact + # outside the scan root. + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(project) + + result = CliRunner().invoke(cli, ["scan", ".", "--format", fmt, "--output", f"reports/{filename}"]) + + assert result.exit_code == 2 + assert "escapes project root" in result.output + assert not (outside / filename).exists() + + +def test_explicit_output_through_root_alias_refuses_parent_symlink_escape(tmp_path: Path, monkeypatch) -> None: + # The output path can name the same scan root through a symlink alias. Once the path + # enters the untrusted root, later repo-controlled parent symlinks must still be + # rejected instead of falling back to the outside-output writer. + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(project, target_is_directory=True) + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(cli, ["scan", str(project), "--output", "alias/reports/findings.jsonl"]) + + assert result.exit_code == 2 + assert "escapes project root" in result.output + assert not (outside / "findings.jsonl").exists() + + +def test_explicit_output_through_subdir_alias_dotdot_refuses_parent_symlink_escape(tmp_path: Path, monkeypatch) -> None: + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "subdir").mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + alias = tmp_path / "alias_to_subdir" + alias.symlink_to(project / "subdir", target_is_directory=True) + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + cli, + ["scan", str(project), "--output", "alias_to_subdir/../reports/findings.jsonl"], + ) + + assert result.exit_code == 2 + assert "escapes project root" in result.output + assert not (outside / "findings.jsonl").exists() + + +def test_explicit_relative_outside_scan_output_remains_allowed(tmp_path: Path, monkeypatch) -> None: + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + monkeypatch.chdir(project) + + result = CliRunner().invoke(cli, ["scan", ".", "--output", "../outside.jsonl"]) + + assert result.exit_code == 0, result.output + assert (tmp_path / "outside.jsonl").exists() + + +def test_explicit_relative_agent_summary_output_refuses_parent_symlink_escape(tmp_path: Path, monkeypatch) -> None: + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(project) + + result = CliRunner().invoke( + cli, + ["scan", ".", "--format", "agent-summary", "--output", "reports/summary.json"], + ) + + assert result.exit_code == 2 + assert "escapes project root" in result.output + assert not (outside / "summary.json").exists() + + def test_scan_job_explicit_agent_summary_output_refuses_symlink(tmp_path: Path) -> None: # The scan-job WORKER agent-summary artifact write must be no-follow too (regression for # the fa1ca063 _write_scan_artifact restructure, which lost the guard): a planted @@ -189,6 +312,110 @@ def test_scan_job_explicit_agent_summary_output_refuses_symlink(tmp_path: Path) assert victim.read_text(encoding="utf-8") == "KEEP\n" +def test_scan_job_explicit_output_through_root_alias_refuses_parent_symlink_escape(tmp_path: Path, monkeypatch) -> None: + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(project, target_is_directory=True) + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + cli, + [ + "scan-job", + "start", + str(project), + "--output", + str(alias / "reports" / "findings.jsonl"), + "--foreground", + ], + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["status"] == "failed" + assert not (outside / "findings.jsonl").exists() + + +def test_scan_job_explicit_output_through_subdir_alias_dotdot_refuses_parent_symlink_escape( + tmp_path: Path, monkeypatch +) -> None: + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "subdir").mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + alias = tmp_path / "alias_to_subdir" + alias.symlink_to(project / "subdir", target_is_directory=True) + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + cli, + [ + "scan-job", + "start", + str(project), + "--output", + str(alias / ".." / "reports" / "findings.jsonl"), + "--foreground", + ], + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["status"] == "failed" + assert not (outside / "findings.jsonl").exists() + + +def test_scan_job_agent_summary_through_subdir_alias_dotdot_refuses_parent_symlink_escape( + tmp_path: Path, monkeypatch +) -> None: + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "subdir").mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + alias = tmp_path / "alias_to_subdir" + alias.symlink_to(project / "subdir", target_is_directory=True) + (project / "reports").symlink_to(outside, target_is_directory=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + cli, + [ + "scan-job", + "start", + str(project), + "--format", + "agent-summary", + "--output", + str(alias / ".." / "reports" / "summary.json"), + "--foreground", + ], + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["status"] == "failed" + assert not (outside / "summary.json").exists() + + def test_pid_is_scan_job_worker_rejects_non_worker_group_leader() -> None: # A genuine group-leader that is NOT our worker (cmdline mismatch) is rejected. victim = subprocess.Popen(["sleep", "30"], start_new_session=True) # noqa: S603, S607 diff --git a/tests/unit/test_site_kit_fetch.py b/tests/unit/test_site_kit_fetch.py new file mode 100644 index 00000000..7633f2d1 --- /dev/null +++ b/tests/unit/test_site_kit_fetch.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b") + + +def test_pages_workflow_pins_site_kit_fetch_to_commit_sha() -> None: + workflow = (ROOT / ".github" / "workflows" / "deploy-site.yml").read_text(encoding="utf-8") + + ref_match = re.search(r"WEFT_SITE_KIT_REF:\s*([0-9a-f]{40})\b", workflow) + + assert ref_match is not None + assert FULL_SHA_RE.fullmatch(ref_match.group(1)) + assert "WEFT_SITE_KIT_REF || 'main'" not in (ROOT / "site" / "scripts" / "fetch-site-kit.mjs").read_text( + encoding="utf-8" + ) + + +def test_fetch_site_kit_rejects_mutable_ref_in_github_actions() -> None: + env = { + **os.environ, + "GITHUB_ACTIONS": "true", + "WEFT_SITE_KIT_REMOTE": "1", + "WEFT_SITE_KIT_REPO": "file:///definitely/not/a/repo.git", + "WEFT_SITE_KIT_REF": "main", + } + + result = subprocess.run( + ["node", "scripts/fetch-site-kit.mjs"], + cwd=ROOT / "site", + env=env, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 1 + assert "WEFT_SITE_KIT_REF must be a 40-character commit SHA" in result.stderr + assert "not a git repository" not in result.stderr.lower() diff --git a/www/README.md b/www/README.md index 923e8e3b..3bae66e3 100644 --- a/www/README.md +++ b/www/README.md @@ -1,142 +1,78 @@ -# Wardline — front-door site +# Wardline — `www/` (LEGACY / SUPERSEDED front door) -Static front door for **Wardline**, a faithful sibling of the Weft Federation hub site at -`~/weft/www/`. Hand-rolled HTML/CSS/JS, no build step, no runtime dependencies. -GitHub-Pages-deployable as-is. +> **This directory is no longer the deployed site.** It is a hand-rolled static +> front door that has been **superseded by the Astro build in [`site/`](../site/)**. +> `www/` is retained as historical / reference content only. It is **not built, +> not deployed, and not served.** Do not edit it expecting changes to reach the +> live site. -This www front door is the **canonical root** of `wardline.foundryside.dev`. The MkDocs -reference docs (`~/wardline/docs/`) are served from the **`/docs/` subpath** under the same -domain. The CI `docs-deploy` job assembles both into one GitHub-Pages tree (this `www/` at the -root, `mkdocs build` into `publish/docs/`) and force-pushes it to the `gh-pages` branch — see -**Deployment** below. +## What is live instead -## Files +The live site is the **Astro build in [`site/`](../site/)**, which consumes the +shared **`@weft/site-kit`** design system (a `file:` dependency sparse-fetched +from the `foundryside-dev/weft` repo). It is deployed to the apex +**https://wardline.foundryside.dev** by `.github/workflows/deploy-site.yml` +(GitHub Actions → GitHub Pages; CNAME from `site/public/CNAME`). The workflow +runs on pushes to `main` that touch `site/**` or the workflow file. -| File | Purpose | -|---|---| -| `index.html` | The page: header, hero (what Wardline does + install strip + PY-WL-101 finding panel + metric strip + CI gate example), trust model (3 decorators + 8-state lattice + opt-in explanation), command surface (CLI + MCP + install layers), **federation role** (enrich-only, SEI keying, 3 bindings, sibling member links), footer. Content-complete server-side. | -| `colors_and_type.css` | **Token source of truth, copied verbatim from the Weft design system** (`~/weft/www/colors_and_type.css`). Surfaces, text, accent, the per-member thread palette, radii, elevation, spacing, the mono/display type roles, light theme, and the `ddMenuIn` keyframe. **Do not edit tokens here — re-copy from the design system on any update.** | -| `styles.css` | Wardline layout + components, layered on the tokens. Coral (`--thread-wardline: #F0875E`) is the identity color for left-rules, glyph, eyebrow, and section accents. Amber (`--accent`) is reserved for interactive affordances (links, focus rings), exactly as the Weft hub does. | -| `main.js` | Progressive enhancement only: copy-to-clipboard on the install strip; hover-reveal anchor links on section headings; member row hover treatment. Content-complete with JS disabled. | -| `fonts/` | JetBrains Mono (upright + italic) and Space Grotesk variable TTFs + OFL licenses. Copied verbatim from `~/weft/www/fonts/`. Bundled locally — fully offline, no CDN. | -| `assets/marks/` | Federation glyph SVGs: `wardline.svg`, `weft.svg`, `foundryside.svg`, `loomweave.svg`, `filigree.svg`, `legis.svg`. Copied verbatim from `~/weft/www/assets/marks/`. Inlined in `index.html` to inherit thread color via `currentColor`. | -| `.nojekyll` | Serve files verbatim on GitHub Pages (no Jekyll processing). | - -## Preview locally - -``` -cd /home/john/wardline/www -python3 -m http.server 8000 -``` - -Then open `http://localhost:8000/`. Use `localhost` (not `file://`) so the preloaded fonts -resolve under a normal origin. - -## Design fidelity and deliberate decisions - -### Token copy discipline - -`colors_and_type.css` is copied verbatim from the Weft design system. The comment at its top -says not to edit tokens locally — follow that. On a design-system update, replace the whole file -rather than patching individual tokens. - -### Identity colors - -Coral (`--thread-wardline: #F0875E`) is Wardline's strand. It appears on: -- The header glyph -- Left-rule borders on content cards -- Eyebrow labels on each section -- The trust-flow panel's left rule - -Amber (`--accent: #E9B04A`) is the shared interactive affordance color: -- All `` links -- Focus rings -- The CI gate callout border (shared infrastructure concept, not Wardline-specific) -- The "The federation axiom / connective tissue" facts (amber = shared concern) - -### No rule count - -The brief is explicit: do not state a rule count. The rules section names rule families -qualitatively (trust-boundary leaks, untrusted data reaching deserialization/exec/shell/SQL/SSRF/ -path-traversal, fail-open boundaries, non-rejecting validators) and links to the repo as authority. -The "Four policy rules" phrasing in the README and the "~20 rules" phrasing in members/wardline.md -are both off-limits — they conflict and drift. +The current site is a **single landing page** (`site/src/pages/index.astro`). +There is **no `/docs/` subpath** and **no separately published HTML docs site.** +The landing page links the reference docs as **GitHub repo markdown**, e.g. +`https://github.com/foundryside-dev/wardline/tree/main/docs/getting-started.md`. +(The MkDocs config was removed from the repo — see commit `192462e7`, +"retire mkdocs + www/ gh-pages deploy". The `wardline[docs]` extra still builds +a *local* MkDocs render of `docs/`, but it does not publish anything.) -### A-1 binding tag +## What used to be true (and is now wrong) -Tagged `A-1 · LIVE — until Loomweave-absent path demonstrated end-to-end`, per the brief and -the current weft www hub text. The native Filigree emitter has shipped; the asterisk stays live -until the Loomweave-absent composition path is demonstrated end-to-end. +For the historical record, the old model this `www/` directory was written for — +all of which is now **retired**: -### Version +- `www/` was the canonical root of `wardline.foundryside.dev`, deployed via a + `docs-deploy` CI job that copied `www/` to the publish root, ran + `mkdocs build` into a `/docs/` subpath, and force-pushed the combined tree to + the `gh-pages` branch. **That job and `mkdocs.yml` are gone.** +- Docs were served at `wardline.foundryside.dev/docs/`. **They are not** — docs + now live as repo markdown under `github.com/foundryside-dev/wardline/tree/main/docs/`. -Shows `v1.0.0rc4` (from the brief). The working branch is `rc5` at the time of writing — noted -as an open fact for the user. +## The `www/` assets (reference content) -### Dark only +The files below are the hand-rolled static page. They still render if opened +directly (see local preview), but they are decoupled from the live site and may +drift from current brand/version facts. -The warm espresso theme is canonical and the Weft kit ships no toggle, so none is added. The -`colors_and_type.css` tokens include a full light theme under `[data-theme="light"]` if it is -wanted later. - -### No theme-flash / font-flash - -Both brand faces are ``-ed before first paint. +| File | Purpose | +|---|---| +| `index.html` | The page: header, hero, trust model (decorators + 8-state lattice), command surface, federation role, footer. Content-complete server-side. | +| `colors_and_type.css` | Design tokens copied verbatim from the Weft design system (`~/weft/www/colors_and_type.css`). Tokens were not meant to be edited locally. | +| `styles.css` | Layout + components layered on the tokens. Coral (`--thread-wardline: #F0875E`) is Wardline's identity color; amber (`--accent`) is reserved for interactive affordances. | +| `main.js` | Progressive enhancement only: copy-to-clipboard on the install strip; hover-reveal anchor links; member-row hover. Content-complete with JS disabled. | +| `fonts/` | JetBrains Mono + Space Grotesk TTFs + OFL licenses, bundled locally (offline, no CDN). | +| `assets/marks/` | Federation glyph SVGs, inlined in `index.html` to inherit thread color via `currentColor`. | +| `.nojekyll` | Serve files verbatim on GitHub Pages (no Jekyll processing). | -### Content-complete without JS +> Note: the static `index.html` is a historical snapshot and may show a stale +> version string. The authoritative version comes from the package +> (`wardline --version`), and the authoritative site is `site/`. -The entire page is readable with JS disabled. The JS file only adds: -- Copy-to-clipboard on the install command strip -- Hover-reveal anchor links on section headings -- Member row hover treatment +## Preview the legacy page locally -### Federation section +The static page still serves over plain HTTP: -The Federation section is first-class — it precedes the footer and has the same weight as the -Trust Model and Commands sections. It covers, in order: the enrich-only axiom, SEI keying, the -three bindings (Wardline→Loomweave, Wardline→Filigree A-1, Wardline→Legis), and a live sibling -member strip linking back to each member's repo. +``` +cd /home/john/wardline/www +python3 -m http.server 8000 +``` -### Hero finding panel +Then open `http://localhost:8000/`. Use `localhost` (not `file://`) so the +preloaded fonts resolve under a normal origin. -The PY-WL-101 motif is re-colored from the old teal palette onto the warm-Loom palette: -- The panel uses a pinned dark editor surface (`#131E24`/`#0F1A20`) regardless of theme -- Syntax tokens use sky (`#56B7E2`), amber (`#E9B04A`), aqua (`#52C9B8`), warm-emerald (`#5FB98E`) - — colors derived from the Loom thread palette -- The trust leak (`fp-raw`) reads stale-red (`#E2604E`) -- The verdict wash uses `rgba(226, 96, 78, 0.10)` (the Loom stale-red at low opacity) +To preview the **live** site instead, work in [`site/`](../site/) (`npm install` +then `npm run dev`, per that directory's tooling). -## Links — wired to `foundryside-dev` +## Links - Repo: `github.com/foundryside-dev/wardline` -- Docs: `wardline.foundryside.dev/docs/` (the MkDocs reference docs, served from the subpath) +- Docs: repo markdown under `github.com/foundryside-dev/wardline/tree/main/docs/` +- Live site: https://wardline.foundryside.dev (built from `site/`) - Weft hub: `github.com/foundryside-dev/weft` -- Sibling repos: `github.com/foundryside-dev/` (Loomweave repo = `clarion`) - -## Deployment - -This front door owns the site root; the MkDocs docs are served from `/docs/`. Both ship as a -single GitHub-Pages tree assembled by the `docs-deploy` job in `.github/workflows/ci.yml` (runs -only on `push` to `main`): - -1. `cp -r www/. publish/` — this front door becomes the publish root. -2. `mkdocs build --strict -d publish/docs` — the reference docs build into the `/docs/` subpath - (`site_url` in `mkdocs.yml` is pinned to `https://wardline.foundryside.dev/docs/` so internal - links and the canonical resolve under the subpath). -3. `publish/CNAME` (copied from `www/CNAME`) and `publish/.nojekyll` sit at the root; the - `gh-pages` branch is force-pushed with the combined tree. - -**CNAME single source of truth:** `www/CNAME`. There is intentionally no `docs/CNAME` — if there -were, `mkdocs build -d publish/docs` would emit a stray `publish/docs/CNAME`, and a bare -`mkdocs gh-deploy` would publish the docs domain-less. The assembly path is the only correct deploy. - -To preview the **assembled** tree exactly as it ships: - -``` -cd /home/john/wardline -rm -rf publish && cp -r www/. publish/ && uv run mkdocs build --strict -d "$PWD/publish/docs" -cd publish && python3 -m http.server 8000 # root → / · docs → /docs/ -``` - -The trimmed MkDocs landing (`docs/index.md`, no longer using the deleted `overrides/home.html` -template) is a plain reference-docs index; the marketing/front-door role lives here in `www/`.