diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c99b4b..6a865cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-07-03 + ### Added - **Concurrent-writer detection**: the scan now stat-snapshots the discovered file inventory at discovery and re-checks it after analysis. If any scanned file was @@ -60,13 +62,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A configured gate over zero scanned files is `NOT_EVALUATED`, not a vacuous `PASSED`** (`no_files_scanned`): an empty-but-existing source root or an exclude-all config no longer reads as an authoritative green. +- **Partial federation mutations are no longer reported as clean failures on the human + surfaces** (wardline-7924d67d3b). A mid-emit failure after chunks landed used to + print `could not reach Filigree …; findings written locally only` (CLI) and persist + `filigree unreachable` as the scan-job terminal `error` — flatly false once Filigree + had ingested earlier chunks and run their mark_unseen sweeps. The CLI unreachable + branch now renders the partial truth (chunks landed, created/updated counts, findings + that did not land, producer warnings) on every soft-failure rung — auth, server error, + and transport alike — and the transport rung says `connection dropped mid-emit` + instead of claiming first-contact unreachability; `filigree_disabled_reason` appends + the landed-chunk count on mid-batch failures so the scan-job `error` field and + agent-summary `disabled_reason` agree with the counts beside them. Likewise a partial + Loomweave taint-fact write (a later chunk hit an outage/403 after earlier chunks + committed) now reports `taint store partially written … N taint fact(s) committed + before the failure` instead of the flat `taint store not written`. ### Changed - **Filigree emit accounting is honest about mid-batch failures**: `EmitResult` now carries the landed chunk count, per-finding `partial` failures, and a "connection dropped mid-emit: K of N chunk(s) landed" warning when the connection - drops after chunks landed (machine envelope; the CLI text surface is tracked in - wardline-7924d67d3b). + drops after chunks landed (machine envelope; the CLI and scan-job text surfaces + ship in this release — see Fixed, wardline-7924d67d3b). - **Loomweave resolve stops on auth rejection**: a hinted 401/403 halts the batch and is carried as `ResolveResult.auth_status` so surfaces can steer the operator to the token rather than a phantom unresolved entity (consumer threading tracked in @@ -1556,7 +1572,11 @@ 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 +[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/foundryside-dev/wardline/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/foundryside-dev/wardline/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/foundryside-dev/wardline/compare/v1.0.7...v1.1.0 +[1.0.7]: https://github.com/foundryside-dev/wardline/compare/v1.0.6...v1.0.7 [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/UPGRADING.md b/UPGRADING.md index 696074b0..03009878 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,7 +2,27 @@ Migration notes for changes that can alter a previously-green run. Newest first. -## To the next release (recommended v1.2) — preview rules now gate (soundness) +## To v1.3 — federation transports refuse redirects; signing fails closed + +**Redirects are never followed on credential-bearing transports** +(`wardline-f68c483d92`). Previously urllib followed a 3xx, re-sending +`Authorization`/`X-Weft-*` headers to the redirect target (cross-origin included) +and rewriting a redirected POST into a body-less GET whose 200 read as a clean +emit — a silent false green. If your `--filigree-url` / `--loomweave-url` / +OpenRouter endpoint resolves through a redirect (an `http→https` upgrade, a host +alias), the transport now refuses the first 3xx fail-closed: emits report +failure, `verify_token` reports inconclusive, and the promote/judge legs reject +loudly. **What to do:** point the URL at the final, post-redirect target. The +taint gate itself is unaffected — federation stays non-gating enrichment. + +**Signing paths fail closed on indeterminate git state.** A failed `git status` +no longer coerces to *clean*: the legis artifact signs only on a proven-clean +tree, and `attest` signs an indeterminate tree only under explicit +`allow_dirty` (recording `dirty: null` uncoerced). A CI job that signed from a +checkout where git state could not be read now fails instead of producing a +falsely-clean signed bundle. + +## To v1.2 — preview rules now gate (soundness) `wardline-4ada23bb09`. The `--fail-on` gate previously **ignored** any rule whose `maturity` is `preview`, so a scan could pass green while an active ERROR defect diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index 39ab2be4..c1fb54ee 100644 --- a/docs/reference/finding-lifecycle-vocabulary.md +++ b/docs/reference/finding-lifecycle-vocabulary.md @@ -61,7 +61,7 @@ The Filigree metadata only carries the key when the state is not `active` **"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:616`). +`suppressed` count (`src/wardline/cli/scan.py:654`). ## `active` is the one word for "non-suppressed defect" @@ -72,7 +72,7 @@ consistently, on every surface: | --- | --- | --- | | Enum | `src/wardline/core/finding.py:72` | `SuppressionState.ACTIVE = "active"` | | Summary field | `src/wardline/core/run.py:100`, built at `src/wardline/core/run.py:659` | `ScanSummary.active` | -| CLI summary line | `src/wardline/cli/scan.py:617` | `… {s.active} active` | +| CLI summary line | `src/wardline/cli/scan.py:655` | `… {s.active} active` | | MCP scan response | `src/wardline/mcp/server.py:944` | `summary.active` | | Agent-summary JSON | `src/wardline/core/agent_summary.py:130` | `summary.active_defects` | | `wardline:loop` prompt | `src/wardline/mcp/prompts.py:13` | "Read `summary.active`" | @@ -166,7 +166,7 @@ The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:95 armed gate passes (so a hook-captured log self-diagnoses — a harness-side hook failure is distinguishable from a wardline failure, wardline-eef3d30c7d), or a `gate: NOT_EVALUATED — …` line for a bare scan -(`src/wardline/cli/scan.py:676`). +(`src/wardline/cli/scan.py:714`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists @@ -182,7 +182,7 @@ still legitimately means three different things depending on the surface: | --- | --- | --- | | Filigree store | An **unseen fingerprint** — first time this finding identity is seen for a `(file, scan_source)`. | **Filigree-owned** lifecycle (`src/wardline/core/filigree_emit.py:68-76`) | | `wardline scan --new-since ` | **Delta-scope**: the gate fires only on defects in files/entities changed since a git ref; everything else is re-marked `baselined`. | `src/wardline/core/run.py:598`; 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:617` | +| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:655` | The first-seen Filigree sense and the delta-scope `--new-since` sense are genuinely distinct concepts; neither is "active". @@ -194,8 +194,8 @@ 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:99`) | `total` (`server.py:943`) | `total_findings` (`agent_summary.py:129`) | one finding per wire entry | -| live defect | `N active` (`scan.py:617`) | `active` (`run.py:100,596`) | `active` (`server.py:944`) | `active_defects` (`agent_summary.py:130`) | no `suppression_state` key (`finding.py:295`) | -| suppressed (sum) | `N suppressed` (`scan.py:616`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:131`) | `metadata.wardline.suppression_state` (`finding.py:295`) | +| live defect | `N active` (`scan.py:655`) | `active` (`run.py:100,596`) | `active` (`server.py:944`) | `active_defects` (`agent_summary.py:130`) | no `suppression_state` key (`finding.py:295`) | +| suppressed (sum) | `N suppressed` (`scan.py:654`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:131`) | `metadata.wardline.suppression_state` (`finding.py:295`) | | baselined | `N baseline` | `baselined` (`run.py:102`) | `baselined` (`server.py:945`) | `baselined` (`agent_summary.py:133`) | `suppression_state: "baselined"` | | waived | `N waiver` | `waived` (`run.py:103`) | `waived` (`server.py:946`) | `waived` (`agent_summary.py:134`) | `suppression_state: "waived"` | | judged | `N judged` | `judged` (`run.py:104`) | `judged` (`server.py:947`) | `judged` (`agent_summary.py:135`) | `suppression_state: "judged"` | diff --git a/src/wardline/_version.py b/src/wardline/_version.py index c68196d1..67bc602a 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index fe75c133..28233972 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -523,6 +523,23 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: if emit_result is not None: logged_filigree_url = _redact_url_for_log(filigree_url) if not emit_result.reachable: + # PDR-0023 at the human surface: a mid-batch failure is NOT first-contact + # unreachability. chunks_landed > 0 means Filigree already ingested earlier + # chunks (rows created/updated, their mark_unseen sweeps ran), so "findings + # written locally only" would misreport a real remote mutation. Render the + # partial counts + warnings in EVERY soft-failure sub-branch (auth / server + # error / transport alike) — the agent-facing status blocks already carry + # this truth; the operator line must not contradict them. + if emit_result.chunks_landed: + locality = ( + f"Partial emit: {emit_result.chunks_landed} chunk(s) landed before the failure — " + f"{emit_result.created} created / {emit_result.updated} updated remotely; " + f"{emit_result.failed} finding(s) did not land — re-emit to reconcile." + ) + if emit_result.warnings: + locality += f" {len(emit_result.warnings)} warning(s): " + "; ".join(emit_result.warnings) + else: + locality = "Findings written locally only." if emit_result.auth_rejected: # Reachable but refused — actionable, NOT "could not reach" (dogfood #5). # Split 401 (no/bad token → set one) from 403 (token present but lacks @@ -530,8 +547,7 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: if emit_result.status == 403: click.echo( 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.", + f"present but lacks access (scope/permission) or the request is blocked. {locality}", err=True, ) elif emit_result.token_sent: @@ -540,19 +556,27 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: click.echo( 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.", + f"federation token. {locality}", err=True, ) else: click.echo( 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.", + f"set WEFT_FEDERATION_TOKEN (env or .env) to the project token. {locality}", err=True, ) elif emit_result.status is not None: click.echo( - f"warning: Filigree returned {emit_result.status} (server error) at {logged_filigree_url}; " - "findings written locally only.", + f"warning: Filigree returned {emit_result.status} (server error) at {logged_filigree_url}. " + f"{locality}", + err=True, + ) + elif emit_result.chunks_landed: + # status=None + chunks landed = the connection dropped MID-emit. "could + # not reach" is only the first-contact truth; naming it here would send + # the operator down the wrong (connectivity-from-zero) path. + click.echo( + f"warning: Filigree connection dropped mid-emit at {logged_filigree_url}. {locality}", err=True, ) else: @@ -583,10 +607,24 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: logged_loomweave_url = _redact_url_for_log(loomweave_url) if not loomweave_result.reachable: reason = loomweave_result.disabled_reason or "unreachable" - click.echo( - f"warning: Loomweave taint store not written at {logged_loomweave_url} ({reason}); scan unaffected.", - err=True, - ) + if loomweave_result.written: + # Mid-batch soft failure (outage/403 on a later chunk): earlier chunks + # ARE committed (per-entity replace; `written` counts only chunks that + # landed before the failure — the write_taint_facts contract), so the + # flat "not written" would hide a real store mutation. The write is + # whole-batch idempotent — a full re-run reconciles. + click.echo( + f"warning: Loomweave taint store partially written at {logged_loomweave_url} ({reason}): " + f"{loomweave_result.written} taint fact(s) committed before the failure — " + "re-run the scan to reconcile; scan unaffected.", + err=True, + ) + else: + click.echo( + f"warning: Loomweave taint store not written at {logged_loomweave_url} ({reason}); " + "scan unaffected.", + err=True, + ) else: line = f"wrote {loomweave_result.written} taint fact(s) to {logged_loomweave_url}" if loomweave_result.unresolved_qualnames: diff --git a/src/wardline/core/federation_status.py b/src/wardline/core/federation_status.py index da4861f1..2f1efec0 100644 --- a/src/wardline/core/federation_status.py +++ b/src/wardline/core/federation_status.py @@ -78,12 +78,15 @@ def filigree_emit_status( "failures": [f.to_wire() for f in result.failures], "warnings": list(result.warnings), # The shared 401/403-vs-5xx-vs-transport ladder (dogfood #5) instead of flattening - # every soft failure to "filigree unreachable". + # every soft failure to "filigree unreachable". chunks_landed keeps the string + # honest on a MID-BATCH failure (wardline-7924d67d3b) — scan_jobs persists it + # verbatim as the terminal job `error`, so it must not contradict the counts. "disabled_reason": filigree_disabled_reason( reachable=result.reachable, status=result.status, token_sent=result.token_sent, url=result.url, + chunks_landed=result.chunks_landed, ), } if include_destination: diff --git a/src/wardline/core/filigree_emit.py b/src/wardline/core/filigree_emit.py index c85d2d40..406a5bbf 100644 --- a/src/wardline/core/filigree_emit.py +++ b/src/wardline/core/filigree_emit.py @@ -610,7 +610,7 @@ def _record_pending_partial_failures( def filigree_disabled_reason( - *, reachable: bool, status: int | None, token_sent: bool = False, url: str | None = None + *, reachable: bool, status: int | None, token_sent: bool = False, url: str | None = None, chunks_landed: int = 0 ) -> str | None: """The ``disabled_reason`` for an emit attempt, or None when Filigree was reached. @@ -624,26 +624,35 @@ def filigree_disabled_reason( disagrees with the status (the inconsistent triple the standalone signature once allowed). ``reachable`` remains an input because ``status is None`` is ambiguous on its own — it means EITHER a 2xx success (reachable) OR a transport failure (unreachable). + + ``chunks_landed > 0`` marks a MID-BATCH failure (wardline-7924d67d3b): earlier chunks + were ingested (their mark_unseen sweeps ran), so a flat "filigree unreachable" — which + scan_jobs persists verbatim as the terminal job ``error`` — would contradict the counts + beside it. The transport rung becomes "connection dropped mid-emit"; the status rungs + keep their diagnosis and append the landed-chunk truth. """ if reachable: return None diagnostic_url = redact_url_for_diagnostics(url) at = f" at {diagnostic_url}" if diagnostic_url else "" + partial = f"; {chunks_landed} chunk(s) landed before the failure" if chunks_landed 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 # flat "set WEFT_FEDERATION_TOKEN" implied absence even when a token was sent and # rejected — the C-7 misdiagnosis (weft-23574069a1). if status == 403: - return f"filigree forbidden (403){at}; token present but lacks access / blocked" + return f"filigree forbidden (403){at}; token present but lacks access / blocked{partial}" if token_sent: return ( f"filigree rejected the token (401){at}; a token WAS sent but its value is wrong — " - "align WEFT_FEDERATION_TOKEN (env or .env) to the canonical federation token" + f"align WEFT_FEDERATION_TOKEN (env or .env) to the canonical federation token{partial}" ) - return f"filigree auth-rejected (401){at}; no token sent — set WEFT_FEDERATION_TOKEN (env or .env)" + return f"filigree auth-rejected (401){at}; no token sent — set WEFT_FEDERATION_TOKEN (env or .env){partial}" if status is not None: - return f"filigree server error ({status}){at}" + return f"filigree server error ({status}){at}{partial}" + if chunks_landed: + return f"filigree connection dropped mid-emit ({chunks_landed} chunk(s) landed before the failure){at}" return f"filigree unreachable{at}" diff --git a/tests/conformance/test_federation_status_envelope_parity.py b/tests/conformance/test_federation_status_envelope_parity.py index 2fdf0e88..6dfca8b9 100644 --- a/tests/conformance/test_federation_status_envelope_parity.py +++ b/tests/conformance/test_federation_status_envelope_parity.py @@ -507,6 +507,26 @@ def test_raw_loomweave_def_shares_structure_with_canonical() -> None: } +def test_result_builder_disabled_reason_is_partial_aware() -> None: + # wardline-7924d67d3b: the result-based builder feeds the CLI agent-summary, + # scan-job status.json (whose terminal `error` field persists this string), and + # scan_file_findings. A mid-emit drop with chunks landed must not flatten to + # "filigree unreachable" — the counts/failures alongside already say otherwise. + mid_drop = EmitResult( + reachable=False, + created=2, + updated=1, + failures=(FailedFinding(reason="partial", detail="chunk failed at transport layer"),), + token_sent=True, + url="http://x", + chunks_landed=1, + ) + block = fs.filigree_emit_status(mid_drop, configured=True, include_destination=True) + reason = str(block["disabled_reason"]) + assert "unreachable" not in reason + assert "mid-emit" in reason and "1 chunk(s) landed" in reason + + def test_filigree_builder_bytes_are_frozen() -> None: assert _eq(fs.filigree_emit_status(None, configured=False, include_destination=True), _GOLDEN_FILIGREE_NONE) assert _eq(fs.filigree_emit_status(_OK, configured=True, include_destination=True), _GOLDEN_FILIGREE_OK) diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index 51ff8716..ad35a96f 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -39,9 +39,9 @@ ("src/wardline/core/run.py", 659, "active=sum"), ("src/wardline/core/run.py", 758, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 616, "suppressed"), - ("src/wardline/cli/scan.py", 617, "{s.active} active"), - ("src/wardline/cli/scan.py", 676, "gate: FAILED"), + ("src/wardline/cli/scan.py", 654, "suppressed"), + ("src/wardline/cli/scan.py", 655, "{s.active} active"), + ("src/wardline/cli/scan.py", 714, "gate: FAILED"), # src/wardline/mcp/server.py — MCP scan summary + gate block ("src/wardline/mcp/server.py", 943, '"total": result.summary.total'), ("src/wardline/mcp/server.py", 944, '"active": result.summary.active'), diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 9a5e5790..879b8e07 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -1317,6 +1317,115 @@ def test_scan_loomweave_soft_outage_redacts_url_secrets(tmp_path, monkeypatch) - assert "#frag" not in result.output +def test_scan_loomweave_partial_write_surfaces_written_count(tmp_path, monkeypatch) -> None: + # PDR-0023 at the human surface: a mid-batch outage after chunks committed must + # NOT render the flat "taint store not written" — earlier chunks ARE in the store. + from wardline.loomweave.client import WriteResult + + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + monkeypatch.setattr( + "wardline.loomweave.write.write_facts_to_loomweave", + lambda *a, **k: WriteResult(reachable=False, written=4), + ) + out = tmp_path / "f.jsonl" + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--loomweave-url", "http://x/api/taint"]) + assert result.exit_code == 0, result.output + assert "Loomweave taint store not written" not in result.output + assert "partially written" in result.output + assert "4 taint fact(s)" in result.output + assert "scan unaffected" in result.output + + +def test_scan_filigree_partial_emit_transport_drop_surfaces_counts(tmp_path, monkeypatch) -> None: + # A connection drop on chunk 2 after chunk 1 landed is NOT first-contact failure: + # Filigree ingested chunk 1 (rows created/updated, mark_unseen ran). The CLI must + # render the partial counts + warnings, not "could not reach ... locally only". + from wardline.core.filigree_emit import EmitResult, FailedFinding + + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + + class _MidEmitDropEmitter: + def __init__(self, url, **kw): + self.url = url + + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): + return EmitResult( + reachable=False, + created=3, + updated=2, + failures=( + FailedFinding( + reason="partial", + detail="chunk failed at transport layer (connection dropped mid-emit)", + ), + ), + warnings=( + "Filigree connection dropped mid-emit: 1 of 2 chunk(s) landed before the " + "transport failure; ingested counts are partial and the remaining findings " + "are recorded as 'partial' failures — re-emit to reconcile.", + ), + token_sent=True, + url=self.url, + chunks_landed=1, + ) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _MidEmitDropEmitter) + out = tmp_path / "f.jsonl" + result = CliRunner().invoke( + scan, [str(proj), "--output", str(out), "--filigree-url", "http://filigree/api/weft/scan-results"] + ) + assert result.exit_code == 0, result.output + assert "could not reach Filigree" not in result.output + assert "written locally only" not in result.output.lower() + assert "3 created / 2 updated" in result.output + assert "1 finding(s) did not land" in result.output + assert "1 of 2 chunk(s) landed" in result.output + + +def test_scan_filigree_partial_emit_mid_batch_auth_reject_surfaces_counts(tmp_path, monkeypatch) -> None: + # Same honesty rule for the mid-batch 401: chunks 1..N-1 landed before the token + # was rejected, so "Findings written locally only" would misreport remote mutation. + from wardline.core.filigree_emit import EmitResult, FailedFinding + + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + + class _MidBatchAuthRejectEmitter: + def __init__(self, url, **kw): + self.url = url + + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): + failures = tuple( + FailedFinding(reason="partial", detail="chunk rejected by auth (HTTP 401)") for _ in range(3) + ) + return EmitResult( + reachable=False, + created=2, + updated=0, + failures=failures, + status=401, + token_sent=True, + url=self.url, + chunks_landed=1, + ) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _MidBatchAuthRejectEmitter) + out = tmp_path / "f.jsonl" + result = CliRunner().invoke( + scan, [str(proj), "--output", str(out), "--filigree-url", "http://filigree/api/weft/scan-results"] + ) + assert result.exit_code == 0, result.output + assert "401" in result.output + assert "written locally only" not in result.output.lower() + assert "2 created / 0 updated" in result.output + assert "3 finding(s) did not land" in result.output + + def test_scan_filigree_agent_summary_redacts_url_secrets(tmp_path, monkeypatch) -> None: proj = tmp_path / "proj" proj.mkdir() diff --git a/tests/unit/core/test_filigree_emit.py b/tests/unit/core/test_filigree_emit.py index 21190a92..7c917310 100644 --- a/tests/unit/core/test_filigree_emit.py +++ b/tests/unit/core/test_filigree_emit.py @@ -426,6 +426,10 @@ def test_mid_stream_soft_failure_preserves_prior_counts_and_records_pending(stat assert res.created == 2 assert res.updated == 0 assert res.failed == 1 + # The landed-chunk count is the partial-emit discriminator every human surface + # keys on (wardline-7924d67d3b) — assert it on the 401/403/5xx paths too, not + # just the transport-drop path. + assert res.chunks_landed == 1 assert [(failure.reason, failure.fingerprint) for failure in res.failures] == [ ("partial", f"{FINGERPRINT_SCHEME}:{'c' * 64}") ] @@ -569,6 +573,26 @@ def test_disabled_reason_403_and_unreachable_unchanged_in_shape() -> None: assert filigree_disabled_reason(reachable=True, status=None) is None +def test_disabled_reason_partial_names_landed_chunks() -> None: + url = "http://h/api/weft/scan-results" + # Mid-emit transport drop with chunks landed: "unreachable" is only the + # first-contact truth — the persisted reason (scan-job terminal `error` field, + # wardline-7924d67d3b) must name the partial emit instead. + dropped = filigree_disabled_reason(reachable=False, status=None, url=url, chunks_landed=1) + assert dropped is not None + assert "unreachable" not in dropped + assert "mid-emit" in dropped and "1 chunk(s) landed" in dropped and url in dropped + # Mid-batch 5xx / auth keep their ladder diagnosis and append the partial truth. + server = filigree_disabled_reason(reachable=False, status=503, url=url, chunks_landed=2) + assert server is not None and "503" in server and "2 chunk(s) landed" in server + auth = filigree_disabled_reason(reachable=False, status=401, token_sent=True, url=url, chunks_landed=1) + assert auth is not None and "401" in auth and "1 chunk(s) landed" in auth + # Zero chunks landed: the first-contact ladder bytes are unchanged. + assert filigree_disabled_reason(reachable=False, status=None, url=url, chunks_landed=0) == ( + filigree_disabled_reason(reachable=False, status=None, url=url) + ) + + 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" diff --git a/tests/unit/mcp/test_server_arg_hardening.py b/tests/unit/mcp/test_server_arg_hardening.py index ef575a6b..26aeafad 100644 --- a/tests/unit/mcp/test_server_arg_hardening.py +++ b/tests/unit/mcp/test_server_arg_hardening.py @@ -47,9 +47,11 @@ def _leaky_project(tmp_path: Path) -> Path: def _dispatch(server: WardlineMCPServer, name: str, arguments: dict) -> dict: - return server.rpc.dispatch( + resp = server.rpc.dispatch( {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}} ) + assert resp is not None # dispatch returns None only for id-less notifications; this carries id=1 + return resp def _block_jsonschema(monkeypatch) -> None: