diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 51e65f19..ffa95aac 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 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/CHANGELOG.md b/CHANGELOG.md index a3e575c7..8d84d70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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.6] - 2026-06-20 ### Changed @@ -1339,6 +1353,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/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..756540bd 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 . 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/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 3cd59297..6b3c1dd3 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:556`). +`suppressed` count (`src/wardline/cli/scan.py:559`). ## `active` is the one word for "non-suppressed defect" @@ -70,10 +70,10 @@ consistently, on every surface: | Surface | Where | Term | | --- | --- | --- | -| Enum | `src/wardline/core/finding.py:72` | `SuppressionState.ACTIVE = "active"` | +| Enum | `src/wardline/core/finding.py:77` | `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:557` | `… {s.active} active` | -| MCP scan response | `src/wardline/mcp/server.py:1010` | `summary.active` | +| CLI summary line | `src/wardline/cli/scan.py:559-560` | `… {s.active} active` | +| MCP scan response | `src/wardline/mcp/server.py:1027` | `summary.active` | | Agent-summary JSON | `src/wardline/core/agent_summary.py:140` | `summary.active_defects` | | `wardline:loop` prompt | `src/wardline/mcp/prompts.py:13` | "Read `summary.active`" | @@ -101,8 +101,8 @@ 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 +The MCP `summary` block exposes `informational` (`src/wardline/mcp/server.py:1035`) +and `unanalyzed` (`src/wardline/mcp/server.py:1039`); the agent-summary block mirrors both (`src/wardline/core/agent_summary.py:151`, `src/wardline/core/agent_summary.py:152`). ## Emitted-active vs the gate population @@ -150,16 +150,16 @@ 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:1042`), +`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:1046`), `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:1041` (`"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 `gate: FAILED () — ` then `gate: evaluated <…>`, or a `gate: NOT_EVALUATED — …` line for a bare scan -(`src/wardline/cli/scan.py:609`). +(`src/wardline/cli/scan.py:612`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists @@ -173,9 +173,9 @@ still legitimately means three different things depending on the surface: | "new" on this surface | Means | Owner / anchor | | --- | --- | --- | -| 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`) | +| 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:89-105`) | | `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:556` | +| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:559-560` | The first-seen Filigree sense and the delta-scope `--new-since` sense are genuinely distinct concepts; neither is "active". @@ -186,16 +186,16 @@ 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:557`) | `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:556`) | `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 | +| every finding | `N finding(s)` | `total` (`run.py:70`) | `total` (`server.py:1026`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | +| live defect | `N active` (`scan.py:560`) | `active` (`run.py:71,551`) | `active` (`server.py:1027`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:305`) | +| suppressed (sum) | `N suppressed` (`scan.py:559`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:141`) | `metadata.wardline.suppression_state` (`finding.py:305`) | +| baselined | `N baseline` | `baselined` (`run.py:73`) | `baselined` (`server.py:1028`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | +| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:1029`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | +| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:1030`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | +| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:1035`) | `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 | +| under-scan | `N file(s) could not be analyzed` | `unanalyzed` (`run.py:89`) | `unanalyzed` (`server.py:1039`) | `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:1041`), `gate.tripped` (`server.py:1042`), `gate.verdict` (`server.py:1046`) | `gate.tripped` (`agent_summary.py:155`), `gate.verdict` (`agent_summary.py:158`) | not emitted to Filigree | The unsuppressed gate population is built from `Baseline(frozenset())` (`src/wardline/core/run.py:486`). 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/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/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/src/wardline/cli/doctor.py b/src/wardline/cli/doctor.py index a7442657..be2e9d56 100644 --- a/src/wardline/cli/doctor.py +++ b/src/wardline/cli/doctor.py @@ -11,7 +11,7 @@ from wardline.install.doctor import ( _check_config, _check_filigree_auth, - _resolve_probe_url, + _resolve_probe_target, check_install, machine_readable_doctor, repair_install, @@ -47,7 +47,7 @@ def doctor(root: Path, repair: bool, fix_json: bool, filigree_url: str | None) - if repair: # 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,7 +61,7 @@ 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: 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 4c884f2f..d438631a 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 @@ -26,11 +25,12 @@ 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.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: @@ -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. @@ -456,12 +458,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). @@ -469,7 +472,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, @@ -478,26 +481,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: @@ -511,7 +514,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: @@ -700,20 +703,4 @@ def _loomweave_status(result: object | None) -> dict[str, object]: 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/config.py b/src/wardline/core/config.py index 7d149663..e7b557fa 100644 --- a/src/wardline/core/config.py +++ b/src/wardline/core/config.py @@ -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_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..9781e2fb 100644 --- a/src/wardline/core/discovery.py +++ b/src/wardline/core/discovery.py @@ -47,8 +47,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 +71,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 +80,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 +112,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 +122,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/filigree_emit.py b/src/wardline/core/filigree_emit.py index 9fd911ec..7dd19951 100644 --- a/src/wardline/core/filigree_emit.py +++ b/src/wardline/core/filigree_emit.py @@ -12,6 +12,7 @@ from __future__ import annotations +import http.client import json import os import urllib.error @@ -24,7 +25,7 @@ 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, @@ -91,18 +92,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 +133,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 +188,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 +413,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 +540,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 +580,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 @@ -565,12 +608,19 @@ class UrllibTransport: def __init__(self, timeout: float = 30.0) -> None: self._timeout = timeout + 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}") + diagnostic_url = redact_url_for_diagnostics(url) + raise FiligreeEmitError( + f"--filigree-url must use http or https; got scheme {scheme!r} in {diagnostic_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 @@ -581,11 +631,16 @@ def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: # the underlying socket. with exc: return Response(status=exc.code, body=read_response_text(exc)) + except http.client.InvalidURL: + raise self._invalid_url_error(url) from None 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}") + diagnostic_url = redact_url_for_diagnostics(url) + raise FiligreeEmitError( + f"filigree URL must use http or https; got scheme {scheme!r} in {diagnostic_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 @@ -593,6 +648,8 @@ def get(self, url: str, headers: Mapping[str, str]) -> Response: except urllib.error.HTTPError as exc: with exc: return Response(status=exc.code, body=read_response_text(exc)) + except http.client.InvalidURL: + raise self._invalid_url_error(url) from None def _resolve_operator_max_findings_per_request(explicit: int | None) -> int | None: @@ -757,7 +814,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 +829,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 +842,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/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/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_jobs.py b/src/wardline/core/scan_jobs.py index 1479b3c9..b76b97a6 100644 --- a/src/wardline/core/scan_jobs.py +++ b/src/wardline/core/scan_jobs.py @@ -30,7 +30,12 @@ ) 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}$") @@ -297,7 +302,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/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..80b000bc 100644 --- a/src/wardline/install/doctor.py +++ b/src/wardline/install/doctor.py @@ -12,7 +12,7 @@ from typing import Any from urllib.parse import urlsplit -from wardline.core.config import _filigree_published_url, load +from wardline.core.config import _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 @@ -226,20 +226,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 +360,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 +467,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,7 +520,9 @@ 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.""" @@ -535,18 +537,34 @@ 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)) 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/mcp/server.py b/src/wardline/mcp/server.py index 0b298a99..869ee695 100644 --- a/src/wardline/mcp/server.py +++ b/src/wardline/mcp/server.py @@ -23,7 +23,12 @@ 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.filigree_emit import ( + FiligreeEmitter, + filigree_destination, + filigree_disabled_reason, + 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,7 +123,7 @@ 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), } @@ -791,20 +796,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( @@ -2045,11 +2062,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 +3986,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 +4005,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 +4037,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": { @@ -4094,9 +4132,9 @@ def _doctor( }, "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.", }, }, }, @@ -4111,6 +4149,17 @@ def _doctor( } +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 +4194,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 +4258,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 +4323,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 +4609,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 +4840,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 +4861,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 +4936,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 +4963,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..bd14d13b 100644 --- a/src/wardline/scanner/taint/variable_level.py +++ b/src/wardline/scanner/taint/variable_level.py @@ -216,6 +216,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 +646,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 +681,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 +1273,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 +1658,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 +1668,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 +1702,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 +1786,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 +1825,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 +1833,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 +1841,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 +1863,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 +1891,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 +1925,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 +1971,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 +1986,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/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/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index fdc12528..3ad7bef2 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -39,20 +39,20 @@ ("src/wardline/core/run.py", 551, "active=sum"), ("src/wardline/core/run.py", 643, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 556, "suppressed"), - ("src/wardline/cli/scan.py", 557, "{s.active} active"), - ("src/wardline/cli/scan.py", 609, "gate: FAILED"), + ("src/wardline/cli/scan.py", 559, "suppressed"), + ("src/wardline/cli/scan.py", 560, "{s.active} active"), + ("src/wardline/cli/scan.py", 612, "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", 1026, '"total": result.summary.total'), + ("src/wardline/mcp/server.py", 1027, '"active": result.summary.active'), + ("src/wardline/mcp/server.py", 1028, '"baselined": result.summary.baselined'), + ("src/wardline/mcp/server.py", 1029, '"waived": result.summary.waived'), + ("src/wardline/mcp/server.py", 1030, '"judged": result.summary.judged'), + ("src/wardline/mcp/server.py", 1035, '"informational": result.summary.informational'), + ("src/wardline/mcp/server.py", 1039, '"unanalyzed": result.summary.unanalyzed'), + ("src/wardline/mcp/server.py", 1041, '"gate": {'), + ("src/wardline/mcp/server.py", 1042, '"tripped": decision.tripped'), + ("src/wardline/mcp/server.py", 1046, '"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"'), @@ -67,10 +67,10 @@ # informational display array (new, W3 residual fix) ("src/wardline/core/agent_summary.py", 176, '"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/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 d9c82332..35c2bf7e 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_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_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_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_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..e7844aaa 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 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_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_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/`.