From 4db3c578cd66f274b719c3e06ee63a22e5c1fc81 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Thu, 18 Jun 2026 17:30:30 +1000 Subject: [PATCH 1/4] docs: fix doc/site rot surfaced in review - site/src/pages/index.astro: version 1.0.0rc4 -> 1.0.3 - docs/getting-started.md: field is `suppression_state`, not `suppressed` - docs/audits/2026-06-08-comprehensive-audit.md: replace machine-local file:///home/john/... links with repo-relative paths Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/audits/2026-06-08-comprehensive-audit.md | 158 +++++++++--------- docs/getting-started.md | 2 +- site/src/pages/index.astro | 4 +- 3 files changed, 82 insertions(+), 82 deletions(-) diff --git a/docs/audits/2026-06-08-comprehensive-audit.md b/docs/audits/2026-06-08-comprehensive-audit.md index 83a6a734..cf636048 100644 --- a/docs/audits/2026-06-08-comprehensive-audit.md +++ b/docs/audits/2026-06-08-comprehensive-audit.md @@ -8,36 +8,36 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive | Finding ID | Title | Severity | Location | Focus Area | | :--- | :--- | :--- | :--- | :--- | -| **WLN-CRIT-01** | [Parameter Default Expressions Ignored](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L163-L185) | **Critical** | `src/wardline/scanner/taint/variable_level.py` | Systems / Taint Propagation | -| **WLN-HIGH-01** | [Python Standard Input Buffer blocking in MCP Server](file:///home/john/wardline/src/wardline/mcp/protocol.py#L107-L108) | **High** | `src/wardline/mcp/protocol.py` | CLI & MCP | -| **WLN-HIGH-02** | [Fragile Skip Invariant in File Discovery](file:///home/john/wardline/src/wardline/core/discovery.py#L32-L34) | **High** | `src/wardline/core/discovery.py` | Quality / Discovery | -| **WLN-HIGH-03** | [Lack of Import Alias Resolution in TaintedSinkRule](file:///home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py#L76-L82) | **High** | `src/wardline/scanner/rules/_sink_helpers.py` | Static Analysis / Soundness | -| **WLN-HIGH-04** | [Soundness Gap for Generators Yielding Untrusted Data](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L1337-L1367) | **High** | `src/wardline/scanner/taint/variable_level.py` | Static Analysis / Soundness | -| **WLN-HIGH-05** | [Module-level import-time dependency on pyyaml in core modules](file:///home/john/wardline/src/wardline/core/baseline.py#L18) | **High** | `src/wardline/core/*` | Architecture / Layering | -| **WLN-HIGH-06** | [Static Analysis Bypass via Undecorated Nested Helper Functions](file:///home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py#L215-L219) | **High** | `src/wardline/scanner/rules/*` | Python Engineering | -| **WLN-HIGH-07** | [Static Analysis Evasion via Lambda Expressions](file:///home/john/wardline/src/wardline/scanner/ast_primitives.py#L102-L103) | **High** | `src/wardline/scanner/ast_primitives.py` | Python Engineering | -| **WLN-MED-01** | [Transitive Import-Time Dependency on scanner from Base Core Modules](file:///home/john/wardline/src/wardline/core/assure.py#L40) | **Medium** | `src/wardline/core/*` | Architecture / Layering | -| **WLN-MED-02** | [Module-level Import of Optional Dependency (pyyaml) in Base Install](file:///home/john/wardline/src/wardline/install/pack.py#L8) | **Medium** | `src/wardline/install/pack.py` | Architecture / Layering | -| **WLN-MED-03** | [Import-Time Coupling in Dependency-Free MCP Server](file:///home/john/wardline/src/wardline/mcp/server.py#L24) | **Medium** | `src/wardline/mcp/*` | Architecture / Layering | -| **WLN-MED-04** | [Waiver Tool in MCP Server hardcodes configuration path](file:///home/john/wardline/src/wardline/mcp/server.py#L402) | **Medium** | `src/wardline/mcp/server.py` | CLI & MCP | -| **WLN-MED-05** | [Missing context_lines option in MCP judge tool](file:///home/john/wardline/src/wardline/mcp/server.py#L344-L356) | **Medium** | `src/wardline/mcp/server.py` | CLI & MCP | -| **WLN-MED-06** | [Absolute Path Match Failure in explain_finding](file:///home/john/wardline/src/wardline/core/explain.py#L77-L92) | **Medium** | `src/wardline/core/explain.py` | Quality / Explanations | -| **WLN-MED-07** | [Inconsistent Defaults for confine_to_root in Attestation](file:///home/john/wardline/src/wardline/core/attest.py#L270-L288) | **Medium** | `src/wardline/core/attest.py` | Quality / Integrity | -| **WLN-MED-08** | [Under-Tainting with **kwargs Unpacking](file:///home/john/wardline/src/wardline/scanner/analyzer.py#L317-L318) | **Medium** | `src/wardline/scanner/analyzer.py` | Static Analysis / Taint | -| **WLN-MED-09** | [Under-Tainting via Loop-Carried Dependencies](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L826-L897) | **Medium** | `src/wardline/scanner/taint/variable_level.py` | Static Analysis / Taint | -| **WLN-MED-10** | [Ineffective Caching / Performance Architecture Flaw](file:///home/john/wardline/src/wardline/scanner/analyzer.py#L111-L200) | **Medium** | `src/wardline/scanner/*` | Systems / Caching | -| **WLN-MED-11** | [Inert Configuration Option provenance_clash](file:///home/john/wardline/src/wardline/scanner/taint/propagation.py#L160-L182) | **Medium** | `src/wardline/scanner/*` | Systems / Taint Algebra | -| **WLN-MED-12** | [Git Ref Option/Argument Injection in get_changed_files_since](file:///home/john/wardline/src/wardline/core/delta.py#L34-L44) | **Medium** | `src/wardline/core/delta.py` | Security | -| **WLN-LOW-01** | [Incomplete MCP Handshake verification/enforcement](file:///home/john/wardline/src/wardline/mcp/protocol.py#L67-L78) | **Low** | `src/wardline/mcp/protocol.py` | CLI & MCP | -| **WLN-LOW-02** | [Undocumented path property in verify_attestation schema](file:///home/john/wardline/src/wardline/mcp/server.py#L607-L615) | **Low** | `src/wardline/mcp/server.py` | CLI & MCP | -| **WLN-LOW-03** | [Test Coverage Gaps in Error Hierarchy and Path Rejection](file:///home/john/wardline/tests/unit/core/test_errors.py#L1-L14) | **Low** | `tests/unit/core/*` | Quality / Robustness | -| **WLN-LOW-04** | [FQN Resolution Limitation for Nested/Dotted Attribute Calls](file:///home/john/wardline/src/wardline/scanner/ast_primitives.py#L171-L197) | **Low** | `src/wardline/scanner/ast_primitives.py` | Python / Static Analysis | -| **WLN-LOW-05** | [Redundant/Dead Code in SCC Propagation](file:///home/john/wardline/src/wardline/scanner/taint/propagation.py#L337-L347) | **Low** | `src/wardline/scanner/taint/propagation.py` | Static Analysis / Tarjan | -| **WLN-LOW-06** | [Transitive Core-Scanner Coupling in Clarion Extra](file:///home/john/wardline/src/wardline/clarion/facts.py#L25) | **Low** | `src/wardline/clarion/*` | Architecture / Coupling | -| **WLN-LOW-07** | [False-Positive Local Pack Detection for Built-ins](file:///home/john/wardline/src/wardline/core/config.py#L64-L91) | **Low** | `src/wardline/core/config.py` | Security / Custom Packs | -| **WLN-LOW-08** | [Untrusted Custom Pack Loading via wardline.yaml](file:///home/john/wardline/src/wardline/core/config.py#L112-L133) | **Low** | `src/wardline/core/config.py` | Security | -| **WLN-LOW-09** | [Uncontrolled Resource Consumption (OOM) in JSON-RPC stdio](file:///home/john/wardline/src/wardline/mcp/protocol.py#L100-L115) | **Low** | `src/wardline/mcp/protocol.py` | Security / DoS | -| **WLN-LOW-10** | [Fragile Assignment Type Suppression in Variable-Level Control Flow](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L823) | **Low** | `src/wardline/scanner/taint/variable_level.py` | Python Engineering | +| **WLN-CRIT-01** | [Parameter Default Expressions Ignored](/src/wardline/scanner/taint/variable_level.py#L163-L185) | **Critical** | `src/wardline/scanner/taint/variable_level.py` | Systems / Taint Propagation | +| **WLN-HIGH-01** | [Python Standard Input Buffer blocking in MCP Server](/src/wardline/mcp/protocol.py#L107-L108) | **High** | `src/wardline/mcp/protocol.py` | CLI & MCP | +| **WLN-HIGH-02** | [Fragile Skip Invariant in File Discovery](/src/wardline/core/discovery.py#L32-L34) | **High** | `src/wardline/core/discovery.py` | Quality / Discovery | +| **WLN-HIGH-03** | [Lack of Import Alias Resolution in TaintedSinkRule](/src/wardline/scanner/rules/_sink_helpers.py#L76-L82) | **High** | `src/wardline/scanner/rules/_sink_helpers.py` | Static Analysis / Soundness | +| **WLN-HIGH-04** | [Soundness Gap for Generators Yielding Untrusted Data](/src/wardline/scanner/taint/variable_level.py#L1337-L1367) | **High** | `src/wardline/scanner/taint/variable_level.py` | Static Analysis / Soundness | +| **WLN-HIGH-05** | [Module-level import-time dependency on pyyaml in core modules](/src/wardline/core/baseline.py#L18) | **High** | `src/wardline/core/*` | Architecture / Layering | +| **WLN-HIGH-06** | [Static Analysis Bypass via Undecorated Nested Helper Functions](/src/wardline/scanner/rules/_sink_helpers.py#L215-L219) | **High** | `src/wardline/scanner/rules/*` | Python Engineering | +| **WLN-HIGH-07** | [Static Analysis Evasion via Lambda Expressions](/src/wardline/scanner/ast_primitives.py#L102-L103) | **High** | `src/wardline/scanner/ast_primitives.py` | Python Engineering | +| **WLN-MED-01** | [Transitive Import-Time Dependency on scanner from Base Core Modules](/src/wardline/core/assure.py#L40) | **Medium** | `src/wardline/core/*` | Architecture / Layering | +| **WLN-MED-02** | [Module-level Import of Optional Dependency (pyyaml) in Base Install](/src/wardline/install/pack.py#L8) | **Medium** | `src/wardline/install/pack.py` | Architecture / Layering | +| **WLN-MED-03** | [Import-Time Coupling in Dependency-Free MCP Server](/src/wardline/mcp/server.py#L24) | **Medium** | `src/wardline/mcp/*` | Architecture / Layering | +| **WLN-MED-04** | [Waiver Tool in MCP Server hardcodes configuration path](/src/wardline/mcp/server.py#L402) | **Medium** | `src/wardline/mcp/server.py` | CLI & MCP | +| **WLN-MED-05** | [Missing context_lines option in MCP judge tool](/src/wardline/mcp/server.py#L344-L356) | **Medium** | `src/wardline/mcp/server.py` | CLI & MCP | +| **WLN-MED-06** | [Absolute Path Match Failure in explain_finding](/src/wardline/core/explain.py#L77-L92) | **Medium** | `src/wardline/core/explain.py` | Quality / Explanations | +| **WLN-MED-07** | [Inconsistent Defaults for confine_to_root in Attestation](/src/wardline/core/attest.py#L270-L288) | **Medium** | `src/wardline/core/attest.py` | Quality / Integrity | +| **WLN-MED-08** | [Under-Tainting with **kwargs Unpacking](/src/wardline/scanner/analyzer.py#L317-L318) | **Medium** | `src/wardline/scanner/analyzer.py` | Static Analysis / Taint | +| **WLN-MED-09** | [Under-Tainting via Loop-Carried Dependencies](/src/wardline/scanner/taint/variable_level.py#L826-L897) | **Medium** | `src/wardline/scanner/taint/variable_level.py` | Static Analysis / Taint | +| **WLN-MED-10** | [Ineffective Caching / Performance Architecture Flaw](/src/wardline/scanner/analyzer.py#L111-L200) | **Medium** | `src/wardline/scanner/*` | Systems / Caching | +| **WLN-MED-11** | [Inert Configuration Option provenance_clash](/src/wardline/scanner/taint/propagation.py#L160-L182) | **Medium** | `src/wardline/scanner/*` | Systems / Taint Algebra | +| **WLN-MED-12** | [Git Ref Option/Argument Injection in get_changed_files_since](/src/wardline/core/delta.py#L34-L44) | **Medium** | `src/wardline/core/delta.py` | Security | +| **WLN-LOW-01** | [Incomplete MCP Handshake verification/enforcement](/src/wardline/mcp/protocol.py#L67-L78) | **Low** | `src/wardline/mcp/protocol.py` | CLI & MCP | +| **WLN-LOW-02** | [Undocumented path property in verify_attestation schema](/src/wardline/mcp/server.py#L607-L615) | **Low** | `src/wardline/mcp/server.py` | CLI & MCP | +| **WLN-LOW-03** | [Test Coverage Gaps in Error Hierarchy and Path Rejection](/tests/unit/core/test_errors.py#L1-L14) | **Low** | `tests/unit/core/*` | Quality / Robustness | +| **WLN-LOW-04** | [FQN Resolution Limitation for Nested/Dotted Attribute Calls](/src/wardline/scanner/ast_primitives.py#L171-L197) | **Low** | `src/wardline/scanner/ast_primitives.py` | Python / Static Analysis | +| **WLN-LOW-05** | [Redundant/Dead Code in SCC Propagation](/src/wardline/scanner/taint/propagation.py#L337-L347) | **Low** | `src/wardline/scanner/taint/propagation.py` | Static Analysis / Tarjan | +| **WLN-LOW-06** | [Transitive Core-Scanner Coupling in Clarion Extra](/src/wardline/clarion/facts.py#L25) | **Low** | `src/wardline/clarion/*` | Architecture / Coupling | +| **WLN-LOW-07** | [False-Positive Local Pack Detection for Built-ins](/src/wardline/core/config.py#L64-L91) | **Low** | `src/wardline/core/config.py` | Security / Custom Packs | +| **WLN-LOW-08** | [Untrusted Custom Pack Loading via wardline.yaml](/src/wardline/core/config.py#L112-L133) | **Low** | `src/wardline/core/config.py` | Security | +| **WLN-LOW-09** | [Uncontrolled Resource Consumption (OOM) in JSON-RPC stdio](/src/wardline/mcp/protocol.py#L100-L115) | **Low** | `src/wardline/mcp/protocol.py` | Security / DoS | +| **WLN-LOW-10** | [Fragile Assignment Type Suppression in Variable-Level Control Flow](/src/wardline/scanner/taint/variable_level.py#L823) | **Low** | `src/wardline/scanner/taint/variable_level.py` | Python Engineering | --- @@ -45,7 +45,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-CRIT-01: Parameter Default Expressions Ignored * **Focus Area**: Systems / Taint Propagation -* **Target Location**: [_seed_parameters in variable_level.py](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L163-L185) +* **Target Location**: [_seed_parameters in variable_level.py](/src/wardline/scanner/taint/variable_level.py#L163-L185) * **Description**: Parameter default value expressions (e.g., `def func(x=get_untrusted_data())`) are evaluated at the call site at runtime when the argument is omitted. However, Wardline's L2 taint analysis does not evaluate the taint of parameter default expressions. In `_seed_parameters`, if a parameter is not bound in `param_meets` (meaning it wasn't supplied at the call site), it defaults to `function_taint`. @@ -96,11 +96,11 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-01: Python Standard Input Buffer blocking in MCP Server * **Focus Area**: CLI & MCP -* **Target Location**: [protocol.py](file:///home/john/wardline/src/wardline/mcp/protocol.py#L107-L108) +* **Target Location**: [protocol.py](/src/wardline/mcp/protocol.py#L107-L108) * **Description**: The MCP server uses `for raw in in_stream:` (where `in_stream` defaults to `sys.stdin`) to read incoming JSON-RPC messages. Because Python's file iterator implements internal read-ahead block buffering when standard input is a pipe (non-TTY), it blocks/buffers input data (up to 8KB) instead of yielding lines immediately as they are flushed by the client. This causes the MCP server to hang or experience high latency in interactive stdio sessions. * **Concrete Remediation**: - Modify `run_stdio` in [protocol.py](file:///home/john/wardline/src/wardline/mcp/protocol.py#L107-L108) to use a `while True:` loop calling `.readline()` to prevent read-ahead buffering: + Modify `run_stdio` in [protocol.py](/src/wardline/mcp/protocol.py#L107-L108) to use a `while True:` loop calling `.readline()` to prevent read-ahead buffering: ```diff - for raw in in_stream: - line = raw.strip() @@ -113,7 +113,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-02: Fragile Skip Invariant in File Discovery * **Focus Area**: Quality / Discovery -* **Target Location**: [discovery.py](file:///home/john/wardline/src/wardline/core/discovery.py#L32-L34) +* **Target Location**: [discovery.py](/src/wardline/core/discovery.py#L32-L34) * **Description**: In `discover`, files are skipped using: ```python @@ -137,12 +137,12 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-03: Lack of Import Alias Resolution in TaintedSinkRule * **Focus Area**: Static Analysis / Soundness -* **Target Location**: [_sink_helpers.py](file:///home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py#L76-L82) +* **Target Location**: [_sink_helpers.py](/src/wardline/scanner/rules/_sink_helpers.py#L76-L82) * **Description**: Rules extending `TaintedSinkRule` identify matching sink calls by checking whether `dotted_name(call.func)` is in their static `SINKS` set. `dotted_name` merely extracts the raw AST name chain as written. It does not resolve imports or aliases. Any import alias or direct function import—such as `from subprocess import run; run(..., shell=True)` or `import pickle as p; p.loads(raw)`—completely bypasses the checks, leading to severe False Negatives. * **Concrete Remediation**: Resolve aliases using `resolve_call_fqn` against the module's `alias_map` retrieved from `context.alias_maps`. - Update `sink_calls` in [_sink_helpers.py](file:///home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py#L76-L82) to: + Update `sink_calls` in [_sink_helpers.py](/src/wardline/scanner/rules/_sink_helpers.py#L76-L82) to: ```python def sink_calls( func_node: ast.AST, @@ -171,7 +171,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-04: Soundness Gap for Generators Yielding Untrusted Data * **Focus Area**: Static Analysis / Soundness -* **Target Location**: [variable_level.py](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L1337-L1367) +* **Target Location**: [variable_level.py](/src/wardline/scanner/taint/variable_level.py#L1337-L1367) * **Description**: In Python, generators yield values to their callers via `yield` or `yield from` rather than `return`. However, `_collect_return_paths` only extracts `ast.Return` statements. Consequently, any `@trusted` generator that yields untrusted data (e.g., `yield read_raw(p)`) completely escapes return-taint validation under `PY-WL-101`. * **Concrete Remediation**: @@ -199,10 +199,10 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-05: Module-Level Import-Time Dependency on `pyyaml` in Base Core Package * **Focus Area**: Architecture / Layering * **Target Location**: - - [baseline.py](file:///home/john/wardline/src/wardline/core/baseline.py#L18) - - [descriptor.py](file:///home/john/wardline/src/wardline/core/descriptor.py#L27) - - [judged.py](file:///home/john/wardline/src/wardline/core/judged.py#L19) - - [waivers.py](file:///home/john/wardline/src/wardline/core/waivers.py#L18) + - [baseline.py](/src/wardline/core/baseline.py#L18) + - [descriptor.py](/src/wardline/core/descriptor.py#L27) + - [judged.py](/src/wardline/core/judged.py#L19) + - [waivers.py](/src/wardline/core/waivers.py#L18) * **Description**: The core package is designed to be dependency-free. However, several modules in `core/` import `yaml` (provided by the optional `scanner` extra) at module-level. When the package is imported or used as a lightweight base library (e.g. for trust decorators only) without the `scanner` extra, it fails with an `ImportError` on these modules at import time. * **Concrete Remediation**: @@ -211,9 +211,9 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-06: Static Analysis Bypass via Undecorated Nested Helper Functions * **Focus Area**: Python Engineering * **Target Location**: - - [_sink_helpers.py](file:///home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py#L215-L219) - - [broad_exception.py](file:///home/john/wardline/src/wardline/scanner/rules/broad_exception.py#L44-L46) - - [silent_exception.py](file:///home/john/wardline/src/wardline/scanner/rules/silent_exception.py#L44-L46) + - [_sink_helpers.py](/src/wardline/scanner/rules/_sink_helpers.py#L215-L219) + - [broad_exception.py](/src/wardline/scanner/rules/broad_exception.py#L44-L46) + - [silent_exception.py](/src/wardline/scanner/rules/silent_exception.py#L44-L46) * **Description**: In `TaintedSinkRule` and exception rules, checks and severity modulation are determined by looking up the function's `qualname` in `context.project_taints`. Nested helper functions (e.g., `def helper()`) inside a decorated `@trusted` function are indexed as separate entities (e.g., `module.parent..helper`) but do not have decorators. Thus, their trust level resolves to `TaintState.UNKNOWN_RAW`. This causes all modulated rules (sink checks, broad exceptions) to downgrade their severity to `Severity.NONE`, suppressing all findings inside the helper. A developer can bypass any of these checks by nesting a dangerous sink call (such as `pickle.loads`) in a nested helper function. * **Concrete Remediation**: @@ -226,9 +226,9 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-HIGH-07: Static Analysis Evasion via Lambda Expressions * **Focus Area**: Python Engineering * **Target Location**: - - [ast_primitives.py](file:///home/john/wardline/src/wardline/scanner/ast_primitives.py#L102-L103) - - [ast_primitives.py](file:///home/john/wardline/src/wardline/scanner/ast_primitives.py#L122-L124) - - [_sink_helpers.py](file:///home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py#L69-L70) + - [ast_primitives.py](/src/wardline/scanner/ast_primitives.py#L102-L103) + - [ast_primitives.py](/src/wardline/scanner/ast_primitives.py#L122-L124) + - [_sink_helpers.py](/src/wardline/scanner/rules/_sink_helpers.py#L69-L70) * **Description**: The AST iterator and the sink helper search explicitly skip `ast.Lambda` nodes and do not traverse their bodies for calls. Since lambda expressions are also not indexed as independent entities, any dangerous sinks or violations wrapped in a lambda (e.g. `lambda x: exec(x)`) completely evade detection by sink rules. * **Concrete Remediation**: @@ -241,13 +241,13 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-01: Transitive Import-Time Dependency on `scanner` from Base Core Modules * **Focus Area**: Architecture / Layering * **Target Location**: - - [assure.py](file:///home/john/wardline/src/wardline/core/assure.py#L40) - - [attest.py](file:///home/john/wardline/src/wardline/core/attest.py#L56) - - [dossier.py](file:///home/john/wardline/src/wardline/core/dossier.py#L42) - - [explain.py](file:///home/john/wardline/src/wardline/core/explain.py#L20) - - [judge_run.py](file:///home/john/wardline/src/wardline/core/judge_run.py#L35) - - [delta.py](file:///home/john/wardline/src/wardline/core/delta.py#L9) - - [run.py](file:///home/john/wardline/src/wardline/core/run.py#L33-L36) + - [assure.py](/src/wardline/core/assure.py#L40) + - [attest.py](/src/wardline/core/attest.py#L56) + - [dossier.py](/src/wardline/core/dossier.py#L42) + - [explain.py](/src/wardline/core/explain.py#L20) + - [judge_run.py](/src/wardline/core/judge_run.py#L35) + - [delta.py](/src/wardline/core/delta.py#L9) + - [run.py](/src/wardline/core/run.py#L33-L36) * **Description**: Base package modules import `run_scan` at import time, which in turn imports `scanner` modules at import time in `core/run.py`. Additionally, `delta.py` and `judge_run.py` import `scanner` modules directly at the module level. This forces an import-time load of `scanner` (and transitively `yaml`), which triggers `ImportError` when `scanner` is not installed. * **Concrete Remediation**: @@ -258,7 +258,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-02: Module-Level Import of Optional Dependency (`pyyaml`) in Base Install Subpackage * **Focus Area**: Architecture / Layering -* **Target Location**: [pack.py](file:///home/john/wardline/src/wardline/install/pack.py#L8) +* **Target Location**: [pack.py](/src/wardline/install/pack.py#L8) * **Description**: The `install` package is part of the base package. Importing `install/pack.py` at import time will raise an `ImportError` if `pyyaml` is not installed. * **Concrete Remediation**: @@ -267,9 +267,9 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-03: Import-Time Coupling in Dependency-Free MCP Server * **Focus Area**: Architecture / Layering * **Target Location**: - - [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L24) - - [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L35) - - [lsp.py](file:///home/john/wardline/src/wardline/mcp/lsp.py#L14) + - [server.py](/src/wardline/mcp/server.py#L24) + - [server.py](/src/wardline/mcp/server.py#L35) + - [lsp.py](/src/wardline/mcp/lsp.py#L14) * **Description**: The MCP server is intended to be a dependency-free stdlib-only server. However, it imports `descriptor_to_yaml`, `_ALL_RULE_CLASSES`, and `run_scan` at import time. This causes the MCP module to crash on startup if `scanner` and PyYAML are not installed. * **Concrete Remediation**: @@ -279,7 +279,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-04: Waiver Tool in MCP Server hardcodes configuration path to `wardline.yaml` * **Focus Area**: CLI & MCP -* **Target Location**: [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L402) & [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L687-L698) +* **Target Location**: [server.py](/src/wardline/mcp/server.py#L402) & [server.py](/src/wardline/mcp/server.py#L687-L698) * **Description**: The MCP server's `waiver_add` tool does not support a custom configuration path (it is missing the `config` property in its schema). The tool handler `_waiver_add` hardcodes the config path to `root / "wardline.yaml"`. If a user is running scans using a custom configuration path, waivers added via this tool will be written to `wardline.yaml` instead, having no effect on the custom configuration path scans. * **Concrete Remediation**: @@ -288,7 +288,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-05: Missing `context_lines` option in MCP `judge` tool * **Focus Area**: CLI & MCP -* **Target Location**: [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L344-L356) & [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L645-L653) +* **Target Location**: [server.py](/src/wardline/mcp/server.py#L344-L356) & [server.py](/src/wardline/mcp/server.py#L645-L653) * **Description**: The CLI `judge` command accepts a `--context-lines` option to customize the context window excerpt radius sent to the LLM. However, the MCP `judge` tool does not define `context_lines` in its `input_schema` and does not pass it from `args` to `run_judge`. * **Concrete Remediation**: @@ -296,7 +296,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-06: Absolute Path Match Failure in `explain_finding` * **Focus Area**: Quality / Explanations -* **Target Location**: [explain.py](file:///home/john/wardline/src/wardline/core/explain.py#L77-L92) +* **Target Location**: [explain.py](/src/wardline/core/explain.py#L77-L92) * **Description**: The private helper `_match` matches findings using `f.location.path == path`. Since `f.location.path` is normalized as a project-relative POSIX path (e.g. `src/main.py`), passing an absolute path for `path` causes the comparison to fail. * **Concrete Remediation**: @@ -304,7 +304,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-07: Inconsistent Defaults for `confine_to_root` in Attestation * **Focus Area**: Quality / Integrity -* **Target Location**: [attest.py](file:///home/john/wardline/src/wardline/core/attest.py#L270-L288) +* **Target Location**: [attest.py](/src/wardline/core/attest.py#L270-L288) * **Description**: In `build_attestation`, the parameter `confine_to_root` defaults to `False`. However, in `verify_attestation`, the parameter `confine_to_root` defaults to `True`. This inconsistency causes programmatic verification with `reproduce=True` (using defaults) to re-run the scan with differing root confinement rules compared to the build step, potentially causing verification to fail with `reproduced=False`. * **Concrete Remediation**: @@ -312,7 +312,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-08: Under-Tainting in L3 parameter-meet generation with `**kwargs` Unpacking * **Focus Area**: Static Analysis / Taint -* **Target Location**: [analyzer.py](file:///home/john/wardline/src/wardline/scanner/analyzer.py#L317-L318) +* **Target Location**: [analyzer.py](/src/wardline/scanner/analyzer.py#L317-L318) * **Description**: During L2 analysis parameter-meet collection, when a caller passes keyword arguments via dictionary unpack (e.g. `callee(**kwargs)`), the argument taints contain `None: taint`. If the callee signature does not have a `**kwargs` dictionary collector parameter (i.e. `args_node.kwarg` is `None`), this taint is completely ignored. Consequently, callee's named parameters do not receive the taint of the unpacked dictionary. * **Concrete Remediation**: @@ -320,7 +320,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-09: Under-Tainting via Loop-Carried Dependencies * **Focus Area**: Static Analysis / Taint -* **Target Location**: [variable_level.py](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L826-L897) +* **Target Location**: [variable_level.py](/src/wardline/scanner/taint/variable_level.py#L826-L897) * **Description**: Loop statements (`_handle_for` and `_handle_while`) are only walked a single time. Any loop-carried data dependency where a variable is read before it is written in the loop body (e.g., `y = x; x = raw`) will use the pre-loop value of the variable, resulting in an under-tainted final state for `y` after the loop. * **Concrete Remediation**: @@ -329,8 +329,8 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-10: Ineffective Caching / Performance Architecture Flaw * **Focus Area**: Systems / Caching * **Target Location**: - - [analyzer.py](file:///home/john/wardline/src/wardline/scanner/analyzer.py#L111-L200) - - [project_resolver.py](file:///home/john/wardline/src/wardline/scanner/taint/project_resolver.py#L111-L137) + - [analyzer.py](/src/wardline/scanner/analyzer.py#L111-L200) + - [project_resolver.py](/src/wardline/scanner/taint/project_resolver.py#L111-L137) * **Description**: The `SummaryCache` mechanism is designed to cache module summaries to avoid re-invoking analysis for unchanged files. However, the current execution pipeline runs parsing and L2 analysis unconditionally for all files before checking/resolving project taints. The cache is only used to bypass `summarise_module`, which merely packages already-computed seeds and counts, saving virtually zero CPU cycles. * **Concrete Remediation**: @@ -339,8 +339,8 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-11: Inert Configuration Option `provenance_clash` (Dead Engine Path) * **Focus Area**: Systems / Taint Algebra * **Target Location**: - - [propagation.py](file:///home/john/wardline/src/wardline/scanner/taint/propagation.py#L160-L182) - - [variable_level.py](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L777-L824) + - [propagation.py](/src/wardline/scanner/taint/propagation.py#L160-L182) + - [variable_level.py](/src/wardline/scanner/taint/variable_level.py#L777-L824) * **Description**: The configuration parameter `provenance_clash` is designed to switch combinations from rank-meet (`least_trusted`) to provenance-clash (`taint_join`). Although unit tests verify `combine()` delegates correctly, all production code paths in both L2 (expression combiners, control-flow joins) and L3 (callee sets, external influence, SCC seeds) call `least_trusted` directly. As a result, enabling `provenance_clash: true` has zero effect on the static analysis. * **Concrete Remediation**: @@ -348,7 +348,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-MED-12: Git Ref Option/Argument Injection in `get_changed_files_since` * **Focus Area**: Security -* **Target Location**: [delta.py](file:///home/john/wardline/src/wardline/core/delta.py#L34-L44) +* **Target Location**: [delta.py](/src/wardline/core/delta.py#L34-L44) * **Description**: The `new_since` parameter is passed directly to the `subprocess.run` command line as `["git", "diff", "--name-only", ref]`. Although `shell=False` blocks shell command injection, there is no validation on the `ref` string. If an attacker controls the `ref` argument, they can pass git options like `--output=filename` to write diff output to arbitrary locations in the filesystem. * **Concrete Remediation**: @@ -364,7 +364,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-01: Incomplete MCP Handshake verification/enforcement * **Focus Area**: CLI & MCP -* **Target Location**: [protocol.py](file:///home/john/wardline/src/wardline/mcp/protocol.py#L67-L78) +* **Target Location**: [protocol.py](/src/wardline/mcp/protocol.py#L67-L78) * **Description**: The MCP specification requires that no requests be processed by the server until the full initialization handshake is complete (`initialize` request followed by `notifications/initialized`). However, `JsonRpcServer.dispatch` sets `self._initialized = True` immediately upon receiving the `initialize` request, prematurely accepting requests sent before the `notifications/initialized` handshake notification is received. * **Concrete Remediation**: @@ -372,7 +372,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-02: Undocumented `path` property in `verify_attestation` tool schema * **Focus Area**: CLI & MCP -* **Target Location**: [server.py](file:///home/john/wardline/src/wardline/mcp/server.py#L607-L615) +* **Target Location**: [server.py](/src/wardline/mcp/server.py#L607-L615) * **Description**: The `_verify_attestation` handler fetches the optional `path` property from the tool arguments via `args.get("path")` and resolves it using `_resolve_under_root`. However, the `verify_attestation` tool's `input_schema` does not list `path` in its `properties` block. * **Concrete Remediation**: @@ -380,7 +380,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-03: Test Coverage Gaps in Error Hierarchy and Path Rejection * **Focus Area**: Quality / Robustness -* **Target Location**: [test_errors.py](file:///home/john/wardline/tests/unit/core/test_errors.py#L1-L14) & [test_source_excerpt.py](file:///home/john/wardline/tests/unit/core/test_source_excerpt.py#L30-L34) +* **Target Location**: [test_errors.py](/tests/unit/core/test_errors.py#L1-L14) & [test_source_excerpt.py](/tests/unit/core/test_source_excerpt.py#L30-L34) * **Description**: `test_errors.py` only validates inheritance for `ConfigError` and `DiscoveryError`, leaving several `WardlineError` subclasses unverified. Additionally, `test_source_excerpt.py` does not explicitly test absolute escapes (e.g. `/etc/passwd`). * **Concrete Remediation**: @@ -388,7 +388,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-04: FQN Resolution Limitation for Nested/Dotted Attribute Calls * **Focus Area**: Python / Static Analysis -* **Target Location**: [resolve_call_fqn in ast_primitives.py](file:///home/john/wardline/src/wardline/scanner/ast_primitives.py#L171-L197) +* **Target Location**: [resolve_call_fqn in ast_primitives.py](/src/wardline/scanner/ast_primitives.py#L171-L197) * **Description**: `resolve_call_fqn` only resolves call targets of the form `mod.func()`. For nested paths (e.g. `package.submodule.func()`), the receiver is an `ast.Attribute` node, causing resolution to fail and return `None`. * **Concrete Remediation**: @@ -396,7 +396,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-05: Redundant/Dead Code in SCC Propagation * **Focus Area**: Static Analysis / Tarjan -* **Target Location**: [propagation.py](file:///home/john/wardline/src/wardline/scanner/taint/propagation.py#L337-L347) +* **Target Location**: [propagation.py](/src/wardline/scanner/taint/propagation.py#L337-L347) * **Description**: The unresolved calls floor check in `propagation.py` is redundant because a previous floor check already pins the refined taint to be at least as severe as the L1/unresolved floor. * **Concrete Remediation**: @@ -404,7 +404,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-06: Transitive Core-Scanner Coupling in Optional Clarion Extra * **Focus Area**: Architecture / Coupling -* **Target Location**: [facts.py](file:///home/john/wardline/src/wardline/clarion/facts.py#L25) & [write.py](file:///home/john/wardline/src/wardline/clarion/write.py#L17) +* **Target Location**: [facts.py](/src/wardline/clarion/facts.py#L25) & [write.py](/src/wardline/clarion/write.py#L17) * **Description**: Even if a user installs `wardline[clarion]` (which does not declare a dependency on `scanner`), importing `clarion/facts.py` or `clarion/write.py` will trigger the import of `core/run.py` at import time, leading to `ImportError` because `yaml` is missing. * **Concrete Remediation**: @@ -412,7 +412,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-07: False-Positive Local Pack Detection for Built-in/Frozen Modules * **Focus Area**: Security / Custom Packs -* **Target Location**: [config.py](file:///home/john/wardline/src/wardline/core/config.py#L64-L91) +* **Target Location**: [config.py](/src/wardline/core/config.py#L64-L91) * **Description**: For built-in/frozen modules like `sys` or `os`, the `spec.origin` is `'built-in'` or `'frozen'`. Calling `Path('built-in').resolve()` resolves it relative to the current working directory, e.g. `/path/to/project/built-in`. Since this resolved path falls within the current directory, it is incorrectly flagged as a local pack, raising a false-positive `ConfigError`. * **Concrete Remediation**: @@ -425,7 +425,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-08: Untrusted Custom Pack Loading via `wardline.yaml` * **Focus Area**: Security -* **Target Location**: [config.py](file:///home/john/wardline/src/wardline/core/config.py#L112-L133) +* **Target Location**: [config.py](/src/wardline/core/config.py#L112-L133) * **Description**: If `wardline scan` or the MCP server's `scan` tool is run on an untrusted workspace containing a `wardline.yaml`, the `packs` key allows specifying custom packages to import. While `trust_local_packs=False` blocks imports under the root, it does not block importing globally installed Python packages. * **Concrete Remediation**: @@ -433,7 +433,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-09: Uncontrolled Resource Consumption (OOM) in JSON-RPC stdio transport * **Focus Area**: Security / DoS -* **Target Location**: [protocol.py](file:///home/john/wardline/src/wardline/mcp/protocol.py#L100-L115) +* **Target Location**: [protocol.py](/src/wardline/mcp/protocol.py#L100-L115) * **Description**: The stdio read loop iterates over `in_stream` directly using `for raw in in_stream: line = raw.strip()`. Python's stdio stream reading doesn't limit the line length. A massive line payload sent over stdin will consume a huge amount of memory during parsing, causing an Out Of Memory (OOM) crash. * **Concrete Remediation**: @@ -441,7 +441,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive ### WLN-LOW-10: Fragile Assignment Type Suppression in Variable-Level Control Flow Merge * **Focus Area**: Python Engineering -* **Target Location**: [variable_level.py](file:///home/john/wardline/src/wardline/scanner/taint/variable_level.py#L823) +* **Target Location**: [variable_level.py](/src/wardline/scanner/taint/variable_level.py#L823) * **Description**: Mypy type-ignore is used because of set-keys mismatch inference. This can be cleanly written in a type-safe manner without type ignores. * **Concrete Remediation**: diff --git a/docs/getting-started.md b/docs/getting-started.md index a35dd2a1..34e13f12 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -81,7 +81,7 @@ The fields: | `location` | The source `path` plus `line_start`/`line_end` and `col_start`/`col_end`. | | `message` | A human-readable description of the violation. | | `properties` | Rule-specific detail. Here, the declared vs. actually-returned trust tier. | -| `suppressed` | `active` means the finding is live (not suppressed). | +| `suppression_state` | `active` means the finding is live (not suppressed); other states are `baselined`, `waived`, `judged`. | | `suppression_reason` | Why it was suppressed, if it was; `null` for a live finding. | | `confidence`, `suggestion` | Optional extras, `null` when the rule does not supply them. | | `related_entities` | Other entities involved in the finding, if any. | diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index b144c192..a54b8b56 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -30,9 +30,9 @@ const SELF = 'wardline'; const me = getMember(SELF); // Surface facts that move — SNAPSHOTS, each carries a repo pointer. -// (Verified live: `wardline --version` -> 1.0.0rc4; rule count and tool +// (Verified live: `wardline --version` -> 1.0.3; rule count and tool // count are the repo's authority, pointed at, never silently restated.) -const VERSION = '1.0.0rc4'; +const VERSION = '1.0.3'; const REPO = me.repo; const DOCS = `${REPO}/tree/main/docs`; From a779b5fe3afdabecc8f1f1668feb8b6fc9731561 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Thu, 18 Jun 2026 17:30:39 +1000 Subject: [PATCH 2/4] fix: make TYPE_CHECKING reachability scope-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_is_type_checking_guard` honored any `if TYPE_CHECKING:` whose name resolved to typing.TYPE_CHECKING via the module-scoped alias map, ignoring closer bindings in the function scope. Two failure modes: - false PY-WL-102: a parameter `TYPE_CHECKING=True` shadowing the import made a runtime-reachable rejection branch read as the dead typing branch, so the rejection looked missing. - false negative: a function-local `from typing import TYPE_CHECKING` was not in the module alias map, so its dead branch wrongly rescued the boundary. Fix: a per-function effective-alias-map correction applied at the scope entry choke point — drop names shadowed by a parameter/local assignment, restore the genuine constant for function-local typing imports. The legitimate module-level `if TYPE_CHECKING:` dead-branch handling is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/scanner/rules/_ast_helpers.py | 71 ++++++++++++++++++- .../scanner/rules/failopen_boundary.py | 10 ++- .../rules/test_boundary_without_rejection.py | 36 ++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/wardline/scanner/rules/_ast_helpers.py b/src/wardline/scanner/rules/_ast_helpers.py index 7a1eb970..84078c59 100644 --- a/src/wardline/scanner/rules/_ast_helpers.py +++ b/src/wardline/scanner/rules/_ast_helpers.py @@ -48,7 +48,7 @@ def _own_reachable_statements( node: ast.FunctionDef | ast.AsyncFunctionDef, alias_map: Mapping[str, str] | None = None, ) -> Iterator[ast.stmt]: - yield from _reachable_statements_in_block(node.body, alias_map) + yield from _reachable_statements_in_block(node.body, _scope_alias_map(node, alias_map)) def _own_reachable_nodes( @@ -165,6 +165,75 @@ def _is_type_checking_guard(test: ast.expr, alias_map: Mapping[str, str] | None return _resolve_dotted_expr(test, alias_map) == _TYPE_CHECKING_FQN +def _local_binding_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[str]: + """Yield names bound in *node*'s OWN scope that can shadow an outer binding — + parameters and non-import local assignment targets. ``from typing import + TYPE_CHECKING`` / ``import typing`` are deliberately EXCLUDED here: those bind + the real typing constant and are restored by :func:`_local_typing_imports`, + which wins over a shadow. Walks the own scope (skips nested def/class).""" + args = node.args + for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs): + yield arg.arg + if args.vararg: + yield args.vararg.arg + if args.kwarg: + yield args.kwarg.arg + for stmt in _own_statements(node): + if isinstance(stmt, ast.Assign): + for target in stmt.targets: + if isinstance(target, ast.Name): + yield target.id + elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + yield stmt.target.id + + +def _local_typing_imports(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[tuple[str, str]]: + """Yield ``(local_name, fqn)`` for FUNCTION-LOCAL ``typing`` imports that bind + the real typing constant: ``from typing import TYPE_CHECKING [as X]`` and + ``import typing [as t]``. These ARE the genuine constant, so a function-local + guard built on them is honored as the dead typing-only branch exactly like a + module-level import.""" + for stmt in _own_statements(node): + if isinstance(stmt, ast.ImportFrom) and stmt.module == "typing" and (stmt.level or 0) == 0: + for alias in stmt.names: + if alias.name == "TYPE_CHECKING": + yield (alias.asname or alias.name, _TYPE_CHECKING_FQN) + elif isinstance(stmt, ast.Import): + for alias in stmt.names: + if alias.name == "typing": + yield (alias.asname or "typing", "typing") + + +def _scope_alias_map( + node: ast.FunctionDef | ast.AsyncFunctionDef, + alias_map: Mapping[str, str] | None = None, +) -> Mapping[str, str] | None: + """The module *alias_map* corrected for *node*'s OWN-SCOPE bindings, so + ``if TYPE_CHECKING:`` is read as the dead typing-only branch ONLY when the name + actually refers to the typing constant at that point. + + Three corrections, applied in order: + 1. start from the module-level *alias_map* (module ``import typing`` / + ``from typing import TYPE_CHECKING`` keep working); + 2. a parameter or local assignment that SHADOWS such a name removes its + module binding (a ``def f(..., TYPE_CHECKING=True)`` makes the guard a real + runtime branch — defect #3a); + 3. a function-LOCAL ``typing`` import restores the genuine constant, winning + over a same-name shadow (defect #3b). + + Returns the unchanged *alias_map* when no own-scope binding touches a + typing-relevant name, so the common case allocates nothing.""" + shadows = set(_local_binding_names(node)) + local_imports = dict(_local_typing_imports(node)) + if not shadows and not local_imports: + return alias_map + effective: dict[str, str] = dict(alias_map or {}) + for name in shadows: + effective.pop(name, None) + effective.update(local_imports) + return effective + + def own_except_handlers(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.ExceptHandler]: """Yield the ``except`` handlers in *node*'s own scope (excludes nested defs).""" for stmt in _own_statements(node): diff --git a/src/wardline/scanner/rules/failopen_boundary.py b/src/wardline/scanner/rules/failopen_boundary.py index ebc8c611..fb71c1fe 100644 --- a/src/wardline/scanner/rules/failopen_boundary.py +++ b/src/wardline/scanner/rules/failopen_boundary.py @@ -70,6 +70,7 @@ def v(p): from wardline.core.taints import TRUST_RANK from wardline.scanner.rules._ast_helpers import ( _own_statements, + _scope_alias_map, block_has_real_rejection, handler_substitutes_on_failure, has_real_rejection, @@ -127,6 +128,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: # Premise 1 (the family partition): a REAL rejection must exist. None at # all -> PY-WL-102's domain; assert-only -> PY-WL-111's domain. alias_map = module_alias_map(qualname, context) + # Correct the module alias_map for this boundary's own-scope bindings so a + # shadowed / function-local ``TYPE_CHECKING`` guard is read correctly when + # block_has_real_rejection walks the try/handler bodies directly (the + # node-taking helpers self-correct, but these take raw stmt lists). + scoped = _scope_alias_map(entity.node, alias_map) rejecting_calls = rejecting_helper_calls(entity, context.entities, context.call_site_callees, alias_map) if not (has_real_rejection(entity.node, alias_map) or rejecting_calls): continue @@ -136,8 +142,8 @@ def check(self, context: AnalysisContext) -> list[Finding]: if not any( handler_substitutes_on_failure(handler, returned) and ( - block_has_real_rejection(try_stmt.body, rejecting_calls, alias_map) - or block_has_real_rejection(handler.body, rejecting_calls, alias_map) + block_has_real_rejection(try_stmt.body, rejecting_calls, scoped) + or block_has_real_rejection(handler.body, rejecting_calls, scoped) ) for try_stmt in _own_statements(entity.node) if isinstance(try_stmt, (ast.Try, ast.TryStar)) diff --git a/tests/unit/scanner/rules/test_boundary_without_rejection.py b/tests/unit/scanner/rules/test_boundary_without_rejection.py index a2c3f283..bd35c3b0 100644 --- a/tests/unit/scanner/rules/test_boundary_without_rejection.py +++ b/tests/unit/scanner/rules/test_boundary_without_rejection.py @@ -273,3 +273,39 @@ def test_arbitrary_call_return_still_fires(tmp_path) -> None: }, ) assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + +def test_parameter_shadowing_type_checking_is_runtime_reachable(tmp_path) -> None: + # Defect #3(a) — FALSE POSITIVE. A PARAMETER named ``TYPE_CHECKING`` shadows the + # module-level ``typing.TYPE_CHECKING`` import, so ``if TYPE_CHECKING:`` is a real + # RUNTIME branch — its ``raise`` IS reachable and rejects bad input. The guard must + # NOT be treated as the dead typing-only branch when the name is locally shadowed. + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from typing import TYPE_CHECKING\n" + "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p, TYPE_CHECKING=True):\n" + " if TYPE_CHECKING:\n raise ValueError\n x = p\n return x\n", + }, + ) + assert _run(ctx) == [] + + +def test_function_local_type_checking_import_is_honored_as_dead_branch(tmp_path) -> None: + # Defect #3(b) — FALSE NEGATIVE. A FUNCTION-LOCAL ``from typing import TYPE_CHECKING`` + # is still the real typing constant (False at runtime), so ``if TYPE_CHECKING: raise`` + # is the statically-dead typing branch and does NOT rescue the boundary — PY-WL-102 + # must fire exactly as it does for a module-level import. + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n" + " from typing import TYPE_CHECKING\n" + " if TYPE_CHECKING:\n raise ValueError\n x = p\n return x\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] From f2bbd0734806ff9c71f209cfe9f6d37ba62c56c5 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Thu, 18 Jun 2026 17:30:53 +1000 Subject: [PATCH 3/4] feat: warpline delta scan (wardline scan --affected) + review hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producer-agnostic "scan only these entities" capability (Edge 1 / B, wardline-4e08ab9ce6). wardline consumes a warpline reverify-worklist or bare entity list and analyzes only the affected (caller-closure-expanded) files; the full scan stays the gate of record. wardline never calls warpline. Surface: - CLI `wardline scan --affected `; MCP `scan` gains an `affected` param. - core/delta_scope.py (parse 3 envelope shapes + DeltaScopeReport honesty block), core/delta_resolve.py (SEI + qualname-fallback resolution, caller-closure expansion, finding filter), run.py scoping wiring, sei_resolution.py python:method: locator fix, SARIF run.properties channel. - `warpline_e2e` live oracle + marker + ci.yml matrix row + meta-guard. - Hermetic golden + INV-1..INV-5 invariant tests. Security/soundness (INV-4 / THREAT-001): the delta finding filter narrows only the displayed findings, never the gate population — an attacker-influenceable scope cannot hide an in-analyzed-file finding from the gate. The trust- suppressions path is closed by decoupling gate suppression posture from the gate_findings sentinel (ScanResult.gate_honors_suppressions) and materializing the unfiltered analyzed population as the gate population. Review fixes folded in: - MCP delta emit forces mark_unseen=False (INV-5; was CLI-only). - --affected payload byte cap enforced before unbounded read()/json.loads(). - MCP fail_on/where.* schemas gain case-insensitive patterns (reject BOGUS). - CHANGELOG/doc claim corrected: the gate covers the analyzed-file population; a delta --fail-on pass is advisory, the full scan is the gate of record. - cli.md reconciled with `scan --help`; glossary anchors re-bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 28 ++ docs/reference/cli.md | 71 ++- .../reference/finding-lifecycle-vocabulary.md | 68 +-- .../plans/2026-06-18-warpline-delta-scan.md | 413 +++++++++++++++ .../2026-06-18-warpline-delta-scan-design.md | 234 +++++++++ pyproject.toml | 14 +- src/wardline/_live_oracle.py | 2 +- src/wardline/cli/scan.py | 133 ++++- src/wardline/core/delta_resolve.py | 409 +++++++++++++++ src/wardline/core/delta_scope.py | 289 +++++++++++ src/wardline/core/filigree_emit.py | 28 +- src/wardline/core/legis.py | 12 +- src/wardline/core/run.py | 207 +++++++- src/wardline/core/sarif.py | 53 +- src/wardline/core/sei_resolution.py | 8 +- src/wardline/mcp/server.py | 168 ++++++- .../fixtures/warpline_delta/sample_tree/a.py | 25 + .../fixtures/warpline_delta/sample_tree/b.py | 18 + .../warpline_delta/sample_tree/sink_mod.py | 17 + .../warpline_delta/sample_tree/source_mod.py | 15 + .../worklist_all_unresolvable.v1.json | 25 + .../warpline_delta/worklist_alpha.v1.json | 27 + .../worklist_caller_closure.v1.json | 27 + .../warpline_delta/worklist_partial.v1.json | 32 ++ .../conformance/test_mcp_structured_output.py | 10 + .../conformance/test_warpline_delta_scope.py | 247 +++++++++ tests/conformance/test_warpline_e2e.py | 174 +++++++ tests/docs/test_glossary_vocabulary.py | 54 +- tests/unit/cli/test_cli.py | 14 +- tests/unit/cli/test_install.py | 8 +- tests/unit/cli/test_scan_affected_cli.py | 265 ++++++++++ tests/unit/core/test_affected_invariants.py | 474 ++++++++++++++++++ tests/unit/core/test_cli_mcp_parity.py | 2 +- tests/unit/core/test_delta_finding_filter.py | 129 +++++ tests/unit/core/test_delta_resolve.py | 306 +++++++++++ tests/unit/core/test_delta_scope.py | 294 +++++++++++ tests/unit/core/test_delta_scope_report.py | 119 +++++ tests/unit/core/test_run_affected.py | 256 ++++++++++ tests/unit/mcp/test_scan_affected_mcp.py | 196 ++++++++ tests/unit/mcp/test_server_filigree_emit.py | 43 +- tests/unit/mcp/test_server_filigree_wiring.py | 3 +- tests/unit/mcp/test_server_security.py | 7 +- tests/unit/mcp/test_server_tools.py | 49 ++ tests/unit/test_ci_live_oracles.py | 5 +- tests/unit/test_live_oracle.py | 42 ++ 46 files changed, 4871 insertions(+), 152 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-warpline-delta-scan.md create mode 100644 docs/superpowers/specs/2026-06-18-warpline-delta-scan-design.md create mode 100644 src/wardline/core/delta_resolve.py create mode 100644 src/wardline/core/delta_scope.py create mode 100644 tests/conformance/fixtures/warpline_delta/sample_tree/a.py create mode 100644 tests/conformance/fixtures/warpline_delta/sample_tree/b.py create mode 100644 tests/conformance/fixtures/warpline_delta/sample_tree/sink_mod.py create mode 100644 tests/conformance/fixtures/warpline_delta/sample_tree/source_mod.py create mode 100644 tests/conformance/fixtures/warpline_delta/worklist_all_unresolvable.v1.json create mode 100644 tests/conformance/fixtures/warpline_delta/worklist_alpha.v1.json create mode 100644 tests/conformance/fixtures/warpline_delta/worklist_caller_closure.v1.json create mode 100644 tests/conformance/fixtures/warpline_delta/worklist_partial.v1.json create mode 100644 tests/conformance/test_warpline_delta_scope.py create mode 100644 tests/conformance/test_warpline_e2e.py create mode 100644 tests/unit/cli/test_scan_affected_cli.py create mode 100644 tests/unit/core/test_affected_invariants.py create mode 100644 tests/unit/core/test_delta_finding_filter.py create mode 100644 tests/unit/core/test_delta_resolve.py create mode 100644 tests/unit/core/test_delta_scope.py create mode 100644 tests/unit/core/test_delta_scope_report.py create mode 100644 tests/unit/core/test_run_affected.py create mode 100644 tests/unit/mcp/test_scan_affected_mcp.py create mode 100644 tests/unit/test_live_oracle.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aaee323..966f1ec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,8 @@ jobs: marker: legis_e2e - name: Filigree marker: filigree_e2e + - name: Warpline + marker: warpline_e2e steps: - uses: actions/checkout@v4 with: @@ -176,6 +178,7 @@ jobs: WARDLINE_LOOMWEAVE_BIN: ${{ secrets.WARDLINE_LOOMWEAVE_BIN }} WARDLINE_LEGIS_URL: ${{ secrets.WARDLINE_LEGIS_URL }} WARDLINE_FILIGREE_URL: ${{ secrets.WARDLINE_FILIGREE_URL }} + WARDLINE_WARPLINE_BIN: ${{ secrets.WARDLINE_WARPLINE_BIN }} - name: Summarize live oracle if: always() run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2950234b..65f93013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Warpline delta scan (`wardline scan --affected`).** A new `--affected + ` option scopes analysis to the entities named in a `warpline. + reverify_worklist.v1` worklist (or a bare entity list, file or stdin), so an + agent's inner loop can re-verify only what changed. The affected set is + caller-closure expanded so cross-file taint into a changed callee is still + computed; the displayed findings are filtered to the requested entities while + the severity gate evaluates the full unsuppressed population **of the analyzed + files**, so an attacker-influenceable scope cannot hide an in-analyzed-file + finding from the gate (INV-4). Speed, not truth: delta analyzes only the + scoped (caller-closure-expanded) files, so a delta `--fail-on` pass is + **advisory** — it does not certify files it never analyzed (the stderr line + reports `analyzed N of M discovered file(s)`). The full scan remains the gate + of record. An empty or all-unresolvable scope falls back to a full scan. A `scope` block (mode, + gate authority, file counts, unresolved/fell-back/stale-SEI accounting, and a + boundary caveat) is attached to every output format (agent-summary JSON, + SARIF `run.properties`, MCP structured content). Delta emits on both the CLI + and MCP surfaces force `mark_unseen=False` so a scoped run never poisons + Filigree reconciliation. +- **MCP `scan` gains an `affected` parameter.** The same delta scope (inline + object/array worklist or a path under root) is available over MCP, with the + `scope` block surfaced in structured content. +- **`warpline_e2e` live oracle.** A scheduled/manual CI oracle runs the real + `warpline reverify` → `wardline scan --affected -` round-trip; the marker is + fail-closed under `WARDLINE_LIVE_ORACLE_REQUIRED`. + ## [1.0.3] - 2026-06-18 ### Fixed diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3a25d098..1c806bfc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -82,19 +82,64 @@ Usage: wardline scan [OPTIONS] [PATH] Scan PATH for findings. + PATH is the scan root and GOVERNS finding identity: qualnames and + fingerprints are minted relative to it, and baseline/waiver/judged + suppression state is read from PATH's .weft/wardline/. Scan the project root. + Options: - --config FILE Path to a weft.toml whose [wardline] table - supplies configuration overrides (weft.toml). + --config FILE --format [jsonl|sarif|agent-summary|legis] - --lang [python|rust] Language frontend. 'rust' (PREVIEW) scans - .rs files for command-injection findings. + --lang [python|rust] Language frontend. 'rust' scans .rs files + for RS-WL-* command-injection findings + (frozen identity, baseline-eligible; config + severity overrides not yet applied). --output PATH - --fail-on [CRITICAL|ERROR|WARN|INFO] - --cache-dir PATH Store authenticated L3 summary-cache entries - here for faster incremental scans when - WARDLINE_SUMMARY_CACHE_KEY is set. + --fail-on [critical|error|warn|info] + --fail-on-unanalyzed / --no-fail-on-unanalyzed + Exit 1 if any file was discovered but could + not be analyzed. + --cache-dir PATH Persist L3 summary cache here for faster + incremental scans. --filigree-url TEXT POST findings to this Filigree Weft scan- results URL (opt-in). + --local-only, --no-emit Disable sibling emission even when Filigree + or Loomweave URLs resolve from flags, env, + or local install state. + --filigree-max-findings-per-request INTEGER RANGE + Maximum Wardline findings per Filigree scan- + results POST (default 1000). [x>=1] + --loomweave-url TEXT Persist per-entity taint facts to this + Loomweave taint-store URL (opt-in, fail- + soft). + --new-since TEXT PR-scoped 'new findings only' gate: only + gate on findings in files/entities changed + since this git ref. + --affected FILENAME Scan only entities in this warpline + reverify-worklist / entity-list (file path, + or '-' for stdin). Speed, not truth: out-of- + scope cross-file flows are not analyzed (see + the scope block). Empty/unresolvable -> full + scan. Mutually exclusive with --new-since. + --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. + --fix Apply mechanical autofixes during the scan. + -y, --yes Automatically confirm all fixes when --fix + is specified. + --strict-defaults Ignore repository-supplied custom + configuration overrides (weft.toml). + --allow-source-root-escape Allow weft.toml [wardline] source_roots to + resolve outside PATH. + --trust-suppressions Let repository-controlled + baseline/waiver/judged files clear the + --fail-on gate (always annotate findings + regardless). Default off: the gate evaluates + the unsuppressed population so a PR cannot + self-suppress. + --allow-dirty For --format legis only: on a dirty working + tree, emit an UNSIGNED, clearly-marked dev + artifact instead of refusing. --help Show this message and exit. ``` @@ -112,6 +157,16 @@ it at a package root, not a single file. | `--filigree-url TEXT` | Opt-in: POST findings to a Filigree Weft scan-results endpoint as well as emitting them locally. Prefer this native path when agents need Filigree promotion, deduplication, or close/reopen lifecycle state. | | `--local-only`, `--no-emit` | Disable sibling emission for this scan, even if Filigree or Loomweave URLs resolve from flags, environment, MCP install state, or local published ports. The scan still writes local output and evaluates the gate. | | `--filigree-max-findings-per-request INTEGER` | Cap findings per Filigree scan-results POST. Precedence is explicit CLI value, then `WARDLINE_FILIGREE_MAX_FINDINGS_PER_REQUEST`, then Filigree's advertised scan-results limit from `/api/files/_schema` when reachable, then Wardline's safe default (`1000`). Wardline chunks by complete file groups when possible so Filigree reconciliation does not mark later chunks as fixed. | +| `--loomweave-url TEXT` | Opt-in, fail-soft: persist per-entity taint facts to a Loomweave taint-store endpoint alongside local output. | +| `--fail-on-unanalyzed` / `--no-fail-on-unanalyzed` | Exit `1` if any file was discovered but could not be analyzed (e.g. a parse failure), even when no finding trips `--fail-on`. | +| `--new-since TEXT` | PR-scoped "new findings only" gate: gate only on findings in files/entities changed since this git ref. Mutually exclusive with `--affected`. | +| `--affected FILENAME` | Scope analysis to the entities named in a `warpline.reverify_worklist.v1` worklist (or a bare entity list; file path, or `-` for stdin) — the inner-loop fast path. The affected set is caller-closure expanded so cross-file taint into a changed callee is still computed. **Speed, not truth:** only the scoped files are analyzed, so a delta `--fail-on` pass is **advisory** and does not certify out-of-scope files — the full scan stays the gate of record (a `scope` block records the mode, gate authority, and `analyzed N of M` accounting). An empty or all-unresolvable scope falls back to a full scan. Mutually exclusive with `--new-since`. | +| `--trust-pack TEXT` (repeatable), `--allow-custom-packs` | Allow loading trust-grammar packs declared in `weft.toml [wardline]` (`--trust-pack`) or from the local project directory (`--allow-custom-packs`). | +| `--fix`, `-y`/`--yes` | Apply mechanical autofixes during the scan; `-y` auto-confirms every fix. | +| `--strict-defaults` | Ignore repository-supplied custom configuration overrides in `weft.toml`. | +| `--allow-source-root-escape` | Allow `weft.toml [wardline] source_roots` to resolve outside `PATH`. | +| `--trust-suppressions` | Let repository-controlled baseline/waiver/judged files clear the `--fail-on` gate (they always annotate findings regardless). Use only for trusted local checkouts; in CI prefer the unforgeable `--new-since ` ratchet. Default off, so the gate evaluates the unsuppressed population and a PR cannot self-suppress. | +| `--allow-dirty` | `--format legis` only: on a dirty working tree, emit an UNSIGNED, clearly-marked (`dirty: true`) dev artifact instead of refusing. Signing stays clean-tree-only. | Realistic invocation — scan the source tree, emit SARIF to a file, and fail the build on any `ERROR`-or-worse finding: diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index ca0371c2..024b77a4 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:471`). +`suppressed` count (`src/wardline/cli/scan.py:547`). ## `active` is the one word for "non-suppressed defect" @@ -71,9 +71,9 @@ consistently, on every surface: | Surface | Where | Term | | --- | --- | --- | | Enum | `src/wardline/core/finding.py:72` | `SuppressionState.ACTIVE = "active"` | -| Summary field | `src/wardline/core/run.py:52`, built at `src/wardline/core/run.py:393` | `ScanSummary.active` | -| CLI summary line | `src/wardline/cli/scan.py:472` | `… {s.active} active` | -| MCP scan response | `src/wardline/mcp/server.py:910` | `summary.active` | +| Summary field | `src/wardline/core/run.py:71`, built at `src/wardline/core/run.py:536` | `ScanSummary.active` | +| CLI summary line | `src/wardline/cli/scan.py:548` | `… {s.active} active` | +| MCP scan response | `src/wardline/mcp/server.py:999` | `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`" | @@ -88,21 +88,21 @@ surfaces. ## The summary buckets partition the total -`ScanSummary` (`src/wardline/core/run.py:50-70`) counts split the whole scan into +`ScanSummary` (`src/wardline/core/run.py:68-89`) counts split the whole scan into buckets that **sum to `total`** exactly (weft-f506e5f845): - the defect buckets partition the `DEFECT`s by state — - `active` (`src/wardline/core/run.py:52`) + `baselined` (`src/wardline/core/run.py:54`) - + `waived` (`src/wardline/core/run.py:55`) + `judged` (`src/wardline/core/run.py:56`); -- `informational` (`src/wardline/core/run.py:62`) is **every non-defect finding** + `active` (`src/wardline/core/run.py:71`) + `baselined` (`src/wardline/core/run.py:73`) + + `waived` (`src/wardline/core/run.py:74`) + `judged` (`src/wardline/core/run.py:75`); +- `informational` (`src/wardline/core/run.py:81`) is **every non-defect finding** (facts, metrics, classifications) — the rest of `total`. So `active + baselined + waived + judged + informational == total` -(`src/wardline/core/run.py:51` for `total: int`). `unanalyzed` -(`src/wardline/core/run.py:70`) is an **overlay** — a subset of `informational` +(`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:918`) -and `unanalyzed` (`src/wardline/mcp/server.py:922`); the agent-summary block mirrors +The MCP `summary` block exposes `informational` (`src/wardline/mcp/server.py:1007`) +and `unanalyzed` (`src/wardline/mcp/server.py:1011`); 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 @@ -111,19 +111,19 @@ There are **two distinct populations** of defects in one scan, and they can differ on purpose: 1. **Emitted-active** — `summary.active` counts `active` defects in the - **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:393`). + **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:536`). Baseline / waiver / judged annotate these findings in place; a suppressed defect is still emitted, just not counted as `active`. 2. **Gate population** — the `--fail-on` gate evaluates a **separate** `ScanResult.gate_findings` list: the *unsuppressed* population - (`src/wardline/core/run.py:341`). By default, repository-controlled + (`src/wardline/core/run.py:456`). By default, repository-controlled baseline / waiver / judged entries **annotate** the emitted findings but do **not** clear the gate — so a malicious PR cannot green the gate by committing a suppression keyed to its own new defect. `gate_decision` evaluates `gate_findings` when present, else falls back to `findings` (the trusted `--trust-suppressions` / directly-constructed path), selected at - `src/wardline/core/run.py:452` (`honors_suppressions`). + `src/wardline/core/run.py:619` (`honors_suppressions`). This is why **`summary.active: 0` can co-exist with `gate.tripped: true`**: every defect was suppressed by a committed baseline (so emitted-active is 0), but those @@ -132,8 +132,8 @@ bug. ### The gate verdict is explicit (never a vacuous green) -`GateDecision` (`src/wardline/core/run.py:99`) carries `tripped` / `fail_on` / -`exit_class` **plus** an explicit `verdict` (`src/wardline/core/run.py:108`) and a +`GateDecision` (`src/wardline/core/run.py:148`) carries `tripped` / `fail_on` / +`exit_class` **plus** an explicit `verdict` (`src/wardline/core/run.py:157`) and a `would_trip_at`, alongside a human `reason` and the `evaluated` population it judged. The `verdict` is one of: @@ -150,20 +150,20 @@ trips when any file was discovered but never analysed; benign no-module skips excluded). `severity_tripped` / `unanalyzed_tripped` attribute an overall `tripped` to its sub-gate(s) so no consumer has to parse `reason`. -The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:925`), -`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:929`), +The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:1014`), +`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:1018`), `gate.severity_tripped`, `gate.unanalyzed_tripped`, `would_trip_at`, `reason`, -`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:924` +`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:1013` (`"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:524`). +(`src/wardline/cli/scan.py:600`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists -(`src/wardline/core/run.py:369`, `def apply_delta_scope`). +(`src/wardline/core/run.py:484`, `def apply_delta_scope`). ## The three meanings of "new" @@ -174,8 +174,8 @@ 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`) | -| `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:369`; 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:471` | +| `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:484`; 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:547` | The first-seen Filigree sense and the delta-scope `--new-since` sense are genuinely distinct concepts; neither is "active". @@ -186,19 +186,19 @@ How each concept appears on each surface: | Concept | CLI summary text | `ScanSummary` field | MCP `summary` key | Agent-summary key | Filigree store | | --- | --- | --- | --- | --- | --- | -| every finding | `N finding(s)` | `total` (`run.py:51`) | `total` (`server.py:937`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | -| live defect | `N active` (`scan.py:472`) | `active` (`run.py:52,393`) | `active` (`server.py:938`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | -| suppressed (sum) | `N suppressed` (`scan.py:471`) | `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:54`) | `baselined` (`server.py:939`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | -| waived | `N waiver` | `waived` (`run.py:55`) | `waived` (`server.py:940`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | -| judged | `N judged` | `judged` (`run.py:56`) | `judged` (`server.py:941`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | -| informational (summary) | (the remainder of `total`) | `informational` (`run.py:62`) | `informational` (`server.py:946`) | `informational` (`agent_summary.py:151`) | facts/metrics | +| every finding | `N finding(s)` | `total` (`run.py:70`) | `total` (`server.py:998`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | +| live defect | `N active` (`scan.py:548`) | `active` (`run.py:71,496`) | `active` (`server.py:999`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | +| suppressed (sum) | `N suppressed` (`scan.py:547`) | `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:1000`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | +| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:1001`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | +| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:1002`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | +| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:1007`) | `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:70`) | `unanalyzed` (`server.py:950`) | `unanalyzed` (`agent_summary.py:152`) | `WLN-ENGINE-*` facts | -| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:89`; `GateDecision`, `run.py:99`, `verdict` `run.py:108`) | `gate` (`server.py:952`), `gate.tripped` (`server.py:953`), `gate.verdict` (`server.py:957`) | `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:1011`) | `unanalyzed` (`agent_summary.py:152`) | `WLN-ENGINE-*` facts | +| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:108`; `GateDecision`, `run.py:148`, `verdict` `run.py:157`) | `gate` (`server.py:1013`), `gate.tripped` (`server.py:1014`), `gate.verdict` (`server.py:1018`) | `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:341`). +(`src/wardline/core/run.py:456`). ## For the suite diff --git a/docs/superpowers/plans/2026-06-18-warpline-delta-scan.md b/docs/superpowers/plans/2026-06-18-warpline-delta-scan.md new file mode 100644 index 00000000..b00b74b2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-warpline-delta-scan.md @@ -0,0 +1,413 @@ +# Warpline delta scan — implementation plan + +- **Date:** 2026-06-18 +- **Spec:** `docs/superpowers/specs/2026-06-18-warpline-delta-scan-design.md` (Approved) +- **Tracker:** `wardline-4e08ab9ce6` (B, this plan) · `wardline-c0563eee74` (A, follow-on — out of scope) +- **Baseline commit:** `4a24a032` +- **Label:** `warpline-delta-2026-06-18` +- **Branch discipline:** all work on the single in-flight `rcX` branch (per memory `feedback_single_rc_branch_no_scatter`); one PR `rcX → main`. + +## 0. Orientation — what already exists vs. what is new + +Read before writing code. Two distinct "delta" concepts coexist and **must not be conflated**: + +| Concept | Existing? | What it scopes | Mechanism | +|---|---|---|---| +| `--new-since ` | **Yes** — `src/wardline/core/delta.py` (`get_changed_files_since`, `get_affected_entities`) + `run_scan(new_since=...)` | the **gate** (out-of-delta ACTIVE defects → BASELINED in both emitted + gate populations); **full analysis still runs** | git diff + caller-side call-graph closure over `last_context` | +| `--affected ` | **No — this plan** | **discovery/analysis** (engine analyzes only files containing an affected entity, **caller-closure-expanded**) + a **finding post-filter on the DISPLAYED set only** (the gate population is never narrowed — INV-4) | producer-supplied entity scope set (warpline worklist or bare entity list) — **untrusted/unauthenticated input** | + +These are orthogonal **and mutually exclusive at the surface** (composing them is rejected loudly). `--new-since` keeps working byte-identically. The new `--affected` is a *pre-filter on which files reach `analyzer.analyze`* plus a *post-filter on the emitted findings* — but **NOT** on `gate_findings`: an attacker-influenceable scope must never forge a green (INV-4 / THREAT-001). The deferred `--since` (§Phase 8) is the only overlap — it *produces* an affected set from a gitref and then runs the `--affected` path; it does **not** reuse the `new_since` gate-scoping machinery. + +Confirmed real symbols this plan builds on: + +- `src/wardline/core/run.py` — `run_scan(...)`, `ScanResult`, `ScanSummary`, `gate_decision`, `GateDecision`. `run_scan` does `files = discover(...)` then `raw = list(analyzer.analyze(files, cfg, root=root))`. **The file-set scoping seam is between those two lines.** `analyzer.last_context` (`AnalysisContext`) carries `entities: Mapping[str, Entity]` and `project_edges`. +- `src/wardline/scanner/index.py` — `discover_file_entities(tree, *, module, path) -> list[Entity]`; `Entity.qualname`, `Entity.location` (`Location.path`). This is the **cheap structural pass** for the qualname→file index (no taint analysis needed). +- `src/wardline/core/qualname.py` — `module_dotted_name(rel_path)` for the module half of a qualname. +- `src/wardline/loomweave/identity.py` — `SeiResolver`, `SeiCapability`, `SeiResolver.resolve_sei`-style via `resolve_identity_status` / underlying client; `EntityBinding`. `src/wardline/core/sei_resolution.py` — `locator_to_qualname(locator)` (maps `python:function:pkg.mod.f` → `pkg.mod.f`) and the SEI-resolve pattern in `resolve_query_filters` (resolve `loomweave_client.resolve_sei(sei)["current_locator"]` → `locator_to_qualname`). +- `src/wardline/core/finding.py` — `Finding` (`qualname`, `location.path`, `fingerprint`, `kind`, `severity`); `compute_finding_fingerprint` (the **fingerprint invariant** — never touched by this plan); `_PROPERTY_ACCESSOR_QUALNAME_SUFFIXES` + `_to_wire_qualname` (suffix-stripping for `:setter`/`:deleter` — reuse for canonicalization, Phase 2/4). +- `src/wardline/core/filigree_emit.py` — `build_scan_results_body(..., mark_unseen=None)`; **auto-enables `mark_unseen` when findings/`scanned_paths` non-empty** — the INV-5 hazard; delta emit must force `mark_unseen=False`. +- `src/wardline/core/delta.py` — `get_affected_entities(changed_files, entities, project_edges)` builds the **reverse callee→caller** graph; **reused by Phase 2's caller-closure expansion** (taint findings anchor caller-side; warpline's worklist is downstream/callee). +- `src/wardline/core/sei_resolution.py` — `locator_to_qualname` prefix list **must gain `python:method:`** (Phase 2 bug fix; loomweave emits `kind="method"`, `index.py:95`). +- `src/wardline/core/sarif.py` — `SarifSink.write(findings, context=None)` + `build_sarif` — **gain an optional `run_properties` channel** for the SARIF scope block (Phase 7). +- `src/wardline/cli/scan.py` — the `scan` click command; pattern for fail-soft sibling blocks and `WardlineError → SystemExit(2)`. +- `src/wardline/mcp/server.py` — `_scan(args, root, ...)` handler (line ~792); `_SCAN_TOOL` inputSchema (line ~1739, properties incl. `new_since` at ~1832) and `_SCAN_OUTPUT_SCHEMA`. +- `src/wardline/mcp/tooling.py` — `ToolError`, `resolve_under_root`. +- `src/wardline/_live_oracle.py` — `LIVE_ORACLE_MARKERS` frozenset (currently `network, loomweave_e2e, legis_e2e, filigree_e2e`; **`rust_e2e` is deliberately NOT in it**). +- `pyproject.toml` `[tool.pytest.ini_options]` — `addopts` deselection string + `markers` list. +- `.github/workflows/ci.yml` — `live-oracles` job matrix (lines 149–187). +- `tests/conformance/` — golden-vector + faithful-vendor conventions (`test_legis_intake_contract.py`, `test_sei_oracle.py`); `tests/conftest.py` SKIP→FAIL hook. + +--- + +## Invariants (assert with tests in Phase 9) + +- **INV-1 — full-scan path unchanged.** When `affected` is not supplied, `run_scan` is byte-identical to today: same discovered files reach `analyzer.analyze`, same findings, same `ScanSummary`, same `gate_findings`. Proven by an existing-vs-new equivalence test. +- **INV-2 — fingerprints stable.** No code in this plan calls or alters `compute_finding_fingerprint` / `Finding.fingerprint`. The finding *filter* drops findings; it never re-mints identity. Proven by asserting filtered findings' fingerprints == the same findings' fingerprints in a full scan. +- **INV-3 — fail-closed honesty.** An `affected` input that resolves zero files (empty/malformed-but-parseable/all-unresolvable) → **full-fallback scan**, declared in the `scope` block; never a silent narrow. +- **INV-4 — an untrusted scope can never forge an authoritative green (THREAT-001).** `--affected` is attacker-influenceable input (a warpline worklist derived from filigree/loomweave state, or a hand-supplied/stale JSON blob with no signature, producer identity, or freshness binding — unlike `--new-since`, whose gitref is operator-supplied and computed locally). Therefore **the delta finding filter narrows only the EMITTED/displayed findings (`findings`), NEVER the gate population (`gate_findings`).** In delta mode the severity gate evaluates the *full unsuppressed population* exactly as a full scan would, so a worklist that surgically excludes the one file containing a real ERROR sink **cannot** produce `verdict="PASSED"` / exit 0. Fail-closed-on-empty (INV-3) does NOT cover this surgical-exclusion case (it only fires when ZERO files resolve, which a precise exclusion does not); INV-4 is the structural protection, not a deployment convention. Proven by a test: a worklist excluding a known-ERROR file MUST NOT yield a PASSED severity verdict and the verdict/exit must be identical to the full scan's. +- **INV-5 — Filigree reconciliation is never poisoned by a delta run (mark_unseen).** `FiligreeEmitter` auto-enables `mark_unseen` when findings/`scanned_paths` are non-empty (`filigree_emit.build_scan_results_body`), interpreting a fingerprint absent from a scanned path as "fixed" and closing the issue. A delta run emits the FULL discovery list as `scanned_paths` but a FILTERED `findings` list, so out-of-scope findings would be read as fixed → issues closed → irreversible signal loss. In delta mode the CLI emitter MUST be invoked with `mark_unseen=False` (a delta scan never reconciles closure); the `scope` block states that full reconciliation requires a full scan. + +--- + +## Phase 1 — `delta_scope.py`: parse the affected-entity scope (§5.1) + +**New file:** `src/wardline/core/delta_scope.py` (stdlib-only; base package stays zero-dep). + +**Public interface:** + +```python +@dataclass(frozen=True, slots=True) +class AffectedEntity: + sei: str | None + locator: str | None # opaque-ish; warpline locator e.g. "python:function:pkg.mod.f" + +@dataclass(frozen=True, slots=True) +class AffectedScope: + entities: frozenset[AffectedEntity] + source_kind: str # "reverify_worklist_v1" | "entity_list" | "empty" + item_count: int + +class ScopeParseError(WardlineError): # malformed payload — loud (exit 2), NOT a degrade + ... + +def parse_affected_scope(payload: object) -> AffectedScope: ... +def load_affected_scope(source: str) -> AffectedScope: ... + # Internal helper: reads the JSON file at `source` (a real path) and delegates to + # parse_affected_scope. It does NOT handle stdin — the CLI owns the stdin handle via + # click.File('-') and passes already-read text to parse_affected_scope (see Phase 7). + # parse_affected_scope(payload) is the sole public entry point. +``` + +Accepted shapes (§5.1) — **two structurally-distinct envelope variants** that must each have their own fixture/test: +1. `warpline.reverify_worklist.v1` **full success envelope** — `{"data": {"items": [...]}}` (the `data` wrapper present) — reads `data.items[].entity.{sei, locator}`. +2. `warpline.reverify_worklist.v1` **bare `data` payload** — `{"items": [...]}` (no outer envelope) — a producer that sends the inner object directly. **Distinct object from (1); must parse correctly via its own fixture.** +3. Bare entity list — `[{"sei"?: str, "locator"?: str}, ...]`. + +**Payload size cap (DoS guard — §7):** `parse_affected_scope` rejects a payload whose byte length OR `item_count` exceeds a fixed cap with `ScopeParseError` (the stdin/inline ingress is new and uncapped per the SP8 known gap). Pick a generous cap (e.g. 4 MiB / 50_000 items) — large enough never to bite a real worklist, small enough to bound a hostile blob. + +Rules: an entity with neither `sei` nor `locator` is dropped (counts toward neither). Empty/zero-entity input is **not** a `ScopeParseError` — it returns `AffectedScope(frozenset(), "empty", 0)` and the fail-closed rule (Phase 5) handles it. A structurally-malformed payload (not an object/array, `items` not a list, entity not an object) **is** `ScopeParseError` → loud (§7 "Malformed scope payload → exit 2"). + +**Test (proves it):** `tests/unit/core/test_delta_scope.py` — **three distinct shapes with distinct fixtures: full envelope (`{"data": {"items": …}}`), bare-data (`{"items": …}`), bare list**; malformed → `ScopeParseError`; over-cap (bytes and item_count) → `ScopeParseError`; empty → `source_kind="empty"`, `item_count=0`; entity with only `sei`, only `locator`, both, neither; `load_affected_scope` reads a file and raises `ScopeParseError` on bad JSON. + +**Verify:** `uv run pytest tests/unit/core/test_delta_scope.py -q` + +--- + +## Phase 2 — entity→file resolution (§5.2) + +**New file:** `src/wardline/core/delta_resolve.py`. + +**Public interface:** + +```python +@dataclass(frozen=True, slots=True) +class ResolvedScope: + files: frozenset[str] # repo-relative POSIX paths to analyze (caller-expanded) + affected_qualnames: frozenset[str] # BASE affected set (canonical), for the finding filter (Phase 4) + resolved: tuple[AffectedEntity, ...] + fell_back: tuple[AffectedEntity, ...] # resolved via qualname index, not loomweave + stale_sei: tuple[AffectedEntity, ...] # SEI resolved but qualname absent from current index + unresolved: tuple[AffectedEntity, ...] + loomweave_used: bool + +@dataclass(frozen=True, slots=True) +class QualnameIndex: + by_qualname: dict[str, str] # canonical qualname -> repo-relative POSIX path + project_edges: dict[str, frozenset[str]] # caller -> callees, for the reverse-edge closure + entities: dict[str, str] # canonical qualname -> repo-relative POSIX path (for get_affected_entities) + +def build_qualname_index(files: Sequence[Path], root: Path) -> QualnameIndex: + # via ast.parse (try/except SyntaxError|OSError -> skip file) + index.discover_file_entities + # + qualname.module_dotted_name + structural call-edge extraction. Cheap structural pass; + # NO taint analysis. Keys are CANONICAL (suffix-stripped) qualnames. + +def resolve_affected_scope( + scope: AffectedScope, + *, + index: QualnameIndex, + sei_resolver: SeiResolver | None, +) -> ResolvedScope: + # Resolves each entity to a base file; then caller-expands ResolvedScope.files via the + # reverse call graph (get_affected_entities over index.project_edges/entities). The + # filter set (affected_qualnames) stays the BASE set; only the analyzed files expand. +``` + +Resolution order per entity (§5.2). **All qualname comparisons use a canonical key** (see canonicalization below): +1. **SEI present and `sei_resolver` supported** → resolve SEI to a current locator (mirror `sei_resolution.resolve_query_filters`: `client.resolve_sei(sei)["current_locator"]` → `locator_to_qualname`) → canonicalize → match in the index → file. Authoritative; entity → `resolved`. + - **SEI-drift guard (§5.2):** if the SEI resolves but the returned qualname is **absent** from the freshly-built qualname index (loomweave stale vs working tree — e.g. a rename since loomweave's last index), the SEI is treated as effectively stale: fall through to the supplied-locator path (step 2) rather than landing the entity in `unresolved`. Record this as `loomweave_stale_sei` resolution. +2. **SEI absent / loomweave unavailable / SEI did not resolve / SEI drifted** → fall back: `locator_to_qualname(locator)` → canonicalize → match in `qualname_index` → file; entity → `fell_back`. +3. **Neither path yields a file** → entity → `unresolved` (contributes no file; trips fail-closed only when the WHOLE set is unresolved, per Phase 5). + +**Caller-closure expansion (load-bearing — §5.3a).** Warpline's worklist is a "changed + downstream (callee)" set, but taint findings anchor **caller-side**. After resolving the affected entities to a base file set, **expand once over the reverse call graph**: reuse `core.delta.get_affected_entities(changed_files=, entities=last_context.entities, project_edges=last_context.project_edges)` to pull in the **callers** of the affected entities, and add their files to `ResolvedScope.files`. This requires `last_context` (the analyzer context) — so the expansion runs as a second pass in `run_scan` (Phase 3) after a first cheap pass produces `project_edges`, OR `build_qualname_index` is extended to also return the structural call edges. Decision: build the edges in `build_qualname_index`'s structural pass (it already walks every file's AST) and expose them so the closure runs without a taint analysis. `affected_qualnames` for the filter is the **base** affected set (NOT the caller-expanded set) — the filter still only displays findings on requested entities, but the *analyzed file set* is caller-expanded so those findings are computed correctly. + +**Qualname canonicalization (FN guard).** `Finding.qualname` carries property-accessor suffixes `:setter`/`:deleter` (see `finding.py` `_PROPERTY_ACCESSOR_QUALNAME_SUFFIXES`, normalized away only at the Filigree wire via `_to_wire_qualname`, NOT in the raw `Finding`) plus full class/method nesting. A locator `python:function:pkg.mod.Cls.prop` will NOT string-equal a finding qualname `pkg.mod.Cls.prop:setter`. **Both the index keys and the affected_qualnames set must be canonicalized through a single suffix-stripping helper** (reuse/lift `_to_wire_qualname`'s suffix logic) before membership tests, AND a worklist entry for a class scopes-in all its methods (a `python:class:` locator matches any finding qualname under that class prefix). This applies in `build_qualname_index`, in `resolve_affected_scope`, and in `filter_to_affected` (Phase 4). + +**`locator_to_qualname` method bug (must fix in `sei_resolution.py`).** The current prefix list is `("python:function:", "python:class:", "python:")`. A `python:method:pkg.mod.Cls.m` locator (loomweave emits `kind="method"` for class members — confirmed `index.py:95`) hits the `python:` catch-all and returns `method:pkg.mod.Cls.m`, which never matches the index. **Add `"python:method:"` to the prefix list before the generic `python:` catch-all.** Phase 2 tests must cover a `python:method:` locator. + +`build_qualname_index` uses `index.discover_file_entities(ast.parse(src), module=module_dotted_name(rel), path=rel)` over the **already-discovered** files — discovery still walks the whole tree (§5.3), so the index is complete and the loomweave path is optional. **Parse-error handling:** wrap `ast.parse(path.read_bytes())` in `try/except (SyntaxError, OSError)` and `continue` on failure (a parse-error file contributes no entries and does NOT raise out of `run_scan`). Return type is `dict[str, str]`: key = canonical bare dotted qualname (matching `Entity.qualname` post-canonicalization), value = repo-relative POSIX path (matching `Entity.location.path`). Use `cast`/`match` to narrow `ast.parse` to `ast.Module` for `mypy --strict`. + +**Test:** `tests/unit/core/test_delta_resolve.py` — SEI path (fake `SeiResolver`/client double, mirroring `test_sei_oracle.py` doubles) returns a file; **`python:method:` locator** resolves correctly; **property setter/deleter finding vs base-name locator**, **method finding vs class-level locator**, **nested-class qualname** all match under canonicalization; qualname fallback (no resolver, or resolver unsupported) returns a file; **partial resolution** — two entities, one resolvable + one absent → `len(resolved.files) == 1`, `len(unresolved) == 1`, the unresolved locator appears in `unresolved`, and the result is NOT full-fallback; **SEI-drift** — SEI resolves to a qualname absent from the index → falls through to the locator path (or `unresolved`), recorded as `loomweave_stale_sei`; unresolved entity lands in `unresolved`; `loomweave_used` reflects whether the SEI path fired; `build_qualname_index` maps a known qualname to its file on a sample tree and **skips a `SyntaxError` file without raising**; **caller-closure** — a source entity in `b.py` whose caller (the sink) is in `a.py`: a worklist naming the `b.py` entity pulls `a.py` into `ResolvedScope.files` via the reverse-edge closure. + +**Verify:** `uv run pytest tests/unit/core/test_delta_resolve.py -q` + +--- + +## Phase 3 — scan scoping in `run_scan` (§5.3, §5.4) + +**Edit:** `src/wardline/core/run.py`. + +Add parameters (keyword-only, defaults preserve INV-1). **The SEI resolver is INJECTED, not self-constructed** — `run_scan` stays network-free and matches the existing caller-constructs-and-injects pattern (CLI scan.py and MCP server.py already build/inject the loomweave client; `run_scan` must not construct a second one inline): + +```python +def run_scan( + root: Path, + *, + ..., + affected: AffectedScope | None = None, # NEW + sei_resolver: SeiResolver | None = None, # NEW — injected by the caller (CLI/MCP), never built here + ... +) -> ScanResult: ... +``` + +Each caller builds the resolver if a loomweave URL is available (the CLI already resolves `loomweave_url` before calling `run_scan`; the MCP `_scan` already has the injected `loomweave` client) and passes it down. Any loomweave error at the call site → `sei_resolver=None` (fail-soft, recorded as "loomweave unavailable"). + +Wiring, **between** `files = discover(...)` and `analyzer.analyze(...)`: + +1. **If `affected is None` → unchanged path (INV-1). Short-circuit BEFORE any new work:** `build_qualname_index` and `resolve_loomweave_url`/resolver use are NOT reached; the `scope` block is absent. (INV-1 test asserts via spy that these are not called when `affected is None`, so a future refactor can't make the full-scan path pay delta cost.) +2. **Mutual exclusion:** if `affected is not None and new_since is not None` → raise `ScopeParseError` (`--affected` and `--new-since` scope different things via different mechanisms; composing them is rejected loudly, never silently double-scoped). The CLI (Phase 7) and MCP (Phase 8) also reject the pair at their layer. +3. Else build `index = build_qualname_index(files, root)`; `resolved = resolve_affected_scope(affected, index=index, sei_resolver=sei_resolver)` (resolution + caller-closure expansion happen inside, per Phase 2). +4. **Fail-closed (INV-3, §5.4):** if `resolved.files` is empty → run the **full** analysis over all `files` (`scope_mode = "full-fallback"`); else analyze only `scoped_files = [p for p in files if relpath(p) in resolved.files]` (`scope_mode = "delta"`). Each scoped file is analyzed **in full** (whole-module context preserved — no intra-file soundness loss; the inter-file gap is declared, §5.3a). +5. After `apply_suppressions`, apply the **finding filter** (Phase 4) to `findings` (the EMITTED set) when `scope_mode == "delta"`. **Do NOT filter `gate_findings`** (INV-4 / THREAT-001): the gate evaluates the full unsuppressed population so an attacker-influenceable scope cannot forge a green. The `None` sentinel on `gate_findings` is preserved (never coerced to `[]`). +6. **`files_scanned` / discovery semantics:** keep `ScanResult.files_scanned = len(files)` as the **discovery** count (its existing meaning), and update the MCP `_SCAN_OUTPUT_SCHEMA` description (Phase 8) to read *"Number of files discovered (see scope.files_analyzed for the delta-mode analyzed count)."* The scope block carries both `files_discovered` (== `len(files)`) and `files_analyzed` (== `len(scoped_files)`; equal only in full-fallback), so no top-level field silently lies. +7. **Progress callback:** when `scope_mode == "delta"`, emit `{"files_discovered": len(files), "files_analyzed": len(scoped_files)}` (not `len(files)` for both) so a progress subscriber sees the real analyzed count. +8. Build the `scope` block (Phase 5) and attach it to `ScanResult`. + +Add to `ScanResult` (default `None` ⇒ full scans serialize no scope block; `run_scan` constructs `ScanResult` by keyword so field order is call-safe — but **grep `tests/` for positional `ScanResult(` construction first** and fix any in this same commit): + +```python +@dataclass(frozen=True, slots=True) +class ScanResult: + ... + scope: DeltaScopeReport | None = None +``` + +**Test:** covered by Phase 6 hermetic golden + a focused `tests/unit/core/test_run_affected.py` (scoped-file subset reaches the analyzer; full path when `affected is None`; **`affected` + `new_since` together → `ScopeParseError`**; **delta-mode `gate_findings` retains an out-of-scope finding** — i.e. the gate population is NOT narrowed; INV-1 spy asserts `build_qualname_index` is not called when `affected is None`). + +**Verify:** `uv run pytest tests/unit/core/test_run_affected.py -q` + +--- + +## Phase 4 — finding filter (§5.3) + +**New function in** `src/wardline/core/delta_resolve.py` (keeps it pure/testable): + +```python +def filter_to_affected( + findings: list[Finding], + affected_qualnames: frozenset[str], + affected_files: frozenset[str], +) -> list[Finding]: ... +``` + +Keeps a finding iff `canonical(finding.qualname) in affected_qualnames` (or the finding's class matches a class-level affected locator — see Phase 2 canonicalization) **or** (`finding.qualname is None` and `finding.location.path in affected_files`) — so an anchored finding is kept by canonical qualname (matching `:setter`/`:deleter` and nested-class shapes); a file-level engine FACT (`WLN-ENGINE-*`, qualname `None`) on an analyzed affected file is kept as context. Findings on **other** entities in the same analyzed file are dropped from the *displayed* output (context, per §5.3). **Does not re-mint fingerprints (INV-2).** + +**Apply ONLY to the emitted/displayed `findings`. Do NOT apply to `gate_findings`** (INV-4 / THREAT-001 — see Phase 3 step 5). The gate population must remain the full unsuppressed set so an attacker-influenceable scope can never produce an authoritative green. The `gate_findings is None` sentinel (secure-default / `--trust-suppressions`) is load-bearing security logic and is left untouched by this filter — `filter_to_affected` is never called with `None`. + +**Test:** `tests/unit/core/test_delta_finding_filter.py` — a finding on an affected qualname is kept; a finding on a co-located non-affected qualname is dropped; **a `:setter`/`:deleter` finding whose base name is the affected locator is kept; a method finding under a class-level locator is kept; a nested-class qualname matches**; a qualname-`None` engine FACT on an affected file is kept; fingerprints of kept findings are unchanged vs input. Plus a `test_run_affected.py` assertion that **`result.gate_findings` after a delta scan still contains an out-of-scope ERROR finding** (gate population NOT narrowed) and the `gate_decision` verdict over it equals the full scan's. + +**Verify:** `uv run pytest tests/unit/core/test_delta_finding_filter.py -q` + +--- + +## Phase 5 — the `scope` block + fail-closed full-fallback (§5.4) + +**New in** `src/wardline/core/delta_scope.py`: + +```python +BOUNDARY_CAVEAT = ( + "Delta scan analyzes only files containing the affected entities. Findings here " + "may be incomplete OR absent: cross-file taint whose source lies outside the " + "analyzed set is not computed, so an in-scope entity can read clean without being " + "clean. Advisory inner-loop signal, not a verdict — the full scan is the gate of record." +) + +@dataclass(frozen=True, slots=True) +class DeltaScopeReport: + mode: str # "delta" | "full-fallback" + gate_authority: str # "advisory" (delta) | "gate-of-record" (full-fallback) — machine-readable + entities_requested: int + files_discovered: int # == ScanResult.files_scanned + files_analyzed: int # scoped subset; == files_discovered only in full-fallback + in_scope_findings: int + fell_back_count: int # entities resolved via the spoofable qualname locator path, not SEI + stale_sei_count: int # entities whose SEI resolved to a now-absent qualname (loomweave stale) + unresolved_entities: tuple[dict[str, str | None], ...] # [{locator, sei}, ...] + loomweave_used: bool + boundary_caveat: str = BOUNDARY_CAVEAT + + def to_dict(self) -> dict[str, object]: ... # dict[str, object] (not Any) for mypy --strict +``` + +Built in `run_scan` from the `ResolvedScope` + post-filter counts. `mode == "full-fallback"` (and `gate_authority == "gate-of-record"`) when `resolved.files` was empty (empty scope, all-unresolvable, or loomweave-absent-and-qualname-miss); otherwise `mode == "delta"`, `gate_authority == "advisory"`. `unresolved_entities` lists every entity that did not resolve even in `delta` mode (§7 "Some entities unresolved → scan the resolved subset; list unresolved"); `fell_back_count`/`stale_sei_count` surface how much of the scope rests on the spoofable/stale path so a consumer can judge trust without treating fell-back entities as SEI-equivalent. `to_dict` returns `dict[str, object]` (the inner `unresolved_entities` dicts are `dict[str, str | None]`, not assignable to `dict[str, Any]` under strict). + +**Test:** `tests/unit/core/test_delta_scope_report.py` — `to_dict` keys/shape incl. `gate_authority`, `files_discovered`, `fell_back_count`, `stale_sei_count`; `boundary_caveat` is the fixed (stronger) string and names the in-scope-correctness hazard; full-fallback mode → `gate_authority == "gate-of-record"`; delta mode → `gate_authority == "advisory"`. + +**Verify:** `uv run pytest tests/unit/core/test_delta_scope_report.py -q` + +--- + +## Phase 6 — hermetic golden (§8, every PR, no sibling) + +**New file:** `tests/conformance/test_warpline_delta_scope.py` + fixtures under `tests/conformance/fixtures/warpline_delta/` (a small sample tree + a fixed `warpline.reverify_worklist.v1` JSON fixture). Mirror the faithful-vendor / golden conventions of `test_legis_intake_contract.py` and `test_sei_oracle.py` (no import from warpline; the worklist shape is vendored from §9 of the spec). + +Assertions (the spec's three golden axes plus the two honesty paths): +1. **Scoped file set** — for a worklist naming entity X in `a.py`, only `a.py` is analyzed (assert via `result.scope.files_analyzed` and that a known finding in `b.py` is *absent*). +2. **Filtered finding set** — a finding on a co-located non-affected entity in `a.py` is filtered out; the affected entity's finding is present. +3. **`scope` block** — `mode="delta"`, `entities_requested`, `files_analyzed`, `in_scope_findings`, `boundary_caveat` exact string. +4. **Fallback path** — an all-unresolvable worklist → `mode="full-fallback"`, `gate_authority="gate-of-record"`, all files analyzed. +5. **Unresolved-entity path** — a worklist with one resolvable + one bogus entity → `delta` mode, `unresolved_entities` lists the bogus one, `len(resolved.files) >= 1` (NOT fallback). +6. **Caller-closure / inter-file taint axis** — a tainted source in `b.py` feeds a sink in affected `a.py` (the worklist names `b.py`'s entity, the changed callee). Assert `a.py` is pulled into the analyzed set via the reverse-edge closure so the sink finding IS computed — and pin the behavior (this documents the inter-file gap as covered, not latent). A companion negative case: with caller-closure DISABLED the finding would be missing — assert the closure is what saves it. +7. **gate-not-narrowed axis** — a finding on a co-located non-affected entity is absent from displayed `findings` but PRESENT in `gate_findings` (mirrors INV-4 at the golden level). + +No loomweave needed: SEI-less entities exercise the qualname fallback; the SEI path is covered by the Phase 2 unit double and the Phase 11 live oracle. + +**Verify:** `uv run pytest tests/conformance/test_warpline_delta_scope.py -q` + +--- + +## Phase 7 — CLI `--affected` with stdin (§6) + +**Edit:** `src/wardline/cli/scan.py`. + +Add option using `click.File('r')` so `-` is handled natively by Click and is testable through `CliRunner.invoke(..., input=...)` (reading `sys.stdin` directly does NOT work under `CliRunner` — it patches Click's stdin, not `sys.stdin`): + +```python +@click.option( + "--affected", + "affected_file", + type=click.File("r"), + default=None, + help="Scan only entities in this warpline reverify-worklist / entity-list " + "(file path, or '-' for stdin). Speed, not truth: out-of-scope cross-file " + "flows are not analyzed (see the scope block). Empty/unresolvable → full scan. " + "Mutually exclusive with --since.", +) +``` + +In the handler: +- If `affected_file` is set, read `payload_text = affected_file.read()` and `affected = parse_affected_scope(json.loads(payload_text))` inside the existing `try` so `ScopeParseError`/`json.JSONDecodeError` → the existing `SystemExit(2)` path (§7 malformed → exit 2). (Do NOT use `sys.stdin.read()`; `click.File('-')` already resolved the stream.) +- **Mutual exclusion:** if both `--affected` and `--since` (Phase 12) are supplied → loud error (exit 2). +- **Build and inject the SEI resolver** (Phase 3 injection contract): if a loomweave URL resolves, construct it here (the CLI already resolves `loomweave_url`) and pass `sei_resolver=...` into `run_scan`; else `None`. Pass `affected=affected`. +- **Filigree emit guard (INV-5 / mark_unseen):** when `result.scope is not None and result.scope.mode == "delta"`, invoke the `FiligreeEmitter` with `mark_unseen=False` (a delta scan emits the FULL discovery list as `scanned_paths` but a FILTERED `findings` list — auto-`mark_unseen` would read out-of-scope findings as fixed and close their issues). Full-fallback scans reconcile normally. +- When `result.scope is not None`, echo a one-line scope summary to stderr (mode, gate_authority, files_analyzed/files_discovered, in_scope_findings, unresolved count) and include the block in every `--format` output: `scope` block in `agent-summary` JSON; SARIF run-level `properties` (see SARIF interface below); jsonl unaffected (findings only) but the stderr line still prints. Full-scan path (no `--affected`) prints nothing new (INV-1). + +**SARIF interface (must be spelled out, not invented at implementation time):** `build_sarif` and `SarifSink.write` currently take `(findings, context=None)` with no run-properties channel. Add `run_properties: dict[str, object] | None = None` to `build_sarif` and thread it into `runs[0].properties`; `SarifSink.write` gains the same optional param and forwards it. The CLI passes `run_properties={"wardline_delta_scope": result.scope.to_dict()}` when `result.scope is not None`. Keep the param optional/defaulted so the existing dogfood self-scan and other `SarifSink` callers are unaffected. + +**Test:** `tests/unit/cli/test_scan_affected_cli.py` — mirror `tests/unit/cli/test_scan_rust.py` (`CliRunner` + `from wardline.cli.scan import scan`). Cases: `--affected ` scopes; **`--affected -` via `CliRunner(mix_stderr=False).invoke(scan, [..., "--affected", "-"], input=json.dumps([...]))`** — a valid entity list → `mode="delta"` + non-null `scope.files_analyzed`; **empty `--affected -` (`input="[]"`) → `mode="full-fallback"`**; malformed payload → exit code 2; `--affected` + `--since` together → exit 2; **`--format sarif` output contains `runs[0].properties.wardline_delta_scope`**; **a delta CLI emit does NOT trigger `mark_unseen`** (assert the emitter body is built with `mark_unseen=False` — mock the emitter or assert on the request body). + +**Verify:** `uv run pytest tests/unit/cli/test_scan_affected_cli.py -q` and manual: `printf '[]' | uv run wardline scan src --affected -` + +--- + +## Phase 8 — MCP `scan` gains `affected` (§6) + +**Edit:** `src/wardline/mcp/server.py`. + +1. `_scan` handler (~line 805, beside `new_since = args.get("new_since")`): read `affected_arg = args.get("affected")`. Accept (a) an object/array (worklist/entity-list inline) → `parse_affected_scope(affected_arg)`; (b) a string path under root → `load_affected_scope(_resolve_under_root(root, affected_arg))`. `ScopeParseError` → `ToolError(str(exc))` (isError result, matching the §7 loud-malformed posture). **Reject `affected` + `new_since` together with a `ToolError`** (mutual exclusion, matching the CLI). **Build/pass `sei_resolver`** from the already-injected `loomweave` client (Phase 3 injection contract). Pass `affected=...` into `run_scan`. The inline form bypasses `_resolve_under_root` confinement (it is the MCP-primary ergonomic) — that is acceptable because INV-4 makes the scope's trust level moot for the gate; record `source_kind` + `item_count` in the scope echo so the inline provenance is at least logged. +2. `_SCAN_TOOL` inputSchema (~line 1832, next to `new_since`): add + +```python +"affected": { + "type": ["object", "array", "string"], + "description": "Scan only entities in this warpline reverify-worklist (warpline." + "reverify_worklist.v1) or bare entity list, or a path to one. Speed, not truth: " + "cross-file flows outside the affected set are not analyzed (see scope block). " + "Empty/unresolvable input falls back to a full scan.", +}, +``` +3. `_SCAN_OUTPUT_SCHEMA`: add an optional `scope` object property (mode/`gate_authority`/entities_requested/`files_discovered`/files_analyzed/in_scope_findings/`fell_back_count`/`stale_sei_count`/unresolved_entities/loomweave_used/boundary_caveat) so the structured-content emission stays schema-valid (pinned by `tests/conformance/test_mcp_structured_output.py`). Emit `result.scope.to_dict()` when present. **Also update the `files_scanned` property description** to "Number of files discovered (see scope.files_analyzed for the delta-mode analyzed count)" so the schema does not lie in delta mode (per Phase 3 step 6). + +**Test:** extend `tests/unit/mcp/test_*scan*.py` (or a new `tests/unit/mcp/test_scan_affected_mcp.py`) — inline array `affected` scopes; path-string `affected`; malformed inline → `ToolError`/isError; `affected` + `new_since` → `ToolError`; **the returned `structuredContent` with `affected` supplied and `scope` non-null VALIDATES against `_SCAN_OUTPUT_SCHEMA`** (this is the only end-to-end pin of the scope shape; an optional property stays green even when malformed unless a delta invocation actually exercises it). Add a delta invocation to `tests/conformance/test_mcp_structured_output.py` (or assert the scope-present validation in the new MCP test) so the scope shape is pinned with `scope` present, not just absent. Re-run `tests/conformance/test_mcp_structured_output.py` (`EXPECTED_TOOLS`/tool-count unchanged — `affected` is a new param on an existing tool, not a new tool). + +**Verify:** `uv run pytest tests/unit/mcp/ tests/conformance/test_mcp_structured_output.py -q` + +--- + +## Phase 9 — invariant tests (INV-1/2/3) + +**New file:** `tests/unit/core/test_affected_invariants.py`. + +- **INV-1:** scan a fixture tree with `run_scan(affected=None)` vs the pre-change behavior — assert findings, `ScanSummary`, `gate_findings`, `scanned_paths` identical (frozen expected set). Plus a spy assertion that `build_qualname_index` and the loomweave-resolver path are NOT invoked when `affected is None` (the full-scan path pays no delta cost / probes no loomweave). +- **INV-2:** for an entity scoped by `--affected`, assert each kept finding's `fingerprint` equals the same finding's fingerprint from a full scan of the same tree (filter drops, never re-mints). +- **INV-3:** `affected` with all-unresolvable entities → `result.scope.mode == "full-fallback"`, `gate_authority == "gate-of-record"`, and the finding set equals the full scan's. +- **INV-4 (THREAT-001 — the load-bearing security test):** a fixture tree with a known ERROR sink in `evil.py`. A worklist that resolves >0 files but **surgically excludes `evil.py`** → `scope.mode == "delta"` (NOT full-fallback — fail-closed does not catch this), the displayed `findings` omit the `evil.py` finding, BUT `gate_decision(result, fail_on=ERROR)` returns `verdict == "FAILED"` / `tripped is True` / `exit_class == 1` — **identical to the full scan's verdict**. Assert the delta gate cannot green a real ERROR. +- **INV-5 (mark_unseen):** a delta scan's CLI emit path builds the Filigree request body with `mark_unseen=False` (assert via the body builder / a mocked emitter), so out-of-scope findings are never read as fixed. + +**Verify:** `uv run pytest tests/unit/core/test_affected_invariants.py -q` + +--- + +## Phase 10 — wire the `warpline_e2e` marker (pyproject + `_live_oracle`) + +1. **`src/wardline/_live_oracle.py`:** add `"warpline_e2e"` to `LIVE_ORACLE_MARKERS` (so the conftest SKIP→FAIL hook covers it, exactly like `loomweave_e2e`/`legis_e2e`/`filigree_e2e`; **not** like `rust_e2e`, which is intentionally excluded). +2. **`pyproject.toml` `[tool.pytest.ini_options]`:** + - `addopts`: append ` and not warpline_e2e` to the deselection string (currently `-m 'not network and not loomweave_e2e and not legis_e2e and not filigree_e2e and not rust_e2e and not loomweave_drift'`). + - `markers`: add `"warpline_e2e: live \`warpline reverify | wardline scan --affected -\` round-trip (delta scope; weekly/manual)"`. + +**Test:** `tests/unit/test_live_oracle.py` (extend if present, else add) — `warpline_e2e` is in `LIVE_ORACLE_MARKERS`; **`rust_e2e` is still ABSENT** from `LIVE_ORACLE_MARKERS` (guards an accidental copy-paste); `should_fail_live_oracle_skip(["warpline_e2e"], "skipped")` is True iff the env var is set; a `rust_e2e`-only skip is NOT failed even when `WARDLINE_LIVE_ORACLE_REQUIRED=1`. Plus a meta-assert that the marker is registered (no `PytestUnknownMarkWarning`). + +**Verify:** `uv run pytest -q` (full suite still green; new marker deselected by default) and `WARDLINE_LIVE_ORACLE_REQUIRED=1 uv run pytest -m warpline_e2e -q` (skips clean when no `warpline` binary → **fails** because the env var is set, proving the fail-closed wiring). + +--- + +## Phase 11 — the `warpline_e2e` live oracle + CI matrix + +1. **New file:** `tests/conformance/test_warpline_e2e.py`, marked `@pytest.mark.warpline_e2e`, gated on a `warpline` binary (`WARDLINE_WARPLINE_BIN` override, skip clean otherwise — same auto-skip shape as `loomweave_e2e`/`legis_e2e`). It runs the real round-trip: `warpline reverify` → pipe its `reverify_worklist.v1` into `wardline scan --affected -`, assert exit code and a `scope` block with `mode in {"delta","full-fallback"}`, and that the analyzed file set is a subset of discovery. +2. **`.github/workflows/ci.yml`:** add to the `live-oracles` matrix `include:` (after `filigree_e2e`): + ```yaml + - name: Warpline + marker: warpline_e2e + ``` + Add `WARDLINE_WARPLINE_BIN: ${{ secrets.WARDLINE_WARPLINE_BIN }}` to the job `env:` alongside the existing `WARDLINE_LOOMWEAVE_BIN`/`WARDLINE_LEGIS_URL`/`WARDLINE_FILIGREE_URL`. The job already passes `WARDLINE_LIVE_ORACLE_REQUIRED: "1"` (fail-closed) and runs only on `schedule`/`workflow_dispatch`. +3. **Extend the CI meta-guard `tests/unit/test_ci_live_oracles.py` (MUST — it will go RED otherwise).** `test_ci_exposes_scheduled_and_manual_live_oracles` asserts an EXACT env-var set `{WARDLINE_OPENROUTER_API_KEY, WARDLINE_LOOMWEAVE_BIN, WARDLINE_LEGIS_URL, WARDLINE_FILIGREE_URL}` and an EXACT marker set `{loomweave_e2e, legis_e2e, filigree_e2e}` against `ci.yml`'s text. Adding the warpline matrix row + `WARDLINE_WARPLINE_BIN` env makes the CI text contain new entries, but this meta-test pins the *current* set; once Phase 11 lands the file must be updated to include `WARDLINE_WARPLINE_BIN` in the env-var loop and `warpline_e2e` in the marker loop, AND assert both `'warpline_e2e' in workflow` and `'WARDLINE_WARPLINE_BIN' in workflow`. Without this, the new oracle can be omitted/misconfigured in CI while the default suite stays green (the fail-closed wiring would never be validated until a scheduled run). + +**Verify (local, optional):** `WARDLINE_LIVE_ORACLE_REQUIRED=1 WARDLINE_WARPLINE_BIN= uv run pytest -m warpline_e2e -q`. CI: confirm the matrix row renders in a `workflow_dispatch` run. Run `uv run pytest tests/unit/test_ci_live_oracles.py -q` to confirm the meta-guard is green AFTER the edit (it goes red before). + +--- + +## Phase 12 — deferred-optional `--since ` (§6, may slip to a later increment) + +Built **on top of** `--affected`, not on the `new_since` gate path. **New function** `delta_scope.affected_scope_from_gitref(ref, root) -> AffectedScope` (reuse `core/delta.get_changed_files_since`, then map changed files → entity locators via the same qualname index) → feeds the identical `run_scan(affected=...)` path. CLI `--since` is mutually exclusive with `--affected`. Marked **optional**; ship Phases 1–11 first. If cut, it carries forward as a labelled follow-up — not deferred *defect* work. + +**Test:** `tests/unit/core/test_delta_since.py` (only if Phase 12 lands). + +**Verify:** `uv run pytest tests/unit/core/test_delta_since.py -q` + +--- + +## Definition of done + +- [ ] `delta_scope.py` parses three distinct shapes (full envelope / bare-data / bare-list); malformed → `ScopeParseError`; over-cap → `ScopeParseError`; empty → non-error (Phase 1 tests green). +- [ ] `delta_resolve.py` resolves SEI (loomweave) and qualname-fallback with **canonicalized** qualnames (`:setter`/`:deleter`/method/nested-class); `python:method:` locators resolve; **SEI-drift → locator fallback**; **caller-closure expansion** pulls in callers; `build_qualname_index` is taint-free + skips `SyntaxError` files (Phase 2 tests green). +- [ ] `locator_to_qualname` gains `python:method:` prefix (bug fix). +- [ ] `run_scan(affected=..., sei_resolver=...)` scopes analysis to affected-entity files (caller-expanded); SEI resolver is **injected, not self-constructed**; `--affected`+`--new-since` → `ScopeParseError`; full files analyzed whole-module (Phase 3 + golden). +- [ ] Finding filter applies to `findings` only, keeps in-scope + qualname-`None` engine facts, drops co-located others, no re-mint; **`gate_findings` NOT narrowed; `None` sentinel preserved** (Phase 4 + INV-2 + INV-4). +- [ ] **INV-4 (THREAT-001):** a surgical-exclusion worklist over a known-ERROR file CANNOT produce a PASSED severity verdict — delta gate == full gate. **INV-5 (mark_unseen):** delta CLI emit forces `mark_unseen=False`. +- [ ] Fail-closed full-fallback on empty/all-unresolvable; `scope` block with the **stronger** `boundary_caveat` + `gate_authority`/`files_discovered`/`fell_back_count`/`stale_sei_count`; mode/counts/unresolved correct (Phase 5 + golden). +- [ ] Hermetic golden green every PR, no sibling — incl. **inter-file caller-closure axis** and **gate-not-narrowed axis** (Phase 6). +- [ ] CLI `--affected ` via `click.File` (CliRunner `input=` testable); malformed → exit 2; `--affected`+`--since` → exit 2; scope in human/json/**sarif (`run_properties` channel)**; delta emit `mark_unseen=False` (Phase 7). +- [ ] MCP `scan` `affected` param (object/array/path); `affected`+`new_since` → `ToolError`; `scope` in structured content **validated with scope PRESENT**; `_SCAN_OUTPUT_SCHEMA` + `files_scanned` description updated; `test_mcp_structured_output.py` green (Phase 8). +- [ ] **INV-1** full-scan path byte-identical (+ spy: no delta work / no loomweave probe when `affected is None`); **INV-2** fingerprints stable; **INV-3** fail-closed; **INV-4** gate unforgeable; **INV-5** no false mark_unseen (Phase 9). +- [ ] CI meta-guard `test_ci_live_oracles.py` extended for `WARDLINE_WARPLINE_BIN` + `warpline_e2e` (Phase 11). +- [ ] `warpline_e2e` in `LIVE_ORACLE_MARKERS` + `pyproject` `addopts`/`markers`; fail-closed verified (Phase 10). +- [ ] `warpline_e2e` live oracle + `ci.yml` `live-oracles` matrix row + `WARDLINE_WARPLINE_BIN` env, wired exactly like loomweave/legis/filigree (Phase 11). +- [ ] `ruff` + `mypy --strict` clean; base package still zero-dep (no new runtime import of blake3/extras in `delta_scope.py`/`delta_resolve.py`). +- [ ] Full suite green by default: `uv run pytest -q`. +- [ ] CHANGELOG `[Unreleased] Added` notes `wardline scan --affected` + MCP `affected` + `warpline_e2e`. + +## Out of scope — deferred to spec A (`wardline-c0563eee74`) + +- **Published, drift-checked contract.** Emitting a `wardline.delta_scope.v1` provenance block and pinning it in CI against warpline's *published* `warpline.reverify_worklist.v1` artifact (a golden vector vs the producer's live artifact). This plan vendors the worklist shape hermetically from spec §9; it does **not** consume a published artifact or drift-check against the producer. +- **Fixing the stale-vendored-golden gap** (`test_sei_oracle.py::test_vendored_oracle_matches_loomweave_source` skipping unless `LOOMWEAVE_REPO` is set). That is A's remit. +- **Any wardline→warpline outbound transport.** Spec §3 non-goal: wardline never calls warpline (HTTP/MCP/CLI). The scope set is producer-agnostic input only. +- **`--since` git-driven convenience** is *optional within this plan* (Phase 12), not part of A; if cut it carries forward under the `warpline-delta-2026-06-18` label. diff --git a/docs/superpowers/specs/2026-06-18-warpline-delta-scan-design.md b/docs/superpowers/specs/2026-06-18-warpline-delta-scan-design.md new file mode 100644 index 00000000..5aae7a3a --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-warpline-delta-scan-design.md @@ -0,0 +1,234 @@ +# Warpline delta scan — design spec + +- **Date:** 2026-06-18 +- **Status:** Approved (brainstorm), pre-plan +- **Tracker:** `wardline-4e08ab9ce6` (B, this spec) · `wardline-c0563eee74` (A, follow-on) +- **Baseline commit:** `4a24a032` +- **Label:** `warpline-delta-2026-06-18` + +## 1. Background & topology correction + +This spec emerged from a 2026-06-18 review (CI comparison vs `~/elspeth` → +federation-seam review → the warpline node) and the "next evolution" brainstorm. + +**Warpline is the Weft federation's change-impact authority and is +advisory-only.** It does **not** ingest wardline findings. The data flow is: + +``` +wardline scan → findings → filigree (issues bound to loomweave SEIs) +warpline reads SEI + filigree → computes "changed + downstream" (call-graph + closure, keyed by loomweave SEI) → reverify worklist → filigree + warpline_worklist_ingest +``` + +So the in-grain wardline↔warpline edge runs the **opposite** direction from +"findings out": warpline says **what to re-verify**, and wardline is the +trust-gate that re-verifies it. Building "wardline findings → warpline worklist" +would be against the design. + +## 2. Goal + +Give wardline a **producer-agnostic "scan only these entities" capability** for +fast agent inner-loop feedback. Warpline's reverify worklist is the primary +producer, but **wardline never talks to warpline** — it consumes a scope set +that any producer (warpline, or an agent by hand) can supply. + +The full scan remains the **gate of record** (CI). Delta scan is **speed, never +truth**: it never claims completeness, so there is no soundness regression to +engineer around. + +## 3. Non-goals + +- Delta is **not** a gate of record. CI keeps running the full scan. +- **No new sibling client.** wardline does not call warpline (HTTP/MCP/CLI). +- **No soundness claim** about cross-file taint flows outside the scope set — + those are *declared out* in the result, never silently dropped. +- No change to the existing full-scan path's findings or fingerprints. + +## 4. Decisions locked in the brainstorm + +| Decision | Choice | Rationale | +|---|---|---| +| Edge | Delta scan from impact | The in-grain composition; warpline=impact, wardline=trust gate | +| Boundary | Producer-agnostic scope input | Decoupled, hermetically testable, no new client; the scope-set is the contract A pins | +| Authority | Advisory inner-loop | Full scan stays gate of record → no soundness regression | + +## 5. Architecture + +All new code lives under `src/wardline/core/`. The full-scan path is unchanged; +delta is a **scoping pre-filter on discovery** plus a **post-filter on +findings**. + +### 5.1 Scope input — `delta_scope.py` + +A new module that parses an **affected-entity scope** from one of two accepted +shapes: + +1. A `warpline.reverify_worklist.v1` envelope (success envelope or its bare + `data` payload), reading `items[].entity.{sei, locator}`. +2. A bare entity list: `[{ "sei"?: str, "locator"?: str }, ...]`. + +Output: a normalized `AffectedScope` value — a frozen set of +`(sei | None, locator | None)` entries plus provenance (source kind, item +count). Empty or malformed input is **not** an error here; it is reported to the +caller, which applies the fail-closed rule (§5.4). + +### 5.2 Entity → file resolution + +Each affected entity must resolve to a file the scanner can analyze: + +1. **SEI present** → resolve via loomweave (`SeiResolver`, already wired in + `src/wardline/loomweave/`) to a file path. Authoritative. +2. **SEI absent or loomweave unavailable** → fall back to wardline's own + parse-time **qualname index**: discovery already walks the tree and the + engine resolves qualnames; we build a `qualname → file` map cheaply and match + `locator` against it. +3. **Unresolvable entity** → contributes no file **and** trips the fail-closed + rule (§5.4). Resolution outcomes (resolved / fell-back / unresolved) are + recorded for the scope block. + +### 5.3 Scan scoping & finding filter + +- **Discovery still walks the whole tree** (cheap — a filesystem walk), so the + `qualname → file` index is complete. +- The engine **only analyzes files that contain an affected entity.** Each such + file is analyzed **in full** — whole-module context is preserved, so there is + no *intra-file* soundness loss. **There IS inter-file soundness loss** (see + §5.3a) and the design does not claim otherwise. +- After analysis, the **finding filter** keeps only findings whose + `qualname`/`location` is in the affected set, **for the EMITTED/displayed + findings only** (`findings`). The **gate population (`gate_findings`) is NEVER + narrowed** in delta mode (INV-4 / §5.4). Findings on other entities in the + same analyzed file are *context* in the displayed output, not silently dropped + from the gate. + +### 5.3a Soundness: the inter-file taint gap (honest accounting) + +A taint **defect anchors at the SINK entity**, not the source. Because only +affected-entity files reach `analyzer.analyze`, cross-file taint that flows from +an out-of-scope **source** file into an in-scope **sink** is not computed: the +real finding can be **silently dropped (false negative)**, and an in-scope sink +whose source moved out of scope can read **clean**. This is the *dominant* +cross-function taint case, so the design must NOT claim "no soundness loss" — the +loss is inter-file and real. Two consequences for the plan: + +1. **Closure-direction reconciliation.** Warpline computes "changed + DOWNSTREAM + (callees)", but taint findings live **caller-side**; wardline's own + `--new-since` path deliberately closes over **callers** + (`core/delta.get_affected_entities`, reverse callee→caller BFS). Feeding + warpline's downstream worklist straight into a caller-side analysis can + scope-OUT the caller that actually carries the finding. The plan must, after + resolving affected entities to files, **expand the analyzed file set once via + the reverse-edge caller closure** (`get_affected_entities` over + `last_context.project_edges`) so sinks downstream of a changed source are + pulled in. This is a load-bearing correctness precondition, not a footnote. +2. **The boundary_caveat (above) states the stronger in-scope-correctness truth** + so an agent does not over-trust "no findings on my change." + +### 5.4 Fail-closed + honesty (load-bearing) + +- If the scope is **empty or unresolvable** — or `--affected` was supplied but + neither loomweave nor the qualname index can resolve any entity — wardline + **falls back to a full scan**, and says so. A trust tool never narrows itself + into a blind spot silently. +- Every delta result carries a **`scope` block**: + - `mode`: `"delta"` | `"full-fallback"` + - `gate_authority`: `"advisory"` (delta) | `"gate-of-record"` (full-fallback) — + a **machine-readable** field an automated consumer can gate on, so a delta + pass is type-distinguishable from a full pass without parsing prose. + - `entities_requested`: N + - `files_discovered`: D (the full discovery count) + - `files_analyzed`: M (the scoped subset actually handed to `analyzer.analyze`; + M == D only in `full-fallback`) + - `in_scope_findings`: K + - `fell_back_count`: number of entities resolved via the spoofable qualname + locator path rather than authoritative SEI + - `unresolved_entities`: list (locator/sei that did not resolve) + - `boundary_caveat`: a fixed string — *"Delta scan analyzes only files + containing the affected entities. Findings here may be incomplete OR absent: + cross-file taint whose source lies outside the analyzed set is not computed, + so an in-scope entity can read clean without being clean. Advisory + inner-loop signal, not a verdict — the full scan is the gate of record."* +- This block is both the **honesty contract** for agents reading the result and + the **seam that spec A pins** as a published contract. The caveat names the + *in-scope-entity correctness* hazard (a finding ON an analyzed entity can be + missing because its upstream taint source was out of scope), not just the + omission of out-of-scope entities — because an agent acts on the findings that + ARE present. +- **The honesty contract is enforceable, not just readable:** the severity gate + in delta mode runs over the full unsuppressed population (INV-4), so the + `gate_authority="advisory"` label and the full-population gate together make a + forged green structurally impossible. + +## 6. Surface + +- **CLI:** `wardline scan --affected `. `-` reads the + worklist/entity-list from stdin, so `warpline reverify | wardline scan + --affected -` works with no glue. +- **MCP:** the `scan` tool gains an `affected` parameter (object/array or a path) + — matching the MCP-primary direction. The `scope` block is emitted in + structured content. +- **Deferred-optional convenience:** `--since ` resolves the affected set + itself. It is built **on top of** the generic `--affected` input (it produces a + scope, then the same path runs), keeping the core decoupled. Marked optional; + may land in a later increment. + +Output formats (`--format human|json|sarif`) all carry the `scope` block; SARIF +puts it in run-level `properties`. + +## 7. Error handling summary + +| Condition | Behavior | +|---|---| +| `--affected` not given | Normal full scan (unchanged path) | +| Malformed scope payload | Loud error (exit 2) — agent payload bug, not a degrade | +| Empty/zero-resolvable scope | Full-fallback scan, declared in scope block | +| loomweave absent | Qualname-index resolution; note in scope block | +| Some entities unresolved | Scan the resolved subset; list unresolved in scope block; if **none** resolved → full-fallback | +| Scope **surgically excludes** a sink-bearing file (malicious or stale worklist) | NOT a fail-closed condition (>0 files resolve → normal delta). Protection is INV-4: the severity gate runs over the FULL population, so the gate cannot green a real ERROR. Fail-closed-on-empty does **not** cover this case. | +| `--affected` + `--fail-on` composed | Allowed; the gate evaluates the full unsuppressed population (INV-4), `gate_authority="advisory"`. A delta scan can surface a trip as data but cannot emit an authoritative full-scan PASS. | +| `--affected` + `--new-since` composed | Mutually exclusive at CLI **and** MCP (loud `ScopeParseError`/`ToolError`). They scope different things (analysis vs gate) via different mechanisms; composing them is rejected, not silently double-scoped. | +| Oversized scope payload | `ScopeParseError` above a byte/`item_count` cap (DoS guard on the uncapped stdin/inline ingress). | + +## 8. Testing + +- **Hermetic golden (every PR, no sibling):** a fixed `warpline.reverify_worklist.v1` + fixture + a small sample tree → assert the scoped file set, the filtered + finding set, and the `scope` block (including the fallback path and the + unresolved-entity path). +- **Unit:** `delta_scope.py` parsing (both input shapes, malformed, empty); + entity→file resolution (SEI path, qualname fallback, unresolved); finding + filter; fail-closed fallback. +- **New `warpline_e2e` marker** (fail-closed exactly like `loomweave_e2e` / + `legis_e2e` / `filigree_e2e` — `WARDLINE_LIVE_ORACLE_REQUIRED=1` turns SKIP → + FAIL): live `warpline reverify | wardline scan --affected -` round-trip, + weekly/manual in CI only. + +## 9. Reference contracts (from the 2026-06-18 mapping) + +- **filigree `warpline_worklist_ingest`** — `~/filigree/src/filigree/mcp_tools/federation.py` + + `~/filigree/src/filigree/warpline_consumer.py` (style reference only, not an + import target — note it is at the package root, NOT under `mcp_tools/`). + Worklist item shape: + `{entity:{locator,sei}, priority, reason, depth, why[], suggested_verification[{kind,command}], enrichment{work,risk,governance,requirements}}`. +- **wardline emitter patterns to mirror for style/transport discipline** — + `src/wardline/core/filigree_emit.py` (urllib + Bearer, fail-soft) and + `src/wardline/core/legis.py` (HMAC, build-not-send). Delta scan has **no + outbound transport**, but the fail-soft/loud discipline and config-via-`weft.toml` + conventions carry over. +- **wardline Finding model** — `src/wardline/core/finding.py` + (`rule_id`, `severity`, `kind`, `location`, `fingerprint`, `qualname`, + `properties`, `suppression`). The finding filter keys on `qualname`/`location`. +- **loomweave SEI resolver** — `src/wardline/loomweave/` (`SeiResolver`, + `identity.py`). + +## 10. The A follow-on (separate spec) + +`wardline-c0563eee74` turns this consumption into a **published, drift-checked +contract**: wardline consumes `warpline.reverify_worklist.v1` and emits a +`wardline.delta_scope.v1` provenance block; a golden vector pins it in CI against +the producer's *published* artifact — fixing the live gap that today's +conformance goldens are vendored stale copies whose drift check +(`test_sei_oracle.py::test_vendored_oracle_matches_loomweave_source`) skips +unless `LOOMWEAVE_REPO` is set, which CI never does. A is **out of scope for this +spec**; it gets its own spec → plan cycle. diff --git a/pyproject.toml b/pyproject.toml index 152824d5..4c4d9fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,9 +95,13 @@ packages = ["src/wardline"] [tool.ruff] line-length = 120 target-version = "py312" -# Frozen identity-oracle fixtures are parsed-not-imported corpus inputs; never -# let lint/format rewrite them (that would silently drift the golden corpus). -extend-exclude = ["tests/golden/identity/fixtures"] +# Frozen identity-oracle fixtures and the warpline-delta sample tree are +# parsed-not-imported corpus inputs; never let lint/format rewrite them (that +# would silently drift the golden corpus). +extend-exclude = [ + "tests/golden/identity/fixtures", + "tests/conformance/fixtures/warpline_delta", +] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] @@ -110,6 +114,7 @@ exclude = [ '^tests/grammar/fixtures/', '^tests/unit/install/mock_pack/', '^tests/golden/identity/fixtures/', + '^tests/conformance/fixtures/warpline_delta/', ] mypy_path = ["src", "."] explicit_package_bases = true @@ -139,7 +144,7 @@ disable_error_code = [ [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-m 'not network and not loomweave_e2e and not legis_e2e and not filigree_e2e and not rust_e2e and not loomweave_drift'" +addopts = "-m 'not network and not loomweave_e2e and not legis_e2e and not filigree_e2e and not rust_e2e and not loomweave_drift and not warpline_e2e'" markers = [ "network: tests that need network (live OpenRouter judge e2e — SP5)", "loomweave_e2e: tests that need a real `loomweave serve` binary (SP9 round-trip)", @@ -147,6 +152,7 @@ markers = [ "filigree_e2e: tests that need a real Filigree with the promote route (WS-A2 round-trip)", "rust_e2e: live `wardline scan --lang rust` CLI subprocess over the .rs corpus (WP6)", "loomweave_drift: live recheck of the vendored Rust qualname corpus against the sibling loomweave checkout (release-gate drift alarm)", + "warpline_e2e: live `warpline reverify | wardline scan --affected -` round-trip (delta scope; weekly/manual)", ] [tool.coverage.run] diff --git a/src/wardline/_live_oracle.py b/src/wardline/_live_oracle.py index c4e60e89..29adb18c 100644 --- a/src/wardline/_live_oracle.py +++ b/src/wardline/_live_oracle.py @@ -4,7 +4,7 @@ from collections.abc import Iterable LIVE_ORACLE_REQUIRED_ENV = "WARDLINE_LIVE_ORACLE_REQUIRED" -LIVE_ORACLE_MARKERS = frozenset({"network", "loomweave_e2e", "legis_e2e", "filigree_e2e"}) +LIVE_ORACLE_MARKERS = frozenset({"network", "loomweave_e2e", "legis_e2e", "filigree_e2e", "warpline_e2e"}) _TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index e704c8f9..faab7fd2 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -6,10 +6,16 @@ import json import urllib.parse from pathlib import Path +from typing import IO, TYPE_CHECKING import click from wardline.core.config import resolve_filigree_url, resolve_loomweave_url +from wardline.core.delta_scope import ( + _MAX_PAYLOAD_BYTES, + AffectedScope, + parse_affected_scope_text, +) from wardline.core.emit import JsonlSink from wardline.core.errors import WardlineError from wardline.core.filigree_emit import ( @@ -25,6 +31,9 @@ from wardline.core.safe_paths import safe_write_text, write_text_no_follow from wardline.core.sarif import SarifSink +if TYPE_CHECKING: + from wardline.loomweave.identity import SeiResolver + @click.command() @click.argument( @@ -103,6 +112,18 @@ default=None, help="PR-scoped 'new findings only' gate: only gate on findings in files/entities changed since this git ref.", ) +@click.option( + "--affected", + "affected_file", + type=click.File("r"), + default=None, + help=( + "Scan only entities in this warpline reverify-worklist / entity-list " + "(file path, or '-' for stdin). Speed, not truth: out-of-scope cross-file " + "flows are not analyzed (see the scope block). Empty/unresolvable -> full scan. " + "Mutually exclusive with --new-since." + ), +) @click.option( "--trust-pack", "trusted_packs", @@ -174,6 +195,7 @@ def scan( filigree_max_findings_per_request: int | None, loomweave_url: str | None, new_since: str | None, + affected_file: IO[str] | None, trusted_packs: tuple[str, ...], trust_local_packs: bool, fix: bool, @@ -236,11 +258,38 @@ def scan( if local_only: filigree_url = None loomweave_url = None + # --affected delta scope (Phase 7): read the producer-supplied worklist / entity + # list (a real path, or '-' for stdin — click.File resolved the stream) and parse + # it INSIDE this try so ScopeParseError (invalid JSON / over-cap) lands on the shared + # SystemExit(2) path (malformed scope -> exit 2, spec §7). The hand-supplied JSON is + # untrusted; INV-4 keeps it off the gate population, so trust is moot for the verdict. + affected: AffectedScope | None = None + if affected_file is not None: + # --affected is mutually exclusive with --new-since (they scope different things + # via different mechanisms; run_scan also rejects the pair as ScopeParseError). + # When a git-driven --since lands (Phase 12) it MUST reject against --affected here + # too — there is no --since flag yet, so nothing to check beyond --new-since. + if new_since is not None: + raise WardlineError("--affected and --new-since are mutually exclusive") + # Bound the read at the byte cap BEFORE json.loads: read at most cap+1 chars + # from the (possibly stdin) handle and reject an over-cap blob pre-parse. A + # huge VALID JSON payload must not force a full unbounded read + parse before + # the DoS cap fires. parse_affected_scope_text enforces the byte-accurate cap + # and maps invalid JSON / over-cap to ScopeParseError (a WardlineError), so it + # lands on the shared SystemExit(2) malformed-scope path (§7). + raw = affected_file.read(_MAX_PAYLOAD_BYTES + 1) + affected = parse_affected_scope_text(raw) + # Inject the SEI resolver (run_scan stays network-free). Built only when a delta + # scope is requested and a loomweave URL resolves; any loomweave error -> None + # (fail-soft, recorded as "loomweave unavailable" in the scope block). + sei_resolver = _build_sei_resolver(loomweave_url, path) if affected is not None else None result = run_scan( path, config_path=config_path, cache_dir=cache_dir, new_since=new_since, + affected=affected, + sei_resolver=sei_resolver, trust_local_packs=trust_local_packs, trusted_packs=trusted_packs, strict_defaults=strict_defaults, @@ -279,6 +328,8 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: config_path=config_path, cache_dir=cache_dir, new_since=new_since, + affected=affected, + sei_resolver=sei_resolver, trust_local_packs=trust_local_packs, trusted_packs=trusted_packs, strict_defaults=strict_defaults, @@ -287,11 +338,17 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: lang=lang, ) findings = result.findings + # Delta-scope honesty block (--affected): threaded into every --format channel as a + # run-level property (SARIF), a top-level key (agent-summary), and a stderr line. + # jsonl carries findings only (unchanged), but the stderr summary still prints. + scope_props: dict[str, object] | None = ( + {"wardline_delta_scope": result.scope.to_dict()} if result.scope is not None else None + ) if fmt == "sarif": sarif_sink = SarifSink( confined_name if output_is_default else output, root=path if output_is_default else None ) - sarif_sink.write(findings, result.context) + sarif_sink.write(findings, result.context, run_properties=scope_props) elif fmt == "jsonl": jsonl_sink = JsonlSink( confined_name if output_is_default else output, root=path if output_is_default else None @@ -339,12 +396,22 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: if filigree_url is not None: from wardline.filigree.config import load_filigree_token + # INV-5: a delta scan emits the FULL discovery list as scanned_paths but a + # FILTERED findings list, so Filigree's auto mark_unseen would read every + # out-of-scope finding as fixed and close its issue (irreversible signal loss). + # Force mark_unseen=False in delta mode; full / full-fallback scans reconcile + # normally (mark_unseen=None -> auto). + delta_mode = result.scope is not None and result.scope.mode == "delta" emit_result = FiligreeEmitter( filigree_url, token=load_filigree_token(path), max_findings_per_request=filigree_max_findings_per_request, protocol_errors_loud=False, - ).emit(findings, scanned_paths=result.scanned_paths) + ).emit( + findings, + scanned_paths=result.scanned_paths, + mark_unseen=False if delta_mode else None, + ) # Loomweave taint-store write is fail-soft: an outage/403 returns a not-reachable # WriteResult (reported below); a LoomweaveError (missing extra, 4xx, bad scheme) # is a WardlineError → caught here → exit 2, exactly as Filigree errors do. @@ -363,19 +430,18 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: from wardline.core.agent_summary import build_agent_summary decision = gate_decision(result, Severity(fail_on)) if fail_on is not None else gate_decision(result, None) - agent_summary_json = ( - json.dumps( - build_agent_summary( - result, - decision, - filigree_emit=_filigree_status(emit_result), - loomweave_write=_loomweave_status(loomweave_result), - migration_hint=baseline_migration_hint(result, decision, root=path, new_since=new_since), - ).to_dict(), - sort_keys=True, - ) - + "\n" - ) + agent_summary_dict = build_agent_summary( + result, + decision, + filigree_emit=_filigree_status(emit_result), + loomweave_write=_loomweave_status(loomweave_result), + migration_hint=baseline_migration_hint(result, decision, root=path, new_since=new_since), + ).to_dict() + # Surface the --affected delta-scope honesty block alongside the summary (the + # same block SARIF carries in run_properties; absent for a full scan, INV-1). + if result.scope is not None: + agent_summary_dict["scope"] = result.scope.to_dict() + agent_summary_json = json.dumps(agent_summary_dict, sort_keys=True) + "\n" if output_is_default: safe_write_text(path, confined_name, agent_summary_json, label=default_name) else: @@ -459,6 +525,16 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: f"; {len(loomweave_result.unresolved_qualnames)} qualname(s) unresolved (not indexed by Loomweave)" ) click.echo(line) + # --affected delta-scope one-liner (stderr). Prints for EVERY --format when a scope + # block exists; a full scan (no --affected) prints nothing new (INV-1). + if result.scope is not None: + sc = result.scope + click.echo( + f"scope: {sc.mode} ({sc.gate_authority}) — analyzed {sc.files_analyzed} of " + f"{sc.files_discovered} discovered file(s); {sc.in_scope_findings} in-scope " + f"finding(s); {len(sc.unresolved_entities)} entity(ies) unresolved", + err=True, + ) s = result.summary unanalyzed_segment = f"; {s.unanalyzed} file(s) could not be analyzed" if s.unanalyzed else "" # "active" = non-suppressed DEFECTs in the EMITTED findings — the canonical term @@ -536,6 +612,33 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: raise SystemExit(1) +def _build_sei_resolver(loomweave_url: str | None, root: Path) -> SeiResolver | None: + """Construct a loomweave :class:`SeiResolver` for the ``--affected`` SEI path, or None. + + Built only when a loomweave URL resolved. Fail-soft (Phase 3 injection contract): any + loomweave error — missing extra, bad scheme, unreachable, capabilities probe failure — + yields ``None`` so a delta scan degrades to the spoofable qualname-locator path rather + than exiting 2. ``run_scan`` records ``loomweave_used=False`` in the scope block. The + resolver is injected so ``run_scan`` stays network-free. + """ + if loomweave_url is None: + return None + try: + from wardline.core.errors import LoomweaveError + from wardline.loomweave.client import LoomweaveClient + from wardline.loomweave.config import load_loomweave_token, resolve_project_name + from wardline.loomweave.identity import SeiCapability, SeiResolver + + client = LoomweaveClient( + loomweave_url, + secret=load_loomweave_token(root), + project=resolve_project_name(root), + ) + return SeiResolver(client, SeiCapability.from_capabilities(client.capabilities())) + except (LoomweaveError, OSError): + return None + + def _filigree_status(result: EmitResult | None) -> dict[str, object]: if result is None: return { diff --git a/src/wardline/core/delta_resolve.py b/src/wardline/core/delta_resolve.py new file mode 100644 index 00000000..561c3889 --- /dev/null +++ b/src/wardline/core/delta_resolve.py @@ -0,0 +1,409 @@ +"""Affected-entity → file resolution + the delta finding filter (stdlib-only). + +This module is the **resolution** seam (spec §5.2/§5.3) for the ``--affected`` delta +scan. It turns the parsed :class:`~wardline.core.delta_scope.AffectedScope` (a set of +producer-supplied entities, each carrying a SEI and/or a warpline locator) into a +:class:`ResolvedScope`: the concrete set of repo-relative files the engine should +analyze, plus the canonical affected qualname set the displayed-finding filter +(:func:`filter_to_affected`) keys on. + +Two resolution paths, in order of authority (spec §5.2): + +1. **SEI present and loomweave supports SEI** → resolve the opaque SEI to a current + locator (mirrors :func:`wardline.core.sei_resolution.resolve_query_filters`: + ``client.resolve_sei(sei)["current_locator"]`` → :func:`locator_to_qualname`), + canonicalize, and match it in a freshly-built qualname index. Authoritative. + A **SEI-drift guard** treats an SEI that resolves to a qualname *absent* from the + current index (loomweave stale vs the working tree, e.g. a rename) as effectively + stale and falls through to path 2, recording it as ``stale_sei``. +2. **SEI absent / loomweave unavailable / SEI did not resolve / SEI drifted** → fall + back to the locator: :func:`locator_to_qualname` → canonicalize → match the index. + +An entity neither path resolves lands in ``unresolved`` (it contributes no file and +trips the fail-closed full-fallback rule only when the *whole* set is unresolved — +that decision lives in ``run_scan``, spec §5.4). + +The qualname **index is taint-free**: a cheap structural ``ast.parse`` pass over the +already-discovered files (:func:`build_qualname_index`), so the loomweave path is +optional and the index is always complete. All qualname comparisons use a single +canonical key (suffix-stripped of property-accessor ``:setter``/``:deleter`` markers, +matching :func:`wardline.core.finding._to_wire_qualname`) so a ``:setter`` finding +matches its base-name locator and a class-level locator matches every method under it. + +**Caller-closure expansion (load-bearing — spec §5.3a).** Warpline's worklist is a +"changed + downstream (callee)" set, but taint findings anchor caller-side. After +resolving the affected entities to a base file set, :func:`resolve_affected_scope` +expands the *analyzed file set* once over the reverse call graph (reusing +:func:`wardline.core.delta.get_affected_entities`) to pull in the callers of the +affected entities. The filter set (``affected_qualnames``) stays the **base** set: +only the analyzed files expand, so the displayed findings still cover only the +requested entities while being computed correctly. + +Nothing here re-mints a fingerprint (INV-2): :func:`filter_to_affected` is a pure +drop-filter over a findings list, and it touches only the displayed ``findings`` set — +never ``gate_findings`` (INV-4 / THREAT-001), which ``run_scan`` keeps as the full +population so an attacker-influenceable scope cannot forge a green. +""" + +from __future__ import annotations + +import ast +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from wardline.core.delta import get_affected_entities +from wardline.core.delta_scope import AffectedEntity, AffectedScope +from wardline.core.finding import ( + _PROPERTY_ACCESSOR_QUALNAME_SUFFIXES, + Finding, +) +from wardline.core.qualname import module_dotted_name +from wardline.core.sei_resolution import locator_to_qualname +from wardline.loomweave.identity import SeiResolver +from wardline.scanner.ast_primitives import ( + build_import_alias_map, + iter_calls_in_function_body, + resolve_call_fqn, + resolve_self_method_fqn, +) +from wardline.scanner.index import ( + Entity, + discover_class_qualnames, + discover_file_entities, +) + + +def canonical_qualname(qualname: str) -> str: + """Strip a property-accessor ``:setter``/``:deleter`` suffix for membership tests. + + ``Finding.qualname`` carries these suffixes (normalized away only at the Filigree + wire, never on the raw :class:`~wardline.core.finding.Finding`), so a locator + ``python:function:pkg.mod.Cls.prop`` would not string-equal a finding qualname + ``pkg.mod.Cls.prop:setter`` without this. Index keys, ``affected_qualnames``, and + :func:`filter_to_affected` all compare through this single helper. Reuses the suffix + set defined alongside :func:`wardline.core.finding._to_wire_qualname` so the two + canonicalizations never drift.""" + for suffix in _PROPERTY_ACCESSOR_QUALNAME_SUFFIXES: + if qualname.endswith(suffix): + return qualname.removesuffix(suffix) + return qualname + + +@dataclass(frozen=True, slots=True) +class QualnameIndex: + """A taint-free qualname→file map plus the structural call graph. + + ``by_qualname`` maps a **canonical** (suffix-stripped) qualname to a repo-relative + POSIX path. ``project_edges`` (caller → resolved project callees) and ``entities`` + (canonical qualname → path) feed the reverse-edge caller closure in + :func:`resolve_affected_scope` via :func:`wardline.core.delta.get_affected_entities`, + so the closure runs off this cheap structural pass with no taint analysis.""" + + by_qualname: dict[str, str] + project_edges: dict[str, frozenset[str]] + entities: dict[str, str] + + +@dataclass(frozen=True, slots=True) +class ResolvedScope: + """The outcome of resolving an :class:`AffectedScope` against a :class:`QualnameIndex`. + + ``files`` is the caller-expanded set of repo-relative POSIX paths to analyze. + ``affected_qualnames`` is the **base** (NOT caller-expanded) canonical affected set + the displayed-finding filter keys on. The four entity buckets record provenance for + the scope honesty block (spec §5.4): ``resolved`` via authoritative SEI, ``fell_back`` + via the spoofable qualname-locator path, ``stale_sei`` an SEI that resolved to a + now-absent qualname, ``unresolved`` neither path matched. ``loomweave_used`` is True + iff the SEI path actually resolved at least one entity.""" + + files: frozenset[str] + affected_qualnames: frozenset[str] + resolved: tuple[AffectedEntity, ...] + fell_back: tuple[AffectedEntity, ...] + stale_sei: tuple[AffectedEntity, ...] + unresolved: tuple[AffectedEntity, ...] + loomweave_used: bool + + +def build_qualname_index(files: Sequence[Path], root: Path) -> QualnameIndex: + """Build a taint-free qualname→file index + structural call graph over ``files``. + + A cheap structural pass: for each file, ``ast.parse`` the source (skipping the file + on :class:`SyntaxError`/:class:`OSError` — a parse-error file contributes no entries + and never raises out of ``run_scan``), discover its entities via + :func:`wardline.scanner.index.discover_file_entities`, and resolve each body's call + sites structurally (bare-name + imported-alias + ``self``/``cls`` method calls). No + taint analysis. Index keys are CANONICAL (suffix-stripped) qualnames; values are the + repo-relative POSIX path matching ``Entity.location.path``. + + Args: + files: the already-discovered files (absolute or root-relative paths). + root: the scan root, used to compute each file's repo-relative POSIX path. + """ + by_qualname: dict[str, str] = {} + entities_by_path: dict[str, str] = {} + # First pass: discover entities + classes per file, keyed by relpath, so the call-edge + # pass can resolve project FQNs against the WHOLE project (not just one module). + per_file: list[tuple[str, str, ast.Module, list[Entity], set[str]]] = [] + for file in files: + rel = _relpath(file, root) + module = module_dotted_name(rel) + if module is None: + continue + try: + source = Path(file).read_bytes() + tree = ast.parse(source) + except (SyntaxError, OSError): + # A parse-error / unreadable file contributes no entries and must not raise + # out of run_scan (spec §5.3, Phase 2 parse-error handling). + continue + file_entities = discover_file_entities(tree, module=module, path=rel) + class_qualnames = discover_class_qualnames(tree, module=module) + per_file.append((rel, module, tree, file_entities, class_qualnames)) + for entity in file_entities: + key = canonical_qualname(entity.qualname) + by_qualname[key] = entity.location.path + entities_by_path[key] = entity.location.path + + # The complete project function/class FQN universe, so structural call resolution can + # see cross-module callees. Built from the RAW (non-canonical) qualnames because + # resolve_call_fqn / resolve_self_method_fqn match the as-defined entity FQNs. + project_fqns: frozenset[str] = frozenset(entity.qualname for _, _, _, ents, _ in per_file for entity in ents) + all_class_qualnames: frozenset[str] = frozenset(cls for _, _, _, _, classes in per_file for cls in classes) + + project_edges: dict[str, frozenset[str]] = {} + for _, module, tree, file_entities, _ in per_file: + alias_map = build_import_alias_map(tree, module) + for entity in file_entities: + caller_class_fqn: str | None = entity.qualname.rsplit(".", 1)[0] + if caller_class_fqn not in all_class_qualnames: + caller_class_fqn = None + callees: set[str] = set() + for call in iter_calls_in_function_body(entity.node): + target = resolve_call_fqn(call, alias_map, project_fqns, module) + if target is None or target not in project_fqns: + target = resolve_self_method_fqn( + call, + caller_class_fqn=caller_class_fqn, + project_fqns=project_fqns, + ) + if target is not None and target in project_fqns: + callees.add(canonical_qualname(target)) + project_edges[canonical_qualname(entity.qualname)] = frozenset(callees) + + return QualnameIndex( + by_qualname=by_qualname, + project_edges=project_edges, + entities=entities_by_path, + ) + + +def resolve_affected_scope( + scope: AffectedScope, + *, + index: QualnameIndex, + sei_resolver: SeiResolver | None, +) -> ResolvedScope: + """Resolve each entity in ``scope`` to a file, then caller-expand the file set. + + Resolution order per entity (spec §5.2): authoritative SEI (when ``sei_resolver`` + supports SEI), then the qualname-locator fallback. An SEI that resolves to a qualname + absent from ``index`` is treated as stale and falls through to the locator path + (recorded in ``stale_sei``). The filter set (``affected_qualnames``) is the BASE set; + only ``files`` is caller-expanded via the reverse call graph so caller-side sinks of a + changed callee are analyzed (spec §5.3a).""" + sei_supported = sei_resolver is not None and sei_resolver.capability.supported + + base_qualnames: set[str] = set() + base_files: set[str] = set() + resolved: list[AffectedEntity] = [] + fell_back: list[AffectedEntity] = [] + stale_sei: list[AffectedEntity] = [] + unresolved: list[AffectedEntity] = [] + loomweave_used = False + + for entity in scope.entities: + drifted = False # this entity's SEI resolved but its qualname is index-absent + + # 1. Authoritative SEI path. + if entity.sei is not None and sei_supported: + assert sei_resolver is not None # narrowed by sei_supported + sei_qualname = _resolve_sei_qualname(sei_resolver, entity.sei) + if sei_qualname is not None: + matched = _match_qualname(sei_qualname, index) + if matched is not None: + qual, path = matched + base_qualnames.add(qual) + base_files.add(path) + resolved.append(entity) + loomweave_used = True + continue + # SEI resolved but the qualname is absent from the current index + # (loomweave stale vs the working tree) → treat as stale, fall through + # to the locator path (SEI-drift guard, spec §5.2). + drifted = True + + # 2. Qualname-locator fallback (also the SEI-drift recovery path). + if entity.locator is not None: + locator_qualname = canonical_qualname(locator_to_qualname(entity.locator)) + matched = _match_qualname(locator_qualname, index) + if matched is not None: + qual, path = matched + base_qualnames.add(qual) + base_files.add(path) + # A drifted SEI recovered via its locator is recorded as stale (so the + # scope block surfaces how much of the scope rests on a stale SEI), + # never double-counted as a clean qualname fall-back. + (stale_sei if drifted else fell_back).append(entity) + continue + + # 3. Neither path yielded a file → unresolved (a drifted SEI whose locator also + # missed is unresolved, recorded in exactly one bucket). + unresolved.append(entity) + + files = _expand_callers(base_files, index) + + return ResolvedScope( + files=files, + affected_qualnames=frozenset(base_qualnames), + resolved=tuple(resolved), + fell_back=tuple(fell_back), + stale_sei=tuple(stale_sei), + unresolved=tuple(unresolved), + loomweave_used=loomweave_used, + ) + + +def filter_to_affected( + findings: list[Finding], + affected_qualnames: frozenset[str], + affected_files: frozenset[str], +) -> list[Finding]: + """Narrow the DISPLAYED findings to the affected entities (spec §5.3, Phase 4). + + Keeps a finding iff its canonical qualname is in ``affected_qualnames`` (so a + ``:setter``/``:deleter`` finding matches its base-name locator and a method finding + matches a class-level locator via its class prefix), OR it is a file-level engine + FACT (``qualname is None``) on an analyzed affected file (kept as context). Findings + on *other* entities in the same analyzed file are dropped from the displayed output. + + PURE drop-filter: it never re-mints a fingerprint (INV-2). It is applied ONLY to the + emitted ``findings`` list, NEVER to ``gate_findings`` (INV-4 / THREAT-001); the caller + (``run_scan``) keeps the gate population as the full unsuppressed set so an + attacker-influenceable scope cannot forge a green. Never called with a ``None`` + findings list (the ``gate_findings is None`` secure-default sentinel is left + untouched).""" + kept: list[Finding] = [] + for finding in findings: + if finding.qualname is None: + if finding.location.path in affected_files: + kept.append(finding) + continue + if _qualname_in_affected(canonical_qualname(finding.qualname), affected_qualnames): + kept.append(finding) + return kept + + +def _qualname_in_affected(qualname: str, affected_qualnames: frozenset[str]) -> bool: + """True if ``qualname`` is in the affected set or is a method under an affected class. + + A class-level locator (``python:class:pkg.mod.Cls`` → ``pkg.mod.Cls``) scopes in every + method qualname under that class prefix (``pkg.mod.Cls.method``), so a worklist naming + a class matches findings on all its methods (spec §5.2 canonicalization).""" + if qualname in affected_qualnames: + return True + return any(qualname.startswith(f"{affected}.") for affected in affected_qualnames) + + +def _match_qualname(qualname: str, index: QualnameIndex) -> tuple[str, str] | None: + """Match a canonical ``qualname`` against the index, returning ``(qualname, path)``. + + Matches an exact entity qualname, or a class-level qualname (which has no entity of + its own) against any method under it — returning that class qualname as the affected + key so the filter's class-prefix rule scopes in all its methods, and the method's + file as a file to analyze.""" + path = index.by_qualname.get(qualname) + if path is not None: + return (qualname, path) + # Class-level locator: no entity carries the bare class qualname, but its methods do. + prefix = f"{qualname}." + for member_qualname, member_path in index.by_qualname.items(): + if member_qualname.startswith(prefix): + return (qualname, member_path) + return None + + +def _resolve_sei_qualname(sei_resolver: SeiResolver, sei: str) -> str | None: + """Resolve an opaque SEI to a canonical qualname via the resolver's client. + + Mirrors :func:`wardline.core.sei_resolution.resolve_query_filters`: + ``client.resolve_sei(sei)["current_locator"]`` → :func:`locator_to_qualname` → + :func:`canonical_qualname`. Returns ``None`` on any soft failure (no body, no + ``current_locator``) — never raises, so a flaky loomweave degrades to the locator + fallback rather than failing the scan. Reaches the SEI→current-locator wire through + the resolver's bound :class:`~wardline.loomweave.identity.SeiClient` (the resolver's + public surface returns an :class:`~wardline.core.identity.EntityBinding`, not the raw + ``current_locator`` this path needs, matching ``resolve_query_filters``' use of the + client directly).""" + data = sei_resolver._client.resolve_sei(sei) + if not isinstance(data, dict): + return None + locator = data.get("current_locator") + if not isinstance(locator, str) or not locator: + return None + return canonical_qualname(locator_to_qualname(locator)) + + +def _expand_callers(base_files: frozenset[str] | set[str], index: QualnameIndex) -> frozenset[str]: + """Expand a base file set with the files of every caller of an affected entity. + + Reuses :func:`wardline.core.delta.get_affected_entities` (the reverse callee→caller + BFS) over the index's structural call graph so a worklist naming a changed callee + pulls in the caller-side files that carry the taint finding (spec §5.3a). The base + files are always retained; only callers are added.""" + if not base_files: + return frozenset(base_files) + entity_map = {qualname: _IndexEntity(_IndexLocation(path)) for qualname, path in index.entities.items()} + # get_affected_entities reads only ``entity.location.path``; _IndexEntity provides + # exactly that, so the cast bridges the concrete-Entity annotation without building + # real AST-backed Entity objects in this taint-free pass. + affected_qualnames = get_affected_entities( + set(base_files), cast("Mapping[str, Entity]", entity_map), index.project_edges + ) + files = set(base_files) + for qualname in affected_qualnames: + path = index.entities.get(qualname) + if path is not None: + files.add(path) + return frozenset(files) + + +@dataclass(frozen=True, slots=True) +class _IndexLocation: + """The minimal ``.path`` surface :func:`get_affected_entities` reads off an entity.""" + + path: str + + +@dataclass(frozen=True, slots=True) +class _IndexEntity: + """A lightweight stand-in exposing only ``.location.path`` for the caller closure. + + :func:`wardline.core.delta.get_affected_entities` reads only ``entity.location.path``, + so the caller closure runs off the cheap structural index without constructing real + :class:`~wardline.scanner.index.Entity` objects (which would need an AST node).""" + + location: _IndexLocation + + +def _relpath(file: Path, root: Path) -> str: + """Repo-relative POSIX path for ``file``, matching ``Entity.location.path``. + + Mirrors discovery's relpath convention so index keys line up with finding locations. + A path already relative to (or outside) ``root`` is returned POSIX-normalized.""" + try: + return file.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return file.as_posix() diff --git a/src/wardline/core/delta_scope.py b/src/wardline/core/delta_scope.py new file mode 100644 index 00000000..0d99199f --- /dev/null +++ b/src/wardline/core/delta_scope.py @@ -0,0 +1,289 @@ +"""Affected-entity scope parsing for the ``--affected`` delta scan (stdlib-only). + +This module is the **scope input** seam (spec §5.1). It parses a producer-supplied +affected-entity scope from one of three structurally-distinct shapes and normalizes +it into an :class:`AffectedScope` value. It also defines the :class:`DeltaScopeReport` +honesty block (spec §5.4) that ``run_scan`` attaches to a delta result. + +The scope input is **untrusted/unauthenticated** (a warpline reverify worklist derived +from filigree/loomweave state, or a hand-supplied JSON blob — no signature, producer +identity, or freshness binding). Parsing is therefore defensive: structurally malformed +payloads fail LOUD (``ScopeParseError`` → exit 2), oversized payloads are rejected +(DoS guard on the uncapped stdin/inline ingress), but an *empty* or zero-entity payload +is **not** an error — it returns ``source_kind="empty"`` and the fail-closed full-fallback +rule (spec §5.4, applied in ``run_scan``) takes over. The trust level of the scope never +relaxes the gate: the severity gate runs over the full population (INV-4), so an +attacker-influenceable scope cannot forge a green. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from wardline.core.errors import WardlineError + +# DoS guards on the new (previously uncapped) stdin/inline ingress (spec §7). Both are +# generous — large enough never to bite a real worklist, small enough to bound a hostile +# blob handed to a long-running ``wardline mcp`` server. +_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024 # 4 MiB +_MAX_ITEM_COUNT = 50_000 + + +class ScopeParseError(WardlineError): + """A ``--affected`` scope payload is structurally malformed or oversized. + + Loud (CLI → exit 2; MCP → ``ToolError``/isError) — an agent payload bug, NOT a + degrade. An *empty* or zero-entity payload is NOT this error; it is reported via + ``source_kind="empty"`` so the caller can apply the fail-closed full-fallback rule + (spec §5.4). This covers: a payload that is not an object/array, an ``items`` value + that is not a list, an entity that is not an object, invalid JSON (from + :func:`load_affected_scope`), and a payload exceeding the byte / item-count cap. + """ + + +@dataclass(frozen=True, slots=True) +class AffectedEntity: + """One affected entity from the producer scope. At least one of ``sei`` / ``locator`` + is non-``None`` (entities with neither are dropped during parsing). ``locator`` is an + opaque-ish warpline locator, e.g. ``python:function:pkg.mod.f``.""" + + sei: str | None + locator: str | None + + +@dataclass(frozen=True, slots=True) +class AffectedScope: + """A normalized affected-entity scope plus provenance. + + ``source_kind`` records which shape was parsed: ``"reverify_worklist_v1"`` (either + the full ``{"data": {"items": …}}`` envelope or its bare ``{"items": …}`` payload), + ``"entity_list"`` (a bare ``[…]`` list), or ``"empty"`` (a parseable payload that + yielded zero usable entities). ``item_count`` is the number of *input* items seen + (before dropping entities with neither ``sei`` nor ``locator``), used for the + item-count DoS cap and for the ``entities_requested`` scope-block field.""" + + entities: frozenset[AffectedEntity] + source_kind: str + item_count: int + + +def parse_affected_scope(payload: object) -> AffectedScope: + """Parse an already-decoded JSON ``payload`` into an :class:`AffectedScope`. + + Sole public entry point. Accepts (spec §5.1): + + 1. ``warpline.reverify_worklist.v1`` full envelope — ``{"data": {"items": [...]}}``. + 2. ``warpline.reverify_worklist.v1`` bare ``data`` payload — ``{"items": [...]}``. + 3. A bare entity list — ``[{"sei"?: str, "locator"?: str}, ...]``. + + Worklist items (shapes 1/2) read ``items[].entity.{sei, locator}``; bare-list items + (shape 3) read ``{sei, locator}`` directly. An entity with neither ``sei`` nor + ``locator`` is dropped (counts toward neither resolution path). A structurally + malformed payload raises :class:`ScopeParseError`; a parseable-but-empty payload + returns ``source_kind="empty"``. + """ + _enforce_byte_cap(payload) + + if isinstance(payload, list): + return _parse_entity_list(payload) + if isinstance(payload, dict): + return _parse_worklist(payload) + raise ScopeParseError( + f"affected scope payload must be a JSON object (worklist) or array (entity list), got {type(payload).__name__}" + ) + + +def parse_affected_scope_text(raw: str) -> AffectedScope: + """Parse untrusted raw JSON *text* into an :class:`AffectedScope`, capping RAW INPUT + BYTES *before* ``json.loads`` (DoS guard at the bytes boundary, spec §7). + + This is the entry point for every path where untrusted text enters from outside the + process (a file or a stdin stream): the byte cap is enforced on ``len(raw bytes)`` + BEFORE the blob is parsed, so a huge VALID JSON payload cannot force a full + parse/allocation before the cap fires. Invalid JSON raises :class:`ScopeParseError` + (the same malformed-payload posture as :func:`load_affected_scope`). + + The already-decoded :func:`parse_affected_scope` entry point keeps its own post-parse + byte cap as defense-in-depth for the MCP inline path, where the object arrives + pre-parsed by the JSON-RPC transport and the raw-bytes guard does not apply.""" + if len(raw.encode("utf-8")) > _MAX_PAYLOAD_BYTES: + raise ScopeParseError(f"affected scope payload exceeds the {_MAX_PAYLOAD_BYTES}-byte cap") + try: + payload = json.loads(raw) + except (json.JSONDecodeError, ValueError) as exc: + raise ScopeParseError(f"affected scope payload is not valid JSON: {exc}") from exc + return parse_affected_scope(payload) + + +def load_affected_scope(source: str) -> AffectedScope: + """Read the JSON file at ``source`` (a real filesystem path) and parse it. + + Internal helper for callers that hold a path rather than already-read text. It does + **not** handle stdin — the CLI owns the stdin handle via ``click.File('-')`` and + routes already-read text to :func:`parse_affected_scope_text` directly. Invalid JSON, + an over-cap blob, or an unreadable file raises :class:`ScopeParseError` (the + malformed-payload posture, §7). + + The read is itself bounded: at most ``_MAX_PAYLOAD_BYTES + 1`` bytes are read, never + the whole file unbounded. Reading cap+1 and checking the length is a sufficient *bound* + (we do not need to stream): if the read returns more than the cap we reject; if it + returns at-or-under the cap we have the complete payload. The text is then routed + through :func:`parse_affected_scope_text`, which re-checks the byte cap before parse.""" + try: + with open(source, encoding="utf-8") as fh: + # Read at most cap+1 bytes: enough to detect an over-cap file without an + # unbounded read/allocation. (text-mode read(n) counts characters; for ASCII + # JSON that equals bytes, and the byte-accurate cap is re-checked downstream.) + raw = fh.read(_MAX_PAYLOAD_BYTES + 1) + except OSError as exc: + raise ScopeParseError(f"could not read affected scope file {source!r}: {exc}") from exc + try: + return parse_affected_scope_text(raw) + except ScopeParseError as exc: + if "not valid JSON" in str(exc): + raise ScopeParseError(f"affected scope file {source!r} is not valid JSON: {exc}") from exc + raise + + +def _enforce_byte_cap(payload: object) -> None: + """Reject an oversized payload by re-serialized byte length (DoS guard, §7). + + The item-count cap is enforced once the item list is known (it is cheaper to count + items than to re-serialize, but the byte cap bounds a payload that is one giant value + rather than many items).""" + try: + encoded = json.dumps(payload, separators=(",", ":")) + except (TypeError, ValueError): + # Non-serializable payloads are not valid scope inputs anyway; let the shape + # checks below raise a precise ScopeParseError instead of swallowing here. + return + if len(encoded.encode("utf-8")) > _MAX_PAYLOAD_BYTES: + raise ScopeParseError(f"affected scope payload exceeds the {_MAX_PAYLOAD_BYTES}-byte cap") + + +def _enforce_item_cap(count: int) -> None: + if count > _MAX_ITEM_COUNT: + raise ScopeParseError(f"affected scope has {count} items, exceeding the {_MAX_ITEM_COUNT}-item cap") + + +def _parse_worklist(payload: dict[object, object]) -> AffectedScope: + """Parse a ``warpline.reverify_worklist.v1`` envelope (full or bare-data).""" + data = payload.get("data", payload) + if not isinstance(data, dict): + raise ScopeParseError(f"affected scope 'data' must be an object, got {type(data).__name__}") + items = data.get("items") + if items is None: + # An object with no 'items' is a parseable but empty worklist — not malformed. + return AffectedScope(frozenset(), "empty", 0) + if not isinstance(items, list): + raise ScopeParseError(f"affected scope 'items' must be a list, got {type(items).__name__}") + _enforce_item_cap(len(items)) + entities: set[AffectedEntity] = set() + for item in items: + if not isinstance(item, dict): + raise ScopeParseError(f"affected scope worklist item must be an object, got {type(item).__name__}") + entity_obj = item.get("entity") + if entity_obj is None: + continue + if not isinstance(entity_obj, dict): + raise ScopeParseError(f"affected scope item 'entity' must be an object, got {type(entity_obj).__name__}") + entity = _coerce_entity(entity_obj) + if entity is not None: + entities.add(entity) + if not entities: + return AffectedScope(frozenset(), "empty", len(items)) + return AffectedScope(frozenset(entities), "reverify_worklist_v1", len(items)) + + +def _parse_entity_list(payload: list[object]) -> AffectedScope: + """Parse a bare ``[{"sei"?, "locator"?}, ...]`` entity list.""" + _enforce_item_cap(len(payload)) + entities: set[AffectedEntity] = set() + for item in payload: + if not isinstance(item, dict): + raise ScopeParseError(f"affected scope entity-list item must be an object, got {type(item).__name__}") + entity = _coerce_entity(item) + if entity is not None: + entities.add(entity) + if not entities: + return AffectedScope(frozenset(), "empty", len(payload)) + return AffectedScope(frozenset(entities), "entity_list", len(payload)) + + +def _coerce_entity(obj: dict[object, object]) -> AffectedEntity | None: + """Coerce a ``{sei?, locator?}`` object into an :class:`AffectedEntity`, or ``None`` + if it carries neither a usable ``sei`` nor ``locator`` (such entities are dropped).""" + sei = _coerce_str(obj.get("sei")) + locator = _coerce_str(obj.get("locator")) + if sei is None and locator is None: + return None + return AffectedEntity(sei=sei, locator=locator) + + +def _coerce_str(value: object) -> str | None: + """Return a non-empty string, else ``None``. Non-string/blank values are treated as + absent (a producer that emits ``"sei": null`` or ``""`` means 'no SEI').""" + if isinstance(value, str) and value: + return value + return None + + +# --- Phase 5: the scope honesty block (spec §5.4) --------------------------------- + +BOUNDARY_CAVEAT = ( + "Delta scan analyzes only files containing the affected entities. Findings here " + "may be incomplete OR absent: cross-file taint whose source lies outside the " + "analyzed set is not computed, so an in-scope entity can read clean without being " + "clean. Advisory inner-loop signal, not a verdict — the full scan is the gate of record." +) + + +@dataclass(frozen=True, slots=True) +class DeltaScopeReport: + """The honesty/provenance block attached to a delta-scan :class:`ScanResult`. + + ``mode`` is ``"delta"`` when a scoped subset was analyzed, or ``"full-fallback"`` + when the scope resolved zero files (empty / all-unresolvable / loomweave-absent + + qualname-miss) and ``run_scan`` fell back to a full scan (fail-closed honesty, + INV-3). ``gate_authority`` is the **machine-readable** companion an automated + consumer can gate on without parsing prose: ``"advisory"`` in delta mode (the gate + still runs over the full population per INV-4, but a delta pass is type-distinguishable + from a full pass), ``"gate-of-record"`` in full-fallback. + + ``fell_back_count`` / ``stale_sei_count`` surface how much of the scope rests on the + spoofable qualname-locator path or a stale SEI, so a consumer can judge trust without + treating fell-back entities as SEI-equivalent. ``unresolved_entities`` lists every + entity that did not resolve even in delta mode.""" + + mode: str + gate_authority: str + entities_requested: int + files_discovered: int + files_analyzed: int + in_scope_findings: int + fell_back_count: int + stale_sei_count: int + unresolved_entities: tuple[dict[str, str | None], ...] + loomweave_used: bool + boundary_caveat: str = field(default=BOUNDARY_CAVEAT) + + def to_dict(self) -> dict[str, object]: + """Serialize to a JSON-ready mapping for the agent-summary / SARIF / MCP channels. + + Return type is ``dict[str, object]`` (not ``dict[str, Any]``) so it stays sound + under ``mypy --strict`` — the inner ``unresolved_entities`` items are + ``dict[str, str | None]``, which is not assignable to ``dict[str, Any]``.""" + return { + "mode": self.mode, + "gate_authority": self.gate_authority, + "entities_requested": self.entities_requested, + "files_discovered": self.files_discovered, + "files_analyzed": self.files_analyzed, + "in_scope_findings": self.in_scope_findings, + "fell_back_count": self.fell_back_count, + "stale_sei_count": self.stale_sei_count, + "unresolved_entities": [dict(e) for e in self.unresolved_entities], + "loomweave_used": self.loomweave_used, + "boundary_caveat": self.boundary_caveat, + } diff --git a/src/wardline/core/filigree_emit.py b/src/wardline/core/filigree_emit.py index 36db1170..9fd911ec 100644 --- a/src/wardline/core/filigree_emit.py +++ b/src/wardline/core/filigree_emit.py @@ -355,6 +355,8 @@ def _scan_result_chunks( findings: Sequence[Finding], scanned_paths: Sequence[str], max_findings_per_request: int, + *, + force_no_mark_unseen: bool = False, ) -> tuple[_ScanResultChunk, ...]: """Split large emits without corrupting Filigree's per-file unseen sweep. @@ -362,12 +364,18 @@ def _scan_result_chunks( chunk must carry a complete set of findings for every path it names. Most large scans can be chunked by whole-file groups. If one file alone exceeds the cap, the file has to be split and reconciliation is disabled only for those chunks. + + ``force_no_mark_unseen`` hard-disables reconciliation on every chunk (INV-5): a + delta ``--affected`` scan emits the FULL discovery list as ``scanned_paths`` but a + FILTERED ``findings`` list, so Filigree's absent-fingerprint sweep would read every + out-of-scope finding as fixed and close its issue — irreversible signal loss. A delta + scan never reconciles closure; only a full scan may. """ if max_findings_per_request < 1: 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 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 UNANALYZED_RULE_IDS for f in findings) if len(findings) <= max_findings_per_request: return ( _ScanResultChunk( @@ -703,7 +711,16 @@ def emit( *, scanned_paths: Sequence[str] = (), language: str | None = None, + mark_unseen: bool | None = None, ) -> EmitResult: + """Emit ``findings`` to the configured Filigree scan-results URL. + + ``mark_unseen`` is the reconciliation override. ``None`` (default) keeps the + auto-derived per-chunk behaviour (sweep absent fingerprints on scanned paths). + ``False`` hard-disables reconciliation on every chunk — required by INV-5 for a + delta ``--affected`` scan, whose ``findings`` are a filtered subset of the + ``scanned_paths`` it names; auto-sweep would close out-of-scope issues. + """ headers = {"Content-Type": "application/json"} token_sent = bool(self._token) if token_sent: @@ -713,7 +730,14 @@ def emit( or _fetch_scan_results_limit(self._url, self._transport, headers) or _DEFAULT_MAX_FINDINGS_PER_REQUEST ) - chunks = list(_scan_result_chunks(findings, scanned_paths, max_findings_per_request)) + chunks = list( + _scan_result_chunks( + findings, + scanned_paths, + max_findings_per_request, + force_no_mark_unseen=mark_unseen is False, + ) + ) created = 0 updated = 0 failures: list[FailedFinding] = [] diff --git a/src/wardline/core/legis.py b/src/wardline/core/legis.py index db6d5d69..87144974 100644 --- a/src/wardline/core/legis.py +++ b/src/wardline/core/legis.py @@ -302,10 +302,14 @@ def build_legis_artifact( Sign last, over the otherwise-complete scan: ``artifact_signature`` is added after the rest is in place, exactly as legis verifies (scan-minus-signature). """ - # Mirror gate_decision's exact fallback so the artifact tracks the operator's - # posture: secure-default -> gate_findings (baselined/judged/waived ride as - # active -> legis enforces them, the one-judge property); --trust-suppressions - # -> gate_findings is None -> honour the repo suppressions in ``findings``. + # Mirror gate_decision's exact population selection so the artifact tracks the gate: + # use ``gate_findings`` whenever it is present, falling back to ``findings`` only for + # the legacy ``None`` sentinel. Secure-default -> the unsuppressed population (baselined/ + # judged/waived ride as active -> legis enforces them, the one-judge property). A full + # --trust-suppressions scan -> ``gate_findings is None`` -> honour the repo suppressions + # in ``findings``. A delta --trust-suppressions scan MATERIALISES a concrete (post- + # suppression, pre-delta-filter) ``gate_findings`` so the artifact, like the gate, is the + # unfiltered analyzed population — never the delta display set (INV-4 / THREAT-001). gate_population = result.gate_findings if result.gate_findings is not None else result.findings findings = [project_finding(f) for f in gate_population] scan: dict[str, Any] = { diff --git a/src/wardline/core/run.py b/src/wardline/core/run.py index 9f618ad8..34161116 100644 --- a/src/wardline/core/run.py +++ b/src/wardline/core/run.py @@ -18,6 +18,12 @@ from wardline.core import config as config_mod from wardline.core.baseline import Baseline, load_baseline from wardline.core.delta import get_affected_entities, get_changed_files_since +from wardline.core.delta_resolve import ( + build_qualname_index, + filter_to_affected, + resolve_affected_scope, +) +from wardline.core.delta_scope import AffectedScope, DeltaScopeReport, ScopeParseError from wardline.core.discovery import discover, missing_source_roots from wardline.core.errors import ConfigError from wardline.core.finding import ( @@ -37,6 +43,7 @@ from wardline.core.waivers import WaiverSet, load_project_waivers if TYPE_CHECKING: + from wardline.loomweave.identity import SeiResolver from wardline.scanner.context import AnalysisContext @@ -46,6 +53,18 @@ def _fp(*parts: str) -> str: return digest.hexdigest() +def _relpath(file: Path, root: Path) -> str: + """Repo-relative POSIX path for ``file`` — the discovery/finding-location convention. + + Matches ``ScanResult.scanned_paths`` and ``delta_resolve._relpath`` so the delta scope's + ``resolved.files`` (which are these relpaths) line up with the discovered ``files``.""" + resolved_root = root.resolve() + resolved_file = file.resolve() + if resolved_file.is_relative_to(resolved_root): + return resolved_file.relative_to(resolved_root).as_posix() + return file.as_posix() + + @dataclass(frozen=True, slots=True) class ScanSummary: total: int # every finding (defects + facts/metrics) @@ -87,6 +106,36 @@ class ScanResult: # local ``--trust-suppressions`` / directly-constructed-ScanResult behaviour). It is # scoped by ``--new-since`` identically to ``findings``. gate_findings: list[Finding] | None = None + # Whether the gate population HONORS the repository suppressions (the + # ``--trust-suppressions`` posture). Historically this was inferred from the + # ``gate_findings is None`` sentinel, but a delta scan under ``--trust-suppressions`` + # must MATERIALISE a concrete gate population (the post-suppression, pre-delta-filter + # findings) so the gate is never the delta-FILTERED display set — an attacker- + # influenceable ``--affected`` scope must not forge a green (INV-4 / THREAT-001). That + # materialisation breaks the ``is None`` proxy, so the posture is carried EXPLICITLY + # here. ``None`` ⇒ derive from the legacy sentinel (``gate_findings is None``), so a + # directly-constructed ScanResult keeps its prior meaning. Read via ``honors_suppressions``. + gate_honors_suppressions: bool | None = None + # The delta-scan honesty/provenance block (``--affected``), or None for a full scan + # (so a full scan serialises no scope block — INV-1). Constructed by ``run_scan`` from + # the resolved scope + post-filter counts when ``affected`` is supplied. Carries + # ``files_discovered``/``files_analyzed`` and the boundary caveat — see + # ``wardline.core.delta_scope.DeltaScopeReport``. + scope: DeltaScopeReport | None = None + + @property + def honors_suppressions(self) -> bool: + """Whether the gate honors the repository suppressions (``--trust-suppressions``). + + Explicit when ``gate_honors_suppressions`` is set; otherwise derived from the legacy + ``gate_findings is None`` sentinel so a directly-constructed ScanResult is unchanged. + Decoupling the posture from the sentinel is what lets a delta scan materialise a + concrete (post-suppression, pre-delta-filter) gate population without flipping the + gate into "ignore suppressions" mode (INV-4: the gate is never the delta display set). + """ + if self.gate_honors_suppressions is not None: + return self.gate_honors_suppressions + return self.gate_findings is None _SEVERITY_VALUES: frozenset[str] = frozenset(s.value for s in Severity) @@ -166,6 +215,8 @@ def run_scan( cache_dir: Path | None = None, confine_to_root: bool = True, new_since: str | None = None, + affected: AffectedScope | None = None, + sei_resolver: SeiResolver | None = None, trust_local_packs: bool = False, trusted_packs: tuple[str, ...] = (), strict_defaults: bool = False, @@ -193,10 +244,32 @@ def run_scan( only for a trusted checkout, never for enforcement on untrusted PR content. The secure CI ratchet is the operator-supplied, unforgeable ``--new-since`` instead. + ``affected`` (default None) is the ``--affected`` delta scope: a parsed, producer- + supplied :class:`~wardline.core.delta_scope.AffectedScope`. When None the path is the + byte-identical full scan (INV-1 — no qualname index is built, no resolver is probed). + When supplied, discovery still walks the whole tree but only the files containing an + affected entity (caller-closure-expanded) reach the analyzer, and the EMITTED findings + are narrowed to those entities. The severity gate still evaluates the FULL unsuppressed + population (``gate_findings`` is NEVER narrowed — INV-4 / THREAT-001), so an attacker- + influenceable scope cannot forge a green. An empty/all-unresolvable scope falls back to + a full scan (fail-closed honesty, INV-3). Mutually exclusive with ``new_since`` + (composing them is a ``ScopeParseError``). The ``scope`` block on the result records the + mode/counts/caveat. + + ``sei_resolver`` (default None) is the loomweave SEI resolver, INJECTED by the caller + (CLI/MCP) — ``run_scan`` never constructs one, so it stays network-free. Used only to + resolve an affected entity's SEI to a current locator; absent/unavailable resolvers + degrade to the qualname-locator fallback. + ``lang`` (default ``"python"``) selects the language frontend: ``"python"`` is the released path (byte-identical to before this parameter existed); ``"rust"`` routes ``.rs`` discovery to the preview ``RustAnalyzer``. Any other value is a ``ConfigError``. """ + if affected is not None and new_since is not None: + # --affected and --new-since scope different things via different mechanisms + # (discovery/analysis pre-filter vs. operator-supplied gate ratchet); composing + # them is rejected loudly, never silently double-scoped. + raise ScopeParseError("--affected and --new-since are mutually exclusive") if lang not in FRONTENDS: known = ", ".join(f"'{k}'" for k in sorted(FRONTENDS)) raise ConfigError(f"unknown language {lang!r}; expected one of {known}") @@ -245,15 +318,57 @@ def run_scan( warn.lineno, ) analyzer: Analyzer = frontend.build_analyzer(config=cfg, summary_cache=cache) + # Delta scoping (--affected) lives BETWEEN discovery and analysis. When ``affected`` + # is None this whole block is short-circuited (INV-1): no qualname index is built and + # no SEI resolver is probed, so the full-scan path pays zero delta cost. When supplied, + # the engine analyzes only the files containing an affected entity (caller-closure- + # expanded). Each scoped file is still analyzed in FULL (whole-module context), so the + # only soundness gap is the declared inter-file one (spec §5.3a). Fail-closed: an empty + # resolution → analyze EVERYTHING (full-fallback, INV-3). + scope_mode: str | None = None + affected_qualnames: frozenset[str] = frozenset() + affected_files: frozenset[str] = frozenset() + entities_requested = 0 + fell_back_count = 0 + stale_sei_count = 0 + unresolved_entities: tuple[dict[str, str | None], ...] = () + loomweave_used = False + analyze_files = files + if affected is not None: + entities_requested = affected.item_count + index = build_qualname_index(files, root) + resolved = resolve_affected_scope(affected, index=index, sei_resolver=sei_resolver) + fell_back_count = len(resolved.fell_back) + stale_sei_count = len(resolved.stale_sei) + loomweave_used = resolved.loomweave_used + unresolved_entities = tuple({"locator": e.locator, "sei": e.sei} for e in resolved.unresolved) + if resolved.files: + scope_mode = "delta" + affected_qualnames = resolved.affected_qualnames + affected_files = resolved.files + analyze_files = [f for f in files if _relpath(f, root) in resolved.files] + else: + # Fail-closed: zero files resolved (empty / all-unresolvable / loomweave-absent + # + qualname-miss) → run the FULL analysis, declared as full-fallback (INV-3). + scope_mode = "full-fallback" if progress_callback is not None: - progress_callback({"phase": "analyzing", "files_discovered": len(files)}) - raw = list(analyzer.analyze(files, cfg, root=root)) + if scope_mode == "delta": + progress_callback( + { + "phase": "analyzing", + "files_discovered": len(files), + "files_analyzed": len(analyze_files), + } + ) + else: + progress_callback({"phase": "analyzing", "files_discovered": len(files)}) + raw = list(analyzer.analyze(analyze_files, cfg, root=root)) if progress_callback is not None: progress_callback( { "phase": "analyzed", "files_discovered": len(files), - "files_analyzed": len(files), + "files_analyzed": len(analyze_files), "findings": len(raw), } ) @@ -362,9 +477,9 @@ def run_scan( changed_files = get_changed_files_since(new_since, root) context = analyzer.last_context if context is not None: - affected = get_affected_entities(changed_files, context.entities, context.project_edges) + new_since_affected = get_affected_entities(changed_files, context.entities, context.project_edges) else: - affected = set() + new_since_affected = set() def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: # Suppress any ACTIVE defect outside the delta so the gate only fires on @@ -373,7 +488,9 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: scoped: list[Finding] = [] for f in candidates: if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE: - is_new = (f.location.path in changed_files) or (f.qualname is not None and f.qualname in affected) + is_new = (f.location.path in changed_files) or ( + f.qualname is not None and f.qualname in new_since_affected + ) if not is_new: f = replace( f, @@ -387,6 +504,32 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: if gate_findings is not None: gate_findings = apply_delta_scope(gate_findings) + # The gate posture is carried EXPLICITLY (not inferred from ``gate_findings is None``) + # so delta mode can materialise a concrete gate population without flipping the gate + # into "ignore suppressions" mode. ``None`` here ⇒ ScanResult derives the legacy + # sentinel meaning, preserving the full-scan path byte-for-byte (INV-1). + gate_honors_suppressions: bool | None = None + + # --affected finding filter: narrow the EMITTED findings to the affected entities only + # in delta mode (NOT full-fallback). The gate population is NEVER the delta-FILTERED + # display set — an attacker-influenceable ``--affected`` scope must not forge a green + # (INV-4 / THREAT-001). + # + # Secure default (trust_suppressions off): ``gate_findings`` already holds the full + # UNSUPPRESSED analyzed population, unfiltered — leave it untouched. + # + # ``--trust-suppressions`` on (``gate_findings is None`` by design): the gate would + # otherwise FALL BACK to ``result.findings`` — which is about to be delta-filtered, so + # a surgical-exclusion worklist could hide an in-analyzed-file ERROR from the gate. + # MATERIALISE the gate population HERE as the post-suppression / pre-delta-filter + # snapshot, and record that the posture still honors suppressions. Only the DISPLAYED + # ``findings`` then get the delta filter. + if scope_mode == "delta": + if trust_suppressions and gate_findings is None: + gate_findings = list(findings) + gate_honors_suppressions = True + findings = filter_to_affected(findings, affected_qualnames, affected_files) + defects = [f for f in findings if f.kind is Kind.DEFECT] summary = ScanSummary( total=len(findings), @@ -397,6 +540,24 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: informational=len(findings) - len(defects), unanalyzed=sum(1 for f in findings if f.rule_id in UNANALYZED_RULE_IDS), ) + # The delta scope honesty block (spec §5.4), attached only when --affected was supplied. + # ``gate_authority`` is the machine-readable companion: a delta scan is ADVISORY (the + # gate still runs over the full population, but a delta pass is type-distinguishable from + # a full pass), a full-fallback is the gate-of-record. + scope: DeltaScopeReport | None = None + if scope_mode is not None: + scope = DeltaScopeReport( + mode=scope_mode, + gate_authority="advisory" if scope_mode == "delta" else "gate-of-record", + entities_requested=entities_requested, + files_discovered=len(files), + files_analyzed=len(analyze_files), + in_scope_findings=len(findings), + fell_back_count=fell_back_count, + stale_sei_count=stale_sei_count, + unresolved_entities=unresolved_entities, + loomweave_used=loomweave_used, + ) resolved_root = root.resolve() return ScanResult( findings=findings, @@ -408,6 +569,8 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: for path in files ), gate_findings=gate_findings, + gate_honors_suppressions=gate_honors_suppressions, + scope=scope, ) @@ -444,13 +607,17 @@ def gate_decision(result: ScanResult, fail_on: Severity | None, *, fail_on_unana surfaces share it: the CLI exits on ``tripped`` and the MCP gate block serialises the same decision, so neither can drift. """ - # None SENTINEL: evaluate the unsuppressed gate population when present (secure - # default), else the suppressed ``findings`` (trusted ``--trust-suppressions`` / - # a directly-constructed ScanResult with no gate_findings). Population selection is - # LIFTED above the no-threshold branch so even a bare scan computes would_trip_at over - # the SAME population an actual --fail-on would judge (weft-b937e53854). - honors_suppressions = result.gate_findings is None - gate_population = result.findings if honors_suppressions else result.gate_findings + # Population selection is DECOUPLED from the suppression posture: the gate ALWAYS + # evaluates ``gate_findings`` when present, falling back to ``findings`` only when it is + # the legacy ``None`` sentinel (a full ``--trust-suppressions`` scan or a directly- + # constructed ScanResult). A delta ``--trust-suppressions`` scan MATERIALISES a concrete + # ``gate_findings`` (post-suppression, pre-delta-filter) so the gate is never the delta- + # FILTERED display set — an attacker-influenceable scope cannot forge a green (INV-4). + # ``honors_suppressions`` (the explicit posture, NOT the sentinel) only labels the + # ``evaluated`` string. Selection is LIFTED above the no-threshold branch so even a bare + # scan computes would_trip_at over the SAME population an actual --fail-on would judge. + honors_suppressions = result.honors_suppressions + gate_population = result.findings if result.gate_findings is None else result.gate_findings assert gate_population is not None # narrow for mypy; the sentinel branch set findings would_trip_at = _would_trip_at(gate_population) evaluated = ( @@ -520,7 +687,10 @@ def baseline_migration_hint( if not decision.tripped or decision.fail_on is None or new_since is not None: return None # --trust-suppressions honors the baseline, so there is no surprise to migrate from. - if result.gate_findings is None: + # Use the explicit posture, NOT the ``gate_findings is None`` sentinel: a delta + # --trust-suppressions scan materialises a concrete gate population but still honors + # suppressions, so it must be treated identically to a full trusted run here. + if result.honors_suppressions: return None if not baseline_path(root).is_file(): return None @@ -558,9 +728,14 @@ def _gate_reason(result: ScanResult, fail_on: Severity, *, tripped: bool, honors return f"no {sev}+ defects in the evaluated population" # Under --trust-suppressions the gate IS the annotated findings (suppressions # honored), so only genuinely-active defects can have tripped it; never misdirect to - # the suppression flags. + # the suppression flags. Count over the GATE population, not the emitted ``findings``: + # a delta scan materialises a concrete (post-suppression, pre-delta-filter) gate + # population while ``findings`` is the narrowed display set — counting the display set + # would understate the trip. For a full scan ``gate_findings`` is the ``None`` sentinel, + # so this falls back to ``findings`` and is byte-identical to before. if honors_suppressions: - active, _ = gate_breakdown(result.findings, fail_on) + honored_pop = result.gate_findings if result.gate_findings is not None else result.findings + active, _ = gate_breakdown(honored_pop, fail_on) return f"{active} active {sev}+ defect(s) at or above {sev}" # Secure default: classify the defects that ACTUALLY gate (the unsuppressed gate # population) by their state in the emitted findings. A ``--new-since`` delta scopes diff --git a/src/wardline/core/sarif.py b/src/wardline/core/sarif.py index 9e92a2cb..339aaa41 100644 --- a/src/wardline/core/sarif.py +++ b/src/wardline/core/sarif.py @@ -125,7 +125,12 @@ def _result(finding: Finding, rule_index: int, context: AnalysisContext | None = return result -def build_sarif(findings: Sequence[Finding], context: AnalysisContext | None = None) -> dict[str, Any]: +def build_sarif( + findings: Sequence[Finding], + context: AnalysisContext | None = None, + *, + run_properties: dict[str, object] | None = None, +) -> dict[str, Any]: """Build a SARIF 2.1.0 log with a single run from *findings* (pure). ``Kind.METRIC`` findings (engine telemetry such as WLN-L3-LOW-RESOLUTION @@ -133,6 +138,13 @@ def build_sarif(findings: Sequence[Finding], context: AnalysisContext | None = N diagnostic statistics about the scan run itself — not actionable code issues — and pollute GitHub Code Scanning with noise alerts. The full picture (including METRIC findings) is always available in the JSONL sink. + + ``run_properties`` (default None) is an optional run-level ``properties`` bag + threaded into ``runs[0].properties``. The CLI passes + ``{"wardline_delta_scope": result.scope.to_dict()}`` for a ``--affected`` delta + scan so the SARIF carries the same scope/honesty block as the other formats. + Defaulted/optional so existing callers (dogfood self-scan, other ``SarifSink`` + users) are unaffected — a full scan emits no ``runs[0].properties`` key. """ included = [f for f in findings if f.kind is not Kind.METRIC] rule_index: dict[str, int] = {} @@ -141,22 +153,23 @@ def build_sarif(findings: Sequence[Finding], context: AnalysisContext | None = N rule_index[finding.rule_id] = len(rule_index) rules = [{"id": rid} for rid in rule_index] results = [_result(f, rule_index[f.rule_id], context) for f in included] + run: dict[str, Any] = { + "tool": { + "driver": { + "name": "wardline", + "informationUri": _INFO_URI, + "version": __version__, + "rules": rules, + } + }, + "results": results, + } + if run_properties is not None: + run["properties"] = dict(run_properties) return { "version": "2.1.0", "$schema": _SCHEMA, - "runs": [ - { - "tool": { - "driver": { - "name": "wardline", - "informationUri": _INFO_URI, - "version": __version__, - "rules": rules, - } - }, - "results": results, - } - ], + "runs": [run], } @@ -165,8 +178,16 @@ def __init__(self, path: Path, *, root: Path | None = None) -> None: self._path = path self._root = root - def write(self, findings: Sequence[Finding], context: AnalysisContext | None = None) -> None: - content = json.dumps(build_sarif(findings, context), indent=2, ensure_ascii=False) + def write( + self, + findings: Sequence[Finding], + context: AnalysisContext | None = None, + *, + run_properties: dict[str, object] | None = None, + ) -> None: + content = json.dumps( + build_sarif(findings, context, run_properties=run_properties), indent=2, ensure_ascii=False + ) if self._root is not None: safe_write_text(self._root, self._path, content, label=self._path.name) else: diff --git a/src/wardline/core/sei_resolution.py b/src/wardline/core/sei_resolution.py index 5c7031a7..46577961 100644 --- a/src/wardline/core/sei_resolution.py +++ b/src/wardline/core/sei_resolution.py @@ -13,9 +13,13 @@ def locator_to_qualname(locator: str) -> str: """Extract a Wardline qualname from a Loomweave locator string. - A locator looks like 'python:function:pkg.mod.func' or 'python:class:pkg.mod.Class'. + A locator looks like 'python:function:pkg.mod.func', 'python:method:pkg.mod.Cls.m', + or 'python:class:pkg.mod.Class'. """ - for prefix in ("python:function:", "python:class:", "python:"): + # 'python:method:' MUST precede the generic 'python:' catch-all: loomweave emits + # kind="method" for class members (scanner/index.py: Entity.kind), and the catch-all + # would otherwise return 'method:pkg.mod.Cls.m', which never matches the qualname index. + for prefix in ("python:function:", "python:method:", "python:class:", "python:"): if locator.startswith(prefix): return locator[len(prefix) :] return locator diff --git a/src/wardline/mcp/server.py b/src/wardline/mcp/server.py index 40f7ffee..203d2f7c 100644 --- a/src/wardline/mcp/server.py +++ b/src/wardline/mcp/server.py @@ -20,6 +20,7 @@ from wardline.core.attest import build_attestation, verify_attestation from wardline.core.attest_key import load_attest_key from wardline.core.baseline import generate_baseline, load_baseline +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 @@ -44,20 +45,28 @@ # (the "facts carry no defect severity" sentinel), deliberately excluded here: # fail_on=NONE is not a meaningful gate threshold. _SEVERITY_ENUM = ["CRITICAL", "ERROR", "WARN", "INFO"] +# Closed vocabularies are pinned with a case-insensitive `pattern` so jsonschema rejects an +# off-vocab string (fail_on="BOGUS") at the argument-validation layer, not only at runtime. +# The `(?i)` inline flag is honoured by jsonschema's Python-`re` pattern backend; the vocab is +# case-insensitive (runtime lowercases) so a plain upper-case enum would wrongly reject "error". _FAIL_ON_INPUT_SCHEMA = { "type": "string", + "pattern": "(?i)^(critical|error|warn|info)$", "description": "Gate threshold. Allowed values, case-insensitive: CRITICAL, ERROR, WARN, INFO.", } _WHERE_SEVERITY_INPUT_SCHEMA = { "type": "string", + "pattern": "(?i)^(critical|error|warn|info|none)$", "description": "Severity filter. Allowed values, case-insensitive: CRITICAL, ERROR, WARN, INFO, NONE.", } _WHERE_SUPPRESSION_INPUT_SCHEMA = { "type": "string", + "pattern": "(?i)^(active|baselined|waived|judged)$", "description": "Suppression-state filter. Allowed values, case-insensitive: active, baselined, waived, judged.", } _WHERE_KIND_INPUT_SCHEMA = { "type": "string", + "pattern": "(?i)^(defect|fact|classification|metric|suggestion)$", "description": ( "Finding kind filter. Allowed values, case-insensitive: defect, fact, classification, metric, suggestion." ), @@ -74,17 +83,25 @@ def _emit_filigree( - findings: list[Finding], filigree: Any, *, scanned_paths: tuple[str, ...] = () + findings: list[Finding], + filigree: Any, + *, + scanned_paths: tuple[str, ...] = (), + mark_unseen: bool | None = None, ) -> dict[str, Any] | None: """Emit to Filigree for the MCP `scan`, returning None when no emitter is injected. Sibling-unreachable / 5xx results are already soft in FiligreeEmitter as ``reachable=False``. Protocol/client rejections stay loud by letting ``FiligreeEmitError`` propagate into the tool's isError result. + + ``mark_unseen`` forwards the reconciliation control: None lets the emitter + auto-enable mark_unseen for a full scan; False suppresses it for a delta scan + (INV-5 — see the call site). """ if filigree is None: return None - er = filigree.emit(findings, scanned_paths=scanned_paths) + er = filigree.emit(findings, scanned_paths=scanned_paths, mark_unseen=mark_unseen) return { "reachable": er.reachable, "created": er.created, @@ -803,6 +820,25 @@ def _scan( # A4 (wardline-7fd0f3a82c): the CLI's --fail-on-unanalyzed knob, same default (off). fail_on_unanalyzed = _bool_arg(args, "fail_on_unanalyzed", False) new_since = args.get("new_since") + # --affected delta scope: an inline worklist/entity-list object|array, or a path under + # root to one. The inline form is the MCP-primary ergonomic and bypasses + # _resolve_under_root confinement — acceptable because INV-4 makes the scope's trust + # level moot for the gate (the gate always evaluates the full population). A malformed + # payload is the loud ScopeParseError -> isError result, matching the CLI's exit-2 posture. + affected_arg = args.get("affected") + affected = None + if affected_arg is not None: + if new_since is not None: + # --affected and --new-since scope different things via different mechanisms; + # composing them is rejected loudly, never silently double-scoped (mirrors the CLI). + raise ToolError("affected and new_since are mutually exclusive") + try: + if isinstance(affected_arg, str): + affected = load_affected_scope(str(_resolve_under_root(root, affected_arg))) + else: + affected = parse_affected_scope(affected_arg) + except ScopeParseError as exc: + raise ToolError(str(exc)) from exc trusted_packs = _trusted_packs_arg(args) cache_dir = _cache_dir_arg(args, root) # _bool_arg, not bool(...): without jsonschema the handler runs unvalidated, and @@ -813,12 +849,26 @@ def _scan( # A1 (wardline-2ee1bbda82): the same frontend selector the CLI's --lang exposes. # A bad value is run_scan's ConfigError (names the valid set) -> isError result. lang = args.get("lang") or "python" + # The SEI resolver is INJECTED, never self-constructed by run_scan (which stays + # network-free). Build it from the already-injected loomweave client when a delta scope + # is in play; absent/unsupported loomweave degrades to the spoofable qualname-locator path. + sei_resolver = None + if affected is not None and loomweave is not None: + from wardline.loomweave.identity import SeiResolver + + try: + sei_resolver = SeiResolver.detect(loomweave) + except WardlineError: + # Loomweave probe failed (outage / pre-SEI 404) — fail-soft to qualname fallback. + sei_resolver = None result = run_scan( path, config_path=_cfg(args, root), cache_dir=cache_dir, confine_to_root=True, new_since=new_since, + affected=affected, + sei_resolver=sei_resolver, trust_local_packs=trust_local_packs, trusted_packs=trusted_packs, strict_defaults=strict_defaults, @@ -848,7 +898,18 @@ def _scan( } decision = gate_decision(result, threshold, fail_on_unanalyzed=fail_on_unanalyzed) migration_hint = baseline_migration_hint(result, decision, root=path, new_since=new_since) - filigree_block = _emit_filigree(result.findings, filigree, scanned_paths=result.scanned_paths) + # INV-5: a delta scan emits the FULL discovery list as scanned_paths but a FILTERED + # findings list, so Filigree's auto mark_unseen would read every out-of-scope finding as + # fixed and close its issue (irreversible signal loss). Force mark_unseen=False in delta + # mode; full / full-fallback scans reconcile normally (mark_unseen=None -> auto). Mirrors + # the CLI guard at cli/scan.py. + delta_mode = result.scope is not None and result.scope.mode == "delta" + filigree_block = _emit_filigree( + result.findings, + filigree, + scanned_paths=result.scanned_paths, + mark_unseen=False if delta_mode else None, + ) filigree_status = _filigree_emit_status(filigree_block) loomweave_status = _loomweave_write_status(loomweave_block) where = args.get("where") @@ -970,6 +1031,10 @@ def _scan( "filigree_emit": filigree_status, "agent_summary": agent_summary, } + # The delta-scan honesty/provenance block (--affected); absent on a full scan so the + # structured payload is byte-identical to today when no scope was requested (INV-1). + if result.scope is not None: + response["scope"] = result.scope.to_dict() _attach_legis_artifact( response, result, @@ -987,7 +1052,10 @@ def _scan( "description": "Success payload of the wardline MCP `scan` tool (the dict _scan returns, served verbatim as " "structuredContent).", "properties": { - "files_scanned": {"type": "integer", "description": "Number of files discovered and handed to the analyzer."}, + "files_scanned": { + "type": "integer", + "description": "Number of files discovered (see scope.files_analyzed for the delta-mode analyzed count).", + }, "summary": { "type": "object", "description": "Whole-project finding counts. active+baselined+waived+judged+informational == total; " @@ -1315,6 +1383,91 @@ def _scan( ], "additionalProperties": False, }, + "scope": { + "type": "object", + "description": "OPTIONAL: the delta-scan (--affected) honesty/provenance block. Present only when an " + "`affected` scope was supplied; absent on a full scan. Declares whether the run analyzed a scoped subset " + "(mode='delta', gate_authority='advisory') or fell back to a full scan (mode='full-fallback', " + "gate_authority='gate-of-record'). The severity gate ALWAYS evaluates the full population regardless of " + "scope (INV-4) — a delta pass is advisory, not a verdict.", + "properties": { + "mode": { + "type": "string", + "enum": ["delta", "full-fallback"], + "description": "delta = a scoped file subset was analyzed; full-fallback = the scope resolved zero " + "files (empty / all-unresolvable) so a full scan ran (fail-closed honesty).", + }, + "gate_authority": { + "type": "string", + "enum": ["advisory", "gate-of-record"], + "description": "Machine-readable companion to mode: advisory in delta mode (a delta pass is " + "type-distinguishable from a full pass), gate-of-record in full-fallback.", + }, + "entities_requested": { + "type": "integer", + "description": "Number of input items in the supplied affected scope.", + }, + "files_discovered": { + "type": "integer", + "description": "Files discovered (== top-level files_scanned).", + }, + "files_analyzed": { + "type": "integer", + "description": "Files actually analyzed; the scoped subset in delta mode, == files_discovered in " + "full-fallback.", + }, + "in_scope_findings": { + "type": "integer", + "description": "Displayed (post-filter) finding count for the affected entities.", + }, + "fell_back_count": { + "type": "integer", + "description": "Entities resolved via the spoofable qualname-locator path rather than an " + "authoritative SEI.", + }, + "stale_sei_count": { + "type": "integer", + "description": "Entities whose SEI resolved to a now-absent qualname (loomweave stale vs working " + "tree).", + }, + "unresolved_entities": { + "type": "array", + "description": "Entities that did not resolve to any file even in delta mode (scanned subset, not " + "fallback).", + "items": { + "type": "object", + "properties": { + "locator": {"type": ["string", "null"]}, + "sei": {"type": ["string", "null"]}, + }, + "additionalProperties": False, + }, + }, + "loomweave_used": { + "type": "boolean", + "description": "Whether the authoritative SEI (loomweave) resolution path fired for any entity.", + }, + "boundary_caveat": { + "type": "string", + "description": "The fixed honesty caveat naming the in-scope-correctness hazard (cross-file taint " + "outside the analyzed set is not computed).", + }, + }, + "required": [ + "mode", + "gate_authority", + "entities_requested", + "files_discovered", + "files_analyzed", + "in_scope_findings", + "fell_back_count", + "stale_sei_count", + "unresolved_entities", + "loomweave_used", + "boundary_caveat", + ], + "additionalProperties": False, + }, "legis_artifact": { "type": "object", "description": "OPTIONAL: the verbatim-postable signed scan object for legis POST /wardline/scan-results. " @@ -1834,6 +1987,13 @@ def _scan( "description": "PR-scoped 'new findings only' gate: only gate on findings in " "files/entities changed since this git ref", }, + "affected": { + "type": ["object", "array", "string"], + "description": "Scan only entities in this warpline reverify-worklist (warpline." + "reverify_worklist.v1) or bare entity list, or a path to one. Speed, not truth: " + "cross-file flows outside the affected set are not analyzed (see scope block). " + "Empty/unresolvable input falls back to a full scan.", + }, "cache_dir": { "type": "string", "description": "subdir relative to project root for summary cache", diff --git a/tests/conformance/fixtures/warpline_delta/sample_tree/a.py b/tests/conformance/fixtures/warpline_delta/sample_tree/a.py new file mode 100644 index 00000000..b0cbc35a --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/sample_tree/a.py @@ -0,0 +1,25 @@ +"""Affected module for the warpline delta-scope golden. + +``alpha`` is the entity the reverify worklist names; ``gamma`` is a CO-LOCATED entity +in the same file that the worklist does NOT name. Both carry a PY-WL-101 ERROR (a +``@trusted`` function returning the result of an ``@external_boundary`` source). The +golden asserts the delta DISPLAY shows only ``alpha`` while the gate population retains +``gamma`` (gate-not-narrowed axis / INV-4). +""" + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def read_raw(p): + return p + + +@trusted +def alpha(p): + return read_raw(p) + + +@trusted +def gamma(p): + return read_raw(p) diff --git a/tests/conformance/fixtures/warpline_delta/sample_tree/b.py b/tests/conformance/fixtures/warpline_delta/sample_tree/b.py new file mode 100644 index 00000000..6dc60c75 --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/sample_tree/b.py @@ -0,0 +1,18 @@ +"""Unaffected module for the warpline delta-scope golden. + +``beta`` carries its own PY-WL-101 ERROR, but no worklist names anything in this file. +The golden asserts ``b.py`` is NOT analyzed in delta mode (scoped-file-set axis) and so +``beta`` is absent from both the display and the (a.py-scoped) gate population. +""" + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def read_raw(p): + return p + + +@trusted +def beta(p): + return read_raw(p) diff --git a/tests/conformance/fixtures/warpline_delta/sample_tree/sink_mod.py b/tests/conformance/fixtures/warpline_delta/sample_tree/sink_mod.py new file mode 100644 index 00000000..e369f883 --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/sample_tree/sink_mod.py @@ -0,0 +1,17 @@ +"""The caller/sink of ``source_mod.tainted_source`` (caller-closure axis, spec §5.3a). + +The PY-WL-101 taint finding anchors HERE (``downstream_sink``), downstream of the source +the worklist names. It is only computed when this file reaches the analyzer — which, for a +worklist that names ``source_mod.tainted_source``, happens ONLY because the reverse-edge +caller closure pulls this caller file in. The negative case (scanning the source module +alone) yields no finding, proving the closure is load-bearing. +""" + +from source_mod import tainted_source + +from wardline.decorators import trusted + + +@trusted +def downstream_sink(p): + return tainted_source(p) diff --git a/tests/conformance/fixtures/warpline_delta/sample_tree/source_mod.py b/tests/conformance/fixtures/warpline_delta/sample_tree/source_mod.py new file mode 100644 index 00000000..7703b1d2 --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/sample_tree/source_mod.py @@ -0,0 +1,15 @@ +"""The changed callee/source the worklist names (caller-closure axis, spec §5.3a). + +Warpline's worklist is a "changed + DOWNSTREAM (callee)" set, but a taint finding anchors +caller-side (at the sink). This module defines the ``@external_boundary`` source; the +PY-WL-101 finding for the flow lands in ``sink_mod.downstream_sink``, NOT here. A worklist +naming ``tainted_source`` must pull ``sink_mod.py`` into the analyzed set via the reverse- +edge caller closure so the sink finding is computed at all. +""" + +from wardline.decorators import external_boundary + + +@external_boundary +def tainted_source(p): + return p diff --git a/tests/conformance/fixtures/warpline_delta/worklist_all_unresolvable.v1.json b/tests/conformance/fixtures/warpline_delta/worklist_all_unresolvable.v1.json new file mode 100644 index 00000000..eb62325e --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/worklist_all_unresolvable.v1.json @@ -0,0 +1,25 @@ +{ + "schema": "warpline.reverify_worklist.v1", + "data": { + "generated_at": "2026-06-18T00:00:00Z", + "items": [ + { + "entity": { + "locator": "python:function:ghost.module.nowhere", + "sei": null + }, + "priority": "low", + "reason": "stale", + "depth": 3, + "why": ["names an entity that no longer exists in the working tree"], + "suggested_verification": [], + "enrichment": { + "work": null, + "risk": null, + "governance": null, + "requirements": [] + } + } + ] + } +} diff --git a/tests/conformance/fixtures/warpline_delta/worklist_alpha.v1.json b/tests/conformance/fixtures/warpline_delta/worklist_alpha.v1.json new file mode 100644 index 00000000..509f78fe --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/worklist_alpha.v1.json @@ -0,0 +1,27 @@ +{ + "schema": "warpline.reverify_worklist.v1", + "data": { + "generated_at": "2026-06-18T00:00:00Z", + "items": [ + { + "entity": { + "locator": "python:function:a.alpha", + "sei": null + }, + "priority": "high", + "reason": "changed", + "depth": 0, + "why": ["edited in HEAD"], + "suggested_verification": [ + {"kind": "scan", "command": "wardline scan . --affected -"} + ], + "enrichment": { + "work": null, + "risk": "medium", + "governance": null, + "requirements": [] + } + } + ] + } +} diff --git a/tests/conformance/fixtures/warpline_delta/worklist_caller_closure.v1.json b/tests/conformance/fixtures/warpline_delta/worklist_caller_closure.v1.json new file mode 100644 index 00000000..c5a67bd1 --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/worklist_caller_closure.v1.json @@ -0,0 +1,27 @@ +{ + "schema": "warpline.reverify_worklist.v1", + "data": { + "generated_at": "2026-06-18T00:00:00Z", + "items": [ + { + "entity": { + "locator": "python:function:source_mod.tainted_source", + "sei": null + }, + "priority": "high", + "reason": "changed", + "depth": 0, + "why": ["the @external_boundary source was edited"], + "suggested_verification": [ + {"kind": "scan", "command": "wardline scan . --affected -"} + ], + "enrichment": { + "work": null, + "risk": "high", + "governance": null, + "requirements": [] + } + } + ] + } +} diff --git a/tests/conformance/fixtures/warpline_delta/worklist_partial.v1.json b/tests/conformance/fixtures/warpline_delta/worklist_partial.v1.json new file mode 100644 index 00000000..1bd34271 --- /dev/null +++ b/tests/conformance/fixtures/warpline_delta/worklist_partial.v1.json @@ -0,0 +1,32 @@ +{ + "schema": "warpline.reverify_worklist.v1", + "data": { + "generated_at": "2026-06-18T00:00:00Z", + "items": [ + { + "entity": { + "locator": "python:function:a.alpha", + "sei": null + }, + "priority": "high", + "reason": "changed", + "depth": 0, + "why": ["edited in HEAD"], + "suggested_verification": [], + "enrichment": {"work": null, "risk": "medium", "governance": null, "requirements": []} + }, + { + "entity": { + "locator": "python:function:ghost.module.nowhere", + "sei": null + }, + "priority": "low", + "reason": "stale", + "depth": 2, + "why": ["names an entity that no longer exists"], + "suggested_verification": [], + "enrichment": {"work": null, "risk": null, "governance": null, "requirements": []} + } + ] + } +} diff --git a/tests/conformance/test_mcp_structured_output.py b/tests/conformance/test_mcp_structured_output.py index bbbb1f17..00c8d8fd 100644 --- a/tests/conformance/test_mcp_structured_output.py +++ b/tests/conformance/test_mcp_structured_output.py @@ -176,6 +176,16 @@ def test_scan_structured_output(tmp_path: Path) -> None: assert out["gate"]["tripped"] is True +def test_scan_delta_structured_output_pins_scope_present(tmp_path: Path) -> None: + # The OPTIONAL scope block stays schema-valid only if a delta invocation actually + # exercises it — an absent optional property would pass even if its sub-schema were + # malformed. This is the one execution-conformance case that pins scope PRESENT. + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "scan", {"full": True, "affected": [{"locator": "python:function:svc.leaky"}]}) + assert out["scope"]["mode"] == "delta" + assert out["scope"]["gate_authority"] == "advisory" + + def _job_status_stub(job_id: str = "a" * 32, status: str = "running") -> dict[str, Any]: """A schema-valid scan-job status payload (the core's _base_status shape).""" return { diff --git a/tests/conformance/test_warpline_delta_scope.py b/tests/conformance/test_warpline_delta_scope.py new file mode 100644 index 00000000..2ce09905 --- /dev/null +++ b/tests/conformance/test_warpline_delta_scope.py @@ -0,0 +1,247 @@ +"""Warpline delta-scope conformance golden (hermetic, every PR, no sibling). + +This is the always-on contract guard for ``wardline scan --affected`` (spec §8). It +pins the delta-scope behavior against a FIXED ``warpline.reverify_worklist.v1`` fixture +and a small sample tree, **importing nothing from a warpline package** — the worklist +shape is vendored faithfully from spec §9, exactly as ``test_legis_intake_contract.py`` +vendors the legis ingest contract and ``test_sei_oracle.py`` vendors the loomweave SEI +oracle. Warpline is the fixed external producer; wardline is the consumer of the scope +set. There is no live sibling here (the SEI path is covered by the Phase 2 unit double +and the Phase 11 ``warpline_e2e`` live oracle); these goldens run on every PR with no +network and no binary. + +The sample tree (``fixtures/warpline_delta/sample_tree/``): + +* ``a.py`` — the affected module: ``alpha`` (named by the worklist) and ``gamma`` (a + CO-LOCATED entity NOT named) each carry a PY-WL-101 ERROR. +* ``b.py`` — an unaffected module whose ``beta`` carries its own PY-WL-101 ERROR; no + worklist names it, so ``b.py`` is never analyzed in delta mode. +* ``source_mod.py`` / ``sink_mod.py`` — a CROSS-FILE taint flow: the ``@external_boundary`` + source lives in ``source_mod`` (the changed callee the worklist names), and the + PY-WL-101 finding anchors caller-side in ``sink_mod.downstream_sink``. Proves the + reverse-edge caller closure (spec §5.3a) pulls the caller file in. + +The seven axes asserted below mirror the plan's Phase 6 list (the spec's three golden +axes — scoped file set, filtered finding set, scope block — plus fallback, the +unresolved-entity path, the load-bearing caller-closure / inter-file taint axis with a +negative case, and the gate-not-narrowed axis that re-states INV-4 at the golden level). +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +from wardline.core.delta_resolve import build_qualname_index, resolve_affected_scope +from wardline.core.delta_scope import BOUNDARY_CAVEAT, parse_affected_scope +from wardline.core.finding import Severity +from wardline.core.run import gate_decision, run_scan + +_FIXTURES = Path(__file__).parent / "fixtures" / "warpline_delta" +_SAMPLE_TREE = _FIXTURES / "sample_tree" + + +def _load_worklist(name: str) -> object: + """Read a vendored ``warpline.reverify_worklist.v1`` fixture as decoded JSON. + + The fixture shape is the faithful spec §9 envelope (``{schema, data: {items: + [{entity:{locator, sei}, priority, reason, depth, why, suggested_verification, + enrichment}]}}``); only ``items[].entity.{locator, sei}`` is load-bearing for the + parser, and the rich surrounding fields prove it tolerates the real producer shape.""" + return json.loads((_FIXTURES / name).read_text(encoding="utf-8")) + + +def _project(tmp_path: Path) -> Path: + """Copy the sample tree into a writable scan root (the convention other conformance + goldens follow — never scan the fixtures dir in place).""" + proj = tmp_path / "proj" + shutil.copytree(_SAMPLE_TREE, proj) + return proj + + +def _py101(findings: object) -> set[tuple[str | None, str]]: + assert isinstance(findings, list) + return {(f.qualname, f.location.path) for f in findings if f.rule_id == "PY-WL-101"} + + +# --- Axis 1: scoped file set ------------------------------------------------------ + + +def test_axis1_scoped_file_set(tmp_path: Path) -> None: + """A worklist naming ``a.alpha`` analyzes ONLY ``a.py``; ``b.py``'s ``beta`` finding + is absent because ``b.py`` never reaches the analyzer (spec §5.3).""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_alpha.v1.json")) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + assert result.scope.mode == "delta" + assert result.scope.files_analyzed == 1 + assert result.scope.files_discovered == 4 + # b.py's beta finding is absent from the displayed set (b.py was not analyzed). + displayed = _py101(result.findings) + assert ("b.beta", "b.py") not in displayed + assert all(path == "a.py" for _, path in displayed) + + +# --- Axis 2: filtered finding set ------------------------------------------------- + + +def test_axis2_filtered_finding_set(tmp_path: Path) -> None: + """The affected entity ``alpha``'s finding is displayed; the CO-LOCATED non-affected + ``gamma`` finding (same analyzed file) is filtered out of the display (spec §5.3).""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_alpha.v1.json")) + + result = run_scan(proj, affected=scope) + + displayed = _py101(result.findings) + assert ("a.alpha", "a.py") in displayed + assert ("a.gamma", "a.py") not in displayed + + +# --- Axis 3: the scope block ------------------------------------------------------ + + +def test_axis3_scope_block(tmp_path: Path) -> None: + """The ``scope`` honesty block carries delta mode, the requested-entity count, the + analyzed-file count, the in-scope finding count, and the EXACT boundary caveat.""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_alpha.v1.json")) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + block = result.scope + assert block.mode == "delta" + assert block.gate_authority == "advisory" + assert block.entities_requested == 1 + assert block.files_analyzed == 1 + assert block.in_scope_findings == 1 + assert block.boundary_caveat == BOUNDARY_CAVEAT + # The caveat names the in-scope-correctness hazard, not just out-of-scope omission. + assert "can read clean without being clean" in block.boundary_caveat + + +# --- Axis 4: fail-closed full-fallback -------------------------------------------- + + +def test_axis4_full_fallback_when_all_unresolvable(tmp_path: Path) -> None: + """An all-unresolvable worklist falls back to a FULL scan (fail-closed, INV-3): mode + ``full-fallback``, ``gate-of-record`` authority, every file analyzed, full display.""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_all_unresolvable.v1.json")) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + assert result.scope.mode == "full-fallback" + assert result.scope.gate_authority == "gate-of-record" + assert result.scope.files_analyzed == result.scope.files_discovered == 4 + # The full population is displayed in full-fallback (no silent narrow). + displayed = _py101(result.findings) + assert ("a.alpha", "a.py") in displayed + assert ("a.gamma", "a.py") in displayed + assert ("b.beta", "b.py") in displayed + + +# --- Axis 5: the unresolved-entity path ------------------------------------------- + + +def test_axis5_partial_resolution_lists_unresolved(tmp_path: Path) -> None: + """A worklist with one resolvable (``a.alpha``) + one bogus entity stays in DELTA mode + (not fallback — >0 files resolved), lists the bogus entity in ``unresolved_entities``, + and scopes the analysis to the resolved subset (spec §7).""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_partial.v1.json")) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + block = result.scope + assert block.mode == "delta" # NOT full-fallback — a.alpha resolved + assert block.files_analyzed == 1 + assert block.entities_requested == 2 + unresolved_locators = {e.get("locator") for e in block.unresolved_entities} + assert "python:function:ghost.module.nowhere" in unresolved_locators + assert "python:function:a.alpha" not in unresolved_locators + # The resolvable entity's finding is displayed. + assert ("a.alpha", "a.py") in _py101(result.findings) + + +# --- Axis 6: caller-closure / inter-file taint (load-bearing, spec §5.3a) ---------- + + +def test_axis6_caller_closure_computes_inter_file_sink(tmp_path: Path) -> None: + """A worklist naming the changed CALLEE/source (``source_mod.tainted_source``) pulls + the caller file ``sink_mod.py`` into the analyzed set via the reverse-edge closure, so + the cross-file taint finding (which anchors caller-side in ``sink_mod.downstream_sink``) + IS computed. This pins the inter-file gap as covered, not latent.""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_caller_closure.v1.json")) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + assert result.scope.mode == "delta" + # Closure expanded the analyzed set from {source_mod.py} to include sink_mod.py. + assert result.scope.files_analyzed == 2 + # The caller-side sink finding was computed (present in the gate population — it is not + # in the DISPLAY because the base affected entity is the source, not the sink; INV-4). + assert result.gate_findings is not None + assert any(f.qualname == "sink_mod.downstream_sink" and f.rule_id == "PY-WL-101" for f in result.gate_findings) + + +def test_axis6_negative_without_closure_the_finding_is_missing(tmp_path: Path) -> None: + """The negative case proving the closure is what saves it: the resolution layer's BASE + file set for the source-naming worklist is ``{source_mod.py}`` (the closure adds + ``sink_mod.py``), and scanning ``source_mod.py`` ALONE — i.e. with no caller in the + analyzed set, the closure-disabled outcome — computes NO ``downstream_sink`` finding.""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_caller_closure.v1.json")) + + # The closure is the only reason sink_mod.py enters the analyzed set: the BASE affected + # qualname set is just the source, and the source's own file is the only base file. + files = sorted(proj.glob("*.py")) + index = build_qualname_index(files, proj) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.affected_qualnames == frozenset({"source_mod.tainted_source"}) + assert resolved.files == frozenset({"source_mod.py", "sink_mod.py"}) + + # With the caller file absent (closure disabled), the sink finding does not exist at + # all: scan a tree containing ONLY the source module. + source_only = tmp_path / "source_only" + source_only.mkdir() + shutil.copy(proj / "source_mod.py", source_only / "source_mod.py") + bare = run_scan(source_only) + assert not any(f.qualname == "sink_mod.downstream_sink" for f in bare.findings) + assert not any(f.rule_id == "PY-WL-101" for f in bare.findings) + + +# --- Axis 7: gate-not-narrowed (INV-4 / THREAT-001 at the golden level) ------------ + + +def test_axis7_gate_population_not_narrowed(tmp_path: Path) -> None: + """The CO-LOCATED non-affected ``gamma`` finding is ABSENT from the displayed findings + but PRESENT in ``gate_findings`` — the display filter narrows only the display, never + the gate population, so an attacker-influenceable scope cannot forge a green (INV-4). + The delta gate verdict is IDENTICAL to the full scan's over the same population.""" + proj = _project(tmp_path) + scope = parse_affected_scope(_load_worklist("worklist_alpha.v1.json")) + + delta = run_scan(proj, affected=scope) + + # gamma dropped from the DISPLAY but live in the GATE population. + assert ("a.gamma", "a.py") not in _py101(delta.findings) + assert delta.gate_findings is not None + assert ("a.gamma", "a.py") in _py101(delta.gate_findings) + + # The delta gate cannot green a real ERROR: its verdict matches a full scan's gate over + # the same (a.py) population. (b.py is not analyzed in delta; the gate axis here is the + # co-located gamma the DISPLAY excluded yet the gate retains.) + delta_decision = gate_decision(delta, Severity.ERROR) + assert delta_decision.tripped is True + assert delta_decision.verdict == "FAILED" + assert delta_decision.exit_class == 1 diff --git a/tests/conformance/test_warpline_e2e.py b/tests/conformance/test_warpline_e2e.py new file mode 100644 index 00000000..620367c2 --- /dev/null +++ b/tests/conformance/test_warpline_e2e.py @@ -0,0 +1,174 @@ +"""Live `warpline reverify | wardline scan --affected -` round-trip oracle. + +Deselected by default (marker ``warpline_e2e``); run with: + WARDLINE_WARPLINE_BIN= .venv/bin/pytest -m warpline_e2e -v + +This is the ONLY oracle that exercises the real producer→consumer delta seam: a +live ``warpline`` binary computes a ``warpline.reverify_worklist.v1`` over a small +sample tree, and wardline consumes it through the same stdin path an agent would +(`warpline reverify | wardline scan --affected -`). The hermetic golden +(`test_warpline_delta_scope.py`) vendors the worklist shape and runs on every PR; +this one proves the live wire — that a real warpline emits something wardline's +parser accepts and scopes against, end to end. + +Auto-skips cleanly when no ``warpline`` binary is resolvable (same shape as the +loomweave / legis oracles), so default CI is never affected. The scheduled / +``workflow_dispatch`` ``live-oracles`` matrix runs it FAIL-CLOSED +(`WARDLINE_LIVE_ORACLE_REQUIRED=1` turns the skip into a failure via the conftest +hook), so a missing binary in the required run fails instead of passing green. + +The warpline invocation is kept tolerant on purpose: warpline owns its own CLI +surface, so this oracle resolves the binary, runs ``reverify`` over the sample +tree, and skips with a specific reason if the producer cannot emit a worklist — +it asserts on wardline's side of the seam (exit code + scope block + analyzed ⊆ +discovery), never on warpline's internal output shape beyond "is parseable JSON". +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from shutil import which + +import pytest + +pytestmark = pytest.mark.warpline_e2e + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + + +def _wardline_cli() -> str: + """The wardline console script next to the running interpreter (installed by the + project's ``[project.scripts]`` entry point), falling back to PATH.""" + candidate = Path(sys.executable).parent / "wardline" + if candidate.is_file(): + return str(candidate) + on_path = which("wardline") + if on_path is not None: + return on_path + pytest.skip("wardline console script not found next to the interpreter or on PATH") + + +def _resolve_warpline() -> str | None: + """Pick an explicit binary first, then PATH, then local builds — mirrors the + loomweave oracle's resolution so a valid build is never skipped for a missing + PATH entry.""" + candidates: list[str | None] = [ + os.environ.get("WARDLINE_WARPLINE_BIN"), + which("warpline"), + str(Path.home() / "warpline" / "target" / "release" / "warpline"), + str(Path.home() / "warpline" / "target" / "debug" / "warpline"), + ] + for cand in candidates: + if cand and Path(cand).is_file(): + return cand + return None + + +def _run_reverify(warpline_bin: str, proj: Path) -> str: + """Run ``warpline reverify`` over the sample tree and return its JSON stdout. + + Drives the real producer CLI (`warpline reverify --repo --changed-entity-key-id + --json`; ``--changed-entity-key-id`` is a loomweave entity key id). Skips with + a specific reason if the producer cannot emit a parseable worklist — the contract this + oracle pins is wardline's consumption of whatever warpline emits, not warpline's + internal worklist completeness (a NO_SNAPSHOT worklist with null locators is a valid + producer output and exercises the consumer's empty-scope → full-fallback path).""" + argv = [ + warpline_bin, + "reverify", + "--repo", + str(proj), + "--changed-entity-key-id", + "1", + "--json", + ] + try: + proc = subprocess.run( # noqa: S603 — test-local, operator-supplied binary + argv, + cwd=str(proj), + capture_output=True, + text=True, + timeout=120, + ) + except OSError as exc: # binary not executable / spawn failure + pytest.skip(f"warpline reverify could not be spawned: {exc}") + if proc.returncode != 0: + pytest.skip(f"warpline reverify rc={proc.returncode}: {proc.stderr.strip()[-300:]}") + out = proc.stdout.strip() + if not out: + pytest.skip("warpline reverify produced empty stdout") + try: + json.loads(out) + except json.JSONDecodeError as exc: + pytest.skip(f"warpline reverify stdout is not JSON: {exc}") + return out + + +def test_warpline_reverify_into_wardline_affected_round_trip(tmp_path: Path) -> None: + warpline_bin = _resolve_warpline() + if warpline_bin is None: + pytest.skip( + "no warpline binary found; set WARDLINE_WARPLINE_BIN to a warpline build " + "(or put `warpline` on PATH) to run the live delta-scope oracle" + ) + + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + + # Producer half: warpline computes the reverify worklist over the sample tree. + worklist = _run_reverify(warpline_bin, proj) + + # Consumer half: pipe the worklist into `wardline scan --affected -` exactly as an + # agent would. agent-summary carries the `scope` block as a top-level key; write it + # to an explicit path so we read the block back regardless of stdout chatter. + summary_path = proj / "summary.json" + scan = subprocess.run( # noqa: S603 — test-local invocation of our own CLI + [ + _wardline_cli(), + "scan", + str(proj), + "--affected", + "-", + "--format", + "agent-summary", + "--output", + str(summary_path), + ], + input=worklist, + capture_output=True, + text=True, + timeout=120, + ) + + # Exit code: a clean tree gates green (0); a finding-driven non-green is also an + # acceptable outcome (1). A wardline-internal error (2) is a real failure of the seam. + assert scan.returncode in (0, 1), ( + f"wardline scan --affected - errored (rc={scan.returncode}):\n{scan.stderr.strip()[-1000:]}" + ) + + assert summary_path.is_file(), f"no agent-summary written; stderr:\n{scan.stderr[-1000:]}" + summary = json.loads(summary_path.read_text(encoding="utf-8")) + + # The scope block is the load-bearing proof the delta seam fired end to end. + scope = summary.get("scope") + assert scope is not None, f"agent-summary carried no scope block: {summary!r}" + assert scope["mode"] in {"delta", "full-fallback"}, scope["mode"] + + # Analyzed set is a subset of discovery (equal only in full-fallback). This is the + # core invariant the oracle pins on the live wire: a producer-supplied scope never + # widens analysis beyond what discovery found. + files_discovered = scope["files_discovered"] + files_analyzed = scope["files_analyzed"] + assert isinstance(files_discovered, int) and isinstance(files_analyzed, int) + assert files_analyzed <= files_discovered + if scope["mode"] == "full-fallback": + assert files_analyzed == files_discovered diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index 55fca7e9..973cdd0d 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -24,35 +24,35 @@ # ``(repo-relative path, 1-based line, substring required on that line)``. _ANCHORS: tuple[tuple[str, int, str], ...] = ( # src/wardline/core/run.py — ScanSummary fields, gate population, delta-scope, gate_decision - ("src/wardline/core/run.py", 51, "total: int"), - ("src/wardline/core/run.py", 52, "active: int"), - ("src/wardline/core/run.py", 54, "baselined: int"), - ("src/wardline/core/run.py", 55, "waived: int"), - ("src/wardline/core/run.py", 56, "judged: int"), - ("src/wardline/core/run.py", 62, "informational: int"), - ("src/wardline/core/run.py", 70, "unanalyzed: int"), - ("src/wardline/core/run.py", 89, "gate_findings:"), - ("src/wardline/core/run.py", 99, "class GateDecision"), - ("src/wardline/core/run.py", 108, "verdict: str"), - ("src/wardline/core/run.py", 341, "Baseline(frozenset())"), - ("src/wardline/core/run.py", 369, "def apply_delta_scope"), - ("src/wardline/core/run.py", 393, "active=sum"), - ("src/wardline/core/run.py", 452, "honors_suppressions"), + ("src/wardline/core/run.py", 70, "total: int"), + ("src/wardline/core/run.py", 71, "active: int"), + ("src/wardline/core/run.py", 73, "baselined: int"), + ("src/wardline/core/run.py", 74, "waived: int"), + ("src/wardline/core/run.py", 75, "judged: int"), + ("src/wardline/core/run.py", 81, "informational: int"), + ("src/wardline/core/run.py", 89, "unanalyzed: int"), + ("src/wardline/core/run.py", 108, "gate_findings:"), + ("src/wardline/core/run.py", 148, "class GateDecision"), + ("src/wardline/core/run.py", 157, "verdict: str"), + ("src/wardline/core/run.py", 456, "Baseline(frozenset())"), + ("src/wardline/core/run.py", 484, "def apply_delta_scope"), + ("src/wardline/core/run.py", 536, "active=sum"), + ("src/wardline/core/run.py", 619, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 471, "suppressed"), - ("src/wardline/cli/scan.py", 472, "{s.active} active"), - ("src/wardline/cli/scan.py", 524, "gate: FAILED"), + ("src/wardline/cli/scan.py", 547, "suppressed"), + ("src/wardline/cli/scan.py", 548, "{s.active} active"), + ("src/wardline/cli/scan.py", 600, "gate: FAILED"), # src/wardline/mcp/server.py — MCP scan summary + gate block - ("src/wardline/mcp/server.py", 937, '"total": result.summary.total'), - ("src/wardline/mcp/server.py", 938, '"active": result.summary.active'), - ("src/wardline/mcp/server.py", 939, '"baselined": result.summary.baselined'), - ("src/wardline/mcp/server.py", 940, '"waived": result.summary.waived'), - ("src/wardline/mcp/server.py", 941, '"judged": result.summary.judged'), - ("src/wardline/mcp/server.py", 946, '"informational": result.summary.informational'), - ("src/wardline/mcp/server.py", 950, '"unanalyzed": result.summary.unanalyzed'), - ("src/wardline/mcp/server.py", 952, '"gate": {'), - ("src/wardline/mcp/server.py", 953, '"tripped": decision.tripped'), - ("src/wardline/mcp/server.py", 957, '"verdict": decision.verdict'), + ("src/wardline/mcp/server.py", 998, '"total": result.summary.total'), + ("src/wardline/mcp/server.py", 999, '"active": result.summary.active'), + ("src/wardline/mcp/server.py", 1000, '"baselined": result.summary.baselined'), + ("src/wardline/mcp/server.py", 1001, '"waived": result.summary.waived'), + ("src/wardline/mcp/server.py", 1002, '"judged": result.summary.judged'), + ("src/wardline/mcp/server.py", 1007, '"informational": result.summary.informational'), + ("src/wardline/mcp/server.py", 1011, '"unanalyzed": result.summary.unanalyzed'), + ("src/wardline/mcp/server.py", 1013, '"gate": {'), + ("src/wardline/mcp/server.py", 1014, '"tripped": decision.tripped'), + ("src/wardline/mcp/server.py", 1018, '"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"'), diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 78cbcb76..de132cb2 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -878,7 +878,7 @@ class _StubEmitter: def __init__(self, url, **kw): captured["url"] = url - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult captured["n"] = len(findings) @@ -909,7 +909,7 @@ def __init__(self, url, **kw): captured["url"] = url captured["kwargs"] = kw - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult, FailedFinding return EmitResult( @@ -962,7 +962,7 @@ class _AbsentEmitter: def __init__(self, url, **kw): pass - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult return EmitResult(reachable=False) @@ -986,7 +986,7 @@ class _AuthRejectedEmitter: def __init__(self, url, **kw): pass - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult return EmitResult(reachable=False, status=401) # auth_rejected derived from status @@ -1012,7 +1012,7 @@ class _E: def __init__(self, url, **kw): pass - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult return EmitResult(reachable=False, status=status) @@ -1125,7 +1125,7 @@ class _OkEmitter: def __init__(self, url, **kw): pass - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult return EmitResult(reachable=True, created=1, updated=2) @@ -1626,7 +1626,7 @@ class _WarningFailedEmitter: def __init__(self, url, **kw): pass - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): from wardline.core.filigree_emit import EmitResult, FailedFinding return EmitResult( diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py index 5480c80c..6373a22d 100644 --- a/tests/unit/cli/test_install.py +++ b/tests/unit/cli/test_install.py @@ -20,7 +20,7 @@ class _FakeEmitter: def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url - def emit(self, findings, *, scanned_paths=()): # noqa: ANN001 + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): # noqa: ANN001 from wardline.core.filigree_emit import EmitResult captured["scanned_paths"] = tuple(scanned_paths) @@ -214,7 +214,7 @@ class _FakeEmitter: def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url - def emit(self, findings, *, scanned_paths=()): # noqa: ANN001 + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): # noqa: ANN001 from wardline.core.filigree_emit import EmitResult captured["scanned_paths"] = tuple(scanned_paths) @@ -242,7 +242,7 @@ def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url captured["token"] = token - def emit(self, findings, *, scanned_paths=()): # noqa: ANN001 + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): # noqa: ANN001 from wardline.core.filigree_emit import EmitResult return EmitResult(reachable=True) @@ -270,7 +270,7 @@ def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url captured["token"] = token - def emit(self, findings, *, scanned_paths=()): # noqa: ANN001 + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): # noqa: ANN001 from wardline.core.filigree_emit import EmitResult return EmitResult(reachable=True) diff --git a/tests/unit/cli/test_scan_affected_cli.py b/tests/unit/cli/test_scan_affected_cli.py new file mode 100644 index 00000000..4d61402f --- /dev/null +++ b/tests/unit/cli/test_scan_affected_cli.py @@ -0,0 +1,265 @@ +"""Phase 7 — ``wardline scan --affected`` through the Click CLI. + +Drives the real ``scan`` command over a tmp project tree, mirroring +``tests/unit/cli/test_scan_rust.py``. Covers: + +* ``--affected `` scopes the analysis to the affected entity's file; +* ``--affected -`` (stdin) via ``CliRunner().invoke(..., input=...)``; +* an empty ``--affected -`` payload falls back to a full scan (INV-3); +* a malformed payload exits 2 (spec §7); +* ``--affected`` + ``--new-since`` together exits 2 (mutual exclusion); +* ``--format sarif`` carries the scope block at ``runs[0].properties.wardline_delta_scope``; +* a delta CLI Filigree emit forces ``mark_unseen=False`` (INV-5). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from wardline.cli.scan import scan + +# A trusted boundary returning an external-tainted value: PY-WL-101 ERROR defect. +# Mirrors ``_LEAKY`` in tests/unit/core/test_run_affected.py. +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + + +def _two_file_proj(tmp_path: Path) -> Path: + """A project with two leaky modules, ``good.py`` + ``evil.py``, each carrying a + PY-WL-101 ERROR on its ``leaky`` entity.""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "good.py").write_text(_LEAKY, encoding="utf-8") + (proj / "evil.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +def _worklist_file(tmp_path: Path, *locators: str) -> Path: + """Write a bare warpline entity-list JSON fixture and return its path.""" + payload = [{"locator": loc} for loc in locators] + fixture = tmp_path / "worklist.json" + fixture.write_text(json.dumps(payload), encoding="utf-8") + return fixture + + +def test_affected_file_scopes_analysis(tmp_path: Path) -> None: + """``--affected `` naming only ``good.leaky`` analyzes one of two files and + reports a delta scope block; the stderr scope line names delta mode.""" + proj = _two_file_proj(tmp_path) + worklist = _worklist_file(tmp_path, "python:function:good.leaky") + out = proj / "findings.agent-summary.json" + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", str(worklist), "--format", "agent-summary", "--output", str(out)], + ) + + assert result.exit_code == 0 + summary = json.loads(out.read_text()) + assert summary["scope"]["mode"] == "delta" + assert summary["scope"]["files_discovered"] == 2 + assert summary["scope"]["files_analyzed"] == 1 + assert "scope: delta" in result.stderr + + +def test_affected_stdin_dash_scopes(tmp_path: Path) -> None: + """``--affected -`` reads the entity list from stdin (Click's stdin, not sys.stdin); + a valid list → delta mode with a non-null analyzed count.""" + proj = _two_file_proj(tmp_path) + out = proj / "findings.agent-summary.json" + payload = json.dumps([{"locator": "python:function:good.leaky"}]) + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", "-", "--format", "agent-summary", "--output", str(out)], + input=payload, + ) + + assert result.exit_code == 0 + summary = json.loads(out.read_text()) + assert summary["scope"]["mode"] == "delta" + assert summary["scope"]["files_analyzed"] == 1 + + +def test_affected_stdin_empty_falls_back_to_full(tmp_path: Path) -> None: + """An empty ``--affected -`` payload (``[]``) is NOT an error — it falls back to a full + scan (INV-3), declared as full-fallback / gate-of-record.""" + proj = _two_file_proj(tmp_path) + out = proj / "findings.agent-summary.json" + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", "-", "--format", "agent-summary", "--output", str(out)], + input="[]", + ) + + assert result.exit_code == 0 + summary = json.loads(out.read_text()) + assert summary["scope"]["mode"] == "full-fallback" + assert summary["scope"]["gate_authority"] == "gate-of-record" + assert summary["scope"]["files_analyzed"] == summary["scope"]["files_discovered"] == 2 + + +def test_affected_malformed_payload_exits_2(tmp_path: Path) -> None: + """A structurally malformed payload (not valid JSON / not an object|array) → the shared + SystemExit(2) path (spec §7 malformed scope → exit 2).""" + proj = _two_file_proj(tmp_path) + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", "-"], + input="this is not json", + ) + + assert result.exit_code == 2 + + +def test_affected_stdin_over_cap_exits_2(tmp_path: Path) -> None: + """A VALID-JSON ``--affected -`` stdin payload that exceeds the byte cap is rejected + BEFORE an unbounded read + parse (DoS guard, §7) → the shared SystemExit(2) path.""" + from wardline.core.delta_scope import _MAX_PAYLOAD_BYTES + + proj = _two_file_proj(tmp_path) + big_locator = "python:function:" + ("x" * (_MAX_PAYLOAD_BYTES + 1)) + payload = json.dumps([{"locator": big_locator}]) + assert len(payload.encode("utf-8")) > _MAX_PAYLOAD_BYTES + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", "-"], + input=payload, + ) + + assert result.exit_code == 2 + + +def test_affected_with_new_since_exits_2(tmp_path: Path) -> None: + """``--affected`` and ``--new-since`` are mutually exclusive → exit 2.""" + proj = _two_file_proj(tmp_path) + worklist = _worklist_file(tmp_path, "python:function:good.leaky") + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", str(worklist), "--new-since", "origin/main"], + ) + + assert result.exit_code == 2 + assert "mutually exclusive" in result.stderr + + +def test_affected_sarif_carries_scope_run_properties(tmp_path: Path) -> None: + """``--format sarif`` threads the scope block into ``runs[0].properties. + wardline_delta_scope`` (the SARIF run-properties channel).""" + proj = _two_file_proj(tmp_path) + worklist = _worklist_file(tmp_path, "python:function:good.leaky") + out = proj / "findings.sarif" + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", str(worklist), "--format", "sarif", "--output", str(out)], + ) + + assert result.exit_code == 0 + sarif = json.loads(out.read_text()) + props = sarif["runs"][0]["properties"]["wardline_delta_scope"] + assert props["mode"] == "delta" + assert props["files_analyzed"] == 1 + assert "boundary_caveat" in props + + +def test_full_scan_sarif_has_no_scope_properties(tmp_path: Path) -> None: + """INV-1: a full scan (no ``--affected``) emits no ``runs[0].properties`` scope key.""" + proj = _two_file_proj(tmp_path) + out = proj / "findings.sarif" + + result = CliRunner().invoke( + scan, + [str(proj), "--format", "sarif", "--output", str(out)], + ) + + assert result.exit_code == 0 + sarif = json.loads(out.read_text()) + assert "properties" not in sarif["runs"][0] + assert "scope:" not in result.stderr + + +def test_delta_emit_forces_mark_unseen_false(tmp_path: Path, monkeypatch) -> None: + """INV-5: a delta CLI Filigree emit builds the request body with ``mark_unseen=False`` + so out-of-scope findings (absent from the FILTERED findings list but present in the + FULL scanned_paths) are never read as fixed and closed.""" + captured: dict[str, object] = {} + + class _RecordingEmitter: + def __init__(self, url: str, **kwargs: object) -> None: + captured["url"] = url + + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): # type: ignore[no-untyped-def] + captured["mark_unseen"] = mark_unseen + from wardline.core.filigree_emit import EmitResult + + return EmitResult(reachable=True, created=len(list(findings)), token_sent=False, url=str(captured["url"])) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _RecordingEmitter) + monkeypatch.setattr("wardline.filigree.config.load_filigree_token", lambda root: None) + + proj = _two_file_proj(tmp_path) + worklist = _worklist_file(tmp_path, "python:function:good.leaky") + out = proj / "findings.jsonl" + + result = CliRunner().invoke( + scan, + [ + str(proj), + "--affected", + str(worklist), + "--filigree-url", + "http://example.invalid/api/scan-results", + "--output", + str(out), + ], + ) + + assert result.exit_code == 0 + assert captured["mark_unseen"] is False + + +def test_full_scan_emit_uses_auto_mark_unseen(tmp_path: Path, monkeypatch) -> None: + """The companion: a FULL scan emit passes ``mark_unseen=None`` (auto) so reconciliation + proceeds normally — the delta guard above is specific to delta mode.""" + captured: dict[str, object] = {} + + class _RecordingEmitter: + def __init__(self, url: str, **kwargs: object) -> None: + captured["url"] = url + + def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): # type: ignore[no-untyped-def] + captured["mark_unseen"] = mark_unseen + from wardline.core.filigree_emit import EmitResult + + return EmitResult(reachable=True, created=len(list(findings)), token_sent=False, url=str(captured["url"])) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _RecordingEmitter) + monkeypatch.setattr("wardline.filigree.config.load_filigree_token", lambda root: None) + + proj = _two_file_proj(tmp_path) + out = proj / "findings.jsonl" + + result = CliRunner().invoke( + scan, + [ + str(proj), + "--filigree-url", + "http://example.invalid/api/scan-results", + "--output", + str(out), + ], + ) + + assert result.exit_code == 0 + assert captured["mark_unseen"] is None diff --git a/tests/unit/core/test_affected_invariants.py b/tests/unit/core/test_affected_invariants.py new file mode 100644 index 00000000..f02dcea8 --- /dev/null +++ b/tests/unit/core/test_affected_invariants.py @@ -0,0 +1,474 @@ +"""Phase 9 — the five load-bearing invariants of the ``--affected`` delta scan. + +These tests pin the security/soundness properties INV-1..INV-5 (plan §Invariants) at +the ``run_scan`` seam, end-to-end, independently of the focused per-phase unit tests: + +* **INV-1** — the full-scan path (``affected is None``) is byte-identical to today AND + pays NO delta cost: ``build_qualname_index`` is never built and an injected loomweave + SEI resolver is never probed. +* **INV-2** — fingerprints are stable: a finding kept by a delta scan carries the exact + fingerprint it has in a full scan (the filter drops, never re-mints). +* **INV-3** — fail-closed honesty: an all-unresolvable scope falls back to a FULL scan + (``mode="full-fallback"``, ``gate_authority="gate-of-record"``) and the finding set + equals a plain full scan's. +* **INV-4 (THREAT-001, the load-bearing security test)** — an untrusted scope that + surgically EXCLUDES the one file carrying a real ERROR sink stays ``mode="delta"`` + (NOT full-fallback — fail-closed does not catch a precise exclusion), narrows the + DISPLAYED findings to omit that ERROR, but CANNOT forge a green: the severity gate + evaluates the full unsuppressed population, so the verdict/exit are IDENTICAL to the + full scan's (FAILED / tripped / exit_class 1). +* **INV-5 (mark_unseen)** — a delta CLI Filigree emit builds the request body with + ``mark_unseen=False`` so out-of-scope findings (absent from the FILTERED ``findings`` + list but present in the FULL ``scanned_paths``) are never read as fixed and closed. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from click.testing import CliRunner + +from wardline.cli.scan import scan +from wardline.core.delta_scope import parse_affected_scope +from wardline.core.finding import Severity, SuppressionState +from wardline.core.run import gate_decision, run_scan +from wardline.loomweave.identity import SeiCapability, SeiResolver + +# A trusted boundary returning an external-tainted value: PY-WL-101 ERROR defect. The +# entity ``leaky`` carries the ERROR (mirrors ``_LEAKY`` in test_run_affected.py). +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + +# Two CO-LOCATED leaky entities (``alpha``, ``beta``) in one module — used to exercise a +# surgical display-exclusion that the gate must still see (INV-4). +_TWO_ENTITY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef alpha(p):\n return read_raw(p)\n" + "@trusted\ndef beta(p):\n return read_raw(p)\n" +) + +# ``evil.py``: a benign affected entity ``touched`` co-located WITH a real ERROR sink +# ``backdoor`` in the SAME file. A worklist naming only ``touched`` makes evil.py the +# analyzed file (so the engine DOES compute the backdoor's ERROR) while surgically +# excluding ``backdoor`` from the DISPLAYED findings — the precise THREAT-001 shape: the +# attacker hides the sink from the report, but it is still in the gate population. +_EVIL = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef touched(p):\n return 'safe'\n" + "@trusted\ndef backdoor(p):\n return read_raw(p)\n" +) + + +def _two_file_proj(tmp_path: Path) -> Path: + """A project with two structurally-identical leaky modules, ``good.py`` + ``evil.py``, + each carrying a PY-WL-101 ERROR on its ``leaky`` entity.""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "good.py").write_text(_LEAKY, encoding="utf-8") + (proj / "evil.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +def _co_located_proj(tmp_path: Path) -> Path: + """A project with one module carrying two co-located leaky entities (``alpha``, + ``beta``).""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_TWO_ENTITY, encoding="utf-8") + return proj + + +def _evil_proj(tmp_path: Path) -> Path: + """A project whose ``evil.py`` co-locates a benign affected entity ``touched`` with a + real ERROR sink ``backdoor``, plus a clean ``other.py`` so the scope is a true subset.""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "evil.py").write_text(_EVIL, encoding="utf-8") + (proj / "other.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +def _py101(findings: list) -> list: + return [f for f in findings if f.rule_id == "PY-WL-101"] + + +def _py101_paths(findings: list) -> set[str]: + return {f.location.path for f in _py101(findings)} + + +def _py101_quals(findings: list) -> set[str]: + return {f.qualname for f in _py101(findings)} + + +def _fp_by_qual(findings: list) -> dict[str, str]: + return {f.qualname: f.fingerprint for f in _py101(findings)} + + +def _frozen_finding_repr(findings: list) -> list[tuple]: + """A stable, order-independent projection of a findings list for byte-identity asserts.""" + return sorted( + (f.rule_id, f.location.path, f.qualname, f.severity, f.kind, f.suppressed, f.fingerprint) for f in findings + ) + + +class _SpyClient: + """A SeiClient double whose every wire call is recorded. INV-1 asserts NONE of these + fire when ``affected is None`` — the full-scan path probes no loomweave.""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def capabilities(self) -> dict[str, Any] | None: + self.calls.append("capabilities") + return {"sei": {"supported": True, "version": 1}} + + def resolve_identity(self, locator: str) -> dict[str, Any] | None: + self.calls.append("resolve_identity") + return None + + def resolve_sei(self, sei: str) -> dict[str, Any] | None: + self.calls.append("resolve_sei") + return None + + +# --- INV-1 ---------------------------------------------------------------------------- + + +def test_inv1_full_scan_byte_identical_to_no_resolver(tmp_path: Path) -> None: + """INV-1: a full scan (``affected is None``) yields the same findings, summary, + gate_findings, and scanned_paths whether or not a resolver is injected — and never a + scope block. Passing a resolver must not perturb the full-scan path.""" + proj = _two_file_proj(tmp_path) + spy = _SpyClient() + resolver = SeiResolver(spy, SeiCapability(supported=True, version=1)) + + plain = run_scan(proj) + with_resolver = run_scan(proj, sei_resolver=resolver) + + assert plain.scope is None and with_resolver.scope is None + assert _frozen_finding_repr(plain.findings) == _frozen_finding_repr(with_resolver.findings) + assert plain.summary == with_resolver.summary + assert plain.scanned_paths == with_resolver.scanned_paths + assert plain.gate_findings is not None and with_resolver.gate_findings is not None + assert _frozen_finding_repr(plain.gate_findings) == _frozen_finding_repr(with_resolver.gate_findings) + # The frozen full-scan expectation: both leaky entities, both files, both gate. + assert _py101_paths(plain.findings) == {"good.py", "evil.py"} + + +def test_inv1_no_delta_cost_and_no_loomweave_probe_when_affected_none(tmp_path: Path) -> None: + """INV-1: the full-scan path pays NO delta cost — ``build_qualname_index`` is never + invoked — and probes NO loomweave — the injected resolver's client is never touched — + when ``affected is None`` (a future refactor cannot silently make the full path build + the index or hit the network).""" + proj = _two_file_proj(tmp_path) + spy = _SpyClient() + resolver = SeiResolver(spy, SeiCapability(supported=True, version=1)) + + with patch("wardline.core.run.build_qualname_index") as index_spy: + run_scan(proj, affected=None, sei_resolver=resolver) + + index_spy.assert_not_called() + assert spy.calls == [] + + +def test_inv1_negative_control_resolver_probed_in_delta(tmp_path: Path) -> None: + """The companion control: when ``affected`` IS supplied with an SEI entity, the + resolver's client IS probed — so the negative assertion above is meaningful (green + because the path is skipped, not because the resolver is inert).""" + proj = _two_file_proj(tmp_path) + spy = _SpyClient() + resolver = SeiResolver(spy, SeiCapability(supported=True, version=1)) + scope = parse_affected_scope([{"sei": "loomweave:eid:" + "a" * 32}]) + + run_scan(proj, affected=scope, sei_resolver=resolver) + + assert "resolve_sei" in spy.calls + + +# --- INV-2 ---------------------------------------------------------------------------- + + +def test_inv2_kept_finding_fingerprint_matches_full_scan(tmp_path: Path) -> None: + """INV-2: a finding kept by a delta scan carries the EXACT fingerprint it has in a + full scan of the same tree — the filter drops findings, it never re-mints identity.""" + proj = _co_located_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:svc.alpha"}]) + + full = run_scan(proj) + delta = run_scan(proj, affected=scope) + + # The delta scan displays only alpha; assert its fingerprint is byte-identical to the + # same entity's fingerprint in the full scan. + assert _py101_quals(delta.findings) == {"svc.alpha"} + full_fps = _fp_by_qual(full.findings) + delta_fps = _fp_by_qual(delta.findings) + assert delta_fps["svc.alpha"] == full_fps["svc.alpha"] + + +def test_inv2_all_kept_fingerprints_stable_across_files(tmp_path: Path) -> None: + """INV-2 across files: a worklist naming an entity in ``good.py`` keeps that file's + finding with the same fingerprint a full scan mints for it.""" + proj = _two_file_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:good.leaky"}]) + + full = run_scan(proj) + delta = run_scan(proj, affected=scope) + + full_by_path = {(f.location.path, f.qualname): f.fingerprint for f in _py101(full.findings)} + for f in _py101(delta.findings): + assert f.fingerprint == full_by_path[(f.location.path, f.qualname)] + + +# --- INV-3 ---------------------------------------------------------------------------- + + +def test_inv3_all_unresolvable_falls_back_to_full(tmp_path: Path) -> None: + """INV-3: an all-unresolvable scope falls back to a FULL scan — ``mode`` is + ``full-fallback``, ``gate_authority`` is ``gate-of-record``, every file is analyzed, + and the finding set equals a plain full scan's (never a silent narrow).""" + proj = _two_file_proj(tmp_path) + # A locator that matches no entity in the tree → zero files resolve → full-fallback. + scope = parse_affected_scope([{"locator": "python:function:nope.missing"}]) + + full = run_scan(proj) + fallback = run_scan(proj, affected=scope) + + assert fallback.scope is not None + assert fallback.scope.mode == "full-fallback" + assert fallback.scope.gate_authority == "gate-of-record" + assert fallback.scope.files_analyzed == fallback.scope.files_discovered == 2 + # The finding set is identical to a plain full scan's (full-fallback applies NO filter). + assert _frozen_finding_repr(fallback.findings) == _frozen_finding_repr(full.findings) + + +# --- INV-4 (THREAT-001) ---------------------------------------------------------------- + + +def test_inv4_surgical_exclusion_cannot_forge_a_green(tmp_path: Path) -> None: + """INV-4 / THREAT-001 — the load-bearing security test. + + A worklist that resolves >0 files (so NOT full-fallback) but SURGICALLY EXCLUDES the + real ERROR sink ``evil.backdoor`` from the DISPLAYED findings — by naming only the + benign co-located entity ``evil.touched`` — stays ``mode="delta"`` and omits the ERROR + from the report. evil.py IS still analyzed (the worklist's entity lives there), so the + engine computes the backdoor's ERROR; the display filter hides it but the severity gate + evaluates the FULL unsuppressed population, so the verdict/exit are IDENTICAL to a full + scan's: an attacker-influenceable scope cannot green a real ERROR. + + The complementary inter-file gap (an ERROR in a file the worklist never names, hence + never analyzed) is the DECLARED soundness limitation surfaced by ``boundary_caveat`` — + INV-4 protects against the display filter forging a green, not against the honestly- + declared non-analysis of out-of-scope files. + """ + proj = _evil_proj(tmp_path) + # Names only the benign evil.touched: evil.py resolves (>0 files → delta, not + # full-fallback) AND is analyzed, so backdoor's ERROR is computed but display-excluded. + scope = parse_affected_scope([{"locator": "python:function:evil.touched"}]) + + full = run_scan(proj) + delta = run_scan(proj, affected=scope) + + # Delta mode (NOT full-fallback): fail-closed-on-empty does NOT catch a precise + # exclusion, so INV-4 is the structural protection, not a deployment convention. + assert delta.scope is not None + assert delta.scope.mode == "delta" + # The backdoor ERROR is GONE from the displayed findings (surgically excluded by name). + assert _py101_quals(delta.findings) == set() or "evil.backdoor" not in _py101_quals(delta.findings) + assert not any(f.location.path == "evil.py" and f.qualname == "evil.backdoor" for f in _py101(delta.findings)) + # ...but it is STILL live in the gate population (the filter never narrows the gate). + assert delta.gate_findings is not None + backdoor_in_gate = [ + f + for f in delta.gate_findings + if f.rule_id == "PY-WL-101" + and f.location.path == "evil.py" + and f.qualname == "evil.backdoor" + and f.suppressed is SuppressionState.ACTIVE + ] + assert backdoor_in_gate, "the surgically-excluded ERROR must remain in the gate population" + + # The gate verdict is IDENTICAL to the full scan's — the delta gate cannot forge a green. + full_decision = gate_decision(full, Severity.ERROR) + delta_decision = gate_decision(delta, Severity.ERROR) + assert full_decision.tripped is True + assert delta_decision.verdict == full_decision.verdict == "FAILED" + assert delta_decision.tripped == full_decision.tripped is True + assert delta_decision.exit_class == full_decision.exit_class == 1 + + +def test_inv4_co_located_surgical_exclusion_cannot_forge_a_green(tmp_path: Path) -> None: + """INV-4 within a single analyzed file: a worklist naming only ``alpha`` displays just + alpha's ERROR, but ``beta``'s co-located ERROR stays in the gate population, so the + delta gate verdict still equals the full scan's FAILED.""" + proj = _co_located_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:svc.alpha"}]) + + full = run_scan(proj) + delta = run_scan(proj, affected=scope) + + assert delta.scope is not None and delta.scope.mode == "delta" + assert _py101_quals(delta.findings) == {"svc.alpha"} + assert delta.gate_findings is not None + assert _py101_quals(delta.gate_findings) == {"svc.alpha", "svc.beta"} + + assert gate_decision(full, Severity.ERROR).verdict == "FAILED" + assert gate_decision(delta, Severity.ERROR).verdict == "FAILED" + assert gate_decision(delta, Severity.ERROR).exit_class == 1 + + +def test_inv4_surgical_exclusion_cannot_forge_a_green_under_trust_suppressions(tmp_path: Path) -> None: + """INV-4 / THREAT-001 under ``--trust-suppressions`` — the dangerous combination. + + With ``trust_suppressions=True`` the gate would, on a FULL scan, fall back to the + suppressed ``findings`` (``gate_findings is None``). A delta scan filters ``findings``, + so a naive fallback would let a surgical-exclusion worklist hide an in-analyzed-file + ERROR from the gate and forge a PASSED. The fix MATERIALISES a concrete gate population + (post-suppression, pre-delta-filter), so the delta gate verdict is IDENTICAL to the + full scan's FAILED even though the ERROR was excluded from the DISPLAYED set.""" + proj = _evil_proj(tmp_path) + # Names only the benign evil.touched; evil.py is analyzed (backdoor's ERROR is computed) + # but the worklist surgically drops backdoor from the displayed findings. + scope = parse_affected_scope([{"locator": "python:function:evil.touched"}]) + + full = run_scan(proj, trust_suppressions=True) + delta = run_scan(proj, affected=scope, trust_suppressions=True) + + # Delta mode (NOT full-fallback) — a precise exclusion does not trip fail-closed. + assert delta.scope is not None and delta.scope.mode == "delta" + # The backdoor ERROR is surgically excluded from the DISPLAYED findings... + assert not any(f.location.path == "evil.py" and f.qualname == "evil.backdoor" for f in _py101(delta.findings)) + # ...but the gate population is a CONCRETE list (the None sentinel was materialised) that + # still carries the backdoor ERROR; the posture stays trust-suppressions. + assert delta.gate_findings is not None + assert delta.honors_suppressions is True + assert any(f.location.path == "evil.py" and f.qualname == "evil.backdoor" for f in _py101(delta.gate_findings)) + + # The surgical exclusion CANNOT forge a green: verdict/exit identical to the full scan's. + full_decision = gate_decision(full, Severity.ERROR) + delta_decision = gate_decision(delta, Severity.ERROR) + assert full_decision.tripped is True + assert full_decision.verdict == "FAILED" + assert delta_decision.verdict == full_decision.verdict == "FAILED" + assert delta_decision.tripped == full_decision.tripped is True + assert delta_decision.exit_class == full_decision.exit_class == 1 + + +# --- INV-5 (mark_unseen) -------------------------------------------------------------- + + +class _RecordingTransport: + """A Filigree transport double that captures every POST body, so INV-5 can assert the + request body's ``mark_unseen`` flag at the WIRE (the body builder), not just the + emitter argument.""" + + def __init__(self) -> None: + self.bodies: list[dict[str, Any]] = [] + + def post(self, url: str, body: bytes, headers: Any): # type: ignore[no-untyped-def] + from wardline.core.filigree_emit import Response + + self.bodies.append(json.loads(body.decode("utf-8"))) + return Response(status=200, body=json.dumps({"stats": {}})) + + +def _worklist_file(tmp_path: Path, locator: str) -> Path: + """A minimal warpline.reverify_worklist.v1 envelope file naming ``locator``.""" + path = tmp_path / "worklist.json" + path.write_text( + json.dumps({"data": {"items": [{"entity": {"locator": locator}}]}}), + encoding="utf-8", + ) + return path + + +def test_inv5_delta_emit_body_forces_mark_unseen_false(tmp_path: Path, monkeypatch) -> None: + """INV-5: a delta CLI Filigree emit builds the request BODY with ``mark_unseen=False``. + + A delta scan emits the FULL discovery list as ``scanned_paths`` but a FILTERED + ``findings`` list; if Filigree's absent-fingerprint sweep ran, every out-of-scope + finding would be read as fixed and its issue closed (irreversible signal loss). We + capture the actual wire body via a recording transport — proving the whole chain + (CLI → FiligreeEmitter → _scan_result_chunks(force_no_mark_unseen) → body builder) + yields ``mark_unseen: false`` — not merely that the CLI passes the right argument. + """ + transport = _RecordingTransport() + + def _emitter_factory(url: str, **kwargs: Any): # type: ignore[no-untyped-def] + from wardline.core.filigree_emit import FiligreeEmitter + + kwargs.pop("transport", None) + return FiligreeEmitter(url, transport=transport, **kwargs) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _emitter_factory) + monkeypatch.setattr("wardline.filigree.config.load_filigree_token", lambda root: None) + + proj = _two_file_proj(tmp_path) + worklist = _worklist_file(tmp_path, "python:function:good.leaky") + out = proj / "findings.jsonl" + + result = CliRunner().invoke( + scan, + [ + str(proj), + "--affected", + str(worklist), + "--filigree-url", + "http://example.invalid/api/scan-results", + "--output", + str(out), + ], + ) + + assert result.exit_code == 0 + assert transport.bodies, "the delta emit must have POSTed at least one chunk" + # The wire body — every chunk — disables reconciliation in delta mode. + assert all(body["mark_unseen"] is False for body in transport.bodies) + # The body names the FULL discovery (both files) as scanned_paths while findings are + # the filtered subset — the exact shape mark_unseen would mis-reconcile. + scanned = set() + for body in transport.bodies: + scanned.update(body.get("scanned_paths", [])) + assert {"good.py", "evil.py"} <= scanned + + +def test_inv5_full_scan_emit_body_allows_auto_mark_unseen(tmp_path: Path, monkeypatch) -> None: + """The companion: a FULL scan emit body carries ``mark_unseen=True`` (auto-enabled when + findings/scanned_paths are non-empty), so reconciliation proceeds normally — the INV-5 + guard is specific to delta mode and does not over-disable reconciliation.""" + transport = _RecordingTransport() + + def _emitter_factory(url: str, **kwargs: Any): # type: ignore[no-untyped-def] + from wardline.core.filigree_emit import FiligreeEmitter + + kwargs.pop("transport", None) + return FiligreeEmitter(url, transport=transport, **kwargs) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _emitter_factory) + monkeypatch.setattr("wardline.filigree.config.load_filigree_token", lambda root: None) + + proj = _two_file_proj(tmp_path) + out = proj / "findings.jsonl" + + result = CliRunner().invoke( + scan, + [ + str(proj), + "--filigree-url", + "http://example.invalid/api/scan-results", + "--output", + str(out), + ], + ) + + assert result.exit_code == 0 + assert transport.bodies + # A full scan has findings + scanned_paths, so the auto-sweep is on (mark_unseen=True). + assert any(body["mark_unseen"] is True for body in transport.bodies) diff --git a/tests/unit/core/test_cli_mcp_parity.py b/tests/unit/core/test_cli_mcp_parity.py index 05e664b6..998464b8 100644 --- a/tests/unit/core/test_cli_mcp_parity.py +++ b/tests/unit/core/test_cli_mcp_parity.py @@ -117,7 +117,7 @@ def __init__(self) -> None: self.seen: list = [] self.scanned_paths: tuple[str, ...] = () - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), mark_unseen=None): self.seen = list(findings) self.scanned_paths = tuple(scanned_paths) return EmitResult(reachable=True) diff --git a/tests/unit/core/test_delta_finding_filter.py b/tests/unit/core/test_delta_finding_filter.py new file mode 100644 index 00000000..6bed2019 --- /dev/null +++ b/tests/unit/core/test_delta_finding_filter.py @@ -0,0 +1,129 @@ +"""Phase 4 — the delta finding filter (``core.delta_resolve.filter_to_affected``). + +The filter narrows ONLY the displayed findings to the affected entities: it keeps an +anchored finding by canonical qualname (matching ``:setter``/``:deleter`` and +nested-class shapes and class-level locators) and a qualname-``None`` engine FACT on an +analyzed affected file, and drops co-located findings on other entities. It is a pure +drop-filter and never re-mints a fingerprint (INV-2). +""" + +from __future__ import annotations + +from wardline.core.delta_resolve import filter_to_affected +from wardline.core.finding import ( + Finding, + Kind, + Location, + Severity, + compute_finding_fingerprint, +) + + +def _finding( + *, + rule_id: str = "PY-WL-101", + path: str = "a.py", + qualname: str | None = "a.handler", + severity: Severity = Severity.ERROR, + kind: Kind = Kind.DEFECT, +) -> Finding: + fp = compute_finding_fingerprint(rule_id=rule_id, path=path, qualname=qualname) + return Finding( + rule_id=rule_id, + message="boundary not validated", + severity=severity, + kind=kind, + location=Location(path=path, line_start=3, line_end=3), + fingerprint=fp, + qualname=qualname, + ) + + +def test_keeps_finding_on_affected_qualname() -> None: + f = _finding(qualname="a.handler") + kept = filter_to_affected([f], frozenset({"a.handler"}), frozenset({"a.py"})) + assert kept == [f] + + +def test_drops_colocated_non_affected_qualname() -> None: + keep = _finding(qualname="a.handler") + drop = _finding(qualname="a.other") + kept = filter_to_affected([keep, drop], frozenset({"a.handler"}), frozenset({"a.py"})) + assert kept == [keep] + + +def test_keeps_setter_finding_under_base_locator() -> None: + # Finding qualname carries ':setter'; the affected set holds the base name. + f = _finding(qualname="cfg.Cfg.value:setter") + kept = filter_to_affected([f], frozenset({"cfg.Cfg.value"}), frozenset({"cfg.py"})) + assert kept == [f] + + +def test_keeps_deleter_finding_under_base_locator() -> None: + f = _finding(qualname="cfg.Cfg.value:deleter") + kept = filter_to_affected([f], frozenset({"cfg.Cfg.value"}), frozenset({"cfg.py"})) + assert kept == [f] + + +def test_keeps_method_finding_under_class_level_locator() -> None: + # A class-level affected key scopes in every method under it via the prefix rule. + a = _finding(qualname="svc.Svc.a") + b = _finding(qualname="svc.Svc.b") + kept = filter_to_affected([a, b], frozenset({"svc.Svc"}), frozenset({"svc.py"})) + assert kept == [a, b] + + +def test_keeps_nested_class_qualname() -> None: + f = _finding(qualname="nest.Outer.Inner.deep") + kept = filter_to_affected([f], frozenset({"nest.Outer.Inner.deep"}), frozenset({"nest.py"})) + assert kept == [f] + + +def test_class_level_locator_does_not_match_sibling_prefix() -> None: + # 'svc.Svc' must not match a sibling class 'svc.SvcOther' by string prefix — the + # prefix rule appends a '.' so only true members under the class qualify. + member = _finding(qualname="svc.Svc.a") + sibling = _finding(qualname="svc.SvcOther.a") + kept = filter_to_affected([member, sibling], frozenset({"svc.Svc"}), frozenset({"svc.py"})) + assert kept == [member] + + +def test_keeps_engine_fact_with_none_qualname_on_affected_file() -> None: + fact = _finding( + rule_id="WLN-ENGINE-PARSE-ERROR", + qualname=None, + kind=Kind.FACT, + severity=Severity.NONE, + path="a.py", + ) + kept = filter_to_affected([fact], frozenset({"a.handler"}), frozenset({"a.py"})) + assert kept == [fact] + + +def test_drops_engine_fact_with_none_qualname_off_affected_file() -> None: + fact = _finding( + rule_id="WLN-ENGINE-PARSE-ERROR", + qualname=None, + kind=Kind.FACT, + severity=Severity.NONE, + path="other.py", + ) + kept = filter_to_affected([fact], frozenset({"a.handler"}), frozenset({"a.py"})) + assert kept == [] + + +def test_does_not_remint_fingerprints() -> None: + # INV-2: kept findings keep their exact input fingerprints; the filter never re-mints. + keep = _finding(qualname="a.handler") + drop = _finding(qualname="a.other") + kept = filter_to_affected([keep, drop], frozenset({"a.handler"}), frozenset({"a.py"})) + assert len(kept) == 1 + assert kept[0].fingerprint == keep.fingerprint + assert kept[0] is keep # identity preserved — not reconstructed + + +def test_empty_affected_set_keeps_only_facts_on_files() -> None: + anchored = _finding(qualname="a.handler") + fact = _finding(rule_id="WLN-ENGINE-FILE-SKIPPED", qualname=None, kind=Kind.FACT, path="a.py") + kept = filter_to_affected([anchored, fact], frozenset(), frozenset({"a.py"})) + assert kept == [fact] diff --git a/tests/unit/core/test_delta_resolve.py b/tests/unit/core/test_delta_resolve.py new file mode 100644 index 00000000..38ea0a5f --- /dev/null +++ b/tests/unit/core/test_delta_resolve.py @@ -0,0 +1,306 @@ +"""Phase 2 — entity→file resolution + caller-closure (``core.delta_resolve``). + +Covers every case the warpline-delta-scan plan names for resolution: the SEI +(loomweave) path, the ``python:method:`` locator bug fix, ``:setter``/``:deleter`` and +nested-class canonicalization, method-vs-class-level locators, the qualname fallback, +partial resolution, the SEI-drift → locator fallback (recorded ``stale_sei``), +``build_qualname_index`` skipping a ``SyntaxError`` file, and the reverse-edge caller +closure pulling a caller's file into the analyzed set. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from wardline.core.delta_resolve import ( + build_qualname_index, + canonical_qualname, + resolve_affected_scope, +) +from wardline.core.delta_scope import AffectedEntity, AffectedScope +from wardline.core.sei_resolution import locator_to_qualname +from wardline.loomweave.identity import SeiCapability, SeiResolver + +_CAPS_PRESENT = {"sei": {"supported": True, "version": 1}} + + +class FakeClient: + """LoomweaveClient stand-in at the surface the resolver uses (mirrors the SEI oracle + / SeiResolver test doubles). ``resolve_sei`` returns a fixed body per SEI.""" + + def __init__(self, *, caps: Any = None, resolve_sei: dict[str, Any] | None = None) -> None: + self._caps = caps + self._resolve_sei = resolve_sei + self.sei_calls: list[str] = [] + + def capabilities(self) -> Any: + return self._caps + + def resolve_identity(self, locator: str) -> dict[str, Any] | None: + return None + + def resolve_sei(self, sei: str) -> dict[str, Any] | None: + self.sei_calls.append(sei) + return self._resolve_sei + + +def _resolver(client: FakeClient) -> SeiResolver: + return SeiResolver(client, SeiCapability.from_capabilities(client.capabilities())) + + +def _write(root: Path, rel: str, body: str) -> Path: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + + +def _scope(*entities: AffectedEntity, source_kind: str = "entity_list") -> AffectedScope: + return AffectedScope(frozenset(entities), source_kind, len(entities)) + + +# --- canonicalization helper ------------------------------------------------- + + +def test_canonical_qualname_strips_property_suffixes() -> None: + assert canonical_qualname("pkg.mod.Cls.prop:setter") == "pkg.mod.Cls.prop" + assert canonical_qualname("pkg.mod.Cls.prop:deleter") == "pkg.mod.Cls.prop" + assert canonical_qualname("pkg.mod.func") == "pkg.mod.func" + + +def test_locator_to_qualname_handles_method_prefix() -> None: + # The Phase 2 bug fix: python:method: must resolve, not hit the python: catch-all. + assert locator_to_qualname("python:method:pkg.mod.Cls.m") == "pkg.mod.Cls.m" + assert locator_to_qualname("python:function:pkg.mod.f") == "pkg.mod.f" + assert locator_to_qualname("python:class:pkg.mod.Cls") == "pkg.mod.Cls" + + +# --- build_qualname_index ---------------------------------------------------- + + +def test_build_qualname_index_maps_qualname_to_file(tmp_path: Path) -> None: + a = _write(tmp_path, "a.py", "def alpha():\n return 1\n") + b = _write(tmp_path, "b.py", "class Box:\n def beta(self):\n return 2\n") + index = build_qualname_index([a, b], tmp_path) + assert index.by_qualname["a.alpha"] == "a.py" + assert index.by_qualname["b.Box.beta"] == "b.py" + + +def test_build_qualname_index_skips_syntax_error_file(tmp_path: Path) -> None: + ok = _write(tmp_path, "ok.py", "def good():\n return 1\n") + bad = _write(tmp_path, "bad.py", "def broken(:\n pass\n") # invalid syntax + index = build_qualname_index([ok, bad], tmp_path) # must not raise + assert index.by_qualname["ok.good"] == "ok.py" + assert all(path != "bad.py" for path in index.by_qualname.values()) + + +def test_build_qualname_index_canonicalizes_property_accessor(tmp_path: Path) -> None: + src = ( + "class Cfg:\n" + " @property\n" + " def value(self):\n" + " return self._v\n" + " @value.setter\n" + " def value(self, v):\n" + " self._v = v\n" + ) + f = _write(tmp_path, "cfg.py", src) + index = build_qualname_index([f], tmp_path) + # The setter entity's raw qualname carries ':setter'; the key is canonical. + assert index.by_qualname["cfg.Cfg.value"] == "cfg.py" + + +# --- resolve via SEI --------------------------------------------------------- + + +def test_resolve_via_sei_authoritative(tmp_path: Path) -> None: + f = _write(tmp_path, "svc.py", "def handler():\n return 1\n") + index = build_qualname_index([f], tmp_path) + client = FakeClient( + caps=_CAPS_PRESENT, + resolve_sei={"alive": True, "current_locator": "python:function:svc.handler"}, + ) + scope = _scope(AffectedEntity(sei="loomweave:eid:abc", locator=None)) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=_resolver(client)) + assert resolved.files == frozenset({"svc.py"}) + assert "svc.handler" in resolved.affected_qualnames + assert len(resolved.resolved) == 1 + assert resolved.loomweave_used is True + assert client.sei_calls == ["loomweave:eid:abc"] + + +def test_resolve_via_sei_method_locator(tmp_path: Path) -> None: + src = "class Svc:\n def handle(self):\n return 1\n" + f = _write(tmp_path, "svc.py", src) + index = build_qualname_index([f], tmp_path) + client = FakeClient( + caps=_CAPS_PRESENT, + # SEI resolves to a python:method: locator — exercises the prefix bug fix. + resolve_sei={"alive": True, "current_locator": "python:method:svc.Svc.handle"}, + ) + scope = _scope(AffectedEntity(sei="loomweave:eid:m", locator=None)) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=_resolver(client)) + assert resolved.files == frozenset({"svc.py"}) + assert "svc.Svc.handle" in resolved.affected_qualnames + + +# --- qualname fallback ------------------------------------------------------- + + +def test_resolve_qualname_fallback_no_resolver(tmp_path: Path) -> None: + f = _write(tmp_path, "svc.py", "def handler():\n return 1\n") + index = build_qualname_index([f], tmp_path) + scope = _scope(AffectedEntity(sei=None, locator="python:function:svc.handler")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.files == frozenset({"svc.py"}) + assert len(resolved.fell_back) == 1 + assert resolved.loomweave_used is False + + +def test_resolve_qualname_fallback_resolver_unsupported(tmp_path: Path) -> None: + f = _write(tmp_path, "svc.py", "def handler():\n return 1\n") + index = build_qualname_index([f], tmp_path) + client = FakeClient(caps=None) # pre-SEI loomweave → unsupported + scope = _scope(AffectedEntity(sei="loomweave:eid:x", locator="python:function:svc.handler")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=_resolver(client)) + assert resolved.files == frozenset({"svc.py"}) + assert len(resolved.fell_back) == 1 + assert resolved.loomweave_used is False + + +def test_resolve_method_locator_via_fallback(tmp_path: Path) -> None: + src = "class Svc:\n def handle(self):\n return 1\n" + f = _write(tmp_path, "svc.py", src) + index = build_qualname_index([f], tmp_path) + scope = _scope(AffectedEntity(sei=None, locator="python:method:svc.Svc.handle")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.files == frozenset({"svc.py"}) + assert "svc.Svc.handle" in resolved.affected_qualnames + + +def test_resolve_property_setter_via_base_locator(tmp_path: Path) -> None: + src = ( + "class Cfg:\n" + " @property\n" + " def value(self):\n" + " return self._v\n" + " @value.setter\n" + " def value(self, v):\n" + " self._v = v\n" + ) + f = _write(tmp_path, "cfg.py", src) + index = build_qualname_index([f], tmp_path) + # Locator names the base property — must match the (canonical) setter entity's key. + scope = _scope(AffectedEntity(sei=None, locator="python:function:cfg.Cfg.value")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.files == frozenset({"cfg.py"}) + assert "cfg.Cfg.value" in resolved.affected_qualnames + + +def test_resolve_class_level_locator_scopes_methods(tmp_path: Path) -> None: + src = "class Svc:\n def a(self):\n return 1\n def b(self):\n return 2\n" + f = _write(tmp_path, "svc.py", src) + index = build_qualname_index([f], tmp_path) + scope = _scope(AffectedEntity(sei=None, locator="python:class:svc.Svc")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.files == frozenset({"svc.py"}) + # The class qualname itself is the affected key; the filter's prefix rule scopes in + # both methods (covered in the finding-filter tests). + assert "svc.Svc" in resolved.affected_qualnames + + +def test_resolve_nested_class_qualname(tmp_path: Path) -> None: + src = "class Outer:\n class Inner:\n def deep(self):\n return 1\n" + f = _write(tmp_path, "nest.py", src) + index = build_qualname_index([f], tmp_path) + scope = _scope(AffectedEntity(sei=None, locator="python:method:nest.Outer.Inner.deep")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.files == frozenset({"nest.py"}) + assert "nest.Outer.Inner.deep" in resolved.affected_qualnames + + +# --- partial resolution / unresolved ----------------------------------------- + + +def test_partial_resolution_is_not_full_fallback(tmp_path: Path) -> None: + f = _write(tmp_path, "svc.py", "def handler():\n return 1\n") + index = build_qualname_index([f], tmp_path) + good = AffectedEntity(sei=None, locator="python:function:svc.handler") + bogus = AffectedEntity(sei=None, locator="python:function:nope.missing") + resolved = resolve_affected_scope(_scope(good, bogus), index=index, sei_resolver=None) + assert len(resolved.files) == 1 + assert resolved.files == frozenset({"svc.py"}) + assert len(resolved.unresolved) == 1 + assert resolved.unresolved[0] is bogus + + +# --- SEI drift --------------------------------------------------------------- + + +def test_sei_drift_falls_through_to_locator(tmp_path: Path) -> None: + f = _write(tmp_path, "svc.py", "def renamed():\n return 1\n") + index = build_qualname_index([f], tmp_path) + client = FakeClient( + caps=_CAPS_PRESENT, + # SEI resolves to a qualname that no longer exists in the index (a rename since + # loomweave's last index) → drift; recovery is via the supplied locator. + resolve_sei={"alive": True, "current_locator": "python:function:svc.old_name"}, + ) + entity = AffectedEntity(sei="loomweave:eid:drift", locator="python:function:svc.renamed") + resolved = resolve_affected_scope(_scope(entity), index=index, sei_resolver=_resolver(client)) + assert resolved.files == frozenset({"svc.py"}) + assert len(resolved.stale_sei) == 1 + assert resolved.stale_sei[0] is entity + assert len(resolved.fell_back) == 0 + assert len(resolved.resolved) == 0 + + +def test_sei_drift_no_locator_is_unresolved(tmp_path: Path) -> None: + f = _write(tmp_path, "svc.py", "def renamed():\n return 1\n") + index = build_qualname_index([f], tmp_path) + client = FakeClient( + caps=_CAPS_PRESENT, + resolve_sei={"alive": True, "current_locator": "python:function:svc.old_name"}, + ) + entity = AffectedEntity(sei="loomweave:eid:drift", locator=None) + resolved = resolve_affected_scope(_scope(entity), index=index, sei_resolver=_resolver(client)) + assert resolved.files == frozenset() + assert len(resolved.unresolved) == 1 + assert len(resolved.stale_sei) == 0 + + +# --- caller closure ---------------------------------------------------------- + + +def test_caller_closure_pulls_callers_file(tmp_path: Path) -> None: + # b.py defines the changed callee; a.py's sink calls it. A worklist naming b.py's + # entity must pull a.py into the analyzed set via the reverse-edge closure. + _write(tmp_path, "b.py", "def source():\n return input()\n") + a = _write( + tmp_path, + "a.py", + "from b import source\n\ndef sink():\n return source()\n", + ) + b = tmp_path / "b.py" + index = build_qualname_index([a, b], tmp_path) + # The structural pass resolved a.sink -> b.source. + assert "b.source" in index.project_edges["a.sink"] + scope = _scope(AffectedEntity(sei=None, locator="python:function:b.source")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert "b.py" in resolved.files # base + assert "a.py" in resolved.files # caller closure + # The filter set stays the BASE set (only files expanded). + assert resolved.affected_qualnames == frozenset({"b.source"}) + + +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" + ) + f = _write(tmp_path, "svc.py", src) + index = build_qualname_index([f], tmp_path) + assert "svc.Svc.source" in index.project_edges["svc.Svc.sink"] + scope = _scope(AffectedEntity(sei=None, locator="python:method:svc.Svc.source")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + assert resolved.files == frozenset({"svc.py"}) diff --git a/tests/unit/core/test_delta_scope.py b/tests/unit/core/test_delta_scope.py new file mode 100644 index 00000000..2cf5a31a --- /dev/null +++ b/tests/unit/core/test_delta_scope.py @@ -0,0 +1,294 @@ +"""Phase 1 — ``delta_scope.parse_affected_scope`` / ``load_affected_scope`` (spec §5.1).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from wardline.core.delta_scope import ( + _MAX_ITEM_COUNT, + _MAX_PAYLOAD_BYTES, + AffectedEntity, + AffectedScope, + ScopeParseError, + load_affected_scope, + parse_affected_scope, + parse_affected_scope_text, +) +from wardline.core.errors import WardlineError + +# --- the three structurally-distinct accepted shapes ----------------------------- + + +def test_full_envelope_with_data_wrapper() -> None: + """Shape 1: ``warpline.reverify_worklist.v1`` full success envelope.""" + payload = { + "schema": "warpline.reverify_worklist.v1", + "data": { + "items": [ + {"entity": {"sei": "loomweave:eid:aaaa", "locator": "python:function:pkg.a"}}, + {"entity": {"locator": "python:function:pkg.b"}}, + ] + }, + } + scope = parse_affected_scope(payload) + assert isinstance(scope, AffectedScope) + assert scope.source_kind == "reverify_worklist_v1" + assert scope.item_count == 2 + assert scope.entities == frozenset( + { + AffectedEntity(sei="loomweave:eid:aaaa", locator="python:function:pkg.a"), + AffectedEntity(sei=None, locator="python:function:pkg.b"), + } + ) + + +def test_bare_data_payload_no_envelope() -> None: + """Shape 2: a producer that sends the inner ``data`` object directly (``{"items": …}``) + — a DISTINCT object from shape 1; must parse via its own fixture.""" + payload = { + "items": [ + {"entity": {"sei": "loomweave:eid:bbbb"}}, + {"entity": {"locator": "python:method:pkg.Cls.m"}}, + ] + } + scope = parse_affected_scope(payload) + assert scope.source_kind == "reverify_worklist_v1" + assert scope.item_count == 2 + assert scope.entities == frozenset( + { + AffectedEntity(sei="loomweave:eid:bbbb", locator=None), + AffectedEntity(sei=None, locator="python:method:pkg.Cls.m"), + } + ) + + +def test_bare_entity_list() -> None: + """Shape 3: a bare ``[{sei?, locator?}, ...]`` list.""" + payload = [ + {"sei": "loomweave:eid:cccc", "locator": "python:function:pkg.c"}, + {"locator": "python:class:pkg.D"}, + ] + scope = parse_affected_scope(payload) + assert scope.source_kind == "entity_list" + assert scope.item_count == 2 + assert scope.entities == frozenset( + { + AffectedEntity(sei="loomweave:eid:cccc", locator="python:function:pkg.c"), + AffectedEntity(sei=None, locator="python:class:pkg.D"), + } + ) + + +# --- entity sei/locator combinations --------------------------------------------- + + +@pytest.mark.parametrize( + ("obj", "expected"), + [ + ({"sei": "s"}, AffectedEntity(sei="s", locator=None)), + ({"locator": "python:function:x"}, AffectedEntity(sei=None, locator="python:function:x")), + ( + {"sei": "s", "locator": "python:function:x"}, + AffectedEntity(sei="s", locator="python:function:x"), + ), + ], +) +def test_entity_sei_locator_combinations(obj: dict[str, str], expected: AffectedEntity) -> None: + scope = parse_affected_scope([obj]) + assert scope.entities == frozenset({expected}) + assert scope.source_kind == "entity_list" + + +def test_entity_with_neither_sei_nor_locator_is_dropped() -> None: + """An entity with neither key (or only blank/null values) is dropped — counts toward + ``item_count`` (the input was seen) but not toward the resolved entity set; a list of + only such entities is 'empty'.""" + scope = parse_affected_scope([{"priority": 1}, {"sei": None, "locator": ""}]) + assert scope.entities == frozenset() + assert scope.source_kind == "empty" + assert scope.item_count == 2 + + +# --- empty payloads are NOT errors (fail-closed handled downstream) ---------------- + + +def test_empty_list_is_empty_not_error() -> None: + scope = parse_affected_scope([]) + assert scope == AffectedScope(frozenset(), "empty", 0) + + +def test_empty_worklist_items_is_empty_not_error() -> None: + scope = parse_affected_scope({"data": {"items": []}}) + assert scope == AffectedScope(frozenset(), "empty", 0) + + +def test_worklist_object_with_no_items_is_empty_not_error() -> None: + scope = parse_affected_scope({"data": {}}) + assert scope == AffectedScope(frozenset(), "empty", 0) + + +# --- malformed payloads are loud ScopeParseError ---------------------------------- + + +def test_scope_parse_error_is_wardline_error() -> None: + assert issubclass(ScopeParseError, WardlineError) + + +@pytest.mark.parametrize( + "payload", + [ + "a string", + 42, + None, + True, + ], +) +def test_non_object_non_array_payload_raises(payload: object) -> None: + with pytest.raises(ScopeParseError): + parse_affected_scope(payload) + + +def test_items_not_a_list_raises() -> None: + with pytest.raises(ScopeParseError): + parse_affected_scope({"data": {"items": {"not": "a list"}}}) + + +def test_data_not_an_object_raises() -> None: + with pytest.raises(ScopeParseError): + parse_affected_scope({"data": "not an object"}) + + +def test_worklist_item_not_an_object_raises() -> None: + with pytest.raises(ScopeParseError): + parse_affected_scope({"items": ["not-an-object"]}) + + +def test_worklist_item_entity_not_an_object_raises() -> None: + with pytest.raises(ScopeParseError): + parse_affected_scope({"items": [{"entity": "not-an-object"}]}) + + +def test_entity_list_item_not_an_object_raises() -> None: + with pytest.raises(ScopeParseError): + parse_affected_scope(["not-an-object"]) + + +def test_worklist_item_without_entity_key_is_skipped() -> None: + """A worklist item missing its ``entity`` key is skipped (not malformed) — only a + non-object ``entity`` value is malformed.""" + scope = parse_affected_scope({"items": [{"priority": 3}, {"entity": {"locator": "python:function:pkg.f"}}]}) + assert scope.entities == frozenset({AffectedEntity(sei=None, locator="python:function:pkg.f")}) + assert scope.item_count == 2 + + +# --- over-cap (bytes AND item_count) ---------------------------------------------- + + +def test_over_cap_by_item_count_raises() -> None: + payload = [{"locator": f"python:function:pkg.f{i}"} for i in range(_MAX_ITEM_COUNT + 1)] + with pytest.raises(ScopeParseError, match="item cap"): + parse_affected_scope(payload) + + +def test_over_cap_by_item_count_worklist_raises() -> None: + payload = {"items": [{"entity": {"locator": f"python:function:pkg.f{i}"}} for i in range(_MAX_ITEM_COUNT + 1)]} + with pytest.raises(ScopeParseError, match="item cap"): + parse_affected_scope(payload) + + +def test_over_cap_by_bytes_raises() -> None: + """A payload whose serialized byte length exceeds the cap raises even if the item + count alone is under the limit (one giant value).""" + big_locator = "python:function:" + ("x" * (_MAX_PAYLOAD_BYTES + 1)) + with pytest.raises(ScopeParseError, match="byte cap"): + parse_affected_scope([{"locator": big_locator}]) + + +def test_under_cap_passes() -> None: + payload = [{"locator": f"python:function:pkg.f{i}"} for i in range(10)] + scope = parse_affected_scope(payload) + assert scope.item_count == 10 + + +# --- parse_affected_scope_text (raw-text ingress, pre-parse byte cap) -------------- + + +def test_parse_affected_scope_text_valid() -> None: + """The raw-text entry point parses a valid JSON string into an AffectedScope.""" + raw = json.dumps([{"locator": "python:function:pkg.f"}]) + scope = parse_affected_scope_text(raw) + assert scope.entities == frozenset({AffectedEntity(sei=None, locator="python:function:pkg.f")}) + assert scope.source_kind == "entity_list" + + +def test_parse_affected_scope_text_bad_json_raises() -> None: + """Invalid JSON in the raw text raises ScopeParseError (malformed posture, §7).""" + with pytest.raises(ScopeParseError, match="not valid JSON"): + parse_affected_scope_text("{not valid json") + + +def test_parse_affected_scope_text_over_cap_rejected_before_parse(monkeypatch) -> None: + """A VALID JSON blob whose RAW byte length just exceeds the cap is rejected BEFORE + ``json.loads`` ever parses it (DoS guard at the raw-bytes boundary, §7). + + We assert the pre-parse ordering directly: patch ``json.loads`` in the module to + explode if it is ever called, then confirm the over-cap raw text raises the byte-cap + ScopeParseError without that call firing.""" + import wardline.core.delta_scope as ds + + def _boom(_s: str) -> object: # pragma: no cover - must never run + raise AssertionError("json.loads was called on an over-cap blob before the byte cap fired") + + monkeypatch.setattr(ds.json, "loads", _boom) + + # A valid JSON array padded to just over the byte cap by a giant locator value. + big_locator = "python:function:" + ("x" * (_MAX_PAYLOAD_BYTES + 1)) + raw = json.dumps([{"locator": big_locator}]) + assert len(raw.encode("utf-8")) > _MAX_PAYLOAD_BYTES + with pytest.raises(ScopeParseError, match="byte cap"): + parse_affected_scope_text(raw) + + +def test_parse_affected_scope_text_at_cap_passes() -> None: + """A raw text whose byte length is exactly at (not over) the cap is accepted.""" + raw = json.dumps([{"locator": "python:function:pkg.f"}]) + assert len(raw.encode("utf-8")) <= _MAX_PAYLOAD_BYTES + scope = parse_affected_scope_text(raw) + assert scope.item_count == 1 + + +# --- load_affected_scope (file ingress) ------------------------------------------- + + +def test_load_affected_scope_reads_file(tmp_path: Path) -> None: + src = tmp_path / "worklist.json" + src.write_text(json.dumps([{"locator": "python:function:pkg.f"}]), encoding="utf-8") + scope = load_affected_scope(str(src)) + assert scope.entities == frozenset({AffectedEntity(sei=None, locator="python:function:pkg.f")}) + assert scope.source_kind == "entity_list" + + +def test_load_affected_scope_bad_json_raises(tmp_path: Path) -> None: + src = tmp_path / "broken.json" + src.write_text("{not valid json", encoding="utf-8") + with pytest.raises(ScopeParseError, match="not valid JSON"): + load_affected_scope(str(src)) + + +def test_load_affected_scope_missing_file_raises(tmp_path: Path) -> None: + with pytest.raises(ScopeParseError, match="could not read"): + load_affected_scope(str(tmp_path / "does-not-exist.json")) + + +def test_load_affected_scope_full_envelope_file(tmp_path: Path) -> None: + src = tmp_path / "envelope.json" + src.write_text( + json.dumps({"data": {"items": [{"entity": {"sei": "loomweave:eid:z"}}]}}), + encoding="utf-8", + ) + scope = load_affected_scope(str(src)) + assert scope.source_kind == "reverify_worklist_v1" + assert scope.entities == frozenset({AffectedEntity(sei="loomweave:eid:z", locator=None)}) diff --git a/tests/unit/core/test_delta_scope_report.py b/tests/unit/core/test_delta_scope_report.py new file mode 100644 index 00000000..d65ffca9 --- /dev/null +++ b/tests/unit/core/test_delta_scope_report.py @@ -0,0 +1,119 @@ +"""Phase 5 — ``delta_scope.DeltaScopeReport`` + ``BOUNDARY_CAVEAT`` (spec §5.4).""" + +from __future__ import annotations + +from wardline.core.delta_scope import BOUNDARY_CAVEAT, DeltaScopeReport + + +def _delta_report(**overrides: object) -> DeltaScopeReport: + base: dict[str, object] = { + "mode": "delta", + "gate_authority": "advisory", + "entities_requested": 3, + "files_discovered": 10, + "files_analyzed": 2, + "in_scope_findings": 1, + "fell_back_count": 1, + "stale_sei_count": 0, + "unresolved_entities": (), + "loomweave_used": True, + } + base.update(overrides) + return DeltaScopeReport(**base) # type: ignore[arg-type] + + +def test_boundary_caveat_is_the_fixed_stronger_string() -> None: + """The caveat is the exact (stronger) string and names the in-scope-correctness + hazard — a finding ON an analyzed entity can be MISSING, not just out-of-scope + entities omitted.""" + assert BOUNDARY_CAVEAT == ( + "Delta scan analyzes only files containing the affected entities. Findings here " + "may be incomplete OR absent: cross-file taint whose source lies outside the " + "analyzed set is not computed, so an in-scope entity can read clean without being " + "clean. Advisory inner-loop signal, not a verdict — the full scan is the gate of " + "record." + ) + # names the in-scope-entity correctness hazard (not just omission) + assert "incomplete OR absent" in BOUNDARY_CAVEAT + assert "read clean without being" in BOUNDARY_CAVEAT + assert "gate of record" in BOUNDARY_CAVEAT + + +def test_boundary_caveat_is_the_default() -> None: + report = _delta_report() + assert report.boundary_caveat == BOUNDARY_CAVEAT + + +def test_to_dict_keys_and_shape() -> None: + report = _delta_report( + unresolved_entities=( + {"locator": "python:function:pkg.bogus", "sei": None}, + {"locator": None, "sei": "loomweave:eid:gone"}, + ), + ) + d = report.to_dict() + assert set(d.keys()) == { + "mode", + "gate_authority", + "entities_requested", + "files_discovered", + "files_analyzed", + "in_scope_findings", + "fell_back_count", + "stale_sei_count", + "unresolved_entities", + "loomweave_used", + "boundary_caveat", + } + assert d["mode"] == "delta" + assert d["gate_authority"] == "advisory" + assert d["entities_requested"] == 3 + assert d["files_discovered"] == 10 + assert d["files_analyzed"] == 2 + assert d["in_scope_findings"] == 1 + assert d["fell_back_count"] == 1 + assert d["stale_sei_count"] == 0 + assert d["loomweave_used"] is True + assert d["boundary_caveat"] == BOUNDARY_CAVEAT + assert d["unresolved_entities"] == [ + {"locator": "python:function:pkg.bogus", "sei": None}, + {"locator": None, "sei": "loomweave:eid:gone"}, + ] + + +def test_to_dict_unresolved_entities_are_copies() -> None: + """``to_dict`` copies each inner dict so mutating the output does not corrupt the + frozen report.""" + original = {"locator": "python:function:pkg.x", "sei": None} + report = _delta_report(unresolved_entities=(original,)) + d = report.to_dict() + unresolved = d["unresolved_entities"] + assert isinstance(unresolved, list) + unresolved[0]["locator"] = "MUTATED" + assert original["locator"] == "python:function:pkg.x" + + +def test_delta_mode_gate_authority_is_advisory() -> None: + report = _delta_report(mode="delta", gate_authority="advisory") + assert report.to_dict()["gate_authority"] == "advisory" + + +def test_full_fallback_mode_gate_authority_is_gate_of_record() -> None: + """In full-fallback the scan IS the gate of record — machine-readable distinction + from an advisory delta pass.""" + report = _delta_report( + mode="full-fallback", + gate_authority="gate-of-record", + files_analyzed=10, # == files_discovered in full-fallback + ) + d = report.to_dict() + assert d["mode"] == "full-fallback" + assert d["gate_authority"] == "gate-of-record" + assert d["files_analyzed"] == d["files_discovered"] + + +def test_stale_sei_and_fell_back_counts_surface_trust() -> None: + report = _delta_report(fell_back_count=2, stale_sei_count=1) + d = report.to_dict() + assert d["fell_back_count"] == 2 + assert d["stale_sei_count"] == 1 diff --git a/tests/unit/core/test_run_affected.py b/tests/unit/core/test_run_affected.py new file mode 100644 index 00000000..88219c5d --- /dev/null +++ b/tests/unit/core/test_run_affected.py @@ -0,0 +1,256 @@ +"""Phase 3 — scan scoping in ``run_scan`` (``--affected`` delta scan). + +These tests pin the wiring ``run_scan`` adds between discovery and analysis: + +* a scoped subset reaches the analyzer (only affected-entity files are analyzed); +* the full path is unchanged when ``affected is None`` (INV-1) — proven by a spy that + ``build_qualname_index`` is NOT called on the full-scan path; +* ``affected`` + ``new_since`` together is a loud ``ScopeParseError``; +* in delta mode ``gate_findings`` RETAINS an out-of-scope ERROR finding (the gate + population is NEVER narrowed — INV-4 / THREAT-001) and the gate verdict equals the + full scan's, so a surgical worklist cannot forge a green. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from wardline.core.delta_scope import ScopeParseError, parse_affected_scope +from wardline.core.finding import Kind, Severity, SuppressionState +from wardline.core.run import gate_decision, run_scan + +# A trusted boundary returning an external-tainted value: PY-WL-101 ERROR defect. +# Mirrors ``_LEAKY`` in test_run.py — the entity ``leaky`` carries the ERROR. +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + +# Two CO-LOCATED leaky entities in one module: ``alpha`` and ``beta``. A worklist naming +# only ``alpha`` displays alpha's finding, drops beta's from the displayed set, but keeps +# beta's finding in ``gate_findings`` (the file IS analyzed; the display filter never +# touches the gate population — INV-4 at the Phase 3 seam). +_TWO_ENTITY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef alpha(p):\n return read_raw(p)\n" + "@trusted\ndef beta(p):\n return read_raw(p)\n" +) + + +def _two_file_proj(tmp_path: Path) -> Path: + """A project with two structurally-identical leaky modules, ``good.py`` + ``evil.py``, + each carrying a PY-WL-101 ERROR on its ``leaky`` entity.""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "good.py").write_text(_LEAKY, encoding="utf-8") + (proj / "evil.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +def _co_located_proj(tmp_path: Path) -> Path: + """A project with one module carrying two co-located leaky entities (``alpha``, + ``beta``).""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_TWO_ENTITY, encoding="utf-8") + return proj + + +def _py101_paths(findings: list) -> set[str]: + return {f.location.path for f in findings if f.rule_id == "PY-WL-101"} + + +def _py101_quals(findings: list) -> set[str]: + return {f.qualname for f in findings if f.rule_id == "PY-WL-101"} + + +def test_affected_scopes_analysis_to_subset(tmp_path: Path) -> None: + """A worklist naming only ``good.leaky`` analyzes ``good.py`` and not ``evil.py``; + the displayed findings carry only the ``good.py`` PY-WL-101 finding.""" + proj = _two_file_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:good.leaky"}]) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + assert result.scope.mode == "delta" + assert result.scope.files_discovered == 2 + assert result.scope.files_analyzed == 1 + # Displayed findings narrowed to the affected entity's file only. + assert _py101_paths(result.findings) == {"good.py"} + + +def test_full_path_when_affected_is_none(tmp_path: Path) -> None: + """``affected is None`` → byte-identical full scan: both files analyzed, both + PY-WL-101 findings present, and no scope block (INV-1).""" + proj = _two_file_proj(tmp_path) + + result = run_scan(proj) + + assert result.scope is None + assert _py101_paths(result.findings) == {"good.py", "evil.py"} + + +def test_inv1_spy_build_qualname_index_not_called_when_affected_none(tmp_path: Path) -> None: + """INV-1: the full-scan path pays NO delta cost — ``build_qualname_index`` is never + invoked when ``affected is None`` (a future refactor cannot make the full path + silently build the index).""" + proj = _two_file_proj(tmp_path) + + with patch("wardline.core.run.build_qualname_index") as spy: + run_scan(proj) + + spy.assert_not_called() + + +def test_inv1_spy_build_qualname_index_called_in_delta(tmp_path: Path) -> None: + """The companion: the spy DOES fire when ``affected`` is supplied, so the negative + assertion above is meaningful (not green because the symbol moved).""" + proj = _two_file_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:good.leaky"}]) + + # Call through the real implementation while recording the call. + from wardline.core import delta_resolve + + with patch( + "wardline.core.run.build_qualname_index", + wraps=delta_resolve.build_qualname_index, + ) as spy: + run_scan(proj, affected=scope) + + spy.assert_called_once() + + +def test_affected_and_new_since_mutually_exclusive(tmp_path: Path) -> None: + """``--affected`` and ``--new-since`` scope different things via different mechanisms; + composing them is rejected loudly with ``ScopeParseError`` (never silently double- + scoped).""" + proj = _two_file_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:good.leaky"}]) + + with pytest.raises(ScopeParseError): + run_scan(proj, affected=scope, new_since="origin/main") + + +def test_delta_gate_findings_retains_out_of_scope_finding(tmp_path: Path) -> None: + """INV-4 / THREAT-001: a worklist naming only ``alpha`` narrows the DISPLAYED findings + (``beta`` dropped) but the gate population (``gate_findings``) STILL contains ``beta``'s + ERROR — the display filter NEVER narrows the gate, so an attacker-influenceable scope + cannot forge a green by excluding a co-located sink.""" + proj = _co_located_proj(tmp_path) + # Names only alpha; beta is the out-of-(display)-scope co-located sink. + scope = parse_affected_scope([{"locator": "python:function:svc.alpha"}]) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None and result.scope.mode == "delta" + # Displayed: only alpha. Gate population: BOTH (never narrowed by the filter). + assert _py101_quals(result.findings) == {"svc.alpha"} + assert result.gate_findings is not None + assert _py101_quals(result.gate_findings) == {"svc.alpha", "svc.beta"} + # The display-excluded beta ERROR is still live in the gate population. + beta_in_gate = [ + f + for f in result.gate_findings + if f.rule_id == "PY-WL-101" and f.qualname == "svc.beta" and f.suppressed is SuppressionState.ACTIVE + ] + assert beta_in_gate + + +def test_delta_gate_verdict_equals_full_scan(tmp_path: Path) -> None: + """INV-4: the delta gate verdict is IDENTICAL to the full scan's — narrowing the + DISPLAYED findings to ``alpha`` cannot turn a FAILED into a PASSED, because the gate + population still carries ``beta``.""" + proj = _co_located_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:svc.alpha"}]) + + full = run_scan(proj) + delta = run_scan(proj, affected=scope) + + full_decision = gate_decision(full, Severity.ERROR) + delta_decision = gate_decision(delta, Severity.ERROR) + + assert full_decision.tripped is True + assert delta_decision.tripped == full_decision.tripped + assert delta_decision.verdict == full_decision.verdict == "FAILED" + assert delta_decision.exit_class == full_decision.exit_class == 1 + + +def test_delta_trust_suppressions_gate_population_is_unfiltered(tmp_path: Path) -> None: + """INV-4 / THREAT-001 under ``--trust-suppressions``: a delta scan MATERIALISES a + concrete gate population (post-suppression, pre-delta-filter) instead of leaving + ``gate_findings`` at the ``None`` sentinel — otherwise the gate would fall back to the + delta-FILTERED ``findings`` and a surgical-exclusion worklist could forge a green. + + The displayed ``findings`` are narrowed to ``alpha``, but the gate population retains + BOTH co-located ERROR sinks, and the posture still HONORS suppressions (``--trust- + suppressions`` is unchanged in meaning — only the gate population is now unforgeable).""" + proj = _co_located_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:svc.alpha"}]) + + result = run_scan(proj, affected=scope, trust_suppressions=True) + + # Displayed: only alpha. Gate population: a CONCRETE list carrying BOTH sinks — + # never the None sentinel (which would fall back to the filtered display set). + assert _py101_quals(result.findings) == {"svc.alpha"} + assert result.gate_findings is not None + assert _py101_quals(result.gate_findings) == {"svc.alpha", "svc.beta"} + # The posture is still trust-suppressions (the verdict honors repo suppressions), + # but it is carried EXPLICITLY, decoupled from the gate_findings sentinel. + assert result.honors_suppressions is True + + +def test_delta_trust_suppressions_cannot_forge_green(tmp_path: Path) -> None: + """INV-4 / THREAT-001: under ``--trust-suppressions`` a surgical-exclusion worklist + (names only ``alpha``, drops the co-located ``beta`` ERROR from display) MUST NOT turn + the FULL scan's FAILED verdict into a PASSED. The delta gate verdict is identical to the + full scan's because the gate evaluates the unfiltered analyzed population.""" + proj = _co_located_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:svc.alpha"}]) + + full = run_scan(proj, trust_suppressions=True) + delta = run_scan(proj, affected=scope, trust_suppressions=True) + + full_decision = gate_decision(full, Severity.ERROR) + delta_decision = gate_decision(delta, Severity.ERROR) + + assert full_decision.tripped is True + assert full_decision.verdict == "FAILED" + # The surgical exclusion CANNOT produce PASSED — verdict/exit identical to the full scan. + assert delta_decision.tripped == full_decision.tripped is True + assert delta_decision.verdict == full_decision.verdict == "FAILED" + assert delta_decision.exit_class == full_decision.exit_class == 1 + + +def test_empty_scope_falls_back_to_full(tmp_path: Path) -> None: + """Fail-closed (INV-3): an empty / all-unresolvable scope analyzes EVERYTHING and is + declared as full-fallback, never a silent narrow.""" + proj = _two_file_proj(tmp_path) + # All-unresolvable: a locator that matches no entity in the tree. + scope = parse_affected_scope([{"locator": "python:function:nope.missing"}]) + + result = run_scan(proj, affected=scope) + + assert result.scope is not None + assert result.scope.mode == "full-fallback" + assert result.scope.gate_authority == "gate-of-record" + assert result.scope.files_analyzed == result.scope.files_discovered == 2 + # Full population displayed in full-fallback. + assert _py101_paths(result.findings) == {"good.py", "evil.py"} + + +def test_delta_mode_summary_reflects_filtered_findings(tmp_path: Path) -> None: + """The summary is derived from the (filtered) emitted findings, so a delta scan's + active-defect count reflects only the displayed in-scope defects.""" + proj = _two_file_proj(tmp_path) + scope = parse_affected_scope([{"locator": "python:function:good.leaky"}]) + + result = run_scan(proj, affected=scope) + + active_defects = [f for f in result.findings if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE] + assert result.summary.active == len(active_defects) == 1 diff --git a/tests/unit/mcp/test_scan_affected_mcp.py b/tests/unit/mcp/test_scan_affected_mcp.py new file mode 100644 index 00000000..6ccec6be --- /dev/null +++ b/tests/unit/mcp/test_scan_affected_mcp.py @@ -0,0 +1,196 @@ +"""Phase 8: the MCP ``scan`` tool's ``affected`` (delta-scope) parameter. + +CLI ``wardline scan --affected `` scopes a scan to a warpline reverify-worklist +or bare entity list; the MCP scan tool must expose the same selector or the delta-scope +path is unreachable over the primary surface. The handler: + +- accepts an inline object/array worklist|entity-list, OR a string path under root; +- rejects ``affected`` + ``new_since`` together with a ``ToolError`` (mutual exclusion, + matching the CLI); +- maps a malformed inline payload (``ScopeParseError``) to a ``ToolError`` / isError result; +- emits ``result.scope.to_dict()`` as a top-level ``scope`` block that VALIDATES against + ``_SCAN_OUTPUT_SCHEMA`` (the only end-to-end pin of the scope shape with scope present). + +The scope block is OPTIONAL (absent on a full scan, INV-1) — so it must be exercised by a +real delta invocation, not just left untested. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import pytest + +from wardline.mcp.server import _SCAN_OUTPUT_SCHEMA, WardlineMCPServer, _scan +from wardline.mcp.tooling import ToolError + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) +_OTHER = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef rr(p):\n return p\n" + "@trusted\ndef otherleak(p):\n return rr(p)\n" +) + + +def _two_file_project(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + (proj / "other.py").write_text(_OTHER, encoding="utf-8") + return proj + + +# Two CO-LOCATED ERROR sinks (``alpha``, ``beta``) in ONE module — a worklist naming only +# ``alpha`` surgically excludes ``beta`` from the displayed findings while still analyzing +# the file, the in-analyzed-file THREAT-001 shape (INV-4). +_TWO_ENTITY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef alpha(p):\n return read_raw(p)\n" + "@trusted\ndef beta(p):\n return read_raw(p)\n" +) + + +def _co_located_project(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_TWO_ENTITY, encoding="utf-8") + return proj + + +def test_inline_entity_list_scopes_analysis(tmp_path: Path) -> None: + proj = _two_file_project(tmp_path) + out = _scan({"full": True, "affected": [{"locator": "python:function:svc.leaky"}]}, root=proj) + scope = out["scope"] + assert scope["mode"] == "delta" + assert scope["gate_authority"] == "advisory" + assert scope["files_discovered"] == 2 + assert scope["files_analyzed"] == 1 + # Only the affected entity's finding is displayed; the co-located other.py one is dropped. + shown = {e["qualname"] for e in out["agent_summary"]["active_defects"]} + assert shown == {"svc.leaky"} + + +def test_inline_worklist_envelope_scopes_analysis(tmp_path: Path) -> None: + proj = _two_file_project(tmp_path) + worklist = {"data": {"items": [{"entity": {"locator": "python:function:svc.leaky"}}]}} + out = _scan({"full": True, "affected": worklist}, root=proj) + assert out["scope"]["mode"] == "delta" + assert out["scope"]["files_analyzed"] == 1 + shown = {e["qualname"] for e in out["agent_summary"]["active_defects"]} + assert shown == {"svc.leaky"} + + +def test_affected_path_string_under_root(tmp_path: Path) -> None: + proj = _two_file_project(tmp_path) + worklist_path = proj / "worklist.json" + worklist_path.write_text(json.dumps([{"locator": "python:function:svc.leaky"}]), encoding="utf-8") + out = _scan({"full": True, "affected": "worklist.json"}, root=proj) + assert out["scope"]["mode"] == "delta" + assert out["scope"]["files_analyzed"] == 1 + + +def test_empty_affected_falls_back_to_full_scan(tmp_path: Path) -> None: + proj = _two_file_project(tmp_path) + out = _scan({"full": True, "affected": []}, root=proj) + scope = out["scope"] + assert scope["mode"] == "full-fallback" + assert scope["gate_authority"] == "gate-of-record" + assert scope["files_analyzed"] == scope["files_discovered"] == 2 + + +def test_malformed_inline_affected_is_tool_error(tmp_path: Path) -> None: + proj = _two_file_project(tmp_path) + # A non-object entity-list item is structurally malformed -> ScopeParseError -> ToolError. + with pytest.raises(ToolError): + _scan({"affected": ["not-an-object"]}, root=proj) + + +def test_affected_plus_new_since_is_tool_error(tmp_path: Path) -> None: + proj = _two_file_project(tmp_path) + with pytest.raises(ToolError, match="mutually exclusive"): + _scan({"affected": [{"locator": "python:function:svc.leaky"}], "new_since": "HEAD~1"}, root=proj) + + +def test_full_scan_emits_no_scope_block(tmp_path: Path) -> None: + # INV-1: a scan with no `affected` carries no scope block. + proj = _two_file_project(tmp_path) + out = _scan({"full": True}, root=proj) + assert "scope" not in out + + +def test_delta_structured_content_validates_with_scope_present(tmp_path: Path) -> None: + # The end-to-end pin: a delta invocation's structuredContent (scope NON-NULL) must + # validate against the tool's declared outputSchema, and the scope block must round-trip + # the dual-emission text block. + proj = _two_file_project(tmp_path) + server = WardlineMCPServer(root=proj) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "scan", + "arguments": {"full": True, "affected": [{"locator": "python:function:svc.leaky"}]}, + }, + } + ) + assert "error" not in resp, resp + result = resp["result"] + assert result.get("isError") is not True, result + structured = result["structuredContent"] + assert "scope" in structured and structured["scope"]["mode"] == "delta" + jsonschema.validate(structured, _SCAN_OUTPUT_SCHEMA) + assert json.loads(result["content"][0]["text"]) == structured + + +def test_affected_with_trust_suppressions_cannot_forge_a_pass(tmp_path: Path) -> None: + """INV-4 / THREAT-001 over the MCP primary surface, under ``trust_suppressions=True``. + + The MCP ``scan`` handler delegates to the same ``run_scan`` + ``gate_decision`` as the + CLI, so the engine-level fix covers it: a surgical-exclusion worklist (names only + ``svc.alpha``, drops the co-located ``svc.beta`` ERROR from display) with + ``trust_suppressions=True`` and ``fail_on=ERROR`` MUST surface ``verdict=FAILED`` — + identical to the full scan — and CANNOT forge a PASS by narrowing the gate population.""" + proj = _co_located_project(tmp_path) + + full = _scan({"fail_on": "ERROR", "trust_suppressions": True}, root=proj) + delta = _scan( + { + "fail_on": "ERROR", + "trust_suppressions": True, + "affected": [{"locator": "python:function:svc.alpha"}], + }, + root=proj, + ) + + # Delta narrowed the DISPLAYED defects to alpha only... + shown = {e["qualname"] for e in delta["agent_summary"]["active_defects"]} + assert shown == {"svc.alpha"} + assert delta["scope"]["mode"] == "delta" + # ...but the gate verdict is unforgeable: identical to the full scan's FAILED. + assert full["gate"]["verdict"] == "FAILED" + assert full["gate"]["tripped"] is True + assert delta["gate"]["verdict"] == "FAILED" + assert delta["gate"]["tripped"] is True + assert delta["gate"]["exit_class"] == full["gate"]["exit_class"] == 1 + + +def test_affected_schema_declared_on_scan_tool(tmp_path: Path) -> None: + server = WardlineMCPServer(root=tmp_path) + tools = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + scan_tool = next(t for t in tools["result"]["tools"] if t["name"] == "scan") + affected = scan_tool["inputSchema"]["properties"]["affected"] + assert affected["type"] == ["object", "array", "string"] + assert "reverify_worklist" in affected["description"] + # The scope output property is declared and optional (absent on a full scan). + scope_prop = scan_tool["outputSchema"]["properties"]["scope"] + assert scope_prop["type"] == "object" + assert "scope" not in scan_tool["outputSchema"]["required"] diff --git a/tests/unit/mcp/test_server_filigree_emit.py b/tests/unit/mcp/test_server_filigree_emit.py index b2c7027c..be23b666 100644 --- a/tests/unit/mcp/test_server_filigree_emit.py +++ b/tests/unit/mcp/test_server_filigree_emit.py @@ -27,15 +27,17 @@ def __init__(self, result): self._result = result self.seen = None self.scanned_paths = None + self.mark_unseen = "UNSET" - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), mark_unseen=None): self.seen = list(findings) self.scanned_paths = tuple(scanned_paths) + self.mark_unseen = mark_unseen return self._result class RaisingEmitter: - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), mark_unseen=None): raise FiligreeEmitError("Filigree rejected scan-results (400) at http://x: bad payload") @@ -206,3 +208,40 @@ def test_scan_filigree_5xx_says_server_error_not_unreachable(tmp_path): assert "503" in reason and "server error" in reason assert "unreachable" not in reason assert "WEFT_FEDERATION_TOKEN" not in reason + + +_TWO_FILE_OTHER = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef rr(p):\n return p\n" + "@trusted\ndef otherleak(p):\n return rr(p)\n" +) + + +def test_delta_scan_forces_mark_unseen_false(tmp_path): + # INV-5: a delta scan emits the FULL discovery list as scanned_paths but a FILTERED + # findings list. If Filigree's auto mark_unseen runs, every out-of-scope finding reads as + # "fixed" and its tracker issue closes (irreversible). The MCP path must force + # mark_unseen=False in delta mode, exactly as the CLI does. + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + (tmp_path / "other.py").write_text(_TWO_FILE_OTHER, encoding="utf-8") + emitter = FakeEmitter(EmitResult(reachable=True, created=1)) + out = _scan( + {"full": True, "affected": [{"locator": "python:function:svc.leaky"}]}, + tmp_path, + None, + emitter, + ) + assert out["scope"]["mode"] == "delta" + # The full discovery list is handed to the emitter (both files)... + assert len(emitter.scanned_paths) == 2 + # ...so mark_unseen MUST be forced False, never left to auto. + assert emitter.mark_unseen is False + + +def test_full_scan_leaves_mark_unseen_auto(tmp_path): + # A full / full-fallback scan must still reconcile normally: mark_unseen=None (auto), not + # forced False — otherwise the tracker never closes genuinely-fixed findings. + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + emitter = FakeEmitter(EmitResult(reachable=True, created=1)) + _scan({}, tmp_path, None, emitter) + assert emitter.mark_unseen is None diff --git a/tests/unit/mcp/test_server_filigree_wiring.py b/tests/unit/mcp/test_server_filigree_wiring.py index becef45c..e7245441 100644 --- a/tests/unit/mcp/test_server_filigree_wiring.py +++ b/tests/unit/mcp/test_server_filigree_wiring.py @@ -16,9 +16,10 @@ def __init__(self): self.seen = None self.scanned_paths = None - def emit(self, findings, *, scanned_paths=()): + def emit(self, findings, *, scanned_paths=(), mark_unseen=None): self.seen = list(findings) self.scanned_paths = tuple(scanned_paths) + self.mark_unseen = mark_unseen return EmitResult(reachable=True, created=len(self.seen)) diff --git a/tests/unit/mcp/test_server_security.py b/tests/unit/mcp/test_server_security.py index 56df828a..845c9816 100644 --- a/tests/unit/mcp/test_server_security.py +++ b/tests/unit/mcp/test_server_security.py @@ -90,12 +90,13 @@ def test_waiver_add_default_config_symlink_escape_is_iserror(tmp_path: Path) -> def test_scan_bad_fail_on_enum_is_actionable_iserror(tmp_path: Path) -> None: # A bad fail_on used to surface as an opaque -32603; now it is an actionable - # isError naming the valid set. + # isError. Defect #5: the closed-vocab `pattern` on the input schema rejects it at the + # jsonschema layer, so the actionable message names the valid set (case-insensitively). server = WardlineMCPServer(root=tmp_path) resp = _dispatch(server, "scan", {"fail_on": "BOGUS"}) _assert_iserror(resp, "") - text = resp["result"]["content"][0]["text"] - for w in ["CRITICAL", "ERROR", "WARN", "INFO"]: + text = resp["result"]["content"][0]["text"].lower() + for w in ["critical", "error", "warn", "info"]: assert w in text diff --git a/tests/unit/mcp/test_server_tools.py b/tests/unit/mcp/test_server_tools.py index f7d06dc7..a41a539e 100644 --- a/tests/unit/mcp/test_server_tools.py +++ b/tests/unit/mcp/test_server_tools.py @@ -314,3 +314,52 @@ def test_fix_tool_requires_explicit_apply(tmp_path: Path) -> None: modified_src = (proj / "svc.py").read_text(encoding="utf-8") assert "assert val is not None" not in modified_src assert "raise ValueError" in modified_src + + +# Defect #5: the closed-vocabulary input schemas must REJECT off-vocab strings at the +# jsonschema layer (not just at the runtime). The vocab is case-insensitive, so both +# "ERROR" and "error" must be ACCEPTED while "BOGUS"/"urgent" are REJECTED. +import jsonschema # noqa: E402 + +from wardline.mcp.server import ( # noqa: E402 + _FAIL_ON_INPUT_SCHEMA, + _WHERE_KIND_INPUT_SCHEMA, + _WHERE_SEVERITY_INPUT_SCHEMA, + _WHERE_SUPPRESSION_INPUT_SCHEMA, +) + + +def _accepts(schema: dict[str, Any], value: str) -> bool: + try: + jsonschema.validate(value, schema) + return True + except jsonschema.ValidationError: + return False + + +def test_fail_on_schema_constrains_closed_vocab() -> None: + for ok in ("CRITICAL", "ERROR", "error", "Warn", "info"): + assert _accepts(_FAIL_ON_INPUT_SCHEMA, ok), ok + for bad in ("BOGUS", "urgent", "none", "NONE", ""): + assert not _accepts(_FAIL_ON_INPUT_SCHEMA, bad), bad + + +def test_where_severity_schema_constrains_closed_vocab() -> None: + for ok in ("CRITICAL", "ERROR", "error", "WARN", "info", "NONE", "none"): + assert _accepts(_WHERE_SEVERITY_INPUT_SCHEMA, ok), ok + for bad in ("BOGUS", "urgent", ""): + assert not _accepts(_WHERE_SEVERITY_INPUT_SCHEMA, bad), bad + + +def test_where_suppression_schema_constrains_closed_vocab() -> None: + for ok in ("active", "ACTIVE", "baselined", "waived", "judged"): + assert _accepts(_WHERE_SUPPRESSION_INPUT_SCHEMA, ok), ok + for bad in ("BOGUS", "suppressed", ""): + assert not _accepts(_WHERE_SUPPRESSION_INPUT_SCHEMA, bad), bad + + +def test_where_kind_schema_constrains_closed_vocab() -> None: + for ok in ("defect", "DEFECT", "fact", "classification", "metric", "suggestion"): + assert _accepts(_WHERE_KIND_INPUT_SCHEMA, ok), ok + for bad in ("BOGUS", "finding", ""): + assert not _accepts(_WHERE_KIND_INPUT_SCHEMA, bad), bad diff --git a/tests/unit/test_ci_live_oracles.py b/tests/unit/test_ci_live_oracles.py index 5a49a4fa..172200df 100644 --- a/tests/unit/test_ci_live_oracles.py +++ b/tests/unit/test_ci_live_oracles.py @@ -20,13 +20,16 @@ def test_ci_exposes_scheduled_and_manual_live_oracles() -> None: "WARDLINE_LOOMWEAVE_BIN", "WARDLINE_LEGIS_URL", "WARDLINE_FILIGREE_URL", + "WARDLINE_WARPLINE_BIN", ): assert f"{key}: ${{{{ secrets.{key} }}}}" in workflow assert f'{LIVE_ORACLE_REQUIRED_ENV}: "1"' in workflow assert "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" in workflow - for marker in ("loomweave_e2e", "legis_e2e", "filigree_e2e"): + for marker in ("loomweave_e2e", "legis_e2e", "filigree_e2e", "warpline_e2e"): assert "-m ${{ matrix.marker }}" in workflow assert marker in workflow + assert "warpline_e2e" in workflow + assert "WARDLINE_WARPLINE_BIN" in workflow assert "GITHUB_STEP_SUMMARY" in workflow assert "fail this required oracle run" in workflow diff --git a/tests/unit/test_live_oracle.py b/tests/unit/test_live_oracle.py new file mode 100644 index 00000000..810e1b17 --- /dev/null +++ b/tests/unit/test_live_oracle.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from wardline._live_oracle import ( + LIVE_ORACLE_MARKERS, + LIVE_ORACLE_REQUIRED_ENV, + should_fail_live_oracle_skip, +) + + +def test_warpline_e2e_is_a_live_oracle_marker() -> None: + assert "warpline_e2e" in LIVE_ORACLE_MARKERS + + +def test_rust_e2e_is_not_a_live_oracle_marker() -> None: + # rust_e2e is deliberately excluded from the SKIP->FAIL fail-closed set; + # this guards against an accidental copy-paste addition. + assert "rust_e2e" not in LIVE_ORACLE_MARKERS + + +def test_warpline_skip_fails_only_when_env_var_set(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(LIVE_ORACLE_REQUIRED_ENV, "1") + assert should_fail_live_oracle_skip(["warpline_e2e"], "skipped") is True + + monkeypatch.delenv(LIVE_ORACLE_REQUIRED_ENV, raising=False) + assert should_fail_live_oracle_skip(["warpline_e2e"], "skipped") is False + + +def test_rust_only_skip_never_fails_even_when_required(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(LIVE_ORACLE_REQUIRED_ENV, "1") + assert should_fail_live_oracle_skip(["rust_e2e"], "skipped") is False + + +@pytest.mark.warpline_e2e +def test_warpline_marker_is_registered() -> None: + # If `warpline_e2e` were unregistered, collecting this test would raise a + # PytestUnknownMarkWarning (treated as an error in strict-markers configs); + # the test running at all proves the marker is declared in pyproject.toml. + # Skip cleanly under the default deselection so the body never executes + # (and so the SKIP->FAIL hook is exercised under WARDLINE_LIVE_ORACLE_REQUIRED=1). + pytest.skip("warpline_e2e is a live-oracle marker; no live binary in unit runs") From 2e5167c589eabd0d36889b251dd5200c93f22923 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Thu, 18 Jun 2026 17:59:04 +1000 Subject: [PATCH 4/4] fix: reject --affected with --fail-on (advisory delta cannot gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delta scan analyzes only the scoped (caller-closure-expanded) subset of the tree, so `--affected --fail-on ERROR` could exit 0 while a discovered-but- unanalyzed file held an ERROR — an unearned green. Documenting it as "advisory" (prior commit) did not fix the behavior: the process exit still read green. A trust gate must never emit a green it did not earn, and a gate over a partial scope cannot be earned. So refuse the combination at both surfaces: - CLI: `--affected` + `--fail-on` -> WardlineError -> exit 2. - MCP: `affected` + `fail_on` -> ToolError. Both messages point to `--new-since` (the authoritative change-scoped gate: full analysis, gates only the changed subset) or a full scan for the gate of record. Mirrors the existing `--affected` <-> `--new-since` rejection. The engine (run_scan + gate_decision) is unchanged and still accepts an affected scope with a threshold — the INV-4 guarantee that the gate population is never narrowed (even under trust_suppressions) remains enforced and tested at the engine level (test_run_affected.py, test_affected_invariants.py). The rejection is a surface policy; the MCP trust-suppressions surface test is replaced by a rejection test since the combination is no longer reachable. Help text, CHANGELOG, and cli.md reframed: --affected is advisory, not a gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 +++-- docs/reference/cli.md | 13 ++++-- .../reference/finding-lifecycle-vocabulary.md | 38 +++++++-------- src/wardline/cli/scan.py | 20 ++++++-- src/wardline/mcp/server.py | 18 ++++++-- tests/docs/test_glossary_vocabulary.py | 26 +++++------ tests/unit/cli/test_scan_affected_cli.py | 20 ++++++++ tests/unit/mcp/test_scan_affected_mcp.py | 46 ++++++++----------- 8 files changed, 117 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f93013..64bdcecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 computed; the displayed findings are filtered to the requested entities while the severity gate evaluates the full unsuppressed population **of the analyzed files**, so an attacker-influenceable scope cannot hide an in-analyzed-file - finding from the gate (INV-4). Speed, not truth: delta analyzes only the - scoped (caller-closure-expanded) files, so a delta `--fail-on` pass is - **advisory** — it does not certify files it never analyzed (the stderr line - reports `analyzed N of M discovered file(s)`). The full scan remains the gate - of record. An empty or all-unresolvable scope falls back to a full scan. A `scope` block (mode, + finding from the gate (INV-4). Advisory, not a gate: delta analyzes only the + scoped (caller-closure-expanded) files, so it cannot certify files it never + analyzed — `--affected` is therefore **rejected together with `--fail-on`** + (and over MCP, `affected` with `fail_on`); use `--new-since` for an + authoritative change-scoped gate (full analysis, gates the changed subset) or + a full scan for the gate of record. An empty or all-unresolvable scope falls + back to a full scan. A `scope` block (mode, gate authority, file counts, unresolved/fell-back/stale-SEI accounting, and a boundary caveat) is attached to every output format (agent-summary JSON, SARIF `run.properties`, MCP structured content). Delta emits on both the CLI diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1c806bfc..a2ca0062 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -116,10 +116,13 @@ Options: since this git ref. --affected FILENAME Scan only entities in this warpline reverify-worklist / entity-list (file path, - or '-' for stdin). Speed, not truth: out-of- - scope cross-file flows are not analyzed (see - the scope block). Empty/unresolvable -> full - scan. Mutually exclusive with --new-since. + or '-' for stdin). Advisory delta, not a gate: + out-of-scope cross-file flows are not analyzed + (see the scope block), so it cannot drive + --fail-on (use --new-since to gate changed + code, or a full scan for the gate of record). + Empty/unresolvable -> full scan. Mutually + exclusive with --new-since and --fail-on. --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 @@ -160,7 +163,7 @@ it at a package root, not a single file. | `--loomweave-url TEXT` | Opt-in, fail-soft: persist per-entity taint facts to a Loomweave taint-store endpoint alongside local output. | | `--fail-on-unanalyzed` / `--no-fail-on-unanalyzed` | Exit `1` if any file was discovered but could not be analyzed (e.g. a parse failure), even when no finding trips `--fail-on`. | | `--new-since TEXT` | PR-scoped "new findings only" gate: gate only on findings in files/entities changed since this git ref. Mutually exclusive with `--affected`. | -| `--affected FILENAME` | Scope analysis to the entities named in a `warpline.reverify_worklist.v1` worklist (or a bare entity list; file path, or `-` for stdin) — the inner-loop fast path. The affected set is caller-closure expanded so cross-file taint into a changed callee is still computed. **Speed, not truth:** only the scoped files are analyzed, so a delta `--fail-on` pass is **advisory** and does not certify out-of-scope files — the full scan stays the gate of record (a `scope` block records the mode, gate authority, and `analyzed N of M` accounting). An empty or all-unresolvable scope falls back to a full scan. Mutually exclusive with `--new-since`. | +| `--affected FILENAME` | Scope analysis to the entities named in a `warpline.reverify_worklist.v1` worklist (or a bare entity list; file path, or `-` for stdin) — the inner-loop fast path. The affected set is caller-closure expanded so cross-file taint into a changed callee is still computed. **Advisory, not a gate:** only the scoped files are analyzed, so it cannot certify out-of-scope files and is **rejected together with `--fail-on`** — use `--new-since` for an authoritative change-scoped gate (full analysis, gates the changed subset) or a full scan for the gate of record (a `scope` block records the mode, gate authority, and `analyzed N of M` accounting). An empty or all-unresolvable scope falls back to a full scan. Mutually exclusive with `--new-since` and `--fail-on`. | | `--trust-pack TEXT` (repeatable), `--allow-custom-packs` | Allow loading trust-grammar packs declared in `weft.toml [wardline]` (`--trust-pack`) or from the local project directory (`--allow-custom-packs`). | | `--fix`, `-y`/`--yes` | Apply mechanical autofixes during the scan; `-y` auto-confirms every fix. | | `--strict-defaults` | Ignore repository-supplied custom configuration overrides in `weft.toml`. | diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index 024b77a4..44c219e9 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:547`). +`suppressed` count (`src/wardline/cli/scan.py:561`). ## `active` is the one word for "non-suppressed defect" @@ -72,8 +72,8 @@ consistently, on every surface: | --- | --- | --- | | Enum | `src/wardline/core/finding.py:72` | `SuppressionState.ACTIVE = "active"` | | Summary field | `src/wardline/core/run.py:71`, built at `src/wardline/core/run.py:536` | `ScanSummary.active` | -| CLI summary line | `src/wardline/cli/scan.py:548` | `… {s.active} active` | -| MCP scan response | `src/wardline/mcp/server.py:999` | `summary.active` | +| CLI summary line | `src/wardline/cli/scan.py:562` | `… {s.active} active` | +| MCP scan response | `src/wardline/mcp/server.py:1009` | `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:1007`) -and `unanalyzed` (`src/wardline/mcp/server.py:1011`); the agent-summary block mirrors +The MCP `summary` block exposes `informational` (`src/wardline/mcp/server.py:1017`) +and `unanalyzed` (`src/wardline/mcp/server.py:1021`); 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:1014`), -`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:1018`), +The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:1024`), +`gate.fail_on_unanalyzed`, `gate.verdict` (`src/wardline/mcp/server.py:1028`), `gate.severity_tripped`, `gate.unanalyzed_tripped`, `would_trip_at`, `reason`, -`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:1013` +`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:1023` (`"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:600`). +(`src/wardline/cli/scan.py:614`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists @@ -175,7 +175,7 @@ still legitimately means three different things depending on the surface: | --- | --- | --- | | Filigree store | An **unseen fingerprint** — first time this finding identity is seen for a `(file, scan_source)`. | **Filigree-owned** lifecycle (`src/wardline/core/filigree_emit.py:68-76`) | | `wardline scan --new-since ` | **Delta-scope**: the gate fires only on defects in files/entities changed since a git ref; everything else is re-marked `baselined`. | `src/wardline/core/run.py:484`; 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:547` | +| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:561` | 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:998`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | -| live defect | `N active` (`scan.py:548`) | `active` (`run.py:71,496`) | `active` (`server.py:999`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | -| suppressed (sum) | `N suppressed` (`scan.py:547`) | `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:1000`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | -| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:1001`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | -| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:1002`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | -| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:1007`) | `informational` (`agent_summary.py:151`) | facts/metrics | +| every finding | `N finding(s)` | `total` (`run.py:70`) | `total` (`server.py:1008`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | +| live defect | `N active` (`scan.py:562`) | `active` (`run.py:71,496`) | `active` (`server.py:1009`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | +| suppressed (sum) | `N suppressed` (`scan.py:561`) | `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:1010`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | +| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:1011`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | +| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:1012`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | +| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:1017`) | `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:1011`) | `unanalyzed` (`agent_summary.py:152`) | `WLN-ENGINE-*` facts | -| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:108`; `GateDecision`, `run.py:148`, `verdict` `run.py:157`) | `gate` (`server.py:1013`), `gate.tripped` (`server.py:1014`), `gate.verdict` (`server.py:1018`) | `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:1021`) | `unanalyzed` (`agent_summary.py:152`) | `WLN-ENGINE-*` facts | +| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:108`; `GateDecision`, `run.py:148`, `verdict` `run.py:157`) | `gate` (`server.py:1023`), `gate.tripped` (`server.py:1024`), `gate.verdict` (`server.py:1028`) | `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:456`). diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index faab7fd2..3d85b6af 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -119,9 +119,11 @@ default=None, help=( "Scan only entities in this warpline reverify-worklist / entity-list " - "(file path, or '-' for stdin). Speed, not truth: out-of-scope cross-file " - "flows are not analyzed (see the scope block). Empty/unresolvable -> full scan. " - "Mutually exclusive with --new-since." + "(file path, or '-' for stdin). Advisory delta, not a gate: out-of-scope " + "cross-file flows are not analyzed (see the scope block), so it cannot drive " + "--fail-on (use --new-since to gate changed code, or a full scan for the gate " + "of record). Empty/unresolvable -> full scan. Mutually exclusive with " + "--new-since and --fail-on." ), ) @click.option( @@ -271,6 +273,18 @@ def scan( # too — there is no --since flag yet, so nothing to check beyond --new-since. if new_since is not None: raise WardlineError("--affected and --new-since are mutually exclusive") + # --affected is an ADVISORY delta: it analyzes only the scoped subset, so it can + # never authoritatively PASS a severity gate (an ERROR in an unanalyzed file + # would go unseen — exit 0 would be an unearned green). Refuse to gate it. Use + # --new-since for an authoritative change-scoped gate (full analysis, gates the + # changed subset), or a full scan for the gate of record. + if fail_on is not None: + raise WardlineError( + "--affected (advisory delta) cannot drive --fail-on: a delta scan analyzes " + "only part of the tree, so it cannot certify a green gate. Use --new-since " + " to gate only changed code (full analysis), or run a full scan for the " + "gate of record." + ) # Bound the read at the byte cap BEFORE json.loads: read at most cap+1 chars # from the (possibly stdin) handle and reject an over-cap blob pre-parse. A # huge VALID JSON payload must not force a full unbounded read + parse before diff --git a/src/wardline/mcp/server.py b/src/wardline/mcp/server.py index 203d2f7c..d9b4cf36 100644 --- a/src/wardline/mcp/server.py +++ b/src/wardline/mcp/server.py @@ -832,6 +832,16 @@ def _scan( # --affected and --new-since scope different things via different mechanisms; # composing them is rejected loudly, never silently double-scoped (mirrors the CLI). raise ToolError("affected and new_since are mutually exclusive") + if threshold is not None: + # affected is an ADVISORY delta (analyzes only the scoped subset), so it can never + # authoritatively PASS a severity gate — a green here would be unearned. Refuse to + # gate it (symmetric with the CLI's exit-2 rejection). Use new_since for an + # authoritative change-scoped gate (full analysis), or a full scan for the gate of record. + raise ToolError( + "affected (advisory delta) cannot drive fail_on: a delta scan analyzes only part " + "of the tree, so it cannot certify a green gate. Use new_since to gate only changed " + "code (full analysis), or a full scan for the gate of record." + ) try: if isinstance(affected_arg, str): affected = load_affected_scope(str(_resolve_under_root(root, affected_arg))) @@ -1990,9 +2000,11 @@ def _scan( "affected": { "type": ["object", "array", "string"], "description": "Scan only entities in this warpline reverify-worklist (warpline." - "reverify_worklist.v1) or bare entity list, or a path to one. Speed, not truth: " - "cross-file flows outside the affected set are not analyzed (see scope block). " - "Empty/unresolvable input falls back to a full scan.", + "reverify_worklist.v1) or bare entity list, or a path to one. Advisory delta, not " + "a gate: cross-file flows outside the affected set are not analyzed (see scope " + "block), so it cannot be combined with fail_on (use new_since to gate changed " + "code, or a full scan for the gate of record). Empty/unresolvable input falls " + "back to a full scan.", }, "cache_dir": { "type": "string", diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index 973cdd0d..da5c74b5 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -39,20 +39,20 @@ ("src/wardline/core/run.py", 536, "active=sum"), ("src/wardline/core/run.py", 619, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 547, "suppressed"), - ("src/wardline/cli/scan.py", 548, "{s.active} active"), - ("src/wardline/cli/scan.py", 600, "gate: FAILED"), + ("src/wardline/cli/scan.py", 561, "suppressed"), + ("src/wardline/cli/scan.py", 562, "{s.active} active"), + ("src/wardline/cli/scan.py", 614, "gate: FAILED"), # src/wardline/mcp/server.py — MCP scan summary + gate block - ("src/wardline/mcp/server.py", 998, '"total": result.summary.total'), - ("src/wardline/mcp/server.py", 999, '"active": result.summary.active'), - ("src/wardline/mcp/server.py", 1000, '"baselined": result.summary.baselined'), - ("src/wardline/mcp/server.py", 1001, '"waived": result.summary.waived'), - ("src/wardline/mcp/server.py", 1002, '"judged": result.summary.judged'), - ("src/wardline/mcp/server.py", 1007, '"informational": result.summary.informational'), - ("src/wardline/mcp/server.py", 1011, '"unanalyzed": result.summary.unanalyzed'), - ("src/wardline/mcp/server.py", 1013, '"gate": {'), - ("src/wardline/mcp/server.py", 1014, '"tripped": decision.tripped'), - ("src/wardline/mcp/server.py", 1018, '"verdict": decision.verdict'), + ("src/wardline/mcp/server.py", 1008, '"total": result.summary.total'), + ("src/wardline/mcp/server.py", 1009, '"active": result.summary.active'), + ("src/wardline/mcp/server.py", 1010, '"baselined": result.summary.baselined'), + ("src/wardline/mcp/server.py", 1011, '"waived": result.summary.waived'), + ("src/wardline/mcp/server.py", 1012, '"judged": result.summary.judged'), + ("src/wardline/mcp/server.py", 1017, '"informational": result.summary.informational'), + ("src/wardline/mcp/server.py", 1021, '"unanalyzed": result.summary.unanalyzed'), + ("src/wardline/mcp/server.py", 1023, '"gate": {'), + ("src/wardline/mcp/server.py", 1024, '"tripped": decision.tripped'), + ("src/wardline/mcp/server.py", 1028, '"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"'), diff --git a/tests/unit/cli/test_scan_affected_cli.py b/tests/unit/cli/test_scan_affected_cli.py index 4d61402f..ba3379b3 100644 --- a/tests/unit/cli/test_scan_affected_cli.py +++ b/tests/unit/cli/test_scan_affected_cli.py @@ -153,6 +153,26 @@ def test_affected_with_new_since_exits_2(tmp_path: Path) -> None: assert "mutually exclusive" in result.stderr +def test_affected_with_fail_on_exits_2(tmp_path: Path) -> None: + """``--affected`` (advisory delta) cannot drive ``--fail-on`` (the gate of record). + + A delta scan analyzes only the scoped subset of the tree, so a green gate would be + unearned (an ERROR in an unanalyzed file would never be seen). The combination is + rejected at the surface → exit 2, pointing the user at ``--new-since`` (the + authoritative change-scoped gate) or a full scan.""" + proj = _two_file_proj(tmp_path) + worklist = _worklist_file(tmp_path, "python:function:good.leaky") + + result = CliRunner().invoke( + scan, + [str(proj), "--affected", str(worklist), "--fail-on", "ERROR"], + ) + + assert result.exit_code == 2 + assert "--fail-on" in result.stderr + assert "--new-since" in result.stderr + + def test_affected_sarif_carries_scope_run_properties(tmp_path: Path) -> None: """``--format sarif`` threads the scope block into ``runs[0].properties. wardline_delta_scope`` (the SARIF run-properties channel).""" diff --git a/tests/unit/mcp/test_scan_affected_mcp.py b/tests/unit/mcp/test_scan_affected_mcp.py index 6ccec6be..140b15ee 100644 --- a/tests/unit/mcp/test_scan_affected_mcp.py +++ b/tests/unit/mcp/test_scan_affected_mcp.py @@ -151,36 +151,26 @@ def test_delta_structured_content_validates_with_scope_present(tmp_path: Path) - assert json.loads(result["content"][0]["text"]) == structured -def test_affected_with_trust_suppressions_cannot_forge_a_pass(tmp_path: Path) -> None: - """INV-4 / THREAT-001 over the MCP primary surface, under ``trust_suppressions=True``. - - The MCP ``scan`` handler delegates to the same ``run_scan`` + ``gate_decision`` as the - CLI, so the engine-level fix covers it: a surgical-exclusion worklist (names only - ``svc.alpha``, drops the co-located ``svc.beta`` ERROR from display) with - ``trust_suppressions=True`` and ``fail_on=ERROR`` MUST surface ``verdict=FAILED`` — - identical to the full scan — and CANNOT forge a PASS by narrowing the gate population.""" +def test_affected_with_fail_on_is_rejected(tmp_path: Path) -> None: + """``affected`` (advisory delta) cannot drive ``fail_on`` (the gate of record). + + A delta scan analyzes only the scoped subset, so a green gate would be unearned (an + ERROR in an unanalyzed file would never be seen). The MCP handler refuses the + combination with a ToolError — symmetric with the CLI's exit-2 rejection. The + engine-level INV-4 guarantee (the gate population is never narrowed, even under + ``trust_suppressions``) is covered by the ``run_scan`` / ``gate_decision`` unit tests + (tests/unit/core/test_run_affected.py, test_affected_invariants.py); here the surface + simply refuses to gate an advisory scope at all.""" proj = _co_located_project(tmp_path) - full = _scan({"fail_on": "ERROR", "trust_suppressions": True}, root=proj) - delta = _scan( - { - "fail_on": "ERROR", - "trust_suppressions": True, - "affected": [{"locator": "python:function:svc.alpha"}], - }, - root=proj, - ) - - # Delta narrowed the DISPLAYED defects to alpha only... - shown = {e["qualname"] for e in delta["agent_summary"]["active_defects"]} - assert shown == {"svc.alpha"} - assert delta["scope"]["mode"] == "delta" - # ...but the gate verdict is unforgeable: identical to the full scan's FAILED. - assert full["gate"]["verdict"] == "FAILED" - assert full["gate"]["tripped"] is True - assert delta["gate"]["verdict"] == "FAILED" - assert delta["gate"]["tripped"] is True - assert delta["gate"]["exit_class"] == full["gate"]["exit_class"] == 1 + with pytest.raises(ToolError, match="fail_on"): + _scan( + { + "fail_on": "ERROR", + "affected": [{"locator": "python:function:svc.alpha"}], + }, + root=proj, + ) def test_affected_schema_declared_on_scan_tool(tmp_path: Path) -> None: