diff --git a/.agents/skills/filigree-workflow/SKILL.md b/.agents/skills/filigree-workflow/SKILL.md deleted file mode 100644 index aae6e10f..00000000 --- a/.agents/skills/filigree-workflow/SKILL.md +++ /dev/null @@ -1,325 +0,0 @@ ---- -name: filigree-workflow -description: > - This skill should be used when the user asks to "track work", "create an issue", - "find something to work on", "what should I work on next", "triage bugs", "close - an issue", "check what's blocked", "plan a milestone", "review sprint progress", - "coordinate agents", or when working in a project that uses filigree for issue - tracking. Provides workflow patterns, team coordination protocols, and operational - guidance for the filigree issue tracker. ---- - -# Filigree Workflow - -Filigree is an agent-native issue tracker that stores data locally in `.filigree/`. -This skill provides procedural knowledge for using filigree effectively — as a solo -agent or in a multi-agent swarm. - -## Core Workflow - -Every task follows this lifecycle: - -``` -filigree ready → find available work (no blockers) -filigree show → read requirements and context -filigree transitions → check valid status transitions -filigree start-work --assignee → atomically claim + transition into its working status -[do the work, commit code] -filigree close --reason="summary of what was done" -``` - -Or skip steps 1–3 entirely with `filigree start-next-work --assignee ` to grab the highest-priority **startable** issue. - -> **Ready ≠ startable.** The working status is type-specific (tasks → -> `in_progress`, features → `building`). Bugs start at `triage`, which has no -> single-hop transition into work — they walk `triage → confirmed → fixing`. So -> a triage bug is *ready* but not directly *startable*: `start-work` on one -> returns `INVALID_TRANSITION` naming the next status to move through, and -> `start-next-work` skips it. `ready` items carry a `startable` flag (and a -> `next_action` hint when false). Pass `--advance` to either command to walk the -> soft transitions automatically (`triage → confirmed → fixing`) instead of -> being blocked or skipped. - -Always close with a `--reason` — it becomes audit trail for the next agent. - -## Priority Semantics - -| Priority | Meaning | Action | -|----------|---------|--------| -| P0 | Critical | Drop everything. Production is broken. | -| P1 | High | Do next. Current sprint must-have. | -| P2 | Medium | Default. Normal backlog work. | -| P3 | Low | Nice to have. Do when P1/P2 are clear. | -| P4 | Backlog | Someday. Don't schedule unless promoted. | - -When triaging, use `filigree batch-update --priority=N` for bulk changes. - -## Starting Work - -### Solo or Swarm — Same Tool - -Use `start-work` (or `start-next-work`) for the usual case. Both atomically -claim the issue *and* transition it into its working status in one DB -transaction — optimistic-locking on the assignee, so concurrent callers can't -both think they own the issue. The working status is type-specific (tasks → -`in_progress`, features → `building`, bugs → `fixing`). - -```bash -filigree start-work --assignee # specific issue -filigree start-next-work --assignee # highest-priority startable -filigree start-work --assignee --advance # walk triage → confirmed → fixing -``` - -If another agent already owns the claim, the call fails with `code: CONFLICT` -(CLI exit 4). Safe to retry against a different issue. - -`start-work` on a `triage` bug (or any type with no single-hop working status) -returns `INVALID_TRANSITION` naming the intermediate status to move through -first; `start-next-work` skips such issues. Pass `--advance` to walk the soft -transitions to the nearest working status automatically (missing required -fields become warnings, not blocks; hard edges are never auto-walked). - -### Niche: Claim Without Transitioning - -`claim` and `claim-next` still exist for the rare case where you want to -reserve an issue but not advance its status (e.g. a coordinator earmarking -work for a worker that will pick it up later). Prefer `start-work` for -normal flow. - -```bash -filigree claim --assignee # reserve only, no transition -filigree claim-next --assignee -``` - -## Key Commands - -### Finding Work - -```bash -filigree ready # ready issues sorted by priority -filigree list --status=open # all open issues -filigree search "auth" # full-text search -filigree critical-path # longest dependency chain -``` - -### Creating Issues - -```bash -filigree create "Title" --type=bug --priority=1 -filigree create "Title" --type=task -d "description" --dep -filigree create-plan --file plan.json # milestone/phase/step hierarchy -``` - -### Managing Dependencies - -```bash -filigree add-dep # A depends on B -filigree remove-dep -filigree blocked # show all blocked issues -``` - -### Context and Handoff - -```bash -filigree add-comment "what I found / what's left to do" -filigree get-comments # read previous context -filigree show # full details including deps -``` - -Always add a comment before closing or handing off — the next agent has no memory -of the current conversation. - -## Workflow Patterns - -### Before Starting Work - -1. Run `filigree ready` to see available work -2. Check `filigree critical-path` — unblocking the critical path has highest leverage -3. Pick work that matches the current session's context (e.g., if code is already open) - -### When Finishing Work - -1. Add a comment summarising what was done and any follow-up needed -2. Close with a reason: `filigree close --reason="implemented X, tested Y"` -3. Check if closing this issue unblocks anything: `filigree ready` - -### When Blocked - -1. Add a comment explaining the blocker -2. Create the blocking issue if it doesn't exist -3. Add the dependency: `filigree add-dep ` -4. Move to other available work - -## Guidance Sheets - -For detailed patterns, consult these reference files: - -- **`references/workflow-patterns.md`** — Triage flows, sprint planning, - dependency management, bug lifecycle patterns -- **`references/team-coordination.md`** — Multi-agent swarm protocols, - handoff conventions, claiming strategies, status update patterns -- **`examples/sprint-plan.json`** — Complete create-plan input template - with cross-phase dependencies - -Load these when facing a specific workflow challenge rather than reading upfront. - -## File Records & Scan Findings - -The dashboard API tracks files and scan findings across the project. Use the -schema discovery endpoint to find valid values and available endpoints: - -``` -GET /api/files/_schema -``` - -This returns valid severities, finding statuses, association types, sort fields, -and a full endpoint catalog. When linking issues to files, use file associations: - -| Association Type | Meaning | -|-----------------|---------| -| `bug_in` | Bug reported in this file | -| `task_for` | Task related to this file | -| `scan_finding` | Automated scan finding | -| `mentioned_in` | File referenced in issue | - -## Response Shapes (2.0) - -When parsing `--json` output or MCP responses, expect these unified envelopes: - -- **Batch ops** → `{succeeded: [...], failed: [{id, error, code}, ...], newly_unblocked?: [...]}`. - `failed` is always present (empty list if none); `newly_unblocked` is - present only when non-empty (omitted when the op unblocked nothing). Pass `--detail=full` (CLI) or - `response_detail="full"` (MCP) to get full records back. -- **List ops** → `{items: [...], has_more: bool, next_offset?: int}`. - `next_offset` only appears when there is a next page. -- **Errors** → `{error: str, code: ErrorCode, details?: dict}`. `code` is - one of: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `INVALID_TRANSITION`, - `PERMISSION`, `NOT_INITIALIZED`, `IO`, `INVALID_API_URL`, - `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`, - `LOOMWEAVE_REGISTRY_VERSION_MISMATCH`, `LOOMWEAVE_OUT_OF_SYNC`, - `BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`. - Branch on `code` for retry policy - (`CONFLICT` → exit 4, retryable; everything at exit 1 needs operator - intervention). - -The issue ID is always `issue_id` in 2.0 — in MCP inputs, response payloads, -and CLI JSON. Status is always `status`; "state" was retired as a -user-facing word. - -## Health and Diagnostics - -```bash -filigree doctor # check installation health -filigree stats # project-wide counts -filigree metrics # cycle time, lead time, throughput -filigree events # audit trail for a specific issue -``` - -## Observations — Ambient Note-Taking - -Observations are a scratchpad for things you notice *while doing other work*. They -are not issues — they're lightweight, expiring notes that let you capture a thought -without breaking flow. - -### When to Observe - -Observations are for **incidental** defects — things you notice *in passing* -while working on something else, that fall *outside the scope of your current -task*. The core use case is: "I don't have time to investigate this right now, -but I want to come back to it." - -Examples of good observations: - -- A code smell in a neighbouring file you happened to read -- A missing test for an edge case unrelated to what you're changing -- A potential bug in a module you're not touching -- A TODO or FIXME that looks stale -- A dependency that might be outdated - -**Always include `file_path` and `line`** when the observation is about specific code. -This anchors it for whoever triages it later. - -### When NOT to Observe - -**You fix bugs in your currently defined scope. You do NOT use observations to -finish work prematurely.** - -If you're working on task X and you notice that your implementation of X has a -gap, a missed edge case, an untested branch, a known shortcoming, or a piece of -follow-up that "should really be done too" — that is **task scope, not an -observation**. You own it. Handle it one of these ways instead: - -- **Fix it now** as part of the current task. (Default.) -- **Expand the task** (or split a sub-task) and address it in this work stream. -- **File a proper issue** with a dependency on the current task, so the gap is - visible in the work record before you close. -- **Surface it to the user** if it changes the shape of what you're delivering. - -Filing your own task's deficiencies as observations and closing the task is -**not** completing the task. It is shipping known-broken work and hiding the -debt in a 14-day expiring scratchpad — where it will quietly rot, get -auto-dismissed, and never be addressed. The work record must reflect what is -actually outstanding. - -**The test:** *"Would I have noticed this even if I weren't working on this -task?"* If yes → observation. If no → it's part of the work, fix it. - -**Don't observe things that are clearly issues either.** If you're confident -something is a bug or a needed feature, create an issue directly. Observations -are for "hmm, this might be worth looking at" — the uncertain middle ground. - -### Triage Workflow - -Observations expire after 14 days. Triage them before they rot: - -1. **At session end:** run `observation_list` and quickly scan what's accumulated -2. **For each observation, decide:** - - **Dismiss** — not actionable, already fixed, or not worth tracking. Use - `observation_dismiss` with a brief reason for the audit trail. - - **Promote** — deserves to be tracked as an issue. Use `observation_promote` - which atomically creates an issue and labels it `from-observation`. Choose - the right issue type: - - `type='bug'` — something is broken or produces wrong results - - `type='task'` (default) — cleanup, improvement, or "this works but is shitty" - - `type='feature'` — a missing capability that should exist - - `type='requirement'` — a formal requirement to be reviewed, approved, and verified, when the requirements pack is enabled - - **Leave it** — still uncertain. Let it age. If it survives a few sessions - without being promoted, it's probably a dismiss. - -3. **Batch cleanup:** use the MCP tool `observation_batch_dismiss` when several observations - have gone stale together. - -### Promote vs Dismiss - -| Signal | Action | -|--------|--------| -| You noticed it twice in separate sessions | Promote | -| It's in a hot code path or critical module | Promote | -| It has a clear fix or next step | Promote | -| It was about code that's since been refactored | Dismiss | -| It's a style/taste preference, not a defect | Dismiss | -| You can't articulate what the fix would be | Leave it (or dismiss if > 7 days old) | - -### Tracking the Pipeline - -Promoted observations get the `from-observation` label. To see the pipeline output: - -```bash -filigree list --label=from-observation # All promoted observations -filigree search "from-observation" # Search with context -``` - -## Quick Decision Guide - -| Situation | Action | -|-----------|--------| -| "What should I work on?" | `filigree ready`, pick highest priority | -| "Is this blocked?" | `filigree show `, check blocked_by | -| "Multiple agents need work" | `filigree start-next-work --assignee ` | -| "I found a new bug" | `filigree create "..." --type=bug --priority=1` | -| "This task is bigger than expected" | Create sub-tasks, add deps | -| "I'm done" | Comment, close with reason, check `ready` | -| "Something changed while I worked" | `filigree changes --since ` | -| "I noticed something odd in a file I'm passing through" | `observation_create` with file_path and line — keep working | -| "I noticed a gap in the work I'm currently doing" | Fix it, expand the task, or file a proper issue — **do not** observe it | -| "These observations are piling up" | `observation_list`, then dismiss or promote each | diff --git a/.agents/skills/filigree-workflow/examples/sprint-plan.json b/.agents/skills/filigree-workflow/examples/sprint-plan.json deleted file mode 100644 index af4bb09b..00000000 --- a/.agents/skills/filigree-workflow/examples/sprint-plan.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "milestone": { - "title": "Sprint 3 — Auth & Dashboard", - "priority": 1 - }, - "phases": [ - { - "title": "Backend API", - "steps": [ - {"title": "Auth endpoint (JWT token issuance)", "priority": 1}, - {"title": "User CRUD endpoints", "priority": 2, "deps": [0]}, - {"title": "Rate limiting middleware", "priority": 2, "deps": [0]} - ] - }, - { - "title": "Frontend", - "steps": [ - {"title": "Login page", "priority": 1, "deps": ["0.0"]}, - {"title": "Dashboard layout", "priority": 2, "deps": ["0.1"]} - ] - }, - { - "title": "Integration & QA", - "steps": [ - {"title": "End-to-end auth flow test", "priority": 1, "deps": ["1.0"]}, - {"title": "Load test rate limiter", "priority": 3, "deps": ["0.2"]} - ] - } - ] -} diff --git a/.agents/skills/filigree-workflow/references/team-coordination.md b/.agents/skills/filigree-workflow/references/team-coordination.md deleted file mode 100644 index 8f2102e9..00000000 --- a/.agents/skills/filigree-workflow/references/team-coordination.md +++ /dev/null @@ -1,202 +0,0 @@ -# Team Coordination - -Multi-agent swarm protocols for filigree 2.0. Load this reference when coordinating -work across multiple agents. - -## Atomic Start - -### The Race Condition Problem - -When multiple agents call `filigree update --status=` -simultaneously, both think they own the issue. Filigree 2.0 solves this with -`start-work`, which atomically claims the issue *and* transitions it to its -type-specific working status (tasks → `in_progress`, features → `building`, -bugs → `fixing`) in a single DB transaction with optimistic locking on the -assignee. - -### Start Protocol - -```bash -# Option A: Start a specific issue -filigree start-work --assignee - -# Option B: Start the highest-priority ready issue -filigree start-next-work --assignee -``` - -If another agent already claimed the issue, the call fails with -`code: CONFLICT` (CLI exit 4). No silent overwrite, no half-claimed state — -either both the claim and the transition land, or neither does. - -`start-next-work` accepts the work-scoping filters `claim-next` also -takes (`--type`, `--priority-min`, `--priority-max`) so specialised agents -can scope their work. Because `start-next-work` *transitions* (not just -reserves), it additionally accepts `--target-status` to override the wip -target and `--advance` to walk soft transitions to wip — neither of which -`claim-next` has, since `claim-next` only reserves and never changes status. - -### Niche: Claim Without Transitioning - -If a coordinator wants to reserve an issue without advancing its status -(e.g. earmarking it for a downstream worker), use the atomic primitives: - -```bash -filigree claim --assignee -filigree claim-next --assignee -``` - -These are kept for niche use; `start-work` is the default in 2.0. - -### Releasing Claims - -If an agent cannot finish the work: - -```bash -filigree add-comment "Releasing: blocked on X, needs Y to continue" -filigree release -``` - -Always add a comment before releasing — the next agent needs context. - -## Handoff Protocol - -When passing work between agents, follow this sequence: - -### Outgoing Agent (Finishing) - -1. **Document state**: Add a comment with current progress, decisions made, - and remaining work -2. **Update status**: Leave in its working status (`in_progress` / `building` / - `fixing`) if partially done, or close if complete -3. **Flag blockers**: Create blocker issues and add dependencies if needed - -```bash -filigree add-comment "Completed: API endpoints for auth. -Remaining: frontend login page needs the /api/token response format. -Decision: used JWT not sessions — see commit abc123. -Blocker: need CORS config before frontend can call API." -``` - -### Incoming Agent (Picking Up) - -1. **Read context**: `filigree show ` and `filigree get-comments ` -2. **Check dependencies**: Look at `blocked_by` in the show output -3. **Start**: `filigree start-work --assignee ` -4. **Continue**: Build on the previous agent's work, don't restart - -## Status Update Conventions - -### When to Update Status - -| Event | Action | -|-------|--------| -| Starting work | `start-work --assignee ` (atomic claim + transition) | -| Hit a blocker | Add comment, create blocker issue, add dep | -| Completed the work | `close --reason="..."` | -| Can't finish, releasing | Comment + `release` | -| Found additional work | Create new issues, add deps if needed | - -### Comment Conventions - -Prefix comments with context markers for quick scanning: - -```bash -filigree add-comment "PROGRESS: implemented X and Y, Z remaining" -filigree add-comment "BLOCKED: waiting on for API schema" -filigree add-comment "DECISION: chose approach A because of B" -filigree add-comment "HANDOFF: releasing, next agent should start at Z" -``` - -## Swarm Work Distribution - -### Leader-Follower Pattern - -One agent acts as coordinator: - -1. **Leader** runs `filigree ready` and assigns work (or pre-claims via `claim`) -2. **Followers** use `filigree start-work --assignee ` to take it on -3. **Followers** report back via comments when done -4. **Leader** monitors `filigree stats` and `filigree list --status=in_progress` - -### Self-Organising Pattern - -All agents are peers: - -1. Each agent runs `filigree start-next-work --assignee ` -2. Works on the started issue independently -3. Closes and immediately calls `start-next-work` again -4. No central coordinator needed - -This works best when: -- Issues are well-defined and independent -- Dependencies are properly wired (so `start-next-work` only returns unblocked work) -- Priority ordering reflects actual importance - -Tie-break ordering for `start-next-work` (and `claim-next`): -1. `priority` ascending (0 = critical first) -2. `created_at` ascending (oldest first within a priority tier) -3. `issue_id` ascending (deterministic tie-break) - -### Filtering by Type - -Specialised agents can filter their start calls: - -```bash -# Backend agent -filigree start-next-work --assignee backend-1 --type task - -# Bug-fixing agent -filigree start-next-work --assignee bugfix-1 --type bug --priority-max 1 -``` - -## Conflict Resolution - -### Two Agents Modified the Same Code - -1. The second agent's commit will show merge conflicts -2. Add a comment on the issue explaining the conflict -3. The agent with the simpler change should rebase -4. Use `filigree add-comment` to document the resolution - -### Two Agents Claimed Related Work - -If agents discover their tasks overlap: - -1. One agent adds a dependency between the tasks -2. The agent with the lower-priority task releases their claim -3. The remaining agent completes the prerequisite first - -### Stale Claims - -If an agent disappears without completing work: - -```bash -filigree list --status=in_progress --assignee -filigree release # free the claim -filigree add-comment "Released: previous agent did not complete" -``` - -### CONFLICT Responses - -A `start-work` (or `claim`) call that loses the race returns -`{error: ..., code: "CONFLICT", details: {current_assignee: "..."}}` and -exits with code 4. This is distinct from operational errors (exit 1) so -automated callers can retry against a different issue without escalating. - -## Session Resumption - -When an agent starts a new session and needs to resume context: - -```bash -# What was I working on? -filigree list --status=in_progress --assignee - -# What happened since I last worked? -filigree changes --since - -# What's ready now? -filigree ready -``` - -The `filigree session-context` hook does this automatically at session start, -but these commands are useful for manual context recovery. diff --git a/.agents/skills/filigree-workflow/references/workflow-patterns.md b/.agents/skills/filigree-workflow/references/workflow-patterns.md deleted file mode 100644 index 3758ce59..00000000 --- a/.agents/skills/filigree-workflow/references/workflow-patterns.md +++ /dev/null @@ -1,178 +0,0 @@ -# Workflow Patterns - -Detailed procedural patterns for common filigree workflows. Load this reference -when facing a specific workflow challenge. - -## Triage Pattern - -Triage turns an unsorted pile of issues into a prioritised, actionable backlog. - -### Process - -1. **Gather**: `filigree list --status=open --json` to get all open issues -2. **Categorise by type**: Separate bugs from features from tasks -3. **Set priorities**: - - P0/P1 for anything blocking users or other work - - P2 for standard backlog items - - P3/P4 for nice-to-haves and future ideas -4. **Batch update**: `filigree batch-update --priority=N` -5. **Add dependencies**: Wire up blocking relationships so `ready` reflects reality -6. **Verify**: `filigree ready` should now show a clean, prioritised work queue - -### Anti-patterns - -- Setting everything to P1 — defeats the purpose of priorities -- Skipping dependency wiring — agents pick blocked work and waste time -- Triaging without reading descriptions — priorities should reflect actual impact - -## Sprint Planning Pattern - -Plan a focused set of work for a bounded time period. - -### Using Milestones - -```bash -# Create the plan structure -filigree create-plan --file sprint.json -``` - -See `examples/sprint-plan.json` for a complete template. The key structure: - -```json -{ - "milestone": {"title": "Sprint 3", "priority": 1}, - "phases": [ - { - "title": "Phase name", - "steps": [ - {"title": "Step A", "priority": 1}, - {"title": "Step B", "deps": [0]} - ] - } - ] -} -``` - -Dependencies use indices: integer for same-phase (`0` = first step), cross-phase -uses `"phase.step"` format (`"0.0"` = phase 0, step 0). - -### Tracking Progress - -```bash -filigree plan # tree view with progress bars -filigree stats # overall project health -filigree metrics --days 14 # velocity for this sprint period -``` - -## Dependency Management - -### When to Add Dependencies - -- Task B cannot start until task A's output exists (data dependency) -- Task B would be invalidated by task A's changes (ordering dependency) -- Task B is a sub-task of epic A (parent-child, not a dep — use `--parent`) - -### When NOT to Add Dependencies - -- Tasks are merely related but can proceed independently -- The ordering is preferred but not required -- One task "should" be done first but the other won't break without it - -### Debugging Blocked Work - -```bash -filigree blocked # all blocked issues with blockers -filigree critical-path # longest chain to unblock -filigree show # see what blocks this specific issue -``` - -To unblock: close the blocker, or if the dependency is wrong, remove it: -```bash -filigree remove-dep -``` - -## Bug Lifecycle - -### Standard Flow - -Bugs in the core pack do **not** start in a directly-startable state. They -open at `triage` and walk soft transitions toward work (run -`filigree type-info bug` for the authoritative graph): - -``` -create (triage) → confirmed → fixing → verifying → closed -``` - -`triage` has no single-hop transition into a `wip` status, so a fresh bug is -*ready* but not *startable*. Pass `--advance` to walk the soft transitions to -the nearest working status automatically: - -```bash -filigree start-work --assignee --advance # triage → confirmed → fixing -``` - -Without `--advance`, `start-work` on a `triage` bug returns -`INVALID_TRANSITION` naming the next status (`confirmed`), and -`start-next-work` skips it. - -### Disambiguating the wip target - -If the workflow has multiple `wip`-category targets reachable from the -current status and the resolver needs disambiguation, pass -`--target-status fixing` to `start-work` / `start-next-work`. (`claim` / -`claim-next` only reserve and never transition, so they do not take -`--target-status` or `--advance`.) - -### Bug Report Template - -```bash -filigree create "Short description" \ - --type=bug \ - --priority=1 \ - -d "Steps to reproduce: ... -Expected: ... -Actual: ... -Impact: ..." -``` - -### After Fixing - -Always add a comment with: -1. Root cause explanation -2. What was changed -3. How it was tested - -```bash -filigree add-comment "Root cause: off-by-one in pagination. -Fixed in commit abc123. Tested with 0, 1, and boundary cases." -filigree close --reason="Fixed off-by-one in pagination logic" -``` - -## Event History and Auditing - -### Reviewing What Happened - -```bash -filigree events # full history for one issue -filigree changes --since 2026-01-15T00:00:00 # everything since a timestamp -``` - -### Undoing Mistakes - -```bash -filigree undo # reverts last reversible action (status, priority, etc.) -``` - -Only reversible actions can be undone. Check `filigree events ` first to -see what the last action was. - -## Archiving and Maintenance - -### Cleaning Up Old Issues - -```bash -filigree archive --days 30 # archive issues closed >30 days ago -filigree compact --keep 50 # trim event history for archived issues -``` - -Archive when the active issue count exceeds ~500 and queries start slowing down. diff --git a/.agents/skills/loomweave-workflow/.fingerprint b/.agents/skills/loomweave-workflow/.fingerprint deleted file mode 100644 index b8934d20..00000000 --- a/.agents/skills/loomweave-workflow/.fingerprint +++ /dev/null @@ -1 +0,0 @@ -8af48023ff74748434eec046b718fe586bce8784e51d474c9c58daf8f292326b \ No newline at end of file diff --git a/.agents/skills/loomweave-workflow/SKILL.md b/.agents/skills/loomweave-workflow/SKILL.md deleted file mode 100644 index fd7ab55c..00000000 --- a/.agents/skills/loomweave-workflow/SKILL.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -name: loomweave-workflow -description: > - Use when orienting in an unfamiliar or large codebase and you want to avoid - re-reading or grepping the whole source tree: answering "what calls X", - "where is X defined", "what does X depend on", "what subsystem is X in", or - "find the function/class/module that does Y". Applies whenever a Loomweave - code-archaeology MCP server (loomweave serve / mcp__loomweave__* tools) is - available for the project. ---- - -# Loomweave Workflow - -## Overview - -Loomweave pre-extracts a codebase into a queryable map — entities (functions, -classes, modules, files), the call/reference/import edges between them, and -subsystem clusters — and serves it over MCP. **Ask Loomweave instead of -re-exploring the tree.** One `find_entity` + one `callers_of` answers "what -calls this?" without reading a single file. - -## When to use - -- You're dropped into a codebase and need to locate a symbol or trace its callers/callees. -- You'd otherwise `grep`/read many files to answer a structural question. -- You need a function's neighborhood, execution paths, or which subsystem it belongs to. - -**Not for:** editing code, reading exact implementation bodies (use `summary` or -read the file once you have its path), or codebases with no `.loomweave/` index. - -## Entity IDs — the model - -Every entity has an ID: `{plugin}:{kind}:{qualified_name}` -(e.g. `python:function:pkg.mod.func`, `python:class:pkg.mod.Cls`, -`python:module:pkg.mod`). Subsystems are `core:subsystem:{hash}`. - -**You almost never type IDs.** Get one from `find_entity` / `entity_at`, then -**copy it verbatim** into the next tool. Don't hand-construct or guess IDs. - -### `id` vs `sei` — which one to bind on - -Every entity in a tool response now carries an `sei` field alongside its `id`. -They are not interchangeable: - -- **`id`** is the entity's *locator* — a mutable address. It changes when the - code is renamed or moved, and it's the right thing to feed into the next - Loomweave tool call (above). -- **`sei`** is the entity's *durable, stable identity*. It survives renames and - moves. **When you record a cross-tool binding** — e.g. attaching a Filigree - issue to a Loomweave entity — **bind on the `sei`, not the `id`.** A binding - keyed on the mutable `id` silently breaks the first time the entity moves. - -`sei` is `null` when the index predates SEI support or the entity has no binding -yet; `project_status` and `orientation_pack` report `sei.populated` so you can -tell which case you're in. - -## Tools - -| Tool | Use when | Args | -|------|----------|------| -| `find_entity` | locate an entity by name/text | `{"pattern": ""}` | -| `entity_at` | what's at a file:line | `{"file": "rel/path.py", "line": 42}` | -| `callers_of` | what calls this entity | `{"id": ""}` | -| `neighborhood` | one-hop callers+callees+container+contained+references+imports | `{"id": ""}` | -| `execution_paths_from` | bounded call paths out of an entity | `{"id": "", "max_depth": 5}` | -| `subsystem_members` | modules in a subsystem | `{"id": "core:subsystem:"}` | -| `subsystem_of` | the subsystem an entity belongs to (reverse of `subsystem_members`) | `{"id": ""}` | -| `summary` † | on-demand prose summary of one entity | `{"id": ""}` | -| `summary_preview_cost` | preview a `summary` call's cache status / cost before spending | `{"id": ""}` | -| `issues_for` | Filigree issues attached to an entity | `{"id": ""}` | -| `source_for_entity` | an entity's exact indexed source span + bounded context | `{"id": "", "context_lines": 10}` | -| `call_sites` | the source line(s) behind a calls/references edge | `{"id": "", "role": "caller"}` | -| `orientation_pack` | one deterministic orientation packet for an entity or file:line (entity + context + neighbors + paths + issues + freshness) | `{"file": "rel/path.py", "line": 42}` | -| `index_diff` | index freshness / drift vs. the current working tree | `{}` | -| `analyze_start` † | launch a background re-index, return its `run_id` | `{}` | -| `analyze_status` | poll a started analyze (queued/running/terminal + progress) | `{"run_id": ""}` | -| `analyze_cancel` † | stop a running analyze (group-kills plugin + Pyright) | `{"run_id": ""}` | -| `project_status` | index freshness, counts, LLM + Filigree status | `{}` | - -† **Write-gated.** `summary` (`entity_summary_get`), `analyze_start`, -`analyze_cancel`, `propose_guidance`, and `promote_guidance` are registered only -when `serve.mcp.enable_write_tools: true` is set in `loomweave.yaml` (default -`false`). When the gate is off they do not appear in `tools/list` and a call -returns a tool-disabled error — run `loomweave config check` to see the active -policy. `summary` additionally requires the live LLM provider to be enabled -(`llm_policy.enabled: true` + `allow_live_provider: true`), or it serves cache -only. - -`callers_of` / `neighborhood` / `execution_paths_from` take a `confidence` -tier — one of `"resolved"` (default; only high-confidence edges), -`"ambiguous"`, or `"inferred"`. There is no `"all"` value. When you suspect an -edge is missing (e.g. dynamic dispatch), re-query at `"ambiguous"` and -`"inferred"` and union the results — a default `resolved` count can understate -the true caller set. - -These three tools also return a `scope_excludes` array listing static blind -spots the query did **not** search (e.g. `"attribute-receiver-calls"` like -`ctx.svc.run()`). A non-empty -`scope_excludes` means an empty/short result is **not** a guaranteed true -negative — re-query at `"inferred"` (which searches those categories and returns -`scope_excludes: []`) before concluding "nothing calls this." - -`execution_paths_from` returns a compact shape: `root`, a deduplicated `nodes` -table (id + short_name + location, each node once), and `paths` as arrays of -node-id strings ranked longest-first. Resolve a path id against `nodes`, not by -re-reading each path element. `truncated`/`truncation_reason` report `edge-cap` -(traversal stopped early) or `path-cap` (ranked output trimmed for size). - -## Catalogue tools — inspection · faceted search · shortcuts - -Beyond navigation, Loomweave serves a **stateless catalogue** of read tools. All -of them: take explicit ids/scopes (no cursor/session — there is no `goto`/`back` -state to manage); **paginate** (`limit`/`offset`, with a `page` block reporting -`total`/`returned`/`truncated` — no silent caps); carry `sei` on every entity -they return; and are **honest-empty** — where a signal isn't present they return -an empty result with a `signal` note (`available:false`, the reason), never a -fabricated answer. - -`scope?` (where accepted) takes **either** an entity id (→ that entity's -descendants) **or** a path glob (`"src/auth/**"`); omit it for the whole project. - -**Inspection (read):** - -| Tool | Use when | Args | -|------|----------|------| -| `guidance_for` | guidance sheets applicable to an entity, scope-ranked | `{"id": ""}` | -| `findings_for` | findings anchored to an entity (filter kind/severity/status) | `{"id": "", "filter": {"status": "open"}}` | -| `wardline_for` | the entity's Wardline metadata (verbatim, opaque) | `{"id": ""}` | - -**Faceted search:** - -| Tool | Use when | Args | -|------|----------|------| -| `find_by_tag` | entities carrying a categorisation tag | `{"tag": "", "scope": "src/**"}` | -| `find_by_kind` | entities of a kind (`function`/`class`/`module`/…) | `{"kind": "function"}` | -| `find_by_wardline` | entities by Wardline tier/group (best-effort) | `{"tier": "exact"}` | - -**Exploration-elimination shortcuts** (on-demand graph/index queries — no -analyze-time precompute): - -| Tool | Use when | -|------|----------| -| `find_circular_imports` | import cycles (SCCs over `imports` edges) | -| `find_coupling_hotspots` | entities ranked by fan-in + fan-out | -| `find_entry_points` / `find_http_routes` / `find_data_models` / `find_tests` | entities by categorisation tag | -| `find_deprecations` / `find_todos` | deprecated / TODO-tagged entities | -| `what_tests_this` | test-tagged callers of an entity | -| `high_churn` | entities ranked by git churn | -| `recently_changed` | entities changed since a timestamp | - -`find_circular_imports` and `find_coupling_hotspots` are edge-derived, so they -take a `confidence` tier (default `resolved`, a ceiling) and echo it. The -categorisation shortcuts read plugin-emitted tags. The Python plugin emits -conservative tags for common conventions (`entry-point`, `http-route`, `test`, -`data-model`, `cli-command`, `exported-api`), so root/tag shortcuts and -`find_dead_code` light up on freshly analyzed Python projects where those -signals are present. `find_deprecations` / `find_todos` still return -honest-empty unless a plugin emits those tags. Likewise `high_churn` and -`recently_changed` are honest-empty until churn/change signals are populated (use -`index_diff` for repo-level freshness). - -`search_semantic` is also in the catalogue. It is opt-in under -`semantic_search:`; when enabled, `loomweave analyze` populates the git-ignored -`.loomweave/embeddings.db` sidecar and the query path filters stale vectors by -content hash. - -> Not in this catalogue: `emit_observation` as a general-purpose write surface. - -**Guidance authoring has an operator boundary.** Operators can manage sheets via -`loomweave guidance create/edit/show/list/delete/promote` (plus `export`/`import` -for team sharing). Agents may call `propose_guidance` to create a Filigree -observation, but that proposal is inert until an operator promotes it through -`promote_guidance` or the CLI. Promoted sheets reach you through `guidance_for` -and are composed into `summary` prompts with a real guidance fingerprint. -(`propose_guidance` and `promote_guidance` are write-gated — see the † note above.) - -## Workflow: orient, then navigate - -1. **Anchor.** `find_entity` by name (or `entity_at` for a file:line) to get the - entity and its `id`. For a code location you're about to dig into, prefer - `orientation_pack` — it returns the entity, its context, one-hop neighbors, - execution paths, attached issues, and index freshness in one deterministic - call, instead of hand-composing those queries. -2. **Navigate.** Feed that `id` into `callers_of`, `neighborhood`, - `execution_paths_from`, or `summary`. Chain results' IDs to keep walking. - -## Gotchas (read before hunting for a subsystem) - -- **To find a package's subsystem, search the package NAME with `kind`.** - Subsystems are *named after* their dominant package (e.g. `mypkg`), so - `find_entity {"pattern":"subsystem"}` returns nothing. Search the package name - and pass `{"kind":"subsystem"}` to return only subsystem entities, then call - `subsystem_members`. (`find_entity` accepts an optional `kind` filter — - `"subsystem"`, `"function"`, `"class"`, `"module"`, …; omit it for no filter.) -- **To go from an entity to its subsystem, use `subsystem_of`.** - `neighborhood` does **not** return the entity's subsystem. Call - `subsystem_of {"id": ""}` — it accepts any entity (a function/class - resolves through its containing module) and returns the subsystem plus the - module it resolved through. `subsystem_members` is the forward direction. -- **`find_entity` is paginated** (~20/page, `next_cursor`); narrow the pattern - rather than paging if you can. - -## Launch - -`loomweave serve --path ` where `` contains `.loomweave/loomweave.db` -(built by `loomweave analyze `). In an MCP client the tools appear as -`mcp__loomweave__find_entity`, etc. - -Besides the tools, the server exposes a `loomweave://context` **resource** — live -entity/subsystem/finding counts and index freshness as JSON, a lightweight read -when you only want the numbers (`project_status` is the fuller tool-based view). diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index cacf9ef5..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "hooks": [ - { - "command": "/home/john/.local/bin/filigree ensure-dashboard", - "timeout": 5000, - "type": "command" - } - ], - "matcher": "mcp__filigree__.*" - } - ], - "SessionStart": [ - { - "hooks": [ - { - "command": "/home/john/.local/bin/filigree session-context", - "timeout": 5000, - "type": "command" - }, - { - "command": "/home/john/.local/bin/filigree ensure-dashboard", - "timeout": 5000, - "type": "command" - } - ] - }, - { - "hooks": [ - { - "command": "loomweave hook session-start --path '/home/john/wardline'", - "type": "command" - } - ] - } - ] - } -} diff --git a/.claude/skills/filigree-workflow/SKILL.md b/.claude/skills/filigree-workflow/SKILL.md deleted file mode 100644 index aae6e10f..00000000 --- a/.claude/skills/filigree-workflow/SKILL.md +++ /dev/null @@ -1,325 +0,0 @@ ---- -name: filigree-workflow -description: > - This skill should be used when the user asks to "track work", "create an issue", - "find something to work on", "what should I work on next", "triage bugs", "close - an issue", "check what's blocked", "plan a milestone", "review sprint progress", - "coordinate agents", or when working in a project that uses filigree for issue - tracking. Provides workflow patterns, team coordination protocols, and operational - guidance for the filigree issue tracker. ---- - -# Filigree Workflow - -Filigree is an agent-native issue tracker that stores data locally in `.filigree/`. -This skill provides procedural knowledge for using filigree effectively — as a solo -agent or in a multi-agent swarm. - -## Core Workflow - -Every task follows this lifecycle: - -``` -filigree ready → find available work (no blockers) -filigree show → read requirements and context -filigree transitions → check valid status transitions -filigree start-work --assignee → atomically claim + transition into its working status -[do the work, commit code] -filigree close --reason="summary of what was done" -``` - -Or skip steps 1–3 entirely with `filigree start-next-work --assignee ` to grab the highest-priority **startable** issue. - -> **Ready ≠ startable.** The working status is type-specific (tasks → -> `in_progress`, features → `building`). Bugs start at `triage`, which has no -> single-hop transition into work — they walk `triage → confirmed → fixing`. So -> a triage bug is *ready* but not directly *startable*: `start-work` on one -> returns `INVALID_TRANSITION` naming the next status to move through, and -> `start-next-work` skips it. `ready` items carry a `startable` flag (and a -> `next_action` hint when false). Pass `--advance` to either command to walk the -> soft transitions automatically (`triage → confirmed → fixing`) instead of -> being blocked or skipped. - -Always close with a `--reason` — it becomes audit trail for the next agent. - -## Priority Semantics - -| Priority | Meaning | Action | -|----------|---------|--------| -| P0 | Critical | Drop everything. Production is broken. | -| P1 | High | Do next. Current sprint must-have. | -| P2 | Medium | Default. Normal backlog work. | -| P3 | Low | Nice to have. Do when P1/P2 are clear. | -| P4 | Backlog | Someday. Don't schedule unless promoted. | - -When triaging, use `filigree batch-update --priority=N` for bulk changes. - -## Starting Work - -### Solo or Swarm — Same Tool - -Use `start-work` (or `start-next-work`) for the usual case. Both atomically -claim the issue *and* transition it into its working status in one DB -transaction — optimistic-locking on the assignee, so concurrent callers can't -both think they own the issue. The working status is type-specific (tasks → -`in_progress`, features → `building`, bugs → `fixing`). - -```bash -filigree start-work --assignee # specific issue -filigree start-next-work --assignee # highest-priority startable -filigree start-work --assignee --advance # walk triage → confirmed → fixing -``` - -If another agent already owns the claim, the call fails with `code: CONFLICT` -(CLI exit 4). Safe to retry against a different issue. - -`start-work` on a `triage` bug (or any type with no single-hop working status) -returns `INVALID_TRANSITION` naming the intermediate status to move through -first; `start-next-work` skips such issues. Pass `--advance` to walk the soft -transitions to the nearest working status automatically (missing required -fields become warnings, not blocks; hard edges are never auto-walked). - -### Niche: Claim Without Transitioning - -`claim` and `claim-next` still exist for the rare case where you want to -reserve an issue but not advance its status (e.g. a coordinator earmarking -work for a worker that will pick it up later). Prefer `start-work` for -normal flow. - -```bash -filigree claim --assignee # reserve only, no transition -filigree claim-next --assignee -``` - -## Key Commands - -### Finding Work - -```bash -filigree ready # ready issues sorted by priority -filigree list --status=open # all open issues -filigree search "auth" # full-text search -filigree critical-path # longest dependency chain -``` - -### Creating Issues - -```bash -filigree create "Title" --type=bug --priority=1 -filigree create "Title" --type=task -d "description" --dep -filigree create-plan --file plan.json # milestone/phase/step hierarchy -``` - -### Managing Dependencies - -```bash -filigree add-dep # A depends on B -filigree remove-dep -filigree blocked # show all blocked issues -``` - -### Context and Handoff - -```bash -filigree add-comment "what I found / what's left to do" -filigree get-comments # read previous context -filigree show # full details including deps -``` - -Always add a comment before closing or handing off — the next agent has no memory -of the current conversation. - -## Workflow Patterns - -### Before Starting Work - -1. Run `filigree ready` to see available work -2. Check `filigree critical-path` — unblocking the critical path has highest leverage -3. Pick work that matches the current session's context (e.g., if code is already open) - -### When Finishing Work - -1. Add a comment summarising what was done and any follow-up needed -2. Close with a reason: `filigree close --reason="implemented X, tested Y"` -3. Check if closing this issue unblocks anything: `filigree ready` - -### When Blocked - -1. Add a comment explaining the blocker -2. Create the blocking issue if it doesn't exist -3. Add the dependency: `filigree add-dep ` -4. Move to other available work - -## Guidance Sheets - -For detailed patterns, consult these reference files: - -- **`references/workflow-patterns.md`** — Triage flows, sprint planning, - dependency management, bug lifecycle patterns -- **`references/team-coordination.md`** — Multi-agent swarm protocols, - handoff conventions, claiming strategies, status update patterns -- **`examples/sprint-plan.json`** — Complete create-plan input template - with cross-phase dependencies - -Load these when facing a specific workflow challenge rather than reading upfront. - -## File Records & Scan Findings - -The dashboard API tracks files and scan findings across the project. Use the -schema discovery endpoint to find valid values and available endpoints: - -``` -GET /api/files/_schema -``` - -This returns valid severities, finding statuses, association types, sort fields, -and a full endpoint catalog. When linking issues to files, use file associations: - -| Association Type | Meaning | -|-----------------|---------| -| `bug_in` | Bug reported in this file | -| `task_for` | Task related to this file | -| `scan_finding` | Automated scan finding | -| `mentioned_in` | File referenced in issue | - -## Response Shapes (2.0) - -When parsing `--json` output or MCP responses, expect these unified envelopes: - -- **Batch ops** → `{succeeded: [...], failed: [{id, error, code}, ...], newly_unblocked?: [...]}`. - `failed` is always present (empty list if none); `newly_unblocked` is - present only when non-empty (omitted when the op unblocked nothing). Pass `--detail=full` (CLI) or - `response_detail="full"` (MCP) to get full records back. -- **List ops** → `{items: [...], has_more: bool, next_offset?: int}`. - `next_offset` only appears when there is a next page. -- **Errors** → `{error: str, code: ErrorCode, details?: dict}`. `code` is - one of: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `INVALID_TRANSITION`, - `PERMISSION`, `NOT_INITIALIZED`, `IO`, `INVALID_API_URL`, - `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`, - `LOOMWEAVE_REGISTRY_VERSION_MISMATCH`, `LOOMWEAVE_OUT_OF_SYNC`, - `BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`. - Branch on `code` for retry policy - (`CONFLICT` → exit 4, retryable; everything at exit 1 needs operator - intervention). - -The issue ID is always `issue_id` in 2.0 — in MCP inputs, response payloads, -and CLI JSON. Status is always `status`; "state" was retired as a -user-facing word. - -## Health and Diagnostics - -```bash -filigree doctor # check installation health -filigree stats # project-wide counts -filigree metrics # cycle time, lead time, throughput -filigree events # audit trail for a specific issue -``` - -## Observations — Ambient Note-Taking - -Observations are a scratchpad for things you notice *while doing other work*. They -are not issues — they're lightweight, expiring notes that let you capture a thought -without breaking flow. - -### When to Observe - -Observations are for **incidental** defects — things you notice *in passing* -while working on something else, that fall *outside the scope of your current -task*. The core use case is: "I don't have time to investigate this right now, -but I want to come back to it." - -Examples of good observations: - -- A code smell in a neighbouring file you happened to read -- A missing test for an edge case unrelated to what you're changing -- A potential bug in a module you're not touching -- A TODO or FIXME that looks stale -- A dependency that might be outdated - -**Always include `file_path` and `line`** when the observation is about specific code. -This anchors it for whoever triages it later. - -### When NOT to Observe - -**You fix bugs in your currently defined scope. You do NOT use observations to -finish work prematurely.** - -If you're working on task X and you notice that your implementation of X has a -gap, a missed edge case, an untested branch, a known shortcoming, or a piece of -follow-up that "should really be done too" — that is **task scope, not an -observation**. You own it. Handle it one of these ways instead: - -- **Fix it now** as part of the current task. (Default.) -- **Expand the task** (or split a sub-task) and address it in this work stream. -- **File a proper issue** with a dependency on the current task, so the gap is - visible in the work record before you close. -- **Surface it to the user** if it changes the shape of what you're delivering. - -Filing your own task's deficiencies as observations and closing the task is -**not** completing the task. It is shipping known-broken work and hiding the -debt in a 14-day expiring scratchpad — where it will quietly rot, get -auto-dismissed, and never be addressed. The work record must reflect what is -actually outstanding. - -**The test:** *"Would I have noticed this even if I weren't working on this -task?"* If yes → observation. If no → it's part of the work, fix it. - -**Don't observe things that are clearly issues either.** If you're confident -something is a bug or a needed feature, create an issue directly. Observations -are for "hmm, this might be worth looking at" — the uncertain middle ground. - -### Triage Workflow - -Observations expire after 14 days. Triage them before they rot: - -1. **At session end:** run `observation_list` and quickly scan what's accumulated -2. **For each observation, decide:** - - **Dismiss** — not actionable, already fixed, or not worth tracking. Use - `observation_dismiss` with a brief reason for the audit trail. - - **Promote** — deserves to be tracked as an issue. Use `observation_promote` - which atomically creates an issue and labels it `from-observation`. Choose - the right issue type: - - `type='bug'` — something is broken or produces wrong results - - `type='task'` (default) — cleanup, improvement, or "this works but is shitty" - - `type='feature'` — a missing capability that should exist - - `type='requirement'` — a formal requirement to be reviewed, approved, and verified, when the requirements pack is enabled - - **Leave it** — still uncertain. Let it age. If it survives a few sessions - without being promoted, it's probably a dismiss. - -3. **Batch cleanup:** use the MCP tool `observation_batch_dismiss` when several observations - have gone stale together. - -### Promote vs Dismiss - -| Signal | Action | -|--------|--------| -| You noticed it twice in separate sessions | Promote | -| It's in a hot code path or critical module | Promote | -| It has a clear fix or next step | Promote | -| It was about code that's since been refactored | Dismiss | -| It's a style/taste preference, not a defect | Dismiss | -| You can't articulate what the fix would be | Leave it (or dismiss if > 7 days old) | - -### Tracking the Pipeline - -Promoted observations get the `from-observation` label. To see the pipeline output: - -```bash -filigree list --label=from-observation # All promoted observations -filigree search "from-observation" # Search with context -``` - -## Quick Decision Guide - -| Situation | Action | -|-----------|--------| -| "What should I work on?" | `filigree ready`, pick highest priority | -| "Is this blocked?" | `filigree show `, check blocked_by | -| "Multiple agents need work" | `filigree start-next-work --assignee ` | -| "I found a new bug" | `filigree create "..." --type=bug --priority=1` | -| "This task is bigger than expected" | Create sub-tasks, add deps | -| "I'm done" | Comment, close with reason, check `ready` | -| "Something changed while I worked" | `filigree changes --since ` | -| "I noticed something odd in a file I'm passing through" | `observation_create` with file_path and line — keep working | -| "I noticed a gap in the work I'm currently doing" | Fix it, expand the task, or file a proper issue — **do not** observe it | -| "These observations are piling up" | `observation_list`, then dismiss or promote each | diff --git a/.claude/skills/filigree-workflow/examples/sprint-plan.json b/.claude/skills/filigree-workflow/examples/sprint-plan.json deleted file mode 100644 index af4bb09b..00000000 --- a/.claude/skills/filigree-workflow/examples/sprint-plan.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "milestone": { - "title": "Sprint 3 — Auth & Dashboard", - "priority": 1 - }, - "phases": [ - { - "title": "Backend API", - "steps": [ - {"title": "Auth endpoint (JWT token issuance)", "priority": 1}, - {"title": "User CRUD endpoints", "priority": 2, "deps": [0]}, - {"title": "Rate limiting middleware", "priority": 2, "deps": [0]} - ] - }, - { - "title": "Frontend", - "steps": [ - {"title": "Login page", "priority": 1, "deps": ["0.0"]}, - {"title": "Dashboard layout", "priority": 2, "deps": ["0.1"]} - ] - }, - { - "title": "Integration & QA", - "steps": [ - {"title": "End-to-end auth flow test", "priority": 1, "deps": ["1.0"]}, - {"title": "Load test rate limiter", "priority": 3, "deps": ["0.2"]} - ] - } - ] -} diff --git a/.claude/skills/filigree-workflow/references/team-coordination.md b/.claude/skills/filigree-workflow/references/team-coordination.md deleted file mode 100644 index 8f2102e9..00000000 --- a/.claude/skills/filigree-workflow/references/team-coordination.md +++ /dev/null @@ -1,202 +0,0 @@ -# Team Coordination - -Multi-agent swarm protocols for filigree 2.0. Load this reference when coordinating -work across multiple agents. - -## Atomic Start - -### The Race Condition Problem - -When multiple agents call `filigree update --status=` -simultaneously, both think they own the issue. Filigree 2.0 solves this with -`start-work`, which atomically claims the issue *and* transitions it to its -type-specific working status (tasks → `in_progress`, features → `building`, -bugs → `fixing`) in a single DB transaction with optimistic locking on the -assignee. - -### Start Protocol - -```bash -# Option A: Start a specific issue -filigree start-work --assignee - -# Option B: Start the highest-priority ready issue -filigree start-next-work --assignee -``` - -If another agent already claimed the issue, the call fails with -`code: CONFLICT` (CLI exit 4). No silent overwrite, no half-claimed state — -either both the claim and the transition land, or neither does. - -`start-next-work` accepts the work-scoping filters `claim-next` also -takes (`--type`, `--priority-min`, `--priority-max`) so specialised agents -can scope their work. Because `start-next-work` *transitions* (not just -reserves), it additionally accepts `--target-status` to override the wip -target and `--advance` to walk soft transitions to wip — neither of which -`claim-next` has, since `claim-next` only reserves and never changes status. - -### Niche: Claim Without Transitioning - -If a coordinator wants to reserve an issue without advancing its status -(e.g. earmarking it for a downstream worker), use the atomic primitives: - -```bash -filigree claim --assignee -filigree claim-next --assignee -``` - -These are kept for niche use; `start-work` is the default in 2.0. - -### Releasing Claims - -If an agent cannot finish the work: - -```bash -filigree add-comment "Releasing: blocked on X, needs Y to continue" -filigree release -``` - -Always add a comment before releasing — the next agent needs context. - -## Handoff Protocol - -When passing work between agents, follow this sequence: - -### Outgoing Agent (Finishing) - -1. **Document state**: Add a comment with current progress, decisions made, - and remaining work -2. **Update status**: Leave in its working status (`in_progress` / `building` / - `fixing`) if partially done, or close if complete -3. **Flag blockers**: Create blocker issues and add dependencies if needed - -```bash -filigree add-comment "Completed: API endpoints for auth. -Remaining: frontend login page needs the /api/token response format. -Decision: used JWT not sessions — see commit abc123. -Blocker: need CORS config before frontend can call API." -``` - -### Incoming Agent (Picking Up) - -1. **Read context**: `filigree show ` and `filigree get-comments ` -2. **Check dependencies**: Look at `blocked_by` in the show output -3. **Start**: `filigree start-work --assignee ` -4. **Continue**: Build on the previous agent's work, don't restart - -## Status Update Conventions - -### When to Update Status - -| Event | Action | -|-------|--------| -| Starting work | `start-work --assignee ` (atomic claim + transition) | -| Hit a blocker | Add comment, create blocker issue, add dep | -| Completed the work | `close --reason="..."` | -| Can't finish, releasing | Comment + `release` | -| Found additional work | Create new issues, add deps if needed | - -### Comment Conventions - -Prefix comments with context markers for quick scanning: - -```bash -filigree add-comment "PROGRESS: implemented X and Y, Z remaining" -filigree add-comment "BLOCKED: waiting on for API schema" -filigree add-comment "DECISION: chose approach A because of B" -filigree add-comment "HANDOFF: releasing, next agent should start at Z" -``` - -## Swarm Work Distribution - -### Leader-Follower Pattern - -One agent acts as coordinator: - -1. **Leader** runs `filigree ready` and assigns work (or pre-claims via `claim`) -2. **Followers** use `filigree start-work --assignee ` to take it on -3. **Followers** report back via comments when done -4. **Leader** monitors `filigree stats` and `filigree list --status=in_progress` - -### Self-Organising Pattern - -All agents are peers: - -1. Each agent runs `filigree start-next-work --assignee ` -2. Works on the started issue independently -3. Closes and immediately calls `start-next-work` again -4. No central coordinator needed - -This works best when: -- Issues are well-defined and independent -- Dependencies are properly wired (so `start-next-work` only returns unblocked work) -- Priority ordering reflects actual importance - -Tie-break ordering for `start-next-work` (and `claim-next`): -1. `priority` ascending (0 = critical first) -2. `created_at` ascending (oldest first within a priority tier) -3. `issue_id` ascending (deterministic tie-break) - -### Filtering by Type - -Specialised agents can filter their start calls: - -```bash -# Backend agent -filigree start-next-work --assignee backend-1 --type task - -# Bug-fixing agent -filigree start-next-work --assignee bugfix-1 --type bug --priority-max 1 -``` - -## Conflict Resolution - -### Two Agents Modified the Same Code - -1. The second agent's commit will show merge conflicts -2. Add a comment on the issue explaining the conflict -3. The agent with the simpler change should rebase -4. Use `filigree add-comment` to document the resolution - -### Two Agents Claimed Related Work - -If agents discover their tasks overlap: - -1. One agent adds a dependency between the tasks -2. The agent with the lower-priority task releases their claim -3. The remaining agent completes the prerequisite first - -### Stale Claims - -If an agent disappears without completing work: - -```bash -filigree list --status=in_progress --assignee -filigree release # free the claim -filigree add-comment "Released: previous agent did not complete" -``` - -### CONFLICT Responses - -A `start-work` (or `claim`) call that loses the race returns -`{error: ..., code: "CONFLICT", details: {current_assignee: "..."}}` and -exits with code 4. This is distinct from operational errors (exit 1) so -automated callers can retry against a different issue without escalating. - -## Session Resumption - -When an agent starts a new session and needs to resume context: - -```bash -# What was I working on? -filigree list --status=in_progress --assignee - -# What happened since I last worked? -filigree changes --since - -# What's ready now? -filigree ready -``` - -The `filigree session-context` hook does this automatically at session start, -but these commands are useful for manual context recovery. diff --git a/.claude/skills/filigree-workflow/references/workflow-patterns.md b/.claude/skills/filigree-workflow/references/workflow-patterns.md deleted file mode 100644 index 3758ce59..00000000 --- a/.claude/skills/filigree-workflow/references/workflow-patterns.md +++ /dev/null @@ -1,178 +0,0 @@ -# Workflow Patterns - -Detailed procedural patterns for common filigree workflows. Load this reference -when facing a specific workflow challenge. - -## Triage Pattern - -Triage turns an unsorted pile of issues into a prioritised, actionable backlog. - -### Process - -1. **Gather**: `filigree list --status=open --json` to get all open issues -2. **Categorise by type**: Separate bugs from features from tasks -3. **Set priorities**: - - P0/P1 for anything blocking users or other work - - P2 for standard backlog items - - P3/P4 for nice-to-haves and future ideas -4. **Batch update**: `filigree batch-update --priority=N` -5. **Add dependencies**: Wire up blocking relationships so `ready` reflects reality -6. **Verify**: `filigree ready` should now show a clean, prioritised work queue - -### Anti-patterns - -- Setting everything to P1 — defeats the purpose of priorities -- Skipping dependency wiring — agents pick blocked work and waste time -- Triaging without reading descriptions — priorities should reflect actual impact - -## Sprint Planning Pattern - -Plan a focused set of work for a bounded time period. - -### Using Milestones - -```bash -# Create the plan structure -filigree create-plan --file sprint.json -``` - -See `examples/sprint-plan.json` for a complete template. The key structure: - -```json -{ - "milestone": {"title": "Sprint 3", "priority": 1}, - "phases": [ - { - "title": "Phase name", - "steps": [ - {"title": "Step A", "priority": 1}, - {"title": "Step B", "deps": [0]} - ] - } - ] -} -``` - -Dependencies use indices: integer for same-phase (`0` = first step), cross-phase -uses `"phase.step"` format (`"0.0"` = phase 0, step 0). - -### Tracking Progress - -```bash -filigree plan # tree view with progress bars -filigree stats # overall project health -filigree metrics --days 14 # velocity for this sprint period -``` - -## Dependency Management - -### When to Add Dependencies - -- Task B cannot start until task A's output exists (data dependency) -- Task B would be invalidated by task A's changes (ordering dependency) -- Task B is a sub-task of epic A (parent-child, not a dep — use `--parent`) - -### When NOT to Add Dependencies - -- Tasks are merely related but can proceed independently -- The ordering is preferred but not required -- One task "should" be done first but the other won't break without it - -### Debugging Blocked Work - -```bash -filigree blocked # all blocked issues with blockers -filigree critical-path # longest chain to unblock -filigree show # see what blocks this specific issue -``` - -To unblock: close the blocker, or if the dependency is wrong, remove it: -```bash -filigree remove-dep -``` - -## Bug Lifecycle - -### Standard Flow - -Bugs in the core pack do **not** start in a directly-startable state. They -open at `triage` and walk soft transitions toward work (run -`filigree type-info bug` for the authoritative graph): - -``` -create (triage) → confirmed → fixing → verifying → closed -``` - -`triage` has no single-hop transition into a `wip` status, so a fresh bug is -*ready* but not *startable*. Pass `--advance` to walk the soft transitions to -the nearest working status automatically: - -```bash -filigree start-work --assignee --advance # triage → confirmed → fixing -``` - -Without `--advance`, `start-work` on a `triage` bug returns -`INVALID_TRANSITION` naming the next status (`confirmed`), and -`start-next-work` skips it. - -### Disambiguating the wip target - -If the workflow has multiple `wip`-category targets reachable from the -current status and the resolver needs disambiguation, pass -`--target-status fixing` to `start-work` / `start-next-work`. (`claim` / -`claim-next` only reserve and never transition, so they do not take -`--target-status` or `--advance`.) - -### Bug Report Template - -```bash -filigree create "Short description" \ - --type=bug \ - --priority=1 \ - -d "Steps to reproduce: ... -Expected: ... -Actual: ... -Impact: ..." -``` - -### After Fixing - -Always add a comment with: -1. Root cause explanation -2. What was changed -3. How it was tested - -```bash -filigree add-comment "Root cause: off-by-one in pagination. -Fixed in commit abc123. Tested with 0, 1, and boundary cases." -filigree close --reason="Fixed off-by-one in pagination logic" -``` - -## Event History and Auditing - -### Reviewing What Happened - -```bash -filigree events # full history for one issue -filigree changes --since 2026-01-15T00:00:00 # everything since a timestamp -``` - -### Undoing Mistakes - -```bash -filigree undo # reverts last reversible action (status, priority, etc.) -``` - -Only reversible actions can be undone. Check `filigree events ` first to -see what the last action was. - -## Archiving and Maintenance - -### Cleaning Up Old Issues - -```bash -filigree archive --days 30 # archive issues closed >30 days ago -filigree compact --keep 50 # trim event history for archived issues -``` - -Archive when the active issue count exceeds ~500 and queries start slowing down. diff --git a/.claude/skills/loomweave-workflow/.fingerprint b/.claude/skills/loomweave-workflow/.fingerprint deleted file mode 100644 index b8934d20..00000000 --- a/.claude/skills/loomweave-workflow/.fingerprint +++ /dev/null @@ -1 +0,0 @@ -8af48023ff74748434eec046b718fe586bce8784e51d474c9c58daf8f292326b \ No newline at end of file diff --git a/.claude/skills/loomweave-workflow/SKILL.md b/.claude/skills/loomweave-workflow/SKILL.md deleted file mode 100644 index fd7ab55c..00000000 --- a/.claude/skills/loomweave-workflow/SKILL.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -name: loomweave-workflow -description: > - Use when orienting in an unfamiliar or large codebase and you want to avoid - re-reading or grepping the whole source tree: answering "what calls X", - "where is X defined", "what does X depend on", "what subsystem is X in", or - "find the function/class/module that does Y". Applies whenever a Loomweave - code-archaeology MCP server (loomweave serve / mcp__loomweave__* tools) is - available for the project. ---- - -# Loomweave Workflow - -## Overview - -Loomweave pre-extracts a codebase into a queryable map — entities (functions, -classes, modules, files), the call/reference/import edges between them, and -subsystem clusters — and serves it over MCP. **Ask Loomweave instead of -re-exploring the tree.** One `find_entity` + one `callers_of` answers "what -calls this?" without reading a single file. - -## When to use - -- You're dropped into a codebase and need to locate a symbol or trace its callers/callees. -- You'd otherwise `grep`/read many files to answer a structural question. -- You need a function's neighborhood, execution paths, or which subsystem it belongs to. - -**Not for:** editing code, reading exact implementation bodies (use `summary` or -read the file once you have its path), or codebases with no `.loomweave/` index. - -## Entity IDs — the model - -Every entity has an ID: `{plugin}:{kind}:{qualified_name}` -(e.g. `python:function:pkg.mod.func`, `python:class:pkg.mod.Cls`, -`python:module:pkg.mod`). Subsystems are `core:subsystem:{hash}`. - -**You almost never type IDs.** Get one from `find_entity` / `entity_at`, then -**copy it verbatim** into the next tool. Don't hand-construct or guess IDs. - -### `id` vs `sei` — which one to bind on - -Every entity in a tool response now carries an `sei` field alongside its `id`. -They are not interchangeable: - -- **`id`** is the entity's *locator* — a mutable address. It changes when the - code is renamed or moved, and it's the right thing to feed into the next - Loomweave tool call (above). -- **`sei`** is the entity's *durable, stable identity*. It survives renames and - moves. **When you record a cross-tool binding** — e.g. attaching a Filigree - issue to a Loomweave entity — **bind on the `sei`, not the `id`.** A binding - keyed on the mutable `id` silently breaks the first time the entity moves. - -`sei` is `null` when the index predates SEI support or the entity has no binding -yet; `project_status` and `orientation_pack` report `sei.populated` so you can -tell which case you're in. - -## Tools - -| Tool | Use when | Args | -|------|----------|------| -| `find_entity` | locate an entity by name/text | `{"pattern": ""}` | -| `entity_at` | what's at a file:line | `{"file": "rel/path.py", "line": 42}` | -| `callers_of` | what calls this entity | `{"id": ""}` | -| `neighborhood` | one-hop callers+callees+container+contained+references+imports | `{"id": ""}` | -| `execution_paths_from` | bounded call paths out of an entity | `{"id": "", "max_depth": 5}` | -| `subsystem_members` | modules in a subsystem | `{"id": "core:subsystem:"}` | -| `subsystem_of` | the subsystem an entity belongs to (reverse of `subsystem_members`) | `{"id": ""}` | -| `summary` † | on-demand prose summary of one entity | `{"id": ""}` | -| `summary_preview_cost` | preview a `summary` call's cache status / cost before spending | `{"id": ""}` | -| `issues_for` | Filigree issues attached to an entity | `{"id": ""}` | -| `source_for_entity` | an entity's exact indexed source span + bounded context | `{"id": "", "context_lines": 10}` | -| `call_sites` | the source line(s) behind a calls/references edge | `{"id": "", "role": "caller"}` | -| `orientation_pack` | one deterministic orientation packet for an entity or file:line (entity + context + neighbors + paths + issues + freshness) | `{"file": "rel/path.py", "line": 42}` | -| `index_diff` | index freshness / drift vs. the current working tree | `{}` | -| `analyze_start` † | launch a background re-index, return its `run_id` | `{}` | -| `analyze_status` | poll a started analyze (queued/running/terminal + progress) | `{"run_id": ""}` | -| `analyze_cancel` † | stop a running analyze (group-kills plugin + Pyright) | `{"run_id": ""}` | -| `project_status` | index freshness, counts, LLM + Filigree status | `{}` | - -† **Write-gated.** `summary` (`entity_summary_get`), `analyze_start`, -`analyze_cancel`, `propose_guidance`, and `promote_guidance` are registered only -when `serve.mcp.enable_write_tools: true` is set in `loomweave.yaml` (default -`false`). When the gate is off they do not appear in `tools/list` and a call -returns a tool-disabled error — run `loomweave config check` to see the active -policy. `summary` additionally requires the live LLM provider to be enabled -(`llm_policy.enabled: true` + `allow_live_provider: true`), or it serves cache -only. - -`callers_of` / `neighborhood` / `execution_paths_from` take a `confidence` -tier — one of `"resolved"` (default; only high-confidence edges), -`"ambiguous"`, or `"inferred"`. There is no `"all"` value. When you suspect an -edge is missing (e.g. dynamic dispatch), re-query at `"ambiguous"` and -`"inferred"` and union the results — a default `resolved` count can understate -the true caller set. - -These three tools also return a `scope_excludes` array listing static blind -spots the query did **not** search (e.g. `"attribute-receiver-calls"` like -`ctx.svc.run()`). A non-empty -`scope_excludes` means an empty/short result is **not** a guaranteed true -negative — re-query at `"inferred"` (which searches those categories and returns -`scope_excludes: []`) before concluding "nothing calls this." - -`execution_paths_from` returns a compact shape: `root`, a deduplicated `nodes` -table (id + short_name + location, each node once), and `paths` as arrays of -node-id strings ranked longest-first. Resolve a path id against `nodes`, not by -re-reading each path element. `truncated`/`truncation_reason` report `edge-cap` -(traversal stopped early) or `path-cap` (ranked output trimmed for size). - -## Catalogue tools — inspection · faceted search · shortcuts - -Beyond navigation, Loomweave serves a **stateless catalogue** of read tools. All -of them: take explicit ids/scopes (no cursor/session — there is no `goto`/`back` -state to manage); **paginate** (`limit`/`offset`, with a `page` block reporting -`total`/`returned`/`truncated` — no silent caps); carry `sei` on every entity -they return; and are **honest-empty** — where a signal isn't present they return -an empty result with a `signal` note (`available:false`, the reason), never a -fabricated answer. - -`scope?` (where accepted) takes **either** an entity id (→ that entity's -descendants) **or** a path glob (`"src/auth/**"`); omit it for the whole project. - -**Inspection (read):** - -| Tool | Use when | Args | -|------|----------|------| -| `guidance_for` | guidance sheets applicable to an entity, scope-ranked | `{"id": ""}` | -| `findings_for` | findings anchored to an entity (filter kind/severity/status) | `{"id": "", "filter": {"status": "open"}}` | -| `wardline_for` | the entity's Wardline metadata (verbatim, opaque) | `{"id": ""}` | - -**Faceted search:** - -| Tool | Use when | Args | -|------|----------|------| -| `find_by_tag` | entities carrying a categorisation tag | `{"tag": "", "scope": "src/**"}` | -| `find_by_kind` | entities of a kind (`function`/`class`/`module`/…) | `{"kind": "function"}` | -| `find_by_wardline` | entities by Wardline tier/group (best-effort) | `{"tier": "exact"}` | - -**Exploration-elimination shortcuts** (on-demand graph/index queries — no -analyze-time precompute): - -| Tool | Use when | -|------|----------| -| `find_circular_imports` | import cycles (SCCs over `imports` edges) | -| `find_coupling_hotspots` | entities ranked by fan-in + fan-out | -| `find_entry_points` / `find_http_routes` / `find_data_models` / `find_tests` | entities by categorisation tag | -| `find_deprecations` / `find_todos` | deprecated / TODO-tagged entities | -| `what_tests_this` | test-tagged callers of an entity | -| `high_churn` | entities ranked by git churn | -| `recently_changed` | entities changed since a timestamp | - -`find_circular_imports` and `find_coupling_hotspots` are edge-derived, so they -take a `confidence` tier (default `resolved`, a ceiling) and echo it. The -categorisation shortcuts read plugin-emitted tags. The Python plugin emits -conservative tags for common conventions (`entry-point`, `http-route`, `test`, -`data-model`, `cli-command`, `exported-api`), so root/tag shortcuts and -`find_dead_code` light up on freshly analyzed Python projects where those -signals are present. `find_deprecations` / `find_todos` still return -honest-empty unless a plugin emits those tags. Likewise `high_churn` and -`recently_changed` are honest-empty until churn/change signals are populated (use -`index_diff` for repo-level freshness). - -`search_semantic` is also in the catalogue. It is opt-in under -`semantic_search:`; when enabled, `loomweave analyze` populates the git-ignored -`.loomweave/embeddings.db` sidecar and the query path filters stale vectors by -content hash. - -> Not in this catalogue: `emit_observation` as a general-purpose write surface. - -**Guidance authoring has an operator boundary.** Operators can manage sheets via -`loomweave guidance create/edit/show/list/delete/promote` (plus `export`/`import` -for team sharing). Agents may call `propose_guidance` to create a Filigree -observation, but that proposal is inert until an operator promotes it through -`promote_guidance` or the CLI. Promoted sheets reach you through `guidance_for` -and are composed into `summary` prompts with a real guidance fingerprint. -(`propose_guidance` and `promote_guidance` are write-gated — see the † note above.) - -## Workflow: orient, then navigate - -1. **Anchor.** `find_entity` by name (or `entity_at` for a file:line) to get the - entity and its `id`. For a code location you're about to dig into, prefer - `orientation_pack` — it returns the entity, its context, one-hop neighbors, - execution paths, attached issues, and index freshness in one deterministic - call, instead of hand-composing those queries. -2. **Navigate.** Feed that `id` into `callers_of`, `neighborhood`, - `execution_paths_from`, or `summary`. Chain results' IDs to keep walking. - -## Gotchas (read before hunting for a subsystem) - -- **To find a package's subsystem, search the package NAME with `kind`.** - Subsystems are *named after* their dominant package (e.g. `mypkg`), so - `find_entity {"pattern":"subsystem"}` returns nothing. Search the package name - and pass `{"kind":"subsystem"}` to return only subsystem entities, then call - `subsystem_members`. (`find_entity` accepts an optional `kind` filter — - `"subsystem"`, `"function"`, `"class"`, `"module"`, …; omit it for no filter.) -- **To go from an entity to its subsystem, use `subsystem_of`.** - `neighborhood` does **not** return the entity's subsystem. Call - `subsystem_of {"id": ""}` — it accepts any entity (a function/class - resolves through its containing module) and returns the subsystem plus the - module it resolved through. `subsystem_members` is the forward direction. -- **`find_entity` is paginated** (~20/page, `next_cursor`); narrow the pattern - rather than paging if you can. - -## Launch - -`loomweave serve --path ` where `` contains `.loomweave/loomweave.db` -(built by `loomweave analyze `). In an MCP client the tools appear as -`mcp__loomweave__find_entity`, etc. - -Besides the tools, the server exposes a `loomweave://context` **resource** — live -entity/subsystem/finding counts and index freshness as JSON, a lightweight read -when you only want the numbers (`project_status` is the fuller tool-based view). diff --git a/.filigree.conf b/.filigree.conf deleted file mode 100644 index bfc98f3f..00000000 --- a/.filigree.conf +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "project_name": "wardline", - "prefix": "wardline", - "db": ".filigree/filigree.db" -} diff --git a/.gitattributes b/.gitattributes index bd2169b5..fc180e62 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,7 @@ # and treat the frozen corpus as binary so no autocrlf/normalization rewrites it. tests/golden/identity/fixtures/** text eol=lf tests/golden/identity/corpus/** -text +# Same discipline for the Rust oracle (tests/golden/identity/rust/): LF-pinned +# fixtures (tree-sitter byte offsets are frozen in edge spans), binary-frozen corpus. +tests/golden/identity/rust/fixtures/** text eol=lf +tests/golden/identity/rust/corpus/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f8cb7f2..6aaee323 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,6 @@ concurrency: permissions: contents: read - security-events: write jobs: lint: @@ -23,13 +22,20 @@ jobs: if: github.event_name != 'schedule' steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: "3.13" - run: uv sync --all-extras --group dev - run: uv run ruff check src tests - run: uv run ruff format --check src tests + - name: Layering contracts (import-linter, report-only) + # Report-only: `|| true` keeps this non-blocking. Encodes intended core/ layering + # (taint engine must not import the attestation layer). BROKEN today; the layering + # agent moves the code (wardline-9ec283d168). Surfaces drift without gating CI. + run: uv run lint-imports || true typecheck: name: Types (mypy strict) @@ -37,7 +43,9 @@ jobs: if: github.event_name != 'schedule' steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: "3.13" @@ -53,7 +61,9 @@ jobs: python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -67,16 +77,40 @@ jobs: if: github.event_name != 'schedule' steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: "3.13" - run: uv sync --all-extras --group dev - - name: Scan self -> SARIF - run: uv run wardline scan src/ --format sarif --output results.sarif + - name: Scan self -> SARIF (gated) + # --fail-on ERROR makes the dogfood scan a real gate: src/ is clean today so this + # stays green, but a genuinely-introduced ERROR trust-boundary finding now goes red + # instead of being silently uploaded. (wardline-751a9ae71b) + run: uv run wardline scan src/ --format sarif --output results.sarif --fail-on ERROR + - name: Preserve SARIF for trusted upload + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: self-hosting-sarif + path: results.sarif + + self-hosting-sarif-upload: + name: Upload Self-Hosting SARIF + runs-on: ubuntu-latest + needs: self-hosting-scan + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + actions: read + security-events: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: self-hosting-sarif + path: . - name: Upload SARIF - if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@b0c4fd77f6c559021d78430ec4d0d169ae74a4eb # v3 with: sarif_file: results.sarif category: wardline-self-hosting @@ -87,7 +121,9 @@ jobs: if: github.event_name == 'schedule' steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: "3.13" @@ -98,6 +134,18 @@ jobs: WARDLINE_LIVE_ORACLE_REQUIRED: "1" WARDLINE_OPENROUTER_API_KEY: ${{ secrets.WARDLINE_OPENROUTER_API_KEY }} + # Live wire-oracles (loomweave/legis/filigree) require compiled sibling binaries + # (`loomweave serve`) and live servers (WARDLINE_LEGIS_URL / WARDLINE_FILIGREE_URL + # secrets) that a GitHub Actions PR runner cannot host, so they run ONLY on schedule + # / workflow_dispatch — never on PRs. Two guarantees keep that from masking failure: + # (a) PR runs rely on the HERMETIC pins that run by default (legis scan-artifact + # key-set freeze; loomweave HMAC + blake3 golden vectors) — see + # tests/conformance/test_legis_artifact_contract_freeze.py and + # tests/unit/loomweave/. Those run on every PR (no e2e marker). + # (b) The scheduled/dispatch oracle jobs are FAIL-CLOSED: WARDLINE_LIVE_ORACLE_REQUIRED=1 + # makes the conftest hook (pytest_runtest_makereport) turn a SKIP into a FAILURE, + # so a missing binary/secret/capability fails the required run instead of passing + # green. (wardline-83d08aee75) live-oracles: name: Live ${{ matrix.name }} e2e (weekly/manual) runs-on: ubuntu-latest @@ -114,7 +162,9 @@ jobs: marker: filigree_e2e steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: "3.13" @@ -135,39 +185,3 @@ jobs: echo "- Trigger: \`${{ github.event_name }}\`" echo "- Missing local services, secrets, or capabilities fail this required oracle run." } >> "$GITHUB_STEP_SUMMARY" - - docs-build: - name: Docs (build) - runs-on: ubuntu-latest - needs: test - if: github.event_name != 'schedule' - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - python-version: "3.13" - - run: uv sync --extra docs - - name: Build (strict) - run: uv run mkdocs build --strict - - docs-deploy: - name: Docs (deploy) - runs-on: ubuntu-latest - needs: docs-build - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - python-version: "3.13" - - run: uv sync --extra docs - - name: Deploy - run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 00000000..de743e57 --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,73 @@ +# Build the wardline Astro site (site/) and deploy to GitHub Pages. +# +# The site deploys to wardline.foundryside.dev (CNAME in site/public/CNAME, +# copied verbatim into the build output). It consumes the shared @weft/site-kit, +# which lives in a SUBDIRECTORY of a DIFFERENT repo (foundryside-dev/weft). +# npm cannot install a git subdirectory as a file: dep directly, so the build +# sparse-fetches packages/site-kit into site/vendor/site-kit first +# (scripts/fetch-site-kit.mjs), then `npm install` resolves the file: dep and +# `astro build` compiles it. The fetch also runs as a preinstall hook, but the +# explicit step keeps the order legible. +name: Deploy site + +on: + push: + branches: [main] + paths: + - 'site/**' + - '.github/workflows/deploy-site.yml' + workflow_dispatch: + +# GitHub Pages deploy permissions. +permissions: + contents: read + pages: write + id-token: write + +# One in-flight Pages deploy at a time; don't cancel a running deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: site + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Fetch @weft/site-kit + # Sparse-fetch packages/site-kit from the weft hub into vendor/site-kit + # so the file: dep resolves. (preinstall runs this too; explicit here.) + run: npm run fetch-site-kit + + - name: Install + run: npm install --no-audit --no-fund + + - name: Build + # prebuild hook copies @weft/site-kit/assets into public/_site-kit. + run: npm run build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19faeb4c..11feadeb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,14 +12,27 @@ jobs: name: Build distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true python-version: "3.13" - name: Build run: uv build - - uses: actions/upload-artifact@v4 + - name: Guard tag matches built version + # Refuse to publish if the pushed tag (vX.Y.Z) doesn't match the version + # hatchling stamped into the built distributions — prevents tagging one + # version while _version.py says another. + run: | + TAG="${GITHUB_REF_NAME#v}" + test -f "dist/wardline-${TAG}.tar.gz" || { echo "tag $TAG does not match built dist:"; ls -1 dist; exit 1; } + - name: Record artifact hashes + run: | + cd dist + sha256sum * > SHA256SUMS + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist/ @@ -32,8 +45,18 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Verify artifact hashes + run: | + cd dist + sha256sum --check SHA256SUMS + - name: Drop checksum file before publish + # pypa/gh-action-pypi-publish twine-uploads the ENTIRE packages dir (dist/) + # unconditionally; SHA256SUMS is not a distribution, so leaving it here makes + # twine reject it ("Unknown distribution format") and blocks the release. + # Verification above already consumed it. + run: rm -f dist/SHA256SUMS + - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.gitignore b/.gitignore index e6c7ee05..3b62f765 100644 --- a/.gitignore +++ b/.gitignore @@ -10,15 +10,26 @@ dist/ build/ *.egg-info/ -# MkDocs build output -site/ +# MkDocs build output (redirected away from site/, which now holds the Astro +# member site — see mkdocs.yml site_dir). +mkdocs-build/ + +# Astro member site (site/) — track the source, ignore build artifacts & vendored deps. +site/dist/ +site/.astro/ +site/node_modules/ +site/vendor/site-kit/ +site/public/_site-kit/ # Wardline runtime output findings.jsonl +# Transient scan/scratch output (e.g. Playwright captures from the site work). +output/ # Sibling legacy locations (transition window — .weft// is preferred but # the old dot-dirs may still be present until siblings finish migrating). .loomweave +.clarion # NOTE: do NOT ignore .weft/ — wardline's own .weft/wardline/{baseline,judged,waivers}.yaml # are deliberately committed; weft.toml is operator-authored and tracked. @@ -42,3 +53,24 @@ CLAUDE.md .coverage coverage.json loomweave.yaml + +# Filigree issue tracker +.weft/ +.filigree.conf +.agents/skills/loomweave-workflow/.fingerprint +.agents/skills/loomweave-workflow/SKILL.md +.claude/skills/loomweave-workflow/.fingerprint +.claude/skills/loomweave-workflow/SKILL.md +wardline.yaml +.agents/skills/wardline-gate/SKILL.md +.claude/skills/wardline-gate/SKILL.md + +# git worktrees (isolated feature workspaces) +.worktrees/ + +# CI-only assembled Pages tree (www root + docs/) +publish/ + +# Developer config / local tooling — not part of the solution +.claude/ +.agents/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cbc24475..c571a86c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,12 @@ repos: - id: ruff-check args: [--fix] - id: ruff-format - - - + - repo: local + hooks: + - id: wardline-scan + name: wardline scan + entry: wardline scan + language: system + types: [python] + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 2993d617..aba146a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,395 @@ 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] +## [1.0.1] - 2026-06-17 + +### Added +- **Pollable file-backed scan jobs.** `wardline scan-job start|status|cancel` + (CLI) and the matching `scan_job_start` / `scan_job_status` / `scan_job_cancel` + MCP tools run a long scan in a daemon-free worker subprocess that persists status + JSON under `.weft/wardline/jobs/`, so an agent can start a slow scan and poll its + heartbeat/progress instead of blocking a single MCP call. Status reads refresh + liveness (dead-worker / stale-heartbeat) so a hung job is never ambiguous. The MCP + surface is now 18 tools. +- **Filigree-emit capping and local-only fencing.** `scan` gains + `--filigree-max-findings-per-request` (env `WARDLINE_FILIGREE_MAX_FINDINGS_PER_REQUEST`, + default 1000) to bound per-POST payloads, and `--local-only`/`--no-emit` to disable + sibling emission even when URLs resolve from flags/env/install state. Emission stays + ENRICH-ONLY — a sibling's absence never breaks the core scan or gate. +- **Top-level documentation refreshed against the 2026-05-29..2026-06-12 + release-candidate surface.** The README now describes the current product shape: + Python remains the full taint-analysis frontend; Rust is a command-injection + preview behind `wardline[rust]`; configuration/state live in `weft.toml` and + `.weft/wardline/`; the agent/MCP surface includes doctor, rekey, assurance, + attestation, dossier, and finding-lifecycle tools; and the docs index points to + the Rust, Weft, and assurance guides. +- **MCP structured tool output on all 15 tools** — every tool now declares an + `outputSchema` in `tools/list` and returns `structuredContent` alongside the + (byte-identical) text block on `tools/call`, so MCP-spec clients consume and + validate results without parsing JSON out of a text blob. Tool-execution + errors stay `isError` results and never carry `structuredContent`. The + server now negotiates the MCP protocol revision (`2025-06-18` / + `2025-03-26` / `2024-11-05`; previously hard-pinned to `2024-11-05`). + Per-tool declarations (schemas, annotations, capabilities) moved out of + `_register_tools` to module level next to their handlers + (`wardline-47ff226ebe`, MCP-primary B1; declaration colocation from + `wardline-80e457bc41`). +- **Standard MCP tool `annotations` + `title`** — each tool's `tools/list` + entry now carries a human `title` and the standard `ToolAnnotations` hints + (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) + derived from the existing capability model, so standard MCP clients see the + read-only/destructive signal wardline already computes. The homegrown + `capabilities` key is still emitted for existing consumers; hints describe + the integration-free baseline posture and `ToolPolicy` remains the + enforcement authority (`wardline-e63204176b`, MCP-primary B2). + +### Fixed +- **`doctor` now self-heals a stale `--filigree-url` / `--loomweave-url` pin that + shadows live published-port discovery.** When a sibling rotates ports, a + `.mcp.json` flag frozen to the old port (e.g. a legacy `.filigree/ephemeral.port` + rung outliving the rotation) silently outranks discovery and breaks emit. Plain + `wardline doctor` now reports this as an actionable error — "configured + `--filigree-url` is unreachable, but Filigree is live at … (published port)" — + instead of masking it as a soft "daemon not reachable". `wardline doctor --repair` + DROPS such a stale loopback pin (both siblings) so runtime published-port discovery + owns the always-current port; remote (non-loopback) pins, and loopback pins with no + live daemon, are left untouched. Filigree server mode still repairs a loopback pin + to the live project scope (unscoped writes fail-close there). +- **PY-WL-108 no longer treats a quoted COMMAND word as sanitized.** The + `shlex.quote` concat/f-string guard accepted `shlex.quote(raw) + " --version"` + (and the f-string form) as safe, but quoting sanitizes a shell ARGUMENT, not the + identity of the executable — the attacker still chose the program. The guard now + requires the leading command word to be a string constant, so a quoted-command + + constant-arg shape fires while the blessed constant-command + quoted-arg + remediation (`"echo " + shlex.quote(raw)`) stays clean. +- **Install, attestation, and local automation hardening from the 2026-06-12 + security pass.** `wardline install`/doctor paths now refuse unsafe writes through + symlinked published-port files, refuse to mint attestation keys into tracked + environments, preserve loopback-scope handling without needless churn, and keep + operator-pinned sibling URLs intact. Attestation capture disables repository + `fsmonitor` while reading git state and fails stale reproduction instead of + accepting mismatched evidence. The repo `make clean` target refuses symlinked + cleanup targets, and CI pins the `setup-uv` action. +- **Scan/reporting correctness hardening from the latest review pass.** Grammar + fingerprints now include seed dependencies, return-taint resolution uses the + statement snapshot for the return being analyzed, recursive lambda taint + resolution is guarded, `assure` counts unanalyzed files in coverage, and + `scan-file-findings` honors the gate exit status. Default agent-summary output + is guarded from oversized or misleading responses. +- **Filigree/Loomweave federation safety fixes.** Filigree token resolution now + prioritizes env aliases over dotenv fallback, mark-unseen lifecycle emission is + disabled for unanalyzed scans, strict MCP SEI filters preserve their defaults, + and signed scan artifacts include scan scope so a receiver can distinguish + evidence from different roots. +- **Non-string `issue_id` from Filigree promote is normalized to `null`** — + the promote response is type-narrowed at the wire boundary, so a skewed + 2xx body can no longer leak a non-string `issue_id` into `file_finding` / + `scan_file_findings` payloads (violating their published output schemas). +- **Type-skewed Loomweave store blobs coerce to `null` in `explain_taint`** — + `tier_in`/`tier_out`/callee qualnames read from a store blob now get the + same isinstance guards as the adjacent fingerprint/path fields. +- **`scan_file_findings` federation honesty** — the emit block's + `disabled_reason` now uses the shared 401/403-vs-5xx-vs-transport ladder + instead of a flat `filigree unreachable`, and the no-Filigree-URL branch no + longer misattributes the identity-attach skip to a promote that never ran. + +- **MCP `scan` tool `fail_on_unanalyzed` argument** — the CLI's + `--fail-on-unanalyzed` knob over the MCP surface (default off, same as the + CLI), so gate semantics are fully controllable on the primary surface. The + unanalyzed gate now lives **inside** `gate_decision` (one shared + implementation; the CLI exit-code OR is gone), and the gate block/decision + gained sub-gate attribution: `gate.fail_on_unanalyzed`, + `gate.severity_tripped`, `gate.unanalyzed_tripped` — so an agent can tell a + severity trip from an under-scan trip without parsing `reason`. An + unanalyzed trip's `next_actions` point at fixing what blocked analysis + (suppressions cannot clear it). Side fix: CLI `--fail-on-unanalyzed` alone + no longer prints `gate: NOT_EVALUATED` while exiting 1 — the verdict is now + an honest `FAILED`/`PASSED` whenever either sub-gate ran + (`wardline-7fd0f3a82c`, MCP-primary A4). +- **MCP `rekey` tool** — fingerprint-scheme migration over MCP via the same + `core.rekey` the CLI drives (no second migration path). Probe-by-default + (read-only match/orphan/collision report, writes nothing); `apply` / + `resume` / `rollback` are explicit, mutually exclusive, write-gated args; + `apply` re-emits to a configured Filigree (network-gated) like the CLI's + `--filigree-url` leg. Orphaned verdicts are listed verbatim with the shared + orphan-cause explanation (`wardline-d8cc650ab9`, MCP-primary A3). +- **MCP `doctor` tool** — the CLI `doctor --fix` health envelope over MCP + (install artifacts, MCP registration, config parseability, sibling URLs, + Filigree emit-auth probe; read-only by default, `repair: true` is the + explicit write-gated opt-in) **plus server self-identification**: package + version, pid, project root, start time, and a source-freshness verdict + (`server.fresh` / `server.freshness` check) that detects a long-lived MCP + server serving code older than the on-disk tree — the 2026-06-06 + stale-server incident class (`wardline-4c5165e896`, MCP-primary A2). +- **MCP `scan` tool `lang` argument** (`python` | `rust`, default `python`) — + the same frontend selector as CLI `--lang`, so the Rust command-injection + slice (`RS-WL-108`/`RS-WL-112`) is reachable over the MCP surface; CLI and + MCP Rust scans return identical findings (pinned by a parity test). An + unknown value is rejected loudly naming the valid set + (`wardline-2ee1bbda82`, MCP-primary A1). +- **`wardline explain-taint [PATH]`** — the CLI twin of the MCP + `explain_taint` tool (same core builder, identical JSON: provenance slice, + remediation hint, optional `--chain` walk via a Loomweave store), so a + CLI-only agent no longer dead-ends at step 2 of the scan → explain → fix → + rescan loop (dogfood N-2). +- **`wardline findings` flat filter flags** `--rule-id` / `--severity` / + `--sink` alongside the JSON `--where` blob; a filter given via both is + rejected, never silently overridden (dogfood N-5/X-5). +- **Nested-scan-root guard** (dogfood N-3, `wardline-8669de3576`): scanning a + SUBDIRECTORY of a weft project (an ancestor carries `weft.toml` or + `.weft/wardline/`) now emits a `WLN-ENGINE-NESTED-SCAN-ROOT` FACT and a loud + CLI warning — qualnames are minted relative to the scan root, the project's + baseline/waivers are not loaded, and output lands in the subdirectory. + `scan --help`/`dossier --help` document the scan-root/qualname coupling, and + the dossier's entity-not-found error now names the scan-relative form that + WOULD match plus the project root to rerun against (dogfood N-8). ### Changed +- **Closed-vocabulary query values match case-insensitively** (`severity`, + `suppression`, `kind`) in `wardline findings --where` and the MCP + `scan(where=)`; an out-of-domain value (e.g. filigree's `medium`) now errors + loudly naming the allowed vocabulary instead of silently returning empty + (dogfood N-5). `--fail-on` accepts any casing (canonical uppercase echoed). +- **Six new PREVIEW sink rules, `PY-WL-121`–`PY-WL-126`** (the 2026-06-10 + coverage-gap families; all tier-modulated, argument-slot precise, and + construct-then-method / callable-alias aware): + - `PY-WL-121` — untrusted data reaches an **XML parsing** sink (CWE-611); + per-sink severity calibrated to default parser posture (`lxml.etree.*` at + ERROR — entity-resolving by default; stdlib etree/minidom/sax at WARN — + billion-laughs DoS only since CPython 3.7.1). Only the document slot fires. + - `PY-WL-122` — untrusted data **compiled into a server-side template** + (jinja2 `Template`/`Environment.from_string`, mako `Template`; SSTI, + CWE-1336, ERROR). A tainted *render variable* is the safe idiom and never fires. + - `PY-WL-123` — untrusted **attribute NAME** in `setattr`/`getattr` + (dynamic attribute injection / mass assignment, CWE-915, WARN). Fixed-name + writes of untrusted *values* stay silent. + - `PY-WL-124` — untrusted path reaches a **native-library load** + (`ctypes.CDLL`/`WinDLL`/`OleDLL`/`PyDLL`, `ctypes.cdll.LoadLibrary`; + CWE-114, ERROR). + - `PY-WL-125` — untrusted data as the **log MESSAGE format string** + (`logging.*` module-level and Logger-method forms; CWE-117, INFO — visible + to an explicit `--fail-on INFO` without tripping the default gate). The + lazy `%`-args parameterization (`logging.info('u=%s', raw)`) never fires. + - `PY-WL-126` — untrusted **recipient/message** in `smtplib` + `SMTP`/`SMTP_SSL` `.sendmail` (mail/CRLF header injection, CWE-93, WARN). +- **Per-file isolation: `WLN-ENGINE-FILE-FAILED`.** An unexpected exception + while analyzing one file no longer aborts the whole scan (losing every other + file's findings) — and is not a silent skip either: the scan continues and the + failed file is named by a gate-eligible `WLN-ENGINE-FILE-FAILED` ERROR defect, + counted toward `ScanSummary.unanalyzed` (the Rust frontend's per-file contract, + now on the Python path). +- **New config diagnostic `WLN-CONFIG-SANITISER-SINK-COLLISION`.** A configured + sanitiser that collides with a built-in serialisation sink of the same name + (e.g. declaring `pickle.loads` a sanitiser) can never take effect — the + conservative sink classification wins — yet it previously also suppressed + `WLN-CONFIG-UNUSED-SANITISER`, making the dead declaration a silent no-op. One + FACT per colliding sanitiser now names the collision so the operator learns + their suppression attempt was overridden, not honoured. +- **Sink-family expansions across the existing sink rules** (each fires on more + real-world shapes; shared machinery in `_sink_helpers` — construct-then-method + receiver resolution, callable-alias bindings, `ArgSpec` argument-position + matching, and a fail-closed per-argument taint resolver): + - `PY-WL-118` (SQLi) adds **`executescript`** (sqlite3 cursor AND connection — + multi-statement, no parameter binding at all), a fail-closed **receiver + heuristic** (DB-driver binding/name evidence fires, executor/pool evidence + suppresses, unknown receivers fire), and the **constant `text()` clause + exemption** for the canonical SQLAlchemy parameterized pattern. + - `PY-WL-117` (SSRF) now resolves **constructed client/session instance + methods** (`httpx.Client()`, `requests.Session()`, `aiohttp.ClientSession`, + chained and `with`-bound forms) plus client `base_url=`, and is **URL-slot + precise** — a tainted `timeout=`/`headers=` with a clean URL no longer fires. + - `PY-WL-116` (path traversal) adds the **filesystem-mutation** APIs + (`os.remove`/`rename`/`makedirs`/…, `shutil.rmtree`/`copy*`/`move`), + **`pathlib.Path` methods** on a tainted-constructed `Path`, and **archive + extraction** (`tarfile`/`zipfile` `extract`/`extractall` — Zip Slip), with a + literal **`filter="data"` exemption** (tarfile's safe extraction filter). + - `PY-WL-106` (deserialization) adds the **OO streaming-unpickle API** + (`pickle.Unpickler(stream).load()`), **`shelve.open`** (path slot only), and + a curated **third-party CWE-502 table** (`dill`, `jsonpickle.decode`, + `joblib.load`, `torch.load`, `numpy.load`) with two literal-keyword gates: + `numpy.load` fires only with a literal `allow_pickle=True`; `torch.load` is + suppressed by a literal `weights_only=True`. + - `PY-WL-115` (dynamic import) adds **`runpy.run_path`/`run_module`** and + **`importlib.util.spec_from_file_location`** (import-and-execute class). + - `PY-WL-108` (command execution) adds the **argv-style program-execution + family** (`os.exec*`/`os.spawn*`/`os.posix_spawn*`/`pty.spawn`) and decides + **`shlex.quote` GUARDED semantics**: a quote call as a fragment of a + constant-shaped concatenation/f-string guards the always-shell sinks + (`os.system("echo " + shlex.quote(raw))` is clean); a bare whole-command + quote still fires, and the guard never applies to the argv sinks. + - `PY-WL-107` (eval/exec/compile) adds the `builtins.` and `__builtins__.` + spellings. +- **Rust support.** A new `--lang rust` frontend (behind the + `wardline[rust]` extra: tree-sitter, no base dependency) sweeps `*.rs` and flags + command-injection trust-boundary defects — `RS-WL-108` (program injection, ERROR: + untrusted data chooses the executable of `std::process::Command`) and `RS-WL-112` + (shell injection, WARN: untrusted data reaches a `sh -c` command line). Trust is + declared with a `/// @trusted(level=ASSURED|GUARDED)` doc-comment marker; analysis + is default-clean and reuses the Python engine's taint lattice, severity modulation, + and finding/gate machinery. A `.rs` file that does not fully parse is surfaced as + `WLN-ENGINE-PARSE-ERROR` and never half-analyzed. The Python default path is + byte-identical (identity oracle green). Rule coverage is the command-injection + slice and `weft.toml` severity overrides do not yet apply to Rust findings. See + the [Rust support guide](docs/guides/rust-preview.md). +- **Rust finding identity is graduated — RS-WL-* findings are baseline-eligible.** + The whole-tree SP2 pass reads the real crate name from `Cargo.toml` + (`[package].name`, `-`→`_`, two-branch crate-root registration mirroring the + Loomweave extractor, symlink-safe) and routes cross-file modules, so every + qualname/fingerprint carries its real crate prefix. Identity is frozen by a new + byte-exact golden corpus (`tests/golden/identity/rust/`, the SP2 completion + gate); the pre-SP2 `provisional_identity` plumbing (never-baseline-match / + never-baseline-capture) is removed — baseline, waivers, and judged verdicts now + apply to Rust findings exactly as for Python. (Pre-graduation RS-WL-* + fingerprints change once; they were never baseline-eligible, so no migration.) + Finding identity is keyed to the crate name: adding/removing a `Cargo.toml` or + renaming the crate in the manifest rekeys RS-WL-* fingerprints (re-baseline + after such a change); non-conformance files — outside `src/`, or in a + manifest-less tree — carry a reserved `#out` route segment + (`{crate}.#out.{...}` / `crate.#out.{...}`) so their qualnames can never + collide with a Loomweave-conformant locator. +- **Rust frontend is a full ADR-049 producer (Loomweave Phase 1b).** The entity + surface grows from callables-only to the full ten-kind contract set — + `enum`/`trait`/`type_alias`/`const`/`static`/`macro` leaf entities, the `impl` + block as its own entity with `module → impl → method` containment, per-kind + `@cfg` twin discrimination (stacked `#[cfg]` attributes fold sorted-`&`-joined; + reserved chars escape `%`→`%25`, `:`→`%3A`; comments are token-stream-invisible) + — plus the two anchored edge kinds (`imports`, `implements`; resolved-or-dropped, + never `inferred`), under `plugin_id rust` / `ontology_version 0.4.0`. Conformance + against the Loomweave-hosted corpus graduates from the subset-consumer rule to + the full-set ordered byte-for-byte rule, with eight new oracle rows vendored + upstream this sprint and a corpus **drift alarm** (upstream blob byte-pin in the + default suite + an opt-in `loomweave_drift` live recheck against a sibling + checkout). The path-typed-generic-arg reserved-colon case and const-generic-arg + spacing remain a pending cross-tool ADR-049 decision (drafted amendment-request + letter in `docs/integration/`); the frozen identity corpus deliberately avoids + both shapes. +- **Gate verdict is now explicit (no vacuous green).** `GateDecision` carries a + `verdict` (`NOT_EVALUATED` / `PASSED` / `FAILED`) and a `would_trip_at` (the + highest severity that would trip on the evaluated population, or null). A bare + scan with no `--fail-on` reports `verdict: NOT_EVALUATED` + `would_trip_at` + instead of a clean-looking `tripped: false`, so an agent's first scan is never a + false green. Surfaced on every gate block (MCP `scan`, agent-summary, + `scan_file_findings`) and on the CLI as a `gate: NOT_EVALUATED — …` line + (weft-b937e53854). +- **Bounded default scan output + pagination.** The MCP `scan` tool returns a + bounded page (≤25 finding bodies) by default so a bare call cannot overflow an + agent's context (previously ~123KB on one line). New `full: true` lifts the cap; + new `offset` pages through the rest via `agent_summary.truncation.next_offset`. + `explain: true` inlines provenance into the `agent_summary.active_defects` + entries (capped, announced) (weft-439d09fc8d). +- **Emit destination is now echoed (no silent misroute).** Every Filigree emit + status block (MCP `scan`, agent-summary, CLI) carries a `destination` + (`{url, project, project_pinned}`) naming where findings were sent; the CLI + success line names the destination project. When the URL pins no project, + `project_pinned: false` surfaces that Filigree resolves it server-side — the + silent-misroute shape behind the lacuna→filigree contamination — so a + wrong-project write is visible at the caller (C-10(a)). +- `wardline doctor` now verifies the Filigree federation token: it probes the + configured daemon (URL resolved from `.mcp.json`/env) with the token wardline + would emit and reports a `filigree.auth` check. `--repair` recovers the + daemon-accepted token from local mints and pins it as `WEFT_FEDERATION_TOKEN` + in `.env`, removing a stale `WARDLINE_FILIGREE_TOKEN` line. +- **Zero-ceremony Filigree auth on a same-host install (F1).** The outbound token + resolver now reads Filigree's auto-minted `/.weft/filigree/federation_token` + (the C-9e same-host cross-member read) as a middle rung — after the canonical + `WEFT_FEDERATION_TOKEN` (env then `.env`) and before the deprecated + `WARDLINE_FILIGREE_TOKEN` fallback. A fresh same-host install with no + env/`.env`/`.mcp.json` token now authenticates against the per-project daemon + with no operator config, mirroring filigree's own 3-tier resolution. The mint + file is read-only (wardline never mints it); a missing/unreadable file falls + through cleanly to the legacy/off rungs (emit stays soft-fail) (weft-23574069a1). + +### Changed +- **Loomweave resolve client: ADR-036 plugin hint + hinted fail-soft.** + `LoomweaveClient.resolve()` accepts an optional batch-scoped `plugin` hint + (contract: `docs/integration/2026-06-11-wardline-resolve-plugin-hint-proposal.md`) + and threads it from every call site that knows the producer: attest boundary + enrichment and decorator coverage send `python` (Python-surface by + construction); the Filigree identity-attach path derives the producer from + the finding's rule family (`RS-WL-*` → `rust`), so a Rust finding now mints a + `rust:function:` locator and a `rust:function` entity association instead of + the previously hardcoded `python:function`. A 4xx on a *hinted* request + degrades fail-soft to `unresolved` (an older Loomweave under + `deny_unknown_fields` rejects the new field; identity enrichment must + degrade, not crash); an unhinted 4xx stays loud. User-supplied dossier + entities stay unhinted — the contract never fabricates a hint. +- **Rust qualname dialect: ADR-049 Amendments 6–9 (Loomweave lockstep, one + batched re-vendor covering 4–9).** Closes the four Sprint-4 gold-blocker + collision families at the dialect level, mirroring the authoritative + Loomweave producer byte-for-byte (49-entity corpus + the new `module_mounts` + section, blob `d81fb975…`): + - **Amendments 6+7 — the residual-collision ladder** (`@cfg` → stage S + self-type written path → stage T trait written path → method-`@cfg`): + same-key impl twins split on their *written* self-type path + (`impl T for a::X` / `b::X` → `a%3A%3AX.impl[T]` / `b%3A%3AX.impl[T]`) and + then on their written trait path (`Compat<$0>.impl[a%3A%3AAsyncRead]`). + Twin-gated end to end: only already-colliding ids change; a lone impl and + every un-fired group render byte-identically to before (the frozen RS-WL + identity corpus is unchanged). + - **Amendment 8 — `#[path]` mount overlay** (`wardline.rust.mounts`): literal + `#[path = "…"] mod name;` declarations now route mounted files/subtrees to + their *logical* module path (rustc's relative-path rule; chains, cfg-twin + `@cfg` composition, R5 first-wins determinism; macro-wrapped and + `cfg_attr`-delivered mounts stay invisible by dialect rule). Mounted files + re-key from their filesystem route — `rust_module_route` itself is + unchanged and remains the no-mount default. + - **Amendment 9 — `const _` skip-emission:** an unnamed `const _` is no + longer an entity (unconditional — nothing can ever name it; findings inside + one attribute to the enclosing module). +- **BREAKING (gate): parse failures are now gate-eligible.** + `WLN-ENGINE-PARSE-ERROR` (a discovered file that could not be read/parsed) is + promoted from a NONE FACT to an **ERROR DEFECT**: its sinks were never + analyzed, so a default `--fail-on ERROR` reading green over it was a fail-open + (e.g. a latin-1 coding cookie CPython runs but the UTF-8 reader rejects hid + live code from the scan). Baseline/waiver still annotate it but cannot clear + the secure gate; `--trust-suppressions` can (an explicit operator trust + decision). A tree with unparseable files now trips `--fail-on ERROR` until the + files are fixed or the suppression is explicitly trusted. (Python frontend; + the Rust frontend's parse-error surfacing is unchanged — still a FACT.) +- **BREAKING (severity): `PY-WL-108` and `PY-WL-112` base severity WARN → ERROR.** + Tainted command/program execution (always-shell APIs, argv exec/spawn, + literal-`shell=True` subprocess) is the same blast-radius exploit class as + SQLi (CWE-78 ≅ CWE-89), so both calibrate with `PY-WL-118`'s ERROR. Findings + from these rules now trip a default `--fail-on ERROR` gate in fully-trusted + functions; override per project via `rules.severity` if needed. +- **BREAKING (fingerprints): the boundary-integrity family now partitions + four ways — bare `return p` boundaries re-key from `PY-WL-102` to `PY-WL-119`.** + At most one of {102, 111, 113, 119} fires per boundary: 119 wins the bare + degenerate shape (`return `), 102 keeps every other no-rejection shape, + 111 keeps assert-only (including an assert inside a substituting `try` — + documented precedence over 113), 113 fires only when a real rejection exists + and a fail-open handler can swallow it. Because `rule_id` is part of the + fingerprint, **baselined/waived/judged `PY-WL-102` entries for bare `return p` + boundaries go stale** (the finding now carries `PY-WL-119`) — re-baseline / + re-waive once after upgrade. The same boundary is no longer double-counted at + ERROR in the gate population. +- **`PY-WL-102`/`PY-WL-111` recognise more genuine rejection shapes (fewer + FPs on real validators):** a ONE-HOP same-module call to a raising helper + (a factored-out validator, raising staticmethod, or delegation to another + raising boundary — the helper must have a production-surviving rejection; + assert-only helpers never count), curated **raising conversions** + (`return int(p)` / `float`/`complex`/`Decimal`/`Fraction`/`UUID` over a + non-constant argument, and non-constant subscript lookups `Color[p]` / + `ALLOWED[p]` — validate-by-construction), and **conditional-expression falsy + returns** (`return m.group(0) if m else None`). +- **BREAKING (wire): per-finding `suppressed` key renamed to `suppression_state`.** + The JSONL stream, the Filigree `metadata.wardline.*` subtree, and the signed + **legis scan artifact** now emit `suppression_state` (values unchanged: + `active`/`baselined`/`waived`/`judged`). This eliminates the "active" overload + (the per-finding *state* vs the summary `active` *count*). Because the legis + artifact is signed, the canonical bytes — and the golden signature — change; + **legis must adopt `suppression_state` on its ingest/co-sign side** before the + signed hop verifies again (the opt-in `legis_e2e` oracle stays red until then) + (weft-f506e5f845). +- The MCP `scan` response no longer carries a top-level `findings` array; finding + bodies live solely in `agent_summary` (the single canonical carrier). The + `truncation` block moved under `agent_summary` (weft-439d09fc8d). +- The MCP `summary` block adds an `informational` bucket so + `active + baselined + waived + judged + informational == total` + (weft-f506e5f845). +- The Filigree emit `disabled_reason` now distinguishes *no token sent* from + *token sent but rejected (401)* and names the URL it tried, instead of a flat + "set WEFT_FEDERATION_TOKEN" that implied absence (weft-23574069a1 / C-7). - **BREAKING: Weft config/store consolidation.** Operator config moved from `wardline.yaml` (YAML) to the `[wardline]` table of a shared, operator-authored `weft.toml` (TOML), read via stdlib `tomllib` (zero new dependency). An @@ -43,8 +429,162 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is honored as a **deprecated fallback** (read after the new name), so existing deployments keep working with no change; migrate at leisure. Only the token *value* must match what the Filigree operator configured. +- **`wardline doctor --repair`/`install` now preserves operator-pinned + `--filigree-url`/`--loomweave-url` args** in the `.mcp.json` wardline server entry + (in the order the operator wrote them) instead of normalizing them away. Previously + every repair rewrote the entry to the bare canonical args, silently stripping a + fixed-port sibling emit/discovery target the published-port rung cannot reconstruct. ### Fixed +- **Raw-receiver taint laundering via name-collision shadowing (wardline-f6a29ce23a).** + In the L2 per-variable resolver (`_resolve_call`), the early `taint_map` + short-circuits returned a MODULE-LEVEL symbol's clean return-taint without checking + the call's receiver, so a local/parameter that **shadowed** a module/import name + (`import ast; ast = read_raw(p); ast.literal_eval(p)`) laundered raw data through the + shadowed-module's clean entry — an end-to-end soundness false negative (a tainted + value reached an `os.system`/`PY-WL-108` sink unflagged). Each early short-circuit + (the imported-fqn path, the direct dotted lookup, the bare-name call path, the + context-encoder path, and the attribute-READ path in `_resolve_expr`) now defers + when the call/read's chain-ROOT name is a tracked local/parameter currently in the + RAW_ZONE — covering chained receivers (`a.b.method()`) and bare-name shadows + (`foo()`) as well as the one-level case. + `self`/`cls` roots are excluded so analyzer-injected cross-method summaries are + preserved, and the var-types (`Type.method`) path is intentionally left ungated (a + legitimately typed object carries a raw-ish value taint from an unmodeled + constructor — gating it would false-positive `h = Helper(); h.get_assured()`). + Genuine module sanitisers are untouched (the root is never tracked in `var_taints`); + the Python default path stays identity-oracle byte-identical, with two discriminating + corpus fixtures added. +- **Finding fingerprint is now invariant to taint-resolution drift (weft-4a9d0f863c).** + The `fingerprint` — the cross-tool JOIN KEY into the baseline/waiver/judged stores + and the Filigree tracker — folded engine-RESOLUTION outputs (resolved `TaintState` + tiers and `via_callee`) into its `taint_path` component, so it moved across builds + for byte-identical source as the rule suite was extended (a baselined finding + escaped its baseline, tripped the gate, and minted a federation-wide duplicate). + Every rule's `taint_path` now carries only a SOURCE-derived discriminator: rules + emitting ≤1 finding per `(rule_id, path, line_start, qualname)` pass `taint_path=None`; + call-site-anchored rules (PY-WL-105/106/108/115/116/117/118/120) discriminate by the + sink/callee spelling plus the call's full lexical span (`col_offset:end_col_offset`, + collision-free even for chained calls). PY-WL-114 is unchanged (its `name:token` + path is already source-derived and load-bearing). The invariant is documented at + `compute_finding_fingerprint` and enforced by a new identity-corpus collision gate + (distinct-fingerprint-count == active-finding-count, exercised by a `sinks` fixture + with same-line and chained sinks) plus a PY-WL-101 resolved-tier-swap invariance test. + - **MIGRATION (one-time).** This is an intentional, reviewed fingerprint rekey + (identity corpus `corpus_version` 1→2). It stabilises a key that was *already* + drifting on every build, so it converts ongoing unbounded orphaning into a single + bounded event. All four fingerprint-keyed stores must be refreshed once after + upgrade: regenerate `baseline.yaml` (`wardline baseline update`) and re-run the + LLM judge to repopulate `judged.yaml`; previously-waived findings will resurface + (loudly — re-waive intentionally); and Filigree finding↔issue associations keyed + on the old fingerprint orphan until re-associated. Perform across the federation in + lockstep. After this, the key is stable across engine-precision changes. +- **Three PY-WL-118 false-negatives from the `scrub-2026-06-08` regression set + (`scrub-regression`).** Surfaced by adversarial verification of the six P1 scrub + fixes (`24b0a3e`); empirically reproduced and regression-tested. + - **Tainted SQL via `**kwargs` dict-unpacking now fires** (`wardline-8c31463f9f`). The + engine collapses a `**` unpack to a single taint under the `None` arg-key, which the + narrowed `_SQL_STRING_KEYS` gate ignored. `_sql_string_taint` now treats the `None` key + as the SQL-string slot when a `**` unpack could supply the `operation` — by inspecting the + literal-dict keys (`**{"operation": …}` fires, `**{"parameters": …}` stays silent, + preserving `wardline-e0e44852e7`) and failing closed on any opaque/non-static `**`. The + snapshot's per-`**`-key taint collapse means a literal dict mixing a clean operation with a + tainted parameter over-approximates (fires) — a deliberate fail-closed choice, never an FN. + - **Nested defs now honor their OWN trust decorator** (`wardline-bb8396f96e`). The + unconditional `..` strip made a nested `@trusted` def inherit its parent's + (suppressed) tier. The new shared `_sink_helpers.enclosing_declared_tier` walks outward + through enclosing scopes and uses the nearest scope carrying an explicit declaration + (`declared_qualnames`), so a nested def's own decorator governs while a genuinely + undeclared nested def still inherits its enclosing trusted tier (`wardline-9b88ec5419`). + Applied family-wide (PY-WL-106/107/108/115/116/117 share the base). + - **PY-WL-118 now inspects sink calls inside lambda bodies** (`wardline-b8a94cf0ac`). The + rule walked `own_nodes`, which treats `ast.Lambda` as a scope boundary, so a tainted + `execute()` in a lambda escaped — while its sink-family siblings descend into lambdas. It + now uses the shared lambda-descending `sink_method_calls`, attributing the finding to the + enclosing entity as the siblings do. +- **Six P1 correctness defects from the pre-1.0 rule scrub (`scrub-2026-06-08`).** All + empirically reproduced and regression-tested; a new shared fail-closed argument resolver + (`_sink_helpers.resolved_arg_taints`) gives the sink rules per-argument taint with a single + fail-closed implementation (`worst_arg_taint` is now a thin selector over it). + - **PY-WL-118 no longer false-fires on parameterized queries** (`wardline-e0e44852e7`). SQLi + is a property of the SQL-string argument only; untrusted data passed as a *bound parameter* + (the OWASP-canonical mitigation) cannot alter query structure, so the rule now gates on the + operation-string position and ignores the parameter position. A tainted SQL string still + fires (fail-closed on a splatted operation). + - **PY-WL-118 nested-def evasion closed** (`wardline-9b88ec5419`). The rule now applies the + family-wide `..` tier-strip, so a tainted `execute()` wrapped in a nested function + inherits its enclosing trusted tier and fires — matching siblings 108/115/116/117. + - **PY-WL-105 co-argument masking closed** (`wardline-836dcef5b4`). The rule fires when *any* + resolved argument is provably untrusted, not the single `worst_arg_taint`: the + `_PROVABLY_UNTRUSTED` predicate is not upward-closed (a hole at `UNKNOWN_RAW`), so a max-rank + collapse let an `UNKNOWN_RAW` co-argument mask a provably-untrusted one. + - **PY-WL-114 now gates on the resolved builtin FQN** (`wardline-0267c31cd8`), not the trailing + identifier — fixing both the alias-blind false negative (`@t(level='ASURED')` where `t` + aliases the builtin) and the foreign-same-name false positive (a non-wardline decorator + merely spelled `trusted`). Mirrors PY-WL-110's resolver + builtin-prefix check. + - **Engine fail-open: stale `var_types` no longer launders a raw receiver** (`wardline-5ba7ce0f98`). + A name re-bound to an imprecisely-typed RHS (subscript / BinOp / f-string / …) now invalidates + its recorded type, so a method call on a now-raw value can no longer resolve a clean `@trusted` + summary past the RAW_ZONE receiver guard. Conservative direction (more FPs at worst, never an FN). + - **Engine fail-open: summary cache key now binds the effective-scan-policy hash** + (`wardline-9d6a81b9e7`). `compute_cache_key` folds in `attest.ruleset_hash(config)` — the same + single policy identity `attest` signs — so seed-shaping config (`untrusted_sources`, + `sanitisers`, `provenance_clash`) can no longer let a warm/persisted cache serve a stale-CLEAN + summary that suppresses a real defect. Warm runs are byte-identical to cold runs across policy. +- **Six P2 correctness defects from the pre-1.0 rule scrub (`scrub-2026-06-08`).** All + empirically reproduced with a control (the safe near-identical shape that must stay silent), + regression-tested, and hardened across three adversarial review panels. Five make the engine + fire MORE (4 false-negatives + 1 soundness + 1 coverage); one removes a false positive. + - **PY-WL-109 with/`while True` fall-through** (`wardline-786a4ec647`). `_can_fall_through` now + models `with`/`async with` (terminal iff body) and a constant `while True` with no break, so + the None-leak rule no longer false-fires on returns wrapped in those constructs. + - **PY-WL-113 assign-then-fall-through substitution** (`wardline-c314a7140b`). The fail-open + rule now also matches an in-handler ASSIGNMENT of a value to a name the function returns by + fall-through (not just an in-handler `return`). Gated on the handler having no *unconditional* + top-level return (a conditional nested return does not stop fall-through) and excluding + idempotent self-assignment, so a fail-CLOSED `result = p; return None` stays silent. + - **PY-WL-110 nested-path false positive closed** (`wardline-09c09f14df`). Marker recognition + now keys on the engine's exact-export predicate (`_is_builtin_decorator_fqn`), so a nested + attribute path the engine never seeds is no longer counted as a contradictory-marker clash. + - **Engine soundness: loop fixpoint iterates to convergence** (`wardline-e04db6e656`). The + `range(8)` cap in `_handle_for`/`_handle_while` was lattice *height*, not propagation *depth*; + a loop-carried rebind chain longer than 8 links left the head under-tainted (a fail-open). The + walk now iterates to a genuine fixpoint with a `num_vars × lattice_height` backstop. + - **Engine: branch-conditional dispatch resolved flow-sensitively** (`wardline-499c22bbdd`). A + receiver assigned a project class in more than one branch arm is resolved to the SET of + candidate callees via a flow-sensitive reaching-definitions pass (branch arms unioned at + joins, straight-line reassignment replaces, loop body to a fixpoint, walrus replaces), so + PY-WL-105 and PY-WL-120 fire on any anchored trusted-sink candidate regardless of AST order + and emit one finding per call site. Replaces the AST-order-dependent flat last-write-wins. + - **PY-WL-120 DB-cursor fetches now fire** (`wardline-e7c7cda31a`). `cursor.fetch{one,all,many}()` + is seeded `EXTERNAL_RAW` (a curated method set), closing a dead matcher branch so DB reads + trip the stored-taint rule exactly as `open()`/`read_text()` already do. + - **Engine: container mutators contaminate the receiver** (`wardline-67c7498931`). + `.append`/`.add`/`.extend`/`.update`/`.insert` with a tainted argument now write that taint + back onto the receiver variable (matching the container-literal `box = [raw]`); `list.insert`'s + index argument is excluded (position metadata, not stored content). +- **`agent_summary` display arrays now fully partition `total_findings` (W3 residual).** The + pagination union was `active_defects + suppressed_findings + engine_facts`, which excluded + non-defect findings that are not engine facts (metrics, classifications, suggestions, and + non-engine facts). Those findings were counted in `summary.total_findings` / `informational` + but occupied no display slot — an agent paginating `offset → next_offset` believed it had + covered all findings while never seeing metrics or classifications. A new `informational` + display array (parallel to `summary.informational`) captures this population. The union is + now `active_defects + suppressed_findings + engine_facts + informational`, and + `len(union) == truncation.findings_total == summary.total_findings` holds within the + `display_findings` contract. The `engine_facts` display array is unchanged (engine facts + only; non-engine facts go to `informational`). Closes the W3 pagination residual left open + by `weft-f506e5f845`, which added the `informational` summary *count* but not the + display-array complement. +- **Lambda taint: a sink-lambda bound in a non-last branch arm is no longer lost.** + The Level-2 taint engine tracked lambda bindings one-per-name, so at a branch merge + only the last arm's binding survived; a name rebound to a sink-lambda in a non-last + `if`/`try`/`match` arm was overwritten by a later arm's benign lambda, and a tainted + call after the branch resolved the wrong body — a silent false negative. Lambda + bindings are now a per-name candidate set: a call resolves against every body the + name may hold across arms (sink-agnostic; surfaces e.g. PY-WL-106/107/108 on the + newly-covered shapes). May raise new findings on code matching this pattern under + `--fail-on`. (`wardline-383f83fafe`; orthogonal loop zero-trip FN tracked separately) - **Explicit `--config` pointing at a malformed (but existing) `weft.toml` no longer silently falls back to default policy.** The guard previously covered only a *missing* explicit path; a present-but-unparseable one slipped through C-9c's @@ -60,8 +600,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `legis_artifact_outcome` authority instead of being re-derived on each surface; the dead `config` input was dropped from the MCP `waiver_add` schema. -## [1.0.0rc2] - 2026-06-06 - ### Added - **MCP `scan` payload controls — `where` now shrinks the payload, plus `summary_only` / `max_findings` / `include_suppressed` and a default explain cap @@ -344,8 +882,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `--trust-pack` allowlist already gates this code path, so a default scan never reached it.) -## [1.0.0rc1] - 2026-06-02 - ### Changed - **Cross-method class-attribute taint (soundness closure A).** Raw assigned to @@ -709,7 +1245,7 @@ for Python — enterprise-class trust-boundary analysis at small-team weight. - **Packaging** — MIT-licensed; optional extras `scanner` (config + CLI) and `weft` (HTTP integrations). -[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v0.3.0...HEAD +[1.0.1]: https://github.com/foundryside-dev/wardline/compare/v0.3.0...v1.0.1 [0.3.0]: https://github.com/foundryside-dev/wardline/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/foundryside-dev/wardline/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/foundryside-dev/wardline/compare/v0.1.0...v0.2.0 diff --git a/Makefile b/Makefile index 909e9798..6968ab16 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,18 @@ build: ## Build sdist + wheel uv build clean: ## Remove build + cache artifacts - rm -rf dist/ build/ *.egg-info .mypy_cache .ruff_cache .pytest_cache .coverage coverage.json + @set -eu; \ + for path in dist build *.egg-info .mypy_cache .ruff_cache .pytest_cache; do \ + if [ ! -e "$$path" ] && [ ! -L "$$path" ]; then \ + continue; \ + fi; \ + if [ -L "$$path" ]; then \ + echo "refusing to remove symlink $$path" >&2; \ + exit 1; \ + fi; \ + rm -rf -- "$$path"; \ + done; \ + rm -f -- .coverage coverage.json find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true ci: lint typecheck test-cov ## Run the full local CI gate diff --git a/README.md b/README.md index 375911c2..4020ddec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Wardline -Generic, lightweight semantic-tainting static analyzer for Python — track untrusted data across your codebase and gate trust-boundary violations, with zero runtime dependencies. +Generic, lightweight semantic-tainting static analyzer for trust boundaries. Wardline's full analyzer targets +Python; its Rust preview catches command-injection defects over crate-aware identity. The base package has zero +runtime dependencies, and scanner/front-end functionality stays behind opt-in extras. [![CI](https://github.com/foundryside-dev/wardline/actions/workflows/ci.yml/badge.svg)](https://github.com/foundryside-dev/wardline/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/wardline)](https://pypi.org/project/wardline/) @@ -39,11 +41,13 @@ engine facts): ## What is Wardline? -Wardline reads your Python statically — it never runs your code — and asks one -question of every trust-annotated function: **is the data this function works -with as trusted as it claims?** It tracks a *taint* (a trust level) for every -value and propagates it across the whole project, flagging the places where -untrusted data reaches a trusted producer with no validation in between. +Wardline reads your code statically — it never runs it — and asks one question +of every trust-annotated boundary: **is the data this function works with as +trusted as it claims?** For Python, it tracks a *taint* (a trust level) for +values through function bodies and the project call graph, flagging places where +untrusted data reaches a trusted producer with no validation in between. For +Rust, the preview frontend currently focuses on command-injection sinks around +`std::process::Command`. Wardline is part of **Weft** — an agent-first suite of small, local-first developer tools, each driven by a coding agent as much as a person, giving small @@ -59,21 +63,34 @@ lets it scan a large untouched codebase (including its own) with zero noise. ## Key Features -- **Deterministic whole-program taint** — function-, variable-, and project-level - analysis over an inter-module call graph; no runtime instrumentation. -- **Opt-in trust model** — three decorators (`@external_boundary`, - `@trust_boundary`, `@trusted`) mark your boundaries; the engine infers the rest. -- **Four policy rules** — untrusted-reaches-trusted, non-rejecting boundary, - broad exception handler, and silently-swallowed exception. -- **Zero-dependency base** — `pip install wardline` pulls nothing; functionality - lives behind small extras. -- **Structured output** — JSONL, SARIF (generic interchange/GitHub - code-scanning), and native Filigree emit for finding lifecycle work. -- **Agent-native** — `wardline mcp` is a dependency-free MCP-over-stdio server; - `wardline install` wires Wardline into your coding agent in one command. +- **Deterministic Python taint analysis** — function-, variable-, and + project-level analysis over an inter-module call graph; no runtime + instrumentation. +- **Rust command-injection preview** — `wardline scan --lang rust` finds + `RS-WL-108`/`RS-WL-112` over `.rs` trees with crate-prefixed, baseline-eligible + finding identity. +- **Opt-in trust model** — Python decorators (`@external_boundary`, + `@trust_boundary`, `@trusted`) and Rust doc-comment markers declare the + boundary surface; undecorated code stays quiet. +- **Trust-boundary and sink rules** — boundary-integrity rules, exception-flow + rules, and expanded sink families for command execution, dynamic code/imports, + deserialization, path traversal, SSRF, SQL injection, XML parsing, templates, + native library loads, logging format strings, and SMTP sends. +- **Zero-dependency base** — `pip install wardline` pulls nothing; scanner, + Loomweave, Rust, and docs functionality live behind small extras. +- **Structured output** — JSONL, SARIF, agent-summary JSON, signed legis scan + artifacts, and native Filigree emission for finding lifecycle work. +- **MCP-primary agent surface** — `wardline mcp` is a dependency-free + MCP-over-stdio server with structured tool output, schema declarations, and + tools for scan, explain, fix, judge, doctor, rekey, assurance, attestation, + dossier, and finding lifecycle work. +- **Reproducible evidence and migrations** — `assure`, `attest`, and `rekey` + report trust-surface coverage, sign reproducible posture bundles, and migrate + fingerprint-keyed stores across scheme changes. - **Opt-in LLM triage** — `wardline judge` labels findings TRUE/FALSE positive (dependency-free; never runs automatically). -- **Light-touch suppression** — baselines and time-boxed, reasoned waivers. +- **Light-touch suppression** — baselines, time-boxed waivers, and judged + findings with explicit gate semantics. - **Loomweave integration** — persist per-entity taint facts to a Loomweave store. ## Quick Start @@ -107,6 +124,7 @@ Fix findings at the **boundary** (validate before returning), not at the sink. pip install weft-markers # tiny runtime marker package for application code pip install wardline # zero-dependency base (library + decorators) pip install 'wardline[scanner]' # the scan/judge/baseline CLI + MCP server (quote for zsh) +pip install 'wardline[rust]' # Rust command-injection preview frontend ``` Prefer `weft_markers` in application code. Wardline still recognizes @@ -117,6 +135,7 @@ Prefer `weft_markers` in application code. Wardline still recognizes |-------|-------|---------| | `scanner` | pyyaml, jsonschema, click | the `wardline` CLI and `wardline mcp` server | | `loomweave` | blake3 | persisting taint facts to a Loomweave store | +| `rust` | scanner extra, tree-sitter, tree-sitter-rust | `wardline scan --lang rust` | | `docs` | mkdocs, mkdocs-material | building the documentation site | The LLM triage judge (`wardline judge`) is dependency-free (stdlib `urllib` → @@ -130,12 +149,11 @@ wardline install This injects a hash-fenced instruction block into `CLAUDE.md`/`AGENTS.md`, installs the `wardline-gate` skill, merges a `wardline` entry into `.mcp.json`, -writes Codex's `~/.codex/config.toml` MCP entry, and records Loomweave/Filigree -bindings if present. When local sibling config exposes a URL, `install` wires it -directly; otherwise it leaves a commented stanza to fill in. Agents then run the -scan → explain → fix-at-boundary → rescan loop natively. The `wardline mcp` -server exposes `scan`, `explain_taint`, `fix`, `judge`, baseline, and waiver -tools over JSON-RPC with no SDK. +and writes Codex's `~/.codex/config.toml` MCP entry. Agents then run the scan → +explain → fix-at-boundary → rescan loop natively. The `wardline mcp` server +exposes the primary tool surface over JSON-RPC with no SDK, including scan, +filtered findings, explain-taint, fix, judge, baseline/waiver, doctor, rekey, +assure, attest, dossier, and Filigree filing tools. `wardline install` also reminds application projects to install `weft-markers` and import from `weft_markers` when they want runtime-importable trust markers @@ -145,6 +163,17 @@ Run `wardline doctor` to check those artifacts later, or `wardline doctor --repair` to refresh stale/missing wiring after moving tools or starting a Filigree dashboard. +## Configuration and state + +Wardline reads operator configuration from the `[wardline]` table in +`weft.toml`. Machine-written state lives under `.weft/wardline/`: baselines, +waivers, judged findings, and cache data stay out of the authored config file. + +Sibling URLs are resolved at runtime from flags, environment variables, or +published local Weft port files. `wardline install` and `wardline doctor` detect +sibling tools such as Filigree and Loomweave, but they do not persist endpoint +bindings into project config. + ## Where Wardline fits Use Wardline when you want a deterministic, opt-in trust-boundary gate you can @@ -158,9 +187,11 @@ It is **not** the right tool when you need: - **A broad SAST suite.** Wardline checks trust boundaries and a small set of exception-handling rules; it is not a replacement for a general-purpose scanner that covers dozens of vulnerability classes. -- **Non-Python code.** Wardline analyzes Python ≥3.12 only. +- **Full non-Python coverage.** Wardline's Rust frontend is a preview for + command-injection findings only; it is not a general Rust SAST engine. - **Zero-config coverage.** Wardline is silent until you declare trust — that is - the point, but it means it finds nothing on an un-annotated codebase. + the point, but it means it finds nothing meaningful on an un-annotated + codebase. ## Documentation @@ -170,10 +201,13 @@ Full documentation lives at ****. |----------|-------------| | [Getting Started](https://foundryside-dev.github.io/wardline/getting-started/) | Install, decorate, first scan | | [Taint & Trust Model](https://foundryside-dev.github.io/wardline/concepts/model/) | The lattice, decorators, and propagation | -| [Rules](https://foundryside-dev.github.io/wardline/concepts/rules/) | The four policy rules | +| [Rules](https://foundryside-dev.github.io/wardline/concepts/rules/) | The boundary, exception-flow, and sink rules | | [Configuration](https://foundryside-dev.github.io/wardline/guides/configuration/) | `weft.toml` `[wardline]`: rules, severity, excludes | | [Suppression](https://foundryside-dev.github.io/wardline/guides/suppression/) | Baselines and waivers | | [LLM Triage Judge](https://foundryside-dev.github.io/wardline/guides/judge/) | Opt-in TRUE/FALSE-positive labelling | +| [Rust Support](https://foundryside-dev.github.io/wardline/guides/rust-preview/) | Preview Rust command-injection frontend | +| [Weft Integration](https://foundryside-dev.github.io/wardline/guides/weft/) | SARIF, Filigree, Loomweave, and sibling URL resolution | +| [Assurance Posture](https://foundryside-dev.github.io/wardline/guides/assurance-posture/) | Coverage posture, attestations, and trust-surface evidence | | [Loomweave Taint Store](https://foundryside-dev.github.io/wardline/guides/loomweave-taint-store/) | Persisting taint facts | | [CLI Reference](https://foundryside-dev.github.io/wardline/reference/cli/) | Every command and flag | | [Trust Vocabulary](https://foundryside-dev.github.io/wardline/reference/vocabulary/) | The decorators and their arguments | diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 527ad23a..00000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -wardline.foundryside.dev \ No newline at end of file diff --git a/docs/arch-analysis-2026-06-10/05-quality-assessment.md b/docs/arch-analysis-2026-06-10/05-quality-assessment.md new file mode 100644 index 00000000..a287a4d7 --- /dev/null +++ b/docs/arch-analysis-2026-06-10/05-quality-assessment.md @@ -0,0 +1,83 @@ +# Architecture Quality Assessment — High-Risk Areas + +**Source:** Four-lens parallel specialist review (security / engine soundness / architecture / verification), each grounded in direct file reads of the working tree at `rc5` @ `21aeffaa`. +**Assessed:** 2026-06-10 +**Assessor:** architecture-critic synthesis (panel: threat-analyst, python-code-reviewer, architecture-critic, coverage-gap-analyst) + +## Executive Summary + +The codebase's security architecture is strong — THREAT-001 path confinement holds across every new MCP tool, the secure-by-default gate population is structurally enforced, and rekey cannot launder verdicts. The highest risks are elsewhere: **the CI self-hosting scan has no gate** (wardline does not fail its own build on its own findings), **active false-negative soundness gaps in the taint engine's chokepoint** (`_resolve_expr`/`_resolve_call` and loop merging), and **a structural drift seam** (un-layered `core/` held together by 158 deferred imports; the Rust frontend bolted alongside Python with no frontend abstraction). 2 Critical, 8 High findings. + +## Findings by Severity + +### Critical + +1. **CI self-hosting scan is SARIF-upload only — no gate** — verified: `.github/workflows/ci.yml:76` runs `wardline scan src/ --format sarif` with no `--fail-on`; the step succeeds regardless of findings. `tests/test_self_hosting.py` is additionally vacuous by construction (no decorators in own source → tier-gated rules never fire). + - **Impact:** wardline can ship a trust-boundary violation in itself with a green build — direct credibility risk for a trust-boundary gate product. + - **Fix:** add `--fail-on ERROR` to the CI step; add an annotated fixture module so self-scan exercises the real pipeline. Effort: S. + +2. **All live wire-contract oracles are weekly/manual only** — verified: `pyproject.toml:121` addopts excludes `loomweave_e2e`/`legis_e2e`/`filigree_e2e`/`rust_e2e`; the live job is `schedule`/`workflow_dispatch`-gated. Hermetic pins (legis key-set freeze, loomweave HMAC golden vector) do run in PR CI, but a sibling-side wire change surfaces only at the next weekly run. Known ticket `wardline-79ba05f464` (G6) covers the SEI-oracle half. + - **Impact:** wire-breaking drift across the federation seams merges green and fails up to a week later. + - **Fix:** fail-closed weekly job + a required `WARDLINE_LIVE_ORACLE_REQUIRED=1` escape hatch; land G6. Effort: M. + +### High — Engine soundness (the product's core value) + +3. **Zero-trip loop FN is real and the merge structure confirms it** (`scanner/taint/variable_level.py:1350–1394`): the post-loop state never re-merges `pre_loop` as a fallthrough arm, so a loop that *cleans* a RAW variable hides the zero-iteration path where it stayed RAW. Matches open bug `wardline-d6af917bde` (currently P3 — under-prioritized for a soundness FN). Fix is localized: combine post-loop state with `pre_loop`. Effort: S. + +4. **Raw-receiver `taint_map` bypass** (`variable_level.py:740–828`): for attribute calls, the `taint_map` hit returns early *before* the RAW_ZONE receiver guard, so `raw_obj.trusted_method()` can launder taint when the receiver's type isn't tracked. Same family as the scrub's PY-WL-105/stale-var_types cluster. Fix: apply/combine the receiver guard before the `taint_map` lookup. Effort: S–M. *(Confidence: Moderate — trigger needs an untyped raw receiver; confirm with a unit test first.)* + +5. **`collect_attribute_writes` is flow-insensitive with its own uncoordinated `var_types`** (`variable_level.py:1617–1703`): resolves RHS taint against final-state `var_taints`, not per-statement snapshots; branch-stale types can dispatch to `@trusted` summaries for now-raw receivers. *(Confidence: Moderate — the L2 fixed-point re-run in `analyzer.py:476` may partially compensate; verify empirically.)* + +### High — Structure + +6. **`core/` is un-layered: real import cycles + an engine→policy inversion, masked by 158 function-local deferred imports.** Verified cycles include `core.run → scanner.analyzer → … → core.attest → core.assure → core.run`; the inversion is `scanner/taint/project_resolver.py:143` importing `core.attest.ruleset_hash` (taint engine depending on the attestation layer). Every refactor near these modules risks surfacing masked breakage. Fix: `import-linter` contracts in report-only mode, then move `ruleset_hash` down a tier. Effort: M. + +7. **Rust frontend is a parallel vertical, not a plugged-in frontend.** Only `Finding`/`TaintState` are shared (`core/protocols.py:17`); context, rules, vocabulary, dataflow, qualname, and Finding-assembly are all reimplemented under `rust/`. A third language costs a third full vertical, and rule/severity/identity semantics will drift between the two rule trees. Fix: lift a `LanguageFrontend` interface before any third language. Effort: L. + +8. **MCP-vs-CLI surface drift seam is structural.** The `run_scan` spine is genuinely shared (strength), but the federation-status envelope is duplicated (`mcp/server.py:73,94` vs `cli/scan.py:450,480`) — exactly where the two dogfood drift incidents occurred — and `_register_tools` (`server.py:822–1271`) is a 450-line change-magnet. Fix: one shared status projector + per-tool schema declarations. Effort: M. + +### High — Verification gaps in trust-critical paths + +9. **Rekey adversarial scenarios untested**: mixed-scheme partial-migration state (one leg done, source changed before resume) and multi-store rollback (only single-store restore is tested, `tests/unit/core/test_rekey_rollback.py` has 2 tests). Rekey moves user trust decisions; rollback is the recovery path. Effort: S–M. + +10. **Tier-suppression negative tests absent**: no unit tests assert rules do NOT fire below the tier gate (e.g. `UNKNOWN_RAW` context); a silently loosened gate would only be caught if the pattern happens to exist in the labeled corpus. Effort: S. + +### Medium + +- **Symlinked `.env`/federation-token reads bypass `safe_project_file`** (`filigree/config.py:49,68`, `core/judge_run.py:67` — vs `attest_key.py:28`/`legis.py:143` which do it right): an attacker-authored repo can symlink `.env` out of root and wardline sends the first matching line as a bearer token to the configured sibling URL. The one security finding warranting a code change. Effort: S. +- **Judge prompt-injection surface is structural but contained**: attacker-authored source reaches the LLM; an injected FALSE_POSITIVE verdict feeds `judged.yaml` — but the secure-default gate ignores judged without `--trust-suppressions`, so no silent gate clear. Keep that invariant; document advisory status. +- **Rust qualname corpus has no drift alarm**: vendored byte-pinned at a loomweave blob; ADR-049 has already moved three times, each needing a manual re-vendor with no CI cross-check. Plus the known reserved-colon locator bug (`wardline-be5ee9cc34`) emits invalid locators with no degrade gate → fingerprint churn on the eventual fix. +- **Rust `write!`/`writeln!`/`format_args!` not modelled** in `rust/dataflow.py:202–214` (`format!` only) — tainted format strings through writers don't fire. +- **Three federation clients hand-roll the urllib transport independently** (`core/filigree_emit.py:229`, `filigree/dossier_client.py:50`, `loomweave/client.py`); only `read_response_text` is shared. Auth ladders should stay separate; the transport should not. +- **Corpus FP-rate gate has zero FALSE_POSITIVE-labeled entries** — the 5% budget math is never exercised against real corpus data. + +### Low (selected) + +- `resolve_under_root` is escape-rejecting but not symlink-refusing (unlike `safe_project_file`) — fine for today's read-only consumers; document the contract before any write path uses it. +- `attest`/`verify_attestation` is sound (timing-safe compare, schema/key_id binding); missing-`schema`-key and non-dict-payload edges untested. +- L2 loop-convergence backstop truncates silently (`variable_level.py:1362,1393`) with no `WLN-ENGINE-*` diagnostic, unlike the L3 bound. +- `time.sleep(0.1)` polling in `tests/e2e/test_loomweave_live.py:108,121`. + +## Cross-Cutting Concerns + +**Security:** Strong posture. Path confinement uniform across all MCP tools (including args never opened); secure-by-default gate population architecturally enforced (`run.py:87,298–301`); rekey carry keyed on finding-derived fingerprints (no laundering primitive); `install/block.py` injector hardened; secrets never read from `weft.toml`. The residual real item is the symlink token-read inconsistency. + +**Correctness:** The lattice discipline (`combine` vs `taint_join`) is rigorous and the L3 kernel monotone-guarded, but FN risk concentrates in `_resolve_expr`/`_resolve_call` (`variable_level.py:449,647`) — the shared chokepoint where the historical bug record also clusters. That chokepoint, not the file's 1,885-line length, is the real change-magnet; don't split the file for size's sake, invest in differential/property tests around the chokepoint. + +**Maintainability:** `core/paths.py` is a genuinely clean single source of truth for stores/config; the zero-dep constraint is well-contained. The debts are the un-layered `core/`, the duplicated Rust vertical, and the duplicated surface envelopes. + +## Priority Recommendations + +1. **Gate the CI self-scan** (`--fail-on ERROR` + non-vacuous fixture) — Critical, Effort S. A trust-gate product that doesn't gate itself is the cheapest, highest-credibility fix available. +2. **Fix the zero-trip loop FN** (re-merge `pre_loop`) and **confirm/fix the raw-receiver `taint_map` bypass** — Critical-class soundness in the core engine, Effort S each. Re-prioritize `wardline-d6af917bde` above P3. +3. **Close the symlinked token-read gap** (route filigree/judge `.env`+token reads through `safe_project_file` + regression test) — Medium severity, Effort S, restores the codebase's own established discipline. +4. **Land `import-linter` contracts (report-only) + fix the `project_resolver → core.attest` inversion** — High, Effort M; unblocks all future structural work. +5. **Rekey adversarial tests** (mixed-scheme partial, multi-store rollback) + **tier-suppression negative tests** — High, Effort S–M; protects user trust decisions. +6. **Before a third language: `LanguageFrontend` interface** — High, Effort L; the one item that caps the product ceiling. + +## Limitations + +- High-level risk review, not an exhaustive audit. Not reviewed: `core/triage.py`, `core/source_excerpt.py`, full `install/*`, `loomweave/client.py` send-side internals, LSP beyond delegation check. +- Engine findings 4–5 are structurally verified but not empirically reproduced — write the confirming unit tests before fixing (`wardline-d6af917bde`'s pattern: naive fixes here have regressed before). +- Loomweave index was empty (`never_analyzed`); the import-cycle graph was hand-built via AST and should be cross-checked after `loomweave analyze .`. +- No tests were executed; severity ratings assume the documented threat model (attacker authors repo content scanned by wardline). diff --git a/AUDIT.md b/docs/audits/2026-06-08-comprehensive-audit.md similarity index 98% rename from AUDIT.md rename to docs/audits/2026-06-08-comprehensive-audit.md index 83a786d8..83a6a734 100644 --- a/AUDIT.md +++ b/docs/audits/2026-06-08-comprehensive-audit.md @@ -324,7 +324,7 @@ This report compiles, synthesizes, and categorizes findings from a comprehensive * **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**: - Iterate the walk of the loop body until the dictionary of variable taints (`var_taints`) converges (stabilizes). Since the trust model is finite and monotonic, the loop is guaranteed to converge in at most 8 iterations. + Iterate the walk of the loop body until the dictionary of variable taints (`var_taints`) converges (stabilizes). The trust model is finite and monotonic, so a fixpoint is guaranteed — but the iteration bound is `num_vars × lattice_height`, NOT lattice_height (8) alone: 8 caps a *single* variable's monotone rank climb, whereas a read-before-write loop-carried chain propagates taint one link per iteration, so an N-variable chain needs N iterations to reach the head. Iterating to genuine convergence (the `var_taints == previous_state` break) with a `num_vars × lattice_height` backstop is the sound fix; capping at a fixed 8 silently dropped chains longer than 8 links — a fail-open false negative (fixed: wardline-e04db6e656). ### WLN-MED-10: Ineffective Caching / Performance Architecture Flaw * **Focus Area**: Systems / Caching diff --git a/docs/concepts/model.md b/docs/concepts/model.md index cc205d29..8eec6b49 100644 --- a/docs/concepts/model.md +++ b/docs/concepts/model.md @@ -135,8 +135,11 @@ return is the higher trust level you declare. Boundaries matter because they are where trust is *earned*. A function that raises its declared trust but cannot actually reject anything — no `raise`, no early failing return — is not validating; it is just relabelling untrusted data -as trusted. Wardline flags exactly that case (rule [PY-WL-102](rules.md)), so a -boundary that claims to validate is held to actually being able to say "no." +as trusted. Wardline flags exactly that case with its boundary-integrity family +(rules [PY-WL-102 / 111 / 113 / 119](rules.md) — no rejection path, assert-only +rejection, fail-open handler, and the bare no-op `return p` shape, exactly one +of which fires per boundary), so a boundary that claims to validate is held to +actually being able to say "no." Get your boundaries right and the trust propagation does the rest: everything downstream of a real boundary is trusted, everything upstream is raw, and diff --git a/docs/concepts/rules.md b/docs/concepts/rules.md index 3c489361..417632f1 100644 --- a/docs/concepts/rules.md +++ b/docs/concepts/rules.md @@ -1,9 +1,10 @@ # Rules -Wardline ships eleven policy rules. They consume the [taint & trust -model](model.md): each one looks for a place where a function's *declared* trust -and its *actual* trust disagree, where untrusted data reaches a dangerous sink, -or where a trusted-tier function handles errors or declarations carelessly. +Wardline ships twenty-six Python policy rules, `PY-WL-101` through `PY-WL-126`. +They consume the [taint & trust model](model.md): each one looks for a place +where a function's *declared* trust and its *actual* trust disagree, where +untrusted data reaches a dangerous sink, or where a trusted-tier function +handles errors or declarations carelessly. All of them are off by default for undecorated code — they only fire where you have opted in by declaring trust. To turn a rule off entirely, or to change its @@ -11,29 +12,53 @@ severity, see [Configuration](../guides/configuration.md). ## The rules -| Rule | What it flags | Default severity | -|---|---|---| -| `PY-WL-101` | A trust-anchored function returns data less trusted than the level it declares — untrusted data reaches a trusted producer with no validation. | `ERROR` | -| `PY-WL-102` | A trust boundary (a function that raises declared trust on its return) has no rejection path — no raise, no falsy-constant return — so it cannot validate. | `ERROR` | -| `PY-WL-103` | A broad exception handler (bare except / Exception / BaseException) in a trusted-tier function. | `WARN` | -| `PY-WL-104` | An exception handler that silently swallows the error (only pass/.../continue/break) in a trusted-tier function. | `WARN` | -| `PY-WL-105` | Untrusted data is passed as an argument to a trusted producer at a call site. | `ERROR` | -| `PY-WL-106` | Untrusted data reaches a deserialization sink (pickle/marshal/yaml.load) in a trusted-tier function. | `WARN` | -| `PY-WL-107` | Untrusted data reaches a dynamic-code-execution sink (eval/exec/compile) in a trusted-tier function. | `WARN` | -| `PY-WL-108` | Untrusted data reaches an always-shell OS-command sink (os.system/os.popen/subprocess.getoutput). | `WARN` | -| `PY-WL-109` | A trusted producer has both a value-bearing return and a None-yielding return — None leaks from a function declaring trusted output. | `WARN` | -| `PY-WL-110` | An entity carries two or more distinct trust markers (e.g. `@trusted` + `@external_boundary`) — a contradictory declaration the engine resolves silently. | `WARN` | -| `PY-WL-111` | A trust boundary's only rejection path is `assert`, which `python -O` strips — the validation silently vanishes in production (CWE-617). | `ERROR` | +| Rule | What it flags | Base severity | Maturity | +|---|---|---|---| +| `PY-WL-101` | A trust-anchored function returns data less trusted than the level it declares — untrusted data reaches a trusted producer. | `ERROR` | stable | +| `PY-WL-102` | A trust boundary has no rejection path of any recognised shape — it cannot validate. (The bare `return p` shape is `PY-WL-119`'s.) | `ERROR` | stable | +| `PY-WL-103` | A broad exception handler (bare except / Exception / BaseException) in a trusted-tier function. | `WARN` | stable | +| `PY-WL-104` | An exception handler that silently swallows the error in a trusted-tier function. | `WARN` | stable | +| `PY-WL-105` | Untrusted data is passed as an argument to a trusted producer at a call site. | `ERROR` | stable | +| `PY-WL-106` | Untrusted data reaches a deserialization sink (pickle/marshal/yaml, `pickle.Unpickler`, `shelve.open`, and a curated third-party table) (CWE-502). | `WARN` | stable | +| `PY-WL-107` | Untrusted data reaches a dynamic-code-execution sink (`eval`/`exec`/`compile`, including the `builtins.`/`__builtins__.` spellings) (CWE-95). | `WARN` | stable | +| `PY-WL-108` | Untrusted data reaches a command/program-execution sink — the always-shell string APIs plus `os.exec*`/`os.spawn*`/`os.posix_spawn*`/`pty.spawn` (CWE-78). | `ERROR` | stable | +| `PY-WL-109` | A trusted producer has both a value-bearing return and a None-yielding return — None leaks from a function declaring trusted output. | `WARN` | stable | +| `PY-WL-110` | An entity carries two or more distinct trust markers — a contradictory declaration the engine resolves silently. | `WARN` | stable | +| `PY-WL-111` | A trust boundary's only rejection path is `assert`, which `python -O` strips (CWE-617). | `ERROR` | stable | +| `PY-WL-112` | Untrusted data reaches a `subprocess` call with a literal `shell=True` (CWE-78). | `ERROR` | stable | +| `PY-WL-113` | A trust boundary fails open — an exception handler swallows the failure and substitutes a value, so the boundary can be bypassed by triggering the exception (CWE-636). | `ERROR` | stable | +| `PY-WL-114` | A builtin trust decorator's level argument is statically readable but invalid or out-of-range — the engine would silently drop the declaration. | `ERROR` | stable | +| `PY-WL-115` | Untrusted data reaches a dynamic code/module-load sink (`importlib.import_module`, `__import__`, `runpy.run_path`/`run_module`, `importlib.util.spec_from_file_location`) (CWE-829/CWE-94). | `WARN` | stable | +| `PY-WL-116` | Untrusted data reaches a path/filesystem-traversal sink — open/join/`pathlib.Path`, filesystem mutation, `Path` methods, archive extraction (Zip Slip) (CWE-22). | `WARN` | preview | +| `PY-WL-117` | Untrusted data reaches the URL slot of an HTTP client sink (SSRF: requests/httpx/aiohttp/urllib, including constructed client/session methods) (CWE-918). | `WARN` | preview | +| `PY-WL-118` | Untrusted data reaches a SQL execution sink (`execute`/`executemany`/`executescript`) in the SQL-string position (CWE-89). | `ERROR` | preview | +| `PY-WL-119` | A degenerate (no-op) trust boundary — the body is a bare `return ` with no validation at all. | `ERROR` | preview | +| `PY-WL-120` | Stored/persisted taint (file reads, DB cursor fetches) reaches trusted state without validation. | `ERROR` | preview | +| `PY-WL-121` | Untrusted data reaches an XML parsing sink (XXE / billion-laughs) (CWE-611). | `ERROR` (stdlib parsers `WARN`) | preview | +| `PY-WL-122` | Untrusted data is compiled into a server-side template (jinja2/mako) — SSTI (CWE-1336). | `ERROR` | preview | +| `PY-WL-123` | Untrusted data is the attribute NAME in `setattr`/`getattr` — dynamic attribute injection / mass assignment (CWE-915). | `WARN` | preview | +| `PY-WL-124` | Untrusted data reaches a native-library load sink (`ctypes.CDLL` family) (CWE-114). | `ERROR` | preview | +| `PY-WL-125` | Untrusted data is the log MESSAGE format string of `logging` calls — log injection (CWE-117). | `INFO` | preview | +| `PY-WL-126` | Untrusted data reaches the recipient/message of `smtplib` `SMTP.sendmail` — mail/header injection (CWE-93). | `WARN` | preview | !!! info "Declaration-gated vs. tier-modulated severity" - `PY-WL-101`, `PY-WL-102`, `PY-WL-105`, `PY-WL-109`, `PY-WL-110`, and - `PY-WL-111` are **declaration-gated** — the decorator itself is the opt-in, - so they always fire at their base severity. `PY-WL-103`, `PY-WL-104`, and the - sink rules `PY-WL-106`/`107`/`108` are **tier-modulated**: their severity - scales with the function's own trust tier. They report at the base severity - in fully trusted functions (`INTEGRAL`/`ASSURED`), downgrade one step in - partially-trusted functions, and are suppressed entirely on undecorated code. - The `WARN` above is the trusted-tier value. + `PY-WL-101`, `PY-WL-102`, `PY-WL-105`, `PY-WL-109`, `PY-WL-110`, + `PY-WL-111`, `PY-WL-113`, `PY-WL-114`, and `PY-WL-119` are + **declaration-gated** — the decorator itself is the opt-in, so they always + fire at their base severity. `PY-WL-103`, `PY-WL-104`, and the sink rules + (`PY-WL-106`/`107`/`108`/`112`/`115`/`116`/`117`/`118`/`120`/`121`–`126`) + are **tier-modulated**: their severity scales with the function's own trust + tier. They report at the base severity in fully trusted functions + (`INTEGRAL`/`ASSURED`), downgrade one step in partially-trusted functions, + and are suppressed entirely on undecorated code. The base severity above is + the trusted-tier value. + +!!! note "Preview maturity" + Rules marked **preview** carry `maturity: preview` in the + [vocabulary descriptor](../reference/vocabulary.md): their charter and + sink sets are still being calibrated, and their predicates may sharpen + between releases. They participate in the gate, baseline, waivers, and + judge exactly like stable rules. --- @@ -61,23 +86,68 @@ EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer The fix is to validate before returning — for example by routing the raw value through a `@trust_boundary` first. +## The boundary-integrity family (102 / 111 / 113 / 119) + +A `@trust_boundary` declares that it *raises* trust: its body sees raw data and +its return is the higher level it declares. Four rules police whether such a +boundary can actually do that job, and they **partition four ways — at most one +of them fires per boundary**: + +- **`PY-WL-119`** — the bare **degenerate** shape: the body is (modulo + docstrings/`pass`) a single `return `. The more-specific rule wins, so + 102 suppresses itself on this shape. The suppression is structural — keyed on + the shape, not on whether 119 is enabled. +- **`PY-WL-102`** — every *other* shape with **no rejection path** of any + recognised kind: the boundary cannot reject at all. +- **`PY-WL-111`** — the only rejection is **`assert`**, which `python -O` + strips: the validation silently vanishes in production (CWE-617). This + includes an assert inside a `try` whose handler substitutes — the rejection + is still assert-only, so 111 wins over 113. +- **`PY-WL-113`** — a **real rejection exists but a fail-open handler defeats + it**: an `except` swallows the failure and substitutes a value-bearing + result. + +### What counts as a rejection path + +A boundary is silent under 102/111 when it has any of these recognised +rejection shapes: + +- an own-scope **`raise`** (or an `assert` — but assert-*only* is 111's case); +- a **rejection-shaped `return`** — a falsy constant (`None`/`False`/`0`/`""`, + or an empty literal container), a **conditional expression with a rejecting + branch** (`return m.group(0) if m else None` is the ternary form of + `if not m: return None`), or a curated **raising conversion** — a + validate-by-construction expression that raises on bad input: `int(p)`, + `float(p)`, `complex(p)`, `Decimal(p)`, `Fraction(p)`, `UUID(p)`, or a + subscript lookup with a non-constant key (`Color[p]` raises `KeyError`; + `ALLOWED[p]` likewise). A *constant* argument or index (`int("3")`, + `parts[0]`) validates nothing and does not count; +- a **one-hop, same-module call to a raising helper** — a factored-out + validator whose own body has a real rejection (`_require_nonempty(p)`, a + raising staticmethod, or wholesale delegation to another raising boundary). + The helper's body is inspected exactly one hop deep, same module only, and it + must have a *real* (production-surviving) rejection: a helper that cannot + raise never counts, and an assert-only helper never counts (its assert + vanishes under `python -O` exactly like an inline one). + ### PY-WL-102 — trust boundary with no rejection path -Fires on a `@trust_boundary` validator that cannot actually reject anything. The -function declares it raises trust (its return is more trusted than its raw body), -but it contains no `raise` and no falsy-constant `return` — so it has no way to -say "no" to bad input. A validator that cannot reject is not validating. +Fires on a `@trust_boundary` validator that cannot actually reject anything — +no recognised rejection shape at all. A validator that cannot reject is not +validating. ```python @trust_boundary(to_level="ASSURED") -def validate(p): - return p # no raise, no falsy return — cannot reject +def v(p): + x = p + return x # no raise, no falsy return — cannot reject ``` -Wardline reports: +(The single-statement `return p` form of this defect is reported as +`PY-WL-119` instead.) Wardline reports: ``` -demo.validate declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no +demo.v declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate ``` @@ -85,12 +155,89 @@ The fix is to add a real rejection path: ```python @trust_boundary(to_level="ASSURED") -def validate(p): +def v(p): if not p: raise ValueError return p ``` +### PY-WL-111 — trust boundary whose only rejection is `assert` + +Fires on a `@trust_boundary` whose *only* rejection path is an `assert`. The +validation works in development but is stripped under `python -O`, so the +boundary silently stops rejecting in production (CWE-617). + +```python +@trust_boundary(to_level="ASSURED") +def validate(p): + assert p # stripped under python -O — validation vanishes + return p +``` + +A boundary with a real `raise` or rejection-shaped `return` — or a one-hop +same-module raising helper call (the helper's `raise` survives `-O`) — trips +neither 102 nor 111, even if it also has an `assert`: + +```python +@trust_boundary(to_level="ASSURED") +def validate(p): + assert isinstance(p, str) # an internal invariant, not the gate + if not p: + raise ValueError # the real, -O-safe rejection + return p +``` + +### PY-WL-113 — trust boundary that fails open + +Fires on a `@trust_boundary` where a real rejection *exists* but an `except` +handler swallows the failure and **substitutes** a value-bearing result instead +of re-raising — either by returning it directly or by assigning it to a name +the function returns by fall-through. Such a boundary can be bypassed by +*triggering* the exception. The most insidious shape is the self-catch, where +the handler catches the very exception the boundary's own rejection raises: + +```python +@trust_boundary(to_level="ASSURED") +def v(p): + try: + if bad(p): + raise ValueError # the rejection ... + return p + except ValueError: + return p # ... immediately caught and bypassed +``` + +The rule enforces its premise: a real, production-surviving rejection must +exist (no rejection at all is 102's domain; assert-only is 111's), and the +rejection must be lexically *swallowable* by the matching handler. A rejection +wholly outside the `try` cannot be defeated by the handler — the boundary fails +**closed** and the rule stays silent. A handler that re-raises, or that returns +a falsy/empty constant (signalling rejection, not substitution), never matches. + +### PY-WL-119 — degenerate (no-op) trust boundary + +Fires on the bare degenerate boundary: the body is a single `return ` — +no checks, no asserts, no validation of any kind. It is a strict structural +subset of "no rejection path", carved out of `PY-WL-102`'s domain so the family +partitions cleanly: 119 wins on this shape and the same boundary is never +counted twice at `ERROR` in the gate population. + +```python +@trust_boundary(to_level="ASSURED") +def validate(x): + return x # PY-WL-119: a no-op validator +``` + +```python +@trust_boundary(to_level="ASSURED") +def validate(x): + if not x: + raise ValueError + return x # clean +``` + +--- + ### PY-WL-103 — broad exception handler in a trusted-tier function Fires on a `bare except`, `except Exception`, or `except BaseException` inside a @@ -139,17 +286,59 @@ argument to a `@trusted`-style callee whose body operates on trusted data. Where 101 polices a function's *own* return, 105 polices the *arguments* a trusted callee is handed. Declaration-gated on the callee. -### PY-WL-106 / 107 / 108 — untrusted data reaches a dangerous sink +## The sink rules + +The sink rules fire when raw-zone data reaches a named dangerous call inside a +trusted-tier function. They are tier-modulated: they speak only where trust is +declared and are silent in the developer-freedom zone. Sink matching is +import-alias aware, and most families also resolve the **construct-then-method +form** (`client = httpx.Client(); client.get(url)`, `with smtplib.SMTP(h) as +s`, the chained `Ctor().method(raw)`) and **function-local callable aliases** +(`runner = subprocess.run; runner(...)`). Where only specific argument slots +are dangerous, the rules match by **argument position/keyword** so taint in a +harmless slot (a `timeout=`, a `parser=`, a logging `%`-args parameter) does +not fire. + +### PY-WL-106 — untrusted data reaches a deserialization sink (CWE-502) + +Deserializing untrusted bytes is a classic remote-code-execution vector. The +sink set covers four families: + +- **stdlib direct loaders** — `pickle.load`/`loads`, `marshal.load`/`loads`, + `yaml.load`/`load_all`/`unsafe_load`/`full_load` (the `safe_*` loaders and + the *dump* direction are deliberately not sinks; `json.loads` is excluded — + it does not execute); +- **the OO streaming-unpickle API** — `pickle.Unpickler(stream).load()`, + chained or stored-instance; the dangerous data is the stream handed to the + *constructor*, so the taint is read there; +- **`shelve.open`** — pickle-backed: opening a shelf at an attacker-controlled + *path* then reading keys unpickles attacker bytes (only the path slot is + dangerous); +- **a curated third-party table** — `dill.load`/`loads`, `jsonpickle.decode`, + `joblib.load`, `torch.load`, `numpy.load`. Two literal-keyword gates: + `numpy.load` fires **only** with a literal `allow_pickle=True` (the default + has been the safe `False` since numpy 1.16.3, so absent/`False`/dynamic + stays silent); `torch.load` is suppressed by a literal `weights_only=True` + (the restricted unpickler) and fires otherwise. + +Every entry is RCE-equivalent, so all carry the family base severity (`WARN`, +tier-modulated). + +```python +@trusted(level="ASSURED") +def f(req): + return pickle.loads(read_request(req)) # fires -The three sink rules fire when raw-zone data reaches a named dangerous call -inside a trusted-tier function: +@trusted(level="ASSURED") +def g(path): + return numpy.load("model.npy") # clean: no allow_pickle=True +``` -- **`PY-WL-106`** — a deserialization sink (`pickle.loads`, `marshal.loads`, - `yaml.load`): arbitrary-object construction from untrusted bytes. -- **`PY-WL-107`** — a dynamic-code-execution sink (`eval`, `exec`, `compile`): - arbitrary code execution (CWE-95). -- **`PY-WL-108`** — an always-shell OS-command sink (`os.system`, `os.popen`, - `subprocess.getoutput`): shell command injection. +### PY-WL-107 — untrusted data reaches a dynamic-code-execution sink (CWE-95) + +`eval` / `exec` / `compile` on untrusted input is arbitrary code execution. +Matches the bare builtins (`eval(x)`), the `builtins.eval` forms, and the +`__builtins__.eval` spelling. ```python @trusted(level="ASSURED") @@ -157,10 +346,331 @@ def run(req): eval(read_request(req)) # read_request is @external_boundary -> EXTERNAL_RAW ``` -These are tier-modulated: they speak only where trust is declared and are silent -in the developer-freedom zone. They match curated, importable sink symbols -(framework-specific sinks whose receiver is a runtime object — `cursor.execute`, -`Template().render` — belong in opt-in trust-grammar packs, not the builtin set). +### PY-WL-108 — untrusted data reaches a command/program-execution sink (CWE-78) + +Covers two sink shapes, both stdlib: + +- **always-shell string APIs** — `os.system`, `os.popen`, + `subprocess.getoutput`, `subprocess.getstatusoutput`: these take a shell + *string*, so an untrusted argument is directly injectable; +- **argv-style program execution** — the `os.exec*` and `os.spawn*` families, + `os.posix_spawn`/`os.posix_spawnp`, and `pty.spawn`: no shell mediates, but + an attacker-controlled program path or argv element *is* arbitrary-program + execution. + +Base severity is `ERROR`, calibrated with `PY-WL-118` (SQLi): tainted +command/program execution is the same blast-radius exploit class. + +The `subprocess.run`/`call`/`Popen`/`check_*` family is intentionally **not** +in this sink set — with the default `shell=False` an argv-list is safe, and the +one condition that makes it injectable (`shell=True`) is policed by +`PY-WL-112`. + +**`shlex.quote` semantics (GUARDED, concatenation context only).** +`shlex.quote(x)` neutralizes shell-string taint for the always-shell sinks +*only as a fragment of a constant-shaped command*: a `+` chain or f-string with +at least one constant fragment in which every non-constant leaf is a +`shlex.quote(...)` call is treated as guarded — +`os.system("echo " + shlex.quote(raw))` is clean. A **bare whole-command quote +still fires** (`os.system(shlex.quote(raw))` — a fully-quoted single token +handed to a shell executes that token as the program name, so the attacker +still picks what runs), and the guard never applies to the argv +program-execution sinks (no shell parses the value, so quoting protects +nothing). The guard is inline-syntactic only: a quote result routed through a +variable still fires. + +```python +@trusted(level="ASSURED") +def f(p): + os.execv(read_raw(p), ["prog"]) # fires (program execution) + +@trusted(level="ASSURED") +def g(p): + os.system("echo " + shlex.quote(read_raw(p))) # clean (guarded fragment) +``` + +### PY-WL-112 — untrusted data reaches a `shell=True` subprocess call (CWE-78) + +The completion of `PY-WL-108`'s deliberate exclusion: the +`subprocess.run`/`call`/`check_call`/`check_output`/`Popen` family fires only +when **both** a literal `shell=True` keyword is statically visible **and** +untrusted data reaches the call. A `**kwargs` spread, a non-constant +`shell=flag`, or `shell=1` is not matched (a bounded false negative, chosen +over flooding argv-list false positives); a fully-literal +`subprocess.run('ls -la', shell=True)` does not fire either — the rule keys on +untrusted *data*, not on `shell=True` alone. Base severity `ERROR`, calibrated +with 108/118. + +```python +@trusted(level="ASSURED") +def f(p): + subprocess.run(read_raw(p), shell=True) # fires + +@trusted(level="ASSURED") +def g(p): + subprocess.run(["ls", "-la"]) # clean: argv list, no shell +``` + +### PY-WL-115 — untrusted data reaches a dynamic code/module-load sink (CWE-829/CWE-94) + +The import-and-execute class: `importlib.import_module` and `__import__` +(attacker-chosen module name), `runpy.run_path` / `runpy.run_module` +(import-and-*execute* an attacker-controlled file path / module — blast radius +equivalent to `exec`), and `importlib.util.spec_from_file_location` (a tainted +file-path argument builds a loader for attacker-chosen code). + +```python +@trusted(level="ASSURED") +def f(p): + runpy.run_path(read_raw(p)) # fires + +@trusted(level="ASSURED") +def g(p): + importlib.import_module("sys") # clean +``` + +### PY-WL-116 — untrusted data reaches a path/filesystem-traversal sink (CWE-22) + +Three sink families: + +- **direct dotted calls** with a tainted path argument — `open`, `os.open`, + `os.path.join`, `pathlib.Path`, plus the filesystem-**mutation** APIs + (`os.remove`/`unlink`/`rmdir`/`mkdir`/`makedirs`/`rename`/`renames`/`replace`, + `shutil.rmtree`/`copy`/`copy2`/`copyfile`/`copytree`/`move`), where a tainted + path is a destructive traversal; +- **`Path` methods** (`read_text`/`read_bytes`/`write_text`/`write_bytes`/ + `open`/`unlink`/`rmdir`/`mkdir`) on a `pathlib.Path` **constructed from + tainted input** — the dangerous data is the constructor's argument + (`q = Path(raw); q.read_text()`); +- **archive extraction** (Zip Slip / tarbomb): `extractall`/`extract` on a + `tarfile.open`/`tarfile.TarFile`/`zipfile.ZipFile` instance whose *archive + source* is tainted — a malicious archive escapes the target directory via + `../` member names. **Exemption:** an extraction call passing tarfile's safe + filter as the literal `filter="data"` (blocks absolute paths, traversal, and + device members since Python 3.12) does not fire; any other filter value + (including `"fully_trusted"` or a dynamic expression) still fires. + +```python +@trusted(level="ASSURED") +def f(p): + tf = tarfile.open(read_raw(p)) + tf.extractall("/dst") # fires (Zip Slip) + +@trusted(level="ASSURED") +def g(p): + tf = tarfile.open(read_raw(p)) + tf.extractall("/dst", filter="data") # clean: safe extraction filter +``` + +### PY-WL-117 — untrusted data reaches an HTTP client sink (SSRF, CWE-918) + +Covers `requests`, `httpx`, `aiohttp`, and `urllib` — both the module-level +calls and **constructed client/session instance methods** +(`client = httpx.Client(); client.get(url)`, `async with httpx.AsyncClient() +as c`, `requests.Session().get(url)`, `aiohttp.ClientSession`), plus a client +constructor's `base_url=`. Matching is **URL-slot precise**: only the +request-target argument is an SSRF vector, so a tainted +`timeout=`/`verify=`/`headers=` with a clean literal URL does not fire. + +```python +@trusted(level="ASSURED") +def f(p): + client = httpx.Client() + client.get(read_raw(p)) # fires + +@trusted(level="ASSURED") +def g(p): + requests.get("https://example.com", timeout=read_raw(p)) # clean: not the URL slot +``` + +### PY-WL-118 — untrusted data reaches a SQL execution sink (CWE-89) + +Fires when untrusted data reaches the **SQL-string position** of +`cursor.execute`, `cursor.executemany`, or `executescript` (sqlite3 cursor +*and* connection — `executescript` runs a multi-statement script with **no +parameter binding at all**, so it is strictly more dangerous than `execute`). + +Three precision guards: + +- **operation-slot only** — SQLi is a property of the SQL *string*; untrusted + data passed as a *bound parameter* (the OWASP-canonical mitigation) cannot + alter query structure and does not fire. Splatted/`**`-unpacked arguments + that could supply the operation fail closed (fire); +- **receiver heuristic (fail-closed)** — `.execute` is matched by method name, + so binding evidence (a constructor from a known DB-driver module fires; one + from a known executor module suppresses) and exact name-token evidence + (`cursor`/`conn`/`db`/... fires and wins over mixed names; `pool`/`executor`/ + `worker`/... alone suppresses) keep task pools from firing a CWE-89 `ERROR`. + Unknown receivers **fire** — when unsure, a missed finding is worse than a + false positive; +- **constant `text()` exemption** — the canonical SQLAlchemy parameterized + pattern `conn.execute(text("... :id"), {"id": uid})` wraps a compile-time + constant in a recognized text-clause constructor and is treated as clean; + `text(tainted)` / `text(f"...")` still fire (`text()` is not a sanitiser). + +```python +@trusted(level="ASSURED") +def f(p, cursor): + cursor.executescript(read_raw(p)) # fires + +@trusted(level="ASSURED") +def g(p, cursor): + cursor.execute("SELECT * FROM t WHERE id = ?", (read_raw(p),)) # clean: bound parameter +``` + +### PY-WL-120 — stored/persisted taint reaches trusted state + +Fires when raw data loaded from persistent storage — file reads via +`open`/`read_text` or database cursor fetches (`fetchone`/`fetchall`/ +`fetchmany`) — reaches a trusted state (returned by a `@trusted` function or +passed to a `@trusted` callee) without being validated. The storage-read +matcher is **receiver-aware**: an `io.StringIO`/`io.BytesIO` receiver is an +in-memory buffer, never persistent storage, so its `.read()` is exempt. On the +return arm the rule de-conflicts with `PY-WL-101`: where 101 already reports +the trust-claim violation with unresolved provenance, 120 suppresses; where the +storage provenance is substantiated, the pair stands deliberately (101 reports +the trust claim, 120 adds the storage-provenance annotation). + +```python +@trusted(level="ASSURED") +def get_config(): + data = open("config.txt").read() + return data # fires +``` + +## The preview sink expansions (121–126) + +Six PREVIEW rules added in the 2026-06-10 coverage-gap pass. All are +tier-modulated, argument-slot precise, and resolve the construct-then-method +form and callable aliases. + +### PY-WL-121 — untrusted data reaches an XML parsing sink (CWE-611) + +A tainted document/stream reaching an XML parser. Only the DOCUMENT slot +(position 0 / its keyword spelling) is dangerous — taint in a `parser=` or +handler slot is not XXE. Severity is **per-sink**, calibrated to each parser's +*default* posture: `lxml.etree.*` is `ERROR` (resolves external entities by +default, so tainted XML is genuine XXE — local file disclosure / SSRF); the +stdlib `xml.etree.ElementTree` / `xml.dom.minidom` / `xml.sax` parsers are +`WARN` (external general entities have been disabled by default since CPython +3.7.1; the residual default-on risk is the billion-laughs internal-entity +expansion DoS). `defusedxml` is the blessed remediation and is deliberately +not a sink. An operator `rules.severity` override re-bases the whole rule. + +```python +from lxml import etree + +@trusted(level="ASSURED") +def f(p): + return etree.fromstring(read_raw(p)) # fires at ERROR + +@trusted(level="ASSURED") +def g(): + ET.fromstring("") # clean: constant document +``` + +### PY-WL-122 — untrusted data compiled into a server-side template (SSTI, CWE-1336) + +A tainted string reaching a template **compilation** sink — `jinja2.Template`, +`jinja2.Environment.from_string` (including the construct-then-method form), +`mako.template.Template`. Only the template SOURCE slot is dangerous: tainted +data passed as a *render variable* is the safe idiom and does not fire, and +loading a template *by name* (`env.get_template(raw)`) is not SSTI. Severity +`ERROR`: SSTI in Jinja2/Mako is RCE-adjacent. + +```python +@trusted(level="ASSURED") +def f(p): + return jinja2.Template(read_raw(p)).render() # fires + +@trusted(level="ASSURED") +def g(p): + jinja2.Template("Hello {{ name }}").render(name=read_raw(p)) # clean: render variable +``` + +### PY-WL-123 — tainted attribute NAME reaches `setattr`/`getattr` (CWE-915) + +Dynamic attribute injection — an untrusted NAME argument (position 1) to the +builtin `setattr`/`getattr` lets an attacker pick which attribute is +written/read (mass assignment). Only the NAME slot is dangerous: an untrusted +VALUE assigned to a fixed attribute, a tainted `getattr` default, or a tainted +receiver are ordinary data flow and stay silent. Severity `WARN` — a +mass-assignment *vector*, not direct code execution. + +```python +@trusted(level="ASSURED") +def f(p, obj): + setattr(obj, read_raw(p), 1) # fires: attacker picks the attribute + +@trusted(level="ASSURED") +def g(p, obj): + setattr(obj, "name", read_raw(p)) # clean: fixed attribute name +``` + +### PY-WL-124 — untrusted path reaches a native-library load sink (CWE-114) + +A tainted library path/name reaching `ctypes.CDLL` / `WinDLL` / `OleDLL` / +`PyDLL` or `ctypes.cdll.LoadLibrary`. Loading an attacker-controlled shared +object is arbitrary **native** code execution — the same blast radius as the +command-execution family, so the same `ERROR` base. + +```python +@trusted(level="ASSURED") +def f(p): + return ctypes.CDLL(read_raw(p)) # fires + +@trusted(level="ASSURED") +def g(): + ctypes.CDLL("libm.so.6") # clean +``` + +### PY-WL-125 — untrusted data as the log MESSAGE format string (CWE-117) + +Log injection / log forging — a tainted value used as the message FORMAT string +of `logging.debug/info/warning/error/critical/exception` (module-level +functions or the Logger-method form, `logger = logging.getLogger(...); +logger.info(raw)`). Newline-spoofed entries forge audit lines and seed +log-viewer XSS downstream. Only the message slot is dangerous: tainted data in +the lazy `%`-args parameters (`logging.info('user=%s', raw)`) is logging's own +parameterization — the canonical safe idiom — and never fires. Severity +`INFO`: the class is real but high-noise by nature, and its blast radius is +forgery/foothold, not execution — visible to agents and to an explicit +`--fail-on INFO` gate without ever tripping the default gate. + +```python +@trusted(level="ASSURED") +def f(p): + logging.info(read_raw(p)) # fires: tainted format string + +@trusted(level="ASSURED") +def g(p): + logging.info("user input = %s", read_raw(p)) # clean: lazy %-parameterization +``` + +### PY-WL-126 — untrusted recipient/message reaches `SMTP.sendmail` (CWE-93) + +Mail (CRLF/header) injection — tainted data in the `to_addrs` (position 1) or +`msg` (position 2) argument of `smtplib.SMTP.sendmail` / +`smtplib.SMTP_SSL.sendmail`, with the receiver matched through the +construct-then-method machinery. Newlines in a recipient or message inject +spoofed headers / BCC recipients. The envelope sender (`from_addr`) is +deliberately not a dangerous slot in v1, and `send_message` is out of scope +(its header serialization already rejects bare newlines). Severity `WARN` — +real injection, but bounded blast radius (spam/spoofing, not code execution). + +```python +@trusted(level="ASSURED") +def f(p): + s = smtplib.SMTP("localhost") + s.sendmail("from@example.com", "to@example.com", read_raw(p)) # fires + +@trusted(level="ASSURED") +def g(): + s = smtplib.SMTP("localhost") + s.sendmail("from@example.com", "to@example.com", "body") # clean +``` + +--- ### PY-WL-109 — None leaks from a trusted producer @@ -176,33 +686,40 @@ together with `@external_boundary`). The combination is contradictory; the engin resolves it fail-closed but the conflicting intent is declaration hygiene worth surfacing. Declaration-gated. -### PY-WL-111 — trust boundary whose only rejection is `assert` - -A `PY-WL-102`-adjacent refinement. Fires on a `@trust_boundary` whose *only* -rejection path is an `assert`. The validation works in development but is -stripped under `python -O`, so the boundary silently stops rejecting in -production (CWE-617). - -```python -@trust_boundary(to_level="ASSURED") -def validate(p): - assert p # stripped under python -O — validation vanishes - return p -``` - -The two rules partition the space: `PY-WL-102` fires when a boundary cannot -reject *at all*; `PY-WL-111` fires when it *appears* to reject but only via a -guard that disappears in production. A boundary with a real `raise` or a -falsy-constant `return` trips neither — even if it also has an `assert`. - -```python -@trust_boundary(to_level="ASSURED") -def validate(p): - assert isinstance(p, str) # an internal invariant, not the gate - if not p: - raise ValueError # the real, -O-safe rejection - return p -``` +### PY-WL-114 — invalid level on a builtin trust decorator + +Fires on any entity carrying a builtin trust decorator (`@trusted` or +`@trust_boundary`) whose level argument is statically readable but not a valid +trust level, or not within the decorator's allowed set. This is a critical +safety defect: a typo (e.g. `level='ASURED'`) causes the engine to silently +drop the decorator, disabling all taint gates on that function. Recognition +resolves the decorator to the builtin FQN (import-alias aware), so an aliased +builtin with a typo still fires while a foreign decorator that merely happens +to be spelled `trusted` does not. A dynamic level (`level=cfg.LEVEL`) is not +statically readable and stays silent. + +## Engine diagnostics and the gate + +Alongside the policy rules, the engine emits `WLN-ENGINE-*` / `WLN-CONFIG-*` +diagnostics about the scan itself. Two of them are **gate-eligible `ERROR` +defects** (fail-closed — their absence of analysis must not read as green): + +- **`WLN-ENGINE-PARSE-ERROR`** — a discovered file could not be read or parsed. + Its sinks were never analyzed, so a default `--fail-on ERROR` reading green + over it would be a fail-open. Baseline/waiver still *annotate* it but cannot + clear the secure gate; `--trust-suppressions` can (an explicit operator trust + decision). +- **`WLN-ENGINE-FILE-FAILED`** — an unexpected exception while analyzing one + file. The scan continues (per-file isolation — other files' findings are + kept) and the failed file is named, counted toward the scan's unanalyzed + population. + +A configuration that silently weakens analysis is also surfaced: +`WLN-CONFIG-SANITISER-SINK-COLLISION` (a fact, not a defect) reports a +configured sanitiser that collides with a built-in serialisation sink of the +same name — the conservative sink classification takes precedence, so the +sanitiser declaration has no effect, and the diagnostic says so instead of +letting the suppression attempt pass silently. ## Configuring rules diff --git a/docs/concepts/taint-algebra.md b/docs/concepts/taint-algebra.md index f6eb60c9..839fbed8 100644 --- a/docs/concepts/taint-algebra.md +++ b/docs/concepts/taint-algebra.md @@ -154,8 +154,10 @@ reads the validator's **declared** output tier (`effective_return`, the annotation as the contract. This is sound for the statically-decidable property. A **broken** validator with -*no rejection path at all* is caught by `PY-WL-102` (it can never raise, so it -cannot validate). +*no rejection path at all* is caught by the boundary-integrity family — `PY-WL-119` +for the bare `return p` shape, `PY-WL-102` for every other no-rejection shape +(it can never raise, so it cannot validate), with `PY-WL-111` (assert-only) and +`PY-WL-113` (fail-open handler) covering the defeated-rejection variants. The **residual** — accepted, out of static reach — is a validator that **has** a rejection path but checks the **wrong predicate** (e.g. it validates length when diff --git a/docs/concepts/trust-vocabulary-convergence.md b/docs/concepts/trust-vocabulary-convergence.md index 2450a176..6b89aa6d 100644 --- a/docs/concepts/trust-vocabulary-convergence.md +++ b/docs/concepts/trust-vocabulary-convergence.md @@ -29,7 +29,7 @@ the Weft mechanism that delivers it (or the reason it is declined). | Effect / idea | Verdict | Weft mechanism (or reason) | |---|---|---| -| **Fabrication test** — a trust boundary must be able to say *no* (reject), or it isn't a boundary | **Covered** | **PY-WL-102** (`boundary_without_rejection`): a `@trust_boundary` with no rejection path is flagged — it cannot say no, so it cannot be trusted to raise trust | +| **Fabrication test** — a trust boundary must be able to say *no* (reject), or it isn't a boundary | **Covered** | the **boundary-integrity family PY-WL-102/111/113/119** (anchored by `boundary_without_rejection`): a `@trust_boundary` with no rejection path — or only an `-O`-stripped `assert`, a fail-open handler, or a bare `return p` — is flagged; it cannot say no, so it cannot be trusted to raise trust | | **Custody / provenance** — trust is earned and tracked, never assumed | **Covered** | the trust **lattice** (a value is only as trusted as its least-trusted contributor; `least_trusted` weakest-link meet) + `taint_provenance` (source + contributing callee), carried on every finding and in the dossier | | **Fail-closed boundaries** — what cannot be proven is not trusted | **Covered** | the `UNKNOWN_*` states + observable `WLN-ENGINE-*` FACTs; a custom boundary the engine cannot prove seeds `UNKNOWN_RAW` and emits `WLN-ENGINE-UNPROVABLE-BOUNDARY` (T2.4) — the extension plane inherits the no-false-green guarantee | | **Tiered boundary** — a validated boundary *raises* trust to a named tier | **Covered** | `@trust_boundary(to_level=GUARDED\|ASSURED)` — named Weft levels rather than integer tiers, the same "raise trust at a validated boundary" effect expressed in the lattice | diff --git a/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md b/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md index d9c43df7..88e9a745 100644 --- a/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md +++ b/docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md @@ -105,6 +105,23 @@ pass unchanged.** Concretely: prerequisite on the Rust core landing and must not be negotiated away under schedule pressure. +8. **`taint_path` discriminator convention (weft-4a9d0f863c).** A finding's + `taint_path` (the fifth fingerprint input) holds ONLY a source-derived + discriminator — never a resolved `TaintState` tier or `via_callee`, which drift + across builds for identical source. Call-site-anchored rules that can emit more + than one finding per `(rule_id, path, line_start, qualname)` discriminate by the + sink/callee spelling plus the call node's **full lexical span**, serialized as + `{col_offset}:{end_col_offset}`. These are CPython `ast` column coordinates: + **0-based UTF-8 byte offsets**, with a `Call` anchored at its func-expression + start (so a method chain's outer and inner calls share `col_offset` but differ in + `end_col_offset`). A second engine MUST reproduce these byte-offset column + semantics — the same obligation `entity_spans` (§3) already imposes — or the + hashed join key drifts *silently* (unlike a span diff, which the parity test + surfaces). A per-line source-order ordinal was considered as a more portable + alternative but rejected: it would require the rule to sort calls by + `(lineno, col_offset)` anyway, reintroducing the column dependency without the + span's collision-completeness. + ## Consequences - **No silent drift.** A span/fingerprint/qualname change fails the parity test diff --git a/docs/getting-started.md b/docs/getting-started.md index 176a905c..836eee88 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,7 @@ wardline --version ``` ```text -wardline, version 1.0.0rc4 +wardline, version 1.0.1 ``` ## 2. Run a first scan @@ -61,7 +61,7 @@ readability): "location": {"path": "service.py", "line_start": 7, "line_end": 8, "col_start": 0, "col_end": 26}, "message": "service.current_user declares return trust INTEGRAL but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW"}, - "suppressed": "active", + "suppression_state": "active", "suppression_reason": null, "confidence": null, "suggestion": null, diff --git a/docs/guides/agents.md b/docs/guides/agents.md index f6bddd0c..66f2aeff 100644 --- a/docs/guides/agents.md +++ b/docs/guides/agents.md @@ -54,10 +54,14 @@ wardline install: runtime markers: install `weft-markers` and import from `weft_markers` ``` -It is idempotent (re-run to refresh after upgrading wardline) and non-interactive -(safe in CI). Opt out of any piece with `--no-claude-md`, `--no-agents-md`, -`--no-skill`, `--no-mcp`, or `--no-bindings`. There is no SessionStart hook — -freshness is enforced only when you re-run `wardline install`. +It is idempotent (re-run to refresh after upgrading wardline) and +non-interactive, but it writes project-local agent and MCP files. Run it only +on a trusted checkout or as an operator-controlled bootstrap step. For +untrusted pull-request CI, use `wardline scan ... --fail-on ERROR`; do not run +`wardline install` against attacker-controlled working-tree contents. Opt out +of any piece with `--no-claude-md`, `--no-agents-md`, `--no-skill`, `--no-mcp`, +or `--no-bindings`. There is no SessionStart hook — freshness is enforced only +when you re-run `wardline install`. Once installed, the MCP server resolves a Loomweave/Filigree URL at runtime from the flag, env var, or published `.weft//ephemeral.port` rung — not from @@ -77,6 +81,14 @@ dashboard, or changing sibling tool config. It refreshes the instruction blocks, skills, and MCP entries, and re-detects siblings using the same discovery rules as `wardline install` — it never writes `weft.toml` or a sibling binding. +Over MCP, the `doctor` tool returns the same machine-readable envelope +(read-only by default; pass `repair: true` for the write-gated repair) **plus +the running server's self-identification**: package version, pid, start time, +and a source-freshness verdict. If `server.fresh` is `false`, the long-lived +MCP server process predates the on-disk wardline code — every result it serves +is stale; restart the server. Call it whenever federation writes fail or after +upgrading/editing wardline itself. + ## Gate the agent's work with `wardline scan` Wardline marks trust boundaries with marker decorators from `weft_markers`: @@ -146,15 +158,18 @@ To make the gate run on every commit, drop a `.git/hooks/pre-commit` script ```bash #!/usr/bin/env sh # Block a commit if Wardline finds a new ERROR-or-worse defect. -# Write the findings file outside the working tree so the commit stays clean. -wardline scan . --fail-on ERROR --output /tmp/wardline-findings.jsonl +# Use mktemp for the findings file; never write to a predictable shared /tmp path. +out="$(mktemp "${TMPDIR:-/tmp}/wardline-findings.XXXXXX.jsonl")" || exit 2 +trap 'rm -f "$out"' EXIT +wardline scan . --fail-on ERROR --output "$out" ``` A `scan` always writes a findings file (default `findings.jsonl` in the scan -path), so point `--output` outside the tree — as above — or at a git-ignored -path; otherwise the hook litters every commit. The script's exit code becomes -the hook's exit code: a clean tree commits, a new defect aborts the commit with -the finding already on screen for the agent to act on. +path), so point `--output` at a per-run temporary file — as above — or at a +git-ignored path inside the repository; otherwise the hook litters every commit. +Avoid predictable filenames in shared directories such as `/tmp`. The script's +exit code becomes the hook's exit code: a clean tree commits, a new defect +aborts the commit with the finding already on screen for the agent to act on. ## Let the agent triage with `wardline judge` @@ -284,3 +299,20 @@ For shell workflows, `wardline scan --format agent-summary` writes the same versioned handoff shape (`wardline-agent-summary-1`) to disk: active defects first with fingerprints and next tool calls, plus suppressed findings, engine facts, and Loomweave/Filigree write status when configured. + +## Scanning Rust + +For a Rust codebase, add `--lang rust` (install the `wardline[rust]` extra first); +over MCP, pass `lang: "rust"` to the `scan` tool — the two surfaces share the +engine and return identical findings. +It sweeps `*.rs` and flags command-injection defects (`RS-WL-108` program +injection / `RS-WL-112` shell injection) through the same gate, formats, and +emission paths as the Python frontend. Rust finding identity is frozen and +crate-prefixed, so RS-WL-* findings are **baseline-eligible** — baseline, waivers, +and judged verdicts apply exactly as for Python findings. Read the result at the +right scope: rule coverage is the command-injection slice, and `weft.toml` +severity overrides do not yet apply. Declare a function's trust tier +with a `/// @trusted(level=ASSURED|GUARDED)` doc-comment marker so the +default-clean analysis knows which functions are part of your trust surface. See +the [Rust support guide](rust-preview.md) for the boundary sources, the trust +marker, and the documented false-negative families. diff --git a/docs/guides/assurance-posture.md b/docs/guides/assurance-posture.md index 532b081f..767d0254 100644 --- a/docs/guides/assurance-posture.md +++ b/docs/guides/assurance-posture.md @@ -5,10 +5,12 @@ prior question a fail-closed tool must own: **how much of the declared trust surface did the engine reach a definite verdict on, and how much is honestly unknown?** -This is the "prove your coverage" answer an IRAP or SOC 2 assessor will ask -that a small team otherwise cannot give — and no tool lacking explicit -`UNKNOWN_*` states can replicate it, because a tool without an honesty gap has -already silently promoted every undecided case to "clean". +This is a coverage question, not a compliance claim. Wardline is +deconfliction-first tooling, not a security or compliance product, and the +posture object makes no assertion about any assessment regime. What it does +give you is the honest denominator: a tool lacking explicit `UNKNOWN_*` states +cannot answer "how much did you actually decide?", because a tool without an +honesty gap has already silently promoted every undecided case to "clean". ## The trust surface @@ -28,7 +30,9 @@ fires, and it never enters the coverage denominator. `assure` is not about ## Coverage: definite verdict vs. the honesty gap ``` -coverage_pct = 100 × (boundaries_total − unknown_count) / boundaries_total +coverage_pct = + 100 × (boundaries_total − unknown_count) + / (boundaries_total + unanalyzed_total) ``` A **definite verdict** is either: @@ -40,11 +44,15 @@ A **definite verdict** is either: The **honesty gap** is `unknown` — entities whose trust the engine could not determine. Wardline records these explicitly rather than silently passing them. +`unanalyzed_total` counts source files discovered but never analyzed; each counts +as at least one uncovered surface item because the engine could not know whether +the skipped file contained trust declarations. -When `boundaries_total == 0` (no trust annotations in the scanned path), -`coverage_pct` is `null` — no trust surface to cover means coverage is null, -never a vacuous `100.0` that reads as a false-green to an agent using a numeric -gate. The human format prints "nothing to assure" to make this explicit. +When `boundaries_total == 0` and `unanalyzed_total == 0` (no trust annotations +and no skipped source in the scanned path), `coverage_pct` is `null` — no trust +surface to cover means coverage is null, never a vacuous `100.0` that reads as a +false-green to an agent using a numeric gate. The human format prints "nothing +to assure" to make this explicit. ## The structured posture object @@ -73,6 +81,7 @@ return the same object (identical by construction — both call the same ], "engine_limited": 1, "coverage_pct": 83.3, + "unanalyzed_total": 0, "unanalyzed_rule_ids": ["WLN-ENGINE-PARSE-ERROR"], "waiver_debt": [ { @@ -91,12 +100,13 @@ return the same object (identical by construction — both call the same | Field | Type | Meaning | |---|---|---| -| `boundaries_total` | int | Count of anchored (trust-declared) entities — the denominator | +| `boundaries_total` | int | Count of known anchored (trust-declared) entities | | `proven` | int | Entities with a clean verdict (no active defect) | | `defect_total` | int | Entities with an active defect (covered — a definite negative verdict) | | `unknown` | list | Entities with no definite verdict — the honesty gap | -| `engine_limited` | int | Subset of `unknown` caused by engine under-scan (parse/recursion skip → `WLN-ENGINE-*` FACT) | -| `coverage_pct` | float \| null | `100 × (boundaries_total − unknown_count) / boundaries_total`; `null` when `boundaries_total == 0` (no trust surface → not a false-green 100%) | +| `engine_limited` | int | Unknown known entities plus unanalyzed files caused by engine under-scan (parse/recursion skip → `WLN-ENGINE-*` FACT) | +| `coverage_pct` | float \| null | `100 × (boundaries_total − unknown_count) / (boundaries_total + unanalyzed_total)`; `null` when both counts are zero (no trust surface → not a false-green 100%) | +| `unanalyzed_total` | int | Files discovered but never analyzed; each counts as at least one uncovered surface item | | `unanalyzed_rule_ids` | list[str] | Distinct `WLN-ENGINE-*` rule ids seen in findings — indicates *why* engine-limited unknowns occurred | | `waiver_debt` | list | Every waiver from `.weft/wardline/waivers.yaml`, with days-to-expiry | | `baselined_total` | int | Findings suppressed via the accepted baseline | @@ -148,6 +158,7 @@ When the scanned path contains no trust-annotated functions: "unknown": [], "engine_limited": 0, "coverage_pct": null, + "unanalyzed_total": 0, "unanalyzed_rule_ids": [], "waiver_debt": [], "baselined_total": 0, @@ -203,6 +214,9 @@ if posture["unknown"]: # May be a missing decorator or a complex data-flow pattern. report_unprovable_boundaries(unprovable) +if posture["unanalyzed_total"]: + report_parse_failures([], posture["unanalyzed_rule_ids"]) + lapsed = [w for w in posture["waiver_debt"] if w["days_left"] is not None and w["days_left"] < 0] if lapsed: # Accepted-debt waivers have expired — require re-review before merge. diff --git a/docs/guides/attestation.md b/docs/guides/attestation.md index 0d09e63e..88ac1821 100644 --- a/docs/guides/attestation.md +++ b/docs/guides/attestation.md @@ -25,8 +25,11 @@ system, a governance plugin like legis, or an agent making a deploy decision. `wardline install` mints a 64-hex signing key and appends it to `.env` at the project root as `WARDLINE_ATTEST_KEY=""`. It also ensures `.env` is listed -in `.gitignore` — the key must never be committed. This is the only setup step. -Pass `--no-attest-key` to `wardline install` if you want to skip minting. +in `.gitignore` — the key must never be committed. If `.env` is already tracked +by Git, install refuses to mint into it; untrack `.env` first or pass +`--no-attest-key` and provide the key through the environment. This is the only +setup step. Pass `--no-attest-key` to `wardline install` if you want to skip +minting. The key lookup order at run time: environment variable `WARDLINE_ATTEST_KEY` → `root/.env` line `WARDLINE_ATTEST_KEY=`. An already-set environment value diff --git a/docs/guides/legis-handoff.md b/docs/guides/legis-handoff.md index 72820e22..e8039274 100644 --- a/docs/guides/legis-handoff.md +++ b/docs/guides/legis-handoff.md @@ -21,10 +21,18 @@ signature: ```json { - "scanner_identity": "wardline@1.0.0rc1", + "scanner_identity": "wardline@1.0.1", "rule_set_version": "sha256:9f86d0…", "commit_sha": "0a4a00e…", "tree_sha": "4b825dc…", + "scan_scope": { + "schema": "wardline-legis-scan-scope-1", + "scan_root": ".", + "is_git_root": true, + "source_roots": ["."], + "resolved_source_roots": ["."], + "scanned_paths": ["src/service.py"] + }, "findings": [ … ], "artifact_signature": "hmac-sha256:v2:73eb9f0c…" } @@ -38,6 +46,9 @@ The agent posts it verbatim as the `scan` field: one [`attest`](attestation.md) signs. Two artifacts with the same value were produced under the same rules. * **`commit_sha` / `tree_sha`** — the committed revision and its tree object SHA. +* **`scan_scope`** — the signed scope binding: scan root, whether that root is the + git toplevel, configured and resolved `source_roots`, and the files actually + scanned. A signed CI artifact must have `"is_git_root": true`. * **`artifact_signature`** — `hmac-sha256:v2:` over the canonical JSON of every other field (see [Signing](#signing)). @@ -61,6 +72,12 @@ as `unverified` — the trust-the-agent posture before a key is set). `tree_sha` that does not match the scanned content is false provenance, so it is refused rather than emitted. +!!! warning "Signed artifacts are repository-root scans" + When `WARDLINE_LEGIS_ARTIFACT_KEY` is provisioned, Wardline signs only when `PATH` + is the containing git repository root. A subdirectory scan still emits an unsigned + dev artifact when requested without a key, but it cannot be presented as verified + evidence for the repository commit/tree. + !!! tip "Dev/tour loop on a dirty tree: `--allow-dirty`" Signing is clean-tree-only, but you do not need a commit to exercise the Wardline→legis handshake. Pass `--allow-dirty` (CLI) / `allow_dirty: true` (MCP diff --git a/docs/guides/rust-preview.md b/docs/guides/rust-preview.md new file mode 100644 index 00000000..f4c1557c --- /dev/null +++ b/docs/guides/rust-preview.md @@ -0,0 +1,156 @@ +# Rust support + +Wardline ships a language frontend for Rust. Point it at a tree of +`.rs` files and it flags command-injection trust-boundary defects — untrusted +data reaching the program or shell command line of `std::process::Command`. + +!!! note "Identity graduated — RS-WL-* findings are baseline-eligible" + Rust finding identity is **frozen**: qualnames are real crate-prefixed module + routes (`Cargo.toml`-aware, whole-tree) and fingerprints are stable across + releases, gated byte-for-byte by the frozen identity corpus in + `tests/golden/identity/rust/`. RS-WL-* findings participate fully in the + suppression machinery — they match committed baseline/waiver/judged entries + and are captured by `wardline baseline` like any Python finding. + +!!! note "Weft freeze-set posture" + The Rust qualname dialect is governed by Loomweave ADR-049 and the vendored + `qualnames_rust.json` corpus. It is **out of the clean-break core API freeze + set** and versions with the Rust feature, even though Weft PDR-0014 gates the + launch cutover on Rust reaching gold. Wardline must adopt ADR-049 amendments + in lockstep with Loomweave by re-vendoring the shared corpus; it must not + independently reinterpret the dialect. + +Finding identity is **keyed to the crate name**: the qualname every fingerprint +hashes starts with the crate read from `Cargo.toml` (`[package].name`, +underscored). Adding or removing a `Cargo.toml`, or renaming the crate in the +manifest, therefore **rekeys** every fingerprint under that crate — re-baseline +after such a change. For manifest-less trees the identity is path-pure (stable +under a scan-root directory rename), but adding a manifest later rekeys those +findings too. Relatedly, scanning a **subtree** of a crate (e.g. its `src/` +directory directly) loses sight of the manifest and degrades identity to the +manifest-less form — always scan from the crate root. + +!!! warning "Scope — command-injection slice; no config severity overrides yet" + Rule coverage is the **command-injection slice** (`RS-WL-108` / `RS-WL-112`) — + a Rust scan says nothing about other defect families. `weft.toml` severity + overrides do **not** apply to Rust rules yet (they carry hardcoded base + severities). The frontend is reachable from both surfaces — CLI `--lang rust` + and the MCP `scan` tool's `lang: "rust"` argument — but only on `scan`: + `baseline`, `judge`, `explain_taint`, `assure`, and the other verbs still + analyze Python only on both surfaces. + +## Running it + +The Rust frontend is gated behind the `rust` extra (tree-sitter is not a base +dependency): + +```console +$ pip install 'wardline[rust]' +``` + +Then select it with `--lang rust`: + +```console +$ wardline scan . --lang rust --fail-on ERROR +``` + +`--lang rust` sweeps `*.rs` (skipping `target/`) instead of `*.py`. Everything +else about the scan is unchanged: `--fail-on`, `--format {jsonl,sarif,agent-summary,legis}`, +`--output`, `--new-since`, Filigree/Loomweave emission, and the exit-code gate all +work exactly as they do for Python. The default (`--lang python`) is untouched. + +Over MCP, the `scan` tool takes the same selector as a `lang` argument: + +```json +{"name": "scan", "arguments": {"lang": "rust", "fail_on": "ERROR"}} +``` + +The two surfaces share `run_scan`, so an MCP Rust scan returns the same findings +and gate verdict as the CLI (pinned by a parity test). The CLI's stderr posture +banners have no MCP twin — over MCP, read the `WLN-RUST-COVERAGE` fact in the +scan payload before trusting `0 active` on a tree with no `@trusted` markers. + +## What it finds + +| Rule | Severity | What it catches | +| --- | --- | --- | +| `RS-WL-108` | ERROR | **Program injection** — untrusted data chooses the executable: `Command::new(tainted)`. An attacker controls *which* binary runs. | +| `RS-WL-112` | WARN | **Shell injection** — untrusted data reaches a `sh -c` style shell command line: `Command::new("sh").arg("-c").arg(tainted)`. An attacker can inject shell syntax. | + +De-confliction: when the program itself is tainted (108's territory), 112 stays +silent, so one boundary yields one finding. + +## The trust marker + +Like the Python frontend, Rust analysis is **default-clean**: taint flows only +from known boundary sources, and a function that declares no trust has its +findings modulated to nothing. Declare a function's trust tier with a doc-comment +marker on the line(s) above it: + +```rust +/// @trusted(level=ASSURED) +fn run_user_command() { + let prog = std::env::var("PROG").unwrap(); + std::process::Command::new(prog).output(); // RS-WL-108 (ERROR) +} +``` + +- `ASSURED` — full trust; findings fire at full severity. +- `GUARDED` — partial trust; findings are downgraded one step (ERROR → WARN). +- *(no marker)* — the function is treated as outside the trust surface and its + findings are suppressed. + +Because analysis is default-clean, a scan over a repo with **no** markers is +vacuously green. Every Rust scan therefore reports a **trust-surface coverage** +line (`trust surface: D of T function(s) declared @trusted`) and a +`WLN-RUST-COVERAGE` metric in the findings, and warns loudly when `D == 0` — so a +clean result is never mistaken for "analyzed and safe" when the truth is "nothing +was in the trust surface." + +## Boundary sources + +These standard-library calls introduce untrusted (`EXTERNAL_RAW`) data: + +`std::env::var`, `std::env::var_os`, `std::env::args`, `std::env::vars`, +`std::fs::read_to_string`, `std::fs::read`. + +A local bound directly to one of these — `let t = std::env::var("X").unwrap();` — +is tracked as tainted through stepwise (`let mut c = Command::new(t); c.output();`) +and fluent (`Command::new(t).output()?`) command construction, including the +idiomatic `?`, `.await`, `.unwrap()`, return-position, and tail-expression +terminators. + +## Known limitations (this slice) + +The frontend reports **provable** taint, not fail-closed unknowns. The following +are **known false-negative families** — deliberately out of scope for this +slice, documented so you do not mistake silence for safety: + +- **Iterator extraction of `args`/`vars`.** `env::args()`/`env::vars()` are in the + vocabulary, but multi-hop adapter chains (`.nth(1).unwrap()`, `.collect()`) are + not yet propagated. A program built from `env::args().nth(1)` may not flag. +- **`.args(vec)` is opaque.** Only per-argument `.arg(x)` calls are inspected; a + tainted element inside a `.args([...])` vector is not seen. +- **Captured `format!` interpolation.** Only direct interpolation arguments are + read. The captured form `format!("{t}")` carries no argument token and is not + tracked. +- **Cross-function and stored taint.** Analysis is per-function and flat-local: a + tainted value passed *into* another function, or stashed in a field/global, is + not followed. +- **Out-parameter sources.** `io::stdin().read_line(&mut buf)` writes through an + out-parameter the flat-local model does not track. +- **Closures and nested `fn`s.** A finding inside a closure or nested function + attributes to the enclosing named function by line; the inner scope is not + walked separately. +- **`#[path]` module attributes are not honoured.** Module routing is + `Cargo.toml`-aware (real crate names, `src/` roots, workspace members, + longest-prefix nesting), but a `#[path = "..."]` override is routed + mechanically by file path — a known gap shared with the Loomweave extractor. + +A `.rs` file that tree-sitter cannot fully parse is **not** half-analyzed: it is +surfaced as a gate-eligible `WLN-ENGINE-PARSE-ERROR` ERROR defect, counts toward +the "could not be analyzed" total, and trips the default `--fail-on ERROR` gate — +never reported as a clean result. Likewise, a single file whose analysis fails +(for example a pathologically deep expression that overflows the dataflow walk) +is isolated to a gate-eligible `WLN-ENGINE-FILE-FAILED` ERROR defect and counted +as under-scanned; it never aborts the run or loses the other files' findings. diff --git a/docs/guides/weft.md b/docs/guides/weft.md index 126e8331..0c69a0cd 100644 --- a/docs/guides/weft.md +++ b/docs/guides/weft.md @@ -5,8 +5,9 @@ load-bearing**: `wardline scan` boots, analyzes, writes findings, and gates with both siblings absent. The three output paths below are enrichment you opt into. This "additive, never load-bearing" rule is the federation's enrich-only axiom, -which is defined authoritatively in the Weft hub (`~/weft/doctrine.md` §5) — the -canonical source for the suite's roster and composition doctrine. +which is defined authoritatively in the Weft hub doctrine +([weft.foundryside.dev/#doctrine](https://weft.foundryside.dev/#doctrine) §5) — +the canonical source for the suite's roster and composition doctrine. | Path | How | Consumer | |---|---|---| @@ -52,7 +53,7 @@ Suppressed findings (baseline / waiver / judged) emit a SARIF reason as the justification. Downstream importers should preserve -`partialFingerprints["wardlineFingerprint/v1"]` as the finding's lifecycle +`partialFingerprints["wardlineFingerprint/v2"]` as the finding's lifecycle identity. If that field arrives empty or is discarded, the imported finding may still be visible as a generic SARIF result, but Filigree promotion, deduplication, and close/reopen behavior cannot rely on the same stable identity @@ -88,20 +89,31 @@ $ wardline scan . --filigree-url http://localhost:8377/api/weft/scan-results This is layered on top of the normal local output — Wardline still writes `findings.jsonl` (or your `--output`) and runs the gate; emission is additive. +Use `--local-only` (alias `--no-emit`) when a scan must stay local even though a +Filigree or Loomweave endpoint is discoverable from the environment or project +install state. The emitter is stdlib `urllib` only (no new dependency). Findings of **all** kinds are sent; each goes on the wire with `path`, `rule_id`, `message`, mapped lowercase `severity`, line range, a **top-level `fingerprint`** (Filigree's cross-run identity key), and a `metadata.wardline.*` namespace carrying qualname, kind, internal severity, and per-rule properties. +Large finding sets are chunked before upload. Wardline uses the explicit +`--filigree-max-findings-per-request` value first, then +`WARDLINE_FILIGREE_MAX_FINDINGS_PER_REQUEST`, then Filigree's advertised +scan-results limit from `/api/files/_schema` when reachable, and finally a safe +default of 1000. Chunking keeps complete file groups together whenever possible +so Filigree's `mark_unseen` reconciliation does not treat a later chunk as a fix. + The outcome split is **load-bearing for the charter guarantee**: - **Sibling absent / outage** — connection refused, timeout, or any 5xx: warn and continue. The scan proceeds to its gate; the exit code is unaffected. A Filigree outage must never make Wardline's gate load-bearing. -- **Client/protocol error** — a 4xx (or stray 3xx): loud failure. Wardline sent a - request the server rejected — a payload/config bug — so the response body is - echoed and the command exits `2`, even if findings are otherwise clean. +- **Client/protocol error** — a 4xx (or stray 3xx): warn and continue for + `wardline scan`, after the local findings and gate remain authoritative. + Wardline reports the rejected upload as failed enrichment instead of exiting + `2` before the gate verdict. - **Success** — a one-line summary reports created/updated counts plus any server-side `warnings` (Filigree reports severity coercions and line clamps there) and partial-ingest failures. diff --git a/docs/index.md b/docs/index.md index abf67db1..166796f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,13 @@ ---- -template: home.html -hide: - - navigation - - toc ---- +# Wardline + +Wardline is a lightweight semantic-tainting static analyzer for trust +boundaries. It scans Python source, includes a Rust command-injection preview, +and gives agents and CI a deterministic gate for untrusted data reaching trusted +code. + +This is the wardline documentation site — the front door for installing, +running, and integrating the tool, with the concept, guide, and reference +material behind it. ## Install @@ -17,12 +21,13 @@ Wardline ships in layers, so you only pull what you use: | --- | --- | --- | | `wardline` (base) | nothing | the analysis engine as a zero-dependency library | | `wardline[scanner]` | pyyaml, jsonschema, click | the `wardline scan` command-line tool | +| `wardline[rust]` | scanner extra, tree-sitter, tree-sitter-rust | the Rust command-injection preview frontend | The `wardline scan` CLI lives in the `scanner` extra, so install `wardline[scanner]` to run the examples below. Everything in the -[Weft integration](guides/weft.md) guide — SARIF output, the Filigree emitter, -Loomweave conformance — also ships in `scanner` (the Filigree emitter uses only -the standard library), so no further extra is required. +[Weft integration](guides/weft.md) guide — SARIF output, agent-summary output, +signed governance artifacts, native Filigree emission, and Loomweave +conformance — composes with the normal scanner path. ## 30-second example @@ -41,7 +46,7 @@ directory; the line above is the run summary. One of those findings flags a trust-boundary violation: ```json -{"rule_id": "PY-WL-101", "severity": "ERROR", "kind": "defect", "qualname": "service.current_user", "location": {"path": "service.py", "line_start": 7, "line_end": 8, "col_start": 0, "col_end": 26}, "message": "service.current_user declares return trust INTEGRAL but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW"}, "suppressed": "active"} +{"rule_id": "PY-WL-101", "severity": "ERROR", "kind": "defect", "qualname": "service.current_user", "location": {"path": "service.py", "line_start": 7, "line_end": 8, "col_start": 0, "col_end": 26}, "message": "service.current_user declares return trust INTEGRAL but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW"}, "suppression_state": "active"} ``` That is Wardline reporting that a function annotated as a trusted producer @@ -49,8 +54,24 @@ actually returns raw, untrusted data — a trust-boundary leak. The [Getting Started](getting-started.md) guide walks through this finding field by field. +## Product workflow + +1. Mark the boundary with `@external_boundary`, `@trust_boundary`, or + `@trusted`. +2. Run `wardline scan . --fail-on ERROR` locally or in CI. +3. Ask `wardline explain-taint` or the MCP `explain_taint` tool why the gate + tripped. +4. Fix the validation or normalization at the boundary and rescan. + +Agents can run the same loop through `wardline mcp` without scraping terminal +output. Use `wardline install` to add the agent guidance and MCP registration to +an application project. + ## Next steps - [Getting Started](getting-started.md) — install, run a first scan, and read a finding. - [The model](concepts/model.md) — trust tiers, boundaries, and how taint flows. +- [Rust support](guides/rust-preview.md) — command-injection preview for Rust code. +- [Weft integration](guides/weft.md) — SARIF, Filigree, Loomweave, and signed handoff paths. - [Arming agents](guides/agents.md) — using Wardline to give coding agents a trust-boundary check. +- [MCP tool reference](reference/mcp.md) — all 18 MCP tools the `wardline mcp` server serves. diff --git a/docs/integration/2026-05-29-wardline-weft-integration-brief.md b/docs/integration/2026-05-29-wardline-weft-integration-brief.md index 0192d6fd..d5828e04 100644 --- a/docs/integration/2026-05-29-wardline-weft-integration-brief.md +++ b/docs/integration/2026-05-29-wardline-weft-integration-brief.md @@ -38,6 +38,7 @@ Wardline's internal `Finding` is a pure analysis fact. It is designed as a **sup | `kind` (`defect\|fact\|classification\|metric\|suggestion`) | `metadata.wardline.kind` | | `confidence`, `related_entities` | `metadata.wardline.*` | | `properties` (per-rule extension) | `metadata.wardline.properties.*` | +| `suppressed` / `suppression_reason` (the `SuppressionState`) | `metadata.wardline.suppression_state` (`baselined\|waived\|judged`) / `metadata.wardline.suppression_reason` — carried only when not active; absent ⇒ active (see [finding-lifecycle-vocabulary.md](../reference/finding-lifecycle-vocabulary.md)) | ### The `metadata.wardline.*` namespace @@ -52,7 +53,9 @@ All Wardline-specific richness lands under a single namespaced key, preserved ve "internal_severity": "ERROR", // round-trips the 4-level original "confidence": 0.92, // optional "related_entities": [], // optional - "properties": { "cwe": "CWE-200" } // arbitrary per-rule extension + "properties": { "cwe": "CWE-200" }, // arbitrary per-rule extension + "suppression_state": "baselined", // optional; OMITTED when active (absent ⇒ active) — one of baselined|waived|judged + "suppression_reason": "in baseline" // optional; rides only a non-active suppression_state } } ``` diff --git a/docs/integration/2026-06-05-wardline-loomweave-weft-rename.md b/docs/integration/2026-06-05-wardline-loomweave-weft-rename.md index c97fc10d..fe18bdf5 100644 --- a/docs/integration/2026-06-05-wardline-loomweave-weft-rename.md +++ b/docs/integration/2026-06-05-wardline-loomweave-weft-rename.md @@ -115,7 +115,7 @@ Preserve case: `Loom`→`Weft`, `loom`→`weft`, `LOOM`→`WEFT`; `Clarion`→`L - **Out of scope (historical/working records, left as-is):** `docs/**/archive/**`, all of `docs/superpowers/**` (working specs/plans + progress tracker — internal design history, cross-linked by exact name), and point-in-time audit files - (`wardline-readonly-audit-*.md`, `AUDIT.md`). Renaming these rewrites the record + (`wardline-readonly-audit-*.md`, `docs/audits/2026-06-08-comprehensive-audit.md`). Renaming these rewrites the record for no live value and risks cross-link breakage. ## Deferred (NOT a rename — flagged, not done here) diff --git a/docs/integration/2026-06-09-loomweave-rust-qualname-dialect-reply.md b/docs/integration/2026-06-09-loomweave-rust-qualname-dialect-reply.md new file mode 100644 index 00000000..78595e0b --- /dev/null +++ b/docs/integration/2026-06-09-loomweave-rust-qualname-dialect-reply.md @@ -0,0 +1,83 @@ +# Loomweave → Wardline: Rust qualname dialect is fixed — drop the blocker + +**From:** Loomweave maintainers (Rust plugin) +**To:** Wardline maintainer (Rust frontend) +**Date:** 2026-06-09 +**Re:** `docs/superpowers/specs/2026-06-08-wardline-rust-frontend-design.md` §3.6, §6, §12 Q1 — the "Loomweave Rust entity-ID dialect unfixed" blocker. +**Full decision doc (Loomweave-side, authoritative):** `loomweave/docs/federation/2026-06-09-rust-qualname-dialect-response.md` on branch `feat/rust-plugin-spec`. + +--- + +> **UPDATE 2026-06-09 (ADR-049 amendment, Option b).** After this reply was written, Loomweave +> **amended ADR-049** ("Option b", authoritative extractor on `feat/rust-plugin-spec` @ commit +> `8adb1ee`): the **inherent-impl source-order ordinal was dropped**. Same-`(type, generic-sig)` +> inherent impls now **MERGE** into one `impl` entity (their methods all hang off one `impl#<...>` +> key, reorder-stable), so the old `Foo.impl#<>#0.bar` / `Foo.impl#<$0>#0.get` forms below are now +> `Foo.impl#<>.bar` / `Foo.impl#<$0>.get` (**no ordinal**). cfg-twin inherent impls are now split by +> an `@cfg(...)` suffix on the impl key (`Foo.impl#<>@cfg(unix)` vs `Foo.impl#<>@cfg(windows)`) — with +> the ordinal gone, `@cfg` is the **sole** distinguisher for inherent twins. Trait generic args now +> drop lifetimes *and* associated-type bindings (keep only concrete Type/Const args), omitting `<...>` +> entirely when nothing survives (`Iterator` → `impl[Iterator]`, `Trait<'a>` → `impl[Trait]`); +> positional generics count **type params only**. Wardline **re-vendored** the corpus from `8adb1ee` +> (blob `795ae03`) to `tests/conformance/qualnames_rust.json` and **conformed**. ⚠️ The Loomweave-side +> federation response doc (`docs/federation/2026-06-09-rust-qualname-dialect-response.md`) still +> describes the **OLD ordinal form** — it was **not** updated; the authoritative extractor + the +> vendored corpus are the oracle, not that doc. The inline examples below are corrected to the new form. + +--- + +## TL;DR + +**The premise is now false: Loomweave's Rust qualname dialect IS fixed.** It was settled in **ADR-049** (`loomweave/docs/loomweave/adr/ADR-049-rust-qualname-canonicalization.md`, Accepted 2026-06-08) and is emitted today by `crates/loomweave-plugin-rust`. We've landed a shared corpus and a byte-for-byte extractor parity test. **You can drop the §6.4 blocker and freeze `RS-WL-*` finding identity within the dialect** (still baseline-ineligible until your sp2 surface lands, exactly per your own §3.6 staging — nothing changes there). + +You are the **second producer** of this qualname (same role as the future rust-analyzer enrichment that ADR-049 §2 already contemplates "MUST reproduce §1's qualname byte-for-byte"). Mint the same string from your tree-sitter frontend; don't parse Loomweave's locator. + +## What changed vs. your §6 proposal — adopt these forms + +Your dialect was directionally right (dotted, `.`-delimited, crate-rooted, generics-agnostic at the definition, async-suffix-free). Four things move to **Loomweave's normative form** (we have the whole-tree view; where we're stronger, ours is authoritative): + +| Your §6 proposal | **Adopt instead (emitted by Loomweave today)** | +|---|---| +| trait method `Foo.bar:trait=Trait` (suffix) | **`Foo.impl[Display].fmt`** — an `impl[...]` path **segment**, concrete generics kept: `Foo.impl[From].from` ≠ `Foo.impl[From].from`. **Reason you must change:** `:` is the one reserved char `entity_id.rs` rejects; a `:trait=` id would be refused. `impl[...]` uses `[]<>#@`, all permitted and `.`-split-safe. | +| (no inherent disambig) | **`Foo.impl#<>.bar`** / **`Foo.impl#<$0>.get`** — positional `$i` generics (rename-stable), **no ordinal** (ADR-049 amendment, Option b). Same-`(type, generic-sig)` inherent impls **MERGE** into one `impl` entity (their methods all hang off the one `impl#<...>` key, reorder-stable); cfg-twins are split by an `@cfg(...)` suffix on the impl key. | +| closure `crate.mod.func..{closure#N}` | **Not an entity.** Loomweave never descends into fn bodies. **Drop `{closure#N}` entirely** — it's positional and churns under edits (the exact instability SEI exists to kill). Attribute a finding inside a closure to the enclosing named fn; your `line_start` already localises it. | +| nested `fn` `crate.mod.outer..inner` | **Not an entity.** Same reason — attribute to `outer`. | +| `kind = function|method` | id-kind is **`function`** for every callable (free fn, method, assoc fn). No `method` id-kind. Put the function/method semantic split in `Entity` metadata, never the qualname (you already proposed this for the trait distinction in §6.2 — extend it to method-ness). | + +Unchanged / confirmed agreements: `.` delimiter (keep your ~20 split sites), crate-rooted prefix (crate name `-`→`_`, from `Cargo.toml [package].name` read as **text**), `async fn` renders identically to `fn`, and **macro-generated items are not indexed by either engine** (syn doesn't expand; tree-sitter can't see expansion — we agree they don't exist). + +**One sharp edge on cfg-twins — it's item-GENERAL, not functions-only.** A path-colliding cfg-gated sibling gets a normalised `@cfg(pred)` suffix (predicate whitespace-stripped, `any()/all()` args sorted) for **every** emitted item kind: `fn`, **`struct`**, and inline **`mod`** alike (`S@cfg(unix)`, `inner@cfg(windows)`, not just `f@cfg(unix)`). Count per-kind (the id `kind` segment already separates `fn S` from `struct S`). Flagging this loudly because it's an easy under-implementation: we caught and fixed exactly this gap in our own extractor during this review (structs/mods were silently colliding), and a single-file frontend is just as exposed. The corpus carries a `struct_cfg_twin` row so your drift-gate trips if you miss it. + +## The corpus — vendor it + +- **Loomweave hosts** `fixtures/qualnames_rust.json` (repo root, beside `fixtures/entity_id.json`) on `feat/rust-plugin-spec`. Every `expected` value was **generated by running the extractor**, not hand-authored, and is locked by `crates/loomweave-plugin-rust/tests/qualname_conformance.rs` (byte-for-byte). +- **You vendor** a pinned copy to `tests/conformance/qualnames_rust.json` — the early format drift-gate you named in §6.4 — and reproduce `expected` from your frontend. This **inverts** the Python arrangement (you seeded `qualnames.json`, we vendored) because for Rust **our whole-tree extractor is the oracle**: the dialect is defined by what it emits, so the seed lives where it's generated. Same shape as `qualnames.json`: a `module_route` section + an `entities` section (`{name, crate, module_path, rel_path, source, reproducibility, expected:[{qualname,kind}]}`). **Your `qualnames.json` / `loomweave_qualname_parity.json` and their tests are untouched.** +- Say the word and I'll drop the vendored copy + a `test_loomweave_rust_qualname_parity.py` skeleton straight into your `tests/conformance/`; I left it as your step so I'm not editing your test tree uninvited. + +**How to compare against this corpus (don't reuse `test_qualname_conformance.py` verbatim — it'll fail).** The corpus `entities[].expected` is Loomweave's **full** emission: it includes `module` rows (file-scope + nested `mod`) that your `discover_file_entities` never emits, and its `kind` is the **id-kind** (`function` for every callable — Loomweave has no semantic `method` kind), whereas you tag impl fns `kind=method` (§6.2). So a raw `assert found == expected` fails on rows you don't produce and on the kind axis. The contract (spelled out in the corpus `_consumer_comparison` key): **(1)** the byte-exact obligation is the **`qualname`** of every non-`module` row — that's the string you fold into `fingerprint`, match it character-for-character; **(2)** `kind` is informational — your semantic `method` ↔ this corpus's id-kind `function` for impl-scoped fns (the documented boundary, like the `None ↔ ""` module boundary in `loomweave_qualname_parity.json`); **(3)** `module` rows are validated against the corpus `module_route` section, not re-emitted. Recommended parity test: extract the file, take your function/method entities, assert each `qualname` is in the case's set of non-`module` `expected` qualnames. **Do not edit the vendored copy to drop rows** — apply this comparison rule. (This mirrors the `None↔""` accommodation your existing Python parity test already makes.) + +## Reproducibility tiers — the part that matters for slice-1 + +Every corpus case is tagged **`slice-1`** or **`sp2`**: + +- **`slice-1`** — the qualname **suffix** (impl discriminator, `@cfg`, the merge of same-signature inherent impls — no ordinal, positional generics, the closure/nested-fn folding) is reproducible by your one-file pass **now**. +- **`sp2`** — needs the whole-tree view: **crate name from `Cargo.toml`**, **cross-file module route**, **`#[path]`**. The **crate-root prefix of every entity is itself sp2** until you read manifests. (Inherent-impl ordinals no longer exist as a concept post-amendment — same-signature impls merge — so the old "cross-file ordinals" sp2 item is gone.) + +So slice-1 rows are your drift-gate today; the crate-prefix and cross-file rows are your SP2 completion gate — and you never accumulate a baseline on an sp2 row that an SP2 rekey would orphan. **Known gap pinned honestly:** `#[path]` is not yet honoured by our `module_path.rs`; the `path_attr_known_gap` corpus row pins what we *actually emit today* (the mechanical file-path module), with `known_gap: true`. Both engines match the emitted form; `#[path]`-correct routing is a shared sp2 task. + +## SEI fold (your §12 Q1) — guidance, and it does NOT block + +Freezing the *dialect* is what drops your blocker; this is the separable forward question. + +- **Keep folding the qualname** into `fingerprint` (you do today). It's the only id you can reproduce single-file and it's unique+stable within a repo version. +- **Do not fold our SEI token.** You can't reproduce it — the ADR-038 token folds content signatures we mint whole-tree; a single-file frontend has no path to the same bytes. +- The cross-rename/move stability you'd want is **our** job, not your fingerprint's: the locator churns on rename by design, and our SEI matcher (carry-or-mint, keyed on the prior locator) carries the binding server-side. The resolve oracle that would let you re-key an old qualname to a carried entity is still **deferred** on our side, so cross-rename carry isn't available to either of us yet. When it ships, whether you re-key historical findings through it or accept churn-on-rename as a baseline reset is your baseline-policy call — flagging it so it's tracked, not so it gates slice 1. + +## Landed on `feat/rust-plugin-spec` + +- `docs/loomweave/adr/ADR-049-rust-qualname-canonicalization.md` — authoritative decision. +- `fixtures/qualnames_rust.json` — shared corpus (extractor-generated, reproducibility-tiered). +- `crates/loomweave-plugin-rust/tests/qualname_conformance.rs` — byte-for-byte parity test (passing; full plugin suite 41/41, fmt + clippy-pedantic clean). +- `fixtures/entity_id.json` — already carries the ADR-049 Rust rows (`impl[Display]`, `impl#<$0>`, `@cfg(unix)`). *(Post-amendment form — the inherent ordinal is dropped.)* + +Ping me to vendor the copy + parity-test skeleton into your tree whenever you want it. diff --git a/docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md b/docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md new file mode 100644 index 00000000..ac702f4e --- /dev/null +++ b/docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md @@ -0,0 +1,242 @@ +# Loomweave → Wardline: Rust qualname dialect — Phase 1b conformance change-set + +**From:** Loomweave maintainers (Rust plugin) +**To:** Wardline engineering (Rust tree-sitter frontend) +**Date:** 2026-06-09 +**Re:** Phase 1b changes to the Rust `qualified_name` dialect that Wardline's second-producer frontend (and its vendored corpus) must mirror. +**Status:** **Change-set, action required.** The Phase 1b dialect amendments are landed and emitted today by `crates/loomweave-plugin-rust` on `feat/rust-plugin-spec`. The shared corpus `fixtures/qualnames_rust.json` and the byte-for-byte parity gate `tests/qualname_conformance.rs` are amended in lockstep. Wardline must update its tree-sitter engine and re-vendor the corpus. +**Amendment (2026-06-09, self-type-args — THIRD dialect amendment):** the impl-key `` segment now carries the self type's **concrete generic arguments** (`impl Foo` → `…Foo`, `impl Foo` → `…Foo`), closing a second silent-data-loss family where `Foo` and `Foo` impls (and their trait twins) collided on a bare-`Foo` key and one like-named method was overwritten at the writer. The impl's **own** declared generic params render positionally (`…Foo<$0>`), preserving rename-stability and same-instantiation merging. This affects every impl-bearing corpus row (see new §3a and the amended `positional_generic_param`/`_renamed` rows + new `generic_self_*` rows). Wardline must re-vendor the corpus and update its frontend to fold concrete self-type args into the impl ``. +**Supersedes:** the **inherent-impl-discriminator section** of `docs/federation/2026-06-09-rust-qualname-dialect-response.md` (decision 4b and the corresponding clause of its one-paragraph reply). That letter described an inherent-impl key of `Foo.impl##` (a source-order ordinal). **Phase 1b drops the ordinal.** Everything else in the prior letter still stands; this document amends only what changed and adds the new surface (leaf kinds, the `impl` entity, edges). +**Authority:** Loomweave remains the authoritative producer for the Rust dialect (**ADR-049**, amended 2026-06-09). The corpus `expected` values are generated from the live extractor, never hand-authored; where this document and your frontend diverge, **Loomweave's emitted form is normative and Wardline conforms**. +**Version:** `ontology_version` bumped **0.1.0 → 0.4.0** (ADR-027 MINOR bumps: new entity kinds, then the `imports`/`implements` edge kinds). `plugin_id` is `rust`, so ids read `rust::` (e.g. `rust:macro:demo.m.make`, `rust:impl:demo.m.Foo.impl#<>`). + +--- + +## 1. Context — what this changes and why + +The prior letter froze the slice-1 dialect: dotted, crate-rooted, `impl[Trait]` / `impl##` discriminators, `@cfg` twins, `function` id-kind for every callable, no closure/nested-fn entities. Wardline pinned that corpus and froze `RS-WL-*` identity against it. + +Phase 1b makes four classes of change, all already emitted: + +1. **Six new leaf entity kinds** (`enum`, `trait`, `type_alias`, `const`, `static`, `macro`) plus a **macro-definition rule**. +2. **The `impl` block is now its own entity** (`kind=impl`), and methods **re-parent** onto it: containment is `module → impl → method` (was `module → method`). +3. **BREAKING: the inherent-impl source-order `#` is dropped.** Same-`(type, positional-generic-sig, cfg)` inherent impls now **merge** into one `impl` entity. This is the amendment that supersedes the prior letter's decision 4b. +4. **Two new anchored edge kinds**, `imports` and `implements`. + +Why the ordinal drop: the prior scheme called its inherent discriminator "source-order-independent" while keying it on a source-order ordinal — a self-contradiction (ADR-049 §1, amendment 2026-06-09, Option (b)). Dropping the ordinal and merging makes the discriminator genuinely **reorder-stable AND method-set-stable**: adding, removing, or reordering an inherent `impl` block of an already-seen `(type, sig, cfg)` does not churn any id. + +--- + +## 2. Change-set summary + +| # | Change | What Wardline must do | +|---|---|---| +| A | **New leaf kinds** `enum`/`trait`/`type_alias`/`const`/`static`/`macro` | Emit these item kinds as entities with id-kind = the kind name; free-item qualname `..`. | +| B | **`macro_rules! foo` is a `macro` entity** | Emit `rust:macro:..foo` for a `macro_rules!` *definition*. A bare `foo!()` *invocation* emits **nothing** (neither engine expands macros). | +| C | **`impl` block is its own entity** (`kind=impl`) | Emit one `impl` entity per impl block (post-merge), parented to its module. Trait form `….impl[]`, inherent form `….impl#`. | +| D | **Method re-parenting** | Containment is now `module → impl → method`. If you emit containment, parent impl methods to the `impl` entity, not the module. (Method **qualname** is unchanged — it already carried the impl discriminator.) | +| E | **BREAKING — inherent-impl ordinal dropped** | Stop emitting the trailing `#`. Merge same-`(type, positional-generic-sig, cfg)` inherent impls into ONE entity; union their methods under it. See §3. | +| F | **`@cfg` now splits impls** (inherent AND trait) | Render the `@cfg()` suffix on cfg-twin impl keys (`impl#<>@cfg(unix)`, `impl[Display]@cfg(windows)`). This is the SOLE discriminant once the ordinal is gone. See §3, §7. | +| F2 | **Self-type concrete generic args fold into ``** (self-type-args amendment) | Render the self type's concrete generic args in the impl ``: `impl Foo` → `…Foo`, `impl Foo` → `…Foo` (distinct impls + distinct methods). The impl's OWN declared params render positionally **only when the arg IS the param directly (top-level)**: `impl Foo` → `…Foo<$0>` (rename-stable). **A param NESTED inside another type arg is NOT positionally substituted** — `impl Foo>` → `…Foo>`, `impl Foo<&T>` → `…Foo<&T>` (rendered via `type_textual`, literal `T`, so a nested-param rename DOES churn). **Wardline MUST mirror this `type_textual` rendering of nested params — do NOT implement recursive positional substitution (`Foo>`)** or you diverge with no conformance row to catch it (a nested-param row is owed as follow-up). Concrete args whitespace-free; lifetimes/bindings dropped; non-generic self renders bare `Foo`. Same-instantiation blocks still merge. See §3a. | +| G | **`imports` edge** (anchored) | If you emit edges: resolve `use` leaves via your symbol table → anchored `imports` edge; external/unresolvable dropped. See §6. | +| H | **`implements` edge** (anchored) | Anchored edge from the trait-`impl` entity to the resolved trait; external trait dropped. See §6. | + +Changes A–F are **qualname/entity-set** changes — they are in the conformance corpus and the byte-for-byte gate WILL catch a divergence. Changes G–H are edge-shape changes (corpus is entity-only); conform to §6. + +--- + +## 3. The BREAKING change in detail — the inherent-impl ordinal drop + +**Before (frozen, what you implement today):** an inherent-impl method was `….impl##.`, the ordinal assigned by source order within the module scope (`impl#<>#0`, `impl#<>#1`, …), resetting inside nested `mod`s. Two same-signature inherent blocks produced **two distinct entities**. + +**After (Phase 1b):** the ordinal is **gone**. The key is `….impl#` with no `#`. Same-`(type, positional-generic-sig, cfg)` inherent impls **merge into one `impl` entity**; the union of their methods hangs off that single entity. + +### Before / after table + +| case | old (frozen — prior letter) | new (Phase 1b — corpus) | +|---|---|---| +| single inherent impl method | `Foo.impl#<>#0.bar` | `Foo.impl#<>.bar` | +| two same-sig inherent impls | `Foo.impl#<>#0.a` + `Foo.impl#<>#1.b` (two `impl` entities) | both under ONE `Foo.impl#<>` → `Foo.impl#<>.a` + `Foo.impl#<>.b` | +| generic inherent impl | `Foo.impl#<$0>#0.get` | `Foo.impl#<$0>.get` | +| cfg-twin inherent impl | (was ordinal-separated) | `Foo.impl#<>@cfg(unix)` / `Foo.impl#<>@cfg(windows)` (NOT merged) | + +(Qualnames above are shown without the crate/module prefix for readability; in the corpus they are fully rooted, e.g. `demo.m.Foo.impl#<>.bar`.) + +### Merge semantics — the key is the triple `(type, positional-generic-sig, cfg)` + +Two inherent `impl` blocks **merge into one `impl` entity iff** they share all three of: + +- the **target type** (already disambiguated by the module prefix — a `Foo` in `mod inner` is a different type from a top-level `Foo`); +- the **positional-generic signature** (`impl#<>` for non-generic, `impl#<$0>` for one type param, De Bruijn-positional so a ``→`` rename does not churn); +- the **cfg** (the normalised `@cfg()` suffix, or its absence). + +`cfg` is part of the key — **that is exactly why cfg-twin impls do NOT merge.** Put the reference pair side by side: + +- **`multiple_inherent_merge`** (corpus): `impl Foo { fn a }` + `impl Foo { fn b }`, no cfg → same triple → **ONE** entity `demo.m.Foo.impl#<>` carrying both `a` and `b`. +- **`inherent_impl_cfg_twin`** (corpus, new this change-set): `#[cfg(unix)] impl Foo { fn go }` + `#[cfg(windows)] impl Foo { fn go }` → cfg differs → triple differs → **TWO** entities `demo.m.Foo.impl#<>@cfg(unix)` and `demo.m.Foo.impl#<>@cfg(windows)`, each carrying its own `go`. + +If a Wardline impl-extractor omitted the `@cfg` impl suffix, the two cfg-twin blocks would collapse to one `Foo.impl#<>` and one `go` would be silently dropped (the writer's `ON CONFLICT(id) DO UPDATE` overwrite — the exact data-loss family ADR-049 exists to prevent). The new `inherent_impl_cfg_twin` / `trait_impl_cfg_twin` corpus rows are the conformance trip-wire for precisely this omission (see §7). + +**Emit ordering (matches the corpus):** the merged `impl` entity is emitted **once**, at the first contributing block in source order; later same-key blocks contribute only their methods. Reorder-stability holds because the key never depends on which block came first. + +--- + +## 3a. The self-type-args amendment (THIRD dialect amendment) — concrete self-type generics fold into `` + +**The bug (silent data loss).** The impl `` segment previously dropped the self type's generic arguments — only the bare last path-segment ident (`Foo`) was used. So `impl Foo` and `impl Foo` both rendered the bare key `…Foo.impl#<>`, and their trait twins `impl Display for Foo` / `…` both rendered `…Foo.impl[Display]`. The extraction layer's `seen_impl_ids` then **spuriously merged** the second impl into the first, re-parented both impls' methods under one id, and a like-named method (e.g. two `get`, two `fmt`) was **silently overwritten** at the writer's `ON CONFLICT(id) DO UPDATE` — the exact data-loss family this dialect exists to prevent. + +**The fix.** The self type's concrete generic arguments now fold into ``: + +| impl | `` segment | distinct? | +|---|---|---| +| `impl Foo { fn get }` | `Foo` → `…Foo.impl#<>.get` | ✅ vs `Foo` | +| `impl Foo { fn get }` | `Foo` → `…Foo.impl#<>.get` | ✅ | +| `impl Display for Foo` | `Foo` → `…Foo.impl[Display].fmt` | ✅ vs `Foo` | +| `impl Foo { fn get }` | `Foo<$0>` → `…Foo<$0>.impl#<$0>.get` | rename-stable (T→U identical) | +| `impl Foo { fn get }` (non-generic) | `Foo` → `…Foo.impl#<>.get` | bare, unchanged | + +**Rendering rules** (mirror the `@cfg`/positional conventions already in the dialect): + +- A self-type arg that is the impl's **own declared generic parameter** is rendered **positionally** (`$0`, `$1`, … the same De Bruijn scheme as the inherent `#<…>` signature), so an `impl Foo` → `impl Foo` rename does not churn. Note this changes the **prior** `positional_generic_param` corpus rows from `…Foo.impl#<$0>` to `…Foo<$0>.impl#<$0>` (the self-type prefix now also shows the `$0`). +- A **concrete** arg (`i32`, `String`, a const, a nested generic) is rendered whitespace-free (the same `type_textual` normaliser used for trait generic args), e.g. `Foo>`. +- Lifetimes and associated-type bindings are **dropped** (not part of the locator). +- A non-generic self type renders the **bare** name (`Foo`), unchanged from before. +- **Composition order:** `.|impl[Trait]>[@cfg(...)]` — self-type args first, then the inherent/trait discriminant, then any cfg suffix. + +**Merge semantics now key on the self-type args too.** The merge triple is `(self-type-with-concrete-args, positional-generic-sig, cfg)`. Two blocks on the **same** instantiation still merge: + +- **`generic_self_same_concrete_two_blocks_merge`** (corpus, new): `impl Foo { fn a }` + `impl Foo { fn b }` → same triple → **ONE** entity `demo.m.Foo.impl#<>` carrying both `a` and `b`. +- **`generic_self_inherent_concrete_args`** (corpus, new): `impl Foo { fn get }` + `impl Foo { fn get }` → self-type args differ → **TWO** entities, each with its own `get`. A frontend that dropped the self-type args would collapse these to one and silently drop one `get`. +- **`generic_self_trait_concrete_args`** (corpus, new): the trait twin of the above. + +These three new rows plus the amended `positional_generic_param`/`positional_generic_param_renamed` rows are the conformance trip-wire for this amendment. + +--- + +## 4. The `impl` entity + method re-parenting + +**The `impl` block is now its own entity**, `kind=impl`, with these key forms: + +- **Trait impl:** `….impl[]` — e.g. `Foo.impl[Display]`, `Foo.impl[From]`, `Foo.impl[Display]`. Lifetimes dropped; concrete type/const generic args **kept** in BOTH the self-type prefix and the trait fragment (`From` ≠ `From`; `Foo` ≠ `Foo` — see §3a). +- **Inherent impl:** `….impl#` — e.g. `Foo.impl#<>`, `Foo.impl#<$0>`, `Foo.impl#<>`. No ordinal (§3); concrete self-type args fold into `` (§3a). + +**Containment is now `module → impl → method`** (was `module → method`): + +- the `impl` entity is parented to the enclosing **module** (`module → impl`); +- each impl method is re-parented onto the **`impl` entity** (`impl → method`), NOT the module. + +The method **qualname is unchanged** — it already carried the impl discriminator (`Foo.impl[Display].fmt`, `Foo.impl#<>.bar`), so the re-parent does not churn any method id. **What changes for Wardline:** (a) you must emit the `impl` row itself (the corpus `expected` lists it — see §7), and (b) if you emit containment, the method's parent is the impl entity. + +Corpus reference rows: `inherent_method`, `trait_method`, `trait_method_collision` (Display::fmt vs Debug::fmt stay distinct via the impl segment), `generic_trait_impl_concrete_args`, `positional_generic_param` (+ `_renamed` for rename-stability). + +--- + +## 5. The new leaf kinds + the macro rule + +Six new free-item kinds, each emitted with id-kind = the kind name and free-item qualname `..`: + +`enum`, `trait`, `type_alias`, `const`, `static`, `macro`. + +(`trait` here is the trait *definition* item — distinct from `impl[Trait]`, which is a discriminator on an impl entity.) + +**Macro rule (corpus `macro_invocation_generates_no_entity`):** + +- `macro_rules! foo { … }` — the **definition** is a `macro` entity → `rust:macro:..foo`. +- `foo!()` — a bare **invocation** emits **NOTHING**. Neither engine expands macros (`syn` does not expand; tree-sitter cannot see expansion), so the compile-time-generated items do not exist for either producer. Both engines must agree they do not exist. + +The full contract-vetted id-kind set is now exactly these ten (mirrors `plugin.toml entity_kinds`, and the corpus `corpus_kinds_are_known` guard): `module`, `struct`, `function`, `enum`, `trait`, `type_alias`, `const`, `static`, `macro`, `impl`. There is still **no `method` id-kind** — every callable (free fn, inherent method, assoc fn, trait method) is id-kind `function`. + +--- + +## 6. The new edges — `imports` and `implements` + +Both are **anchored** (ADR-026 decision 3): they carry the source byte span, so they may **never** be `inferred` confidence. Both are **resolved-or-dropped**: an unresolvable / external target yields no edge (never a dangling edge). On the Loomweave side both are additionally subject to the host **seen-entity-set gate** — an edge whose target entity was not stored (e.g. gitignored, out-of-`src`) is dropped at the host, so an emitted edge can still be gated away if its target never landed. Wire shape (the host deserialises into `RawEdge`): + +```json +{ "kind": "imports", "from_id": "", "to_id": "", "source_byte_start": , "source_byte_end": , "confidence": "resolved" | "ambiguous" } +{ "kind": "implements", "from_id": "", "to_id": "", "source_byte_start": , "source_byte_end": , "confidence": "resolved" | "ambiguous" } +``` + +**`imports`** — one per file-scope `use` leaf, resolved against the project symbol table: + +- `from_id` is the enclosing **module** entity (a file-scope `use` is a module property), NOT the crate root. +- a unique in-project target → `confidence: "resolved"`; a glob / multi-kind candidate → `confidence: "ambiguous"`; an external or unresolvable path → **NOTHING** (external dropped, D1). +- the byte span anchors the `use` statement. + +**`implements`** — one per resolved trait impl: + +- `from_id` is the trait-**`impl` entity** (`….impl[]`); `to_id` is the resolved trait entity. +- the byte span anchors the **implemented-trait path** (the `Tr` in `impl Tr for Foo`), not the whole impl block. +- a unique in-project trait → `resolved`; a multi-kind candidate → `ambiguous`; an external trait → **no edge** (dropped at emit). + +The shared corpus is **entity-only** — it does not pin edges. The contract for edges is this section; conform to the shape and the resolved-or-dropped / anchored-never-inferred rules. + +--- + +## 7. The conformance corpus + +The authoritative corpus is **Loomweave-hosted** at `fixtures/qualnames_rust.json` on `feat/rust-plugin-spec`, generated from the live extractor and parity-tested by `crates/loomweave-plugin-rust/tests/qualname_conformance.rs`. **Wardline re-vendors a pinned copy to `tests/conformance/qualnames_rust.json`** and reproduces `expected` byte-for-byte; on any Loomweave dialect change, re-copy verbatim and let your conformance test fail loudly — fix the producer or resync, never edit the vendored copy silently. + +**New cases this change-set (Phase 1b), reproducibility `slice-1`:** + +- **`inherent_impl_cfg_twin`** — `struct Foo; #[cfg(unix)] impl Foo { fn go } #[cfg(windows)] impl Foo { fn go }` → + - `demo.m.Foo.impl#<>@cfg(unix)` (impl), `demo.m.Foo.impl#<>@cfg(unix).go` (function) + - `demo.m.Foo.impl#<>@cfg(windows)` (impl), `demo.m.Foo.impl#<>@cfg(windows).go` (function) +- **`trait_impl_cfg_twin`** — `struct Foo; #[cfg(unix)] impl Display for Foo { fn fmt } #[cfg(windows)] impl Display for Foo { fn fmt }` → + - `demo.m.Foo.impl[Display]@cfg(unix)` (impl), `demo.m.Foo.impl[Display]@cfg(unix).fmt` (function) + - `demo.m.Foo.impl[Display]@cfg(windows)` (impl), `demo.m.Foo.impl[Display]@cfg(windows).fmt` (function) + +Both also carry the `demo.m` module row and the `demo.m.Foo` struct row. These two cases close a coverage gap: before them, every `@cfg` corpus row was a **free-item** twin (`f@cfg`, `S@cfg`), so the gate never exercised `@cfg`-on-an-impl-key rendering — a Wardline impl-extractor that omitted the `@cfg` impl suffix would have **passed** conformance while silently merging cfg-twin impls. Note where the suffix lands: **after** the impl discriminator (`impl#<>@cfg(unix)`, `impl[Display]@cfg(unix)`), and the method appends **after that** (`impl#<>@cfg(unix).go`). + +**The two comparison rules (unchanged, restated):** + +1. **`entities_match_byte_for_byte` — full-set rule.** The corpus `expected` is Loomweave's FULL emission in source order, including `module` rows and `impl` rows. The byte-exact obligation is the `qualname` of every NON-`module`, NON-`impl` row (the string folded into the fingerprint); it must match character-for-character. +2. **Subset-consumer rule.** A method-only / function-only consumer (Wardline's `discover_file_entities`, where modules and impl blocks are scope-only and §6.2 tags impl fns `kind=method`) MUST NOT list-equality-compare against `expected`. Instead: take your function/method entities and assert each produces a `qualname` present in the case's non-`module`/non-`impl` `expected` qualnames. `module` rows validate against the `module_route` section; `impl` rows are scope entities a method-only consumer skips exactly as it skips `module` rows. `kind` is informational (the locator id-kind) — map your semantic `method` → corpus `function`, or compare qualname-only. Do not edit the vendored copy to drop rows; apply this comparison rule. + +--- + +## 8. Tree-sitter engine guidance + +Concrete steps to conform your frontend: + +**(a) Drop the trailing `#`.** Remove the source-order ordinal from the inherent-impl key entirely. The inherent key is now `….impl#` with nothing after the `>`. + +**(b) Merge same-key inherent impls.** Key each inherent `impl` block by the triple `(target-type-path, positional-generic-sig, normalised-cfg)`. Blocks sharing a key produce **one** `impl` entity (emitted at the first block in source order); accumulate methods from all same-key blocks under it. Do not assign per-block ids. + +**(c) Render `@cfg()` on cfg-twin impls.** When two impls of the same `(type, sig)` are gated on different cfgs, append the `@cfg()` suffix to the impl key — inherent → `impl#<>@cfg(unix)`, trait → `impl[Display]@cfg(unix)` — and let each method inherit it (`…@cfg(unix).go`). **The `@cfg` predicate normalisation must match Loomweave's exactly:** predicate whitespace-stripped, `any()`/`all()` args sorted. The corpus cfg rows (`cfg_twin`, `struct_cfg_twin`, and now `inherent_impl_cfg_twin` / `trait_impl_cfg_twin`) are the reference rendering — diff against them. Apply `@cfg` to **every** emitted item kind that can twin (free fn, struct, inline mod, AND impls), not just `fn`. + +**(d) Emit the `impl` entity + re-parent methods.** Emit one `impl` row per impl block (post-merge) with the key from (a)/(b)/(c). Parent it to the enclosing module; parent each impl method to the `impl` entity (`module → impl → method`). Method qualnames are unchanged. + +**(e) Add the leaf/macro kinds.** Emit `enum`/`trait`/`type_alias`/`const`/`static`/`macro` as free-item entities. `macro_rules! foo` → a `macro` entity; a bare `foo!()` invocation emits nothing. + +**(f) Edges (if you emit them).** Conform to §6: anchored, byte-span-carrying, resolved-or-dropped; `imports` from the module entity, `implements` from the trait-impl entity; never `inferred`. + +After these, run your vendored-corpus conformance test (re-vendor first) and the subset-consumer parity (§7 rule 2). + +--- + +## 9. What is UNCHANGED + +The bulk of the frozen dialect is stable — Phase 1b is additive plus the one ordinal-drop amendment. Unchanged: + +- **`.`-delimited, crate-rooted** path; crate name `-`→`_` normalised, from `Cargo.toml [package].name` read as text (still **sp2** for you — needs manifest reads). +- **Module routing:** lib.rs/main.rs/mod.rs contribute no segment; inline `mod foo {}` nests; `#[path]` still NOT honoured (KNOWN-GAP, the `path_attr_known_gap` corpus row pins the emitted-today behaviour). +- **The `impl[Trait]` trait-impl form** — unchanged (trait last segment, concrete generics kept, lifetimes dropped, `From` ≠ `From`). +- **Methods carry their impl's discriminator** (`Foo.impl[Display].fmt` ≠ `Foo.impl[Debug].fmt`) — unchanged; the re-parent is a containment change only. +- **Positional De Bruijn generics** (`impl#<$0>`, rename-stable) — unchanged. +- **`@cfg` predicate normalisation** (whitespace-stripped, args sorted, per-kind) — unchanged; Phase 1b only **extends** where it applies (now impls too). +- **Closures and nested `fn` items are NOT entities** — the extractor never descends into bodies; attribute body-local findings to the nearest enclosing named item. Unchanged (corpus `closure_is_not_an_entity`, `nested_fn_is_not_an_entity`). +- **`function` id-kind for every callable** — no `method` id-kind; a semantic function/method split rides Entity metadata, not the locator. Unchanged. +- **`async fn` renders identically to `fn`** (no suffix). Unchanged. +- **SEI fold = qualname only** — keep folding the qualname into your fingerprint; do not attempt to fold the ADR-038 SEI token (unreproducible single-file). Cross-rename carry remains Loomweave's server-side SEI matcher + the deferred resolve oracle. Unchanged. +- **Reproducibility tiers** (`slice-1` / `sp2`) — unchanged; all Phase 1b corpus cases (incl. the two new ones) are `slice-1` modulo the shared crate-root-prefix sp2 caveat. + +--- + +## Loomweave-side landed artifacts (`feat/rust-plugin-spec`) + +- `docs/loomweave/adr/ADR-049-rust-qualname-canonicalization.md` — authoritative decision, amended 2026-06-09 (§1 ordinal drop, Option (b)). +- `fixtures/qualnames_rust.json` — shared corpus, generated from the live extractor; carries the Phase 1b `impl` entity rows, the merge case, and the two new `inherent_impl_cfg_twin` / `trait_impl_cfg_twin` cfg-twin-impl cases. +- `crates/loomweave-plugin-rust/tests/qualname_conformance.rs` — byte-for-byte parity gate (`entities_match_byte_for_byte`, `module_routes_match_byte_for_byte`, `corpus_kinds_are_known`). +- `crates/loomweave-plugin-rust/plugin.toml` — `ontology_version = "0.4.0"`, `entity_kinds` = the ten kinds, `edge_kinds = ["contains", "imports", "implements"]`. +- `docs/federation/2026-06-09-rust-qualname-dialect-response.md` — the prior letter, left intact; this document supersedes only its inherent-impl-discriminator section. diff --git a/docs/integration/2026-06-10-wardline-loomweave-rust-qualname-amendment-requests.md b/docs/integration/2026-06-10-wardline-loomweave-rust-qualname-amendment-requests.md new file mode 100644 index 00000000..cec7f7ce --- /dev/null +++ b/docs/integration/2026-06-10-wardline-loomweave-rust-qualname-amendment-requests.md @@ -0,0 +1,108 @@ +# Wardline → Loomweave: Rust qualname dialect — two ADR-049 amendment requests + one closed tail + +**From:** Wardline engineering (Rust tree-sitter frontend, second ADR-049 producer) +**To:** Loomweave maintainers (Rust plugin) +**Date:** 2026-06-10 +**Re:** Two un-decided dialect gaps both producers share — (1) reserved-colon path-typed generic args, (2) const-generic-arg spacing — plus the record of the amend-3 corpus tail your `rc4` now carries. +**Status:** **SIGNED OFF — Wardline owner (john@foundryside.dev), 2026-06-10. ADR-049 amended same day (loomweave `rc4`, amendment 4: one shared `escape_reserved(strip_ws(arg))` pipeline for every concrete generic argument, type or const). Implementation + the three corpus rows land next sprint; both producers conform in lockstep once the rows exist (`wardline-be5ee9cc34`, const-spacing half of `wardline-e8f7c0508f`, blocker `wardline-e3e9e109ba`).** + +--- + +## 0. Why this letter + +Your Phase 1b change-set (`docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md`) and the corpus rows landed since put both producers byte-for-byte on every *decided* surface. Two surfaces remain **undecided** — not "Wardline diverges from the oracle" but "the oracle has no good output either." Per ADR-049's authority model, the decision is yours; per the second-producer discipline, we will not normalize unilaterally. This letter proposes the decision, names the alternatives we rejected, and requests the corpus rows that would gate both producers' conformance. + +Nothing here is implemented on the Wardline side, and nothing should be implemented on the Loomweave side, until (a) the Wardline owner signs this off, (b) ADR-049 §2 is amended, and (c) the corpus rows exist. Tracked Wardline-side as `wardline-be5ee9cc34` (§1) and the const-spacing half of `wardline-e8f7c0508f` (§2), both blocked on this decision. + +--- + +## 1. Reserved-colon path-typed generic args — the decision request + +### The shared defect (both endpoints are bad) + +A trait or self-type concrete generic argument that is itself a `::`-path mints a locator segment containing the dialect's one reserved character: + +```rust +impl From for Foo { fn from(e: std::io::Error) -> Self { … } } +``` + +- **Loomweave:** `trait_generic_args` → `type_textual` → `strip_ws` (qualname.rs:158-172, :258-269) renders the arg as `std::io::Error`, so the assembled locator is `…Foo.impl[From]` — colon-bearing. `entity_id()` then rejects it (`validate_no_colon`, entity_id.rs:140-148, invoked at :102), `build_id` maps the `EntityIdError` into a `syn::Error` (extract.rs:861-865), and `degraded_aware` (extract.rs:232-274) collapses the **whole cleanly-parsed file** to a single `syntax_error` module plus one Warning finding. One ubiquitous impl erases every entity in its file. +- **Wardline:** our tree-sitter frontend renders the byte-identical `From` segment and emits it **un-gated** — a locator that violates the dialect's own reserved-separator invariant and that your host would refuse. + +So today the dialect has *no canonical colon-free form* for this construct, and the construct is everywhere in real Rust (`From`, `TryFrom`, `From`, `AsRef`). The vendored corpus only exercises single-segment generic args (`From`, `From`), so neither producer's conformance gate can see the gap. + +### The proposal: extend `escape_reserved` to generic-arg rendering + +ADR-049 already owns an injective reserved-char escape — `escape_reserved` (qualname.rs:314-317): `%` → `%25` **first**, then `:` → `%3A` — applied today to cfg predicates inside `normalise_pred` (qualname.rs:319-336), and now corpus-pinned by `cfg_escape_reserved_char`. We propose applying **the same function** to the **full `type_textual` rendering of each concrete generic argument**, in **both** places that rendering reaches the locator: + +| construct | today (rejected / un-gated) | proposed canonical form | +|---|---|---| +| trait fragment | `Foo.impl[From]` | `Foo.impl[From]` | +| self-type prefix | `Foo.impl#<>` | `Foo.impl#<>` | +| composed | `Foo.impl[From].from` | `Foo.impl[From].from` | + +Precisely: wherever a concrete generic argument is rendered for the locator — the `GenericArgument::Type` arms of `trait_generic_args` (qualname.rs:165) and `self_ty_locator`/`self_ty_arg` (qualname.rs:222, :250) — the rendered string becomes `escape_reserved(type_textual(ty))`. Nested args are covered for free because the escape runs over the full rendered text (`Vec` → `Vec`), as are qself paths (`::Item`). The dialect's structural characters `[ ] # < > @ $ , &` are untouched — they are already legal id bytes; only `%` and `:` rewrite. + +Properties: + +- **Collision-free.** Unlike last-segment truncation, distinct paths stay distinct: `From` ≠ `From` (`…io%3A%3AError` vs `…fmt%3A%3AError`). +- **Injective, hence reversible.** Because `%` is escaped before `:` (the same ordering argument documented at qualname.rs:308-313), a literal `%3A` in source type text cannot alias an escaped `::`; the original rendering is recoverable byte-for-byte. +- **One normalizer, already shared.** It is the SAME `escape_reserved` both engines already mirror for cfg predicates (Wardline's copy is gated by your `cfg_escape_reserved_char` row as of rc4 `a209fc7`). No new escape grammar enters the dialect. +- **Rename-stability unchanged.** Positional params (`$0`) never carry `:`; only concrete instantiations are affected, and they were already churn-on-edit by design. +- **`implements`-edge resolution unaffected.** Trait lookup strips generic args before resolving (extract.rs:788-794), so the escape never reaches the resolver. + +### Rejected alternatives (so the ADR can record them) + +1. **Last-segment truncation** (`From` → `From`): collides `io::Error` with `fmt::Error` — re-opening exactly the silent-data-loss family (writer `ON CONFLICT(id) DO UPDATE` overwrite) that the self-type-args amendment just closed. Rejected. +2. **Keep the degrade-whole-file behavior as canonical:** one `impl From` erases every entity in an otherwise clean file — and Wardline mirroring it would mean a single-file frontend discarding a whole file's findings surface over one impl. Disproportionate, and it leaves the dialect with no rendering at all for a ubiquitous construct. Rejected. +3. **Wardline-only normalization:** breaks the byte-for-byte parity that is the second-producer arrangement's entire reason to exist. Rejected without discussion. + +### Requested artifacts + +- **ADR-049 §2 amendment** specifying `escape_reserved` over the `type_textual` rendering of concrete generic args in both the trait fragment and the self-type prefix. +- **Two corpus rows** (extractor-generated as always; suggested sources): + - **`path_typed_generic_arg_inherent`** — `struct Foo(T); impl Foo { pub fn get(&self) {} }` → self-type prefix escape: `demo.m.Foo.impl#<>` (+ `.get`). + - **`path_typed_generic_arg_trait`** — `struct Foo; impl From for Foo { fn from(_: std::io::Error) -> Self { Foo } }` → trait-fragment escape: `demo.m.Foo.impl[From]` (+ `.from`). + +Both producers then conform in lockstep against the rows; Wardline re-vendors and implements only after they land. + +--- + +## 2. Const-generic-arg spacing — converge on `strip_ws` + +### The divergence + +Const generic arguments are the one place the oracle's rendering is **not** whitespace-free: both `trait_generic_args` (qualname.rs:166-168) and `self_ty_locator` (qualname.rs:223-225) render `GenericArgument::Const` via `to_token_stream().to_string()` — proc-macro2 canonical spacing — **without** the `strip_ws` pass every `Type` arg gets. So a multi-token const arg renders spaced: + +| source | oracle today | Wardline today | +|---|---|---| +| `Foo<{ N + 1 }>` | `Foo<{ N + 1 }>` | `Foo<{N+1}>` | +| `Foo<-1>` | `Foo<- 1>` | `Foo<-1>` | +| `Foo<3>` | `Foo<3>` | `Foo<3>` (already matches) | + +Wardline cannot faithfully match the spaced form without reimplementing proc-macro2's token-spacing algorithm inside a tree-sitter frontend — the same impossibility class as the SEI token. And the spaced form is the odd one out in a dialect whose own normalizer comment calls `strip_ws` "the crate's one path/type normaliser" (qualname.rs:253-257). + +### The proposal + +Route `Const` args through the same `strip_ws` as `Type` args (in both call sites), converging the dialect on the whitespace-free form — which is also embedded whitespace's only sane treatment in a locator. Plain const args (`Foo<3>`, `Foo`, bare const-param idents) are byte-identical under both renderings, so the blast radius is multi-token const expressions only — currently un-oracled, so no corpus row churns. + +Note the composition with §1: a const expression can also carry `::` (`Foo<{ usize::MAX }>`), so the §1 escape should apply to the const rendering after `strip_ws` — i.e. one shared pipeline `escape_reserved(strip_ws(arg))` for every concrete generic argument, type or const. + +### Requested artifact + +- **One corpus row** (suggested name **`const_generic_arg_spacing`**), e.g. `struct Foo; impl Foo<{ 1 + 2 }> { pub fn get(&self) {} }` → `demo.m.Foo<{1+2}>.impl#<>` (+ `.get`) — pinning the stripped form in the self-type prefix. (If you prefer, a trait twin `impl From> …` would pin the trait fragment too; one row is enough to gate the normalizer.) + +--- + +## 3. Record: the amend-3 corpus tail is CLOSED (no action needed) + +For completeness against the open list in `wardline-e8f7c0508f` / the amendment-3 fidelity review — these are **done**, landed on your `rc4` and already vendored + conformed on our side: + +- **Loomweave rc4 commit `a209fc7603b09bb06564e09c8c99390d410ea5b2`** ("test(plugin-rust): pin leaf kinds, stacked-cfg fold, cfg reserved-char escape, leaf-kind cfg twin with corpus rows", fixture blob `56cba0fe2d6c449ebc841c52a2800368b2e389e4`) added four extractor-verified rows to `fixtures/qualnames_rust.json`: + - **`leaf_item_kinds`** — the five previously un-oracled free-item kinds (`enum`/`trait`/`type_alias`/`const`/`static`); + - **`stacked_cfg_twin`** — the all-cfg fold (`impl#<>@cfg(feature="a"&unix)`), pinning `cfg_discriminant`'s sort-and-join over EVERY stacked predicate; + - **`cfg_escape_reserved_char`** — `feature="a:b"` → `f@cfg(feature="a%3Ab")`, pinning `escape_reserved` on the cfg path (the precedent §1 extends); + - **`leaf_kind_cfg_twin`** — per-(kind, name) twin counting on a leaf kind (`LIMIT@cfg(unix)` / `LIMIT@cfg(windows)`). +- The owed **nested-param row** (`generic_self_nested_param`, the F2 follow-up from the Phase 1b change-set) was already present upstream before that commit and is likewise vendored. + +That closes every item of the amend-3 tail except the two decisions requested above. Once ADR-049 is amended and the §1/§2 rows land, ping us; Wardline re-vendors, conforms byte-for-byte, and `wardline-be5ee9cc34` / the spacing half of `wardline-e8f7c0508f` close in lockstep. diff --git a/docs/integration/2026-06-11-wardline-resolve-plugin-hint-proposal.md b/docs/integration/2026-06-11-wardline-resolve-plugin-hint-proposal.md new file mode 100644 index 00000000..40c0318b --- /dev/null +++ b/docs/integration/2026-06-11-wardline-resolve-plugin-hint-proposal.md @@ -0,0 +1,72 @@ +# Wardline → Loomweave: `ResolveRequest` plugin-hint — proposed contract shape + +**From:** Wardline engineering +**To:** Loomweave maintainers (federation resolver, ADR-036) +**Date:** 2026-06-11 +**Re:** §7 of the Amendments 6–9 changeset letter +(`loomweave docs/federation/2026-06-11-rust-qualname-amendment-6-9-changeset.md`): +the resolver is now plugin-aware (`clarion-69db8b2739`, ADR-036 amended +2026-06-11) and a qualname owned by more than one plugin resolves `Ambiguous`, +which the federation wire degrades to `unresolved`. The agreed disambiguator is +a **plugin-hint field on `ResolveRequest`**. Loomweave asked Wardline to agree +the shape in the same escalation-gated exchange as the 4–9 re-vendor; this is +that agreement, from the consumer side. Loomweave owns the normative resolver +semantics (ADR-036) — where this proposal and the implemented endpoint diverge, +the endpoint + its conformance fixtures are the contract. + +## Why a hint, not inference + +A Rust free-function locator (`crate.module.func`) is byte-ambiguous with a +Python qualname — no locator-dialect sniffing can attribute it. But Wardline +always knows which frontend minted a finding (`--lang` selects the analyzer; +findings carry `lang`), and both Wardline call sites of +`POST /api/wardline/resolve` — dossier source resolution and the +Filigree entity-association bridge — resolve qualnames taken from a finding. +The producer knows; the wire should carry it. + +## Proposed shape + +```json +POST /api/wardline/resolve +{ + "project": "…", + "qualnames": ["demo.m.func", "…"], + "plugin": "rust" +} +``` + +- **`plugin`** — OPTIONAL string; values are ADR-049 plugin ids exactly as they + appear in entity ids (`python`, `rust`). **Batch-scoped** (one hint per + request, not per qualname): every Wardline resolve batch is minted by exactly + one frontend, so a per-row field would only invite mixed batches no producer + sends. A consumer with mixed-language qualnames sends one request per plugin. +- **Semantics with the hint:** resolution is restricted to the named plugin's + namespace. Unique match → resolved; no match in that plugin → `unresolved` + (even if another plugin owns the qualname — the hint is a constraint, never a + preference order); ambiguous *within* one plugin → `unresolved` (fail-closed; + post-gold this is `duplicate_ids() = 0` territory, but the wire must not + guess if it recurs). +- **Semantics without the hint:** exactly today's behavior — cross-plugin + lookup, `Ambiguous` degrades to `unresolved`. Omission stays legal forever + (a consumer that genuinely does not know the language must not be forced to + fabricate a hint). +- **Unknown plugin value:** `unresolved` for the whole batch (or a 400 naming + the field — Loomweave's call; Wardline only ever sends ids it produces). + +## Rollout (deliberate, coordinated — `deny_unknown_fields` forces ordering) + +1. **Loomweave first:** accept + honor `plugin` on `ResolveRequest` (the struct + is `#[serde(deny_unknown_fields)]`, so Wardline cannot send a byte before + this lands). Reject-with-a-message that NAMES the field if it ever 400s, for + cross-version diagnosability. +2. **Wardline second (tracked, see ticket below):** thread the producing + plugin from the finding's `lang` through `LoomweaveClient.resolve()` at both + call sites, and treat a resolve 4xx as fail-soft `unresolved` (an old server + must degrade the dossier/association, not crash it). +3. **Conformance:** three fixture rows ride the resolver's conformance surface + and Wardline's vendored oracle when the field ships — hinted-hit, + hinted-miss (qualname owned by the *other* plugin only), and + unhinted-ambiguous → `unresolved`. + +No `ontology_version` interaction; no change to `resolved`/`unresolved` +response shape; SEIs stay opaque to Wardline throughout. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index aea1c0c9..ac01c1b0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,6 +1,6 @@ # CLI reference -Complete reference for the `wardline` command-line interface, version `1.0.0rc4`. +Complete reference for the `wardline` command-line interface, version `1.0.1`. Every `--help` block below is the verbatim output of the installed CLI; every example is a realistic invocation. @@ -42,21 +42,31 @@ Options: --help Show this message and exit. Commands: - baseline Manage the finding baseline (.weft/wardline/baseline.yaml). - decorator-coverage - List every Wardline trust-decorated entity under PATH. - file-finding - File the finding identified by FINGERPRINT into a tracked... - judge Triage active DEFECTs with the opt-in LLM judge. - scan Scan PATH for findings. - vocab Emit the NG-25 trust-vocabulary descriptor as YAML... + assure Report the trust-surface coverage posture for PATH. + attest Build a signed evidence bundle for PATH (or verify... + baseline Manage the finding baseline... + decorator-coverage List every Wardline trust-decorated entity under PATH. + doctor Check Wardline agent install artifacts and sibling... + dossier Assemble the one-call dossier for ENTITY (a... + explain-taint Explain ONE finding's taint provenance by... + file-finding File the finding identified by FINGERPRINT into a... + findings Scan PATH and print filtered findings as JSONL... + fix Scan PATH and apply autofixes interactively. + install Install wardline's agent-facing guidance and... + judge Triage active DEFECTs with the opt-in LLM judge. + lsp Run the Wardline LSP diagnostics server over stdio... + mcp Run the Wardline MCP server over stdio (JSON-RPC 2.0). + rekey Re-key baseline/waiver/judge verdicts across a... + scan Scan PATH for findings. + scan-file-findings Run the agent workflow from scan to optionally... + vocab Emit the NG-25 trust-vocabulary descriptor as YAML... ``` Check the installed version: ```text $ wardline --version -wardline, version 1.0.0rc4 +wardline, version 1.0.1 ``` Use `--version` in CI before a scan to pin the toolchain in your build log; the @@ -76,10 +86,13 @@ Options: --config FILE Path to a weft.toml whose [wardline] table supplies configuration overrides (weft.toml). --format [jsonl|sarif|agent-summary|legis] + --lang [python|rust] Language frontend. 'rust' (PREVIEW) scans + .rs files for command-injection findings. --output PATH --fail-on [CRITICAL|ERROR|WARN|INFO] - --cache-dir PATH Persist L3 summary cache here for faster - incremental scans. + --cache-dir PATH Store authenticated L3 summary-cache entries + here for faster incremental scans when + WARDLINE_SUMMARY_CACHE_KEY is set. --filigree-url TEXT POST findings to this Filigree Weft scan- results URL (opt-in). --help Show this message and exit. @@ -91,11 +104,14 @@ it at a package root, not a single file. | Option | Effect | | --- | --- | | `--config FILE` | Path to a `weft.toml` config file; Wardline reads its `[wardline]` table for rule enable/severity and judge settings (defaults to `weft.toml` in the scan path). | -| `--format [jsonl\|sarif\|agent-summary\|legis]` | Output shape. `jsonl` is one finding per line; `sarif` is SARIF 2.1.0 for GitHub code-scanning and other generic SARIF consumers; `agent-summary` is stable versioned JSON for agents (`schema: wardline-agent-summary-1`) with active defects first, suppressed findings, engine facts, integration status, and suggested next tool calls; `legis` is the signed, verbatim-postable `scan` for legis's `POST /wardline/scan-results` (signed when `WARDLINE_LEGIS_ARTIFACT_KEY` is provisioned — write it **outside** the working tree, see the [legis handoff guide](../guides/legis-handoff.md)). SARIF carries Wardline identity in `partialFingerprints["wardlineFingerprint/v1"]`; downstream Filigree lifecycle quality depends on importers preserving that field. | +| `--format [jsonl\|sarif\|agent-summary\|legis]` | Output shape. `jsonl` is one finding per line; `sarif` is SARIF 2.1.0 for GitHub code-scanning and other generic SARIF consumers; `agent-summary` is stable versioned JSON for agents (`schema: wardline-agent-summary-1`) with active defects first, suppressed findings, engine facts, integration status, and suggested next tool calls; `legis` is the signed, verbatim-postable `scan` for legis's `POST /wardline/scan-results` (signed when `WARDLINE_LEGIS_ARTIFACT_KEY` is provisioned and `PATH` is the git repository root — write it **outside** the working tree, see the [legis handoff guide](../guides/legis-handoff.md)). SARIF carries Wardline identity in `partialFingerprints["wardlineFingerprint/v2"]`; downstream Filigree lifecycle quality depends on importers preserving that field. | +| `--lang [python\|rust]` | Language frontend (default `python`). `rust` sweeps `*.rs` and covers the **command-injection slice** (`RS-WL-108`/`RS-WL-112`); needs the `wardline[rust]` extra. Finding identity is frozen and crate-prefixed (baseline-eligible); config severity overrides do not yet apply to Rust findings — see the [Rust support guide](../guides/rust-preview.md). | | `--output PATH` | Write findings to a file instead of stdout. | | `--fail-on [CRITICAL\|ERROR\|WARN\|INFO]` | Exit non-zero when any finding at or above this severity survives the baseline. Use this as your CI gate. | -| `--cache-dir PATH` | Persist the L3 inter-procedural summary cache here so the next scan reuses unchanged summaries. | +| `--cache-dir PATH` | Store the L3 inter-procedural summary cache here so the next scan can reuse unchanged summaries when `WARDLINE_SUMMARY_CACHE_KEY` is set in the process environment. Unsigned or incorrectly signed files are ignored and the scan falls back to recomputing summaries. Use an operator-owned directory outside untrusted checkouts; do not put the cache in a path that pull-request content can commit or modify. | | `--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. | Realistic invocation — scan the source tree, emit SARIF to a file, and fail the build on any `ERROR`-or-worse finding: @@ -107,9 +123,15 @@ $ wardline scan src/ --format sarif --output wardline.sarif --fail-on ERROR Incremental local run reusing a warm cache: ```text -$ wardline scan src/ --cache-dir .weft/wardline/cache +$ export WARDLINE_SUMMARY_CACHE_KEY="$(openssl rand -hex 32)" +$ wardline scan src/ --cache-dir ~/.cache/wardline/my-project ``` +Only reuse a disk summary cache when `WARDLINE_SUMMARY_CACHE_KEY` is provisioned +from trusted process environment. Unsigned cache files are never loaded, and a +cache directory inside an untrusted checkout is still a poor CI choice because +repository content can force cold-cache behavior by deleting or replacing files. + Agent handoff summary: ```text @@ -119,6 +141,97 @@ $ wardline scan src/ --format agent-summary --output findings.agent-summary.json See the [getting-started guide](../getting-started.md) for a first end-to-end scan and how to read the findings. +## `wardline scan-job` + +**Purpose:** start a file-backed scan job and poll status without requiring a +daemon. Job state lives under `.weft/wardline/jobs//status.json`; the +default findings artifact lives beside it. + +```text +$ wardline scan-job start . --fail-on ERROR --timeout 600 +{"job_id":"...","status":"running","phase":"starting",...} + +$ wardline scan-job status --path . +{"job_id":"...","status":"completed","phase":"complete",...} + +$ wardline scan-job cancel --path . +{"job_id":"...","status":"cancelled","phase":"cancelled",...} +``` + +Status JSON includes `phase`, `progress`, `heartbeat`, `artifacts.findings`, +`gate`, `filigree_emit`, and a terminal `failure_kind`. Scan/tool errors report +`failure_kind: "scan"`, gate trips report `"gate"`, and successful scans with +non-load-bearing upload failure report `status: +"completed_with_enrichment_failure"` plus `failure_kind: "enrichment"`. Use +scan jobs default to a 30-minute timeout so a stuck analyzer becomes a terminal +`failure_kind: "timeout"` status instead of an ambiguous hang. Use +`--timeout SECONDS` to choose a tighter or looser bound, or `--timeout 0` for an +intentionally unbounded local run. Polling `status` also reports stale/dead +workers, and `cancel` persists a terminal `cancelled` status for scans that are +no longer useful. + +## `wardline explain-taint` + +**Purpose:** explain ONE finding's taint provenance — the immediate tainted +callee, the originating boundary, the trust tiers at the sink, and a +remediation hint. The CLI twin of the MCP `explain_taint` tool (same core +builder, identical JSON), so a CLI-only agent can run the full +scan → explain → fix-at-the-boundary → rescan loop. + +```text +Usage: wardline explain-taint [OPTIONS] FINGERPRINT [PATH] +``` + +| Option | What it does | +|---|---| +| `--sink-qualname TEXT` | The finding's `qualname`: with a configured Loomweave store this serves the explanation from the store with no re-scan. | +| `--chain` | Also walk the full taint chain to the originating boundary (needs a Loomweave store; degrades to the single-hop explanation without one). | +| `--max-hops INTEGER` | Chain-walk hop budget (default 20). | +| `--loomweave-url TEXT` | Loomweave taint-store URL (opt-in; also resolved from env/published port). | +| `--config FILE` | Explicit config file. | + +Call it right after a scan and before editing: a fingerprint from a stale scan +errors with exit 2 and asks for a re-scan. `PATH` is the scan root and must +match the scan that minted the fingerprint (qualnames and fingerprints are +minted relative to it). + +```text +$ wardline scan . --fail-on ERROR +$ wardline explain-taint 40dd3530…54619e . +{ + "fingerprint": "40dd3530…54619e", + "rule_id": "PY-WL-101", + "sink_qualname": "specimen.trust_flow.leaks_untrusted", + "location": {"path": "specimen/trust_flow.py", "line": 13}, + "tier_in": "UNKNOWN_RAW", + "tier_out": "ASSURED", + "immediate_tainted_callee": "read_raw", + "source_boundary_qualname": "specimen.trust_flow.read_raw", + "remediation": {"kind": "boundary_placement", "summary": "Validate or normalize data from …"} +} +``` + +## `wardline findings` + +**Purpose:** read-only filtered query — scan PATH and print matching findings +as JSONL. The CLI counterpart of the MCP `scan(where=)` filter; no file +output, no Filigree/Loomweave emission. + +```text +Usage: wardline findings [OPTIONS] [PATH] +``` + +| Option | What it does | +|---|---| +| `--rule-id TEXT` | Filter by rule id, e.g. `PY-WL-101`. | +| `--severity TEXT` | Filter by severity, case-insensitive: `CRITICAL`/`ERROR`/`WARN`/`INFO`/`NONE`. An out-of-vocabulary value (e.g. `medium`) errors loudly with the allowed list — never a silent empty result. | +| `--sink TEXT` | Filter by the finding's `sink` property, e.g. `subprocess.run`. | +| `--where TEXT` | JSON filter object for the full predicate set (`rule_id`, `qualname`, `severity`, `suppression`, `kind`, `path_glob`, `sink`, `tier`), conjunctive. Closed-vocabulary values (`severity`, `suppression`, `kind`) match case-insensitively. | +| `--config FILE` | Explicit config file. | + +A filter given both as a flag and inside `--where` is rejected (exit 2) rather +than silently preferring one. + ## `wardline file-finding` **Purpose:** promote one already-emitted finding, keyed by fingerprint, into a @@ -398,3 +511,132 @@ $ wardline baseline update src/ For the full baseline-and-waiver workflow — when to baseline vs. waive, and how the baseline interacts with the judge — see the [suppression guide](../guides/suppression.md). + +## `wardline install` + +**Purpose:** install wardline's agent-facing guidance and sibling bindings into +a project root. Idempotent — re-running refreshes stale artifacts. This is how +you arm a coding agent with wardline: it writes the instruction blocks, the +`wardline-gate` skill, the `.mcp.json` / Codex MCP registration, optional +Loomweave/Filigree bindings, the attest signing key, and a pre-commit hook. + +```text +Usage: wardline install [OPTIONS] [PACK] + + Install wardline's agent-facing guidance and sibling bindings into ROOT. + +Options: + --root DIRECTORY Project root to install into (default: cwd). + --no-claude-md Skip the CLAUDE.md instruction block. + --no-agents-md Skip the AGENTS.md instruction block. + --no-skill Skip the wardline-gate skill. + --no-mcp Skip wiring .mcp.json and Codex MCP config. + --no-bindings Skip Loomweave/Filigree detection. + --no-attest-key Skip minting the attest signing key. + --no-pre-commit Skip adding pre-commit hook config. + --help Show this message and exit. +``` + +For what the install writes and how agents consume it, see +[Using Wardline with your coding agent](../guides/agents.md). + +## `wardline doctor` + +**Purpose:** check the wardline agent-install artifacts and sibling bindings, +and optionally repair them. `--repair` rewrites missing or stale install +artifacts; `--fix` repairs the bindings *and* emits the machine-readable JSON +envelope (the same builder the MCP `doctor` tool returns). + +```text +Usage: wardline doctor [OPTIONS] + + Check Wardline agent install artifacts and sibling bindings. + +Options: + --root DIRECTORY Project root to inspect (default: cwd). + --repair Repair missing or stale install artifacts. + --fix Repair install bindings and emit machine-readable JSON. + --filigree-url TEXT Filigree Weft URL to probe (default: resolve from + .mcp.json/env). + --help Show this message and exit. +``` + +See [Using Wardline with your coding agent](../guides/agents.md) for the +install-and-federation health checks doctor runs. + +## `wardline mcp` + +**Purpose:** run the wardline MCP server over stdio (JSON-RPC 2.0). This is the +agent transport — it exposes the full tool surface (`scan`, `explain_taint`, +`assure`, `dossier`, `attest`, and the rest) without scraping terminal output. +`--read-only` and `--no-network` drop the tools that would write to disk or +reach a sibling, for a hardened launch. + +```text +Usage: wardline mcp [OPTIONS] + + Run the Wardline MCP server over stdio (JSON-RPC 2.0). + +Options: + --root DIRECTORY Project root the server scans (default: cwd). + --loomweave-url TEXT Loomweave taint-store URL: `scan` writes facts; + `explain_taint`/`dossier` query it. + --filigree-url TEXT Filigree URL: `scan` POSTs findings to it (fail-soft); + `dossier` reads entity-associations (open work) from + it. + --read-only Disable MCP tools that require write capability. + --no-network Disable MCP tools that require network capability. + --help Show this message and exit. +``` + +For the full tool catalogue this server serves, see the +[MCP tool reference](mcp.md). For wiring it into a coding agent, see +[Using Wardline with your coding agent](../guides/agents.md). + +## `wardline lsp` + +**Purpose:** run the wardline LSP diagnostics server over stdio (JSON-RPC +2.0), so an editor can surface trust-boundary findings as inline diagnostics. + +```text +Usage: wardline lsp [OPTIONS] + + Run the Wardline LSP diagnostics server over stdio (JSON-RPC 2.0). + +Options: + --root DIRECTORY Project root the server scans (default: cwd). + --help Show this message and exit. +``` + +## `wardline rekey` + +**Purpose:** re-key the baseline / waiver / judged verdicts across a +**fingerprint-scheme change** — that is, after the engine's fingerprint +*formula* migrates, not after ordinary refactors (fingerprints are +line-insensitive). The default `--probe` is a read-only dry run that reports +which stored verdicts carry, which orphan, and any collisions, writing nothing. + +```text +Usage: wardline rekey [OPTIONS] [PATH] + + Re-key baseline/waiver/judge verdicts across a fingerprint-scheme change. + +Options: + --config FILE + --cache-dir PATH + --trust-pack TEXT Allow a trust-grammar pack from weft.toml. Repeatable. + --allow-custom-packs Allow local custom trust-grammar packs. + --strict-defaults Ignore repository-supplied configuration overrides. + --filigree-url TEXT Re-emit findings under the new fingerprints to this + Filigree URL (last leg, best-effort). + --probe Read-only dry run: report match/orphans/collisions, + write nothing. + --resume Finish an interrupted migration WITHOUT re-scanning. + --rollback Restore the pre-migration stores from the snapshot. + --help Show this message and exit. +``` + +`--probe`, `--resume`, and `--rollback` are mutually exclusive. A migration +snapshots the stores first and writes a resumable journal, so an interrupted +run can be finished with `--resume` or undone with `--rollback`. The MCP twin +is the `rekey` tool (see the [MCP tool reference](mcp.md)). diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index b6d50c1a..4b1e6283 100644 --- a/docs/reference/finding-lifecycle-vocabulary.md +++ b/docs/reference/finding-lifecycle-vocabulary.md @@ -1,7 +1,7 @@ # Finding lifecycle & gate vocabulary This is the single source of truth for the words Wardline uses to describe the -**state and lifecycle of a finding** — `new`, `active`, `suppressed`, +**state and lifecycle of a finding** — `new`, `active`, `suppression_state`, `baselined`, `waived`, `judged` — and how each one maps onto the three surfaces an agent reads: the **CLI summary line**, the **MCP / agent-summary JSON**, and the **Filigree store**. @@ -21,35 +21,47 @@ Before lifecycle state, two orthogonal axes classify every finding: | Axis | Values | Defined at | | --- | --- | --- | -| `kind` | `defect`, `fact`, `classification`, `metric`, `suggestion` | `src/wardline/core/finding.py:59-65` (`Kind`) | -| `severity` | `CRITICAL`, `ERROR`, `WARN`, `INFO`, `NONE` | `src/wardline/core/finding.py:51-56` (`Severity`) | +| `kind` | `defect`, `fact`, `classification`, `metric`, `suggestion` | `src/wardline/core/finding.py:63-69` (`Kind`) | +| `severity` | `CRITICAL`, `ERROR`, `WARN`, `INFO`, `NONE` | `src/wardline/core/finding.py:55-60` (`Severity`) | Only `Kind.DEFECT` findings are ever suppressed or gated; facts and metrics (`Severity.NONE`) never participate in the `--fail-on` gate -(`src/wardline/core/suppression.py:20-22`, `src/wardline/core/suppression.py:37-39`). +(`src/wardline/core/suppression.py:27-30`, `src/wardline/core/suppression.py:44-46`). ## The four suppression states -`SuppressionState` (`src/wardline/core/finding.py:67-71`) has exactly four +`SuppressionState` (`src/wardline/core/finding.py:71-75`) has exactly four values. Every emitted `DEFECT` carries exactly one: | State | Meaning | Set by | | --- | --- | --- | -| `active` | Not suppressed — the default. A live defect. | default (`src/wardline/core/finding.py:68`, `src/wardline/core/finding.py:103`) | -| `baselined` | Matched a fingerprint in `.weft/wardline/baseline.yaml`. | `src/wardline/core/suppression.py:70` | -| `waived` | Matched an unexpired waiver in `.weft/wardline/waivers.yaml`. | `src/wardline/core/suppression.py:66` | -| `judged` | The LLM triage judge ruled it a false positive (`.weft/wardline/judged.yaml`). | `src/wardline/core/suppression.py:68` | +| `active` | Not suppressed — the default. A live defect. | default (`src/wardline/core/finding.py:72`, `src/wardline/core/finding.py:107`) | +| `baselined` | Matched a fingerprint in `.weft/wardline/baseline.yaml`. | `src/wardline/core/suppression.py:24` | +| `waived` | Matched an unexpired waiver in `.weft/wardline/waivers.yaml`. | `src/wardline/core/suppression.py:22` | +| `judged` | The LLM triage judge ruled it a false positive (`.weft/wardline/judged.yaml`). | `src/wardline/core/suppression.py:23` | When more than one layer matches a finding, **precedence is waiver > judged > baseline** — explicit human intent wins, then the LLM verdict -(so its rationale is the visible reason), then the silent baseline -(`src/wardline/core/suppression.py:61-70`). - -**"suppressed"** is the umbrella term for "any state other than `active`": -`baselined` + `waived` + `judged`. The CLI prints this sum as the `suppressed` -count (`src/wardline/cli/scan.py:360`), and `to_filigree_metadata` only writes a -`suppressed` key when the state is not `active` -(`src/wardline/core/finding.py:184-187`). +(so its rationale is the visible reason), then the silent baseline. The precedence +itself lives in the single JOIN predicate `resolve_identity` +(`src/wardline/core/finding_identity.py`); the suppression layer maps its verdict +onto the state (`src/wardline/core/suppression.py:78-87`). + +### The per-finding key is `suppression_state` (not `suppressed`) + +Each serialized finding carries its state under the key **`suppression_state`** +(`src/wardline/core/finding.py:140` in `to_jsonl`; `src/wardline/core/finding.py:295` +in the Filigree `metadata.wardline.*` subtree; the agent-summary entries and the +legis artifact use the same key). The key was renamed from `suppressed` → +`suppression_state` (weft-f506e5f845) so the per-finding **state** never reads as +the opposite of the summary's `active` **count**: `suppression_state: "active"` +clearly names a state, while `summary.active` is a count of unsuppressed defects. +The Filigree metadata only carries the key when the state is not `active` +(`src/wardline/core/finding.py:295`). + +**"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`). ## `active` is the one word for "non-suppressed defect" @@ -58,38 +70,40 @@ consistently, on every surface: | Surface | Where | Term | | --- | --- | --- | -| Enum | `src/wardline/core/finding.py:68` | `SuppressionState.ACTIVE = "active"` | -| Summary field | `src/wardline/core/run.py:50`, built at `src/wardline/core/run.py:288` | `ScanSummary.active` | -| CLI summary line | `src/wardline/cli/scan.py:361` | `… {s.active} active` | -| MCP scan response | `src/wardline/mcp/server.py:313` | `summary.active` | -| Agent-summary JSON | `src/wardline/core/agent_summary.py:99` | `summary.active_defects` | +| 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` | +| 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`" | The agent-summary key is `active_defects` rather than bare `active` — that is a descriptive-suffix convention alongside `total_findings` / `suppressed_findings` -(`src/wardline/core/agent_summary.py:98-105`), not a different concept. It counts +(`src/wardline/core/agent_summary.py:144-152`), not a different concept. It counts the same population. The discipline test `tests/cli/test_scan_summary_vocab.py` pins this: the CLI line says `active` (never `new`), and the count matches the agent-summary and MCP surfaces. -## The three meanings of "new" +## The summary buckets partition the total -"new" is overloaded across the suite. Wardline's own surfaces no longer use it -for the active count (that was a historical CLI mislabel, now `active`). The word -still legitimately means three different things depending on the surface: +`ScanSummary` (`src/wardline/core/run.py:50-70`) counts split the whole scan into +buckets that **sum to `total`** exactly (weft-f506e5f845): -| "new" on this surface | Means | Owner / anchor | -| --- | --- | --- | -| Filigree store | An **unseen fingerprint** — first time this finding identity is seen for a `(file, scan_source)`. Driven by `mark_unseen` / the absent-fingerprint sweep. | **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:264-283`; help text `src/wardline/cli/scan.py` (`--new-since`, "new findings only") | -| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"** so the CLI matches every other surface. | `src/wardline/cli/scan.py:360` | +- 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** + (facts, metrics, classifications) — the rest of `total`. -The first-seen Filigree sense and the delta-scope `--new-since` sense are -genuinely distinct concepts; neither is "active". An agent should read the CLI / -MCP `active` count as "live defects now", Filigree's first-seen status as "is this -identity new to the tracker", and `--new-since` as "only gate on what changed". +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` +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 +both (`src/wardline/core/agent_summary.py:151`, `src/wardline/core/agent_summary.py:152`). ## Emitted-active vs the gate population @@ -97,35 +111,74 @@ 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 (`src/wardline/core/run.py:285-293`). + **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:393`). 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:250-254`). By default, repository-controlled + (`src/wardline/core/run.py:341`). 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) - (`src/wardline/core/run.py:315-316`). + `--trust-suppressions` / directly-constructed path), selected at + `src/wardline/core/run.py:452` (`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 suppressions do not clear the unsuppressed gate population. It is by design, not a -bug. The gate result is reported separately from `summary.active`: `GateDecision` -carries `tripped` / `fail_on` / `exit_class` **plus** a human `reason` and the -`evaluated` population it judged (`src/wardline/core/run.py:86-96`), so the -`0 active + tripped` case explains itself instead of reading as a defect. The MCP -`scan` block exposes `gate.tripped` / `gate.reason` / `gate.evaluated` / -`gate.migration_hint` (`src/wardline/mcp/server.py:332-338`); the CLI prints -`gate: FAILED (--fail-on …) — ` then `gate: evaluated <…>` on stderr -(`src/wardline/cli/scan.py:375-376`). +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 +`would_trip_at`, alongside a human `reason` and the `evaluated` population it +judged. The `verdict` is one of: + +- **`NOT_EVALUATED`** — neither gate knob (`--fail-on` / `--fail-on-unanalyzed`) + was set, so the gate never judged. A bare scan is **not** a clean pass; + `would_trip_at` names the highest severity that *would* trip so the agent's + first call is not a false green (weft-b937e53854). +- **`PASSED`** — at least one knob ran and nothing tripped. +- **`FAILED`** — a knob ran and tripped. + +The decision composes two independent sub-gates: the severity gate (`fail_on`) +and the opt-in unanalyzed gate (`fail_on_unanalyzed`, MCP-primary A4 — +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`), +`gate.severity_tripped`, `gate.unanalyzed_tripped`, `would_trip_at`, `reason`, +`evaluated`, and `migration_hint`, opened at `src/wardline/mcp/server.py:924` +(`"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`). `--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:264-283`). +(`src/wardline/core/run.py:369`, `def apply_delta_scope`). + +## The three meanings of "new" + +"new" is overloaded across the suite. Wardline's own surfaces no longer use it +for the active count (that was a historical CLI mislabel, now `active`). The word +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` | + +The first-seen Filigree sense and the delta-scope `--new-since` sense are +genuinely distinct concepts; neither is "active". ## Cross-surface mapping table @@ -133,36 +186,43 @@ 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:49`) | `total` (`server.py:312`) | `total_findings` (`agent_summary.py:98`) | one finding per wire entry | -| live defect | `N active` (`scan.py:361`) | `active` (`run.py:50,288`) | `active` (`server.py:313`) | `active_defects` (`agent_summary.py:99`) | no `suppressed` key (`finding.py:184`) | -| suppressed (sum) | `N suppressed` (`scan.py:360`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:100`) | `metadata.wardline.suppressed` (`finding.py:184-187`) | -| baselined | `N baseline` | `baselined` (`run.py:52`) | `baselined` (`server.py:314`) | `baselined` (`agent_summary.py:102`) | `suppressed: "baselined"` | -| waived | `N waiver` | `waived` (`run.py:53`) | `waived` (`server.py:315`) | `waived` (`agent_summary.py:103`) | `suppressed: "waived"` | -| judged | `N judged` | `judged` (`run.py:54`) | `judged` (`server.py:316`) | `judged` (`agent_summary.py:104`) | `suppressed: "judged"` | -| under-scan | `N file(s) could not be analyzed` | `unanalyzed` (`run.py:60`) | `unanalyzed` (`server.py:320`) | `unanalyzed` (`agent_summary.py:105`) | `WLN-ENGINE-*` facts | -| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:79`) | `gate.tripped` (`server.py:333`) | `gate.tripped` (`agent_summary.py:108`) | not emitted to Filigree | +| every finding | `N finding(s)` | `total` (`run.py:51`) | `total` (`server.py:909`) | `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:910`) | `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:911`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | +| waived | `N waiver` | `waived` (`run.py:55`) | `waived` (`server.py:912`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | +| judged | `N judged` | `judged` (`run.py:56`) | `judged` (`server.py:913`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | +| informational (summary) | (the remainder of `total`) | `informational` (`run.py:62`) | `informational` (`server.py:918`) | `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:922`) | `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.tripped` (`server.py:925`), `gate.verdict` (`server.py:929`) | `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`). ## For the suite This page is the **Wardline-anchored** glossary. Two pieces of the vocabulary are -owned by sibling tools and are intentionally **not** renamed by Wardline — they -are recorded here as coordination context, not as a change Wardline executes: +owned by sibling tools and are recorded here as coordination context: - **Filigree's "new" / `seen_count` lifecycle is Filigree-owned.** Filigree decides first-seen vs returning purely from fingerprint presence across scans - (`mark_unseen`, `src/wardline/core/filigree_emit.py:68-76`). Wardline emits the - fingerprint and `scanned_paths`; it does not, and should not, rename Filigree's - first-seen concept to match its own `active`. The two words mean different - things and that distinction is correct. - -- **legis receives the gate population as `active`.** The legis scan artifact - projects the *whole scan*, mapping `baselined` / `judged` onto legis's own - `suppressed` while `active` stays `active`, so legis reproduces Wardline's gate - population exactly (the "one judge" property). This is a contract Wardline - conforms to, not a rename of any other tool's fields (see the CHANGELOG legis - handoff entry and [Signed scan handoff to legis](../guides/legis-handoff.md)). + (`mark_unseen`, `src/wardline/core/filigree_emit.py`). Wardline emits the + fingerprint and `scanned_paths`; it does not rename Filigree's first-seen + concept. If a scan contains under-analysis findings (`WLN-ENGINE-*` unanalyzed + rule ids), Wardline disables `mark_unseen` for that batch so an absent fingerprint + cannot be treated as fixed when the source was not actually analyzed. + +- **legis receives the gate population, keyed by `suppression_state`.** The legis + scan artifact projects the *whole scan*, mapping `baselined` / `judged` onto + legis's own `suppressed` value while `active` stays `active`, so legis reproduces + Wardline's gate population exactly (the "one judge" property). The per-finding key + was renamed `suppressed` → `suppression_state` (weft-f506e5f845); legis adopts the + same key on its side (tracked as a federation contract change). See the CHANGELOG + legis handoff entry and [Signed scan handoff to legis](../guides/legis-handoff.md). In short: **within Wardline, `active` is the single word for a non-suppressed -defect, on every surface.** The remaining divergence is genuine cross-tool -semantics (Filigree's first-seen lifecycle, `--new-since` delta-scope) that this -glossary documents rather than collapses. No cross-repo rename is implied. +defect, and `suppression_state` is the single per-finding key for its state, on +every surface.** The remaining divergence is genuine cross-tool semantics +(Filigree's first-seen lifecycle, `--new-since` delta-scope) that this glossary +documents rather than collapses. diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md new file mode 100644 index 00000000..8fb37116 --- /dev/null +++ b/docs/reference/mcp.md @@ -0,0 +1,329 @@ +# MCP tool reference + +The wardline MCP server (`wardline mcp`) speaks JSON-RPC 2.0 over stdio and +exposes the analyzer to a coding agent without scraping terminal output. This +page documents every tool the server registers — 18 in all — in the order it +publishes them. + +The server is **stateless**: no session is carried between calls. The read-only +tools (`scan`, `explain_taint`, `assure`, `dossier`, `decorator_coverage`, +`attest`, `verify_attestation`, `scan_job_status`) are pure functions of disk + +config. The rest mutate project files on disk or reach a sibling over the +network; each is marked below. Launch with `--read-only` to drop the +write-capable tools and `--no-network` to drop the network-capable ones. + +Every tool is rooted at the launch project path (`--root`, default cwd). Any +`path`/`config`/`cache_dir`/`output` argument is confined under that root — +the same containment guarantee as the CLI. + +For the matching command-line surface, see the [CLI reference](cli.md). + +## Tool capability matrix + +| Tool | Reads | Writes disk | Network | One-line purpose | +| --- | :---: | :---: | :---: | --- | +| `scan` | yes | — | opt | whole-program taint scan + gate verdict | +| `scan_job_start` | yes | yes | opt | start a file-backed background scan job | +| `scan_job_status` | yes | — | — | poll a scan job's status | +| `scan_job_cancel` | yes | yes | — | cancel a non-terminal scan job | +| `explain_taint` | yes | — | opt | explain ONE finding's taint provenance | +| `dossier` | yes | — | opt | one-call entity trust dossier | +| `assure` | yes | — | — | trust-surface coverage posture | +| `decorator_coverage` | yes | — | opt | inventory of trust-decorated entities | +| `attest` | yes | — | — | build a signed evidence bundle | +| `verify_attestation` | yes | — | — | verify an attestation bundle | +| `file_finding` | yes | yes | yes | promote ONE finding to a Filigree issue | +| `scan_file_findings` | yes | yes | yes | scan → emit → promote, one call | +| `judge` | yes | yes | yes | opt-in LLM triage of active defects | +| `baseline` | yes | yes | — | snapshot findings as the baseline | +| `waiver_add` | yes | yes | — | add a time-boxed waiver for ONE finding | +| `fix` | yes | yes | — | apply mechanical autofixes | +| `doctor` | yes | opt | — | install + federation health check | +| `rekey` | yes | opt | opt | migrate verdicts across a fingerprint-scheme change | + +"opt" = the capability is exercised only when the relevant URL/flag is +configured (or, for `doctor`/`rekey`, only under the explicit write opt-in). + +--- + +## `scan` + +**Purpose:** whole-program taint scan of the project. Returns structured +findings, the suppression summary, and the gate verdict. + +**Key params:** `path` (subdir), `fail_on` (`CRITICAL`/`ERROR`/`WARN`/`INFO`), +`fail_on_unanalyzed`, `lang` (`python`/`rust`), `where` (conjunctive filter: +`rule_id`, `qualname`, `severity`, `suppression`, `kind`, `path_glob`, `sink`, +`tier`), `explain` (inline each active defect's provenance), `summary_only`, +`full`, `max_findings`, `offset`, `include_suppressed`, `new_since`, +`trust_suppressions`, `legis_artifact`. By default the `--fail-on` gate +evaluates the **unsuppressed** population, so a repo-controlled +baseline/waiver/judged annotates a finding but does not clear it; pass +`trust_suppressions: true` for the trusted-local behaviour. + +**Returns:** `files_scanned`, a whole-project `summary` +(`total`/`active`/`baselined`/`waived`/`judged`/`informational`/`unanalyzed`), a +`gate` block (`tripped`, `fail_on`, `exit_class` 0/1, `verdict` +NOT_EVALUATED/PASSED/FAILED, `would_trip_at`, sub-gate attribution), `loomweave` +/ `filigree` raw write/emit blocks (null when unconfigured), and the stable +`agent_summary` block (schema `wardline-agent-summary-1`): active defects first, +suppressed debt, engine facts, integration status, a pagination `truncation` +descriptor, and gate-aware `next_actions`. The finding bodies are **bounded by +default** (≤25) so a first call cannot overflow context; `full: true` lifts the +cap and `offset` pages the rest. + +Read-only. When a Filigree URL is configured the scan also POSTs findings to it, +fail-soft (an unreachable sibling or rejected payload is reported in the +`filigree` block, never fails the scan). + +## `scan_job_start` + +**Purpose:** start a file-backed background scan job and return its stable job +id plus initial status. The MCP-safe surface for long scans — prefer it over +synchronous `scan` when a project may take more than a short call. + +**Key params:** `path`, `config`, `format` (`jsonl`/`sarif`/`agent-summary`), +`output`, `fail_on`, `fail_on_unanalyzed`, `cache_dir`, `local_only`, +`timeout_seconds` (default 1800; `0` disables), `lang`, `new_since`, +`trust_suppressions`. + +**Returns:** the scan-job status object — `job_id`, `status` +(`queued`/`running`/`running_stale`/`completed`/`completed_with_enrichment_failure`/`failed`/`cancelled`), +`phase`, `progress`, `heartbeat`, `pid`, `artifacts`, `failure_kind`, `error`, +`request`. Writes job state under `.weft/wardline/jobs//`. + +## `scan_job_status` + +**Purpose:** read the current status JSON for a file-backed scan job. Reports a +stale heartbeat or dead-worker terminal failure rather than leaving an +apparently hung job ambiguous. + +**Key params:** `job_id` (required, 32 hex chars), `path`. + +**Returns:** the same scan-job status object as `scan_job_start`. Read-only. + +## `scan_job_cancel` + +**Purpose:** cancel a non-terminal scan job and return the persisted terminal +status. + +**Key params:** `job_id` (required, 32 hex chars), `path`. + +**Returns:** the scan-job status object with a terminal `cancelled` status. +Writes the terminal status to disk. + +## `explain_taint` + +**Purpose:** explain ONE finding's taint — the immediate tainted callee, the +originating boundary, and the trust tiers at the sink. Call right after `scan` +and before editing: a stale fingerprint returns an error. + +**Key params:** `fingerprint`, `path`, `line`, `sink_qualname` (when a Loomweave +store is configured this serves the explanation from the store instead of +re-scanning), `chain` (also walk the full taint chain to the originating +boundary — needs a configured Loomweave store; without one the `chain` block is +an explicit `status: unavailable` marker naming the missing capability), +`max_hops`. + +**Returns:** the explanation object — `fingerprint`, `rule_id`, `sink_qualname`, +`location`, `tier_in`, `tier_out`, `immediate_tainted_callee`, +`source_boundary_qualname`, resolution counts, and a `remediation` hint. +Read-only. + +## `dossier` + +**Purpose:** one-call entity dossier for a function `entity` (a qualname): its +trust posture (declared vs actual taint, gate verdict, active findings — always +computed fresh), plus Loomweave call-graph linkages and Filigree open work +joined on the entity's opaque SEI. + +**Key params:** `entity` (required — the function qualname, e.g. +`pkg.mod.func`), `config`. + +**Returns:** a token-bounded (~2k) envelope with an explicit truncation marker. +The `identity` section is freshness-stamped on **both** axes (identity +alive/orphaned/unavailable + content fresh/stale/unknown) and is never trimmed; +each cross-tool section degrades to an honest `available: false` + `reason` +shape when its source is absent. Read-only — lets an agent read the whole +context without opening the source. + +## `assure` + +**Purpose:** trust-surface COVERAGE posture — how many declared trust boundaries +the engine reached a definite verdict on vs. how many are honestly unknown, plus +waiver debt. Consult before deciding to trust a module. (This is a coverage +question, not a compliance claim — see the +[assurance posture guide](../guides/assurance-posture.md).) + +**Key params:** `path`, `config` (both optional; default to the server root and +`weft.toml` at the scan root). + +**Returns:** the posture object — `boundaries_total`, `proven`, `defect_total`, +`unknown[]` (the honesty gap, each with `qualname`/`tier`/`location`/`reason`), +`engine_limited`, `coverage_pct` (`null` when there is no trust surface — never a +false-green 100%), `unanalyzed_total`, `unanalyzed_rule_ids`, `waiver_debt[]`, +`baselined_total`, `judged_total`. Identical to the CLI `assure` JSON by +construction (both call `build_posture`). Read-only. + +## `decorator_coverage` + +**Purpose:** stable JSON inventory of every wardline trust-decorated entity. + +**Key params:** `path`, `config`. + +**Returns:** `summary` plus `rows`; each row carries `qualname`, `path`/`line`, +`decorators`, declared/actual tier, gate `verdict`, active/suppressed finding +fingerprints, optional Loomweave SEI/content `identity`, and optional Filigree +linked `work` status. Optional sources degrade explicitly (`available: false`). +Read-only; reaches Loomweave/Filigree only when their URLs resolve. + +## `attest` + +**Purpose:** build a SIGNED, reproducible evidence bundle (commit, ruleset hash, +trust-surface posture, boundaries) for the project, HMAC-signed with the +install-minted project key. + +**Key params:** `path`, `config`, `allow_dirty`, `cache_dir`, `trust_packs`, +`trust_local_packs`, `strict_defaults`. The MCP boundary **inverts** the core +default and refuses a dirty working tree unless `allow_dirty: true`, so an agent +cannot silently attest uncommitted changes. Requires an attest key +(`wardline install` mints one, or set `WARDLINE_ATTEST_KEY`). + +**Returns:** the attestation bundle (`payload` + `signature`). SEI-keyed when a +Loomweave store is configured. Identical to the CLI `attest` by construction. +Read-only. See the [attestation guide](../guides/attestation.md). + +## `verify_attestation` + +**Purpose:** verify an attestation bundle's signature offline (needs the project +key) and optionally re-derive it at the current tree. + +**Key params:** `bundle` (required — must contain `payload` and `signature`), +`reproduce` (`true` re-derives at the current tree), `path`, `config`, +`cache_dir`, `trust_packs`, `trust_local_packs`, `strict_defaults`. + +**Returns:** `{signature_valid, reproduced, mismatches, note}`. Read-only. + +## `file_finding` + +**Purpose:** promote ONE finding (by `fingerprint`) into a tracked Filigree +issue and return its `issue_id`. Idempotent (re-filing returns the same issue). + +**Key params:** `fingerprint` (required), `priority`, `labels`, +`attach_loomweave_identity` (resolve the finding qualname through Loomweave and +attach a Filigree entity association), `config`. Emit findings to Filigree first +(scan with a configured Filigree URL) so the fingerprint is known. + +**Returns:** `reachable`, `issue_id`, `created`, `not_found` (true when Filigree +is reachable but the fingerprint is unknown — 404), `fingerprint`, +`disabled_reason`, and an `identity_attach` block when +`attach_loomweave_identity` was requested. Fail-soft on reachability (a 5xx +outage or 401/403 refusal is soft). Reconciliation (close-on-fixed / +reopen-on-regress) happens automatically on later scans. Writes to Filigree +(network) and requires a configured Filigree URL. + +## `scan_file_findings` + +**Purpose:** the one-shot agent workflow — run a scan, list active defects first +with inline explanation summaries, optionally emit to Filigree, promote selected +fingerprints or all active defects, and attach Loomweave identity when +available. + +**Key params:** `path`, `fail_on`, `config`, `cache_dir`, `fingerprints`, +`all_active`, `dry_run` (defaults to dry-run unless `fingerprints` or +`all_active` are supplied), `priority`, `labels`. + +**Returns:** `mode` (`dry_run`/`all_active`/`fingerprints`), `files_scanned`, +`summary`, `gate`, `filigree_emit`, `active_defects[]` (each with `explanation`, +`promotion`, and `identity_attach` outcomes), `selected_count`, and +`unknown_fingerprints`. Partial failures stay visible per-finding. Writes to +Filigree (network) under the non-dry-run paths. + +## `judge` + +**Purpose:** NETWORK — opt-in LLM triage of active defects via OpenRouter +(needs `WARDLINE_OPENROUTER_API_KEY`). Labels each TRUE/FALSE positive. Never +run automatically; never folded into `scan`. + +**Key params:** `model` (OpenRouter slug), `max_findings` (bound token spend), +`write` (append above-floor false positives to `.weft/wardline/judged.yaml` — +**without it the call is a dry run**), `context_lines`, `config`. + +**Returns:** `verdicts[]` (each with `fingerprint`, `rule_id`, `path`, `line`, +`label` — the TRUE/FALSE-positive classification — `confidence`, and +`rationale`), plus `wrote` and `held_back` counts. Writes `judged.yaml` only +under `write: true`. See the [LLM triage judge guide](../guides/judge.md). + +## `baseline` + +**Purpose:** snapshot current defects as the baseline so only NEW findings +surface. Prefer FIXING a finding over baselining it. + +**Key params:** `reason` (optional), `overwrite` (default `false` refuses to +clobber and returns `already_exists: true`; `true` re-derives and overwrites), +`config`, `cache_dir`, `trust_packs`, `trust_local_packs`, `strict_defaults`. + +**Returns:** `baselined_count`, `path` (absolute path of +`.weft/wardline/baseline.yaml`), `reason`, and `already_exists` (when overwrite +was not requested). Writes the baseline file. See the +[suppression guide](../guides/suppression.md). + +## `waiver_add` + +**Purpose:** waive ONE finding by fingerprint with a mandatory reason and +expiry. Prefer fixing; a waiver is an audited, time-boxed exception. + +**Key params:** `fingerprint` (required), `reason` (required), `expires` +(required, `YYYY-MM-DD`). + +**Returns:** the waiver-add result. Writes `.weft/wardline/waivers.yaml`. The +recorded debt resurfaces in `assure`'s `waiver_debt[]` (including after the +expiry lapses). See [suppression — waivers](../guides/suppression.md#waivers). + +## `fix` + +**Purpose:** scan and apply mechanical autofixes to findings (currently only +`PY-WL-111`, assert-at-boundary rewrites). + +**Key params:** `path`, `config`, `dry_run` (preview without modifying), +`apply` (must be `true` to modify files — the default is a preview). + +**Returns:** `fixed` (map of file path → list of fix descriptions), `applied` +(true when written to disk), and a human-readable `message`. Writes source files +only under `apply: true`. + +## `doctor` + +**Purpose:** health-check the wardline install and federation wiring (the same +checks as CLI `doctor --fix`: instruction blocks, skills, MCP registration, +config parseability, sibling URLs, Filigree emit auth) PLUS this server's +self-identification: package version, pid, start time, and a source-FRESHNESS +verdict. + +**Key params:** `repair` (default `false`, a pure probe that writes nothing; +`true` repairs install artifacts and re-pins a rejected federation token), +`filigree_url` (probed for emit auth — only loopback origins are probed with a +token). + +**Returns:** the machine-readable doctor envelope plus a `server` block. If +`server.fresh` is `false`, this long-lived server predates the on-disk wardline +code and its results are stale — restart the MCP server. Read-only by default; +writes only under `repair: true`. + +## `rekey` + +**Purpose:** re-key baseline/waiver/judged verdicts across a fingerprint-scheme +change (after the engine's fingerprint *formula* migrates, NOT after ordinary +refactors — fingerprints are line-insensitive). + +**Key params:** `path`, `config`, `cache_dir`, `apply` (default `false`, a +read-only probe reporting carry/orphan/collision counts), `resume` (finish an +interrupted migration from the journal without re-scanning), `rollback` (restore +the pre-migration stores byte-identical from the snapshot). `apply`/`resume`/ +`rollback` are mutually exclusive and write-gated. + +**Returns:** the rekey result — carried, orphaned, and collision counts (probe), +or the migration outcome (apply/resume/rollback). A migration snapshots stores +first and writes a resumable journal. Writes only under +`apply`/`resume`/`rollback`; the Filigree re-emit leg runs only under `apply`. +The CLI twin is `wardline rekey` (see the [CLI reference](cli.md)). diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 1ee3a29a..2e58d9e9 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -7,15 +7,15 @@ headings, links, admonitions, tables, radii. We do NOT re-declare any of that. What stays here is purely Wardline's bespoke landing-page identity: - - The Wardline thread accent: CORAL (#F4845F), the federation's trust-boundary + - The Wardline thread accent: CORAL (#F0875E), the federation's trust-boundary strand — a deliberate change from the previous teal. It repaints the hero CTA, eyebrow, links inside the landing, decorator names, section eyebrows, - and the "you are here" badge. (In the LIGHT scheme it deepens to #C2410C so + and the "you are here" badge. (In the LIGHT scheme it deepens to #CF5630 so coral-as-text clears WCAG AA on white — mirroring how weft-mkdocs deepens its sky accent for light.) - The .wl-* landing components (hero, finding panel, feature cards, the eight-state trust lattice, the Weft suite grid), recolored onto the Weft - dark-teal surface ramp + 3/6/8 radii. + warm espresso / paper surface ramp + 3/6/8 radii. - The eight-state lattice swatches are SEMANTIC, not brand: they ride the design-system semantic ramp — most-trusted (INTEGRAL) = --ready emerald, least-trusted (MIXED_RAW) = --stale red, midpoints through --aging amber. @@ -60,9 +60,9 @@ the --ready emerald, least-trusted end the --stale red, gradient through the --aging amber. Pinned here (not per-scheme) so the ramp reads identically in both light and dark — it is a fixed semantic legend, not a chrome surface. */ - --ds-ready: #10B981; /* emerald — most trusted */ - --ds-aging: #F59E0B; /* amber — intermediate */ - --ds-stale: #EF4444; /* red — least trusted */ + --ds-ready: #5FB98E; /* emerald — most trusted */ + --ds-aging: #E9B04A; /* amber — intermediate */ + --ds-stale: #E2604E; /* red — least trusted */ } /* ========================================================================== @@ -79,71 +79,71 @@ /* Repaint Material's interactive accent to the Wardline coral thread so the chrome weft-mkdocs styles (links, nav active rail, TOC marker, headerlinks) read in Wardline's identity colour rather than the suite's default sky. */ - --md-accent-fg-color: var(--thread-wardline); /* #F4845F coral */ + --md-accent-fg-color: var(--thread-wardline); /* #F0875E coral */ --md-typeset-a-color: var(--thread-wardline); /* Weft dark surface ramp (design-system: base / raised / overlay / hover) */ - --wl-bg: #0B1215; /* surface-base */ - --wl-bg-subtle: #131E24; /* surface-raised */ - --wl-bg-card: #1A2B34; /* surface-overlay */ - --wl-bg-hover: #243A45; /* surface-hover */ + --wl-bg: #14110D; /* surface-base */ + --wl-bg-subtle: #1E1A13; /* surface-raised */ + --wl-bg-card: #2A2319; /* surface-overlay */ + --wl-bg-hover: #39301F; /* surface-hover */ /* Code surfaces stay dark in both schemes (editor-screenshot motif) */ - --wl-bg-code: #0B1215; - --wl-fg-code: #E2EEF2; + --wl-bg-code: #14110D; + --wl-fg-code: #F2E9D8; /* Text ramp */ - --wl-text: #E2EEF2; /* text-primary */ - --wl-text-muted: #8FAAB8; /* text-secondary */ + --wl-text: #F2E9D8; /* text-primary */ + --wl-text-muted: #B6A78E; /* text-secondary */ /* Borders — Weft hairlines */ - --wl-border: #1E3340; /* border-default */ - --wl-border-strong: #2A4454; /* border-strong */ + --wl-border: #332A1F; /* border-default */ + --wl-border-strong: #4A3C2A; /* border-strong */ /* Coral accent (the Wardline thread) + a soft tint for chips/pills */ - --wl-accent: var(--thread-wardline); /* #F4845F */ - --wl-accent-soft: rgba(244, 132, 95, 0.14); - --wl-on-accent: #0B1215; /* text on coral fill */ + --wl-accent: var(--thread-wardline); /* #F0875E */ + --wl-accent-soft: rgba(240, 135, 94, 0.14); + --wl-on-accent: #14110D; /* text on coral fill */ /* Semantic signal colours (verdict ✓/✗) from the design-system ramp */ - --wl-danger: var(--ds-stale); /* #EF4444 */ - --wl-danger-soft: rgba(239, 68, 68, 0.14); - --wl-ok: var(--ds-ready); /* #10B981 */ - --wl-ok-soft: rgba(16, 185, 129, 0.14); + --wl-danger: var(--ds-stale); /* #E2604E */ + --wl-danger-soft: rgba(226, 96, 78, 0.14); + --wl-ok: var(--ds-ready); /* #5FB98E */ + --wl-ok-soft: rgba(95, 185, 142, 0.14); } /* ---- LIGHT (default) scheme — the documented alternate ---- */ [data-md-color-scheme="default"] { - /* Coral-as-text fails AA on white; deepen to #C2410C (the same darker coral + /* Coral-as-text fails AA on white; deepen to #CF5630 (the same darker coral weft-mkdocs uses for the light-scheme code-constant colour) so links and the eyebrow clear 4.5:1. Brand reads coral; contrast holds. */ - --md-accent-fg-color: #C2410C; - --md-typeset-a-color: #C2410C; + --md-accent-fg-color: #CF5630; + --md-typeset-a-color: #CF5630; /* Weft light surface ramp */ - --wl-bg: #F0F6F8; /* surface-base */ - --wl-bg-subtle: #E8F1F4; /* surface-overlay */ - --wl-bg-card: #FFFFFF; /* surface-raised */ - --wl-bg-hover: #DCE9EE; /* surface-hover */ + --wl-bg: #ECE3D1; /* surface-base */ + --wl-bg-subtle: #E2D7BF; /* surface-overlay */ + --wl-bg-card: #F8F1E3; /* surface-raised */ + --wl-bg-hover: #D8CBB1; /* surface-hover */ /* Code surfaces deliberately stay dark in both schemes */ - --wl-bg-code: #0B1215; - --wl-fg-code: #E2EEF2; + --wl-bg-code: #14110D; + --wl-fg-code: #F2E9D8; - --wl-text: #0F2027; /* text-primary (light) */ - --wl-text-muted: #3D6070; /* text-secondary */ + --wl-text: #241E13; /* text-primary (light) */ + --wl-text-muted: #5C5238; /* text-secondary */ - --wl-border: #C5D8E0; /* border-default (light) */ - --wl-border-strong: #9BBBC8; /* border-strong */ + --wl-border: #CDBE9F; /* border-default (light) */ + --wl-border-strong: #A8966F; /* border-strong */ - --wl-accent: #C2410C; /* deepened coral */ - --wl-accent-soft: rgba(194, 65, 12, 0.10); - --wl-on-accent: #FFFFFF; /* text on coral fill */ + --wl-accent: #CF5630; /* deepened coral */ + --wl-accent-soft: rgba(207, 86, 48, 0.10); + --wl-on-accent: #F8F1E3; /* text on coral fill */ - --wl-danger: #B91C1C; /* deepened red for AA on light */ - --wl-danger-soft: rgba(239, 68, 68, 0.10); - --wl-ok: #047857; /* deepened emerald for AA on light */ - --wl-ok-soft: rgba(16, 185, 129, 0.12); + --wl-danger: #B23A28; /* deepened red for AA on light */ + --wl-danger-soft: rgba(226, 96, 78, 0.10); + --wl-ok: #2E7D52; /* deepened emerald for AA on light */ + --wl-ok-soft: rgba(95, 185, 142, 0.12); } /* ========================================================================== @@ -317,9 +317,9 @@ align-items: center; gap: var(--wl-space-2); padding: var(--wl-space-3) var(--wl-space-4); - background: #131E24; /* surface-raised — one step up from base */ + background: #1E1A13; /* surface-raised — one step up from base */ border-bottom: 1px solid var(--wl-border); - color: #8FAAB8; + color: #B6A78E; font-size: 0.72rem; } .wl-finding__dot { @@ -345,7 +345,7 @@ display: inline-block; width: 1.4rem; text-align: right; - color: #5A7D8C; /* text-muted */ + color: #7F6F58; /* text-muted */ user-select: none; margin-right: var(--wl-space-3); } @@ -358,12 +358,12 @@ /* Syntax tokens — terminal-grade, tuned to the design-system code-highlight palette (sky / violet / aqua / gold / coral). */ -.wl-tok-kw { color: #38BDF8; } /* keyword — sky */ -.wl-tok-dec { color: #E3B341; } /* decorator — gold */ -.wl-tok-fn { color: #4ECDC4; } /* function — aqua */ -.wl-tok-str { color: #10B981; } /* string — emerald */ -.wl-tok-cmt { color: #5A7D8C; font-style: italic; } /* comment — muted */ -.wl-tok-raw { color: #EF4444; font-weight: 600; } /* the raw leak — red */ +.wl-tok-kw { color: #56B7E2; } /* keyword — sky */ +.wl-tok-dec { color: #E9B04A; } /* decorator — gold */ +.wl-tok-fn { color: #52C9B8; } /* function — aqua */ +.wl-tok-str { color: #5FB98E; } /* string — emerald */ +.wl-tok-cmt { color: #7F6F58; font-style: italic; } /* comment — muted */ +.wl-tok-raw { color: #E2604E; font-weight: 600; } /* the raw leak — red */ /* The verdict strip under the code */ .wl-verdict { @@ -372,12 +372,12 @@ gap: var(--wl-space-3); padding: var(--wl-space-3) var(--wl-space-4); border-top: 1px solid var(--wl-border); - background: rgba(239, 68, 68, 0.12); /* stale-red wash, on the dark panel */ + background: rgba(226, 96, 78, 0.12); /* stale-red wash, on the dark panel */ } .wl-verdict__mark { flex: none; font-weight: 700; - color: #EF4444; + color: #E2604E; font-size: 1rem; line-height: 1.5; } @@ -385,17 +385,17 @@ font-family: var(--wl-font-mono); font-size: 0.74rem; line-height: 1.5; - color: #E2EEF2; /* always on the dark panel */ + color: #F2E9D8; /* always on the dark panel */ text-align: left; } .wl-verdict__rule { - color: #EF4444; + color: #E2604E; font-weight: 700; } /* INTEGRAL (claimed, trusted) reads green; EXTERNAL_RAW (actual, untrusted) reads red — the same most→least-trusted semantics as the lattice. */ -.wl-verdict__body .wl-tier-bad { color: #EF4444; font-weight: 600; } -.wl-verdict__body .wl-tier-good { color: #10B981; font-weight: 600; } +.wl-verdict__body .wl-tier-bad { color: #E2604E; font-weight: 600; } +.wl-verdict__body .wl-tier-good { color: #5FB98E; font-weight: 600; } /* ---- Trust-flow motif: EXTERNAL_RAW flowing into a trusted producer ---- */ .wl-flow { @@ -414,10 +414,10 @@ border: 1px solid currentColor; white-space: nowrap; } -.wl-flow__node--raw { color: #EF4444; } /* untrusted — stale red */ -.wl-flow__node--claim { color: #10B981; } /* claimed trusted — green */ +.wl-flow__node--raw { color: #E2604E; } /* untrusted — stale red */ +.wl-flow__node--claim { color: #5FB98E; } /* claimed trusted — green */ .wl-flow__arrow { - color: #5A7D8C; + color: #7F6F58; flex: none; } @@ -610,16 +610,16 @@ .wl-tier__tag--inf { color: var(--wl-text-muted); } /* Swatch hues — SEMANTIC ramp most→least trusted, anchored on the design-system - triplet: --ready emerald (#10B981) → --aging amber (#F59E0B) → --stale red - (#EF4444). The endpoints are the exact tokens; the interior steps interpolate + triplet: --ready emerald (#5FB98E) → --aging amber (#E9B04A) → --stale red + (#E2604E). The endpoints are the exact tokens; the interior steps interpolate in hue so the eight ordered states stay distinguishable. */ -.wl-sw-1 { background: #10B981; } /* INTEGRAL — --ready emerald */ +.wl-sw-1 { background: #5FB98E; } /* INTEGRAL — --ready emerald */ .wl-sw-2 { background: #34C77E; } /* ASSURED */ .wl-sw-3 { background: #74C66A; } /* GUARDED */ .wl-sw-4 { background: #C2BE4A; } /* UNKNOWN_ASSURED — nearing --aging */ -.wl-sw-5 { background: #F59E0B; } /* UNKNOWN_GUARDED — --aging amber */ +.wl-sw-5 { background: #E9B04A; } /* UNKNOWN_GUARDED — --aging amber */ .wl-sw-6 { background: #F4762A; } /* EXTERNAL_RAW */ -.wl-sw-7 { background: #EF4444; } /* UNKNOWN_RAW — --stale red */ +.wl-sw-7 { background: #E2604E; } /* UNKNOWN_RAW — --stale red */ .wl-sw-8 { background: #C0303A; } /* MIXED_RAW — deepest (most stale) */ /* ========================================================================== diff --git a/docs/stylesheets/weft-mkdocs.css b/docs/stylesheets/weft-mkdocs.css index 04f58d98..bc2a510f 100644 --- a/docs/stylesheets/weft-mkdocs.css +++ b/docs/stylesheets/weft-mkdocs.css @@ -1,5 +1,5 @@ /* ============================================================================ - weft-mkdocs.css · v1 · 2026-06-05 + weft-mkdocs.css · v2 · 2026-06-07 ---------------------------------------------------------------------------- The Weft Federation shared docs theme for MkDocs Material. @@ -16,8 +16,10 @@ light-dark()/[data-theme]. We map the handoff tokens onto Material's own scheme selectors: [data-md-color-scheme="slate"] = dark (CANONICAL), [data-md-color-scheme="default"] = light. - - Token VALUES are lifted verbatim from the design system (terminal-grade - dark-teal surfaces, sky accent, the per-member thread palette, radii). + - Token VALUES are lifted verbatim from the design system. This is the "Loom" + revision: the dark canonical scheme is a warm espresso ground with a dyed- + amber accent and the warmed per-member thread palette; the light scheme is + "Specimen" — a warm-paper field-ledger with oxblood-red ink. - Fonts: JetBrains Mono = product/body face; Space Grotesk = brand/headings. Bundled locally (OFL) so the site works fully offline; mkdocs.yml sets `font: false` so Material pulls no Google Fonts. @@ -58,14 +60,14 @@ --weft-radius: 6px; --weft-radius-lg: 8px; - /* The Weft thread palette — one accent per member (legible on dark base). */ - --thread-loomweave: #4ECDC4; - --thread-filigree: #38BDF8; - --thread-wardline: #F4845F; - --thread-legis: #A78BFA; - --thread-charter: #E3B341; - --thread-shuttle: #6B7C8C; - --lacuna-accent: #CE7AAE; + /* The Weft thread palette — one accent per member (warmed for "Loom"). */ + --thread-loomweave: #52C9B8; + --thread-filigree: #56B7E2; + --thread-wardline: #F0875E; + --thread-legis: #B79BF2; + --thread-charter: #E9B04A; + --thread-shuttle: #8C7C68; + --lacuna-accent: #C77FA6; /* Wire Material's own font slots to the Weft faces (font:false in mkdocs.yml means Material emits --md-text/code-font-family but loads no webfont). */ @@ -74,115 +76,117 @@ } /* =========================================================================== - DARK / slate scheme — the CANONICAL DEFAULT + DARK / slate scheme — the CANONICAL DEFAULT ("Loom") Surfaces, text ramp, accent: design-system dark tokens. =========================================================================== */ [data-md-color-scheme="slate"] { - /* hue used by Material to derive its own slate ramp; keep it teal-leaning */ - --md-hue: 200; + /* hue used by Material to derive its own slate ramp; keep it warm/amber */ + --md-hue: 35; /* Surfaces (design-system: base/raised/overlay/hover) */ - --md-default-bg-color: #0B1215; /* surface-base */ - --md-default-bg-color--light: #131E24; /* surface-raised */ - --md-default-bg-color--lighter: #1A2B34; /* surface-overlay */ - --md-default-bg-color--lightest:#243A45; /* surface-hover */ + --md-default-bg-color: #14110D; /* surface-base */ + --md-default-bg-color--light: #1E1A13; /* surface-raised */ + --md-default-bg-color--lighter: #2A2319; /* surface-overlay */ + --md-default-bg-color--lightest:#39301F; /* surface-hover */ /* Text ramp (primary / secondary / muted) */ - --md-default-fg-color: #E2EEF2; /* text-primary */ - --md-default-fg-color--light: #8FAAB8; /* text-secondary*/ - --md-default-fg-color--lighter: #5A7D8C; /* text-muted */ - --md-default-fg-color--lightest:#1E3340; /* hairline tint */ - - /* Primary chrome (header/nav) = raised surface, sky accent for marks/links */ - --md-primary-fg-color: #131E24; - --md-primary-fg-color--light: #1A2B34; - --md-primary-fg-color--dark: #0B1215; - --md-primary-bg-color: #E2EEF2; - --md-primary-bg-color--light: #8FAAB8; - - /* Accent — sky (the suite's interactive blue) */ - --md-accent-fg-color: #38BDF8; - --md-accent-fg-color--transparent: rgba(56, 189, 248, 0.10); - --md-accent-bg-color: #0B1215; - --md-accent-bg-color--light: #131E24; + --md-default-fg-color: #F2E9D8; /* text-primary */ + --md-default-fg-color--light: #B6A78E; /* text-secondary*/ + --md-default-fg-color--lighter: #7F6F58; /* text-muted */ + --md-default-fg-color--lightest:#332A1F; /* hairline tint (border-default) */ + + /* Primary chrome (header/nav) = raised surface, amber accent for marks/links */ + --md-primary-fg-color: #1E1A13; + --md-primary-fg-color--light: #2A2319; + --md-primary-fg-color--dark: #14110D; + --md-primary-bg-color: #F2E9D8; + --md-primary-bg-color--light: #B6A78E; + + /* Accent — dyed amber (the suite's interactive thread) */ + --md-accent-fg-color: #E9B04A; + --md-accent-fg-color--transparent: rgba(233, 176, 74, 0.10); + --md-accent-bg-color: #14110D; + --md-accent-bg-color--light: #1E1A13; /* Links resolve through the typeset link var below */ - --md-typeset-a-color: #38BDF8; + --md-typeset-a-color: #E9B04A; /* Code surfaces */ - --md-code-bg-color: #131E24; - --md-code-fg-color: #E2EEF2; - --md-code-hl-color: rgba(56, 189, 248, 0.15); - - /* Syntax highlight (Pygments) — dark, terminal-grade */ - --md-code-hl-comment-color: #5A7D8C; - --md-code-hl-keyword-color: #A78BFA; /* violet */ - --md-code-hl-string-color: #4ECDC4; /* aqua */ - --md-code-hl-number-color: #E3B341; /* gold */ - --md-code-hl-name-color: #E2EEF2; - --md-code-hl-function-color: #38BDF8; /* sky */ - --md-code-hl-constant-color: #F4845F; /* coral */ - --md-code-hl-operator-color: #8FAAB8; - --md-code-hl-punctuation-color:#8FAAB8; - --md-code-hl-variable-color: #E2EEF2; - --md-code-hl-special-color: #CE7AAE; + --md-code-bg-color: #1E1A13; + --md-code-fg-color: #F2E9D8; + --md-code-hl-color: rgba(233, 176, 74, 0.15); + + /* Syntax highlight (Pygments) — warm, terminal-grade */ + --md-code-hl-comment-color: #7F6F58; + --md-code-hl-keyword-color: #B79BF2; /* violet */ + --md-code-hl-string-color: #52C9B8; /* aqua */ + --md-code-hl-number-color: #E9B04A; /* gold */ + --md-code-hl-name-color: #F2E9D8; + --md-code-hl-function-color: #56B7E2; /* sky */ + --md-code-hl-constant-color: #F0875E; /* coral */ + --md-code-hl-operator-color: #B6A78E; + --md-code-hl-punctuation-color:#B6A78E; + --md-code-hl-variable-color: #F2E9D8; + --md-code-hl-special-color: #C77FA6; /* Selection / footer */ - --md-typeset-mark-color: rgba(227, 179, 65, 0.30); - --md-footer-bg-color: #0B1215; - --md-footer-bg-color--dark: #070D0F; - --md-footer-fg-color: #E2EEF2; - --md-footer-fg-color--light: #8FAAB8; - --md-footer-fg-color--lighter:#5A7D8C; + --md-typeset-mark-color: rgba(233, 176, 74, 0.30); + --md-footer-bg-color: #14110D; + --md-footer-bg-color--dark: #0C0A07; + --md-footer-fg-color: #F2E9D8; + --md-footer-fg-color--light: #B6A78E; + --md-footer-fg-color--lighter:#7F6F58; } /* =========================================================================== - LIGHT / default scheme — the design system's documented alternate + LIGHT / default scheme — the design system's documented alternate ("Specimen") + Warm-paper field-ledger with oxblood-red ink. =========================================================================== */ [data-md-color-scheme="default"] { - --md-default-bg-color: #F0F6F8; /* surface-base (light) */ - --md-default-bg-color--light: #FFFFFF; /* surface-raised */ - --md-default-bg-color--lighter: #E8F1F4; /* surface-overlay*/ - --md-default-bg-color--lightest:#DCE9EE; /* surface-hover */ - - --md-default-fg-color: #0F2027; /* text-primary */ - --md-default-fg-color--light: #3D6070; /* text-secondary*/ - --md-default-fg-color--lighter: #6B8D9C; /* text-muted */ - --md-default-fg-color--lightest:#C5D8E0; /* hairline tint */ - - --md-primary-fg-color: #FFFFFF; - --md-primary-fg-color--light: #E8F1F4; - --md-primary-fg-color--dark: #DCE9EE; - --md-primary-bg-color: #0F2027; - --md-primary-bg-color--light: #3D6070; - - /* Accent — deeper sky for AA contrast on white */ - --md-accent-fg-color: #0284C7; - --md-accent-fg-color--transparent: rgba(2, 132, 199, 0.10); - --md-accent-bg-color: #FFFFFF; - --md-accent-bg-color--light: #F0F6F8; - - --md-typeset-a-color: #0284C7; - - --md-code-bg-color: #E8F1F4; - --md-code-fg-color: #0F2027; - --md-code-hl-color: rgba(2, 132, 199, 0.12); - - --md-code-hl-comment-color: #6B8D9C; - --md-code-hl-keyword-color: #7C3AED; - --md-code-hl-string-color: #0F766E; - --md-code-hl-number-color: #B45309; - --md-code-hl-function-color: #0284C7; - --md-code-hl-constant-color: #C2410C; - --md-code-hl-operator-color: #3D6070; - --md-code-hl-punctuation-color:#3D6070; - - --md-typeset-mark-color: rgba(2, 132, 199, 0.18); - --md-footer-bg-color: #0F2027; - --md-footer-bg-color--dark: #0B1215; - --md-footer-fg-color: #E2EEF2; - --md-footer-fg-color--light: #8FAAB8; - --md-footer-fg-color--lighter:#5A7D8C; + --md-default-bg-color: #ECE3D1; /* surface-base (paper) */ + --md-default-bg-color--light: #F8F1E3; /* surface-raised (stock) */ + --md-default-bg-color--lighter: #E2D7BF; /* surface-overlay*/ + --md-default-bg-color--lightest:#D8CBB1; /* surface-hover */ + + --md-default-fg-color: #241E13; /* text-primary */ + --md-default-fg-color--light: #5C5238; /* text-secondary*/ + --md-default-fg-color--lighter: #897B5F; /* text-muted */ + --md-default-fg-color--lightest:#CDBE9F; /* hairline tint (border-default) */ + + --md-primary-fg-color: #F8F1E3; + --md-primary-fg-color--light: #E2D7BF; + --md-primary-fg-color--dark: #D8CBB1; + --md-primary-bg-color: #241E13; + --md-primary-bg-color--light: #5C5238; + + /* Accent — oxblood / madder (the ruling-red ink) */ + --md-accent-fg-color: #A33B2C; + --md-accent-fg-color--transparent: rgba(163, 59, 44, 0.10); + --md-accent-bg-color: #F8F1E3; + --md-accent-bg-color--light: #ECE3D1; + + --md-typeset-a-color: #A33B2C; + + --md-code-bg-color: #E2D7BF; + --md-code-fg-color: #241E13; + --md-code-hl-color: rgba(163, 59, 44, 0.12); + + /* Syntax highlight (Pygments) — embroidery-floss threads on paper */ + --md-code-hl-comment-color: #897B5F; + --md-code-hl-keyword-color: #6E4FC0; /* violet */ + --md-code-hl-string-color: #118C7E; /* aqua */ + --md-code-hl-number-color: #A9791F; /* gold */ + --md-code-hl-function-color: #1E7AB0; /* sky */ + --md-code-hl-constant-color: #CF5630; /* coral */ + --md-code-hl-operator-color: #5C5238; + --md-code-hl-punctuation-color:#5C5238; + + --md-typeset-mark-color: rgba(163, 59, 44, 0.18); + --md-footer-bg-color: #241E13; + --md-footer-bg-color--dark: #14110D; + --md-footer-fg-color: #F2E9D8; + --md-footer-fg-color--light: #B6A78E; + --md-footer-fg-color--lighter:#7F6F58; } /* =========================================================================== @@ -225,29 +229,29 @@ border-left: 4px solid var(--md-accent-fg-color); } -/* note/info → sky */ +/* note/info → sky (status-wip, the cool pop) */ .md-typeset .admonition.note, -.md-typeset .admonition.info { border-left-color: #38BDF8; } +.md-typeset .admonition.info { border-left-color: #56B7E2; } .md-typeset .note > .admonition-title, -.md-typeset .info > .admonition-title { background-color: rgba(56,189,248,0.12); } +.md-typeset .info > .admonition-title { background-color: rgba(86,183,226,0.12); } /* tip/success → emerald (ready) */ .md-typeset .admonition.tip, -.md-typeset .admonition.success { border-left-color: #10B981; } +.md-typeset .admonition.success { border-left-color: #5FB98E; } .md-typeset .tip > .admonition-title, -.md-typeset .success > .admonition-title { background-color: rgba(16,185,129,0.12); } +.md-typeset .success > .admonition-title { background-color: rgba(95,185,142,0.12); } /* warning → amber (aging) */ -.md-typeset .admonition.warning { border-left-color: #F59E0B; } -.md-typeset .warning > .admonition-title { background-color: rgba(245,158,11,0.12); } +.md-typeset .admonition.warning { border-left-color: #E9B04A; } +.md-typeset .warning > .admonition-title { background-color: rgba(233,176,74,0.12); } /* danger/bug/failure → red (stale) */ .md-typeset .admonition.danger, .md-typeset .admonition.bug, -.md-typeset .admonition.failure { border-left-color: #EF4444; } +.md-typeset .admonition.failure { border-left-color: #E2604E; } .md-typeset .danger > .admonition-title, .md-typeset .bug > .admonition-title, -.md-typeset .failure > .admonition-title { background-color: rgba(239,68,68,0.12); } +.md-typeset .failure > .admonition-title { background-color: rgba(226,96,78,0.12); } /* =========================================================================== Radii — apply the 3/6/8 system to the surfaces Material rounds. diff --git a/docs/superpowers/plans/2026-06-07-wardline-doctor-filigree-token-check.md b/docs/superpowers/plans/2026-06-07-wardline-doctor-filigree-token-check.md new file mode 100644 index 00000000..ac9d1721 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-wardline-doctor-filigree-token-check.md @@ -0,0 +1,853 @@ +# wardline doctor — filigree federation-token check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `filigree.auth` check to `wardline doctor` that probes the configured Filigree daemon with the token wardline would emit, detects a 401/403 token mismatch, and under `--repair` recovers the accepted token from local mints and pins it as `WEFT_FEDERATION_TOKEN` in `.env`. + +**Architecture:** A new `FiligreeEmitter.verify_token()` probe (sentinel-body POST, reusing the existing injectable `Transport` seam) provides the live auth check. `install/doctor.py` gains `_check_filigree_auth` plus helpers for probe-URL resolution (flag → env → `.mcp.json` arg → published port), candidate-token gathering, and a surgical `.env` rewrite. The check is wired into `machine_readable_doctor` (JSON path) and surfaced in the human `doctor` / `--repair` output. + +**Tech Stack:** Python 3.13, click, stdlib `urllib`/`json`/`tomllib`, pytest. Zero new deps. + +**Spec:** `docs/superpowers/specs/2026-06-07-wardline-doctor-filigree-token-check-design.md` + +--- + +## File Structure + +- `src/wardline/core/filigree_emit.py` — add `ProbeResult` dataclass + `FiligreeEmitter.verify_token()`. +- `src/wardline/install/doctor.py` — add `_check_filigree_auth` + helpers (`_resolve_probe_url`, `_mcp_filigree_url`, `_is_loopback`, `_filigree_token_candidates`, `_rewrite_env_token`); wire into `machine_readable_doctor`. +- `src/wardline/cli/doctor.py` — add optional `--filigree-url`; surface `filigree.auth` in human output. +- `tests/unit/core/test_filigree_verify_token.py` — probe classification. +- `tests/unit/install/test_doctor_filigree_auth.py` — resolution, detection, repair, `.env` rewrite. +- `tests/unit/cli/test_doctor.py` — CLI flag + not-configured integration (append). + +--- + +## Task 1: `FiligreeEmitter.verify_token()` probe + +**Files:** +- Modify: `src/wardline/core/filigree_emit.py` (add `ProbeResult` near `EmitResult`; add `verify_token` method on `FiligreeEmitter`) +- Test: `tests/unit/core/test_filigree_verify_token.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/unit/core/test_filigree_verify_token.py +from collections.abc import Mapping + +import pytest + +from wardline.core.filigree_emit import FiligreeEmitter, Response + + +class _FakeTransport: + """Records the last POST and returns a canned Response or raises.""" + + def __init__(self, *, status: int | None = None, exc: Exception | None = None) -> None: + self._status = status + self._exc = exc + self.calls: list[tuple[str, bytes, dict[str, str]]] = [] + + def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: + self.calls.append((url, body, dict(headers))) + if self._exc is not None: + raise self._exc + assert self._status is not None + return Response(status=self._status, body="") + + +def test_verify_token_401_is_rejected() -> None: + t = _FakeTransport(status=401) + result = FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="bad").verify_token() + assert result.reachable is True + assert result.accepted is False + assert result.status == 401 + + +def test_verify_token_400_is_accepted() -> None: + # Auth middleware runs before body validation: a good token + sentinel body => 400. + t = _FakeTransport(status=400) + result = FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="good").verify_token() + assert result.accepted is True + assert result.status == 400 + # Sentinel body present; bearer attached. + url, body, headers = t.calls[0] + assert headers["Authorization"] == "Bearer good" + assert body # non-empty sentinel + + +def test_verify_token_403_is_rejected() -> None: + result = FiligreeEmitter("http://x/y", transport=_FakeTransport(status=403), token="t").verify_token() + assert result.accepted is False + + +def test_verify_token_transport_error_is_unreachable() -> None: + t = _FakeTransport(exc=OSError("connection refused")) + result = FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="t").verify_token() + assert result.reachable is False + assert result.accepted is False + assert result.status is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/core/test_filigree_verify_token.py -q` +Expected: FAIL — `AttributeError: 'FiligreeEmitter' object has no attribute 'verify_token'`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/wardline/core/filigree_emit.py`, add the `ProbeResult` dataclass immediately after the `EmitResult` class (before `filigree_disabled_reason`): + +```python +@dataclass(frozen=True, slots=True) +class ProbeResult: + """Outcome of an auth probe (verify_token). ``accepted`` is True when the daemon + authenticated the bearer (any non-401/403 status, e.g. a 400 from the sentinel body). + ``reachable`` is False only on a transport failure (connection refused / timeout).""" + + reachable: bool + accepted: bool + status: int | None = None +``` + +Add the method to `FiligreeEmitter` (after `emit`): + +```python + def verify_token(self) -> ProbeResult: + """Probe whether the daemon accepts this emitter's bearer token, WITHOUT + recording anything. Auth runs in middleware before body validation, so a + deliberately-incomplete sentinel body yields 400 (auth passed) or 401/403 + (rejected). Never reuses emit() — that would POST a valid empty scan.""" + body = b"{}" # parses as JSON, missing required scan-results fields => 400 when authed + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + try: + resp = self._transport.post(self._url, body, headers) + except (urllib.error.URLError, OSError): + return ProbeResult(reachable=False, accepted=False) + accepted = resp.status not in (401, 403) + return ProbeResult(reachable=True, accepted=accepted, status=resp.status) +``` + +(`urllib` is already imported at the top of the module.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/core/test_filigree_verify_token.py -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/core/filigree_emit.py tests/unit/core/test_filigree_verify_token.py +git commit -m "feat(emit): add FiligreeEmitter.verify_token auth probe" +``` + +--- + +## Task 2: surgical `.env` token rewriter + +**Files:** +- Modify: `src/wardline/install/doctor.py` (add `_rewrite_env_token`) +- Test: `tests/unit/install/test_doctor_filigree_auth.py` (new file; first tests) + +- [ ] **Step 1: Write the failing test** + +```python +# tests/unit/install/test_doctor_filigree_auth.py +import stat +from pathlib import Path + +from wardline.install.doctor import _rewrite_env_token + + +def test_rewrite_env_sets_new_name_and_drops_legacy(tmp_path: Path) -> None: + env = tmp_path / ".env" + env.write_text( + "WARDLINE_ATTEST_KEY=keep-me\nWARDLINE_FILIGREE_TOKEN=stale\nOTHER=x\n", + encoding="utf-8", + ) + _rewrite_env_token(env, "GOODTOKEN") + text = env.read_text(encoding="utf-8") + assert "WEFT_FEDERATION_TOKEN=GOODTOKEN" in text + assert "WARDLINE_FILIGREE_TOKEN" not in text # stale legacy line removed + assert "WARDLINE_ATTEST_KEY=keep-me" in text # unrelated line preserved + assert "OTHER=x" in text + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + + +def test_rewrite_env_updates_existing_new_name(tmp_path: Path) -> None: + env = tmp_path / ".env" + env.write_text("WEFT_FEDERATION_TOKEN=old\nKEEP=1\n", encoding="utf-8") + _rewrite_env_token(env, "NEW") + text = env.read_text(encoding="utf-8") + assert text.count("WEFT_FEDERATION_TOKEN=") == 1 + assert "WEFT_FEDERATION_TOKEN=NEW" in text + assert "KEEP=1" in text + + +def test_rewrite_env_creates_file_when_absent(tmp_path: Path) -> None: + env = tmp_path / ".env" + _rewrite_env_token(env, "NEW") + assert env.read_text(encoding="utf-8").strip() == "WEFT_FEDERATION_TOKEN=NEW" + assert stat.S_IMODE(env.stat().st_mode) == 0o600 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: FAIL — `ImportError: cannot import name '_rewrite_env_token'`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/wardline/install/doctor.py`, add near the other helpers: + +```python +def _rewrite_env_token(env_path: Path, value: str) -> None: + """Surgically pin ``WEFT_FEDERATION_TOKEN=`` in *env_path*. Removes any + existing ``WEFT_FEDERATION_TOKEN`` or legacy ``WARDLINE_FILIGREE_TOKEN`` line, + preserves all other lines/order, creates the file if absent, and sets mode 0600 + (the file holds a secret).""" + drop = ("WEFT_FEDERATION_TOKEN=", "WARDLINE_FILIGREE_TOKEN=") + kept: list[str] = [] + if env_path.is_file(): + for raw in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): + if raw.strip().startswith(drop): + continue + kept.append(raw) + kept.append(f"WEFT_FEDERATION_TOKEN={value}") + env_path.write_text("\n".join(kept) + "\n", encoding="utf-8") + env_path.chmod(0o600) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_filigree_auth.py +git commit -m "feat(doctor): add surgical .env federation-token rewriter" +``` + +--- + +## Task 3: probe-URL resolution + loopback helper + +**Files:** +- Modify: `src/wardline/install/doctor.py` (add `_mcp_filigree_url`, `_resolve_probe_url`, `_is_loopback`) +- Test: `tests/unit/install/test_doctor_filigree_auth.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/unit/install/test_doctor_filigree_auth.py +import json + +from wardline.install.doctor import _is_loopback, _mcp_filigree_url, _resolve_probe_url + + +def _write_mcp_with_filigree_url(root: Path, url: str) -> None: + root.joinpath(".mcp.json").write_text( + json.dumps( + {"mcpServers": {"wardline": {"type": "stdio", "command": "wardline", + "args": ["mcp", "--root", ".", "--filigree-url", url]}}} + ), + encoding="utf-8", + ) + + +def test_mcp_filigree_url_extracts_arg(tmp_path: Path) -> None: + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + assert _mcp_filigree_url(tmp_path) == "http://127.0.0.1:8749/api/weft/scan-results" + + +def test_mcp_filigree_url_none_when_absent(tmp_path: Path) -> None: + tmp_path.joinpath(".mcp.json").write_text( + json.dumps({"mcpServers": {"wardline": {"args": ["mcp", "--root", "."]}}}), encoding="utf-8" + ) + assert _mcp_filigree_url(tmp_path) is None + assert _mcp_filigree_url(tmp_path / "nope") is None # missing file + + +def test_resolve_probe_url_precedence(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + # flag wins + assert _resolve_probe_url(tmp_path, "http://flag/x") == "http://flag/x" + # env beats .mcp.json + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://env/y") + assert _resolve_probe_url(tmp_path, None) == "http://env/y" + # .mcp.json arg is the fallback that makes the real setup work + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + assert _resolve_probe_url(tmp_path, None) == "http://127.0.0.1:8749/api/weft/scan-results" + + +def test_resolve_probe_url_none_when_unconfigured(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + assert _resolve_probe_url(tmp_path, None) is None + + +def test_is_loopback() -> None: + assert _is_loopback("http://127.0.0.1:8749/x") is True + assert _is_loopback("http://localhost:8749/x") is True + assert _is_loopback("http://[::1]:8749/x") is True + assert _is_loopback("https://filigree.example.com/x") is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: FAIL — `ImportError: cannot import name '_mcp_filigree_url'`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/wardline/install/doctor.py` (the module already imports `json`, `os`, and `urlsplit` from `urllib.parse`), add the helpers. Note the probe URL is resolved ONLY from a deliberately-configured emit target (flag → env → `.mcp.json --filigree-url` arg); the published-port rung is intentionally NOT used, so doctor does no network unless filigree emit is explicitly wired (this also keeps the existing doctor tests network-free): + +```python +_FILIGREE_URL_ENV = "WARDLINE_FILIGREE_URL" + + +def _mcp_filigree_url(root: Path) -> str | None: + """The ``--filigree-url`` value from the wardline server entry in ``.mcp.json``, + or None. This is the URL the agent's MCP server actually emits to, and the only + place it is recorded in the common (MCP) setup.""" + path = root / ".mcp.json" + if not path.is_file(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + try: + args = raw["mcpServers"]["wardline"]["args"] + idx = args.index("--filigree-url") + value = args[idx + 1] + except (KeyError, TypeError, ValueError, IndexError): + return None + return value if isinstance(value, str) else None + + +def _resolve_probe_url(root: Path, flag: str | None) -> str | None: + """Probe-URL precedence: flag > WARDLINE_FILIGREE_URL env > .mcp.json wardline + --filigree-url arg. None when nothing resolves. The published-port rung is + deliberately excluded: doctor probes only a configured emit target, so it does + no network unless filigree emit is explicitly wired.""" + if flag: + return flag + env = os.environ.get(_FILIGREE_URL_ENV) + if env: + return env + return _mcp_filigree_url(root) + + +def _is_loopback(url: str) -> bool: + """True when *url*'s host is loopback — the only origins a bearer is probed against.""" + host = (urlsplit(url).hostname or "").lower() + return host in {"localhost", "::1"} or host.startswith("127.") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_filigree_auth.py +git commit -m "feat(doctor): resolve filigree probe URL (incl. .mcp.json arg) + loopback guard" +``` + +--- + +## Task 4: `_check_filigree_auth` — detection (no repair) + +**Files:** +- Modify: `src/wardline/install/doctor.py` (add `_filigree_token_candidates`, `_check_filigree_auth`) +- Test: `tests/unit/install/test_doctor_filigree_auth.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/unit/install/test_doctor_filigree_auth.py +from collections.abc import Mapping + +from wardline.core.filigree_emit import Response +from wardline.install.doctor import _check_filigree_auth + + +class _ScriptedTransport: + """Returns a Response per token: maps Authorization bearer -> status.""" + + def __init__(self, status_by_token: dict[str, int], *, unreachable: bool = False) -> None: + self._status_by_token = status_by_token + self._unreachable = unreachable + + def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: + if self._unreachable: + raise OSError("connection refused") + token = headers.get("Authorization", "").removeprefix("Bearer ") + return Response(status=self._status_by_token.get(token, 401), body="") + + +def _setup_lacuna(root: Path, monkeypatch, env_token: str) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + _write_mcp_with_filigree_url(root, "http://127.0.0.1:8749/api/weft/scan-results") + root.joinpath(".env").write_text(f"WARDLINE_FILIGREE_TOKEN={env_token}\n", encoding="utf-8") + + +def test_check_detects_rejected_token(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + t = _ScriptedTransport({"GOOD": 400}) # daemon accepts GOOD; STALE -> 401 + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "error" + assert "rejected" in (check.message or "") + assert check.fixed is False + + +def test_check_ok_when_token_accepted(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="GOOD") + t = _ScriptedTransport({"GOOD": 400}) + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "ok" + + +def test_check_error_when_token_absent(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({})) + assert check.status == "error" + assert "no federation token" in (check.message or "") + + +def test_check_ok_when_auth_off_and_no_token(tmp_path: Path, monkeypatch) -> None: + # Daemon has auth OFF: it accepts an unauthenticated emit ("" bearer -> 400). No token + # configured is fine — not an error. + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + t = _ScriptedTransport({"": 400}) # empty (no) bearer is accepted + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "ok" + + +def test_check_ok_when_unreachable(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({}, unreachable=True)) + assert check.status == "ok" + assert "not reachable" in (check.message or "") + + +def test_check_ok_when_non_loopback(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setenv("WEFT_FEDERATION_TOKEN", "T") + check = _check_filigree_auth(tmp_path, repair=False, filigree_url="https://remote.example.com/api/weft/scan-results", + transport=_ScriptedTransport({})) + assert check.status == "ok" + assert "non-loopback" in (check.message or "") + + +def test_check_ok_when_url_unresolved(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({})) + assert check.status == "ok" + assert "not configured" in (check.message or "") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: FAIL — `ImportError: cannot import name '_check_filigree_auth'`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/wardline/install/doctor.py`, add the import for the emitter and token loader at the top with the other imports: + +```python +from wardline.core.filigree_emit import FiligreeEmitter, Transport, UrllibTransport +from wardline.filigree.config import load_filigree_token +``` + +Add the candidate helper and the check (detection only; the `repair` branch is filled in Task 5): + +```python +def _filigree_token_candidates(root: Path) -> list[str]: + """Locally-readable federation-token mints, in precedence order: the server-mode + store (~/.config/filigree) then the project store (/.weft/filigree). Returns + distinct, non-empty values.""" + paths = [ + Path.home() / ".config" / "filigree" / "federation_token", + root / ".weft" / "filigree" / "federation_token", + ] + out: list[str] = [] + for p in paths: + try: + value = p.read_text(encoding="utf-8").strip() + except OSError: + continue + if value and value not in out: + out.append(value) + return out + + +def _check_filigree_auth( + root: Path, + *, + repair: bool, + filigree_url: str | None = None, + transport: Transport | None = None, +) -> DoctorCheck: + """Verify the token wardline would emit is accepted by the configured Filigree + daemon. Read-only probe; under *repair*, recover the accepted token from local + mints and pin it in .env. The probe targets only loopback origins.""" + probe_transport = transport if transport is not None else UrllibTransport(timeout=2.0) + url = _resolve_probe_url(root, filigree_url) + if url is None: + return DoctorCheck("filigree.auth", "ok", message="filigree not configured; nothing to verify") + if not _is_loopback(url): + return DoctorCheck("filigree.auth", "ok", message="non-loopback filigree; token not probed") + token = load_filigree_token(root) # may be None — probe anyway (the daemon may have auth off) + probe = FiligreeEmitter(url, transport=probe_transport, token=token).verify_token() + if not probe.reachable: + return DoctorCheck("filigree.auth", "ok", message="filigree daemon not reachable; token not verified") + if probe.accepted: + return DoctorCheck("filigree.auth", "ok") + # Rejected (401/403): filigree auth is on and our credential is not accepted. + if repair: + return _repair_filigree_auth(root, url, probe_transport) # implemented in Task 5 + if token: + return DoctorCheck( + "filigree.auth", "error", + message=f"emit token rejected by filigree ({probe.status}); " + "the configured token is not what the daemon accepts", + ) + return DoctorCheck( + "filigree.auth", "error", + message="filigree rejected an unauthenticated emit and no federation token is set; " + "export WEFT_FEDERATION_TOKEN or add it to .env", + ) +``` + +For this task, add a temporary stub so the module imports while Task 5 is pending (it is replaced in Task 5): + +```python +def _repair_filigree_auth(root: Path, url: str, transport: Transport) -> DoctorCheck: + return DoctorCheck("filigree.auth", "error", message="repair not yet implemented") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: PASS (detection tests green; repair path not yet exercised). + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_filigree_auth.py +git commit -m "feat(doctor): detect filigree emit-token mismatch via live probe" +``` + +--- + +## Task 5: `_repair_filigree_auth` — recover + pin the accepted token + +**Files:** +- Modify: `src/wardline/install/doctor.py` (replace the `_repair_filigree_auth` stub) +- Test: `tests/unit/install/test_doctor_filigree_auth.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/unit/install/test_doctor_filigree_auth.py +def test_repair_writes_accepted_candidate(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + # server-mode store holds the accepted token + cfg = tmp_path / "home" / ".config" / "filigree" + cfg.mkdir(parents=True) + (cfg / "federation_token").write_text("GOOD\n", encoding="utf-8") + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "home") + t = _ScriptedTransport({"GOOD": 400}) # GOOD accepted, STALE -> 401 + + check = _check_filigree_auth(tmp_path, repair=True, transport=t) + + assert check.status == "ok" + assert check.fixed is True + env_text = (tmp_path / ".env").read_text(encoding="utf-8") + assert "WEFT_FEDERATION_TOKEN=GOOD" in env_text + assert "WARDLINE_FILIGREE_TOKEN" not in env_text + + +def test_repair_no_candidate_matches_does_not_write(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + cfg = tmp_path / "home" / ".config" / "filigree" + cfg.mkdir(parents=True) + (cfg / "federation_token").write_text("ALSO-WRONG\n", encoding="utf-8") + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "home") + t = _ScriptedTransport({"GOOD": 400}) # neither STALE nor ALSO-WRONG is accepted + + check = _check_filigree_auth(tmp_path, repair=True, transport=t) + + assert check.status == "error" + assert "no local federation_token matched" in (check.message or "") + assert "WARDLINE_FILIGREE_TOKEN=STALE" in (tmp_path / ".env").read_text(encoding="utf-8") # untouched +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: FAIL — repair returns the "not yet implemented" stub. + +- [ ] **Step 3: Write minimal implementation** + +Replace the `_repair_filigree_auth` stub in `src/wardline/install/doctor.py`: + +```python +def _repair_filigree_auth(root: Path, url: str, transport: Transport) -> DoctorCheck: + """A 401/403 was seen. Probe each locally-readable mint; if exactly one is + accepted, pin it as WEFT_FEDERATION_TOKEN in .env and confirm. Otherwise write + nothing and report (the daemon likely uses an env override we cannot read).""" + for candidate in _filigree_token_candidates(root): + probe = FiligreeEmitter(url, transport=transport, token=candidate).verify_token() + if probe.reachable and probe.accepted: + _rewrite_env_token(root / ".env", candidate) + return DoctorCheck( + "filigree.auth", "ok", fixed=True, + message="wrote WEFT_FEDERATION_TOKEN to .env (was a stale/mismatched token)", + ) + return DoctorCheck( + "filigree.auth", "error", + message="no local federation_token matched the daemon — it likely uses a " + "WEFT_FEDERATION_TOKEN env override; set that same value in .env", + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/install/test_doctor_filigree_auth.py -q` +Expected: PASS (all detection + repair tests green). + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py tests/unit/install/test_doctor_filigree_auth.py +git commit -m "feat(doctor): repair recovers accepted federation token into .env" +``` + +--- + +## Task 6: wire into machine_readable_doctor + CLI + +**Files:** +- Modify: `src/wardline/install/doctor.py` (`machine_readable_doctor` signature + append check) +- Modify: `src/wardline/cli/doctor.py` (`--filigree-url` option; surface `filigree.auth` in human output; pass through to JSON path) +- Test: `tests/unit/cli/test_doctor.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/unit/cli/test_doctor.py +def test_doctor_accepts_filigree_url_flag_and_reports_not_configured(tmp_path: Path, monkeypatch) -> None: + # No filigree wiring (no .mcp.json arg, no env, no port) => filigree.auth is ok/not-configured, + # so doctor does no network and the new flag is accepted. + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + repair = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert repair.exit_code == 0, repair.output + + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "filigree.auth" in result.output + + +def test_doctor_fix_json_includes_filigree_auth_check(tmp_path: Path, monkeypatch) -> None: + import json as _json + + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--fix"]) + payload = _json.loads(result.output) + ids = [c["id"] for c in payload["checks"]] + assert "filigree.auth" in ids +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/unit/cli/test_doctor.py -q` +Expected: FAIL — `--filigree-url` is an unknown option / `filigree.auth` not in output. + +- [ ] **Step 3: Write minimal implementation** + +In `src/wardline/install/doctor.py`, update `machine_readable_doctor`: + +```python +def machine_readable_doctor( + root: Path, + *, + fix: bool = False, + filigree_url: str | None = None, + transport: Transport | None = None, +) -> dict[str, Any]: +``` + +and append the new check to the `checks` list (after `_check_auth_token`): + +```python + checks.append(_check_auth_token(root)) + checks.append(_check_filigree_auth(root, repair=fix, filigree_url=filigree_url, transport=transport)) +``` + +In `src/wardline/cli/doctor.py`, add the import and option, and surface the check in the human paths: + +```python +from wardline.install.doctor import ( + _check_filigree_auth, + check_install, + machine_readable_doctor, + repair_install, +) +``` + +Add the option decorator (after `--fix`): + +```python +@click.option("--filigree-url", default=None, help="Filigree Weft URL to probe (default: resolve from .mcp.json/env).") +``` + +Update the signature: `def doctor(root: Path, repair: bool, fix_json: bool, filigree_url: str | None) -> None:` + +In the `--fix` branch, pass it through: + +```python + payload = machine_readable_doctor(root, fix=True, filigree_url=filigree_url) +``` + +In the `--repair` branch, after the install-check loop and before the exit decision, add: + +```python + fcheck = _check_filigree_auth(root, repair=True, filigree_url=filigree_url) + status = ("fixed" if fcheck.fixed else fcheck.message) if fcheck.ok else f"failed ({fcheck.message})" + click.echo(f" filigree.auth: {status}") + if not all(check.ok for check in after) or not fcheck.ok: + raise SystemExit(1) + return +``` + +(Replace the existing `if all(check.ok for check in after): return / raise SystemExit(1)` tail of the `--repair` branch with the block above.) + +In the default branch, surface detection too. Replace the default tail: + +```python + checks = check_install(root) + fcheck = _check_filigree_auth(root, repair=False, filigree_url=filigree_url) + ok = all(check.ok for check in checks) and fcheck.ok + click.echo("wardline doctor: ok" if ok else "wardline doctor:") + for check in checks: + if ok: + continue + click.echo(f" {check.name}: {check.message}") + if not ok: + click.echo(f" filigree.auth: {fcheck.message}" if not fcheck.ok else "") + raise SystemExit(1) +``` + +To always show the `filigree.auth` line (the CLI test asserts its presence even when ok), simplify the default tail to: + +```python + checks = check_install(root) + fcheck = _check_filigree_auth(root, repair=False, filigree_url=filigree_url) + ok = all(check.ok for check in checks) and fcheck.ok + click.echo("wardline doctor: ok" if ok else "wardline doctor:") + for check in checks: + if not check.ok: + click.echo(f" {check.name}: {check.message}") + fmsg = fcheck.message or ("ok" if fcheck.ok else "error") + click.echo(f" filigree.auth: {fmsg}") + if not ok: + raise SystemExit(1) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/unit/cli/test_doctor.py -q` +Expected: PASS. The existing `test_doctor_fix_emits_shared_machine_readable_shape` must stay green: with the published-port rung excluded from probe resolution and no `--filigree-url` arg in `_local_mcp_entry()`, `filigree.auth` resolves no URL → `ok`/"not configured" → no network, so `payload["ok"]` stays `True` and `next_actions` stays `[]`. + +- [ ] **Step 5: Commit** + +```bash +git add src/wardline/install/doctor.py src/wardline/cli/doctor.py tests/unit/cli/test_doctor.py +git commit -m "feat(doctor): wire filigree.auth check into machine-readable + CLI surfaces" +``` + +--- + +## Task 7: full-suite gate + changelog + +**Files:** +- Modify: `CHANGELOG.md` (under `[Unreleased] / Added`) +- Test: full suite + linters + +- [ ] **Step 1: Run the whole suite + linters** + +Run: +```bash +.venv/bin/pytest -q +.venv/bin/ruff check src tests +.venv/bin/mypy src +``` +Expected: all green. Fix any failures before continuing (no "pre-existing" excuse — red is red). + +- [ ] **Step 2: Add the changelog entry** + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Added`: + +```markdown +- `wardline doctor` now verifies the Filigree federation token: it probes the + configured daemon (URL resolved from `.mcp.json`/env) with the token wardline + would emit and reports a `filigree.auth` check. `--repair` recovers the + daemon-accepted token from local mints and pins it as `WEFT_FEDERATION_TOKEN` + in `.env`, removing a stale `WARDLINE_FILIGREE_TOKEN` line. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): note doctor filigree.auth check" +``` + +- [ ] **Step 4: Live acceptance against lacuna (manual, optional but recommended)** + +Run a real probe against the running daemon to confirm end-to-end (the spec's oracle): +```bash +wardline doctor --root ~/lacuna --repair +``` +Expected: `filigree.auth: fixed` (writes `WEFT_FEDERATION_TOKEN` into `~/lacuna/.env`), then `wardline scan` from lacuna emits successfully and a finding lands in Filigree. Do not claim done on unit tests alone — confirm the emit block flips to success. + +--- + +## Self-Review Notes + +- **Spec coverage:** probe-URL-from-`.mcp.json` (Task 3), sentinel-`{}` probe not `emit([])` (Task 1), detection table incl. absent/non-loopback/unreachable (Task 4), repair-from-local-mints + no-candidate guard (Task 5), `.env`-only write leaving the MCP bearer alone (Tasks 2/5; no `.mcp.json` write anywhere), loopback discipline (Tasks 3/4), CLI surface (Task 6). All covered. +- **Type consistency:** `ProbeResult(reachable, accepted, status)`, `DoctorCheck(id, status, fixed, message)`, `Response(status, body)`, `_check_filigree_auth(root, *, repair, filigree_url=None, transport=None)`, `_repair_filigree_auth(root, url, transport)` — names match across tasks. +- **Note for executor:** Task 4 introduces a temporary `_repair_filigree_auth` stub that Task 5 replaces — if executing out of order, do Task 5 before relying on repair. diff --git a/docs/superpowers/plans/2026-06-08-wardline-rust-frontend-slice1-command-injection.md b/docs/superpowers/plans/2026-06-08-wardline-rust-frontend-slice1-command-injection.md new file mode 100644 index 00000000..ef98bc9f --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-wardline-rust-frontend-slice1-command-injection.md @@ -0,0 +1,342 @@ +# Rust Frontend — Slice 1: Command-Injection (Implementation Plan) + +**Status:** Draft for review (panel-hardened, round 1) · **Date:** 2026-06-08 · **Branch:** `feat/rust-plugin` +**Design spec:** `docs/superpowers/specs/2026-06-08-wardline-rust-frontend-design.md` (read first). + +This plan cuts a **thin vertical path** through the six sub-projects (SP1→SP6) of the spec to land +**one working Rust finding family** — command-injection via `std::process::Command` — end to end: +discover → parse → index/qualname → seed source → builder-dataflow L2 → sink locator → verdict → +`Finding` → corpus + e2e. It is the **de-risking instrument**, not a sub-project. + +### What this slice proves / does not prove + +- **Proves:** the plumbing (a second frontend producing real `Finding`s the existing + `run_scan` baseline/waiver/gate/SARIF path consumes) **and** real source→propagate→sink taint on + Tier-A syntax. +- **Does NOT prove:** semantic adequacy for Tier-B (trait dispatch) or Tier-C (macros/derives), nor + that the engine middle can be *cleanly factored* for two frontends (that is SP1). A green slice is + **not** evidence that "the rest is just more vocabulary." (Spec §2, §3.3, §9.1.) + +--- + +## Process constraints (all work packages) + +- **TDD, strictly.** Failing test first, then implementation. (Skill: `superpowers:test-driven-development`.) +- **Subagents NEVER run git** — no `status/add/commit/checkout/stash/restore/reset/worktree`. The + orchestrator owns all git. +- **All work in the worktree** `/home/john/wardline/.worktrees/rust-plugin` on `feat/rust-plugin`. +- **Base stays zero-dep.** New runtime deps live behind the **`wardline[rust]` extra** only + (`tree-sitter`, `tree-sitter-rust`). `import tree_sitter` is lazy/guarded (mirror `loomweave`'s + `require_blake3`); a bare `pip install wardline` must not import them. **Every** `tests/unit/rust/` + test module carries a `pytest.importorskip("tree_sitter")` (or an autouse skip via a + `tests/unit/rust/conftest.py`) so the default suite skips clean without the extra — not just the + loader test. +- **Gates:** full `pytest -q` green; `ruff` + `mypy` clean; the **Python corpus + identity oracle stay + byte-green**; `wardline scan src --fail-on ERROR` exit 0. +- **Rust namespace:** rules `RS-WL-*`; Rust corpus `tests/corpus/rust/`; live marker `rust_e2e`. +- **Identity posture:** `RS-WL-*` findings are **provisional / baseline-ineligible** until SP2 (spec + §3.6); the slice emits a provisional-identity flag and does not freeze a `golden/identity/rust/` + corpus. + +--- + +## Non-goals (explicit, deferred to the named SP) + +- Full frontend-seam extraction making Python "frontend #1" (SP1) — slice 1 builds a focused + `RustAnalyzer` alongside the engine middle; SP1 later refactors the shipped code. +- Multi-file / cross-function / full module-tree route resolution (SP2) — slice 1 is single-file, + intra-function; module route is a **`crate`-rooted approximation** from the file path (spec §6.3). +- The L3 interprocedural fixpoint — for a call-free single function it is the identity; slice 1 + computes `project_taints` directly from L1 seeds and does **not** invoke `propagate_callgraph_taints`. +- Loomweave reconciliation + the frozen `golden/identity/rust/` corpus (SP2). +- `.env()`/`.arg0()`/`.args()`-per-element, `push_str`/`+` concat-built shell strings, variable-bound + shell names, `libc`/FFI exec, Tier-B/C sinks (spec §9.4) — documented FNs. +- In-source markers beyond the single `@trusted` doc-comment the specimen needs (full marker grammar + is SP3). +- Auto-detect CLI dispatch (SP6) — slice 1 uses an **explicit `--lang rust`** flag (spec §8.2 / panel: + avoids the mixed-language `ScanResult`/baseline-intermixing/delta-scoping gaps until the + multi-frontend pipeline is designed). + +--- + +## Architecture of the slice + +A focused module tree under `src/wardline/rust/` (new), importing the **shared, neutral** engine +pieces and **not** the Python-AST scanner: + +``` +src/wardline/rust/ + __init__.py + _tree_sitter.py # require_rust() -> (Language, Parser); guarded import + RustToolingError + nodeid.py # NodeId newtype + NodeIdMap + mint_node_ids(tree) -> NodeIdMap (pre-order) + parse.py # source bytes -> tree; cursor helpers (scoped_identifier, field_expression…) + qualname.py # the Rust dialect (ADR-049: impl[Trait]/impl#<>/@cfg; closures NOT entities) + index.py # function_item -> RustEntity + NodeId stamping + vocabulary.py # rust_taint.yaml loader (sources + sinks) + RUST_TAINT_VERSION + rust_taint.yaml # bundled vocabulary (wheel-shipped via hatch force-include) + provider.py # RustTrustProvider: doc-comment @trusted -> FunctionTaint; provider_fingerprint + dataflow.py # builder-dataflow L2: CommandState + local_string_taints -> per-trigger maps + rules.py # RS-WL-108 + RS-WL-112 (RustRule protocol; reuse modulate/RAW_ZONE/Finding) + context.py # RustAnalysisContext (minimal) + adapter to AnalysisContext shape + analyzer.py # RustAnalyzer.analyze(files, config, *, root) -> list[Finding]; .last_context +``` + +**`RustAnalyzer` satisfies the full `Analyzer` protocol** (`core/protocols.py:17-21`): +`analyze(self, files, config, *, root: Path) -> Sequence[Finding]` **and** a `last_context` property. +This is mandatory — `run_scan` (`core/run.py:228`) calls `analyzer.analyze(files, cfg, root=root)` and +reads `analyzer.last_context` at `:282`/`:324` for delta-scope and `ScanResult`. So the slice does +**not** sidestep `run_scan` (that would silently skip baseline/waiver/gate/suppression). `last_context` +returns a minimal `AnalysisContext`-shaped stub built from `RustAnalysisContext` (`project_taints: +dict[str, TaintState]`, `entities: dict[str, RustEntity]`, empty `project_edges`/call-site maps); the +delta-scope path degrades gracefully when `project_edges` is empty. Defining `RustAnalysisContext` now +establishes the shape discipline SP1 will unify (panel: prevents ad-hoc divergence). + +Shared imports (reuse verbatim, spec §3.1): `core.taints` (`TaintState`, `TRUST_RANK`, `RAW_ZONE`, +`least_trusted`), `core.finding` (`Finding`, `Location`, `Severity`, `Kind`, +`compute_finding_fingerprint`), `scanner.rules.severity_model.modulate`, +`scanner.taint.function_level.FunctionSeed` (+ the `FunctionTaint`/`SeedResult` shapes). + +--- + +## Work packages (each TDD) + +### WP0 — Dependency + scaffold + NodeId/NodeIdMap + +**Test first:** `tests/unit/rust/test_tree_sitter_loader.py` — `require_rust()` returns a usable +`(Language, Parser)` and round-trips `fn main(){}` to a tree whose root kind is `source_file`; +module-level `pytest.importorskip("tree_sitter")`. + +**Implement:** +- `pyproject.toml`: `rust = ["tree-sitter>=0.25,<0.26", "tree-sitter-rust==0.24.2"]`. **Pin rationale + (R6):** tree-sitter-rust 0.24.2's parser is ABI 15, loadable only by core ≥0.25.0 — do **not** trust + the grammar's stale self-declared `tree-sitter~=0.22`. Distribution is a **`cp39-abi3` stable-ABI + wheel** (one wheel runs on CPython 3.9+, incl. 3.12) across linux/macos/win — **no compiler at + install** on the supported matrix. Add a comment that `<0.26` is a conservative cap (0.26.x ABI/API + compat unverified; relax after a smoke test). +- `rust/_tree_sitter.py`: `require_rust()` lazy import + `RustToolingError` (clean install message), + mirroring `loomweave.require_blake3`. +- `rust/nodeid.py`: `NodeId = NewType("NodeId", int)`; a `NodeIdMap` typed wrapper; `mint_node_ids(tree) + -> NodeIdMap` assigning a **deterministic per-file pre-order index** (spec §5). **`NodeIdMap` is the + single keying authority** — `dataflow.py` (WP4) and `rules.py` (WP5) both import it; no pass keys on a + raw `ts_node`. Also add the typed `NodeId` alias on the Python side behind `id()` (zero behavior + change) so the contract is shared. +- **DONE this session (2026-06-09):** `tests/conformance/qualnames_rust.json` is **vendored + byte-identical** from Loomweave (`feat/rust-plugin-spec`@`8adb1ee`:`fixtures/qualnames_rust.json`, + blob `795ae03`, the extractor-generated oracle post-ADR-049-amendment — do not author it ourselves), + and `tests/conformance/ + test_loomweave_rust_qualname_parity.py` is the parity skeleton: 4 **structural self-tests run now** + (catch a malformed/stale re-vendor), 22 **producer rows skip** until the Rust frontend lands. Verified + green (4 passed / 22 skipped; ruff + mypy clean). WP2 un-skips the 22 (above). + +**Verify:** loader test green under `wardline[rust]`; skips clean without it. + +### WP1 — Discovery generalization (suffix-parameterized) + security guard + +**Test first:** `tests/unit/core/test_discovery.py` — +- `test_discover_rust_suffix`: `discover(root, config, suffixes=frozenset({'.rs'}))` finds `a.rs`, + **not** `a.py`; the default call (no `suffixes`) is unchanged; `target/` is skipped. +- `test_discover_rust_symlink_confined`: a `.rs` file-symlink resolving **outside** root inside a + source_root is **skipped with `WLN-ENGINE-FILE-SKIPPED`** under `confine_to_root=True` (mirror the + Python THREAT-001 test — this is a **security invariant**, not an edge case). + +**Implement:** `core/discovery.py` — add `suffixes: frozenset[str] = frozenset({'.py'})`; replace the +hardcoded `base.rglob('*.py')` (line 32) with a per-suffix sweep that **yields `Path`s in the same +sorted order** as before (so finding/entity order is unchanged); keep the per-file `confine_to_root` +symlink guard *inside* the loop; add `target` to `_ALWAYS_SKIP`; preserve `fnmatch` excludes + missing +-source-root surfacing. **Python default behavior byte-unchanged.** + +> **Byte-green note (panel):** the existing discovery unit tests assert *sorted names*, not iteration +> order; the real byte-green guard for this change is the **identity oracle**. Run **Verification 2 +> (full suite + `tests/golden/identity` parity) immediately after WP1**, not at the end. Also: a +> configured root with no `.rs` files returns zero files silently (`missing_source_roots()` is not +> suffix-aware) — documented as a known limitation; SP6 adds the empty-root warning (slice 1's +> `RustAnalyzer` runs over an explicit fixture, so it never hits this path). + +### WP2 — Rust parse + minimal index + qualname dialect + +The dialect is **Loomweave's ADR-049** (spec §6), not Wardline's — Wardline is the *second producer* +that **mints the identical string** and never parses the locator. *(ADR-049 amend, Option b: inherent +ordinal dropped; same-`(type, generic-sig)` inherent impls MERGE; cfg-twins split by `@cfg`.)* Reserved char is **`:` (invalid)**; +`[ ] # < > @` are legal. `tests/conformance/qualnames_rust.json` is the copy **vendored from Loomweave** +(`/home/john/loomweave` `feat/rust-plugin-spec`:`fixtures/qualnames_rust.json`, extractor-generated); +Wardline reproduces its function-row `qualname`s byte-for-byte. + +**Test first:** +- `tests/unit/rust/test_qualname.py` (against the vendored corpus, ADR-049 forms — file-module rooted + for the single-file slice, e.g. `demo`): inherent method `demo.m.Foo.impl#<>.bar`; trait method + `demo.m.Foo.impl[Display].fmt`; trait collision `…impl[Display].fmt` + `…impl[Debug].fmt`; concrete + generics `…impl[From].from` ≠ `…impl[From].from`; positional generic `…impl#<$0>.get` + (**and the param-renamed source yields the identical string** — rename-stable); multiple inherent + impls `…impl#<>.a` + `…impl#<>.b` (same-signature inherent impls **MERGE**; no ordinal); cfg-twin `demo.m.f@cfg(unix)` + + `…f@cfg(windows)`; `async fn` renders identically to `fn`; **closure → NOT an entity** (only the + enclosing `demo.m.f`); **nested `fn` → NOT an entity** (only `demo.m.outer`); generics stripped. +- **Comparison rule (do NOT raw-`assert found == expected`):** the corpus rows include `module`/`struct` + rows Wardline never emits and its `kind` is the locator id-kind (`function` for every callable). So: + take Wardline's function entities; assert each `qualname` is in the case's set of **non-`module`** + `expected` qualnames; `kind` is informational (map Wardline's semantic `method` → id-kind `function`, + or compare qualname-only); never edit the vendored copy to drop rows. (Mirrors the `None ↔ ""` + accommodation the Python `loomweave_qualname_parity` test already makes.) +- `tests/unit/rust/test_index.py` — the specimen yields one `RustEntity` per emitted callable + (free fn / inherent / trait / assoc — **closures and nested fns are NOT emitted**), with a `Location` + and NodeIds. Wardline keeps its semantic `function|method` split in `RustEntity` **metadata**; the + qualname/id-kind is `function`. +- `tests/unit/rust/test_nodeid_crosspass.py` — **the spec §5 cross-pass agreement test** (not + re-parse): for a known builder chain, the `NodeId` the (stub) dataflow records for the `.output()` + trigger **equals** the `NodeId` `mint_node_ids` assigns that CST node, which **equals** the one the + rule locator looks up. A mismatch is a hard failure. +- **Un-skip the vendored cross-tool gate.** `tests/conformance/test_loomweave_rust_qualname_parity.py` + (vendored in WP0) currently *skips* its 22 producer rows. **WP2 is not done until those go GREEN** — + i.e. `rust/index.py` + `rust/qualname.py` reproduce Loomweave's `qualnames_rust.json` byte-for-byte. + WP2's `_rust_producer` already pins the **exact API contract** the test calls; implement to it: + - `wardline.rust.index.discover_rust_entities(source: str, *, module: str) -> Sequence[RustEntity]` + — **parses `source` internally** (no AST/path arg; `module` is the supplied root, since deriving it + from `Cargo.toml` is SP2); each `RustEntity` carries `.qualname`. Emit **callables only**. + - `wardline.rust.qualname.rust_module_route(*, crate: str, src_root: str, file: str) -> str`. + +**Implement:** `rust/parse.py`, `rust/qualname.py` (ADR-049 forms), `rust/index.py`. **Root = the +file-module approximation** (e.g. `demo`) — slice-1-reproducible; the **real crate prefix** from +`Cargo.toml`, cross-file module route, and `#[path]` are **SP2** (spec §6.3), so +slice-1 findings are crate-prefix-provisional (consistent with their baseline-ineligibility). A finding +inside a closure/nested fn attributes to the **enclosing named fn** (`line_start` localises). + +### WP3 — Vocabulary (sources + command sinks) + `@trusted` marker + cache-version + +**Test first:** +- `tests/unit/rust/test_vocabulary.py` — `rust_taint.yaml` loads into frozen tables; a source + `{crate: std, path: env::var, returns_taint: EXTERNAL_RAW, …}` and a sink `{crate: std, path: + process::Command::new, sink_kind: command, …}` present; `returns_taint` constrained to `{ASSURED, + GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}`; duplicate keys rejected; `RUST_TAINT_VERSION` exported. +- `tests/unit/rust/test_provider.py` — `/// @trusted(level=ASSURED)` seeds `FunctionTaint(ASSURED, + ASSURED)`; an unmarked fn seeds the fail-closed `UNKNOWN_RAW` default (`source='default'`). + **`provider_fingerprint == f"rust-vocab:{RUST_TAINT_VERSION}"`, and the test asserts it CHANGES when + `RUST_TAINT_VERSION` is bumped** (closes the cache-version gap now — spec §8.1). + +**Implement:** `rust/vocabulary.py` (+ bundled `rust_taint.yaml`, wheel-shipped via +`tool.hatch.build.force-include`), `rust/provider.py`. The vocab version folds into +`provider_fingerprint` (the policy slot `scan_policy_hash` is *separate* — do not overload it). + +### WP4 — Builder-dataflow L2 (the hard core) + +**Test first:** `tests/unit/rust/test_dataflow.py` — over hand-built specimens, assert the per-trigger +arg-taint map (keyed via `NodeIdMap`): + +*Positives:* +- `let mut c = Command::new("sh"); c.arg("-c"); c.arg(tainted); c.output();` → at `.output()`: program + literal `"sh"` (shell), `shell_flag_seen`, a RAW_ZONE arg taint present. +- `Command::new(tainted).output();` → `program_taint` is RAW_ZONE. +- `let s = format!("rm {}", tainted); Command::new("sh").arg("-c").arg(s).output();` → `s` carries + taint via `local_string_taints`; reaches the `-c` arg. +- **Two-hop:** `let s = format!("{}", tainted); let s2 = format!("{}", s); … .arg(s2)` → taint flows. + +*Negatives (the FP guards — spec §9.2/§9.4):* +- `Command::new("ls").arg(tainted).output();` → non-shell program, no shell flag ⇒ neither rule. +- `Command::new("sh").arg(tainted).output();` (**no `-c`**) → must NOT fire RS-WL-112 (shell-without-flag). +- `Command::new("ls").args(tainted_vec).output();` → non-shell `.args` ⇒ neither rule. +- `…arg(format!("echo {}", sanitize(tainted)))` → **accepted bounded FP** (the sanitizer is invisible): + pin current behavior with a test AND register it as a TN fixture in the corpus (measured vs ≤5% gate). +- `format!("rm {}", clean_literal)` → no taint propagates (precision). +- `format!("rm {tainted}")` (captured-identifier form) → **documented FN**: assert no propagation + (pins the boundary). + +*FN pin:* `let s = "sh"; Command::new(s).arg("-c").arg(tainted).output();` (variable-bound shell name) +→ asserts it does **not** fire today (so the bounded FN can't silently flip to an FP later). + +**Implement:** `rust/dataflow.py` — `local_var → CommandState{is_command, program_literal, +program_taint, shell_flag_seen, arg_taints: list[(NodeId, TaintState)]}` **plus** `local_string_taints: +dict[str, TaintState]`; update on `Command::new`, `.arg`/`.args`, terminal `.output/.spawn/.status`; +seed locals from sources (WP3) and `let` initializers; the `format!` heuristic matches **direct +interpolation argument tokens only**. Receiver via `field_expression.value` (no `receiver` field). + +### WP5 — The two rules (verdict layer) + +**Test first:** `tests/unit/rust/test_rules.py` — drive `RustAnalyzer` over the WP4 specimens inside a +`@trusted` fn: +- `RS-WL-112` fires once on `sh -c `, **`Severity.WARN`**, `Kind.DEFECT`, anchored at + `.output()`, fingerprint folds `(rule_id, path, line, qualname, taint_path)`. +- `RS-WL-108` fires once on `Command::new(tainted)`, **`Severity.ERROR`**, message text cites **both** + the `Command::new(...)` constructor line and the terminal trigger line. +- **De-confliction single-fire:** `Command::new(tainted).arg("-c").arg(more_tainted).output();` + (tainted program AND a `-c` flag) → exactly **one** finding (RS-WL-108), not two (spec §9.2). +- Non-shell `.arg(tainted)` → **nothing**. Unmarked containing fn → **nothing** (`modulate(_, UNKNOWN_RAW) + == NONE`). +- **Pinned `taint_path` golden strings** for one RS-WL-108 and one RS-WL-112 finding (the Rust analog + of golden identity entries — spec §3.6 obligation that `taint_path` serialization is pinned). + +**Implement:** `rust/rules.py` — two rule objects satisfying a **`RustRule` protocol** (`rule_id: str` ++ `check(self, context: RustAnalysisContext) -> Sequence[Finding]`) — they are **NOT** registered in +the Python `RuleRegistry` and do **NOT** accept `AnalysisContext` (panel: avoid the protocol-conformance +overstatement). Each: look up the containing fn tier from L1 seeds (`project_taints = {qualname: +seed.body_taint}`, no L3); `modulate(base_severity, tier)`; skip if `NONE`; consume the WP4 per-trigger +maps; **RAW_ZONE membership on the *selected* TaintState** (RS-WL-108 → `program_taint`; RS-WL-112 → +worst-of `arg_taints` gated by the shell test); emit `Finding`. Base severities: **RS-WL-108 = ERROR, +RS-WL-112 = WARN.** CWE-78 lives in the **description prose** (no `cwe` field in `RuleMetadata`). +Drafted `examples_violation`/`examples_clean` per spec §9.2. Reuse `modulate`, `RAW_ZONE`, +`compute_finding_fingerprint` verbatim. + +### WP6 — Corpus + CLI wiring + e2e + coverage posture + +**Test first:** +- `tests/corpus/rust/MANIFEST.yaml` + `tests/corpus/rust/harness.py`: scan + `tests/corpus/rust/fixtures/command_sink.rs` via `RustAnalyzer`, reconcile active `RS-WL-*` DEFECTs + by `(path, rule_id, qualname)`, enforce ≤5% FP gate, with a **documented FN section**. The fixture is + **dense** (≥10 clean fns — non-shell `Command`s, `.args()`, taint-free `format!`, the `sanitize()` + near-miss, unmarked fns — alongside the TP fns), all in `@trusted` fns. A **second + `clean_commands.rs`** carries a **hard 0-findings** gate (panel: makes the percentage gate + non-vacuous). +- `tests/e2e/test_rust_live.py` — `pytestmark = pytest.mark.rust_e2e`; toolchain via the `tree_sitter` + import (skip-clean without the extra); runs `wardline scan --lang rust --format jsonl` and + asserts the two findings + the **provisional-identity flag** + the **coverage-posture line**. + +**Implement:** +- `pyproject.toml`: register the `rust_e2e` marker + `and not rust_e2e` in `addopts`. +- CLI: add an explicit **`--lang rust`** flag to `wardline scan` that routes discovery (`suffixes={'.rs'}`) + and analysis through `RustAnalyzer` via `run_scan` (Python path unchanged; default unchanged). Emit a + **coverage-posture line** (`Rust: N fns scanned, M in declared-trust; Tier-A only — macro/trait-dispatched + sinks not evaluated`) and a **provisional-identity** notice (spec §3.6, §4). Auto-detect is **not** + done here (SP6). +- Bundle `rust_taint.yaml` in the wheel (force-include). + +### WP7 — Docs, CHANGELOG, verification + +- `docs/guides/rust-preview.md`: Tier-A scope; the two rules + severities; the `wardline[rust]` extra; + the `@trusted` doc-comment marker; the **explicit FN families** (Tier-B traits, Tier-C macros/serde, + `arg0`, `push_str`/`+` concat, `.args` flag, variable-bound shell, `libc`/FFI — spec §9.4); the + **coverage-posture** meaning ("green = no declared-trust Tier-A command sinks found", not "Rust + clean"); the **provisional-identity** caveat (baseline-ineligible until SP2). +- `CHANGELOG.md` `[Unreleased] Added`: Rust command-injection frontend (preview, Tier-A, opt-in extra). +- `docs/agents.md`: `.rs` scanning is preview + opt-in + `--lang rust`. + +--- + +## Verification (end-to-end gate) + +1. `.venv/bin/pytest tests/unit/rust tests/corpus/rust tests/conformance/test_loomweave_rust_qualname_parity.py -q` + green — incl. qualname conformance, the **NodeId cross-pass agreement** test, the FP/FN guards, the + provider-fingerprint-on-bump test, **and the vendored Loomweave parity gate with its 22 producer rows + now GOING GREEN (not skipping)** — `discover_rust_entities`/`rust_module_route` reproduce + `qualnames_rust.json` byte-for-byte. +2. **Immediately after WP1** *and* at the end: `.venv/bin/pytest -q` full suite green; the **Python + corpus + `tests/golden/identity` parity stay byte-identical**. +3. `.venv/bin/pytest -m rust_e2e -q` green under `wardline[rust]`; skips clean without it. +4. `ruff check` + `ruff format --check` + `mypy` clean. +5. `.venv/bin/wardline scan tests/corpus/rust/fixtures --lang rust --fail-on ERROR` reports the + RS-WL-108 finding (ERROR) and exits 1; `--fail-on WARN` additionally surfaces RS-WL-112; + `.venv/bin/wardline scan src --fail-on ERROR` still exit 0. +6. `pip install .` (no extras) imports `wardline` without pulling tree-sitter (base stays zero-dep); + every `tests/unit/rust/` module skips clean in that env. + +--- + +## Decisions to confirm in review (genuinely open) + +- **`format!` heuristic narrowing** (spec §12 Q1): "direct-interpolation-arg tokens only" for slice 1? +- **`@trusted` alone is enough** for the specimen to enter declared-trust (vs also needing + `@trust_boundary` external→to_level)? (Default: `@trusted` + a vocabulary source is sufficient.) +- **Accept Loomweave's offer** to drop the vendored `qualnames_rust.json` + parity-test skeleton into + `tests/conformance/`? (Default: yes — they generate it from the oracle; we should not hand-author it.) + +(Resolved in the spec, no longer open here: RS-WL-108 = ERROR; **qualname dialect = Loomweave ADR-049** +(Wardline conforms, file-module root for slice 1, crate prefix is SP2); **closures/nested fns are NOT +entities**; **`:` is invalid** (no `:trait=`); corpus vendored from Loomweave; CLI = explicit +`--lang rust`; `RustAnalyzer` satisfies the full `Analyzer` protocol; identity baseline-ineligible until +the SP2 crate-prefix.) diff --git a/docs/superpowers/plans/2026-06-09-fingerprint-rekey-00-index.md b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-00-index.md new file mode 100644 index 00000000..bab3e20f --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-00-index.md @@ -0,0 +1,73 @@ +# Fingerprint rekey — index & spine + +> Move-stable finding identity: drop `line_start` from the fingerprint, give every +> multi-emit rule a source-derived move-stable discriminator, stamp the scheme so a +> half-migrated store loud-fails, and ship a one-shot scan-driven `wardline rekey`. +> **Architecture is decided — every phase is "do this," not "choose one."** +> +> Tracking: residual of `weft-4a9d0f863c` (resolved); panel `panel-2026-06-09`. +> Tickets: `wardline-8fb773a7af` (P2 = finalizer), `wardline-8654423823` (P3 = +> discriminator redesign), `wardline-6102d4c833` (broad/silent fix, folded into P3), +> migration `weft-e618c4118a` (WL-1). All work on the single `rc4` branch. + +## The problem in one picture + +`src/wardline/core/finding.py:154-165`: + +```python +def compute_finding_fingerprint(*, rule_id, path, line_start, qualname=None, taint_path=None) -> str: + parts = (rule_id, path, str(line_start), qualname or "", taint_path or "") # <-- line_start is IN the key + return hashlib.sha256("\x00".join(parts).encode()).hexdigest() +``` + +Insert a benign comment above a sink → `str(line_start)` `2`→`3` → the 64-hex value +changes. All four stores (`baseline.py` frozenset, `judged.py` `_by_fp`, `waivers.py` +`_by_fp`, `filigree_emit.py` wire) join on the bare hex and store **none** of the +inputs → the old verdict orphans and the finding resurfaces ACTIVE, trips the gate, +mints a Filigree dup. + +## The fix in one picture + +```python +FINGERPRINT_SCHEME = "wlfp2" # scheme-infra (P1) ships "wlfp1" first (format-only) +def compute_finding_fingerprint(*, rule_id, path, qualname=None, taint_path=None) -> str: + parts = (rule_id, path, qualname or "", taint_path or "") # line_start GONE + return hashlib.sha256("\x00".join(parts).encode()).hexdigest() +``` + +Multi-emit discriminator in `taint_path`: singletons → `None`; multi-emit → +`f"{rel_line}:{col_offset}:{end_col_offset}:{callee_or_token}"` where +`rel_line = node.lineno - entity.location.line_start` (CPython byte offsets). PY-WL-114 +keeps its `#{ordinal}`. `line_start` stays on `Finding.location` for SARIF region / display. + +## The spine (THE one true order) + +| # | Phase | file | rekey-impact | corpus_version | scheme persisted | +|---|-------|------|--------------|----------------|------------------| +| P1 | scheme-infra | `…-01-scheme-stamp-infra.md` | format-only | 2 → 3 | `wlfp1` (OLD line_start-in formula) | +| P2 | finalizer guard | `…-02-collision-finalizer.md` | none | unchanged | — | +| P3 | rules-discriminator (THE value-rekey) | `…-03-drop-linestart-discriminator.md` | value-rekey | 3 → 4 | `wlfp2` | +| P4 | migration (`wardline rekey`) | `…-04-scan-driven-migration.md` | value-rekey (operator) | — | from=`wlfp1`, to=`wlfp2` | +| P5 | rust worktree reconciliation | `…-05-rust-worktree-reconciliation.md` | n/a (NOT rc4) | — | inherits | + +**Inviolable constraints:** +- **P2 strictly before P3** — the collision finalizer is the tripwire that must exist *before* `line_start` leaves the hash, or a discriminator bug collapses silently in `baseline.py` `setdefault`. +- **P1 before P4** — migration's loud-miss safety consumes P1's `SchemeMismatchError`. +- **P3 before P4** — migration reads `new_fp` (P3's engine output) and the v0 discriminator component P3 exposes. +- **P1 scheme label MUST differ from P3 scheme label** (`wlfp1` ≠ `wlfp2`). The single decision that makes the loud-fail primitive work: a store written between P1 and P3 must `SCHEME_MISMATCH` after P3. Same label → stale old-formula value loads clean → mass orphan. + +## Verification (after EACH phase) +- Full suite (~2625) green. +- Identity oracle **byte-green on BOTH 3.12 and 3.13** (`compute_finding_fingerprint_v0` lives in its own module, never called by production). +- `ruff` + `mypy` clean. + +## Tracker hygiene (Filigree — `blocks`/`blocked_by` only; no `guarded-by`) +- `wardline-8654423823` priority 3 → 2; label `panel-2026-06-09`. +- `wardline-6102d4c833 blocked_by wardline-8fb773a7af` (latent fix needs the tripwire). +- `wardline-8654423823 blocked_by wardline-8fb773a7af` (redesign needs the tripwire). +- `wardline-6102d4c833 blocked_by wardline-8654423823` (latent fix needs the redesign). + +## Start here +Open `tests/unit/core/test_fingerprint_scheme.py` (create) + `src/wardline/core/finding.py`. +**P1 / S1** — add `FINGERPRINT_SCHEME = "wlfp1"` + `format_fingerprint`/`parse_fingerprint`, +hash UNTOUCHED. Format-only, byte-safe, lays the loud-fail floor. → `…-01-scheme-stamp-infra.md`. diff --git a/docs/superpowers/plans/2026-06-09-fingerprint-rekey-01-scheme-stamp-infra.md b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-01-scheme-stamp-infra.md new file mode 100644 index 00000000..f1647e4c --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-01-scheme-stamp-infra.md @@ -0,0 +1,79 @@ +# P1 — scheme-infra (the loud-fail safety primitive) + +> Phase 1 of the fingerprint rekey. See `…-00-index.md` for the spine. Run FIRST. +> **format-only** rekey-impact: every fingerprint VALUE stays byte-identical. + +- **id:** `scheme-infra` +- **goal:** Make the fingerprint self-describing and make all four stores loud-fail on a scheme mismatch, **without touching the hash**. The SARIF key rename + new META field are the only corpus deltas. +- **depends-on:** nothing (this is the floor). +- **rekey-impact:** **format-only.** +- **blast radius:** `finding.py`, `errors.py`, `baseline.py`, `judged.py`, `waivers.py`, `suppression.py`, `filigree_emit.py`, `sarif.py`, `legis.py`, new `finding_identity.py`, the identity oracle. Cross-tool: Filigree gets a prefixed wire value + envelope scheme; SARIF consumers must key on `wardlineFingerprint/v2`; legis artifact gains an envelope scheme. `facts.py` (Loomweave blob) is **deliberately exempt** (decision D1). Pre-1.0, no compat shim. + +## TDD steps (failing test first → impl) + +- [ ] **S1 — `FINGERPRINT_SCHEME = "wlfp1"` + `format_fingerprint`/`parse_fingerprint` helpers; hash untouched.** + - Test: `tests/unit/core/test_fingerprint_scheme.py` — `FINGERPRINT_SCHEME == "wlfp1"`; `format_fingerprint("wlfp1", "ab"*32) == "wlfp1:"+"ab"*32`; `parse_fingerprint(format_fingerprint(s,h)) == (s,h)`; `parse_fingerprint` rejects no-colon / wrong-len / uppercase → `ValueError`; `compute_finding_fingerprint(...)` returns bare 64-hex (no colon). + - Impl: add the constant + helpers to `src/wardline/core/finding.py`. `compute_finding_fingerprint` UNCHANGED (still hashes `line_start`). In-memory stays bare hex; the prefix is applied only at the wire/store boundary. `parse_fingerprint` ships now (round-trip test) for the Filigree-wire reader + P4. + +- [ ] **S2 — `SchemeMismatchError(ConfigError)`** carrying `store_name`/`found`/`expected` + `run wardline rekey`. + - Test: `tests/unit/core/test_errors.py::test_scheme_mismatch_error` — `issubclass(SchemeMismatchError, ConfigError)`; `str(...)` names the file, the expected scheme, and `run wardline rekey`. + - Impl: add to `src/wardline/core/errors.py`. Subclass `ConfigError` so existing `except ConfigError` / CLI exit mapping stays intact. + +- [ ] **S3 — `baseline.py`: write `fingerprint_scheme` header; assert on load; NO version bump.** + - Test: `tests/unit/core/test_baseline.py` — `build_baseline_document([f])["fingerprint_scheme"] == "wlfp1"`; a doc with no `fingerprint_scheme` → `SchemeMismatchError` (NOT a version-mismatch `ConfigError`); wrong scheme → `SchemeMismatchError`; **missing/empty file → `Baseline(frozenset())` (no error)**; roundtrip; entry value stays bare 64-hex. + - **Edit existing tests** in this file: lines **34, 83, 90, 98** construct docs with `version` but no `fingerprint_scheme` — add `"fingerprint_scheme": "wlfp1"` to each (line 98 still expects the duplicate-fp `ConfigError` AFTER the scheme check passes). + - Impl: add `"fingerprint_scheme": FINGERPRINT_SCHEME` to `build_baseline_document`. **Do NOT bump `BASELINE_VERSION`** (a version bump makes old files hit the version-mismatch branch first → hintless error). **Loader order is load-bearing:** empty-guard FIRST → scheme check → version check → entries. + +- [ ] **S4 — `judged.py`: write header; assert on load; NO version bump; provenance preserved.** + - Test: `tests/unit/core/test_judged.py` — `build_judged_document[...]["fingerprint_scheme"] == "wlfp1"`; no-scheme → `SchemeMismatchError` (not the version-mismatch at `judged.py:98`); wrong scheme → error; absent/empty → empty `JudgedSet`; roundtrip preserves `rationale`/`model_id`/`policy_hash`/`confidence`/`recorded_at` verbatim. + - **Edit existing tests:** lines **47, 52, 59, 77, 88, 101, 115** build `version`-only docs — add the scheme header to each. + - Impl: add the header to `build_judged_document`; **do NOT bump `JUDGED_VERSION`**; loader order empty-guard → scheme → version. + +- [ ] **S5 — `waivers.py`: CREATE the writer (`WAIVERS_VERSION` + `build_waivers_document`) + header + empty-guard before the scheme check.** + - **This is a symbol-creation step** — `build_waivers_document` and `WAIVERS_VERSION` **do not exist today** (verified). `add_waiver` is the only writer (inline hand-rolled YAML). P4's `carry_waivers_forward` consumes the new writer, so it must exist here. + - Test: `tests/unit/core/test_waivers.py` — `add_waiver` writes top-level `fingerprint_scheme: "wlfp1"`; missing scheme → `SchemeMismatchError` naming `waivers.yaml`; wrong scheme → error; roundtrip preserves `reason`+`expires`, entry fp bare 64-hex; second `add` keeps the header; **present-but-empty `{}` waivers.yaml → empty, no error** (empty-guard); absent file → empty. + - **Edit existing tests:** lines **15, 73** load header-less waivers — add the scheme header. + - Impl: add `WAIVERS_VERSION = 1` + `build_waivers_document(waivers) -> dict` (so P4 can write through it). `add_waiver` writes `{fingerprint_scheme, version, waivers:[...]}` (create on first write, preserve on append). `load_project_waivers`: **empty-guard FIRST** (return `()` for absent/empty), THEN scheme check, THEN delegate to `parse_waivers` (pure, unchanged). + +- [ ] **S6 — Filigree wire: scheme in the envelope + prefixed value.** + - Test: `tests/unit/core/test_filigree_emit.py` — `build_scan_results_body([f])["fingerprint_scheme"] == "wlfp1"`; `_finding_to_wire(f)["fingerprint"] == "wlfp1:"+f.fingerprint`; `to_filigree_metadata(f)["wardline"]["fingerprint"] == "wlfp1:"+f.fingerprint`. + - **Edit existing test:** `test_filigree_emit.py:67` asserts bare `"a"*64` for the wire value — change to expect the prefixed value. + - Impl: `build_scan_results_body` adds top-level `fingerprint_scheme`. `_finding_to_wire` (`filigree_emit.py:51`) and `to_filigree_metadata` (`finding.py:186`) emit `format_fingerprint(FINGERPRINT_SCHEME, finding.fingerprint)`. + - **Runtime-consumer audit before landing:** `mcp/server.py:317` (`by_fp.get(entry["fingerprint"])`) must join on the in-memory BARE fingerprint, not the prefixed wire value — verify it does. + +- [ ] **S6b — legis envelope scheme (the cross-artifact consistency fix).** + - Decision D2: legis's per-finding `fingerprint` stays **bare** (it reads `wire["fingerprint"]` from `to_jsonl`, bare-by-design like SARIF's value — confirmed `legis.py:169`). Add a top-level `fingerprint_scheme` to the legis ARTIFACT ENVELOPE so it carries the scheme signal like SARIF's key-version and Filigree's envelope. + - Test: `tests/unit/core/test_legis.py` — `build_legis_artifact(...)["fingerprint_scheme"] == "wlfp1"`; per-finding value stays bare. + - Impl: add the envelope field in `src/wardline/core/legis.py`. + +- [ ] **S7 — SARIF key `wardlineFingerprint/v1` → `/v2` (value stays bare).** + - Test: `tests/unit/core/test_sarif.py::test_partial_fingerprint_key_is_v2` — `partialFingerprints` has `wardlineFingerprint/v2` and NOT `/v1`; value is bare (no colon). + - **Edit existing test:** `test_sarif.py:66` asserts `{"wardlineFingerprint/v1": "a"*64}` — move to `/v2`. + - Impl: change the literal at `sarif.py:110`. Value unchanged. + +- [ ] **S8 — `resolve_identity()`: single JOIN predicate the suppression layer calls.** + - Test: `tests/unit/core/test_finding_identity.py` — waiver wins over judged/baseline; judged when no waiver; baseline when neither; no-match → `matched=False, matched_on=None`; `reason` = waiver.reason → rationale → None; `drifted_from` is None this phase. Behavior-lock `tests/unit/core/test_suppression.py::test_apply_suppressions_unchanged_via_resolver` — identical `SuppressionState` + `suppression_reason`. + - **Behavior-lock MUST also cover the three pre-join early-exit branches:** (a) non-DEFECT passthrough; (b) ENGINE_PATH passthrough; (c) **lineless-DEFECT → `WLN-ENGINE-LINELESS-DEFECT` FACT substitution** (`suppression.py:40-60`, which runs BEFORE any match — assert the FACT is still emitted and the original DEFECT dropped). + - Impl: new `src/wardline/core/finding_identity.py` with `resolve_identity(fingerprint, *, baseline, waivers, judged, today) -> IdentityResolution(matched, matched_on, drifted_from, reason)`. It invokes the stores' existing membership APIs (single predicate, not a fourth store). Refactor `apply_suppressions` to call it, preserving waiver>judged>baseline precedence and the lineless-DEFECT branch unchanged. `drifted_from` stays None (no second scheme yet); the field exists so P4 populates it without a signature change. + +- [ ] **S9 + S10 — META scheme + corpus regen (KEEP ATOMIC — do not commit between).** + - S9 test: `tests/golden/identity/test_identity_parity.py::test_corpus_meta_has_engine_scheme` — `META.json["fingerprint_scheme"] == FINGERPRINT_SCHEME`. (Red until S10 regen runs — that's why they're one unit.) + - S10: the existing `test_identity_corpus_is_byte_identical` goes RED after S7 (captured SARIF now `/v2`); `_capture.py:111` reads the literal `wardlineFingerprint/v1` as a sort key and would `KeyError` — that's the signal. + - Impl: (a) `_capture.py:111` sort key → `wardlineFingerprint/v2`; (b) `regen.py` imports `FINGERPRINT_SCHEME`, writes it into META, bumps `CORPUS_VERSION` 2→3; (c) run: + ``` + cd tests && PYTHONPATH=. python -m golden.identity.regen \ + --reason 'scheme-infra: SARIF key /v1->/v2 + META fingerprint_scheme=wlfp1 (hash unchanged)' + ``` + - (d) **Verify the ONLY corpus diff is the SARIF key rename + META gaining `fingerprint_scheme` (+ `corpus_version` 2→3); every finding `fingerprint` VALUE is byte-identical** — proves the hash was untouched. + +## Acceptance +- S1–S8 unit tests green on BOTH 3.12 and 3.13. +- A store file with no `fingerprint_scheme` (one each: baseline/judged/waivers) → `SchemeMismatchError` naming the file + `run wardline rekey` (NOT a version-mismatch error). +- Absent/empty store on a fresh checkout → empty store, no error (empty-guard precedes scheme check) — incl. empty `{}` waivers.yaml. +- `build_baseline_document`/`build_judged_document`/`build_waivers_document` carry top-level `fingerprint_scheme == "wlfp1"`; per-entry fps bare 64-hex. +- Filigree wire + metadata emit `wlfp1:`; envelope carries the scheme. SARIF uses `/v2` with bare value. legis envelope carries the scheme; legis per-finding value bare. +- `apply_suppressions` routes through `resolve_identity` with byte-identical output (behavior-lock green, incl. lineless-DEFECT branch). +- Identity oracle byte-green; only corpus delta = SARIF `/v1`→`/v2` + META scheme (+ version 2→3); every fingerprint VALUE byte-identical. +- Full suite green; ruff + mypy clean. + +→ Next: `…-02-collision-finalizer.md` (P2, the tripwire — must land before P3). diff --git a/docs/superpowers/plans/2026-06-09-fingerprint-rekey-02-collision-finalizer.md b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-02-collision-finalizer.md new file mode 100644 index 00000000..6e8e559f --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-02-collision-finalizer.md @@ -0,0 +1,86 @@ +# P2 — collision finalizer guard (the tripwire) + +> Phase 2 of the fingerprint rekey. See `…-00-index.md` for the spine. +> Run after P1, **strictly before P3**. **none** rekey-impact. +> Closes `wardline-8fb773a7af`. + +> **✅ SHIPPED — `0a551c4` (guard) + `4928fbd` (real-chokepoint proof + member-list +> fix), rc4. `wardline-8fb773a7af` closed.** This file is reconciled post-hoc to the +> implementation that landed. The original draft proposed a non-gating +> `Kind.METRIC`/`Severity.NONE` diagnostic built by a singular `build_collision_finding`, +> with a separate `detect_fingerprint_collisions` detector scoped by hand to the +> `is_identity_bearing` population. The shipped design is **stronger and simpler** — a +> single gating-DEFECT builder over the full emitted set — see **What landed** below. + +- **id:** `finalizer-guard` +- **goal:** Land the no-collision runtime guard so that when P3 removes `line_start`, any discriminator bug (incl. the latent broad/silent collision `wardline-6102d4c833`) fires LOUD instead of collapsing silently in `baseline.py` `setdefault`. +- **depends-on:** P1 (soft — operates on the bare in-memory hex, untouched by the scheme stamp). **Strictly BEFORE P3.** +- **rekey-impact:** **none.** The diagnostic carries a `WLN-ENGINE-*` rule_id; the identity corpus population is `PY-WL-* ∧ Kind.DEFECT` (ADR §2), so it is excluded **by prefix** — no rekey, frozen contract untouched. (Excluded by prefix, *not* by the draft's `Kind.METRIC`/`Severity.NONE` — the shipped diagnostic is a DEFECT and is still correctly absent from the corpus.) + +**BLOCKER answered (record this):** duplicate-fp emission is NEVER legitimate between two findings a consumer would treat as DISTINCT. Every fingerprint consumer joins on `Finding.fingerprint` as a UNIQUE key — `baseline.generate_baseline` collapses same-fp with `setdefault` keep-first (`baseline.py:49`), `judged` is last-write-wins, the baseline/waiver/judged YAML loaders REJECT a duplicate outright, SARIF (`partialFingerprints`) and Filigree dedup downstream — so a distinct-pair collision silently masks one finding on all four joins (a real trust-boundary false-negative). The ONLY benign same-fp case is two **byte-identical** findings (collapsing loses nothing). `baseline.py:49`'s `setdefault` was silent insurance against a rule-suite bug; the guard makes it LOUD. + +## What landed + +The whole guard is one function — `build_collision_findings(findings)` in +`src/wardline/scanner/diagnostics.py` — wired as the last step of +`WardlineAnalyzer._analyze_inner` (`src/wardline/scanner/analyzer.py:661`; the public +`analyze` delegates through it), after `findings.extend(registry.run(context))` and +before `return findings`: + +```python +findings.extend(build_collision_findings(findings)) +``` + +- **One builder, not detector + builder.** The draft split detection + (`detect_fingerprint_collisions`) from construction (`build_collision_finding`, + singular). The shipped `build_collision_findings` (plural) does both: group the full + emitted set by bare fingerprint, then emit one diagnostic per ≥2-member colliding group. +- **Posture: gating `Kind.DEFECT` / `Severity.ERROR` at `ENGINE_PATH`** — the same + engine-soundness posture as `WLN-L3-MONOTONICITY-VIOLATION`, NOT the draft's + non-gating `Kind.METRIC`/`Severity.NONE`. A lineless DEFECT at `ENGINE_PATH` is NOT + downgraded to a non-gating FACT (`suppression.py` only downgrades lineless DEFECTs + *off* `ENGINE_PATH`), so the diagnostic ITSELF trips `--fail-on ERROR` — a silent + false-negative becomes a loud, gate-tripping signal. The guard is **additive**: it + drops neither colliding finding, it appends one DEFECT per colliding fingerprint. +- **Distinctness oracle: `Finding.to_jsonl()`** (deterministic, `sort_keys`) — the full + consumer-visible surface. A same-fp group whose members differ in `to_jsonl()` is a + lossy collision; byte-identical members are a benign duplicate and do NOT fire. Both + the count and the listed members derive from this single key, so a collision differing + only in `properties`/`severity` is still counted AND listed (the `4928fbd` member-list fix). +- **Scope: the full emitted finding set — which SUBSUMES the draft's hand-scoping, it + did not regress it.** The draft excluded `WLN-ENGINE-*`/`WLN-L3-*` engine diagnostics + from the population by hand. Under `to_jsonl` distinctness that exclusion is + unnecessary: an engine diagnostic's fingerprint is `_fingerprint(rule_id, message)`, + so two engine diagnostics share a fingerprint only if they share rule_id AND message — + i.e. they are byte-identical — i.e. benign and silent. Two *distinct* engine + diagnostics cannot collide by construction. So **both `PY-WL-*` AND `RS-WL-*` are + guarded**, engine diagnostics never false-positive, and the guard's OWN output cannot + collide (each diagnostic's fingerprint is keyed on the colliding fp, distinct per group). + +## Tests (shipped) + +- `tests/unit/scanner/test_diagnostics.py`: + - `test_collision_guard_flags_distinct_findings_sharing_a_fingerprint` + - `test_collision_guard_ignores_byte_identical_duplicates` + - `test_collision_guard_clean_set_emits_nothing` + - `test_collision_guard_distinguishes_on_any_consumer_visible_field` + - `test_collision_guard_is_deterministic_and_per_group` +- `tests/unit/scanner/test_analyzer.py::test_analyze_emits_collision_diagnostic_through_the_real_chokepoint` + — a stub registry returning two same-fp/different-message findings through the REAL + `WardlineAnalyzer.analyze` proves the assembled whole fires (the prior tests forged + findings; a guard against a silent failure that is itself inertly wired fails silently). +- `tests/unit/core/test_suppression.py::test_collision_diagnostic_survives_suppression_and_trips_gate` + — end-to-end: the diagnostic survives `apply_suppressions` as an ACTIVE DEFECT and trips + the gate at `--fail-on ERROR`. +- `tests/golden/identity/test_identity_parity.py::test_corpus_fingerprints_are_collision_free` + stays byte-green on 3.12 and 3.13 — the corpus generator already excludes engine + diagnostics, so no new capture carries the rule_id and the frozen contract is untouched. + +## Acceptance (met) +Guard wired at `analyzer.py:661`; forged DEFECT collision → exactly one gating diagnostic, +drops nothing; engine diagnostics never false-positive (byte-identical ⇒ benign); `PY-WL-*` +AND `RS-WL-*` in scope; oracle byte-green both legs; `WLN-ENGINE-FINGERPRINT-COLLISION` +never in the corpus. Full suite 2704 green; dogfood `wardline scan src` 0 collisions; +ruff + mypy clean. + +→ Next: `…-03-drop-linestart-discriminator.md` (P3, THE value-rekey — this tripwire must be green first). diff --git a/docs/superpowers/plans/2026-06-09-fingerprint-rekey-03-drop-linestart-discriminator.md b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-03-drop-linestart-discriminator.md new file mode 100644 index 00000000..9aa64b67 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-03-drop-linestart-discriminator.md @@ -0,0 +1,69 @@ +# P3 — rules-discriminator (THE value-rekey) + +> Phase 3 of the fingerprint rekey. See `…-00-index.md` for the spine. +> Run after P2 (the tripwire must be green). **value-rekey** rekey-impact. +> Closes `wardline-8654423823`; folds in the `wardline-6102d4c833` broad/silent fix. + +- **id:** `rules-discriminator` +- **goal:** Make the fingerprint invariant to comment-insertion / vertical moves: drop `line_start` from the hashed parts; migrate every rule to the move-stable `taint_path` convention; broad/silent_exception gain the span (also the fix for `wardline-6102d4c833`); regen the corpus once. +- **depends-on:** P1 (scheme + loaders), **P2 (guard must exist first).** +- **rekey-impact:** **value-rekey.** Every `PY-WL-*` (and `RS-WL-*` via `run_scan`) fingerprint VALUE changes. +- **blast radius:** every store + SARIF carry new values (carried forward by P4). Engine `WLN-ENGINE-*` fps (separate local `_fp` helpers) UNAFFECTED. Corpus regenerated (corpus_version 3→4). RS-WL-* values shift but are firewalled from the three local stores by `provisional_identity=True`. + +**Scheme label (must-fix):** P3 stamps **`wlfp2`** — distinct from P1's `wlfp1`, so a store written between P1 and P3 loud-fails `SCHEME_MISMATCH`. Set `FINGERPRINT_SCHEME = "wlfp2"` in `finding.py` as part of this phase. + +**Corpus version (must-fix):** P1 already bumped 2→3, so P3 bumps **3→4**. + +## Per-rule discriminator checklist + +| Rule(s) | Class | Old taint_path | New taint_path | Edit site | +|---|---|---|---|---| +| PY-WL-101/102/109/110/111/113/119 | singleton (def-anchored) | `None` | `None` (drop `line_start` arg only) | assert_only_boundary:80/83, boundary_without_rejection:72/75, contradictory_trust:129/132, degenerate_boundary:90/93, failopen_boundary:119/122, none_leak:250/253, untrusted_reaches_trusted:114/117 | +| PY-WL-103 (broad) / PY-WL-104 (silent) | handler-anchored | `None` | `f"{rel}:{h.col_offset}:{h.end_col_offset}:except"` where `rel = h.lineno - entity.location.line_start` (anchor is `handler.lineno`, NOT def line — confirmed) | broad_exception:52/63, silent_exception:55/63 | +| PY-WL-106/107/108/112/115/116/117 | call-site family | `f"{sink}@{col}:{end_col}"` (NO line term) | prepend `rel_line` → `f"{rel}:{col}:{end_col}:{callee}"` | **single edit at `_sink_helpers.py` base `_fp` (call ~:271, taint_path ~:282)** | +| PY-WL-118 (sql_injection) | call-site | `f"{sink}@{col}:{end_col}"` | prepend `rel_line` | sql_injection.py `_fp` ~:154, taint_path ~:165, line_start ~:157 | +| PY-WL-105 (untrusted_to_trusted_callee) | call-site | span | prepend `rel_line` | untrusted_to_trusted_callee.py `_fp` ~:167 | +| PY-WL-120 **return site** (stored_taint) | singleton-like | `None` | `f"{rel}:{col}:{end_col}:return"` (`:return` token DISTINCT from call-arg callee token) | stored_taint.py ~:155 | +| PY-WL-120 **call-arg site** (stored_taint) | call-site | span | prepend `rel_line`, callee token | stored_taint.py ~:222 | +| PY-WL-114 (invalid_decorator_level) | ordinal — **KEEP** | `f"{name}:{token}#{ordinal}"` | unchanged; **only drop the `line_start` arg** (at ~:172, NOT :190) | invalid_decorator_level.py | +| `_PolicyConfigRule` | — | — | drop `line_start=None` | rules/__init__.py | + +**Reality correction:** there are **23 `line_start=` args across 15 rule files** (not "~12 / 9 files"). Dropping the parameter forces editing all of them; a miss is a `TypeError` at import (loud), not silent — but the file list must be complete. + +## TDD steps + +- [ ] **S1 — comment-insertion stability test (driver) — NON-corpus dir.** + - Test: `tests/golden/identity/test_rekey_mutation_pairs.py::test_comment_above_entity_keeps_fingerprint` — scan `before.py`/`after.py` (byte-identical except a benign comment above a finding-bearing entity); match findings ACROSS scans by `(rule_id, qualname, sorted(properties))` (NOT by fingerprint — circular); assert matched findings have IDENTICAL fingerprint. Fails today. + - **Constrain the multi-emit fixture to exactly ONE finding per `(rule, qualname)`** (else the non-fp match key is ambiguous and the test flakes). Fixtures live in `tests/golden/identity/fixtures/rekey_mutation/` (NON-corpus — does not staleness the frozen corpus). + +- [ ] **S2 — collision-pair gate (driver) — NON-corpus.** + - Test: `tests/golden/identity/test_rekey_collision_pairs.py::test_multiemit_pairs_stay_distinct` — for each `(rule_id, qualname)` group with >1 finding assert `len(set(fingerprints)) == count`. Plant: (a) two same-sink calls at the **SAME column on DIFFERENT lines with SAME-LENGTH call text** (`cur.execute(qa)` / `cur.execute(qb)` — so `rel_line` is the SOLE distinguisher; differing-length text would make `end_col` differ and the gate vacuous); (b) two broad `except` handlers (PY-WL-103); (c) two silent handlers (PY-WL-104) — the `wardline-6102d4c833` case with no fixture today. + - **Put these fixtures in a dedicated NON-corpus dir** (mirror S1), OR if kept in the corpus confirm `test_corpus_fingerprints_are_collision_free` now covers them. (Editing `tests/golden/identity/fixtures/sinks/wardline_sinks.py` stalenesses `sinks.json` immediately — prefer non-corpus.) + +- [ ] **S3 — construction-shape lint (source-AST) + `RuleMetadata.multi_emit`.** + - Test: `tests/unit/scanner/rules/test_discriminator_shape.py::test_every_multiemit_rule_carries_a_span_or_ordinal` — `ast.parse` each `scanner/rules/*.py`, find each `_fp`/`compute_finding_fingerprint` call, read its `taint_path` kwarg; for `metadata.multi_emit` rules assert it references `col_offset`/`end_col_offset` OR contains `#`+ordinal; for singletons assert it's the `None` literal. + - **Special-case the `TaintedSinkRule` base** for the 7 sink subclasses (106/107/108/112/115/116/117) — they have NO per-module `_fp` call (the single call is in `_sink_helpers.py:271`, which carries no rule_id). Assert ITS taint_path carries a span; treat subclasses as covered. + - Impl: add `RuleMetadata.multi_emit: bool` (`metadata.py`) as the non-circular source of truth (`taint_path` is a hash input, never persisted, so a runtime/corpus reader can't see it — the lint must be source-AST). + +- [ ] **S4 — ATOMIC engine edit:** drop `line_start` from `compute_finding_fingerprint` signature/body (`parts = (rule_id, path, qualname or "", taint_path or "")`); set `FINGERPRINT_SCHEME = "wlfp2"`; migrate ALL 23 call sites per the table. `line_start` stays on `Location`. + - **Cross-WP contract for P4 (must expose):** P4's migration needs the v0 `taint_path` STRING for call-site rules (not reconstructible from a post-change Finding). **Surface it** — e.g. stash the pre-change taint_path on `finding.properties["taint_path_v0"]` — and **preserve `handler.lineno` on `Location.line_start` for PY-WL-103/104** (NOT the def line) so P4 can derive the handler old_fp. (If you choose NOT to expose `taint_path_v0`, P4 falls back to a two-engine scan — flag it.) + - The S1–S3 tests are the drivers and go green here. Confirm full suite green EXCEPT the byte-parity gate (red until S5). + +- [ ] **S5 — TERMINAL re-green (corpus regen).** + - Test: `tests/golden/identity/test_identity_parity.py::test_identity_corpus_is_byte_identical` — red until regen, then green on both legs. + - Impl: `regen.py` adds `--new-scheme-version`, bumps `CORPUS_VERSION` 3→4, writes `fingerprint_scheme=wlfp2` into META. Run: + ``` + cd tests && PYTHONPATH=. python -m golden.identity.regen \ + --reason 'rekey: drop line_start + move-stable discriminators' --new-scheme-version wlfp2 + ``` + +## Acceptance +- `compute_finding_fingerprint` no longer accepts/hashes `line_start`; `parts == (rule_id, path, qualname or "", taint_path or "")`; `FINGERPRINT_SCHEME == "wlfp2"`. +- Comment above any finding-bearing entity → byte-identical fingerprint (matched by non-fp key). +- No two distinct ACTIVE findings share a fp: collision-pair gate green for SAME-LENGTH cross-line call pairs AND two-broad-handler AND two-silent-handler functions (`wardline-6102d4c833` verified, not just documented). +- Every multi-emit rule's taint_path carries a span or PY-WL-114's ordinal; every singleton's is `None` — enforced by the shape lint × `RuleMetadata.multi_emit`. +- `taint_path_v0` exposed for call-site rules; `Location.line_start` for PY-WL-103/104 is the handler line (P4 contract). +- Corpus regenerated, corpus_version 4, `fingerprint_scheme=wlfp2` in META; byte-parity green on 3.12 and 3.13. +- Full suite green; `line_start` still on `Finding.location`; ruff + mypy clean. + +→ Next: `…-04-scan-driven-migration.md` (P4, `wardline rekey` — carries verdicts across this rekey). diff --git a/docs/superpowers/plans/2026-06-09-fingerprint-rekey-04-scan-driven-migration.md b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-04-scan-driven-migration.md new file mode 100644 index 00000000..12df4b68 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-04-scan-driven-migration.md @@ -0,0 +1,124 @@ +# P4 — migration (`wardline rekey`) + +> Phase 4 of the fingerprint rekey. See `…-00-index.md` for the spine. +> Run after P1 + P3 land. **value-rekey** (operator-run, never automatic). +> Realizes migration `weft-e618c4118a` (WL-1) as a scan-driven one-shot remap. + +- **id:** `migration` +- **goal:** Carry every baseline/judged/waiver verdict (+ best-effort Filigree) across the value-rekey in ONE migration scan, computing `old_fp` (frozen wlfp1 formula, incl. `line_start`) and `new_fp` (new engine) from the SAME source, journalled, resumable, with snapshot rollback. +- **depends-on:** **P1** (scheme headers + `SchemeMismatchError` + `build_waivers_document`), **P3** (defines `new_fp`; preserves `Location.line_start` == the old `_fp` line). Both landed. NOTE: P3 did NOT surface `taint_path_v0` (corpus-contamination — see S2); P4 adds its own non-serialized field + recomputes the old taint_path forms. +- **rekey-impact:** **value-rekey** (operator-run; never automatic). +- **blast radius:** additive migration-only: `core/rekey.py`, `core/fingerprint_v0.py`, `cli/rekey.py`, one `cli/main.py` registration, `RekeyCollisionError` in `errors.py`, `migration_journal_path` in `paths.py`. Reads the stores + the `build_*_document` writers. Does NOT touch the production hash, suppression, analyzer, or rules. Each project's `.weft/wardline/` is mutated only by explicit `wardline rekey`; snapshot makes the YAML legs reversible. + +## Two corrected fundamentals (these were data-loss bugs in the draft) + +- **D-PROVENANCE — the journal/snapshot is the SOLE provenance source; NEVER re-read the live store on resume.** Crash-after-write-before-flag would otherwise resume, re-read the already-rewritten (new_fp) store, match zero against the journal's old_fp, and write an EMPTY store — shredding every verdict. **Fix:** carry full provenance from the immutable pre-flight snapshot (judged `rationale`/`model_id`/`policy_hash`/`recorded_at`/`confidence`; waiver `reason`/`expires`). Either embed full carried docs in the journal, or pin the snapshot as the sole provenance source. **Add a crash-after-write test asserting post-resume CONTENT equals the snapshot-derived expectation** (the double-invoke idempotency test alone passes while emptying the store). + +- **D-INJECTIVITY — per-collision orphan-and-report, NOT whole-run abort.** The new scheme is by construction lower-cardinality (it dropped `line_start`). Two findings differing only by `line_start` collapse → distinct `old_fp` → identical `new_fp`. A whole-run abort would brick a real project permanently. **But P2/P3 guarantee no two CURRENT findings share a `new_fp`**, so a collision here means a discriminator bug. **Posture:** report it LOUD (record both old_fps + the shared new_fp), orphan that pair, continue the migration. `RekeyCollisionError` becomes a per-pair report, not a fatal raise. + +**v0 scheme reconciliation:** the frozen module computes the **wlfp1** hash (the OLD line_start-in formula that P1 stamped). `from=wlfp1`, `to=wlfp2`. + +## TDD steps + +- [ ] **S1 — freeze `compute_finding_fingerprint_v0` (the wlfp1 formula).** + - Test: `tests/unit/core/test_fingerprint_v0.py::test_v0_matches_pre_change_hash` — pin `compute_finding_fingerprint_v0(rule_id=..., path=..., line_start=42, qualname=..., taint_path=...)` to a hardcoded 64-hex literal **sourced from the pre-change git tip / an independent hand-rolled sha256, NEVER by running the frozen copy** (circular); also assert changing `line_start` changes the digest. + - Impl: new `src/wardline/core/fingerprint_v0.py` — byte-exact copy of the pre-P3 `finding.py:154-165` body (line_start IN). Migration-only consumer; never edited again; never called by production scan (own module so the oracle stays byte-green). + +- [ ] **S2 — dual-fingerprint contract from one scan.** + - Test: `tests/unit/core/test_rekey_dual_fp.py::test_dual_fingerprint_for_every_rule_class` — over a fixture exercising **singleton (PY-WL-102), handler (PY-WL-103), ordinal (PY-WL-114), call-site (PY-WL-118), AND BOTH PY-WL-120 sites** (return → singleton-like, call-arg → call-site), assert `compute_old_new_fingerprints(scan_result)` returns per finding `(old_fp, new_fp)` where `old_fp == compute_finding_fingerprint_v0(...)` matches the v0 golden and `new_fp == finding.fingerprint`. (Do NOT assert `old_fp != new_fp` partially — `line_start` drops for ALL rules so they ALL differ; the load-bearing property is that `old_fp` MATCHES the stored fp.) + - **Cross-WP contract — what P3 ACTUALLY left (decided 2026-06-09, ground-truthed):** P3 **deliberately did NOT surface `taint_path_v0`.** Reason: `taint_path` is ephemeral (consumed by the hash, never stored), and stashing it in `properties` would contaminate the FROZEN identity corpus (`to_jsonl` includes `properties`) + every SARIF/Filigree payload, then need a THIRD rekey to remove — disqualifying. So P3 owes P4 exactly ONE thing, and it is satisfied: **`Location.line_start` == the exact line the OLD `_fp` hashed at every rule** (verified — singletons/114 use the def line, PY-WL-103/104 use the handler line, call-site rules use the call line; every rule sets `Location.line_start` and the old `_fp`'s `line_start` from the SAME value). So `old_fp`'s `line_start` component = `finding.location.line_start` for ALL rules. The OLD `taint_path` is what P4 must reconstruct: + - singletons (101/102/109/110/111/113/119), PY-WL-103/104, PY-WL-120-return: OLD `taint_path` was `None` → P4 uses `None`. + - PY-WL-114: OLD == NEW `taint_path` (`f"{name}:{token}#{ordinal}"`, unchanged by P3) — but it is ephemeral, so P4 must recompute it. + - call-site family (106/107/108/112/115/116/117 via `_sink_helpers`, 118, 105, 120-call-arg): OLD `taint_path` was `f"{sink}@{col}:{end_col}"`; NEW is `f"{rel}:{col}:{end_col}:{sink}"`. Both built from the SAME AST inputs (sink/callee name + `col_offset`/`end_col_offset`), so P4 can recompute the OLD form. + - **P4's chosen mechanism (its own impl, not P3's):** add a non-serialized `Finding.taint_path_v0: str | None = None` field (NEVER referenced by any serializer — `to_jsonl`/SARIF/`to_filigree_metadata`/store-doc builders are all explicit-field dicts, so it stays zero-pollution and needs no third rekey), and set it at the same rule sites P3 already edits by recomputing the OLD form from the still-present AST inputs. The rules still EXIST in P4 — no rule resurrection / two-engine scan needed. + - Impl: `src/wardline/core/rekey.py` with `compute_old_new_fingerprints(result) -> list[FingerprintRemap]` (`FingerprintRemap`: old_fp, new_fp, rule_id, path, qualname). Derive v0 taint_path per the per-finding (not per-rule — PY-WL-120 spans two classes) rule. Raise loudly if a call-site finding lacks the expected v0 component. + +- [ ] **S3 — new_fp injectivity → per-collision orphan-and-report (NOT abort).** See D-INJECTIVITY. + - Test: `tests/unit/core/test_rekey_injective.py::test_collapsing_remap_reports_and_continues` — two distinct old_fp + identical new_fp → the pair is recorded in a collisions list (naming both old_fps + the shared new_fp), neither verdict carried, the rest of the map proceeds; happy path returns the full map. + - Impl: add `RekeyCollisionError`/`RekeyCollision` to `errors.py`/`rekey.py`; build `new_fp -> old_fp` dict; on a second distinct old_fp record the collision and exclude both. Share the invariant text with P2's `WLN-ENGINE-FINGERPRINT-COLLISION`. + +- [ ] **S4 — pre-flight snapshot (the provenance source).** + - Test: `tests/unit/core/test_rekey_snapshot.py::test_snapshot_copies_existing_stores_only` — baseline+waivers present, judged absent → `snapshot_stores(root)` writes `.weft/wardline/.rekey_snapshot/{baseline,waivers}.yaml` byte-identical, no judged snapshot; second call idempotent (refuses to clobber the safe copy). + - Impl: `snapshot_stores(root)` in `rekey.py` using `paths.*_path` + `safe_paths.safe_project_file` confined under root. Copy only existing files; guard against overwrite. + +- [ ] **S5 — carry verdicts from the SNAPSHOT, preserving ALL provenance; flag orphans.** See D-PROVENANCE. + - Test: `tests/unit/core/test_rekey_carry.py::test_carry_preserves_provenance_and_flags_orphans` — seed baseline/judged/waiver with three stored old_fp (two in remap, one absent); assert `carry_*_forward` emit docs whose entries use new_fp, byte-preserve every non-fingerprint field for matched, and report the third as orphan. **Read old verdicts from the snapshot, not the live store.** + - Impl: `carry_baseline_forward`/`carry_judged_forward`/`carry_waivers_forward` in `rekey.py`, each returning `(new_document, carried_old_fps, orphaned_old_fps)`. Build docs via `build_baseline_document`/`build_judged_document`/`build_waivers_document` (the last CREATED in P1/S5) with the `wlfp2` header. New entries via `dataclasses.replace(entry, fingerprint=new_fp)`. + +- [ ] **S6 — journal (provenance-complete).** + - Test: `tests/unit/core/test_rekey_journal.py::test_journal_roundtrip_and_resume_skips_done` — `write_journal`/`load_journal` roundtrip; mark `baseline` done → `next_pending_leg == "judged"`; all-done → complete; resume reads the persisted remap WITHOUT `run_scan` (inject a spy that fails if called). + - Impl: `migration_journal_path(root)` in `paths.py`. `Journal` dataclass (`schema_version`, `fingerprint_scheme_from="wlfp1"`, `fingerprint_scheme_to="wlfp2"`, remap, orphans, collisions, legs `[{name, done, carried, orphaned}]`). **Embed full carried docs OR confirm snapshot is the provenance source** (D-PROVENANCE — the remap alone lacks rationale/reason). YAML via `require_yaml`, confined write. + +- [ ] **S7 — per-leg-atomic idempotent application; YAML legs 1-3 first, gate green after leg 1.** + - Test: `tests/unit/core/test_rekey_legs.py::test_legs_idempotent_and_gate_green_after_yaml` — run `apply_pending_legs(root, journal)` twice; each YAML store written once (mtime stable on 2nd run), all YAML done-flags set; rekeyed files load under wlfp2 with no `SCHEME_MISMATCH` while the pre-snapshot copies still fail. + - **Crash-safety test (must-fix):** `tests/unit/core/test_rekey_legs.py::test_crash_after_write_before_flag_preserves_content` — simulate process death after store write, before done-flag; resume; assert post-resume content equals the snapshot-derived expectation (NOT an empty store). + - Impl: `apply_pending_legs(root, journal, *, filigree=None)` — for each not-done YAML leg: carry from snapshot → write → persist done-flag (crash-safe). Source provenance from snapshot/journal, NEVER the live store. + +- [ ] **S8 — Filigree leg (last, reconciliation debt, soft-fail).** + - Test: `tests/unit/core/test_rekey_filigree.py::test_filigree_leg_soft_fails_after_yaml_done` — all YAML legs done + injected emitter returning a connection error → filigree leg `done=False` with recorded debt, overall YAML migration still success, YAML stores untouched; a 2xx marks it done. + - Impl: build the leg on `filigree_emit.build_scan_results_body` over carried findings (new_fp wire) via the injected `FiligreeEmitter.emit`; the mark_unseen sweep fires by default for a non-empty set (closing old_fps — note `build_scan_results_body` computes `mark_unseen` internally; it is NOT a settable param). Honest: NO remap endpoint exists; old associations may orphan. Record debt; never raise on soft-fail. + +- [ ] **S9 — `--probe` (read-only cross-check).** + - Test: `tests/unit/core/test_rekey_probe.py::test_probe_reports_unmatched_and_collisions_without_writing` — seeded tree with one orphan; `probe(root)` returns `ProbeReport(matched=N, orphaned=[...], collisions=[...])`; NO file under `.weft/wardline/` changes (mtimes stable, no journal). + - Impl: `probe(root, *, config_path, ...) -> ProbeReport` — scan, dual-fp, load stores, compute match/orphan/collision, assert dry-run carry docs load clean. Pure. + +- [ ] **S10 — forward-only rollback.** + - Test: `tests/unit/core/test_rekey_rollback.py::test_rollback_restores_yaml_byte_identical` — after a full rekey, `rollback(root)` restores the three YAML stores byte-identical to snapshot, removes the journal, issues reverse Filigree calls (injected emitter); no-op-safe with a clear error if no snapshot exists. + - Impl: `rollback(root, *, filigree=None)` — require snapshot (else `WardlineError`); copy snapshot back; replay remap in reverse against the emitter (best-effort, soft-fail); delete journal+snapshot on success. **YAML rollback clean+complete; Filigree rollback best-effort (may orphan).** + +- [ ] **S11 — CLI wiring.** + - Test: `tests/unit/cli/test_rekey_cli.py::test_rekey_end_to_end_dry_run_on_copy` — over a COPIED `.weft/wardline/` tree: `rekey PATH` → exit 0, stores `SCHEME_MISMATCH`-clean + journal complete; `--probe` → exit 0, writes nothing; `--resume` after deleting the judged done-flag re-applies only judged without re-scanning; `--rollback` restores snapshot. + - Impl: `src/wardline/cli/rekey.py` (`@click.command "rekey"`) delegating to `core/rekey.py`; register via `cli.add_command(rekey)` in `src/wardline/cli/main.py`. Honor `--config`/`--cache-dir`/`--trust-pack`/`--allow-custom-packs`/`--strict-defaults`; `--filigree-url`/`--filigree-token` opt-in. **Map exceptions mirroring `_generate_baseline` at `cli/main.py:66-93`** (`WardlineError`→exit 2; clean→0; drift-on-probe→nonzero). There is no `cli/baseline.py`. + +- [ ] **S12 — loud-missed-leg integration (the safety contract).** + - Test: `tests/integration/test_rekey_loud_miss.py::test_unrekeyed_store_fails_scheme_mismatch` — rekey baseline+judged, simulate the waivers leg never running (old-scheme waivers.yaml left in place); assert `run_scan`/`load_project_waivers` raises `SchemeMismatchError` naming `waivers.yaml` + `run wardline rekey`; after the waivers leg completes the scan is clean. + - Impl: no new production code — consumes P1's load-time assertion. If it fails, the bug is a missing scheme header on the carried doc (fix in `carry_*_forward`/`build_*_document`). + +## Acceptance +- `wardline rekey PATH` does exactly ONE `run_scan`, writes a complete `migration_journal.yaml`, applies legs `[baseline, judged, waivers, filigree]`. +- After leg 1 the local gate is green under wlfp2; all three YAML stores load `SCHEME_MISMATCH`-clean. +- `old_fp` via frozen `compute_finding_fingerprint_v0` (wlfp1) over `(rule_id, path, finding.location.line_start, qualname, recomputed-old-taint_path)`, `new_fp` via the new engine. +- Every carried entry byte-preserves all non-fingerprint provenance **sourced from the snapshot**; orphans reported, never dropped. +- **Crash-after-write-before-flag preserves content** (not an empty store). +- Two old_fp → one new_fp is reported per-pair and the migration continues (NOT a whole-run abort). +- `--resume` applies only not-done legs WITHOUT re-scanning (spy proves it); re-running a done leg is a no-op. +- `--probe` writes nothing; reports match-rate/orphans/collisions. +- `--rollback` restores YAML byte-identical + removes journal; Filigree reversal best-effort with recorded debt. +- Filigree leg last, soft-fails on absent/5xx/401 (records debt, never aborts the completed YAML migration). +- Integration: un-rekeyed store fails `SCHEME_MISMATCH` naming the file + `run wardline rekey`. +- Full suite green; oracle byte-green both legs (`compute_finding_fingerprint_v0` never called by production); ruff + mypy clean. + +## Operator runbook + +``` +# 0. Pre-1.0; no compat shim. After P1–P3 land, EVERY existing project's stores +# are old-scheme (wlfp1) and will SCHEME_MISMATCH on the next scan. Run rekey. + +# 1. DRY-RUN FIRST — read-only, writes nothing. +wardline rekey PATH --probe +# Reports matched=N, orphaned=[old_fp...], collisions=[...]. +# Investigate orphans (source moved/deleted → verdict won't carry) and any +# collision (a discriminator bug — should be EMPTY given P2/P3). + +# 2. REKEY — one scan, snapshot, journal, legs [baseline, judged, waivers, filigree]. +wardline rekey PATH +# - Snapshots the three YAML stores to .weft/wardline/.rekey_snapshot/ (provenance source). +# - Writes .weft/wardline/migration_journal.yaml (from=wlfp1, to=wlfp2, remap, orphans, collisions, per-leg done-flags). +# - Leg 1 (baseline) → gate green under wlfp2. Legs 2-3 (judged, waivers) → all YAML stores wlfp2-clean. +# - Leg 4 (Filigree, LAST) → re-emit carried findings under new_fp + mark_unseen sweep. +# Soft-fails on sibling-absent/5xx/401 → recorded debt, does NOT abort the (complete) YAML migration. + +# 3. RESUME (if interrupted) — reads the journal, applies only not-done legs, NEVER re-scans. +wardline rekey PATH --resume +# DO NOT edit source between leg runs — old_fp/new_fp are pinned from the single scan; a re-scan would drift. + +# 4. ROLLBACK (forward-only) — restores YAML byte-identical from snapshot + removes journal. +wardline rekey PATH --rollback +# YAML rollback is clean+complete. Filigree reversal is best-effort (re-emit old_fp / close new_fp); old associations may orphan. +``` + +**Leg order rationale:** YAML first (gate-critical — leg 1 restores the local `--fail-on` gate); Filigree last (reconciliation debt, no remap endpoint). **Safety contract:** a missed leg is impossible to ignore — the un-rekeyed store `SCHEME_MISMATCH`es on the next scan and steers the operator to `wardline rekey`. + +**Documented debt (decision D1):** the Loomweave `wardline-taint-1` blob (`facts.py:70`) carries a bare fingerprint VALUE that also rekeys, but is **exempt from a migration leg** — Loomweave independently recomputes the whole-file blake3 and never cross-joins the fingerprint with Filigree. The next `wardline scan --loomweave-url` re-emits the blob with the new value. No leg; recorded as debt. + +→ Next: `…-05-rust-worktree-reconciliation.md` (P5, the rebase runbook — runs when the worktree rebases, not part of rc4). diff --git a/docs/superpowers/plans/2026-06-09-fingerprint-rekey-05-rust-worktree-reconciliation.md b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-05-rust-worktree-reconciliation.md new file mode 100644 index 00000000..fe373255 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-fingerprint-rekey-05-rust-worktree-reconciliation.md @@ -0,0 +1,42 @@ +# P5 — rust worktree reconciliation (NOT rc4 code) + +> Phase 5 of the fingerprint rekey. See `…-00-index.md` for the spine. +> This is a **rebase runbook + worktree-only changes** for `.worktrees/rust-plugin` +> (branch `feat/rust-plugin`), executed when the worktree rebases onto rc4 AFTER +> P1–P4 land. **No rc4 source edit here.** + +**Deliverable:** `docs/superpowers/specs/2026-06-09-rust-plugin-rebase-runbook.md` (a worktree doc — author it in the worktree, not rc4). + +## Rebase recipe — hot-file conflict resolution + +For `finding.py`, `baseline.py`, `suppression.py`: **TAKE rc4's tightened version** +(scheme stamp `wlfp2`, `line_start` dropped from the hash, scheme-mismatch loaders, +dropped `line_start` param), then **re-apply the worktree-only additions.** + +**The worktree-only additions are exactly THREE** (reality-corrected — the draft's +"WLN-ENGINE-LINELESS-DEFECT guard" is FALSE: that guard is ALREADY on rc4 at +`suppression.py:40-60`, diff-proven; the rebaser TAKES it from base, no action): + +1. `finding.py`: `UNANALYZED_RULE_IDS += "WLN-ENGINE-FILE-FAILED"`. +2. `baseline.py` `build_baseline_document`: the `if f.properties.get("provisional_identity") is True: continue` skip. +3. `suppression.py` `apply_suppressions`: the same provisional skip (lines 61-70 in the worktree — the ONLY diff from rc4). + +(2)/(3) are not textual conflicts (different code sections) but ARE a required semantic re-application onto rc4's rewritten hot files. + +**Rebase acceptance:** the worktree's existing firewall tests pass against the rc4-merged hot files — `build_baseline_document` excludes `provisional_identity` findings; `apply_suppressions` keeps them ACTIVE/never-matched. + +## Provisional firewall + scheme inheritance + +- RS-WL-* findings carry `provisional_identity=True` → firewalled from the THREE local stores (`baseline.py` write-exclude, `suppression.py` ACTIVE/never-matched) but DO flow to SARIF/Filigree. They **inherit `wlfp2` + the `/v2` SARIF key** on rebase — the worktree must carry the identical `FINGERPRINT_SCHEME` + helpers. +- **Collision-guard scope (decision D3):** P2's detector scopes to `Kind.DEFECT AND not engine-prefix`, so **RS-WL-* DEFECTs ARE guarded** (they have the riskiest resolved-tier fingerprints). Confirm the rebase does not narrow the scope to `PY-WL-*` only. + +## RS-WL-* discriminator + §8 doctrine (worktree change, SP2-gated) + +- **Drop the EXTERNAL_RAW-in-taint_path violation** (source-derived-only invariant): `rust/rules.py` `_program_finding` (RS-WL-108, folds `program_taint.value` ~:95) AND `_shell_finding` (RS-WL-112, folds `worst.value` ~:107) currently put a RESOLVED `TaintState` in `taint_path`. Replace with `f"{rel_line}:{col}:{end_col}:{token}"` (NodeId `@node{trigger_node_id}` suffix retained as collision-completeness fallback). The clean human form stays in `properties["taint_path"]`. +- **Plumb byte offsets:** `CommandTrigger` (`dataflow.py:44-49`) carries only `trigger_node_id`/`trigger_line`/`constructor_line` today — add `col_start`/`col_end` from `rust/index.py` tree-sitter byte points. +- **§8 byte-offset doctrine (decision D4):** tree-sitter `(row, byte-in-line)` is byte-compatible with CPython `ast` `col_offset` (both UTF-8 bytes), so the `rel_line:col:end_col` convention is uniform across frontends. **Multi-line subtlety:** CPython `end_col_offset` is the byte column on the END line; take `end_col` from the trigger node's END-row `end_point[1]`, NOT a naive `start_point[1]/end_point[1]` copy (wrong when the call spans lines). +- **Test (worktree):** `tests/unit/rust/test_rules.py::test_rs_wl_taint_path_is_source_derived_and_move_stable` — fingerprinted taint_path contains NO `TaintState` value (no `EXTERNAL_RAW`/`.value`); a benign line above the enclosing fn leaves the fingerprint unchanged; two distinct commands on one line stay distinct. **Exercise BOTH `_program_finding` (RS-WL-108) and `_shell_finding` (RS-WL-112)** or one violation survives. +- **Gating:** this change AND un-provisioning are **SP2-gated** — `provisional_identity=True` MUST persist across the rebase (un-provisioning is a later slice). Worktree test `test_rs_wl_still_provisional_post_rebase` confirms RS-WL-* stay provisional and absent from any written baseline post-rebase. + +## Why the blast radius is low +A Python rekey (P1–P4) has near-zero incremental blast radius on the `.rs` plugin: provisional findings never enter the three local stores. The SARIF/Filigree axis rekeys with the shared function, but RS-WL-* identity is already contractually unstable (the provisional banner) — no NEW orphan risk. The real cost is this rebase coordination on the four hot files, which is bounded and scripted above. diff --git a/docs/superpowers/plans/2026-06-09-three-deliverables-runlist.md b/docs/superpowers/plans/2026-06-09-three-deliverables-runlist.md new file mode 100644 index 00000000..95a73f99 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-three-deliverables-runlist.md @@ -0,0 +1,76 @@ +# Three-deliverable runlist — state + executable sequence (2026-06-09) + +> Ground-truthed against rc4 @ local tip (3 commits ahead of origin/rc4), the +> `feat/rust-plugin` worktree, and the Filigree tracker. **`✅ done · ⏳ in +> flight · ❌ not started · 🔒 gated`.** The detailed per-step instructions live +> in the `2026-06-09-fingerprint-rekey-0X-*.md` plan files; this is the index +> above them — what's done, what's next, which file to open. + +## The shape + +- **#2 Fingerprint rekey is the critical path** and a hard **1.0 prereq**. Nothing in #1 ships until it lands. +- **#3 Rust plugin is effectively built** (WP1–WP7 on its branch); its only remaining work is *integration* (the P5 rebase, after #2 lands) — so it's genuinely "downtime / no rush." +- **#1 1.0 is release-cut mechanics** gated on #2: version is already `1.0.0rc4`, dogfood PRs (#30, #34) merged, **no open dogfood/lacuna or P0/P1 tickets**. + +Master order: **rekey spine (P1→P2-finish→P3→P4) → rebase rust (P5) → lacuna re-dogfood → cut 1.0.** + +--- + +## Deliverable #2 — Fingerprint rekey (1.0 PREREQ · critical path) + +The single source serialization point is the **identity corpus** (`tests/golden/identity/corpus/*`): P1 and P3 each regen it. **No two corpus-touching phases run in parallel.** + +| Phase | State | Evidence | +|---|---|---| +| **P1 scheme-infra** | ✅ **DONE · committed · reviewed · ticket closed** | `FINGERPRINT_SCHEME=wlfp1` + format/parse + `require_fingerprint_scheme` (`finding.py`); `SchemeMismatchError`; loud-fail loaders + headers (baseline/judged/waivers, `build_waivers_document`); Filigree wire+envelope+promote prefixed; legis envelope; SARIF `/v2`; `finding_identity.resolve_identity`. Commits `5ff986f`+`30f55dc`; 251 fp byte-identical; suite 2744 green; `wardline-7da6f6df29` closed | +| **P2 collision guard** | ✅ **DONE · committed · ticket closed** | `build_collision_findings` @ `diagnostics.py:111`, wired `analyzer.py:661`; tests committed (`0a551c4` + `4928fbd`); plan `…-02` reconciled to shipped reality; `wardline-8fb773a7af` `closed` | +| **P3 drop line_start** | ✅ **DONE · committed · reviewed · tickets closed** | `compute_finding_fingerprint` drops `line_start`; `wlfp1→wlfp2`; entity-relative discriminators on all multi-emit rules; corpus 3→4; S3 shape-lint + `RuleMetadata.multi_emit`. Commits `966cd9f`+`979f94d`; suite 2748 green; `wardline-8654423823` + `wardline-6102d4c833` closed | +| **P4 migration (`wardline rekey`)** | ✅ **DONE · committed · reviewed · ticket closed** | `core/rekey.py` + `core/fingerprint_v0.py` + `cli/rekey.py`; one scan -> dual-fp -> snapshot -> journal -> legs [baseline,judged,waivers,filigree], resumable + rollback. Non-circular oracle (vs real wlfp1 corpus @ `966cd9f^`). Commits `7bfe81b`+`c3e3ce7`; suite 2788 green; `wardline-cdfd2a7808` (hub `weft-e618c4118a`) closed | +| **P5 rust reconciliation** | 🔒 gated on P1–P4 + rebase | (also = #3 integration) | + +### Executable steps + +- [x] **2.1 — Finish P2 (do NOT rebuild it).** ✅ **DONE.** The shipped impl was the keeper and *diverged from its plan file* (it chose `Kind.DEFECT`/`Severity.ERROR` fail-loud, one `build_collision_findings(findings)`, `to_jsonl()` distinctness — the draft said METRIC/NONE + singular fn). Resolution: + - [x] Tests were already committed (`0a551c4` shipped the 5 unit + 1 e2e suppression/gate tests; `4928fbd` added the real-chokepoint proof + member-list consistency fix). No uncommitted `test_diagnostics.py` edit remained. Suite 2704 green. + - [x] **Reconciled `…-02-collision-finalizer.md`** to shipped reality (DEFECT/ERROR, single `build_collision_findings` over the full set, `to_jsonl` oracle) — reframed as a shipped-record. **`…-03` and `…-04` were re-verified and needed NO edit:** they reference P2 only design-agnostically ("the tripwire / the guard must exist first", the real rule_id `WLN-ENGINE-FINGERPRINT-COLLISION`, "P2/P3 guarantee no two current findings share a new_fp"). The earlier "dangling cross-refs in 03/04" note was a planning-time guess that ground-truth grep disproved — no `detect_fingerprint_collisions` / singular fn / "non-fatal" string exists in either. Likewise `…-00-index` (P2 = "finalizer / tripwire / rekey-impact none") is still accurate. + - [x] Closed `wardline-8fb773a7af`. + +- [x] **2.2 — P1 scheme-infra (the floor).** ✅ **DONE** (`wardline-7da6f6df29` closed; commits `5ff986f` impl S1–S10 + `30f55dc` review fixes). Format-only proven: 251 corpus fp VALUES byte-identical; only corpus delta = SARIF `/v1`→`/v2` + META scheme (corpus_version 2→3). 4-lens adversarial review caught 1 HIGH (Filigree **promote** wire was bare while ingest was prefixed → `file_finding` 404; fixed + lock test) + 2 MEDIUM (stale `/v1` docs; test hardening). Suite 2744 green; oracle byte-green; ruff/mypy/mkdocs clean. + +- [x] **2.3 — P3 drop line_start (THE value-rekey).** ✅ **DONE** (`966cd9f` rekey S1–S5 + `979f94d` S3 shape-lint; `wardline-8654423823` + `wardline-6102d4c833` closed). Dropped `line_start` from the hash; entity-relative discriminators on every multi-emit rule (PY-WL-103/104 gained the handler span — the `wardline-6102d4c833` fix); `wlfp1→wlfp2`; corpus 3→4. **Decision:** `taint_path_v0` deliberately NOT exposed (it would contaminate the frozen corpus via `to_jsonl`); P3 owes P4 only `Location.line_start == old hashed line` (satisfied) — P4 adds its own non-serialized field + recomputes old forms. 4-lens review: injectivity sound, no live defect; the one finding (deferred S3 lint) folded in. Suite 2748 green; oracle byte-green; dogfood 0 collisions; ruff/mypy/mkdocs clean. + +- [x] **2.4 — P4 migration `wardline rekey`.** ✅ **DONE** (`7bfe81b` impl S1–S12 + `c3e3ce7` 5-lens review fold; `wardline-cdfd2a7808` / hub `weft-e618c4118a` closed). `core/rekey.py` + `core/fingerprint_v0.py` (frozen wlfp1) + `cli/rekey.py`; one scan → dual-fp → snapshot → journal → legs [baseline,judged,waivers,filigree], `--probe`/`--resume`/`--rollback`. D-PROVENANCE (snapshot is the sole content source, carry never reads the live store) + D-INJECTIVITY (per-collision orphan-and-report) both held. **Review caught + fixed a real gate regression:** the remap population was PY-WL-* only but the baseline stores every DEFECT → engine DEFECTs (POLICY-CONFIG, L3 diagnostics) orphaned + resurfaced; widened to `Kind.DEFECT and not RS-WL-*` with a two-class old_fp (v0 for compute_finding_fingerprint-based, identity for scheme-independent engine diagnostics). + already-complete forward-rerun guard, Filigree failed>0 debt, atomic journal, orphan-echo. Non-circular oracle (vs the real wlfp1 corpus @ `966cd9f^`). Suite 2788 green; oracle byte-green 3.12+3.13; dogfood clean. **RS-WL exclusion flagged P5-REVISIT.** + +- [ ] **2.5 — Verify after EACH phase:** full suite (~2625) green · identity oracle byte-green on **3.12 AND 3.13** · `ruff` + `mypy` clean. + +--- + +## Deliverable #3 — Rust plugin (nice-to-have · NOT 1.0-required · downtime) + +**Status: feature-complete on `feat/rust-plugin` (worktree clean).** WP1–WP7 all landed: +WP1 discovery `63dabbc` · WP2 parse+qualname `09b015f` · WP3 vocab `4cf7ccf` · WP4 dataflow `5752807` · WP5 rules+RustAnalyzer `17776ee` · WP6 wire into `run_scan`+CLI `--lang rust` `4c2a2dd`/`b344aea`/`8c8dc94` · WP7 docs `ed49189` · qualname corpus re-vendor `c124f0c`. Memory said "WP3 done, WP4 next" — **stale; it's all done.** + +### Executable steps (all 🔒 until #2 lands) + +- [ ] **3.1 — P5 rebase onto rc4** (open `…-05-rust-worktree-reconciliation.md`). Take rc4's tightened `finding.py`/`baseline.py`/`suppression.py` (wlfp2, line_start dropped), re-apply the **3 worktree-only additions** (the doc lists them), inherit `wlfp2` + `/v2`. +- [ ] **3.2 — RS-WL-* discriminator fix** (SP2-gated): drop the `EXTERNAL_RAW`-in-`taint_path` violation in `rust/rules.py`, plumb byte offsets from tree-sitter. `provisional_identity=True` must persist. +- [ ] **3.3 — Decide merge:** rust → rc4 before 1.0 (in) or hold for 1.1 (out). Default per your framing: **out of 1.0**, merge when convenient. + +--- + +## Deliverable #1 — 1.0 release + lacuna dogfood (gated on #2) + +**Status: substantially done.** Version `1.0.0rc4`; dogfood PRs #30/#34 merged to main; **zero open dogfood/lacuna tickets**; no P0/P1 blockers. What remains is the release cut + a final validation pass — all **after #2 lands**. + +### Executable steps + +- [ ] **1.1 🔒 — Gate: fingerprint rekey (#2 P1–P4) merged to rc4.** Hard prereq. +- [ ] **1.2 — lacuna re-dogfood (acceptance).** Once rekey lands: run `wardline rekey` on the lacuna `.weft/wardline/` tree (`--probe` first), then `wardline scan . --fail-on ERROR` against lacuna → confirm clean + no orphaned baseline/waivers. This proves the rekey carries verdicts on a real project. +- [ ] **1.3 — Expansion backlog: in or out of 1.0?** 9× `expansion` P3 sink-family tasks + `wardline-718048a518` (boundary de-confliction) + `wardline-d6af917bde` (lambda zero-trip FN) + `wardline-e159060db7` (test hardening). These are the *separate agent-attributed expansion backlog*, not defect blockers. **Decision needed** — recommend post-1.0 unless you want broader sink coverage in the 1.0 cut. +- [ ] **1.4 — Release cut:** finalize CHANGELOG `[Unreleased]`→`1.0.0`, bump `_version.py` rc4→`1.0.0`, tag, PyPI publish (Trusted Publishing), push rc4 + open/merge the final `rc4→main` PR. (Prior rc PRs #31–#34 are the template.) +- [ ] **1.5 — Post-release:** delete superseded branches; update memory. + +--- + +## The one decision that's blocking nothing but worth making now — ✅ RESOLVED +The committed P2 (`build_collision_findings`, DEFECT/ERROR) **superseded its plan file**, and the **fail-loud design is kept** (it's stronger — the diagnostic itself trips the gate). `…-02` is reconciled to that reality; 2.1 was pure cleanup and is done. Downstream P3/P4 already key off the real design (they reference the rule_id and the no-collision guarantee, not the superseded fn name/posture). diff --git a/docs/superpowers/plans/2026-06-10-rust-sp2-phase1b-producer.md b/docs/superpowers/plans/2026-06-10-rust-sp2-phase1b-producer.md new file mode 100644 index 00000000..2accb830 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-rust-sp2-phase1b-producer.md @@ -0,0 +1,336 @@ +# Rust Frontend → Full ADR-049 Producer + SP2 Whole-Tree — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Panel-reviewed 2026-06-10** (reality/contract/architecture/quality/systems, all "execute-with-fixes") — every finding folded below. Where a step cites oracle source (file:line in /home/john/loomweave), the executor MUST read that source before implementing; the citations are the contract for behaviors the corpus does not pin. + +**Goal:** Take the Rust plugin from "preview / provisional identity, taint-only producer" to a full second ADR-049 producer with real crate-prefixed finding identity — RS-WL-* findings become baseline-eligible. + +**Architecture:** Wardline's tree-sitter Rust frontend (`src/wardline/rust/`) grows from callables-only to the full ten-kind ADR-049 entity surface (leaf kinds, the `impl` entity, `module → impl → method` containment) plus the two anchored edge kinds; an SP2 whole-tree pass (Cargo.toml crate roots, cross-file module routes) replaces the directory-name crate stub; identity then graduates (frozen `tests/golden/identity/rust/` corpus, `provisional_identity` plumbing removed). The Loomweave extractor + vendored corpus remain the oracle: where upstream behavior is decided-and-emitted but un-oracled (stacked-cfg fold, cfg reserved-char escape, leaf kinds), we add the corpus rows **upstream first** (verified by Loomweave's own cargo gate), re-vendor, then conform. Where upstream has no decision (reserved-colon path-typed generic args; const-arg spacing), we draft the ADR-049 decision letter and record the dependency — no unilateral normalization. + +**Tech Stack:** Python 3.12 (stdlib `tomllib` for manifests — zero new dep), tree-sitter / tree-sitter-rust (preview extra), pytest; Rust/cargo only to run Loomweave's own gates upstream. + +**Oracle ground truth (verified 2026-06-10, panel-corrected):** +- Loomweave repo: `/home/john/loomweave`, branch `rc4` @ `510a032` (`feat/rust-plugin-spec` is merged into rc4; the stale branch ref still exists locally — ignore it; the Rust plugin is live on-by-default). Working tree has ONE unrelated dirty file (`.agents/skills/loomweave-workflow/SKILL.md`) — never touch or stash it. +- Upstream corpus `fixtures/qualnames_rust.json` @ blob `a0aaa341041dc66...` (HEAD): 22 entity cases (all slice-1), 6 module_route cases (5 slice-1 + `path_attr_known_gap` sp2/known_gap). vs our vendored copy: ONE new case `generic_self_nested_param`; zero diffs in shared cases. +- The upstream corpus has **no `enum`/`trait`/`type_alias`/`const`/`static` rows** (`macro` IS pinned by `macro_invocation_generates_no_entity`) and **no stacked-cfg or cfg-escape rows** — but the extractor implements all of it (extract.rs:830-843 `cfg_predicates` collects ALL cfg attrs raw; qualname.rs:299-302 `cfg_discriminant` normalises each, sorts, joins `&`; qualname.rs:314-336 `normalise_pred` strips ws → `escape_reserved` (`%`→`%25` then `:`→`%3A`) → any()/all() 1-level arg sort; plugin.toml `entity_kinds` = the ten kinds, `ontology_version = "0.4.0"`). +- **Trait bodies are NOT walked** by the extractor (extract.rs:457 "Trait *bodies* are deliberately NOT walked here"); a trait definition emits only the `trait` entity. +- **cfg-twin suffixing is per-(kind, name) across ALL nine named item kinds** (extract.rs:326-358 `twin_counts`; struct arm :397-400, inline-mod arm :436-439) — suffix applied only on collision, per-kind counted. +- Crate roots (crate_roots.rs:47-95): a dir registers iff (a) `Cargo.toml` **parses as TOML** AND has `[package].name` as a string (toml::Value parse — `name.workspace = true` is a table → falls through), ELSE (b) `src/lib.rs` OR `src/main.rs` exists → directory-name fallback. Virtual workspace roots (no package.name, no src/lib|main.rs) register NOTHING. Names `-`→`_` normalised. Walk skips symlinked dirs (crate_roots.rs:83-94 + dedicated test). File→crate by longest path-prefix. +- Out-of-src files (scope.rs:21-32 `emittable_scope`): loomweave EXCLUDES tests/, benches/, examples/, build.rs, a `src/main.rs` shadowed by sibling lib.rs, and files under no crate root — it emits NOTHING for them. Wardline must NOT mirror that for scan coverage (see Task 4 design). +- Edges (resolve.rs:13-17, :40-49; extract.rs:634-665, :788-794): glob `use a::*` → Ambiguous(in-project module id) else dropped; multi-kind ambiguity to_id = FIRST id by sorted order; use-tree groups fan out, `as` aliases resolve the REAL path, `self` group leaf → the prefix module; trait lookup STRIPS generic args; negative impls emit no edge. +- Reserved-colon (`impl From`): upstream renders the colon then REJECTS at `entity_id` construction (`validate_no_colon`, entity_id.rs:140) and degrades the file — **no canonical colon-free form exists**. Genuine ADR-049 amendment needed (Task 9). +- Wardline already byte-conforms on every vendored slice-1 row (suite 3046 passed, 1 xfailed = the `path_attr_known_gap` sp2 module_route row; both xfail code branches exist at test_loomweave_rust_qualname_parity.py:144-145 + :154-155 but only the module_route one fires today). +- **Wardline's `cfg_predicate_of` (qualname.py:99-114) returns the NORMALIZED predicate and index.py stores it** — the stacked-cfg fix must restructure to collect RAW and normalize exactly once (Task 2). +- **analyzer.py has NO kind filter** (analyzer.py:138-153 iterates ALL entities, calls `taint_for(entity.node)` + `child_by_field_name("body")` unconditionally) — Task 3 MUST add the callable guard. +- weft.toml severity overrides still do NOT apply to Rust (analyzer.py:84-85) — the banner's severity-override warning stays after graduation. +- Loomweave CI gate is `cargo nextest run --workspace --all-features --no-tests=pass` (.github/workflows/verify.yml:91) plus lockstep scripts — Task 1 must run the workspace gate, not just the plugin suite. +- No stored state holds RS-WL fingerprints (no `.weft/` in the worktree; no RS-WL entries in any baseline.yaml) — the Task 4 rekey orphans nothing; keep the cheap defensive grep. + +**Branch/commit discipline:** all Wardline work on `feat/rust-gold` (worktree `/home/john/wardline/.worktrees/rust-gold`), scoped commits per task, merged to `rc5` at the end. Loomweave-side work is ONE scoped commit on their `rc4` touching only the fixture (+ its header text). The rc5 main checkout has unrelated uncommitted changes (ci.yml, mkdocs.yml, docs/index.md, .gitignore, docs/arch-analysis-2026-06-10/) — never touch them; they are disjoint from this sprint's paths (the sprint touches docs/guides/, docs/reference/, docs/integration/, docs/superpowers/, CHANGELOG.md — NOT docs/index.md or mkdocs.yml). + +**Filigree:** sprint label `rust-sp2-2026-06-10` on every issue touched (already applied). Umbrella task `wardline-9f00d5b44b` (claimed). Claim with `--actor rust-gold-sprint` before starting the relevant task; close on completion. Issues: `wardline-be5ee9cc34` (reserved-colon, Task 9), `wardline-4fdad782a7` (stacked-cfg, Tasks 1–2), `wardline-e8f7c0508f` (cfg escape + const spacing, Tasks 1–2 + 9), `wardline-868908944b` (drift alarm, Task 8). + +--- + +## Dependency graph + +``` +Task 0 (spec amendment) ──────────────────────────┐ +Task 1 (loomweave oracle rows) → Task 2 (re-vendor + cfg conformance) + → Task 3 (producer surface: leaf kinds + impl entity + containment + per-kind twins) + → Task 4 (SP2 whole-tree: crate roots + analyzer wiring + un-xfail) + → Task 5 (edges: imports/implements) + → Task 6 (identity freeze: tests/golden/identity/rust/) + → Task 7 (graduation: provisional plumbing removal + docs) +Task 1 → Task 8 (drift alarm) +Task 9 (reserved-colon + const-spacing decision letter) — independent +Task 10 (hard-gates sweep + merge to rc5 + filigree closes) — last +``` + +--- + +### Task 0: Spec amendment — fold Phase 1b into SP2 + +**Files:** +- Modify: `docs/superpowers/specs/2026-06-08-wardline-rust-frontend-design.md` (§6.3, §6.4, §11) + +- [ ] **Step 0.1:** Amend §11's SP2 bullet to record the user-approved fold: SP2 now also comprises the Phase-1b producer surface (six leaf kinds, the `impl` entity + method re-parenting, the `imports`/`implements` anchored edges, `ontology_version 0.4.0`, `plugin_id rust`), citing `docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md`. Amend §6.3/§6.4 where they say "Wardline never emits struct/module rows" — after this sprint Wardline emits the full ten-kind surface and the conformance comparison graduates from the subset-consumer rule to the full-set rule (§7 rule 1 of the changeset). Note the oracle ground-truth corrections: `feat/rust-plugin-spec` is merged into loomweave `rc4`; the corpus gains `generic_self_nested_param`; leaf-kind/stacked-cfg/cfg-escape rows are added upstream by this sprint (Task 1). Add a §13 changelog entry dated 2026-06-10. +- [ ] **Step 0.2:** `.venv/bin/mkdocs build --strict` passes. Commit: `docs(spec): fold Phase 1b producer surface into SP2 (user-approved) + oracle ground-truth corrections`. + +--- + +### Task 1: Loomweave-side oracle rows (upstream, `/home/john/loomweave`, branch rc4) + +Pin four already-emitted-but-un-oracled behaviors with corpus rows, verified by Loomweave's own byte-for-byte gate. **Hand-author the `source`; the extractor dictates `expected`** — pre-derive expected by READING extract.rs/qualname.rs (not guessing), run the gate, and on mismatch adopt the extractor's actual output. Never weaken their test. + +**Files:** +- Modify: `/home/john/loomweave/fixtures/qualnames_rust.json` (append 4 entity cases + fix the stale `_dialect_summary.free_items` header text "enum/trait/const/static/type_alias/macro are Phase 1b, not yet emitted" — it already contradicts the live extractor and would contradict the new rows in the same file) +- Read first: `/home/john/loomweave/crates/loomweave-plugin-rust/tests/qualname_conformance.rs` (case schema + how the gate enumerates JSON cases), `src/extract.rs` (esp. :326-358 twin_counts, :457 trait-body comment, :476-492 Item::Trait arm, :830-843 cfg_predicates), `src/qualname.rs` (:299-336 cfg_discriminant/normalise_pred/escape_reserved) + +- [ ] **Step 1.1:** Append four cases to `entities` (all `"reproducibility": "slice-1"`, crate `demo`, `rel_path` `src/m.rs`, `module_path` `demo.m`): + +1. **`leaf_item_kinds`** — the five missing free-item kinds (NO `macro_rules!` — already pinned by `macro_invocation_generates_no_entity`; NO trait-body method — extract.rs:457 never walks trait bodies): +```rust +pub enum Color { Red } +pub trait Greet {} +pub type Alias = u8; +pub const LIMIT: u32 = 10; +pub static NAME: &str = "x"; +``` +expected (module row first, then source order): `demo.m` (module), `demo.m.Color` (enum), `demo.m.Greet` (trait), `demo.m.Alias` (type_alias), `demo.m.LIMIT` (const), `demo.m.NAME` (static). + +2. **`stacked_cfg_twin`** — source: +```rust +struct Foo; +#[cfg(feature = "a")] +#[cfg(unix)] +impl Foo { pub fn go(&self) {} } +#[cfg(feature = "b")] +#[cfg(unix)] +impl Foo { pub fn go(&self) {} } +``` +expected: per `cfg_discriminant` (each pred normalised, set sorted, joined `&`): module + struct rows, then `demo.m.Foo.impl#<>@cfg(feature="a"&unix)` (impl) + `….go` (function), `demo.m.Foo.impl#<>@cfg(feature="b"&unix)` (impl) + `….go` (function). **Adopt the extractor's exact bytes.** + +3. **`cfg_escape_reserved_char`** — source: +```rust +#[cfg(feature = "a:b")] +pub fn f() {} +#[cfg(feature = "c")] +pub fn f() {} +``` +expected: module row + `demo.m.f@cfg(feature="a%3Ab")` and `demo.m.f@cfg(feature="c")` (function rows; verify exact quoting/escape bytes from `normalise_pred`). + +4. **`leaf_kind_cfg_twin`** — pins the per-kind twin counter on a newly-oracled leaf kind (it becomes load-bearing under Wardline's full-set comparison): +```rust +#[cfg(unix)] +pub const LIMIT: u32 = 1; +#[cfg(windows)] +pub const LIMIT: u32 = 2; +``` +expected: module row + `demo.m.LIMIT@cfg(unix)` and `demo.m.LIMIT@cfg(windows)` (const rows). + +- [ ] **Step 1.2:** Run the gates: `cd /home/john/loomweave && cargo test -p loomweave-plugin-rust --test qualname_conformance`, then the full plugin suite `cargo test -p loomweave-plugin-rust`, then **the actual CI gate** `cargo nextest run --workspace --all-features --no-tests=pass` (fall back to `cargo test --workspace --all-features` if nextest is absent). On mismatch, correct `expected` to the extractor's emission. All green before committing. +- [ ] **Step 1.3:** Commit upstream (ONLY the fixture; never the dirty `.agents/...` file): `git add fixtures/qualnames_rust.json && git commit -m "test(plugin-rust): pin leaf kinds, stacked-cfg fold, cfg reserved-char escape, leaf-kind cfg twin with corpus rows (wardline second-producer conformance)"`. Record the new commit hash and `git rev-parse HEAD:fixtures/qualnames_rust.json` blob hash for Tasks 2/8. + +--- + +### Task 2: Re-vendor + Wardline cfg conformance (stacked-cfg fold, reserved-char escape, nested-param row) + +Claims `wardline-4fdad782a7` and the escape half of `wardline-e8f7c0508f` (`filigree start-work --actor rust-gold-sprint --advance`). + +**Files:** +- Modify: `tests/conformance/qualnames_rust.json` (verbatim copy of upstream blob) +- Modify: `tests/conformance/test_loomweave_rust_qualname_parity.py` (provenance header; `_KNOWN_KINDS` → the ten-kind set `{module, struct, function, enum, trait, type_alias, const, static, macro, impl}`) +- Modify: `src/wardline/rust/qualname.py` (`_escape_reserved`; `cfg_discriminant`; **`cfg_predicate_of` gains a raw mode** — see the layering note) +- Modify: `src/wardline/rust/index.py` (`_walk_scope` cfg accumulation: last-wins scalar → collect-all RAW list) +- Test: `tests/unit/rust/test_qualname.py`, `tests/unit/rust/test_index.py` + +**Layering (locked — mirrors the oracle, prevents the double-escape trap):** loomweave collects predicates RAW (extract.rs:830-843) and normalizes exactly once inside `cfg_discriminant` (qualname.rs:299-302). Wardline's `cfg_predicate_of` currently RETURNS `normalize_cfg_predicate(...)` (qualname.py:99-114) and index.py stores that. Restructure: `cfg_predicate_of` (or a new `raw_cfg_predicate_of`) returns the RAW predicate text; `pending_cfgs: list[str]` accumulates raw strings; `cfg_discriminant(predicates)` = `normalize_cfg_predicate(p)` for each (which now includes `_escape_reserved`), `sorted()`, `"&".join(...)`, wrapped `@cfg(...)`. Normalization+escape happens EXACTLY once. `_escape_reserved` runs after whitespace/paren strip, BEFORE the any()/all() split (qualname.rs:319-336 order). + +- [ ] **Step 2.1:** Copy the upstream fixture verbatim: `cp /home/john/loomweave/fixtures/qualnames_rust.json tests/conformance/qualnames_rust.json`. Update the provenance header (source commit + blob hash from Task 1.3). Extend `_KNOWN_KINDS` to the ten-kind set. Run `.venv/bin/pytest tests/conformance/test_loomweave_rust_qualname_parity.py -q` — expect FAILURES on `stacked_cfg_twin` + `cfg_escape_reserved_char` (today: collide on `@cfg(unix)` / no escape); `generic_self_nested_param` must pass immediately (nested-literal rendering already implemented); `leaf_item_kinds`/`leaf_kind_cfg_twin` rows are invisible to the still-function-only comparison (graduates in Task 3). +- [ ] **Step 2.2:** Failing unit tests first (TDD), expected bytes from Task 1's locked gate output: + +```python +# tests/unit/rust/test_qualname.py +def test_normalize_cfg_predicate_escapes_reserved_chars() -> None: + # % before : (order matters — injective, mirrors loomweave escape_reserved) + assert normalize_cfg_predicate('feature = "a:b"') == 'feature="a%3Ab"' + assert normalize_cfg_predicate('feature = "a%3Ab"') == 'feature="a%253Ab"' + +def test_escape_happens_before_any_all_split() -> None: + # escape applies to the whole stripped pred BEFORE arg sorting (oracle order) + assert normalize_cfg_predicate('any(feature = "a:b", unix)') == 'any(feature="a%3Ab",unix)' + +def test_cfg_discriminant_folds_all_predicates_sorted() -> None: + assert cfg_discriminant(['unix', 'feature = "a"']) == '@cfg(feature="a"&unix)' + assert cfg_discriminant(['feature = "a"', 'unix']) == '@cfg(feature="a"&unix)' # order-independent + +def test_cfg_discriminant_normalizes_exactly_once() -> None: + # raw input with a reserved char escapes ONCE (no double-escape through the pipeline) + assert cfg_discriminant(['feature = "a:b"']) == '@cfg(feature="a%3Ab")' +``` + +```python +# tests/unit/rust/test_index.py +def test_stacked_cfg_twins_get_distinct_folded_suffixes() -> None: + src = ( + 'struct Foo;\n' + '#[cfg(feature = "a")]\n#[cfg(unix)]\nimpl Foo { pub fn go(&self) {} }\n' + '#[cfg(feature = "b")]\n#[cfg(unix)]\nimpl Foo { pub fn go(&self) {} }\n' + ) + names = {e.qualname for e in discover_rust_entities(src, module="demo.m")} + assert 'demo.m.Foo.impl#<>@cfg(feature="a"&unix).go' in names + assert 'demo.m.Foo.impl#<>@cfg(feature="b"&unix).go' in names + +def test_single_stacked_cfg_impl_without_twin_gets_no_suffix() -> None: + # dialect: @cfg is a COLLISION discriminator — a lone stacked-cfg impl stays bare + src = '#[cfg(feature = "a")]\n#[cfg(unix)]\nstruct Foo;\nimpl Foo { pub fn go(&self) {} }\n' + names = {e.qualname for e in discover_rust_entities(src, module="demo.m")} + assert 'demo.m.Foo.impl#<>.go' in names +``` +Run; verify both new behaviors fail for the right reason. + +- [ ] **Step 2.3:** Implement per the locked layering. In index.py the per-item state becomes `pending_cfgs: list[str]` (reset on each non-attribute item; appended per `#[cfg]` attribute); the collision guard is the truthy-list check (`if pending_cfgs:` — NOT `len > 1`); suffix = `cfg_discriminant(pending_cfgs)` applied on collision exactly as today. Run the unit tests, then conformance — `stacked_cfg_twin` + `cfg_escape_reserved_char` rows green. +- [ ] **Step 2.4:** Full quick gate: `.venv/bin/pytest tests/unit/rust tests/conformance -q` green; `.venv/bin/ruff check . && .venv/bin/mypy src` clean. Commit: `fix(rust): fold ALL stacked #[cfg] predicates + mirror cfg reserved-char escape (oracle rows landed upstream) — closes wardline-4fdad782a7`. Filigree: close `wardline-4fdad782a7`; comment on `wardline-e8f7c0508f` (escape half done with oracle row; const-spacing → Task 9 letter). + +--- + +### Task 3: Phase 1b producer surface — leaf kinds, `impl` entity, containment, per-kind twins, full-set conformance + +**Files:** +- Modify: `src/wardline/rust/index.py` (emit all ten kinds; parent links; impl entity rows; **generalized per-kind twin counter**) +- Modify: `src/wardline/rust/qualname.py` (constants `RUST_PLUGIN_ID = "rust"`, `RUST_ONTOLOGY_VERSION = "0.4.0"`, and `entity_id(kind, qualname)` — qualname.py is the dialect home, NOT `__init__.py`) +- Modify: `src/wardline/rust/analyzer.py` (**add the callable filter — it does not exist today**) +- Modify: `tests/conformance/test_loomweave_rust_qualname_parity.py` (graduate subset-consumer → full-set comparison) +- Test: `tests/unit/rust/test_index.py`, `tests/unit/rust/test_qualname.py` + +**Design (locked):** +- `RustEntity.kind` extends over the full id-kind set (`module|struct|function|enum|trait|type_alias|const|static|macro|impl`; Wardline's semantic `method` kept, mapped to id-kind `function` at emission/comparison) + new field `parent: str | None` (qualname of the containing module or impl entity). All entities carry `node_id`/`location` (needed for edges/federation). +- `entity_id(kind, qualname)` returns `f"rust:{kind}:{qualname}"`, maps `method`→`function` ITSELF, and raises on kinds outside the ten-kind set (mirrors loomweave's build_entity_id validation posture). +- **Emit ordering (matches the corpus — verified against `free_fns_and_struct`, `nested_inline_mod`, `same_type_name_distinct_module_scopes`):** the FILE-SCOPE module entity is emitted FIRST (before any items); inline `mod` entities are emitted AT their source position (before recursing into them); the merged `impl` entity is emitted once at the first contributing block in source order; everything else at its source position. +- **Twin counter generalized per-(kind, name)** over all nine named item kinds (mirror extract.rs:326-358) — `fn S` and `struct S` never interfere; the `@cfg` suffix applies per-kind on collision (struct_cfg_twin + new leaf_kind_cfg_twin rows are the trip-wires). +- **The taint path gets an explicit guard:** analyzer.py:138-153 currently iterates ALL entities calling `taint_for(entity.node)` and `child_by_field_name("body")` unconditionally — add `if entity.kind not in ("function", "method"): continue` at the loop top, and count `functions_total` (the coverage metric) over callables only. + +- [ ] **Step 3.1:** Failing unit tests: + - leaf kinds emission (enum/trait/type_alias/const/static with qualname `.`, parent = module; `macro_rules! name` → `macro` entity; bare invocation → nothing — keep the existing guard test), + - impl entity rows (post-merge, `…Foo.impl#<>` / `…Foo.impl[Display]` incl. `@cfg` forms), + - containment (method.parent == impl entity qualname; impl.parent == module; free items' parent == module), + - `test_merged_impl_emitted_at_first_block_in_source_order` — two same-key inherent blocks: the ONE impl entity appears before both blocks' methods and its `location.line_start` is the FIRST block's line, + - `test_file_module_entity_emitted_first_and_inline_mods_at_source_position` — pin the ordering rules above, + - `test_per_kind_twin_counting` — `fn S` + `struct S` with cfg on one: no cross-kind suffix interference, + - `test_non_callable_entities_do_not_enter_taint_analysis` — a file with struct/enum/const/trait + one tainted `Command::new` function: `RustAnalyzer.analyze` returns exactly the function's findings; no crash, no extra findings, + - `test_emission_is_deterministic` — `discover_rust_entities` twice on the same source → byte-identical ordered lists, + - `test_entity_id_maps_method_and_validates_kind` — `entity_id("method", q) == f"rust:function:{q}"`; unknown kind raises. +- [ ] **Step 3.2:** Implement in `index.py` (lift the existing impl-key/merge-triple machinery into emitted `impl` entities; emit the file-scope module first; generalize the twin counter), `qualname.py` (constants + `entity_id`), `analyzer.py` (callable guard + callable-scoped coverage counter). Unit tests green. +- [ ] **Step 3.3:** Graduate the conformance comparison: rewrite `test_entity_qualnames` (and its helper `_expected_function_qualnames` → `_expected_all_pairs(case)`) to compare Wardline's FULL emission as an **ordered list of `(qualname, kind)`** (kind-mapped `method`→`function`) against the corpus `expected` list exactly — this matches loomweave's own ordered gate; the subset-consumer rule in `_consumer_comparison` forbids list-equality only for function-only consumers, which Wardline no longer is. The Step-3.1 ordering tests de-risk this; if a case still fails ONLY on order (qualname sets equal), STOP and check the corpus row's order against `discover_rust_entities` — fix the emitter, never the comparison. Run conformance: ALL rows green including `leaf_item_kinds` + `leaf_kind_cfg_twin`. +- [ ] **Step 3.4:** Full suite + lints (`.venv/bin/pytest -q`, ruff, mypy) — eyeball the conformance output for source-order alignment, not just pass/fail. Commit: `feat(rust): full ADR-049 producer surface — six leaf kinds, impl entity, module→impl→method containment, per-kind cfg twins, full-set conformance`. + +--- + +### Task 4: SP2 whole-tree — crate roots from Cargo.toml, cross-file routes, un-xfail + +**Files:** +- Create: `src/wardline/rust/crate_roots.py` +- Modify: `src/wardline/rust/analyzer.py` (`_module_for` → crate-root-aware) +- Modify: `tests/conformance/test_loomweave_rust_qualname_parity.py` (remove BOTH sp2 auto-xfail branches, :144-145 and :154-155) +- Test: `tests/unit/rust/test_crate_roots.py` (new) + +**Design (locked, panel-corrected — mirror `/home/john/loomweave/crates/loomweave-plugin-rust/src/crate_roots.rs` exactly; read it first):** +- **Manifest read:** parse `Cargo.toml` with stdlib `tomllib` (the oracle does a real `toml::Value` parse — "read as text" in ADR-049 means "not cargo-metadata", NOT a hand-rolled scan). Take `package.name` only if it parses AND is a string (`name.workspace = true` is a table → falls through to fallback). Unparseable TOML → fallback path. +- **Registration rule (two branches):** a dir is a crate root iff (a) its Cargo.toml yields a string `[package].name` → that name `-`→`_` normalised; ELSE (b) `src/lib.rs` or `src/main.rs` exists → directory-name normalised. A virtual workspace root (no package.name, no src/lib|main.rs) registers NOTHING — member crates own their files outright. +- **Walk:** skip symlinked directories (crate_roots.rs:83-94; use `os.scandir` + `entry.is_symlink()`, never a follow-links walk). File→crate by longest path-prefix match. +- **Scan coverage is NOT narrowed (the panel's must-fix):** loomweave's `emittable_scope` (scope.rs:21-32) EXCLUDES tests/, benches/, examples/, build.rs, shadowed src/main.rs, and no-crate-root files — that is its *federation entity surface*, not a scan filter. Wardline keeps scanning ALL discovered `.rs` files. Routing: files under a crate root's `src/` get the oracle route (`rust_module_route(crate, src_root=/src, file)`); all other files (tests/, build.rs, no-Cargo trees — including today's entire preview population) get a documented wardline-local fallback route (current behavior: crate = owning crate name if any else directory name, mechanical path route) with a module docstring stating those qualnames carry no cross-tool conformance claim (loomweave emits nothing there, so no collision is possible). `#[path]` stays un-honoured (shared known gap — `path_attr_known_gap` pins the mechanical form). + +- [ ] **Step 4.1:** Failing tests (`tmp_path` fixtures): + - single crate: `Cargo.toml` `name = "my-app"` → crate `my_app`; `src/lib.rs` → `my_app`; `src/a/b.rs` → `my_app.a.b`; `src/a/mod.rs` → `my_app.a`, + - virtual workspace root (`[workspace]`-only Cargo.toml, no src/) with two members → ONLY the members register; `members' src files route to their own crates`, + - NESTED crates: `outer/Cargo.toml` (`outer`) + `outer/inner/Cargo.toml` (`inner`) → `outer/src/lib.rs` → `outer`, `outer/inner/src/main.rs` → `inner` (longest-prefix), + - `name.workspace = true` manifest WITH `src/lib.rs` → dir-name fallback; package-less manifest WITHOUT src/lib|main.rs → not a root, + - symlinked external crate dir under the scan root → NOT registered (mirror loomweave's `does_not_register_crate_roots_reached_through_symlinked_dirs`), + - **coverage preservation:** `build.rs`, `tests/integration.rs`, and a bare no-Cargo `.rs` tree still produce RS-WL findings via the fallback route (pin that scan population is unchanged). +- [ ] **Step 4.2:** Implement `crate_roots.py`; wire `analyzer.py::_module_for` through it (keeping per-file isolation/error posture). Existing analyzer tests that assumed `crate=root.name` update only where a fixture grows a `Cargo.toml`. +- [ ] **Step 4.3:** Remove BOTH `pytest.xfail("sp2 …")` branches; sp2 rows assert for real (`path_attr_known_gap` passes mechanically). Run conformance: **0 xfail**. Full suite: xfail count 1→0. Defensive check: `grep -rn "RS-WL" .weft/ 2>/dev/null || echo clean` → clean (no stored fingerprints to orphan). +- [ ] **Step 4.4:** Commit: `feat(rust): SP2 whole-tree — Cargo.toml crate roots (tomllib, two-branch registration, symlink-safe), real crate-prefixed routes, sp2 conformance rows un-xfailed`. + +--- + +### Task 5: Anchored edges — `imports` + `implements` + +**Files:** +- Create: `src/wardline/rust/edges.py` +- Test: `tests/unit/rust/test_edges.py` (new) + +**Design (locked, per changeset §6 + the oracle source for what §6 leaves open — the corpus is entity-only; pin these citations in test docstrings):** `RustEdge` frozen dataclass `{kind: Literal["imports","implements"], from_id: str, to_id: str, source_byte_start: int, source_byte_end: int, confidence: Literal["resolved","ambiguous"]}` (never `inferred`). `discover_rust_edges(...)` resolves against the whole-tree entity index (Tasks 3+4). Semantics: +- `imports`: one per file-scope `use` leaf; `from_id` = enclosing module entity; use-tree GROUPS fan out (`use a::{B, C}` → two edges); `use a::B as C` resolves the REAL path `a::B` (alias dropped); a `self` group leaf (`use a::{self, B}`) → the prefix module `a` (extract.rs:634-665). Glob `use a::*`: in-project module → `ambiguous` edge to that module entity; else dropped (resolve.rs:40-49). Unique in-project target → `resolved`; multi-kind candidate set → `ambiguous` with `to_id` = FIRST id by sorted order — deterministic, never null (resolve.rs:13-17); external/unresolvable → DROPPED. Span = the `use` statement. +- `implements`: one per trait impl whose trait resolves in-project; `from_id` = the trait-impl entity id; trait lookup STRIPS generic args (`impl MyTrait for Foo` resolves `MyTrait` — extract.rs:788-794); negative impls (`impl !Send for X`) emit NO edge; merged twin blocks emit exactly ONE edge per impl entity. Span = the implemented-trait path node only. +- Path resolution: `crate::`/`self::`/`super::` prefixes + plain relative paths against the module routes; when in doubt, drop (D1). ids via `entity_id()`. + +- [ ] **Step 5.1:** Failing tests (multi-file `tmp_path` fixtures): the base two-file resolved case (`use crate::Greet` + `impl Greet for Foo` → one `imports` + one `implements`, both `resolved`, spans pinned); `super::` and nested `super::super::` resolution; use-tree group fan-out + `as`-alias real-path + `self` group leaf; glob in-project → `ambiguous(module)`, glob external → dropped; multi-kind ambiguity → `ambiguous` + first-by-sorted-order to_id; external `use std::fmt` → no edge; generic in-project trait `impl MyTrait for Foo` → resolved to `MyTrait`; negative impl → no edge; two merged same-key trait-impl blocks → exactly one `implements` edge; `confidence` never `inferred`. +- [ ] **Step 5.2:** Implement `edges.py`. Resolved-or-dropped throughout. +- [ ] **Step 5.3:** Full suite + lints. Commit: `feat(rust): anchored imports/implements edges (resolved-or-dropped, §6 contract + oracle-cited semantics)`. + +--- + +### Task 6: Freeze `tests/golden/identity/rust/` (the SP2 completion gate) + +**Files:** +- Create: `tests/golden/identity/rust/{__init__.py,README.md,_capture.py,regen.py,test_rust_identity_parity.py,fixtures/rustapp/...,corpus/...}` — follow the Python oracle's conventions (read `tests/golden/identity/{README.md,_capture.py,regen.py}` first: LF-pinned fixtures via `.gitattributes`, no `.weft/`/`weft.toml` in fixtures, META.json with corpus_version/scheme/reason) + +**Design (locked):** the Rust capture is a PARTIAL mirror by necessity: `RustAnalysisContext` is not the Python `AnalysisContext` (analyzer.py last_context → None), so SARIF code-flows / taint facts / explain are NOT capturable — the Rust identity surface is **findings** (`Finding.to_jsonl()` for `RS-WL-* ∧ Kind.DEFECT` — the `is_identity_bearing` predicate filters `RS-WL-*`, not `PY-WL-*`) + **entity rows** (qualname, id-kind, parent, span for EVERY emitted entity) + **edges**. State this in the rust README. Fixture = vendored crate `fixtures/rustapp/` (`Cargo.toml` `name = "rust-app"` → crate `rust_app`; `src/main.rs` + `src/cmd/runner.rs`) exercising: RS-WL-108 (tainted program), RS-WL-112 (tainted sh -c arg), a `/// @trusted(level=ASSURED)` marker, an impl method, a cfg twin, and at least one leaf kind. **Constraint: NO path-typed generic args anywhere in the fixture** (`impl From` is the un-decided reserved-colon case — freezing it would pre-empt Task 9's cross-tool decision; record the constraint in the rust README). + +- [ ] **Step 6.1:** Write `_capture.py` + `regen.py` + the fixture crate + the parity test; run `regen.py` ONCE to seed `corpus/`. +- [ ] **Step 6.1b (non-vacuity — must pass before freezing):** assert over the seeded JSON: (a) ≥1 finding row per rule (`RS-WL-108` AND `RS-WL-112`), each `fingerprint` non-empty; (b) ≥1 entity row with `kind == "impl"`; (c) ≥1 qualname containing `@cfg(`; (d) ≥1 qualname prefixed `rust_app.cmd.runner` (real crate prefix + cross-file route — NOT a directory name); (e) ≥1 edge. Encode these as permanent structural tests in `test_rust_identity_parity.py` (the analogue of the Python oracle's non-vacuity tests), not a one-off eyeball. +- [ ] **Step 6.2:** Parity test green on a fresh run. Trip-wire proof: mutate a **fingerprint hex char** in the seeded corpus findings (NOT a message string), run → test FAILS; revert; mutate one **qualname** in the entity rows, run → FAILS; revert. Commit: `test(identity): freeze the Rust finding-identity corpus (SP2 completion gate) — crate-prefixed RS-WL-* identity`. + +--- + +### Task 7: Identity graduation — remove provisional plumbing, retire the banner claim, docs + +**Files:** +- Modify: `src/wardline/rust/rules.py:141` (remove the `"provisional_identity": True` entry from the `properties` dict in `_finding()`) +- Modify: `src/wardline/core/suppression.py:68-77` (drop the provisional short-circuit — the whole if-block incl. the `continue`; the SEPARATE `Maturity.PREVIEW` guards at :104/:127 are a different mechanism used by Python preview rules — LEAVE THEM) +- Modify: `src/wardline/core/baseline.py:55-62` (drop the provisional exclusion) +- Modify: `src/wardline/cli/scan.py:157-168` (banner: drop ONLY the "provisional identity (baseline-ineligible)" claim. The severity-override claim is STILL TRUE — analyzer.py:84-85: weft.toml severity overrides do not apply to Rust — so KEEP it. New banner: `note: --lang rust covers the command-injection slice (RS-WL-108/112); config severity overrides do not yet apply to Rust findings.`) +- Modify: `tests/unit/rust/test_provisional_identity.py` → rename `test_rust_identity_graduated.py`, INVERT (a matching baseline entry now suppresses; `build_baseline_document` now includes RS findings) +- Modify: `docs/guides/rust-preview.md` (graduate: provisional/baseline-ineligible warning → baseline-eligible statement; KEEP coverage-scope + severity-override warnings), `docs/guides/agents.md:294` (same stale sentence — panel-found), `docs/reference/cli.md`, `CHANGELOG.md` [Unreleased] +- Modify: `src/wardline/rust/qualname.py:20` + `analyzer.py:176` docstrings (stale provisional references) + +- [ ] **Step 7.1 (TDD):** Invert the two provisional tests first; watch them fail against current code. +- [ ] **Step 7.2:** Remove the plumbing (4 code sites); tests pass. Scoped grep: `grep -rn "provisional" src/ tests/ docs/guides/ docs/reference/ CHANGELOG.md` — zero hits outside the CHANGELOG's historical entries (archived plans/specs under docs/superpowers/ are exempt history). Verify no other consumer of `provisional_identity` exists anywhere in `src/`. +- [ ] **Step 7.3:** Docs + CHANGELOG (one [Unreleased] entry: full producer surface, SP2 whole-tree identity, baseline eligibility, BREAKING note that RS-WL fingerprints change once — they were never baseline-eligible, no migration needed; severity-override gap persists and is tracked). `mkdocs build --strict` green. Commit: `feat(rust)!: graduate RS-WL-* identity — baseline-eligible, provisional plumbing removed`. + +--- + +### Task 8: Corpus drift alarm (closes `wardline-868908944b`) + +**Files:** +- Modify: `tests/conformance/test_loomweave_rust_qualname_parity.py` +- Modify: `pyproject.toml` (register `loomweave_drift` in `[tool.pytest.ini_options].markers` AND append `and not loomweave_drift` to the `addopts` `-m` exclusion — BOTH are required; markers-only would run it in the default suite and fail in CI where the sibling checkout is absent) + +**Design (locked, deliberately light):** +1. **Byte-pin:** constant `UPSTREAM_BLOB_SHA = ""` + a test computing the git blob hash of the vendored file — read in BINARY mode, `hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest()` — asserting equality, plus a sanity assert (40 lowercase hex chars). Cross-check the constant once via `git hash-object tests/conformance/qualnames_rust.json` (the reference oracle for the formula) before committing. +2. **Live recheck (opt-in):** `@pytest.mark.loomweave_drift` test: locate the sibling checkout (`WARDLINE_LOOMWEAVE_REPO` env override, default `/home/john/loomweave`); absent → `pytest.skip`; present → byte-compare `fixtures/qualnames_rust.json` to the vendored copy, FAIL on drift with a "re-vendor + conform (see test header)" message. Document the re-vendor as a release-gate item in the test header. + +- [ ] **Step 8.1:** Write both tests; run the blob-pin in the default suite and `-m loomweave_drift -v` live — green against the Task-1 upstream commit. Verify the default suite still deselects it (`pytest -q` collects no loomweave_drift test). Commit: `test(conformance): corpus drift alarm — upstream blob byte-pin + opt-in live recheck (closes wardline-868908944b)`. Filigree: close `wardline-868908944b`. + +--- + +### Task 9: Reserved-colon + const-arg-spacing ADR-049 decision letter (no implementation) + +Claims `wardline-be5ee9cc34` (`--advance` walks triage→confirmed; do NOT move to fixing — it stays blocked on the cross-tool decision; release the claim after the letter lands and record the dependency instead). + +**Files:** +- Create: `docs/integration/2026-06-10-wardline-loomweave-rust-qualname-amendment-requests.md` + +- [ ] **Step 9.1:** Draft the letter (Wardline → Loomweave), three sections: +1. **Reserved-colon path-typed generic args** (the decision request): today `impl From for Foo` renders a `:`-bearing locator that Wardline emits un-gated and Loomweave rejects-and-degrades whole-file (`validate_no_colon`, entity_id.rs:140) — both bad endpoints, ubiquitous in real Rust. **Propose:** extend the existing injective `escape_reserved` (`%`→`%25`, `:`→`%3A`) — already the dialect's cfg-predicate precedent — to `type_textual` rendering inside `impl[...]`/`<...>` fragments, so `From` → `From`: collision-free (unlike last-segment: `io::Error` vs `fmt::Error`), reversible, one normalizer for both producers. Name the rejected alternatives (last-segment collision; degrade-whole-file loses every entity in the file for one impl). Request corpus rows (`path_typed_generic_arg_inherent`, `path_typed_generic_arg_trait`) and an ADR-049 §2 amendment. +2. **Const-generic-arg spacing**: propose the oracle strip_ws const args (`Foo<{N+1}>` not `Foo<{ N + 1 }>`), converging on the whitespace-free form Wardline already renders; request a corpus row. +3. Record that leaf-kind, stacked-cfg, cfg-escape, and leaf-kind-cfg-twin rows were vendored upstream by this sprint (Task 1 commit hash) — closing those from the amend3 tail. +- [ ] **Step 9.2:** Filigree: comment on `wardline-be5ee9cc34` + `wardline-e8f7c0508f` with the letter path; create a blocker issue "Loomweave ADR-049 reserved-colon decision (letter sent, awaiting amendment + corpus rows)" and `dependency_add` from be5ee9cc34 to it. Commit: `docs(integration): ADR-049 amendment requests — reserved-colon escape proposal + const-arg spacing`. +- [ ] **Step 9.3:** Surface in the final report: the letter needs the user's sign-off before sending/implementing. + +--- + +### Task 10: Hard-gates sweep, merge, filigree closes + +- [ ] **Step 10.1:** The full gate matrix, all green, output captured: + - `.venv/bin/pytest -q` (full suite) — then machine-check the xfail gate: `.venv/bin/pytest -q 2>&1 | tail -2` must show **0 xfailed**, and `grep -c "pytest.xfail" tests/conformance/test_loomweave_rust_qualname_parity.py` must be 0 + - `.venv/bin/pytest -m rust_e2e -v` + - `.venv/bin/pytest -m loomweave_drift -v` (live oracle check) + - Python identity oracle: `.venv/bin/pytest tests/golden/identity -q` — all green incl. the 3 byte-identity corpus checks (any byte diff is a sprint-stopping regression) + - Rust identity oracle: `.venv/bin/pytest tests/golden/identity/rust -q` + - `.venv/bin/ruff check . && .venv/bin/ruff format --check .` + - `.venv/bin/mypy src` + - `.venv/bin/mkdocs build --strict` + - Self-scan: `.venv/bin/wardline scan . --fail-on ERROR` (exit 0) +- [ ] **Step 10.2:** Merge `feat/rust-gold` → `rc5`: from the MAIN checkout (`/home/john/wardline`), verify `git status` (the dirty files are ci.yml/.gitignore/docs/index.md/mkdocs.yml/docs/arch-analysis — all disjoint from sprint paths), `git merge --no-ff feat/rust-gold`, verify the dirty files are untouched after. **Re-run the gate on the post-merge rc5 tree** (`pytest -q tests/unit/rust tests/conformance tests/golden` + `mkdocs build --strict` — the rc5 checkout's mkdocs.yml differs from the worktree's). +- [ ] **Step 10.3:** Filigree: verify closes (4fdad782a7, 868908944b), comments + dependency on be5ee9cc34/e8f7c0508f, close the umbrella `wardline-9f00d5b44b` only if every gate held; `observation_list` skim per CLAUDE.md. + +--- + +## Self-review notes (spec-coverage check) + +- Prompt scope 1 (SP2 whole-tree) → Task 4. Scope 2 (Phase 1b surface) → Tasks 1–3, 5. Scope 3 (identity graduation) → Tasks 4 (rekey), 6, 7. Scope 4 bugs → Tasks 1–2 (4fdad782a7), 2+9 (e8f7c0508f), 9 (be5ee9cc34), 8 (868908944b). +- Known deviations from the sprint prompt, justified by verified oracle ground truth: (a) the `_KNOWN_KINDS` guard does NOT trip on today's upstream corpus (no leaf-kind rows exist upstream) — Task 1 creates the rows the prompt assumed, then Tasks 2–3 do the guard/producer work; (b) "worktree + work directly on rc5" contradiction resolved as worktree branch `feat/rust-gold` merged to rc5 at the end (Task 10). +- Edges have no corpus rows (entity-only corpus) — changeset §6 + cited oracle source (resolve.rs/extract.rs) are the contract; tests pin both, citations in docstrings. diff --git a/docs/superpowers/specs/2026-06-07-wardline-doctor-filigree-token-check-design.md b/docs/superpowers/specs/2026-06-07-wardline-doctor-filigree-token-check-design.md new file mode 100644 index 00000000..b682e1a5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-wardline-doctor-filigree-token-check-design.md @@ -0,0 +1,192 @@ +# `wardline doctor` — filigree federation-token check + repair + +**Date:** 2026-06-07 +**Status:** Approved (design) +**Branch:** `rc4` + +## Problem + +When wardline emits scan findings to a Filigree daemon over the Weft federation +surface (`POST /api/weft/scan-results`), the request carries +`Authorization: Bearer ` where the token comes from +`load_filigree_token(root)` (env → `root/.env`, federation name then legacy +fallback). If that token is not the value the **running daemon** accepts, every +emit returns `401` and fails *soft and silent*: the scan still succeeds, findings +are written locally, and the only signal is a `filigree_emit` block the operator +must read. The Filigree tracker looks empty even though wardline holds active +defects. + +### Root cause (observed in lacuna, 2026-06-07) + +The failure is **not** a missing token or an unauthenticated POST. wardline reads +`root/.env` itself and *does* send a bearer token — just the wrong value. The +real cause is **two independently-minted federation-token stores that disagree**, +with no tier-1 env override to unify them: + +- Filigree's federation token is auto-minted **per store-dir** + (`filigree/federation_token.py`). A daemon launched with `--server-mode` + resolves its token from `~/.config/filigree/federation_token`; a single-project + resolution uses `/.weft/filigree/federation_token`. These are minted + independently and hold **different** secrets. +- In lacuna, the live daemon on `:8749` runs `--server-mode` with no + `WEFT_FEDERATION_TOKEN` in its environment, so it accepts the + `~/.config/filigree/` value (call it `W`). lacuna's `.env` carries + `WARDLINE_FILIGREE_TOKEN` set to the *project-store* value (call it `D`). +- wardline emits `Bearer D`; the daemon only knows `W` → `401`. The Filigree + MCP client's `.mcp.json` bearer happens to be `W`, so MCP reads work — masking + the rotation. Empirically: probing the live daemon, `W → HTTP 400` (auth + passed, sentinel body rejected), `D → HTTP 401` (auth rejected). + +This is **not** a stale in-memory daemon and a restart does **not** fix it: the +daemon's token file is unchanged since boot; a restart re-reads `W` and still +rejects `D`. Git history shows recurring `fix(filigree): update authorization +token` commits — the churn of hand-pasting a rotated value with no single source +of truth. + +`wardline doctor` should detect this mismatch and repair it. + +## Goals + +- Detect, from `wardline doctor`, that the token wardline **will emit** is not the + token the configured Filigree daemon **accepts**. +- Under `--repair`, recover the correct token from local mints and pin it as + `WEFT_FEDERATION_TOKEN` in `/.env`, removing the stale legacy line. +- Emit a message that distinguishes *token absent* from *token present but + rejected* — the existing `filigree_emit.py` 401 string ("set + WEFT_FEDERATION_TOKEN") reads as "no token," which is what originally + misdirected diagnosis. + +## Non-goals (YAGNI) + +- Reconciling the **Filigree MCP-client** bearer in `.mcp.json` — that is + Filigree's client config, not wardline's emit path. doctor fixes only the token + **wardline emits** (`.env`). A drifted MCP bearer is out of scope (noted, not + touched). +- Re-minting or editing Filigree's store files (`federation_token`). +- Cross-host recovery: if the daemon authenticates against a `WEFT_FEDERATION_TOKEN` + env override that no local file matches, doctor cannot recover the value — it + reports the situation and the operator action, and writes nothing. + +## Design + +### New check: `filigree.auth` + +A `DoctorCheck` added to `machine_readable_doctor`, alongside the existing +checks, and surfaced in the human `doctor` output. + +#### Probe-URL resolution (precedence) + +doctor must probe the **same daemon the emit path hits**. The emit URL in the +real (MCP) setup lives in `.mcp.json`, not in env/config, so a plain +`resolve_filigree_url(None, root)` returns `None` and would never probe. The +probe URL therefore resolves by: + +1. `--filigree-url` flag (new, optional on `doctor`, mirrors `scan`) +2. `WARDLINE_FILIGREE_URL` env var +3. **`.mcp.json` → `mcpServers.wardline.args` → value after `--filigree-url`** + (doctor already parses `.mcp.json` for `_check_project_mcp`) + +If none resolve → `ok`, message `"filigree not configured; nothing to verify"`. + +The published-port rung (`.weft/filigree/ephemeral.port`) is **deliberately +excluded**: it is a dynamic discovery convenience for live `scan`, not a +configured emit target. doctor probes only a deliberately-configured URL, so it +performs **no network I/O unless filigree emit is actually wired** — which also +keeps the existing doctor test suite network-free. A CLI-only operator relying on +the port rung can pass `--filigree-url` or set `WARDLINE_FILIGREE_URL`. + +#### Token + +`load_filigree_token(root)` — exactly the value emit would send. + +#### Detection (read-only; runs without `--repair`) + +The token (`load_filigree_token(root)`, possibly `None`) is **always probed** — an +absent token is not inherently an error, because the daemon may have auth off and +accept unauthenticated emits. + +| Condition | Result | +|---|---| +| URL does not resolve | `ok` — `"filigree not configured; nothing to verify"` | +| Resolved URL is **non-loopback** | `ok` — `"non-loopback filigree; token not probed"` (never send a bearer off-box) | +| Probe → unreachable (conn refused / timeout) | `ok` — `"filigree daemon not reachable; token not verified"` | +| Probe → accepted (any non-`401/403`, e.g. `400`/`2xx`) | `ok` | +| Probe → `401`/`403`, **token present** | `error` — `"emit token rejected by filigree (); the configured token is not what the daemon accepts"` | +| Probe → `401`/`403`, **no token set** | `error` — `"filigree rejected an unauthenticated emit and no federation token is set; export WEFT_FEDERATION_TOKEN or add it to .env"` | + +**Probe mechanism:** `POST ` with a **sentinel body `{}`** and the bearer, +~2 s timeout. Filigree's auth middleware runs *before* body validation, so a good +token yields `400` (request rejected, **nothing recorded**) and a bad token +yields `401/403`. This is **not** `emit([])`, which would POST a valid +empty-findings body and could register an empty scan. + +#### Repair (`--repair`; only when detection saw `401`/`403`) + +1. Collect candidate tokens from locally-readable mints, in order: + - `~/.config/filigree/federation_token` (server-mode store) + - `/.weft/filigree/federation_token` (project store) + + The already-rejected `.env`/env value is skipped. +2. Probe each candidate against the daemon (same sentinel POST). A daemon accepts + exactly one token, so at most one **distinct** value can authenticate. +3. Outcome: + - **Exactly one accepted** → surgically rewrite `/.env`: set + `WEFT_FEDERATION_TOKEN=`, remove any stale `WARDLINE_FILIGREE_TOKEN=` + line, preserve all other lines, `chmod 0600`. Mark `fixed`; re-probe to + confirm `ok`. + - **None accepted** → `error`, **no write**: `"no local federation_token matched + the daemon — it likely uses a WEFT_FEDERATION_TOKEN env override; set that + same value in .env"`. + +### Code surface + +- **`core/filigree_emit.py`** — add `FiligreeEmitter.verify_token() -> ProbeResult` + (a small frozen dataclass: `accepted: bool`, `reachable: bool`, + `status: int | None`). Reuses the existing auth-header construction and the + injectable `Transport` seam. Sends the sentinel body. Does **not** reuse + `emit()`. +- **`install/doctor.py`** — `_check_filigree_auth(root, *, repair: bool, + filigree_url: str | None)` returning a `DoctorCheck`; helpers: + - `_resolve_probe_url(root, flag)` (precedence above, incl. `.mcp.json` arg + extraction) + - `_filigree_token_candidates(root)` (the two store-dir files) + - `_rewrite_env_token(env_path, value)` (surgical `.env` update + legacy-line + removal + `0600`) + + Wire `_check_filigree_auth` into `machine_readable_doctor`. The repair path runs + inside the existing `fix` flow. +- **`cli/doctor.py`** — add optional `--filigree-url` passthrough; thread it to + `machine_readable_doctor` / `_check_filigree_auth`. + +### Loopback discipline + +The bearer is only sent to loopback origins. A non-loopback resolved URL skips the +probe entirely (reports `ok` / not-probed) rather than transmitting the token +off-box — mirroring the existing Loomweave token-origin discipline. + +## Testing + +Unit tests with an **injected prober / `Transport` stub** — no real network: + +- Detection: rejected (`401`) → `error`; token `None` → absent-message `error`; + unreachable → non-failing `ok`; non-loopback → skipped `ok`; URL unresolved → + `ok`. +- Probe-URL resolution: flag > env > `.mcp.json` arg > published port; the + `.mcp.json`-arg rung exercised explicitly (the lacuna shape). +- Repair: rejected + exactly-one-candidate-accepted → `.env` rewritten, + `WEFT_FEDERATION_TOKEN` set, legacy `WARDLINE_FILIGREE_TOKEN` line removed, + unrelated lines preserved, mode `0600`, re-probe `ok`/`fixed`. +- Repair: rejected + no-candidate-accepted → no write, guidance `error`. +- `verify_token()`: maps `401/403 → accepted=False`, `400/2xx → accepted=True`, + transport error → `reachable=False`. + +**Acceptance oracle (manual, already performed):** against the live lacuna daemon, +`W → HTTP 400`, `D → HTTP 401`, garbage `→ 401`. No real-network test enters the +default suite. + +## Rollout + +- Lands on `rc4` with the rest of the release-candidate work (single-RC-branch + discipline). +- Pure addition: a new check + new emitter method + a new optional flag. No + behavior change to existing checks, `scan`, or `emit`. diff --git a/docs/superpowers/specs/2026-06-08-wardline-rust-frontend-design.md b/docs/superpowers/specs/2026-06-08-wardline-rust-frontend-design.md new file mode 100644 index 00000000..892a40a0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-wardline-rust-frontend-design.md @@ -0,0 +1,734 @@ +# Wardline Rust Frontend — Architecture Design + +**Status:** Draft for review (panel-hardened, round 1) · **Date:** 2026-06-08 · **Branch:** `feat/rust-plugin` +**Scope of this document:** the program-level architecture (the "ceiling") for giving Wardline a +second language frontend that scans Rust (`.rs`) source for trust-boundary / taint findings, plus +the decomposition into buildable sub-projects. The detailed implementation plan for the first +vertical slice (command-injection) is a sibling document: +`docs/superpowers/plans/2026-06-08-wardline-rust-frontend-slice1-command-injection.md`. + +> **Revision note.** This draft incorporates a 7-reviewer adversarial panel (reality / solution-design +> / architecture / systems / quality / rule-designer / security). Decisions previously left open are +> now resolved inline; see the changelog at the end (§13). + +--- + +## 1. Context and the decision that frames this work + +Wardline today has exactly one language frontend: CPython's `ast`. There is **no named "frontend" +abstraction** — the AST leaks across nearly every pipeline stage boundary +(`scanner/pipeline.py`, `scanner/analyzer.py`, `scanner/index.py`, and every rule). A "Rust plugin" +means a **second frontend** that produces the same engine-internal facts from Rust source, reusing +the taint lattice, the trust grammar, the L3 fixpoint, the summary cache, and the `Finding` output +contract. + +### 1.1 Two interpretations — the chosen one, and its cost + +"Rust plugin for wardline" has two orthogonal readings: + +- **Rust-as-target** (this document): a language *frontend* so Wardline can **scan** Rust source. +- **Rust-as-implementation**: rewriting the analysis *engine* in native Rust (PyO3 + maturin). + +These are independent axes — a Python-core Wardline can scan Rust; a Rust-core Wardline can scan +only Python. Wardline already has a **prepped-but-unstarted native-core migration** (the +`Rust-as-implementation` axis): the identity-parity oracle (`tests/golden/identity/`) and ADR +`docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md` ("Python parser gone, +Rust tomorrow") were built specifically to gate that cutover. + +**Decision (user, 2026-06-08): build the Rust *frontend* now, as an interim Python frontend +(tree-sitter), on today's Python engine** — accepting that, like the rest of the Python scanner, it +gets re-ported to Rust at the native-core cutover. + +**Reversibility / cost of the interim (why it still wins).** The work splits cleanly across the +re-port: + +- **Survives the cutover** (engine middle, reused verbatim — §3.1): lattice, L3 kernel, summary + cache, `Finding`/fingerprint, `modulate`, the rule-verdict core, *and* the design artifacts here + (the qualname dialect, the vocabulary schema, the tier map, the rule semantics). +- **Re-written in Rust at the cutover** (the Python-hosted frontend logic): the tree-sitter parse + + index, the builder-dataflow L2 (§9.3, the genuinely new code), the trust provider. + +The interim path wins because the native-core cutover is **unstarted with an unknown timeline**, +Rust-scanning value is wanted **now**, and the re-written fraction is exactly the part whose *design* +(not code) is the expensive thing — and that design is captured here and survives. If the native +cutover were imminent, sequencing it first (build the Rust frontend once, in Rust) would be the +better call; it is not, so the interim is chosen with eyes open. + +### 1.2 Prior art in-repo (read before implementing) + +- `docs/superpowers/plans/2026-06-05-wardline-pre-rust-core-hardening.md` — the native-core prep; + Task A's identity oracle (built, in CI) is the cross-engine identity contract this frontend must + honor. Tasks B (vocabulary descriptor) and C (native-module self-scan allowlist) should be + confirmed complete before SP6's self-scan gate (§11). Note: `tree_sitter` is a *third-party* + package, not `wardline.core`, so Task C's first-party allowlist does not cover it — but the + `wardline[rust]` extra is opt-in and the standard self-scan runs in a base env, so this is a + non-issue for the self-scan path (confirm at SP6). +- `tests/conformance/qualnames.json` + `tests/conformance/test_loomweave_qualname_parity.py` — the + cross-tool qualname grammar; the Rust qualname dialect (§6) needs a counterpart. +- `docs/decisions/2026-05-31-wardline-taint-lattice-retain.md` — why the 8-member lattice is fixed. + +--- + +## 2. The load-bearing reframe: a semantic-capability ceiling + +The single most important design fact: **tree-sitter is a parser, not a compiler frontend.** It +yields a concrete syntax tree — *no types, no name/trait resolution, no macro expansion*. A Rust +sink's reachability depends on how much semantic information you need to *see* it, so the honest +"most general version" is **tiered**, not "all sinks." This tier map governs every promise the spec +is allowed to make. + +| Tier | Needs | Example sink families | tree-sitter alone | +|------|-------|----------------------|-------------------| +| **A** | syntax + constructor/`let` type-tracking | command injection (`std::process::Command`), path traversal, `unsafe`/FFI boundaries | ✅ sound enough | +| **B** | trait/type resolution | sinks behind trait methods, `Deref`-hidden receivers, generic dispatch | ⚠️ unsound (cannot resolve dispatch) | +| **C** | macro expansion | `sqlx::query!`/ORM proc-macros, `serde` derive, code generated by declarative macros | ❌ opaque (expansion never runs) | + +Calibration (verified, recon R6, reality-panel confirmed): + +- **Macro *invocations* are visible as token trees, expansion is not.** `macro_invocation` exposes a + `token_tree` of **raw tokens** (`repeat($._tokens)`), not parsed expressions. So `format!("rm {}", + user_input)` shows `user_input` as an `identifier` *token* — a **heuristic, lexical** taint signal + (Tier-A/B), never structured arg positions or types. +- **SQLi's injectable form is the *function* call** `sqlx::query(runtime_string)` — that callee is a + plain `call_expression`. The checked-safe `query!` *macro* is the opaque one. So the danger is the + `format!`/concatenation feeding the function, not the macro. +- **`serde` derive is an `attribute_item`, not a `macro_invocation`** — strictly Tier-C (we see the + annotation, nothing of the generated impl). + +**Parser decision:** tree-sitter-rust for the **Tier-A floor**, with a **named escalation path** +(rust-analyzer / `rustc` MIR / `cargo expand`) documented for Tier-B/C as opt-in depth in later +sub-projects. The spec promises Tier-A soundness, Tier-B/C as explicitly-bounded heuristics. + +--- + +## 3. Architecture: shared engine + an extracted frontend seam + +### 3.1 What is already language-neutral (reuse verbatim) + +Recon (R1, R3, reality-panel confirmed) shows a clean split. **Reusable as-is, no per-language work:** + +- The **taint lattice** — `TaintState` (8 members), `TRUST_RANK`, `RAW_ZONE`, `least_trusted` (the + live join), `taint_join`, `combine` (`core/taints.py:19-121`). Pure enum + integer-rank lookup. +- The **L3 fixpoint kernel** — `propagate_callgraph_taints(edges, taint_map, taint_sources, + resolved_counts, unresolved_counts, ...)` (`scanner/taint/propagation.py:189-203`). **AST-free**: + call-edge graph + `TaintState` maps + counts only. +- The **summary cache** — `FunctionSeed` (`scanner/taint/function_level.py:26-41` — *not* summary.py), + `FunctionSummary` + `compute_cache_key` (`scanner/taint/summary.py:56-104`), `SummaryCache`, + `module_summariser.summarise_module` (operates on seeds + counts, not AST). + **`compute_cache_key` has six inputs**: `module_path`, `source_bytes`, `schema_version`, + `resolver_version`, `provider_fingerprint`, **`scan_policy_hash`** (the policy-identity slot — do + not conflate it with the vocabulary version, §8.1). +- The **output contract** — `Finding`, `Location`, `Severity`, `Kind`, `compute_finding_fingerprint` + (`core/finding.py:79-151`). Fully neutral; Filigree wire-mapping keys off severity only. +- The **severity model** — `modulate(base, taint)` (`scanner/rules/severity_model.py:47-53`), the + tier-modulation that silences the freedom zone. +- The **rule dispatch seam** — `Rule` protocol (`rule_id` + `check(context) -> Sequence[Finding]`, + `core/protocols.py:24-27`), `RuleRegistry`, `build_default_registry` (`scanner/rules/__init__.py`). + +### 3.2 What is Python-specific (needs a per-language implementation) + +- Parse + `discover_file_entities` / `discover_class_qualnames`, `build_import_alias_map`, + `seed_function_taints` (marker/provider inspection of `decorator_list`). +- `build_call_edges` (call resolution **and node-id minting** — see §5). +- L2 — `analyze_function_variables` (`scanner/taint/variable_level.py`, ~180 `ast.*` references — + the heaviest single file) + `collect_attribute_writes`. +- `_bind_call_site_arguments_to_parameters` — hard-codes Python parameter kinds + (posonly/args/kwonly/`*args`/`**kwargs`). +- **All rules** — they walk `entity.node` (raw AST) to **locate** sink call sites, e.g. + `sql_injection.py:84-117` does `isinstance(node, ast.Call)` / `node.func.attr` / `node.lineno`. + +### 3.3 The seam to introduce + +There is no `frontend` package today; this work introduces one. The seam is **two interfaces**: + +1. **`ParseFrontend`** — given a source file, produce `list[ModuleInput]` + a `ParsedFile`-equivalent, + where `Entity.node: ast.FunctionDef` is replaced by an **opaque, frontend-owned node handle** and + each call/statement carries a **stable `NodeId`** (§5). The neutral payload of `ModuleInput` + (`module_path`, `class_qualnames`, `alias_map`, `seeds: Mapping[str, FunctionSeed]`, + `source_bytes`) is unchanged; only `entities` becomes frontend-typed. +2. **A per-language analysis family** parameterized over that node handle: the L2 variable analyzer, + the call-edge builder, the param binder, and the rule sink-locators. + +The **engine middle stays shared**: L3 kernel + cache + lattice + `modulate` + `Finding`. The +`FunctionSeed` record is the **one fully-neutral interprocedural contract** every frontend emits. + +> **Sequencing reality (panel fix).** The full seam extraction is **SP1**, but SP1 is *not* a forward +> prerequisite — slice 1 builds the Rust path against the engine middle directly and accepts some +> duplication, and SP1 then refactors the *shipped* Python+Rust code into the shared `ParseFrontend` +> (rule of three). **What the slice validates:** that the engine middle can produce real `Finding`s +> from a non-Python source. **What it does NOT validate:** that the engine middle can be *cleanly +> factored* to host two frontends behind one interface — that is SP1's risk and is the reason SP1 +> exists as a named refactor (§11), not why it is sequenced first. + +### 3.4 The rule, split correctly + +Rules do **two** things: (1) **locate** sink call sites by walking the tree (per-language), and (2) +**adjudicate** (neutral). The durable seam is a **neutral verdict core** fed by a **per-language +sink-site locator**. The verdict core is precisely: tier-modulate (`modulate`) + a **`RAW_ZONE` +membership test on a *selected* `TaintState`** + emit a `Finding`. **The selection differs per +rule** — it is *not* always "worst-of-args": + +- `RS-WL-108` selects the single **`program_taint`** (the executable identity). +- `RS-WL-112` selects **`worst-of(arg_taints)`** conditioned on the shell gate. + +Stating it as "RAW_ZONE on a selected TaintState" (not "worst-of selector") prevents an implementer +from feeding `RS-WL-108` the arg list (which would blur it into `RS-WL-112`). Slice 1 may inline the +verdict core in the Rust rules; SP5 extracts it once the second Rust rule lands. + +### 3.5 Reuse boundary diagram (textual) + +``` + Rust source (.rs) + │ + ┌──────────────▼───────────────┐ PER-LANGUAGE (new, Python-hosted, tree-sitter) + │ RustParseFrontend │ - parse → CST (tree-sitter-rust) + │ · module-route resolution │ - entities + Rust qualname dialect (§6) + │ · alias map (use-decls) │ - L1 seed via RustTrustProvider (§7) + │ · stable NodeId minting (§5) │ - builder-dataflow L2 (§9) + │ · call-edge builder │ - command-injection sink locators (§9) + └──────────────┬───────────────┘ + │ ModuleInput(seeds: FunctionSeed, …) + per-trigger arg-taint maps keyed by NodeId + ┌──────────────▼───────────────┐ SHARED (reused verbatim) + │ Engine middle │ - propagate_callgraph_taints (L3; trivial for slice 1) + │ · TaintState lattice/combine │ - SummaryCache / compute_cache_key + │ · modulate / RAW_ZONE gate │ - Finding / Location / fingerprint + └──────────────┬───────────────┘ + │ Finding[] (rule_id="RS-WL-*", Rust qualname, relpath Location) + ▼ + run_scan: baseline / waiver / gate / SARIF / JSONL / Filigree (unchanged, neutral) +``` + +### 3.6 Interim status and the cross-engine identity obligation + +Because this is the **interim Python frontend** (re-ported at the native-core cutover), two +obligations keep it from creating drift the migration would have to unwind: + +- **The dialect is FIXED (Loomweave ADR-049); `RS-WL-*` identity stays baseline-ineligible until SP2 + for a narrower reason.** The Python identity oracle is `PY-WL-* ∧ Kind.DEFECT` only; it excludes + `RS-WL-*`. As of 2026-06-09 the Rust qualname dialect is no longer the blocker — Loomweave fixed it + (§6) and published the byte-exact corpus Wardline vendors. What remains provisional is the + **crate-rooted prefix**, which is SP2 (it needs `Cargo.toml`, §6.3): slice-1 qualnames are + file-module-rooted and reproducible, but the prefix gains its real crate value at SP2, changing each + fingerprint once. So **`RS-WL-*` findings stay identity-provisional and baseline-ineligible** until + SP2 — the CLI/MCP output and preview docs must say so, so users don't accumulate Filigree + associations / baselines that the SP2 crate-prefix rekey would orphan. The **format drift-gate** + (`tests/conformance/qualnames_rust.json`, vendored from Loomweave — §6.4) catches accidental dialect + divergence now; the **frozen** `tests/golden/identity/rust/` finding corpus is the SP2 completion + gate. +- **Do not produce the unreachable lattice states (with one precise exception).** `UNKNOWN_GUARDED` + and `UNKNOWN_ASSURED` are **unconditionally** unreachable under sound analysis and the cache + rehydration guard rejects them (`summary_cache.py:53-85`). **`MIXED_RAW` is unreachable *except* + under `provenance_clash` mode**, where it is a legal state (`summary_cache.py:75`, gated on the + `_PROVENANCE_CLASH` contextvar). Slice 1 / the Tier-A Rust frontend **does not support + provenance-clash** (out of scope), so for the Rust path the practical instruction is: never produce + any of the three. Stated precisely so the inherited invariant is not subtly wrong. + +--- + +## 4. Soundness / precision posture + +Wardline **gates CI** (`wardline scan . --fail-on ERROR`), so the Rust frontend's soundness profile +must be stated explicitly. Wardline's discipline is **fail-closed on uncertainty inside declared-trust +code, and silent in the developer-freedom zone** (`modulate` → `NONE` for undecorated code). The Rust +frontend inherits this: + +- **Fail-closed (over-taint) where information is missing**: an unresolved call returns `UNKNOWN_RAW`; + the flow-insensitive fallback marks every syntactic arg `UNKNOWN_RAW`. A `RAW_ZONE`-gated sink rule + then still fires. +- **Silent by default**: undecorated Rust functions resolve to `UNKNOWN_RAW` ⇒ `modulate → NONE` ⇒ no + finding. Self-hosting / unannotated crates do not flood. The rule only "speaks" in declared-trust + functions (§7). +- **Unsound *by design* at the tier boundary** (documented FNs, not FPs): Tier-B dispatch, Tier-C + macro/derive-generated sinks, and cross-crate bodies are out of reach for the tree-sitter floor. +- **The NodeId hazard (§5) is the one fail-*quiet* path** — §5 makes it a typed, tested invariant. + +**Two false-assurance risks the panel surfaced, and the required mitigation (a coverage-posture +disclosure):** because (a) idiomatic Rust uses traits/macros pervasively (so the Tier-B/C FN surface +grows silently in declared-trust code) and (b) a freshly-enabled scan of an *unannotated* crate +returns zero findings — which reads as "Rust is clean" rather than "Rust is unanalyzed-by-policy" — +the Rust scan path **must emit a coverage/capability line** (e.g. `Rust: N fns scanned, M in +declared-trust; Tier-A only — macro-generated and trait-dispatched sinks not evaluated`). This makes a +silent/green scan *self-describing* at the gate, not only honest in the spec prose. It reuses the +existing `assure` / `decorator_coverage` surface (wired for Rust in SP6; named in slice-1 docs). + +--- + +## 5. The stable `NodeId` contract (the hard requirement) + +**Recon's tightest finding (R1), reality-panel confirmed:** the engine correlates the call graph and +the L2 per-argument taint maps **solely** by `int` keys that are CPython `id(ast.Call)` / `id(stmt)` +— in `call_site_callees`, `function_call_site_arg_taints`, `function_call_site_taints`, +`call_site_taints` (`context.py:41-95`). They are **minted in `build_call_edges` +(`scanner/taint/callgraph.py`, `call_site_callees[id(call)] = …`)** and **collected** at +`project_resolver.py:102-114`; they are object identity persisted across the resolver, L2, and rule +passes. If the keys disagree across passes, every join returns nothing and findings vanish — +**fail-quiet**. + +A Rust frontend cannot use `id()`. It must mint a **deterministic, per-scan-stable `NodeId`** and use +the **same** value in (a) the call-edge/callee builder, (b) the L2 analyzer's call-site/arg maps, and +(c) the rule's tree re-walk. + +**Decision:** + +- Introduce a typed `NodeId` (`NewType('NodeId', int)`) **at the seam** so the contract is explicit. + The Python frontend keeps minting via `id()` behind the newtype (zero behavior change); the Rust + frontend mints its own as a **deterministic per-file pre-order traversal index** over the CST + (intra-scan-stable; cross-*scan* stability is the fingerprint's job, §6, not the NodeId's). +- **The threading contract is named, not implicit.** `mint_node_ids(tree) -> NodeIdMap` (a typed + wrapper over `{ts_node → NodeId}`) is the single source of NodeIds; the L2 dataflow pass and the + rule locators both import it and key every map through it. No pass may key on a raw `ts_node`. +- **The test is cross-pass agreement, not re-parse determinism.** A focused test asserts the NodeId + the dataflow pass stores for a known `.output()` trigger **equals** the NodeId the rule uses to look + up that trigger's arg-taint map (and the builder's). A mismatch is a hard failure — converting the + engine's one fail-open-quiet path into a loud one. (Re-parse stability is a *weak* substitute and + must not be mistaken for this.) +- **Cross-frontend disjointness is an SP1 obligation.** Today's shared `AnalysisContext` keys + `call_site_callees` etc. by a flat `int`. Slice 1's `RustAnalyzer` is **standalone** (it does not + share that flat dict), so the immediate collision risk is nil. But when SP1 merges the frontends, + Python's `id()`-ints and Rust's small pre-order ints share one keyspace — SP1 must partition the + keyspace (or make Python adopt pre-order ids). The WP0 cross-pass test documents that it covers + Rust-internal consistency only. + +--- + +## 6. The Rust qualname dialect + +### 6.1 The constraint, and who owns the dialect + +Wardline's *Python* qualname is a **dotted** string byte-identical to CPython `co_qualname`, with +`..` for nested scopes (`core/qualname.py:24-83`); the `.` format is load-bearing in ~20 sites +(`split('..')[0]` tier-strip; `rsplit('.',1)` enclosing-scope/module recovery — `_sink_helpers.py:202`, +`callgraph.py:93`, `explain.py:56`, etc.). Keeping `.` as the Rust delimiter too lets all of those +sites stay unchanged. + +**But for Rust, Wardline is NOT the dialect author — Loomweave is.** The qualname is the SEI *locator* +(`{plugin}:{kind}:{qualname}`, ADR-003/ADR-038); Loomweave's whole-tree `syn` extractor is the oracle +that defines it, and it is **fixed by Loomweave ADR-049** (`docs/loomweave/adr/ADR-049-rust-qualname-canonicalization.md`, +Accepted 2026-06-08). Wardline is the **second producer**: it **mints the identical string** from its +tree-sitter frontend and **must not parse Loomweave's locator** (the ADR-003/038 opacity discipline). +§6.2 below is the ADR-049 form Wardline conforms to, not a Wardline proposal. (The reply that fixed +this is committed at `docs/integration/2026-06-09-loomweave-rust-qualname-dialect-reply.md`.) + +### 6.2 The dialect (ADR-049 — normative, Loomweave-authoritative) + +> **ADR-049 amendment (Option b), 2026-06-09.** The authoritative extractor (`feat/rust-plugin-spec` @ +> `8adb1ee`) **dropped the inherent-impl source-order ordinal**: same-`(type, generic-sig)` inherent +> impls now **MERGE** into one `impl` entity (their methods hang off one `impl#<...>` key, reorder-stable), +> and cfg-twin inherent impls are split by an `@cfg(...)` suffix on the impl key — with the ordinal gone, +> `@cfg` is the **sole** distinguisher for inherent twins. Trait generic args drop lifetimes *and* +> associated-type bindings (keep only concrete Type/Const args), omitting `<...>` when nothing survives +> (`Iterator` → `impl[Iterator]`, `Trait<'a>` → `impl[Trait]`); positional generics count **type +> params only** (lifetimes/consts do not count). Wardline re-vendored the corpus from `8adb1ee` (blob +> `795ae03`) and conformed. The forms below are the post-amendment (no-ordinal, merge) ones. + +`..`, dot-separated. The reserved char is **`:` (rejected by +`entity_id.rs`)**; `[ ] # < > @` are permitted and `.`-split-safe — which is why my earlier +`:trait=`/`:setter` instinct is *invalid* and was replaced. + +- **Crate token:** the crate name underscored (`-`→`_`), read from `Cargo.toml [package].name` **as + text** (reading a manifest is allowed; running `cargo metadata`/registry resolution is not), falling + back to the dir containing `src/lib.rs`/`src/main.rs`. **Reading the manifest is whole-tree → the + crate prefix is SP2** (see §6.3). +- **Module path:** the `mod` tree (crate root → item), honoring `#[path]` and file-module + (`mod foo;` → `foo.rs`/`foo/mod.rs`) boundaries; inline `mod` blocks nest. +- **Trait-impl method:** `.impl[].` — concrete generic + args are **part of the key**: `Foo.impl[Display].fmt`, `Foo.impl[From].from` ≠ + `Foo.impl[From].from`. Methods **always** carry the impl discriminator (never bare `Foo.fmt`). +- **Inherent-impl method:** `.impl#.` — generics + rendered **positionally/De-Bruijn** (`$0`,`$1`, so a param *rename* does not churn: + `impl Foo` and `impl Foo` both → `impl#<$0>`); non-generic → `impl#<>`. **No ordinal** + (ADR-049 amendment, Option b): same-`(type, generic-sig)` inherent impls **MERGE** into one `impl` + entity — their methods all hang off the one `impl#<...>` key, reorder-stable (`impl#<>.a`, + `impl#<>.b`). cfg-twin inherent impls (same type + generic-sig, mutually-exclusive cfgs) are split by + the `@cfg(...)` suffix on the impl key (`impl#<>@cfg(unix)` vs `impl#<>@cfg(windows)`) — the sole + distinguisher now that the ordinal is gone. +- **`#[cfg]` twins:** a path-colliding cfg-gated sibling appends a normalised `@cfg()` + (whitespace-stripped, `any()/all()` args sorted) — for **every** item kind, `fn`/`struct`/inline + `mod` alike (`f@cfg(unix)`, `S@cfg(windows)`), counted per-kind. Easy to under-implement; the corpus + carries `cfg_twin` + `struct_cfg_twin` rows so the drift-gate trips if missed. +- **Closures and nested `fn` items are NOT entities.** Loomweave never descends into fn bodies — there + is **no `..{closure#N}`** and no nested-fn entity. A finding inside a closure/nested fn + **attributes to the enclosing named fn** (`line_start` localises it). (So no Rust qualname contains + `..`, and the Python tier-strip sites are inert for Rust — the enclosing fn's tier gates the + closure body directly, since Wardline's L2 still analyses that body intra-procedurally.) +- **`async fn`** renders identically to `fn` (no suffix). **Generics** stripped at the definition + (`crate.mod.func`, never `::`); lifetimes stripped. +- **id-`kind` is `function` for EVERY callable** (free fn, method, assoc fn) — there is no `method` + id-kind. Wardline keeps its semantic `function|method` distinction in `Entity` **metadata**, never + in the qualname. + +### 6.3 Module-route resolution + the slice-1 / SP2 reproducibility line + +> **Phase-1b fold amendment, 2026-06-10 (user-approved).** SP2 is no longer the qualname-prefix work +> alone: the Phase-1b producer surface folds into it (§11; the change-set is +> `docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md`), so the SP2 entity +> surface is the **full ten-kind set** — Wardline emits `module` and `struct` (and the other leaf) +> rows itself, not just callables. The *reproducibility* line below is unchanged by the fold (the +> Phase-1b rows are all slice-1 modulo the shared crate-prefix caveat); `#[path]` remains the shared +> known gap. + +Rust modules are **not 1:1 with files**. The route is the `mod` tree from the crate root (§6.2). Per +the corpus's reproducibility tags, the line between what Wardline reproduces **now** vs at **SP2** is: + +- **slice-1 (reproducible single-file now):** the qualname **suffix** — impl discriminator, `@cfg`, + the merge of same-signature inherent impls (no ordinal), positional generics, the closure/nested-fn + folding — and a **file-module root** (the corpus roots its single-file cases at the file module, e.g. + `demo.m.Foo.impl#<>.bar`). +- **SP2 (needs the whole tree):** the real **crate-name prefix from `Cargo.toml`**, the **cross-file + module route**, and **`#[path]`** (inherent-impl ordinals no longer exist post-amendment — same-signature + impls merge — so the old cross-file-ordinal SP2 item is moot). *The crate-root prefix of + every entity is itself SP2.* `#[path]` is a **shared known gap** (Loomweave's `module_path.rs` does + not honour it yet; the corpus `path_attr_known_gap` row pins the mechanical file-path form with + `known_gap: true` — both engines match that, and `#[path]`-correct routing is a joint SP2 task). + +This is the precise reason `RS-WL-*` stays baseline-ineligible until SP2 (§3.6): the *dialect* is +fixed and slice-1 suffixes are stable, but the **crate prefix gains its real value at SP2**, changing +fingerprints once. + +### 6.4 Identity pinning — the corpus is INVERTED (Loomweave seeds, Wardline vendors) + +> **Phase-1b fold amendment, 2026-06-10 (user-approved — supersedes the comparison rule in the second +> bullet).** With the Phase-1b producer surface folded into SP2 (§11), Wardline emits the **full +> ten-kind surface** (`module`, `struct`, `function`, `enum`, `trait`, `type_alias`, `const`, +> `static`, `macro`, `impl`), so "rows Wardline never emits" no longer holds and the conformance +> comparison **graduates from the subset-consumer rule to the full-set rule** (change-set §7 rule 1): +> compare Wardline's full ordered emission of `(qualname, kind)` pairs — semantic `method` mapped to +> id-kind `function` — list-equal against `expected`. Oracle ground-truth corrections (verified +> 2026-06-10): `feat/rust-plugin-spec` is **merged into Loomweave `rc4`** (the plugin is live, +> on-by-default; the stale local branch ref is to be ignored); the upstream corpus gained +> `generic_self_nested_param` (zero diffs in shared cases); and the leaf-kind, stacked-cfg, +> cfg-escape, and leaf-kind-cfg-twin rows are added **upstream by this sprint** (the extractor +> already emits all of it, un-oracled), verified by Loomweave's own cargo gate, then re-vendored. + +- **Loomweave hosts** `fixtures/qualnames_rust.json` (extractor-**generated**, locked by + `crates/loomweave-plugin-rust/tests/qualname_conformance.rs`, passing). **Wardline vendors** a pinned + copy to `tests/conformance/qualnames_rust.json` and reproduces `expected` **byte-for-byte** from its + frontend. This **inverts** the Python arrangement (Wardline seeded `qualnames.json`, Loomweave + vendored) because for Rust the whole-tree extractor is the oracle. The existing Python + `qualnames.json` / `loomweave_qualname_parity.json` and their tests are **untouched**. +- **Comparison rule (do NOT reuse `test_qualname_conformance.py` verbatim — it will fail).** The corpus + `entities[].expected` is Loomweave's *full* emission: it includes `module` and `struct` rows Wardline + never emits, and its `kind` is the locator **id-kind** (`function` for every callable). So, per the + corpus `_consumer_comparison` key: **(1)** the byte-exact obligation is the **`qualname`** of every + function row (the string folded into `fingerprint`) — match char-for-char; **(2)** `kind` is + informational — map Wardline's semantic `method` → id-kind `function`, or compare qualname-only; + **(3)** `module` rows are validated against the `module_route` section, not re-emitted. Recommended + test: take Wardline's function entities, assert each `qualname` is in the case's set of non-`module` + `expected` qualnames. **Never edit the vendored copy to drop rows.** +- The **frozen** `tests/golden/identity/rust/` finding-identity corpus remains the **SP2 completion + gate** (when crate-prefix/cross-file rows stop being provisional). Loomweave offered to drop the + vendored copy + a `test_loomweave_rust_qualname_parity.py` skeleton into Wardline's tree on request. + +--- + +## 7. Trust declaration model for Rust + +Rust has no decorators, and on **stable Rust an unknown `#[trust_boundary]` attribute is a compile +error** (no `register_tool`). So: + +- **Builtin vocabulary is primary.** A bundled `rust_taint.yaml` (§8) ships sources / sinks / + sanitizers for `std` + common crates → **zero-config** Rust findings (the product thesis: power via + activation, not configuration). The L1 seed (sources) and the sink rules (sinks) both read it. +- **Opt-in in-source markers use doc-comments, not attributes.** App-defined boundaries are declared + with `//! @trust_boundary(...)` / `/// @trusted(...)` doc-comments, which cannot break compilation. + These feed the same `BoundaryType` grammar shape (`scanner/grammar.py:39-133`) via a + `RustTrustProvider`. (Rejected: a published no-op proc-macro crate — adds a dep + build step; + `cfg`-gated attributes — fragile. §12 Q4 confirms.) +- The `RustTrustProvider` supplies its **own** `provider_fingerprint` (`rust-vocab:{RUST_TAINT_VERSION}`, + §8.1) — it cannot reuse the Python `_grammar_digest`, which hashes `co_code.hex() | repr(co_consts)` + (both bytecode structure *and* embedded grammar string literals — `decorator_provider.py:188-194`). + No cross-runtime cache interop is assumed on a custom-grammar path. +- Builtin seed **semantics** are replicated byte-compatibly: `external → (EXTERNAL_RAW, EXTERNAL_RAW)`; + `boundary → (EXTERNAL_RAW, to_level)`; `trusted → (level, level)`. + +Slice 1 needs only the **source** half (e.g. `std::env::args`/`var`, `std::fs::read_to_string` → +`EXTERNAL_RAW`) plus the command-injection **sink** set, and the single `@trusted` doc-comment marker +the specimen uses to enter declared-trust. + +--- + +## 8. Vocabulary, discovery, corpus, e2e (the harness changes) + +### 8.1 `rust_taint.yaml` + +Mirror `stdlib_taint.yaml`: top-level `version: int` + `entries: list`. Two **distinct** entry shapes: + +- **Sources:** `{crate, path, returns_taint, rationale}` (`crate`/`path` replace `package`/`function`, + e.g. `crate: std, path: env::var`); `returns_taint` ∈ the legal-return subset `{ASSURED, GUARDED, + EXTERNAL_RAW, UNKNOWN_RAW}`. +- **Sinks:** `{crate, path, sink_kind, rationale}` — `sink_kind` ∈ {`command`, `shell`, …}. + +**Cache-version gap to NOT inherit silently (recon R5):** `stdlib_taint.yaml`'s `version` is **not** +folded into `compute_cache_key`; invalidation relies on a manual `_RESOLVER_VERSION = "sp1d"` bump +(`project_resolver.py:43`). `compute_cache_key`'s sixth input, `scan_policy_hash`, is the *policy* +slot — **not** the vocabulary version. So the Rust path folds `RUST_TAINT_VERSION` into its +**`provider_fingerprint`** (`rust-vocab:{RUST_TAINT_VERSION}`), and a test asserts the +`provider_fingerprint` value changes when `RUST_TAINT_VERSION` bumps. This closes the gap **now** +(testable even though slice 1 has no persisted cache), so SP3/SP6's disk cache cannot inherit it. + +### 8.2 Discovery generalization + +`discover()` **hardcodes `base.rglob('*.py')`** (`core/discovery.py:32`); `WardlineConfig` has **no +`language` field**. Generalize discovery to be **suffix-parameterized**, frontend-owned: +`discover(root, config, *, suffixes=frozenset({'.py'}))` (Python default preserved) with the Rust path +passing `{'.rs'}`. Preserve the `confine_to_root` symlink guard (THREAT-001 — a `.rs` symlink escaping +root must still be skipped with `WLN-ENGINE-FILE-SKIPPED`; this is a **security invariant** with its +own test, not an assumption), the `fnmatch` excludes, and **add `target/` to `_ALWAYS_SKIP`**. +**`missing_source_roots()`** (`discovery.py:51-68`) is *not* suffix-aware — a configured root that +exists but contains no `.rs` is not "missing", so a Rust scan of it returns zero files silently +(false-clean). SP6 must add an empty-root warning for the Rust path; slice 1 documents this as a known +limitation (its `RustAnalyzer` runs over an explicit fixture, so it does not hit the empty-root path). + +### 8.3 Corpus + e2e + +- **Corpus:** Rust specimens live in a **sibling** dir `tests/corpus/rust/fixtures/*.rs` with their + own `MANIFEST.yaml` (keyed by `(path, rule_id, qualname)`, `TRUE_POSITIVE`/`FALSE_POSITIVE` labels, + ≤5% FP gate) plus a **documented FN section**, and a harness variant that runs `RustAnalyzer`. To + make the ≤5% gate non-vacuous on a small corpus, the fixture is **dense** (≥10 clean functions — + non-shell `Command`s, `.args()`, taint-free `format!`, unmarked fns — alongside the TP functions), + *and* a second `clean_commands.rs` fixture carries a **hard 0-findings** negative gate. +- **e2e:** register a `rust_e2e` pytest marker (`pyproject.toml` markers + `addopts` deselect via `and + not rust_e2e`); test file sets `pytestmark = pytest.mark.rust_e2e`; toolchain resolved via the + `tree_sitter` import (skip-clean if the extra is absent). **Correction (reality-panel):** the + `conftest.py` hookwrapper iterates markers generically, so *registering* the marker is enough for + slice 1 — but **promoting `rust_e2e` to a CI-*required* live oracle later** also requires adding it + to the hardcoded `LIVE_ORACLE_MARKERS` set (and its enumerating test). Slice 1 only registers the + marker; CI-required promotion is a named follow-on, not automatic. + +### 8.4 Rule-id namespace + +Rust rules use the **`RS-WL-*`** namespace (disjoint from `PY-WL-*`; the prefix is part of the +fingerprint tuple, so identities never collide). The corpus MANIFEST and `rules.enable`/`severity` +globs treat the prefixes as disjoint namespaces (confirm in SP6). + +--- + +## 9. The first slice: command-injection (Tier A) + +### 9.1 Why this slice + +`Command::new(x).arg(tainted)` is **constructor-tracked plain method calls — zero traits, zero +macros** (squarely Tier-A). The source side is clean (`std::env::args`/`var`). It has direct Python +analogs to match discipline against (`untrusted_to_shell_subprocess.py` = PY-WL-112, +`untrusted_to_command.py` = PY-WL-108). It exercises the **full vertical**. + +**Proves:** the plumbing **and** real source→propagate→sink taint. **Does NOT prove:** semantic +adequacy for Tier-B/C. A green slice is not evidence that "the rest is just more vocabulary." + +### 9.2 Two distinct findings (do not blur them) + +Rust's `std::process::Command` is **always argv-based — there is no implicit shell.** Therefore: + +- **`RS-WL-108` — Tainted program name.** `Command::new(tainted)` — the attacker chooses the + executable. Any `RAW_ZONE` taint on the **program** value fires. **Severity: ERROR.** This is a + **new threat class enabled by Rust's argv model — not a port of PY-WL-108** (which is a *fixed* + always-shell program with a tainted *argument*). A fully attacker-controlled executable is arbitrary + code execution with no shell-metachar dependency — strictly worse than shell-string injection — so + it gates at ERROR. (Resolved §12; was an open question. `modulate` still narrows the blast radius to + declared-fully-trusted fns, so the gate impact is bounded.) +- **`RS-WL-112` — Shell-string injection** (mirrors PY-WL-112): the program is a **shell** + (`sh`/`bash`/`/bin/sh`/`cmd`/`cmd.exe`/`powershell`/`pwsh`, **matched case-insensitively** — Windows + resolution is case-insensitive) **and** a shell flag arg is present (`-c` / `/C` / `-Command`, + case-folded) **and** a tainted command string reaches that shell. Literal-only shell-program + detection (accepts the bounded FN of a variable-bound shell name, §9.4). **Severity: WARN.** + +**De-confliction (panel fix — state it, don't leave it emergent).** The two rules are **mutually +exclusive on the program axis**: `RS-WL-112` requires a *clean literal* shell program; `RS-WL-108` +requires a *tainted* program. A value cannot be both, so they cannot double-fire — *while 112 stays +literal-only*. **Forward-guard:** if SP-later lifts 112's literal-only restriction (the §9.4 FN), a +tainted-variable-that-is-also-a-shell would satisfy both — at that point 108 must suppress when 112 +fires on the same terminal `NodeId` (or 112 yields to 108 on a tainted program). A slice-1 test pins +single-fire on a tainted-program-plus-`-c` specimen (108 only). + +**Hard FP rule:** **do NOT fire on plain `.arg(tainted)` / `.args(tainted_vec)` to a NON-shell +program** — the safe `shell=False` argv list. `.env(k, tainted)` is **out of scope** for slice 1. + +**CWE / metadata.** CWE-78 is pinned in the rule **description prose** (matching PY-WL-108/112) — +`RuleMetadata` has **no `cwe` field**, and adding one is a separate cross-language NG-25 descriptor +change, not smuggled into slice 1. + +**Drafted examples** (these make the rules falsifiable; all inside a `@trusted` fn so the tier gate is +exercised): + +- `RS-WL-108` violation: `Command::new(std::env::var("X").unwrap()).output();` + clean: `Command::new("ls").arg(i).output();` +- `RS-WL-112` violation: `let i = std::env::var("X").unwrap(); Command::new("sh").arg("-c").arg(format!("echo {}", i)).output();` + clean (non-shell argv): `Command::new("ls").arg(i).output();` + clean (literal command, no taint): `Command::new("sh").arg("-c").arg("echo hi").output();` + +### 9.3 The builder-dataflow layer (the genuinely new work) + +Python's `resolved_arg_taints` is keyed to **one** `ast.Call`. The Rust sink is **spread across +statements** bound to a `let`: + +```rust +let mut cmd = Command::new("sh"); // program identity = "sh" (a shell) +cmd.arg("-c"); // shell flag present +cmd.arg(user_input); // tainted arg reaches the shell → RS-WL-112 +cmd.output(); // terminal trigger: anchor the finding here +``` + +The Rust L2 runs a small **intra-function abstract state**: + +- `local_var → CommandState{ is_command, program_literal, program_taint, shell_flag_seen, + arg_taints: list[(NodeId, TaintState)] }`, updated on `Command::new(...)`, `.arg(...)`/`.args(...)`, + and the terminal `.output()`/`.spawn()`/`.status()`. +- `local_string_taints: dict[str, TaintState]` — **string-valued locals carry taint** so a command + string built into a separate `let` and then `.arg`'d is tracked: `let s = format!("rm {}", tainted); + … .arg(s)` must propagate. The two-hop case (`let s2 = format!("{}", s)`) is tested. + +At the terminal call it emits a **per-trigger arg-taint map keyed by `NodeId`** (§5) and feeds it to +the verdict core (§3.4). The finding **anchors at the terminal trigger line** (fingerprint stability), +but **`RS-WL-108`'s message cites the `Command::new(...)` constructor line** as the entering position +(the `CommandState` already tracks it) — otherwise the developer is sent to `.output()` where no +tainted token is visible. + +CST shapes (verified, R6): method calls are `call_expression{ function: field_expression{ value: +, field: 'arg'/'args' }, arguments }` — **no `method_call` node, no `receiver` field**; the +receiver is `field_expression.value`. `Command::new` is a `call_expression` whose `function` is a +`scoped_identifier{ path, name }`. `use std::process::Command [as Alias]` is a `use_declaration` +(`use_as_clause{ path, alias }`) — resolve aliases here so `C::new` canonicalizes. + +**The `format!` heuristic — precise scope and its two error directions.** A tainted identifier +appearing as a **direct interpolation token** inside `format!(…)` is treated as taint reaching the arg. +This is a **lexical token-tree heuristic** (Tier-A/B). It is bounded on **both** sides, and slice 1 +states both: + +- **FN:** the Rust-2021 captured form `format!("rm {user_input}")` embeds the identifier *inside the + string-literal token*, not as a separate arg token — slice 1 does **not** see it (pinned as a + documented FN with a test asserting no propagation, so the boundary is explicit). +- **FP (accepted, bounded):** a sanitizer wrapping the token — + `…arg(format!("echo {}", sanitize(user_input)))` — is invisible to a token scan, so the heuristic + over-taints. This is traded for catching the common direct-interpolation case; the heuristic matches + **direct interpolation argument tokens only** (not any token anywhere in the token-tree) to cut the + FP surface, and a `sanitize()` near-miss is a **measured TN fixture** in the corpus (against the ≤5% + gate). Because `RS-WL-112` is WARN (annotate), this lexical heuristic never feeds the ERROR-gating + population. + +### 9.4 Tier-A boundaries the slice declares up front (documented FNs, not bugs) + +- Trait-method-hidden Command receivers (Tier-B); macro/proc-macro-generated command execution (Tier-C). +- Cross-function builder flow (a `Command` returned from a helper). +- **`Command::arg0(tainted)`** — argv[0] spoofing / login-shell coercion. Same `CommandState` + receiver, so a cheap near-term `RS-WL-108`-family extension; explicitly cut from slice 1 to keep it + tight. +- **Shell strings built by `push_str` / `+` concatenation** (a `call_expression`/`binary_expression`, + not a `macro_invocation`) — the `format!` heuristic does not touch them. **Struck from the §9.3 + in-scope claim and listed here as a documented FN** (slice 1 = `format!`-only lexical heuristic). +- **`Command::new("cmd").args(["/C", tainted])`** — the shell flag arriving via `.args` (per-element + precision is deferred); documented FN. +- Variable-bound shell program names (`let s = "sh"; Command::new(s)`) — mirrors PY-WL-112's + literal-only discipline (bounded FN over FP), pinned with an FN test. +- **Raw `libc::system` / `exec*` FFI** and process spawning outside `std::process::Command` — out of + slice 1; a named Tier-A expansion under SP5. + +--- + +## 10. Risks + +| # | Risk | Severity | Mitigation | +|---|------|----------|------------| +| R-1 | NodeId minting disagrees across passes → findings silently vanish (fail-quiet) | High | §5 typed `NodeId` + named `NodeIdMap` threading + cross-pass agreement test | +| R-2 | ~~Unfixed dialect~~ **RESOLVED 2026-06-09** (Loomweave ADR-049 fixed the dialect, §6). Residual: the **crate-prefix** is SP2-provisional → one fingerprint rekey at SP2 | Med | §3.6/§6.3: `RS-WL-*` baseline-ineligible until SP2; vendored `qualnames_rust.json` drift-gate; frozen `golden/identity/rust/` an SP2 gate | +| R-3 | Builder-dataflow under/over-approximates → FN or the argv-list FP flood | Med-High | §9.2 shell-gated hard FP rule; dense corpus + 0-finding clean fixture; ≤5% gate; FP/FN tests incl. `.args`/no-flag/`format!` negatives | +| R-4 | tree-sitter core/grammar ABI mismatch at install → load failure | Med | §11 SP6 pins `tree-sitter>=0.25,<0.26` + `tree-sitter-rust==0.24.2` (ABI-15 floor); not the grammar's stale `~=0.22` self-pin | +| R-5 | Interim Python frontend orphaned/duplicated at native cutover | Med (accepted) | §1.1 cost analysis (design survives; only frontend code re-written); native cutover unstarted | +| R-6 | Cache-version gap (`rust_taint.yaml` change doesn't invalidate) → stale-clean | Med | §8.1 fold `RUST_TAINT_VERSION` into `provider_fingerprint` + assert-on-bump test now | +| R-7 | Tier ceiling / freedom-zone silence oversold → users read green as "Rust clean" on a CI gate | Med | §4 coverage-posture disclosure in scan output; documented FN families in the guide + MANIFEST FN section | + +--- + +## 11. Decomposition — the program (the "ceiling") + +Six sub-projects, each its own spec→plan→build cycle: + +- **SP1 — Frontend seam extraction** *(post-slice-1 refactor, NOT a forward prerequisite — runs after + slice 1 lands, before SP2 full multi-file)*. Introduce the `frontend` package + the typed `NodeId`; + make today's Python path "frontend #1" behind the seam, **behavior-preserving**; **partition the + shared-context keyspace** so two frontends' NodeIds cannot collide (§5). **Gate: Python corpus + + identity oracle stay byte-green.** +- **SP2 — Rust parse + index** . tree-sitter-rust → entities; the ADR-049 qualname dialect (§6) incl. + the **whole-tree** pieces that are SP2 by construction — crate-name-from-`Cargo.toml`, cross-file + module route, `#[path]` (shared known gap) (§6.3); **the full Phase-1b producer surface** + *(amended 2026-06-10 — user-approved fold; + `docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md`)* — the six leaf kinds + (`enum`/`trait`/`type_alias`/`const`/`static`/`macro`), the `impl` entity with + `module → impl → method` re-parenting, the anchored `imports`/`implements` edges, and + `ontology_version 0.4.0` under `plugin_id rust`, taking the conformance comparison from + subset-consumer to full-set (§6.4); and **the + frozen `tests/golden/identity/rust/` finding corpus** (§6.4). These are the SP2 *completion gates*, + the point at which the crate-prefixed `RS-WL-*` identity stops being provisional. +- **SP3 — Rust trust vocabulary** . `rust_taint.yaml` + `RustTrustProvider` (doc-comment markers) + + `RUST_TAINT_VERSION`-in-`provider_fingerprint` (§8.1). +- **SP4 — Rust L2 builder-dataflow** *(the hard core, §9.3)*. +- **SP5 — Rust sink rules** . `RS-WL-108` + `RS-WL-112`; extract the neutral verdict core (§3.4) once + the second rule lands; then Tier-A expansion (path traversal, `Command::arg0`, `libc::system`/FFI, + `unsafe`) and the named Tier-B/C escalation hooks. +- **SP6 — CLI / MCP / packaging integration** . `wardline[rust]` extra (pins, R-4); `.rs` discovery + + `missing_source_roots` empty-root warning (§8.2); coverage-posture disclosure (§4); `rust_e2e` + marker + corpus harness; rule-id namespace disjointness; confirm hardening Tasks B/C (§1.2); docs + + CHANGELOG. + +**The first vertical slice (the sibling plan) cuts a thin path through SP1→SP6** for command-injection +only. It is the de-risking instrument, not a sub-project. + +--- + +## 12. Open questions for review + +1. **`format!` heuristic narrowing** (§9.3) — confirm "direct-interpolation-arg tokens only" is the + right precision/effort point for slice 1, vs a broader token-tree scan (more FN-resistant, more FP). +2. **Doc-comment markers over a proc-macro crate** (§7) — confirm, given the agent-first/zero-config + thesis. +3. **SEI cross-rename baseline policy** (forward, non-gating — §3.6) — when Loomweave ships its SEI + *resolve* oracle (currently deferred their side), do we re-key historical `RS-WL-*` findings through + it on rename/move, or accept churn-on-rename as a baseline reset? Tracked, not blocking slice 1. + +(Resolved, no longer open: **Q1 Loomweave dialect → FIXED** by ADR-049, 2026-06-09 — Wardline conforms +(§6), blocker dropped; RS-WL-108 severity → **ERROR**; CLI dispatch → **explicit `--lang rust`**; +identity → **baseline-ineligible until the SP2 crate-prefix lands**; SEI fold → **keep folding the +qualname, do NOT fold Loomweave's SEI token** (Wardline can't reproduce it single-file); RS-WL-108/112 +de-confliction → **stated + forward-guarded**.) + +--- + +## 13. Review changelog + +**Round 1 — 7-reviewer panel.** Corrected citations (FunctionSeed → `function_level.py`; NodeId mint → +`callgraph.py build_call_edges`; cache-key 6th input `scan_policy_hash`; `_grammar_digest` hashes +`co_code|co_consts`; live-oracle marker promotion; wheel tag cp39-abi3; qualname sites ~20 not ~12). +Resolved deferred decisions (RS-WL-108 ERROR + reframed as a new threat class; CLI `--lang rust`). +Added: `RustAnalyzer` must satisfy the full `Analyzer` protocol (plan); NodeId `NodeIdMap` threading + +cross-pass test; `local_string_taints` for `format!`-through-`let`; format! FP/FN both directions; +de-confliction + forward-guard; Windows case-fold + `pwsh`; `arg0`/concat/FFI/`args`-flag FNs; +coverage-posture disclosure; dense corpus + clean-fixture hard gate; symlink-confinement + +`missing_source_roots` notes; `MIXED_RAW`-under-provenance-clash precision; interim cost analysis; SP1 +relabelled post-slice refactor. + +**Round 2 — Loomweave ADR-049 conformance (2026-06-09).** The "Loomweave dialect unfixed" blocker is +GONE — Loomweave fixed the Rust qualname dialect (ADR-049) and Wardline now **conforms** (it is the +*second producer* that mints the same string; it never parses the locator). Replaced my proposed forms +with the normative ADR-049 ones: trait impl `Foo.impl[Display].fmt` (concrete generics kept), inherent +`Foo.impl#<>.bar` (positional `$0` generics, **no ordinal** — Option-b amendment: same-signature +inherent impls merge), `@cfg(pred)` twins for all item +kinds, **closures/nested-fns are NOT entities** (dropped `..{closure#N}`; attribute to the +enclosing fn), id-kind always `function`, **`:` is reserved/invalid** (killed `:trait=`/`:setter`). +Corpus **inverted**: Loomweave hosts `fixtures/qualnames_rust.json` (extractor-generated), Wardline +vendors + reproduces byte-for-byte under the documented comparison rule (qualname-only on function +rows). Reproducibility tiers pinned: slice-1 = the file-module-rooted suffix; **SP2 = the crate +prefix + cross-file route + `#[path]`** (so R-2 downgrades to Med). SEI fold resolved: keep folding the +qualname, never Loomweave's SEI token. Reply committed at +`docs/integration/2026-06-09-loomweave-rust-qualname-dialect-reply.md`. + +**Round 3 — Phase 1b folded into SP2 + oracle ground-truth corrections (2026-06-10).** The +user-approved fold: SP2 (§11) now also comprises the **Phase-1b producer surface** from +`docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md` — six leaf kinds, the +`impl` entity + method re-parenting, the anchored `imports`/`implements` edges, `ontology_version +0.4.0` under `plugin_id rust`. Consequence for §6.3/§6.4: Wardline emits the **full ten-kind +surface**, so the "module/struct rows Wardline never emits" premise is retired and the conformance +comparison graduates from the subset-consumer rule to the **full-set rule** (change-set §7 rule 1). +Oracle ground truth corrected (verified 2026-06-10): `feat/rust-plugin-spec` is **merged into +Loomweave `rc4`** (plugin live, on-by-default); the upstream corpus gained +`generic_self_nested_param`; the leaf-kind / stacked-cfg / cfg-escape / leaf-kind-cfg-twin rows — +emitted by the extractor but un-oracled — are added **upstream by this sprint**, gated by +Loomweave's own cargo suite, then re-vendored. diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index c56e8896..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,83 +0,0 @@ -site_name: Wardline -site_description: Generic semantic-tainting static analyzer for Python -site_url: https://wardline.foundryside.dev -repo_url: https://github.com/foundryside-dev/wardline -repo_name: foundryside-dev/wardline -edit_uri: edit/main/docs/ - -theme: - name: material - custom_dir: overrides - palette: - # Dark-first: slate is listed first, so it is the default scheme. - - scheme: slate - primary: custom - accent: custom - toggle: - icon: material/brightness-4 - name: Switch to light mode - - scheme: default - primary: custom - accent: custom - toggle: - icon: material/brightness-7 - name: Switch to dark mode - # Bundled JetBrains Mono + Space Grotesk (vendored in stylesheets/fonts/ via - # weft-mkdocs.css) own typography now — disable Material's Google Fonts. - font: false - features: - - navigation.sections - - navigation.top - - navigation.instant - - content.code.copy - - search.suggest - -markdown_extensions: - - admonition - - toc: - permalink: true - - pymdownx.highlight - - pymdownx.inlinehilite - - pymdownx.superfences - - pymdownx.details - -extra_css: - # Shared Weft Federation docs theme (vendored) first, Wardline identity layer - # on top. - - stylesheets/weft-mkdocs.css - - stylesheets/extra.css - -exclude_docs: | - superpowers/ - integration/ - bitbucket/ - notes/ - decisions/ - audits/ - -nav: - - Home: index.md - - Getting Started: getting-started.md - - Concepts: - - Taint & trust model: concepts/model.md - - Taint algebra: concepts/taint-algebra.md - - Rules: concepts/rules.md - - Trust-vocabulary convergence: concepts/trust-vocabulary-convergence.md - - Guides: - - Configuration: guides/configuration.md - - Suppressing findings: guides/suppression.md - - LLM triage judge: guides/judge.md - - Weft integration: guides/weft.md - - Signed scan handoff to legis: guides/legis-handoff.md - - Loomweave taint store: guides/loomweave-taint-store.md - - Assurance posture: guides/assurance-posture.md - - Attestation: guides/attestation.md - - Using Wardline with your coding agent: guides/agents.md - - Reference: - - CLI: reference/cli.md - - Trust vocabulary: reference/vocabulary.md - - Finding lifecycle & gate vocabulary: reference/finding-lifecycle-vocabulary.md - - About: - - Changelog: https://github.com/foundryside-dev/wardline/blob/main/CHANGELOG.md - - Contributing: https://github.com/foundryside-dev/wardline/blob/main/CONTRIBUTING.md - - License: https://github.com/foundryside-dev/wardline/blob/main/LICENSE diff --git a/overrides/home.html b/overrides/home.html deleted file mode 100644 index ca18c397..00000000 --- a/overrides/home.html +++ /dev/null @@ -1,228 +0,0 @@ -{% extends "main.html" %} - -{# - Wardline product landing page — "technical & confident." - Applied only to docs/index.md via front-matter `template: home.html`. - - The hero is built around the REAL PY-WL-101 finding from index.md - (qualname service.current_user, declared INTEGRAL, actually EXTERNAL_RAW). - The hero code panel is hand-marked-up (NOT Material-highlighted) so the flow - motif and verdict can be styled; the REAL JSON finding stays in the markdown - body below via {{ page.content }} so Material's syntax highlighting and - content.code.copy buttons keep working on the genuine artifact. - Everything is pure HTML/CSS/SVG — no JS, so navigation.instant is unaffected. -#} - -{% block content %} -
- -
-
- - - Static analysis · zero runtime deps - -

See untrusted data
cross a trust boundary.

-

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

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

Three trust decorators

-

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

-
-
- -

Eleven policy rules

-

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

-
-
- -

SARIF & JSONL output

-

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

-
-
- -

Agent-ready: MCP & LLM triage

-

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

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

The trust model

-

Eight ordered states. Four you declare.

-

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

- -
-
- @external_boundary -

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

-
-
- @trust_boundary -

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

-
-
- @trusted -

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

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

Part of Weft Federation

-

Five citizens, one suite

-

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

- -
- -
-{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 26ac0809..152824d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.30.1"] build-backend = "hatchling.build" [project] @@ -14,7 +14,7 @@ dependencies = [] authors = [{ name = "John Morrissey" }] keywords = ["static-analysis", "taint-analysis", "trust-boundaries", "security"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.12", "Typing :: Typed", @@ -24,6 +24,15 @@ classifiers = [ scanner = ["pyyaml>=6.0", "jsonschema>=4.0", "click>=8.0"] docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"] loomweave = ["blake3>=1.0"] +# Rust frontend (preview, Tier-A command-injection). tree-sitter-rust 0.24.2's +# parser is ABI 15, loadable only by tree-sitter core >=0.25.0 — do NOT trust the +# grammar's stale self-declared `tree-sitter~=0.22`. Both ship cp39-abi3 stable-ABI +# wheels (one wheel per platform, CPython 3.9+, no compiler at install). The <0.26 +# cap is conservative: 0.26.x ABI/API compat is unverified — relax after a smoke test. +# The frontend plugs into the scanner pipeline (run_scan, the `--lang rust` click CLI) +# and loads rust_taint.yaml, so it pulls `wardline[scanner]` (pyyaml/click/jsonschema): +# `pip install wardline[rust]` is then usable standalone, not merely additive to [scanner]. +rust = ["wardline[scanner]", "tree-sitter>=0.25,<0.26", "tree-sitter-rust==0.24.2"] # The SP5 LLM triage judge is dependency-free (stdlib urllib -> OpenRouter); no extra needed. [dependency-groups] @@ -39,6 +48,7 @@ dev = [ "types-PyYAML", "types-jsonschema", "hypothesis", + "import-linter>=2.0", ] [project.scripts] @@ -53,12 +63,33 @@ Changelog = "https://github.com/foundryside-dev/wardline/blob/main/CHANGELOG.md" [tool.hatch.version] path = "src/wardline/_version.py" +# Constrain the source distribution to the package, its test suite, and the +# canonical metadata. Without an explicit sdist target hatchling ships every +# non-VCS-ignored path in the repo root (docs/, site/, www/, packages/, +# overrides/, internal audits, uv.lock) — fine for the working tree, wrong for +# a published v1.0 artifact. +[tool.hatch.build.targets.sdist] +# Leading slash anchors each pattern to the repo root — an unanchored +# "README.md"/"pyproject.toml" would also match those filenames nested under +# docs/, packages/, www/ and pull those trees in. +include = [ + "/src/wardline", + "/tests", + "/README.md", + "/CHANGELOG.md", + "/UPGRADING.md", + "/CONTRIBUTING.md", + "/LICENSE", + "/pyproject.toml", +] + [tool.hatch.build.targets.wheel] packages = ["src/wardline"] [tool.hatch.build.targets.wheel.force-include] "src/wardline/scanner/taint/stdlib_taint.yaml" = "wardline/scanner/taint/stdlib_taint.yaml" "src/wardline/core/vocabulary.yaml" = "wardline/core/vocabulary.yaml" +"src/wardline/rust/rust_taint.yaml" = "wardline/rust/rust_taint.yaml" "src/wardline/skills/wardline-gate/SKILL.md" = "wardline/skills/wardline-gate/SKILL.md" [tool.ruff] @@ -108,12 +139,14 @@ 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'" +addopts = "-m 'not network and not loomweave_e2e and not legis_e2e and not filigree_e2e and not rust_e2e and not loomweave_drift'" markers = [ "network: tests that need network (live OpenRouter judge e2e — SP5)", "loomweave_e2e: tests that need a real `loomweave serve` binary (SP9 round-trip)", "legis_e2e: tests that need a real `legis` server (Track 5 intake round-trip)", "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)", ] [tool.coverage.run] @@ -122,3 +155,17 @@ branch = true [tool.coverage.report] show_missing = true + +[tool.importlinter] +root_package = "wardline" + +# REPORT-ONLY layering contracts (CI runs `lint-imports || true`). They encode the +# intended core/ layering: the taint engine must not depend on the attestation layer. +# This contract is BROKEN today (wardline.scanner.pipeline / .taint.project_resolver +# import wardline.core.attest); the layering agent moves that code (wardline-9ec283d168 +# code half). Until then the non-blocking step surfaces the violation without gating CI. +[[tool.importlinter.contracts]] +name = "Taint engine must not import the attestation layer" +type = "forbidden" +source_modules = ["wardline.scanner"] +forbidden_modules = ["wardline.core.attest"] diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 00000000..3e4a8ab4 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,14 @@ +# build output +dist/ +# generated Astro types +.astro/ +# deps +node_modules/ +# the shared @weft/site-kit, sparse-fetched into vendor/ at build time +# (regenerated by scripts/fetch-site-kit.mjs — never committed) +vendor/site-kit/ +# synced from @weft/site-kit at build time (do not commit — regenerated by sync-assets) +public/_site-kit/ +# misc +.DS_Store +*.log diff --git a/site/astro.config.mjs b/site/astro.config.mjs new file mode 100644 index 00000000..45658469 --- /dev/null +++ b/site/astro.config.mjs @@ -0,0 +1,15 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +// wardline deploys to the ROOT of its own subdomain (IA §1.3, §1.4): +// site: https://wardline.foundryside.dev +// base: '/' (each member site is a domain root — no subpath) +// Cross-subdomain links to siblings are ABSOLUTE https://{member}.foundryside.dev +// URLs generated by the shared @weft/site-kit data, so there is no base-path +// gymnastics and the hub and member sites cannot drift. +export default defineConfig({ + site: 'https://wardline.foundryside.dev', + base: '/', + integrations: [react()], +}); diff --git a/site/package-lock.json b/site/package-lock.json new file mode 100644 index 00000000..13c4fbbf --- /dev/null +++ b/site/package-lock.json @@ -0,0 +1,6118 @@ +{ + "name": "@wardline/site", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@wardline/site", + "version": "0.1.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@astrojs/react": "^4.2.0", + "@weft/site-kit": "file:./vendor/site-kit", + "astro": "^5.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", + "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", + "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/react": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@astrojs/react/-/react-4.4.2.tgz", + "integrity": "sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==", + "license": "MIT", + "dependencies": { + "@vitejs/plugin-react": "^4.7.0", + "ultrahtml": "^1.6.0", + "vite": "^6.4.1" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", + "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", + "react": "^17.0.2 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@weft/site-kit": { + "resolved": "vendor/site-kit", + "link": true + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.2.tgz", + "integrity": "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/markdown-remark": "6.3.11", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/astro/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/astro/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "vendor/site-kit": { + "name": "@weft/site-kit", + "version": "0.1.0", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": false + }, + "react-dom": { + "optional": false + } + } + } + } +} diff --git a/site/package.json b/site/package.json new file mode 100644 index 00000000..4e105799 --- /dev/null +++ b/site/package.json @@ -0,0 +1,27 @@ +{ + "name": "@wardline/site", + "version": "0.1.0", + "private": true, + "description": "The wardline member website (wardline.foundryside.dev) — landing + docs entry, built on the shared @weft/site-kit.", + "license": "MIT", + "author": "John Morrissey", + "type": "module", + "scripts": { + "fetch-site-kit": "node ./scripts/fetch-site-kit.mjs", + "preinstall": "node ./scripts/fetch-site-kit.mjs", + "sync-assets": "node ./scripts/sync-assets.mjs", + "predev": "npm run sync-assets", + "prebuild": "npm run sync-assets", + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/react": "^4.2.0", + "@weft/site-kit": "file:./vendor/site-kit", + "astro": "^5.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/site/public/CNAME b/site/public/CNAME new file mode 100644 index 00000000..9850d9b0 --- /dev/null +++ b/site/public/CNAME @@ -0,0 +1 @@ +wardline.foundryside.dev diff --git a/site/scripts/fetch-site-kit.mjs b/site/scripts/fetch-site-kit.mjs new file mode 100644 index 00000000..0d93c658 --- /dev/null +++ b/site/scripts/fetch-site-kit.mjs @@ -0,0 +1,76 @@ +// Fetch @weft/site-kit from the weft hub repo into ./vendor/site-kit/. +// +// The shared kit lives in a SUBDIRECTORY (packages/site-kit) of a DIFFERENT +// repo (foundryside-dev/weft). npm cannot install a git subdirectory as a +// `file:` dep directly, so this sparse-fetches just that subtree and copies it +// into vendor/site-kit/, which package.json then references as +// "@weft/site-kit": "file:./vendor/site-kit". This is the sanctioned +// realization of the "git subdirectory dependency" decision (IA §1.3, §6): +// not a published registry package, not a submodule, not a hand-vendored static +// copy — a regenerated, never-committed vendor tree refreshed on every build. +// +// Runs before `npm install` (the preinstall hook) so the file: target exists +// when the install resolves it; the Pages workflow runs it explicitly too. +import { cp, mkdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const here = dirname(fileURLToPath(import.meta.url)); +const siteRoot = join(here, '..'); + +const REPO = process.env.WEFT_SITE_KIT_REPO || 'https://github.com/foundryside-dev/weft.git'; +const REF = process.env.WEFT_SITE_KIT_REF || 'main'; +const SUBDIR = 'packages/site-kit'; + +const dest = join(siteRoot, 'vendor', 'site-kit'); + +// Escape hatch for local builds: if a sibling weft checkout is present, vendor +// from it directly (no network). Lets the site build offline next to the hub. +const localKit = join(siteRoot, '..', '..', 'weft', 'packages', 'site-kit'); + +function run(cmd, args, opts) { + execFileSync(cmd, args, { stdio: 'inherit', ...opts }); +} + +async function vendorFrom(srcDir, label) { + if (!existsSync(join(srcDir, 'package.json'))) { + throw new Error(`[fetch-site-kit] ${label}: no package.json at ${srcDir}`); + } + await rm(dest, { recursive: true, force: true }); + await mkdir(dirname(dest), { recursive: true }); + await cp(srcDir, dest, { + recursive: true, + filter: (p) => !p.includes(`${join(srcDir, 'node_modules')}`), + }); + console.log(`[fetch-site-kit] vendored @weft/site-kit from ${label} -> ${dest}`); +} + +async function main() { + if (process.env.WEFT_SITE_KIT_LOCAL === '1' || (existsSync(localKit) && process.env.WEFT_SITE_KIT_REMOTE !== '1')) { + if (existsSync(localKit)) { + await vendorFrom(localKit, `local checkout (${localKit})`); + return; + } + } + + const tmp = await mkdir(join(tmpdir(), `weft-site-kit-${Date.now()}`), { recursive: true }).then( + (d) => d || join(tmpdir(), `weft-site-kit-${Date.now()}`), + ); + const clonePath = join(tmpdir(), `weft-site-kit-${process.pid}-${Date.now()}`); + try { + run('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', '--branch', REF, REPO, clonePath]); + run('git', ['sparse-checkout', 'set', SUBDIR], { cwd: clonePath }); + await vendorFrom(join(clonePath, SUBDIR), `${REPO}#${REF}:${SUBDIR}`); + } finally { + await rm(clonePath, { recursive: true, force: true }); + await rm(tmp, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/site/scripts/sync-assets.mjs b/site/scripts/sync-assets.mjs new file mode 100644 index 00000000..325666ff --- /dev/null +++ b/site/scripts/sync-assets.mjs @@ -0,0 +1,30 @@ +// Copy the site-kit brand assets into wardline's public path. +// +// The kit's Nav/Footer/Layout reference the brand glyph at +// /_site-kit/weft-glyph.svg (and the favicon), so every consuming site must +// copy @weft/site-kit/assets/* into public/_site-kit/ before build/dev +// (README "Copy the assets"). Runs automatically via the pre{dev,build} hooks. +// Resolved from the installed package or the vendored copy. +import { cp, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const siteRoot = join(here, '..'); + +// Prefer the installed package; fall back to the vendored copy (works pre-install). +const candidates = [ + join(siteRoot, 'node_modules', '@weft', 'site-kit', 'assets'), + join(siteRoot, 'vendor', 'site-kit', 'assets'), +]; +const src = candidates.find((p) => existsSync(p)); +if (!src) { + console.error('[sync-assets] could not find @weft/site-kit/assets in any of:\n ' + candidates.join('\n ')); + process.exit(1); +} + +const dest = join(siteRoot, 'public', '_site-kit'); +await mkdir(dest, { recursive: true }); +await cp(src, dest, { recursive: true }); +console.log(`[sync-assets] copied ${src} -> ${dest}`); diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro new file mode 100644 index 00000000..39906f9d --- /dev/null +++ b/site/src/pages/index.astro @@ -0,0 +1,522 @@ +--- +// ============================================================ +// wardline.foundryside.dev — the Wardline member site. +// +// MEMBER-PAGE TEMPLATE (IA §5.1, §4.2.3): Hero → What it is → +// Key capabilities → Usage snapshot → How it composes → Status & +// honest limits → Links → CTA. This is the page the IA names as the +// one that TEACHES the brand's honesty mechanic: Wardline is silent +// until you decorate, and silence is not a clean bill — `absent` is +// never `present`. +// +// Everything that could disagree with the hub or a sibling site — +// the roster facts, the pairing slice, the cross-subdomain links — +// is sourced from the shared @weft/site-kit data (ROSTER / MATRIX), +// never hardcoded. Surface facts that MOVE (version, rule count, +// tool count) are snapshots with a repo pointer, not restated canon. +// ============================================================ +import Layout from '@weft/site-kit/layouts/Layout.astro'; +import { Button, Badge, Tag, Banner } from '@weft/site-kit/components'; +import { ExitCode, SeiTag, EnrichmentChip, MemberMark } from '@weft/site-kit/components'; +import { + getMember, + pairingsFor, + partnerOf, + memberUrl, + repoUrl, +} from '@weft/site-kit/data'; + +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 +// count are the repo's authority, pointed at, never silently restated.) +const VERSION = '1.0.0rc4'; + +const REPO = me.repo; +const DOCS = `${REPO}/tree/main/docs`; +const HUB = 'https://weft.foundryside.dev'; +const LACUNA_URL = memberUrl('lacuna'); + +// This member's slice of the combination matrix (IA §2.2). Each pairing's +// partner MemberMark is an absolute cross-subdomain link; a partial/planned +// pairing is NEVER styled as live. +const pairings = pairingsFor(SELF); +const statusTone = (s) => (s === 'live' ? 'ok' : s === 'partial' ? 'warn' : 'neutral'); + +const docLinks = [ + { label: 'Getting started', href: `${DOCS}/getting-started.md`, note: 'install, first scan, read a finding' }, + { label: 'The taint & trust model', href: `${DOCS}/concepts/model.md`, note: 'trust tiers, boundaries, how taint flows' }, + { label: 'Rules', href: `${DOCS}/concepts/rules.md`, note: 'the PY-WL-1xx rule family' }, + { label: 'CLI reference', href: `${DOCS}/reference/cli.md`, note: 'scan, assure, attest, dossier, …' }, + { label: 'MCP tool reference', href: `${DOCS}/reference/mcp.md`, note: 'the tools wardline mcp serves' }, + { label: 'Weft integration', href: `${DOCS}/guides/weft.md`, note: 'SARIF, Filigree, Loomweave, Legis handoff' }, + { label: 'Arming your coding agent', href: `${DOCS}/guides/agents.md`, note: 'the scan → explain → fix loop' }, +]; +--- + + + {/* ---------------------------------------------------------------- */} + {/* 1 · Hero — the question, ExitCode chips, member dossier terminal */} + {/* ---------------------------------------------------------------- */} +
+
+

Wardline · trust-boundary analysis · {me.lang}

+

Is the data each trust-annotated function works with as trusted as it claims?

+

+ A semantic-tainting static analyzer with zero runtime dependencies. + It propagates a taint lattice across the call graph and flags untrusted + data reaching a trusted producer with no validation between. It reads your + Python statically — it never runs your code. +

+ +
+ {me.lang} + {me.threadName} + v{VERSION} · snapshot; see repo +
+ +
+ + +
+ + {/* The exit-code contract is the brand's promise on this page: a clean + run is EARNED (exit 0), not the default. */} +
+ + + +
+ + {/* member dossier terminal — one entity, typed trust facts. The brand + refuses an all-green dossier: it mixes present / absent on purpose. */} +
+
+
$ wardline dossier service.build_record
+
one entity → typed trust facts; absent peers shown as absent, never as clean…
+
+
+ + + + + +
+
+
+
+ + {/* ---------------------------------------------------------------- */} + {/* 2 · What it is + the HONESTY MECHANIC (this page's load-bearing */} + {/* block, IA §4.2.3) */} + {/* ---------------------------------------------------------------- */} +
+
+

What it is · the honesty mechanic

+

Silent until you decorate. Silence is not a clean bill.

+

+ Wardline asks one question of every function: is the data it works with as + trusted as it claims to be? It answers that by tracking a trust level — a + taint — for every value and propagating it across the whole + project. But it says nothing until you mark a trust + boundary. +

+ + + Code with no trust decorators sits in the developer-freedom zone: the + engine treats it as unknown-trust and raises no policy findings about it. + An empty result is not a clean bill of health — it means you have not yet + told Wardline what to guard. You declare trust on the functions that + matter, and only then does it enforce. That is what lets it scan a large + untouched codebase, including its own source, with zero noise — and it is + why a quiet scan must never be read as a pass. + + +
+

You declare trust with three marker decorators (the only three you write):

+
+
+ @external_boundary + marks a source — its return carries raw, untrusted data (EXTERNAL_RAW). +
+
+ @trust_boundary(to_level=…) + marks a validator — it takes raw input and raises its trust on the way out (to GUARDED or ASSURED). +
+
+ @trusted(level=…) + marks a trusted producer — it works on and returns trusted data (INTEGRAL by default). +
+
+
+ +

+ The full eight-state lattice — INTEGRAL → ASSURED → GUARDED → + UNKNOWN_ASSURED → UNKNOWN_GUARDED → EXTERNAL_RAW → UNKNOWN_RAW → MIXED_RAW + — runs most-trusted to least-trusted; less-trusted always wins. You write + only three levels; the UNKNOWN_* and MIXED_RAW + states are the engine's honest record of what it could and could not prove. + The states, the decorator vocabulary, and the rule IDs are Wardline's + authority — see the model and + rules in the repo. +

+
+
+ + {/* ---------------------------------------------------------------- */} + {/* 3 · Key capabilities */} + {/* ---------------------------------------------------------------- */} +
+
+

Key capabilities

+

Scan, gate, explain, attest — and hand the verdict off.

+
+
+

Propagate taint across the call graph

+

+ A function is only as trusted as the least-trusted value it returns — + and that flows transitively across files. Decorate your boundaries and + producers; the engine works out everything in between by following the + calls. The boundary-integrity rule family holds a validator to actually + being able to reject input — a boundary that cannot say "no" is just + relabelling untrusted data. +

+
+
+

Gate a build, deterministically

+

+ wardline scan . --fail-on ERROR exits non-zero when a + finding at or above a level is present. The exit convention is a + contract: 0 clean, 1 gate tripped, + 2 error. +

+
+ + + +
+
+
+

Explain a finding's provenance

+

+ wardline explain-taint (or the MCP explain_taint + tool) walks why the gate tripped — the path the taint took from a source + to the trusted producer that declared more than it returns. +

+
+
+

Many emitters, one engine

+

+ JSONL, SARIF 2.1.0, agent summaries, native Filigree emission, and signed + Legis governance artifacts all compose with the same scanner path. The + rule family PY-WL-1xx and the decorator vocabulary are the + repo's authority — pointers, not restated here. +

+
+
+
+
+ + {/* ---------------------------------------------------------------- */} + {/* 4 · Usage snapshot (curated quick-start, NOT reference) */} + {/* ---------------------------------------------------------------- */} +
+
+

Usage snapshot

+

From install to a gated build.

+

+ The wardline scan CLI lives in the scanner extra. + This is the curated quick-start — the deep CLI and MCP reference live under + this site's own docs paths. +

+ +
+
{`# install the scanner CLI
+pip install "wardline[scanner]"
+
+# mark a boundary, then scan and gate
+wardline scan . --format jsonl --fail-on ERROR
+
+# scanned 2 file(s); 4 finding(s) — 1 active -> findings.jsonl
+#   PY-WL-101  service.current_user  declares INTEGRAL, returns EXTERNAL_RAW
+
+# ask why the gate tripped
+wardline explain-taint --rule PY-WL-101 service.current_user
+
+# agents run the same loop over MCP — no terminal scraping
+wardline mcp`}
+
+ +

+ The product loop: mark the boundary + (@external_boundary / @trust_boundary / + @trusted) → wardline scan --fail-on ERROR → + explain-taint → fix the validation at the boundary and rescan. + See the + getting-started guide and the + CLI reference for the full surface. +

+
+
+ + {/* ---------------------------------------------------------------- */} + {/* 5 · How it composes — this member's matrix slice (IA §2.2) */} + {/* ---------------------------------------------------------------- */} +
+
+

How it composes · value is the weave

+

Each pairing lights up a capability Wardline does not have alone.

+

+ Wardline analyses; it does not enforce — Legis governs. Every pairing's + status is honest on its face; a partial or planned + pair is never rendered as live. The partner mark links to that + tool's site. +

+ +
+ {pairings.map((p) => { + const partner = partnerOf(p, SELF); + const partnerUrl = memberUrl(partner); + return ( +
+
+ + + + + {p.status} + +
+

{p.capability}

+ {p.note &&

{p.note}

} +
+ ); + })} +
+
+
+ + {/* ---------------------------------------------------------------- */} + {/* 6 · Status & honest limits (mandatory, non-empty — IA §5.1) */} + {/* ---------------------------------------------------------------- */} +
+
+

Status & honest limits

+

What it is, and what it is not.

+ +
+ + Wardline is a static analyzer that helps you find untrusted data reaching + trusted code. It does not enforce anything at runtime and does not + adjudicate trust for the federation — Legis governs, Wardline analyses. Do + not read a passing scan as a security guarantee. It is the deterministic + gate that makes a trust-boundary leak visible; the rest is your design. + + +
    +
  • + Silence is opt-in, not clean. Undecorated code is + unknown-trust by design; an empty result means you have not declared a + boundary yet, not that the code is safe. +
  • +
  • + Exit codes are a contract. 0 clean · + 1 gate tripped · 2 error. A clean run is + earned, never the default. +
  • +
  • + Findings → work (A-1 asterisk). The native + Wardline→Filigree emitter has shipped, but the asterisk stays live + until the Loomweave-absent path is proven end-to-end (currently + unit/server-wiring tier) — see the matrix note above. +
  • +
  • + Identity is consumed, never minted. Wardline keys its + facts on the SEI, treats it as opaque, and never mints one — Loomweave + is the identity authority (the SEI spine). +
  • +
  • + Rust support is a preview. The command-injection + frontend is an early preview; Python is the supported surface. +
  • +
+
+
+
+ + {/* ---------------------------------------------------------------- */} + {/* 7 · Links / pointers */} + {/* ---------------------------------------------------------------- */} +
+ +
+ + {/* ---------------------------------------------------------------- */} + {/* 8 · CTA — see it on the specimen */} + {/* ---------------------------------------------------------------- */} +
+
+ +
+

Want to see it actually run?

+

+ Lacuna, the demonstration specimen, seeds PY-WL-101..126 for + exactly this — watch Wardline analyze a small app with catalogued flaws. +

+
+ + + + +
+
+
+ + diff --git a/src/wardline/_version.py b/src/wardline/_version.py index dcbe1b85..5c4105cd 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "1.0.0rc4" +__version__ = "1.0.1" diff --git a/src/wardline/cli/assure.py b/src/wardline/cli/assure.py index ed1f2557..2e76b4b6 100644 --- a/src/wardline/cli/assure.py +++ b/src/wardline/cli/assure.py @@ -54,11 +54,13 @@ def _render_human(posture: object) -> None: assert isinstance(posture, AssurancePosture) d = posture.to_dict() - total: int = d["boundaries_total"] + boundary_total: int = d["boundaries_total"] + unanalyzed_total: int = d["unanalyzed_total"] + coverage_total = boundary_total + unanalyzed_total unknown_list: list[dict[str, Any]] = d["unknown"] waiver_debt: list[dict[str, Any]] = d["waiver_debt"] - if total == 0: + if coverage_total == 0: click.echo("No trust surface declared (0 trust-annotated boundaries) — nothing to assure.") else: pct: float = d["coverage_pct"] @@ -67,25 +69,34 @@ def _render_human(posture: object) -> None: unknown_count = len(unknown_list) engine_limited: int = d["engine_limited"] - definite = total - unknown_count - click.echo(f"Trust-surface coverage: {pct}% ({definite}/{total} boundaries reached a definite verdict)") + definite = boundary_total - unknown_count + click.echo( + f"Trust-surface coverage: {pct}% ({definite}/{coverage_total} surface item(s) reached a definite verdict)" + ) click.echo(f" proven: {proven}") click.echo(f" defect: {defect}") engine_note = f" ({engine_limited} engine-limited)" if engine_limited else "" - click.echo(f" unknown: {unknown_count}{engine_note}") + click.echo(f" unknown: {unknown_count + unanalyzed_total}{engine_note}") + if unanalyzed_total: + click.echo(f" unanalyzed files: {unanalyzed_total}") if unknown_list: click.echo(" Unknown boundaries:") for u in unknown_list: loc = u.get("location") or {} - loc_str = f" {loc.get('path', '?')}:{loc.get('line', '?')}" - reason_str = f" [{u['reason']}]" if u.get("reason") else "" - tier_str = f" (tier: {u['tier']})" if u.get("tier") else "" - click.echo(f" {u['qualname']}{tier_str}{loc_str}{reason_str}") + loc_str = f" {_human_text(loc.get('path', '?'))}:{_human_text(loc.get('line', '?'))}" + reason_str = f" [{_human_text(u['reason'])}]" if u.get("reason") else "" + tier_str = f" (tier: {_human_text(u['tier'])})" if u.get("tier") else "" + click.echo(f" {_human_text(u['qualname'])}{tier_str}{loc_str}{reason_str}") _render_waiver_debt(waiver_debt) +def _human_text(value: object) -> str: + """Escape control characters before writing repository-derived text to terminals.""" + return ascii(str(value))[1:-1] + + def _render_waiver_debt(waiver_debt: list[dict[str, Any]]) -> None: """Render the waiver-debt summary line.""" if not waiver_debt: diff --git a/src/wardline/cli/attest.py b/src/wardline/cli/attest.py index 04b597e2..76d531f1 100644 --- a/src/wardline/cli/attest.py +++ b/src/wardline/cli/attest.py @@ -138,7 +138,9 @@ def attest( click.echo(f"error: invalid attestation bundle: {exc}", err=True) raise SystemExit(2) from exc click.echo(json.dumps(result)) - raise SystemExit(0 if result["signature_valid"] else 1) + if not result["signature_valid"] or (reproduce and result.get("reproduced") is not True): + raise SystemExit(1) + raise SystemExit(0) from wardline.core.attest import build_attestation diff --git a/src/wardline/cli/doctor.py b/src/wardline/cli/doctor.py index 94dae2c4..a7442657 100644 --- a/src/wardline/cli/doctor.py +++ b/src/wardline/cli/doctor.py @@ -8,7 +8,14 @@ import click from wardline.core.errors import WardlineError -from wardline.install.doctor import check_install, machine_readable_doctor, repair_install +from wardline.install.doctor import ( + _check_config, + _check_filigree_auth, + _resolve_probe_url, + check_install, + machine_readable_doctor, + repair_install, +) @click.command() @@ -20,14 +27,15 @@ ) @click.option("--repair", is_flag=True, help="Repair missing or stale install artifacts.") @click.option("--fix", "fix_json", is_flag=True, help="Repair install bindings and emit machine-readable JSON.") -def doctor(root: Path, repair: bool, fix_json: bool) -> None: +@click.option("--filigree-url", default=None, help="Filigree Weft URL to probe (default: resolve from .mcp.json/env).") +def doctor(root: Path, repair: bool, fix_json: bool, filigree_url: str | None) -> None: """Check Wardline agent install artifacts and sibling bindings.""" if repair and fix_json: click.echo("error: use only one of --repair or --fix", err=True) raise SystemExit(2) if fix_json: try: - payload = machine_readable_doctor(root, fix=True) + payload = machine_readable_doctor(root, fix=True, filigree_url=filigree_url) except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc @@ -37,26 +45,40 @@ def doctor(root: Path, repair: bool, fix_json: bool) -> None: raise SystemExit(1) if repair: + # Resolve the probe URL BEFORE repair_install rewrites .mcp.json (which would + # erase a configured --filigree-url arg), so repair can still probe/recover. + probe_url = _resolve_probe_url(root, filigree_url) try: statuses = repair_install(root) except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc after = check_install(root) + config_check = _check_config(root, fixed=statuses.get("weft.toml") == "created") click.echo("wardline doctor:") for check in after: status = statuses.get(check.name, "checked") if check.ok else f"failed ({check.message})" click.echo(f" {check.name}: {status}") - if all(check.ok for check in after): - return - raise SystemExit(1) + config_status = statuses.get("weft.toml", "checked") if config_check.ok else f"failed ({config_check.message})" + click.echo(f" weft.toml: {config_status}") + fcheck = _check_filigree_auth(root, repair=True, filigree_url=probe_url) + fstatus = ("fixed" if fcheck.fixed else fcheck.message) if fcheck.ok else f"failed ({fcheck.message})" + click.echo(f" filigree.auth: {fstatus}") + if not all(check.ok for check in after) or not config_check.ok or not fcheck.ok: + raise SystemExit(1) + return checks = check_install(root) - ok = all(check.ok for check in checks) + config_check = _check_config(root, fixed=False) + fcheck = _check_filigree_auth(root, repair=False, filigree_url=filigree_url) + ok = all(check.ok for check in checks) and config_check.ok and fcheck.ok click.echo("wardline doctor: ok" if ok else "wardline doctor:") for check in checks: - if ok: - continue - click.echo(f" {check.name}: {check.message}") + if not check.ok: + click.echo(f" {check.name}: {check.message}") + if not config_check.ok: + click.echo(f" weft.toml: {config_check.message}") + fmsg = fcheck.message or ("ok" if fcheck.ok else "error") + click.echo(f" filigree.auth: {fmsg}") if not ok: raise SystemExit(1) diff --git a/src/wardline/cli/dossier.py b/src/wardline/cli/dossier.py index 9ab2d8d2..c17341cb 100644 --- a/src/wardline/cli/dossier.py +++ b/src/wardline/cli/dossier.py @@ -47,7 +47,13 @@ def dossier( loomweave_url: str | None, filigree_url: str | None, ) -> None: - """Assemble the one-call dossier for ENTITY (a function qualname) under PATH.""" + """Assemble the one-call dossier for ENTITY (a function qualname) under PATH. + + PATH is the scan root and governs qualnames: ENTITY must be qualified + relative to it. Run against the project root (the directory holding + weft.toml / .weft/wardline/) for the package-qualified form other Weft + tools — and the MCP dossier — use. + """ from wardline.weft_dossier import build_weft_dossier try: diff --git a/src/wardline/cli/explain_taint.py b/src/wardline/cli/explain_taint.py new file mode 100644 index 00000000..78bdc4dd --- /dev/null +++ b/src/wardline/cli/explain_taint.py @@ -0,0 +1,103 @@ +# src/wardline/cli/explain_taint.py +"""`wardline explain-taint` — the CLI twin of the MCP `explain_taint` tool (N-2). + +Thin delegator to ``core.explain.explain_taint_result`` (the same builder the +MCP handler calls — CLI and MCP identical by construction), so a CLI-only agent +can run the full scan -> explain -> fix-at-the-boundary -> rescan loop without +an MCP server.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click + +from wardline.core.config import resolve_loomweave_url +from wardline.core.errors import WardlineError + + +@click.command("explain-taint") +@click.argument("fingerprint", type=str) +@click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") +@click.option( + "--config", + "config_path", + type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), + default=None, +) +@click.option( + "--sink-qualname", + default=None, + help=( + "The finding's qualname: with a configured Loomweave store this serves " + "the explanation from the store (no re-scan)." + ), +) +@click.option( + "--chain", + is_flag=True, + default=False, + help=( + "Also walk the full taint chain to the originating boundary (needs a " + "Loomweave store; degrades to single-hop without one)." + ), +) +@click.option("--max-hops", type=int, default=20, show_default=True, help="Chain-walk hop budget.") +@click.option( + "--loomweave-url", + "loomweave_url", + default=None, + help="Loomweave taint-store URL (opt-in; also resolved from env/published port).", +) +def explain_taint( + fingerprint: str, + path: Path, + config_path: Path | None, + sink_qualname: str | None, + chain: bool, + max_hops: int, + loomweave_url: str | None, +) -> None: + """Explain ONE finding's taint provenance by FINGERPRINT under PATH. + + Prints the immediate tainted callee, the originating boundary, the trust + tiers at the sink, and a remediation hint — the same JSON the MCP + `explain_taint` tool returns. Call right after a scan and before editing: + a fingerprint from a stale scan errors (exit 2) and asks for a re-scan. + PATH is the scan root and must match the scan that minted the fingerprint. + """ + try: + loomweave_url = resolve_loomweave_url(loomweave_url, path, config_path) + loomweave = None + if loomweave_url is not None: + from wardline.loomweave.client import LoomweaveClient + from wardline.loomweave.config import load_loomweave_token, resolve_project_name + + loomweave = LoomweaveClient( + loomweave_url, + secret=load_loomweave_token(path), + project=resolve_project_name(path), + ) + from wardline.core.explain import explain_taint_result + + result = explain_taint_result( + path, + fingerprint=fingerprint, + config_path=config_path, + confine_to_root=True, + loomweave=loomweave, + sink_qualname=sink_qualname, + chain=chain, + max_hops=max_hops, + ) + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc + if result is None: + click.echo( + "error: fingerprint not in current scan; your code changed since the scan that produced it — re-scan.", + err=True, + ) + raise SystemExit(2) + click.echo(json.dumps(result, indent=2, ensure_ascii=False)) diff --git a/src/wardline/cli/findings.py b/src/wardline/cli/findings.py index 6f2db077..3240bba0 100644 --- a/src/wardline/cli/findings.py +++ b/src/wardline/cli/findings.py @@ -27,7 +27,21 @@ default=None, ) @click.option("--where", "where_json", default=None, help='JSON filter object, e.g. \'{"rule_id":"PY-WL-106"}\'.') -def findings(path: Path, config_path: Path | None, where_json: str | None) -> None: +# N-5 / X-5 (wardline-dc6f44707d): the common filters as first-class flat flags so +# an agent does not need the JSON --where blob (filigree-style flag shape). +@click.option( + "--rule-id", "rule_id", default=None, help='Filter by rule id, e.g. PY-WL-101 (same as --where {"rule_id":...}).' +) +@click.option("--severity", default=None, help="Filter by severity (case-insensitive): CRITICAL/ERROR/WARN/INFO/NONE.") +@click.option("--sink", default=None, help="Filter by the finding's sink property, e.g. subprocess.run.") +def findings( + path: Path, + config_path: Path | None, + where_json: str | None, + rule_id: str | None, + severity: str | None, + sink: str | None, +) -> None: """Scan PATH and print filtered findings as JSONL (read-only).""" where = None if where_json is not None: @@ -39,6 +53,18 @@ def findings(path: Path, config_path: Path | None, where_json: str | None) -> No if not isinstance(where, dict): click.echo("error: --where must be a JSON object", err=True) raise SystemExit(2) + flat = {k: v for k, v in {"rule_id": rule_id, "severity": severity, "sink": sink}.items() if v is not None} + if flat: + # A flag and a --where key naming the same filter is ambiguous — refuse + # rather than silently prefer one (the silent-override anti-pattern). + overlap = sorted(set(flat) & set(where or {})) + if overlap: + click.echo( + f"error: {', '.join(overlap)} given both as a flag and inside --where; pass each filter once", + err=True, + ) + raise SystemExit(2) + where = {**(where or {}), **flat} result = run_scan(path, config_path=config_path) try: resolved_where = resolve_query_filters(where, path, config_path) diff --git a/src/wardline/cli/main.py b/src/wardline/cli/main.py index 80372c04..d24423f3 100644 --- a/src/wardline/cli/main.py +++ b/src/wardline/cli/main.py @@ -14,6 +14,7 @@ from wardline.cli.decorator_coverage import decorator_coverage from wardline.cli.doctor import doctor from wardline.cli.dossier import dossier +from wardline.cli.explain_taint import explain_taint from wardline.cli.file_finding import file_finding from wardline.cli.findings import findings from wardline.cli.fix import fix @@ -21,8 +22,10 @@ from wardline.cli.judge import judge as judge_command from wardline.cli.lsp import lsp from wardline.cli.mcp import mcp +from wardline.cli.rekey import rekey from wardline.cli.scan import scan from wardline.cli.scan_file_findings import scan_file_findings +from wardline.cli.scan_job import scan_job from wardline.core.baseline import collect_and_write_baseline from wardline.core.descriptor import descriptor_to_yaml from wardline.core.errors import WardlineError @@ -37,7 +40,9 @@ def cli() -> None: cli.add_command(scan) +cli.add_command(scan_job) cli.add_command(scan_file_findings) +cli.add_command(rekey) cli.add_command(judge_command) cli.add_command(mcp) cli.add_command(lsp) @@ -45,6 +50,7 @@ def cli() -> None: cli.add_command(install) cli.add_command(doctor) cli.add_command(dossier) +cli.add_command(explain_taint) cli.add_command(findings) cli.add_command(file_finding) cli.add_command(assure) diff --git a/src/wardline/cli/mcp.py b/src/wardline/cli/mcp.py index 2440d0b8..f2e629df 100644 --- a/src/wardline/cli/mcp.py +++ b/src/wardline/cli/mcp.py @@ -32,7 +32,15 @@ "`dossier` reads entity-associations (open work) from it." ), ) -def mcp(root: Path, loomweave_url: str | None, filigree_url: str | None) -> None: +@click.option("--read-only", is_flag=True, help="Disable MCP tools that require write capability.") +@click.option("--no-network", is_flag=True, help="Disable MCP tools that require network capability.") +def mcp( + root: Path, + loomweave_url: str | None, + filigree_url: str | None, + read_only: bool, + no_network: bool, +) -> None: """Run the Wardline MCP server over stdio (JSON-RPC 2.0).""" from wardline.core.config import resolve_filigree_url, resolve_loomweave_url @@ -42,4 +50,10 @@ def mcp(root: Path, loomweave_url: str | None, filigree_url: str | None) -> None # point thread the real path here too for parity. See resolve_loomweave_url's docstring. loomweave_url = resolve_loomweave_url(loomweave_url, root, None) filigree_url = resolve_filigree_url(filigree_url, root, None) - WardlineMCPServer(root=root, loomweave_url=loomweave_url, filigree_url=filigree_url).rpc.run_stdio() + WardlineMCPServer( + root=root, + loomweave_url=loomweave_url, + filigree_url=filigree_url, + allow_write=not read_only, + allow_network=not no_network, + ).rpc.run_stdio() diff --git a/src/wardline/cli/rekey.py b/src/wardline/cli/rekey.py new file mode 100644 index 00000000..bda6a50b --- /dev/null +++ b/src/wardline/cli/rekey.py @@ -0,0 +1,202 @@ +"""``wardline rekey`` — one-shot fingerprint migration (P4). + +Carries baseline/judged/waiver verdicts (+ best-effort Filigree) across the +wlfp1->wlfp2 value-rekey from a single scan. ``--probe`` is a read-only dry run; +``--resume`` finishes an interrupted run WITHOUT re-scanning; ``--rollback`` restores +the pre-migration stores. A thin shell over ``core.rekey``. +""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from wardline.core.config import resolve_filigree_url +from wardline.core.errors import WardlineError +from wardline.core.filigree_emit import FiligreeEmitter +from wardline.core.rekey import ORPHAN_CAUSE as _ORPHAN_CAUSE +from wardline.core.rekey import ORPHAN_SAMPLE_LIMIT as _SAMPLE_LIMIT +from wardline.core.rekey import STALE_CAUSE as _STALE_CAUSE +from wardline.core.rekey import Journal, ProbeReport, probe, resume_rekey, rollback, run_rekey +from wardline.core.run import run_scan + + +def _print_prescheme_caution() -> None: + click.echo( + " note: a store here predates the fingerprint-scheme stamp (pre-P1). If its " + "fingerprints also predate the taint-resolution-drift fix, a HIGH orphan rate is a " + "fingerprint-formula change, NOT source churn — re-baseline rather than assume the " + "code moved.", + err=True, + ) + + +def _echo_bounded( + fingerprints: tuple[str, ...], *, indent: str = " ", err: bool = True, remainder_hint: str = "" +) -> None: + """Counts + a bounded sample, never the whole list (bounded-by-default; the cut is + explicit so a sample never reads as the full set).""" + for of in fingerprints[:_SAMPLE_LIMIT]: + click.echo(f"{indent}{of}", err=err) + if len(fingerprints) > _SAMPLE_LIMIT: + more = len(fingerprints) - _SAMPLE_LIMIT + click.echo(f"{indent}… and {more} more (sample bounded{remainder_hint})", err=err) + + +def _print_probe(report: ProbeReport) -> None: + verb = "match current findings" if report.no_op else "will carry" + click.echo(f"probe: {report.scanned_findings} finding(s) scanned; {report.matched} verdict(s) {verb}.") + if report.no_op: + stores = ", ".join(report.current_scheme_stores) or "(no populated stores)" + click.echo(f"probe: no fingerprint migration pending — store(s) already at the current scheme: {stores}.") + if report.orphaned: + click.echo(f" {len(report.orphaned)} orphaned ({_ORPHAN_CAUSE}) — verdict will NOT carry:", err=True) + _echo_bounded(report.orphaned) + if report.stale: + click.echo(f" {len(report.stale)} stale ({_STALE_CAUSE}):", err=True) + _echo_bounded(report.stale) + for c in report.collisions: + click.echo(f" COLLISION: {c.message}", err=True) + if report.prescheme: + _print_prescheme_caution() + if report.clean: + click.echo( + "probe: clean — nothing to migrate." if report.no_op else "probe: clean — every stored verdict will carry." + ) + + +def _print_journal(journal: Journal) -> None: + for leg in journal.legs: + if leg.name == "filigree": + if leg.debt: + click.echo(f" filigree: DEFERRED — {leg.debt}", err=True) + elif leg.done: + click.echo(" filigree: reconciled.") + continue + status = "done" if leg.done else "PENDING" + click.echo(f" {leg.name}: {status} ({len(leg.carried)} carried, {len(leg.orphaned)} orphaned)") + # Surface dropped verdicts loudly but BOUNDED (count + sample): the FULL orphan + # list is recorded verbatim in the migration journal, and the original verdicts + # stay recoverable from .rekey_snapshot/ until rollback. + if leg.orphaned: + click.echo(f" orphaned ({_ORPHAN_CAUSE}) — verdict NOT carried:", err=True) + _echo_bounded(tuple(leg.orphaned), indent=" ") + for c in journal.collisions: + click.echo(f" COLLISION: {c.message}", err=True) + if journal.snapshot_prescheme: + _print_prescheme_caution() + if journal.complete: + click.echo("rekey complete — stores load clean under the new scheme.") + else: + click.echo("rekey incomplete — re-run `wardline rekey` to finish pending leg(s).", err=True) + + +@click.command("rekey") +@click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") +@click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None) +@click.option("--cache-dir", type=click.Path(path_type=Path), default=None) +@click.option( + "--trust-pack", "trusted_packs", multiple=True, help="Allow a trust-grammar pack from weft.toml. Repeatable." +) +@click.option( + "--allow-custom-packs", + "trust_local_packs", + is_flag=True, + default=False, + help="Allow local custom trust-grammar packs.", +) +@click.option( + "--strict-defaults", is_flag=True, default=False, help="Ignore repository-supplied configuration overrides." +) +@click.option( + "--filigree-url", + "filigree_url", + default=None, + help="Re-emit findings under the new fingerprints to this Filigree URL (last leg, best-effort).", +) +@click.option( + "--probe", + "probe_only", + is_flag=True, + default=False, + help="Read-only dry run: report match/orphans/collisions, write nothing.", +) +@click.option( + "--resume", "do_resume", is_flag=True, default=False, help="Finish an interrupted migration WITHOUT re-scanning." +) +@click.option( + "--rollback", "do_rollback", is_flag=True, default=False, help="Restore the pre-migration stores from the snapshot." +) +def rekey( + path: Path, + config_path: Path | None, + cache_dir: Path | None, + trusted_packs: tuple[str, ...], + trust_local_packs: bool, + strict_defaults: bool, + filigree_url: str | None, + probe_only: bool, + do_resume: bool, + do_rollback: bool, +) -> None: + """Re-key baseline/waiver/judge verdicts across a fingerprint-scheme change.""" + if sum((probe_only, do_resume, do_rollback)) > 1: + click.echo("error: --probe, --resume and --rollback are mutually exclusive.", err=True) + raise SystemExit(2) + try: + if do_rollback: + rolled = rollback(path) + click.echo(f"rolled back {len(rolled.restored)} store(s): {', '.join(rolled.restored) or '(none)'}.") + click.echo( + "note: Filigree associations from the forward run are NOT reversed (no remap endpoint); " + "reconcile manually if needed.", + err=True, + ) + return + + if do_resume: + # Resume NEVER re-scans — YAML legs re-carry from the snapshot; a pending + # Filigree leg is deferred (re-run `wardline rekey` to retry it). + journal = resume_rekey(path, findings=None, filigree=None) + _print_journal(journal) + raise SystemExit(0 if journal.complete else 1) + + resolved_url = resolve_filigree_url(filigree_url, path, config_path, strict_defaults=strict_defaults) + # The stores hold verdicts from EVERY frontend (RS-WL graduated to + # baseline-eligible — see core.rekey.is_join_population), so the probe/apply + # scan must cover every frontend too: a python-only scan misreads each healthy + # Rust verdict as orphaned/stale (A7, weft-dda1a6d8dd). + findings = [] + for lang in ("python", "rust"): + result = run_scan( + path, + config_path=config_path, + cache_dir=cache_dir, + trust_local_packs=trust_local_packs, + trusted_packs=trusted_packs, + strict_defaults=strict_defaults, + confine_to_root=True, + # Migration scans the project WITHOUT loading the stores it is about to + # rekey — they are still old-scheme and would SCHEME_MISMATCH. + skip_suppression=True, + lang=lang, + ) + findings.extend(result.findings) + + if probe_only: + report = probe(path, findings) + _print_probe(report) + raise SystemExit(0 if report.clean else 1) + + emitter = None + if resolved_url is not None: + from wardline.filigree.config import load_filigree_token + + emitter = FiligreeEmitter(resolved_url, token=load_filigree_token(path)) + journal = run_rekey(path, findings, filigree=emitter) + _print_journal(journal) + raise SystemExit(0 if journal.complete else 1) + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index 3bf1e9d2..e704c8f9 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import urllib.parse from pathlib import Path import click @@ -11,10 +12,17 @@ from wardline.core.config import resolve_filigree_url, resolve_loomweave_url from wardline.core.emit import JsonlSink from wardline.core.errors import WardlineError -from wardline.core.filigree_emit import EmitResult, FiligreeEmitter, filigree_disabled_reason +from wardline.core.filigree_emit import ( + EmitResult, + FiligreeEmitter, + filigree_destination, + filigree_disabled_reason, + filigree_url_project, +) from wardline.core.finding import Severity from wardline.core.paths import weft_config_path from wardline.core.run import baseline_migration_hint, gate_decision, run_scan +from wardline.core.safe_paths import safe_write_text, write_text_no_follow from wardline.core.sarif import SarifSink @@ -31,9 +39,18 @@ default=None, ) @click.option("--format", "fmt", type=click.Choice(["jsonl", "sarif", "agent-summary", "legis"]), default="jsonl") +@click.option( + "--lang", + type=click.Choice(["python", "rust"]), + default="python", + help=( + "Language frontend. 'rust' scans .rs files for RS-WL-* command-injection findings " + "(frozen identity, baseline-eligible; config severity overrides not yet applied)." + ), +) @click.option("--output", type=click.Path(path_type=Path), default=None) # exit 1 if any non-suppressed DEFECT has severity >= this threshold (SP3b) -@click.option("--fail-on", type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO"]), default=None) +@click.option("--fail-on", type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO"], case_sensitive=False), default=None) # Opt-in CI enforcement: exit 1 when any file was discovered but not analysed # (parse error / too-deep / missing source root — NOT benign no-module skips). # Default FALSE preserves the released exit-code behaviour; the count is ALWAYS @@ -55,6 +72,25 @@ default=None, help="POST findings to this Filigree Weft scan-results URL (opt-in).", ) +@click.option( + "--local-only", + "--no-emit", + "local_only", + is_flag=True, + default=False, + help=( + "Disable sibling emission even when Filigree or Loomweave URLs resolve from flags, env, or local install state." + ), +) +@click.option( + "--filigree-max-findings-per-request", + type=click.IntRange(min=1), + default=None, + help=( + "Maximum Wardline findings per Filigree scan-results POST " + "(default 1000; also configurable with WARDLINE_FILIGREE_MAX_FINDINGS_PER_REQUEST)." + ), +) @click.option( "--loomweave-url", "loomweave_url", @@ -128,11 +164,14 @@ def scan( path: Path, config_path: Path | None, fmt: str, + lang: str, output: Path | None, fail_on: str | None, fail_on_unanalyzed: bool, cache_dir: Path | None, filigree_url: str | None, + local_only: bool, + filigree_max_findings_per_request: int | None, loomweave_url: str | None, new_since: str | None, trusted_packs: tuple[str, ...], @@ -144,7 +183,26 @@ def scan( trust_suppressions: bool, allow_dirty: bool, ) -> None: - """Scan PATH for findings.""" + """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 — a subdirectory scan mints qualnames other Weft tools + (Loomweave/Filigree/dossier) will not match, misses the project's + suppression state, and writes output into the subdirectory (wardline + warns when it detects this). + """ + if lang == "rust": + # Posture banner: RS-WL-* identity is graduated (frozen, baseline-eligible) but + # rule coverage is the command-injection slice and weft.toml severity overrides + # do not yet apply to Rust findings (analyzer accepts config for protocol parity + # only). Surface the remaining gaps so a green gate is read at the right scope. + click.echo( + "note: --lang rust covers the command-injection slice (RS-WL-108/112); " + "config severity overrides do not yet apply to Rust findings.", + err=True, + ) if fmt == "sarif": default_name = "findings.sarif" elif fmt == "agent-summary": @@ -153,12 +211,31 @@ def scan( default_name = "scan.legis.json" else: default_name = "findings.jsonl" + output_is_default = output is None output = output if output is not None else (path / default_name) + # The default output is REPORTED as `output` (path/) but WRITTEN through the + # root-confined safe writer with root=path. Hand that writer the bare root-relative + # name, not `output`: a relative `path` (e.g. `wardline scan pkg`) makes `output` + # already `pkg/`, and safe_project_path would resolve it under `pkg` AGAIN + # (`pkg/pkg/`) while the CLI prints `pkg/` — a write to a path that does + # not exist. Explicit `-o` paths are caller-controlled and written verbatim. + confined_name = Path(default_name) emit_result: EmitResult | None = None loomweave_result = None try: + if config_path is None and not strict_defaults and not weft_config_path(path).is_file(): + click.echo( + "warning: no weft.toml found; using built-in source_roots=['.'], which can make " + "project-root scans broad and slow. Run `wardline doctor --repair --root " + f"{path}` to create a bounded default policy, or `wardline scan-job start {path}` " + "for a pollable long-running scan.", + err=True, + ) filigree_url = resolve_filigree_url(filigree_url, path, config_path, strict_defaults=strict_defaults) loomweave_url = resolve_loomweave_url(loomweave_url, path, config_path, strict_defaults=strict_defaults) + if local_only: + filigree_url = None + loomweave_url = None result = run_scan( path, config_path=config_path, @@ -169,6 +246,7 @@ def scan( strict_defaults=strict_defaults, confine_to_root=not allow_source_root_escape, trust_suppressions=trust_suppressions, + lang=lang, ) findings = result.findings if fix: @@ -206,13 +284,18 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: strict_defaults=strict_defaults, confine_to_root=not allow_source_root_escape, trust_suppressions=trust_suppressions, + lang=lang, ) findings = result.findings if fmt == "sarif": - sarif_sink = SarifSink(output) + 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) elif fmt == "jsonl": - jsonl_sink = JsonlSink(output) + jsonl_sink = JsonlSink( + confined_name if output_is_default else output, root=path if output_is_default else None + ) jsonl_sink.write(findings) elif fmt == "legis": # The signed, verbatim-postable scan for legis's POST /wardline/scan-results. @@ -251,14 +334,17 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: "(dirty: true, legis records it unverified). Commit for a signed artifact.", err=True, ) - # Weft emission is additive: a FiligreeEmitError (HTTP >= 400) is a Wardline - # payload bug -> caught below -> exit 2; an unreachable sibling warns + continues. + # Weft emission is additive: scan uses the emitter's fail-soft protocol mode so + # a Filigree reject is reported as upload failure, not as a pre-gate exit 2. if filigree_url is not None: from wardline.filigree.config import load_filigree_token - emit_result = FiligreeEmitter(filigree_url, token=load_filigree_token(path)).emit( - findings, scanned_paths=result.scanned_paths - ) + 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) # 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. @@ -277,7 +363,7 @@ 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) - output.write_text( + agent_summary_json = ( json.dumps( build_agent_summary( result, @@ -288,9 +374,15 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: ).to_dict(), sort_keys=True, ) - + "\n", - encoding="utf-8", + + "\n" ) + if output_is_default: + safe_write_text(path, confined_name, agent_summary_json, label=default_name) + else: + # Explicit -o path: no-follow, matching the JSONL/SARIF sinks. A raw + # write_text would follow a repo-controlled symlink at the chosen filename + # and truncate an arbitrary user-writable target in an untrusted checkout. + write_text_no_follow(output, agent_summary_json, label=output.name) except WardlineError as exc: click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc @@ -307,10 +399,19 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: "Findings written locally only.", err=True, ) + elif emit_result.token_sent: + # A token WAS sent and rejected — the value is wrong, not absent. Saying + # "set the token" here is the C-7 misdiagnosis (weft-23574069a1). + click.echo( + f"warning: Filigree rejected the token (401) at {filigree_url}; a token WAS sent but " + "its value is wrong — align WEFT_FEDERATION_TOKEN (env or .env) to the canonical " + "federation token. Findings written locally only.", + err=True, + ) else: click.echo( - f"warning: Filigree returned {emit_result.status} (auth rejected) at {filigree_url}; " - "set WEFT_FEDERATION_TOKEN (or .env) to the project token. Findings written locally only.", + f"warning: Filigree returned 401 (auth rejected) at {filigree_url}; no token was sent — " + "set WEFT_FEDERATION_TOKEN (env or .env) to the project token. Findings written locally only.", err=True, ) elif emit_result.status is not None: @@ -325,8 +426,17 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: err=True, ) else: + # N1 / C-10(a): name the destination project so a wrong-project write is visible + # rather than reading as silent success. An unpinned URL means Filigree resolves + # the project server-side — surface that ambiguity explicitly. + dest_project = filigree_url_project(filigree_url) + where = ( + f"project {dest_project!r}" + if dest_project + else "unscoped endpoint (URL pins no project; add ?project= to make routing explicit)" + ) line = ( - f"emitted {len(findings)} finding(s) to {filigree_url} — " + f"emitted {len(findings)} finding(s) to {filigree_url} [{where}] — " f"{emit_result.created} created / {emit_result.updated} updated" ) if emit_result.failed: @@ -335,14 +445,15 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: line += f"; {len(emit_result.warnings)} warning(s): " + "; ".join(emit_result.warnings) click.echo(line) if loomweave_result is not None: + logged_loomweave_url = _redact_url_for_log(loomweave_url) if not loomweave_result.reachable: reason = loomweave_result.disabled_reason or "unreachable" click.echo( - f"warning: Loomweave taint store not written at {loomweave_url} ({reason}); scan unaffected.", + f"warning: Loomweave taint store not written at {logged_loomweave_url} ({reason}); scan unaffected.", err=True, ) else: - line = f"wrote {loomweave_result.written} taint fact(s) to {loomweave_url}" + line = f"wrote {loomweave_result.written} taint fact(s) to {logged_loomweave_url}" if loomweave_result.unresolved_qualnames: line += ( f"; {len(loomweave_result.unresolved_qualnames)} qualname(s) unresolved (not indexed by Loomweave)" @@ -361,26 +472,67 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: f"({s.baselined} baseline / {s.waived} waiver / {s.judged} judged), {s.active} active" f"{unanalyzed_segment} -> {output}" ) + # N-3: a scan rooted in a subdirectory of a weft project mints qualnames no + # federated tool matches and skips the project's suppression state. The FACT + # carries the full explanation — reuse it verbatim so CLI and MCP say the same. + nested = next((f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT"), None) + if nested is not None: + click.echo(f"warning: {nested.message}", err=True) # A discovered-but-not-analysed file is a silent under-scan; never hide it. if s.unanalyzed: click.echo( f"warning: {s.unanalyzed} file(s) were discovered but could not be analyzed " - f"(see WLN-ENGINE-* facts in {output}).", + f"(see WLN-ENGINE-* diagnostics in {output}).", err=True, ) - gate_dec = gate_decision(result, Severity(fail_on)) if fail_on is not None else None - gate_tripped = gate_dec is not None and gate_dec.tripped - if gate_dec is not None and gate_dec.tripped: + if lang == "rust": + # Coverage posture: Rust analysis is default-clean, so a scan over a repo with no + # @trusted markers is vacuously green. Surface the trust surface explicitly so + # "0 active" is never mistaken for "analyzed and safe" (the anti-false-green line). + coverage = next((f for f in result.findings if f.rule_id == "WLN-RUST-COVERAGE"), None) + if coverage is not None: + declared = coverage.properties["functions_declared"] + total = coverage.properties["functions_total"] + click.echo(f"trust surface: {declared} of {total} function(s) declared @trusted", err=True) + if total > 0 and declared == 0: + click.echo( + "warning: no function declares @trusted — the scan analyzed 0 of " + f"{total} function(s) for trust; a clean result here proves nothing. " + "Add /// @trusted(level=ASSURED) markers to your boundary functions.", + err=True, + ) + # Both sub-gates (severity + opt-in unanalyzed) live in the ONE shared decision — + # the same one the MCP scan tool serialises — so the surfaces cannot drift (A4). + gate_dec = gate_decision( + result, + Severity(fail_on) if fail_on is not None else None, + fail_on_unanalyzed=fail_on_unanalyzed, + ) + if gate_dec.verdict == "NOT_EVALUATED": + # A bare scan never ran the gate — say so explicitly so a clean-looking exit is not + # mistaken for a PASS (weft-b937e53854). Carries would_trip_at via the reason. + click.echo(f"gate: NOT_EVALUATED — {gate_dec.reason}", err=True) + elif gate_dec.tripped: # Never let "0 active + gate FAILED" read as a bug: say why and which population. - click.echo(f"gate: FAILED (--fail-on {gate_dec.fail_on}) — {gate_dec.reason}", err=True) + # Name only the knob(s) that actually tripped — an unanalyzed-only trip must not + # print "--fail-on None". + knobs = [] + if gate_dec.severity_tripped: + knobs.append(f"--fail-on {gate_dec.fail_on}") + if gate_dec.unanalyzed_tripped: + knobs.append("--fail-on-unanalyzed") + click.echo(f"gate: FAILED ({', '.join(knobs)}) — {gate_dec.reason}", err=True) click.echo(f"gate: evaluated {gate_dec.evaluated}", err=True) # The secure-gate-default rollout signal: a committed baseline that used to clear # the gate now re-enters it. Loud + separable from the generic reason above. hint = baseline_migration_hint(result, gate_dec, root=path, new_since=new_since) if hint is not None: click.echo(hint, err=True) - # Independent of the severity gate: opt-in enforcement of "everything analysed". - if gate_tripped or (fail_on_unanalyzed and s.unanalyzed): + elif gate_dec.fail_on is None: + # Only the unanalyzed gate ran and it passed — keep the no-vacuous-severity-green + # signal a NOT_EVALUATED verdict used to carry here. + click.echo(f"gate: PASSED (--fail-on-unanalyzed only) — {gate_dec.reason}", err=True) + if gate_dec.tripped: raise SystemExit(1) @@ -392,8 +544,10 @@ def _filigree_status(result: EmitResult | None) -> dict[str, object]: "created": 0, "updated": 0, "failed": 0, + "failures": [], "warnings": [], "disabled_reason": "not configured", + "destination": filigree_destination(None), } return { "configured": True, @@ -401,11 +555,17 @@ def _filigree_status(result: EmitResult | None) -> dict[str, object]: "created": result.created, "updated": result.updated, "failed": result.failed, + # PDR-0023: per-finding reject reasons so a partial ingest is not flattened to a count. + "failures": [f.to_wire() for f in result.failures], "warnings": list(result.warnings), "disabled_reason": filigree_disabled_reason( reachable=result.reachable, status=result.status, + token_sent=result.token_sent, + url=result.url, ), + # N1 / C-10(a): name where findings went so a wrong-project write is visible. + "destination": filigree_destination(result.url), } @@ -425,3 +585,23 @@ def _loomweave_status(result: object | None) -> dict[str, object]: "unresolved_qualnames": list(getattr(result, "unresolved_qualnames", ())), "disabled_reason": getattr(result, "disabled_reason", None), } + + +def _redact_url_for_log(url: str | None) -> str: + if url is None: + return "" + parts = urllib.parse.urlsplit(url) + if not parts.scheme or not parts.netloc: + return url.split("?", 1)[0].split("#", 1)[0] + try: + host = parts.hostname or "" + port = parts.port + except ValueError: + return f"{parts.scheme}://" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + if port is not None: + host = f"{host}:{port}" + if parts.username is not None or parts.password is not None: + host = f"@{host}" + return urllib.parse.urlunsplit((parts.scheme, host, parts.path, "", "")) diff --git a/src/wardline/cli/scan_file_findings.py b/src/wardline/cli/scan_file_findings.py index 368bf600..e8263fd5 100644 --- a/src/wardline/cli/scan_file_findings.py +++ b/src/wardline/cli/scan_file_findings.py @@ -20,7 +20,7 @@ @click.command(name="scan-file-findings") @click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") @click.option("--config", "config_path", type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path)) -@click.option("--fail-on", type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO"]), default=None) +@click.option("--fail-on", type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO"], case_sensitive=False), default=None) @click.option("--cache-dir", type=click.Path(path_type=Path), default=None) @click.option("--filigree-url", "filigree_url", default=None, help="Filigree Weft URL (else flag/env).") @click.option("--loomweave-url", "loomweave_url", default=None, help="Loomweave URL for optional identity attachment.") @@ -90,3 +90,6 @@ def scan_file_findings( click.echo(f"error: {exc}", err=True) raise SystemExit(2) from exc click.echo(json.dumps(result)) + exit_class = result.get("gate", {}).get("exit_class", 0) + if exit_class: + raise SystemExit(exit_class) diff --git a/src/wardline/cli/scan_job.py b/src/wardline/cli/scan_job.py new file mode 100644 index 00000000..f113b3e4 --- /dev/null +++ b/src/wardline/cli/scan_job.py @@ -0,0 +1,119 @@ +"""`wardline scan-job` — file-backed start/status surface for long scans.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click + +from wardline.core.errors import WardlineError +from wardline.core.scan_jobs import ( + DEFAULT_SCAN_JOB_TIMEOUT_SECONDS, + cancel_scan_job, + read_scan_job_status, + start_scan_job, +) + + +@click.group(name="scan-job") +def scan_job() -> None: + """Start and poll file-backed Wardline scan jobs.""" + + +@scan_job.command("start") +@click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") +@click.option("--config", "config_path", type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path)) +@click.option("--format", "fmt", type=click.Choice(["jsonl", "sarif", "agent-summary"]), default="jsonl") +@click.option("--output", type=click.Path(path_type=Path), default=None) +@click.option("--fail-on", type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO"], case_sensitive=False), default=None) +@click.option("--fail-on-unanalyzed/--no-fail-on-unanalyzed", default=False) +@click.option("--cache-dir", type=click.Path(path_type=Path), default=None) +@click.option("--filigree-url", "filigree_url", default=None) +@click.option("--local-only", "--no-emit", "local_only", is_flag=True, default=False) +@click.option("--filigree-max-findings-per-request", type=click.IntRange(min=1), default=None) +@click.option( + "--timeout", + "timeout_seconds", + type=click.FloatRange(min=0.0), + default=None, + help=f"Fail the job after SECONDS; defaults to {DEFAULT_SCAN_JOB_TIMEOUT_SECONDS}. Use 0 to disable.", +) +@click.option("--lang", type=click.Choice(["python", "rust"]), default="python") +@click.option("--new-since", type=str, default=None) +@click.option("--trust-pack", "trusted_packs", multiple=True) +@click.option("--allow-custom-packs", "trust_local_packs", is_flag=True, default=False) +@click.option("--strict-defaults", is_flag=True, default=False) +@click.option("--trust-suppressions", is_flag=True, default=False) +@click.option("--foreground", is_flag=True, hidden=True, default=False) +def start( + path: Path, + config_path: Path | None, + fmt: str, + output: Path | None, + fail_on: str | None, + fail_on_unanalyzed: bool, + cache_dir: Path | None, + filigree_url: str | None, + local_only: bool, + filigree_max_findings_per_request: int | None, + timeout_seconds: float | None, + lang: str, + new_since: str | None, + trusted_packs: tuple[str, ...], + trust_local_packs: bool, + strict_defaults: bool, + trust_suppressions: bool, + foreground: bool, +) -> None: + """Start a scan job and print its status JSON.""" + request = { + "config": str(config_path) if config_path else None, + "format": fmt, + "output": str(output) if output else None, + "fail_on": fail_on.upper() if fail_on else None, + "fail_on_unanalyzed": fail_on_unanalyzed, + "cache_dir": str(cache_dir) if cache_dir else None, + "filigree_url": filigree_url, + "local_only": local_only, + "filigree_max_findings_per_request": filigree_max_findings_per_request, + "timeout_seconds": timeout_seconds, + "lang": lang, + "new_since": new_since, + "trusted_packs": list(trusted_packs), + "trust_local_packs": trust_local_packs, + "strict_defaults": strict_defaults, + "trust_suppressions": trust_suppressions, + } + try: + status = start_scan_job(path, request, foreground=foreground) + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc + click.echo(json.dumps(status, sort_keys=True)) + + +@scan_job.command("status") +@click.argument("job_id") +@click.option("--path", "path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") +def status(job_id: str, path: Path) -> None: + """Print status JSON for a scan job.""" + try: + payload = read_scan_job_status(path, job_id) + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc + click.echo(json.dumps(payload, sort_keys=True)) + + +@scan_job.command("cancel") +@click.argument("job_id") +@click.option("--path", "path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".") +def cancel(job_id: str, path: Path) -> None: + """Cancel a running scan job and print its terminal status JSON.""" + try: + payload = cancel_scan_job(path, job_id) + except WardlineError as exc: + click.echo(f"error: {exc}", err=True) + raise SystemExit(2) from exc + click.echo(json.dumps(payload, sort_keys=True)) diff --git a/src/wardline/cli/scan_job_worker.py b/src/wardline/cli/scan_job_worker.py new file mode 100644 index 00000000..1e7a42b6 --- /dev/null +++ b/src/wardline/cli/scan_job_worker.py @@ -0,0 +1,24 @@ +"""Subprocess entrypoint for file-backed scan jobs.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from wardline.core.errors import WardlineError +from wardline.core.scan_jobs import run_scan_job_worker + + +def main() -> None: + if len(sys.argv) != 3: + print("usage: python -m wardline.cli.scan_job_worker ROOT JOB_ID", file=sys.stderr) + raise SystemExit(2) + try: + run_scan_job_worker(Path(sys.argv[1]), sys.argv[2]) + except WardlineError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + + +if __name__ == "__main__": + main() diff --git a/src/wardline/core/agent_summary.py b/src/wardline/core/agent_summary.py index e11bc5a8..50d6e167 100644 --- a/src/wardline/core/agent_summary.py +++ b/src/wardline/core/agent_summary.py @@ -18,6 +18,17 @@ _SEVERITY_ORDER = {"CRITICAL": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "NONE": 4} +def _is_engine_fact(f: Finding) -> bool: + """Engine diagnostic facts: Kind.FACT with a WLN-ENGINE-* rule_id. + + Used as the shared predicate for both ``_engine_facts`` and the re-split so + the two can never drift from each other. The display array ``engine_facts`` + covers exactly this population; ``informational`` (display) covers everything + else that is not a defect. + """ + return f.kind is Kind.FACT and f.rule_id.startswith("WLN-ENGINE-") + + def _default_filigree_status() -> dict[str, Any]: return { "configured": False, @@ -25,6 +36,7 @@ def _default_filigree_status() -> dict[str, Any]: "created": 0, "updated": 0, "failed": 0, + "failures": [], "warnings": [], "disabled_reason": "not configured", } @@ -53,6 +65,10 @@ class AgentSummary: display_findings: list[Finding] | None = None summary_only: bool = False max_findings: int | None = None + # Offset into the flattened ordered union (active → suppressed → engine_facts → + # informational) for pagination. The default scan returns a bounded first page; the + # agent walks the rest with offset = truncation.next_offset (weft-439d09fc8d). + offset: int = 0 include_suppressed: bool = True # The secure-gate-default rollout hint (or None), surfaced in the gate block so the # "see gate.migration_hint" pointer in next_actions resolves on this surface too — the @@ -67,30 +83,55 @@ def __post_init__(self) -> None: # is too costly for the hot scan path). if self.max_findings is not None and self.max_findings < 0: raise ValueError(f"max_findings must be >= 0, got {self.max_findings}") + if self.offset < 0: + raise ValueError(f"offset must be >= 0, got {self.offset}") def to_dict(self) -> dict[str, Any]: # Counts are whole-project (summary describes the whole project, per the `where` - # contract); arrays come from the displayed/filtered view, then bounded. + # contract); arrays come from the displayed/filtered view, then paginated. count_active = len(_active_defects(self.result.findings)) count_suppressed = len(_suppressed_defects(self.result.findings)) count_facts = len(_engine_facts(self.result.findings)) base = self.result.findings if self.display_findings is None else self.display_findings + # ONE ordered sequence is the pagination unit (weft-439d09fc8d): active defects first + # (most urgent), then suppressed debt, then engine facts, then the remaining + # informational findings (metrics, classifications, suggestions, non-engine facts) — + # each bucket internally sorted by _sort_key. A single offset+max_findings window + # slices this union so one truncation block describes the whole page, not four + # independently-sliced arrays. The union covers EVERY finding exactly once, so + # len(union) == summary.total_findings within the display_findings contract. + union = _active_defects(base) + if self.include_suppressed: + union = union + _suppressed_defects(base) + union = union + _engine_facts(base) + _informational(base) + findings_total = len(union) if self.summary_only: - shown_active: list[Finding] = [] - shown_suppressed: list[Finding] = [] - shown_facts: list[Finding] = [] + window: list[Finding] = [] + elif self.max_findings is not None: + window = union[self.offset : self.offset + self.max_findings] else: - shown_active = _active_defects(base) - shown_suppressed = _suppressed_defects(base) if self.include_suppressed else [] - shown_facts = _engine_facts(base) - if self.max_findings is not None: - shown_active = shown_active[: self.max_findings] - shown_suppressed = shown_suppressed[: self.max_findings] - shown_facts = shown_facts[: self.max_findings] + window = union[self.offset :] + findings_returned = len(window) + end = self.offset + findings_returned + # A truncated page MUST advance: next_offset == offset (a zero-progress window, + # e.g. max_findings=0) would loop a paging agent forever. Only claim truncation + # when the window actually moved the cursor (findings_returned > 0); a degenerate + # zero-size window reports untruncated with no next page rather than a stall. + findings_truncated = (not self.summary_only) and findings_returned > 0 and end < findings_total + next_offset = end if findings_truncated else None + # Re-split the window back into the four display arrays by category (the union was + # built in category order, so this preserves both order and the page boundary). + # NOTE: shown_facts must mirror _is_engine_fact exactly — a non-engine FACT + # (e.g. WLN-L3-*) must NOT land in both shown_facts and shown_informational. + shown_active = [f for f in window if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE] + shown_suppressed = [f for f in window if f.kind is Kind.DEFECT and f.suppressed is not SuppressionState.ACTIVE] + shown_facts = [f for f in window if _is_engine_fact(f)] + shown_informational = [f for f in window if f.kind is not Kind.DEFECT and not _is_engine_fact(f)] active_defects = [_finding_entry(f, include_next=True) for f in shown_active] suppressed = [_finding_entry(f, include_next=False) for f in shown_suppressed] engine_facts = [_finding_entry(f, include_next=False) for f in shown_facts] + informational = [_finding_entry(f, include_next=False) for f in shown_informational] return { "schema": SCHEMA, "summary": { @@ -102,12 +143,20 @@ def to_dict(self) -> dict[str, Any]: "baselined": self.result.summary.baselined, "waived": self.result.summary.waived, "judged": self.result.summary.judged, + # summary.informational counts ALL non-defect findings (engine facts included); + # the display array ``informational`` below covers only non-engine non-defects. + # engine_facts has its own display array, so the display split is: + # active_defects + suppressed_findings + engine_facts + informational(display) + # == total_findings (within the display_findings contract). + "informational": self.result.summary.informational, "unanalyzed": self.result.summary.unanalyzed, }, "gate": { "tripped": self.gate.tripped, "fail_on": self.gate.fail_on, "exit_class": self.gate.exit_class, + "verdict": self.gate.verdict, + "would_trip_at": self.gate.would_trip_at, "reason": self.gate.reason, "evaluated": self.gate.evaluated, "migration_hint": self.migration_hint, @@ -119,6 +168,26 @@ def to_dict(self) -> dict[str, Any]: "active_defects": active_defects, "suppressed_findings": suppressed, "engine_facts": engine_facts, + # Non-defect findings beyond engine facts: metrics, classifications, suggestions, + # and non-engine facts. Parallel to summary.informational (which also includes + # engine_facts; see comment above), but scoped to the display window. This is the + # W3 residual fix: these findings were counted in summary.total_findings but had no + # display array, making pagination falsely appear complete. + "informational": informational, + # Every cut is explicit so a bounded page never reads as "covered all". This is the + # single pagination descriptor for the union above; ``explanations_truncated`` is + # set by the MCP server when explain=true inlines provenance (core never explains). + "truncation": { + "summary_only": self.summary_only, + "include_suppressed": self.include_suppressed, + "max_findings": self.max_findings, + "offset": self.offset, + "findings_total": findings_total, + "findings_returned": findings_returned, + "next_offset": next_offset, + "findings_truncated": findings_truncated, + "explanations_truncated": False, + }, # next_actions follow the whole-project active count, not the displayed slice, # so a summary_only/filtered view does not falsely say "no active defects" — and # they are GATE-AWARE so a baselined-only trip (0 active + gate FAILED) never @@ -153,7 +222,28 @@ def _suppressed_defects(findings: list[Finding]) -> list[Finding]: def _engine_facts(findings: list[Finding]) -> list[Finding]: return sorted( - (f for f in findings if f.kind is Kind.FACT and f.rule_id.startswith("WLN-ENGINE-")), + (f for f in findings if _is_engine_fact(f)), + key=_sort_key, + ) + + +def _informational(findings: list[Finding]) -> list[Finding]: + """Non-defect findings that are NOT engine facts. + + Covers Kind.METRIC, Kind.CLASSIFICATION, Kind.SUGGESTION, and Kind.FACT + findings whose rule_id does NOT start with "WLN-ENGINE-". Together with + _engine_facts this partitions all non-defect findings, so the four display + arrays (active_defects, suppressed_findings, engine_facts, informational) + partition the full finding set exactly once — closing the W3 pagination + residual where these findings were counted in summary.total_findings but + occupied no display slot. + + Note: summary.informational (in ScanSummary) counts ALL non-defect findings + including engine facts, so summary.informational >= len(informational display). + The display split here is finer; both are correct for their purpose. + """ + return sorted( + (f for f in findings if f.kind is not Kind.DEFECT and not _is_engine_fact(f)), key=_sort_key, ) @@ -193,25 +283,89 @@ def _finding_entry(finding: Finding, *, include_next: bool) -> dict[str, Any]: return entry +def _unanalyzed_trip_action(gate: GateDecision) -> dict[str, Any]: + # An unanalyzed trip is an under-scan, not a finding — suppression escape hatches + # (trust_suppressions / new_since / baseline edits) cannot clear it; only fixing what + # blocked analysis (or dropping the knob) can. + return { + "tool": "scan", + "reason": ( + f"gate FAILED on unanalyzed files — {gate.reason}. Fix what blocked analysis " + "(parse errors / too-deep skips / missing source roots — see the WLN-ENGINE-* " + "facts), or drop fail_on_unanalyzed; suppressions cannot clear this trip." + ), + } + + def _next_actions_for(active_count: int, gate: GateDecision) -> list[dict[str, Any]]: if active_count > 0: - return [ + actions = [ {"tool": "explain_taint", "reason": "inspect each active defect before editing"}, {"tool": "file_finding", "reason": "promote confirmed true positives after Filigree emission"}, {"tool": "scan", "reason": "rescan after fixes to verify closure"}, ] + if gate.fail_on is None: + # Active defects AND no severity threshold ran (the gate may still have evaluated + # via fail_on_unanalyzed) — name the enforcement step so a green-looking exit is + # not mistaken for a pass (weft-b937e53854). + actions.append( + { + "tool": "scan", + "reason": ( + f"severity gate did not run (no --fail-on); pass --fail-on " + f"{gate.would_trip_at or 'ERROR'} to enforce" + ), + } + ) + if gate.unanalyzed_tripped: + actions.append(_unanalyzed_trip_action(gate)) + return actions if gate.tripped: - # 0 active defects but the gate FAILED — it tripped on suppressed/baselined findings. - # Do NOT say "rescan after edits" (which reads as passed); point at the gate verdict. - detail = gate.reason or "the gate tripped on suppressed (baselined/waived/judged) findings" + # 0 active defects but the gate FAILED. Attribute each tripping sub-gate — and never + # point an unanalyzed trip at the suppression escape hatches (they cannot clear it). + actions = [] + if gate.unanalyzed_tripped: + actions.append(_unanalyzed_trip_action(gate)) + if gate.severity_tripped: + # The severity gate tripped on suppressed/baselined findings. Do NOT say + # "rescan after edits" (which reads as passed); point at the gate verdict. + detail = gate.reason or "the gate tripped on suppressed (baselined/waived/judged) findings" + actions.append( + { + "tool": "scan", + "reason": ( + f"gate FAILED with 0 active defects — {detail}. To clear: pass " + "trust_suppressions (trusted checkout) or new_since (PR), or remove the " + "baseline/waiver/judged entries; see gate.reason / gate.migration_hint." + ), + } + ) + return actions + if gate.fail_on is None: + # 0 active defects but the severity gate never ran (NOT_EVALUATED, or PASSED on the + # unanalyzed knob alone). If would_trip_at is set, suppressed/baselined defects would + # re-enter an unsuppressed gate (the dogfood-#2 "worse" case); never let this read as + # a clean pass. + label = ( + "gate NOT_EVALUATED (no --fail-on ran)" + if gate.verdict == "NOT_EVALUATED" + else "severity gate did not run (no --fail-on)" + ) + if gate.would_trip_at is not None: + return [ + { + "tool": "scan", + "reason": ( + f"{label}; 0 active defects but suppressed/baselined " + f"{gate.would_trip_at}+ finding(s) would trip an unsuppressed gate — pass --fail-on " + f"{gate.would_trip_at} to enforce, or trust_suppressions / new_since to scope" + ), + } + ] return [ { "tool": "scan", - "reason": ( - f"gate FAILED with 0 active defects — {detail}. To clear: pass " - "trust_suppressions (trusted checkout) or new_since (PR), or remove the " - "baseline/waiver/judged entries; see gate.reason / gate.migration_hint." - ), + "reason": f"{label}; no defect would trip at any threshold — pass --fail-on ERROR to lock it in", } ] return [{"tool": "scan", "reason": "no active defects; rescan after edits"}] @@ -226,6 +380,7 @@ def build_agent_summary( display_findings: list[Finding] | None = None, summary_only: bool = False, max_findings: int | None = None, + offset: int = 0, include_suppressed: bool = True, migration_hint: str | None = None, ) -> AgentSummary: @@ -237,6 +392,7 @@ def build_agent_summary( display_findings=display_findings, summary_only=summary_only, max_findings=max_findings, + offset=offset, include_suppressed=include_suppressed, migration_hint=migration_hint, ) diff --git a/src/wardline/core/assure.py b/src/wardline/core/assure.py index 424ca60f..7b1538d5 100644 --- a/src/wardline/core/assure.py +++ b/src/wardline/core/assure.py @@ -10,9 +10,11 @@ Coverage measures "verdict reached EITHER WAY". A defect is COVERED: the engine reached a definite (negative) verdict. The honesty gap is the ``unknown`` set — entities whose trust could not be proven (no computed return taint, an undeclared / -``UNKNOWN_*`` tier, or an engine under-scan). ``engine_limited`` is the sub-count of -those that are unknown specifically because the engine under-scanned them (a parse / -recursion skip), as distinct from a developer simply not declaring trust. +``UNKNOWN_*`` tier, or an engine under-scan). ``unanalyzed_total`` counts files the +engine discovered but never analyzed; coverage treats each as at least one uncovered +surface item because trust declarations in that file are unknown. ``engine_limited`` +is the total under-scan pressure: entity-level unknowns with a skip reason plus +``unanalyzed_total``. Per-entity verdicts are delegated WHOLESALE to :func:`wardline.core.dossier.classify_entity_trust` — the single source of truth — @@ -90,13 +92,20 @@ def to_dict(self) -> dict[str, Any]: @dataclass(frozen=True, slots=True) class AssurancePosture: """The trust-surface coverage rollup. ``boundaries_total`` is the denominator - (anchored entities only); ``proven`` + ``defect_total`` + ``len(unknown)`` == + for known anchored entities; ``proven`` + ``defect_total`` + ``len(unknown)`` == ``boundaries_total``. ``coverage_pct`` is the share with a definite verdict - (defects counted as covered); ``unknown`` is the honesty gap. + (defects counted as covered) over known boundaries plus ``unanalyzed_total``; + ``unknown`` is the known-entity honesty gap. - ``coverage_pct`` is ``None`` when ``boundaries_total == 0`` — no trust surface to - cover means coverage is null, never a vacuous 100% that reads as a false-green to - an agent using a numeric gate (the project's #1 forbidden failure mode).""" + ``unanalyzed_total`` is discovered source the engine never analysed. Because + those files may contain trust declarations the parser could not recover, + coverage treats each as at least one uncovered surface item instead of silently + shrinking the denominator. + + ``coverage_pct`` is ``None`` when there are no known boundaries and no + unanalyzed files — no trust surface to cover means coverage is null, never a + vacuous 100% that reads as a false-green to an agent using a numeric gate (the + project's #1 forbidden failure mode).""" boundaries_total: int proven: int @@ -104,6 +113,7 @@ class AssurancePosture: unknown: list[UnknownBoundary] engine_limited: int coverage_pct: float | None + unanalyzed_total: int unanalyzed_rule_ids: list[str] waiver_debt: list[WaiverDebtEntry] baselined_total: int @@ -117,6 +127,7 @@ def to_dict(self) -> dict[str, Any]: "unknown": [u.to_dict() for u in self.unknown], "engine_limited": self.engine_limited, "coverage_pct": self.coverage_pct, + "unanalyzed_total": self.unanalyzed_total, "unanalyzed_rule_ids": list(self.unanalyzed_rule_ids), "waiver_debt": [w.to_dict() for w in self.waiver_debt], "baselined_total": self.baselined_total, @@ -141,6 +152,7 @@ def _empty_posture(waivers: tuple[Waiver, ...], today: date) -> AssurancePosture unknown=[], engine_limited=0, coverage_pct=None, + unanalyzed_total=0, unanalyzed_rule_ids=[], waiver_debt=_waiver_debt(waivers, today), baselined_total=0, @@ -208,10 +220,13 @@ def posture_from_scan( unknown.sort(key=lambda u: u.qualname) - if boundaries_total == 0: + unanalyzed_total = result.summary.unanalyzed + engine_limited += unanalyzed_total + coverage_denominator = boundaries_total + unanalyzed_total + if coverage_denominator == 0: coverage_pct: float | None = None else: - coverage_pct = round(100 * (boundaries_total - len(unknown)) / boundaries_total, 1) + coverage_pct = round(100 * (boundaries_total - len(unknown)) / coverage_denominator, 1) unanalyzed_rule_ids = sorted({f.rule_id for f in result.findings if f.rule_id in UNDER_SCAN_RULE_IDS}) @@ -222,6 +237,7 @@ def posture_from_scan( unknown=unknown, engine_limited=engine_limited, coverage_pct=coverage_pct, + unanalyzed_total=unanalyzed_total, unanalyzed_rule_ids=unanalyzed_rule_ids, waiver_debt=_waiver_debt(waivers, today), baselined_total=result.summary.baselined, diff --git a/src/wardline/core/attest.py b/src/wardline/core/attest.py index 1ba8634a..5c7236f0 100644 --- a/src/wardline/core/attest.py +++ b/src/wardline/core/attest.py @@ -44,26 +44,24 @@ import hmac import json import subprocess -from collections.abc import Mapping -from dataclasses import fields, is_dataclass from datetime import date -from enum import Enum from pathlib import Path -from types import ModuleType from typing import Any from wardline._version import __version__ from wardline.core import config as config_mod from wardline.core.assure import _empty_posture, posture_from_scan from wardline.core.attest_key import key_id -from wardline.core.config import WardlineConfig from wardline.core.dossier import classify_entity_trust from wardline.core.errors import AttestError from wardline.core.paths import weft_config_path +from wardline.core.ruleset import ruleset_hash from wardline.core.run import run_scan from wardline.core.waivers import load_project_waivers ATTEST_SCHEMA = "wardline-attest-1" +# `git status` must not execute repo-local helpers while attesting untrusted trees. +_SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") def git_state(root: Path) -> tuple[str | None, bool]: @@ -78,7 +76,7 @@ def git_state(root: Path) -> tuple[str | None, bool]: """ try: rev = subprocess.run( - ["git", "rev-parse", "HEAD"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, @@ -93,7 +91,7 @@ def git_state(root: Path) -> tuple[str | None, bool]: try: status = subprocess.run( - ["git", "status", "--porcelain"], + ["git", *_SAFE_GIT_CONFIG, "status", "--porcelain"], cwd=root, capture_output=True, text=True, @@ -106,128 +104,6 @@ def git_state(root: Path) -> tuple[str | None, bool]: return commit, dirty -def _file_sha256(path: Path | None) -> str | None: - if path is None or not path.is_file(): - return None - digest = hashlib.sha256() - try: - with path.open("rb") as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b""): - digest.update(chunk) - except OSError: - return None - return digest.hexdigest() - - -def _module_origin(module: ModuleType) -> Path | None: - spec = getattr(module, "__spec__", None) - origin = getattr(spec, "origin", None) or getattr(module, "__file__", None) - if not isinstance(origin, str) or origin in {"built-in", "frozen"}: - return None - return Path(origin) - - -def _callable_policy_identity(value: Any) -> dict[str, Any]: - code = getattr(value, "__code__", None) - code_hash = None - if code is not None: - digest = hashlib.sha256() - digest.update(code.co_code) - digest.update(repr(code.co_consts).encode("utf-8", "backslashreplace")) - digest.update(repr(code.co_names).encode("utf-8", "backslashreplace")) - digest.update(repr(code.co_varnames).encode("utf-8", "backslashreplace")) - code_hash = digest.hexdigest() - return { - "module": getattr(value, "__module__", None), - "qualname": getattr(value, "__qualname__", getattr(value, "__name__", repr(value))), - "code_sha256": code_hash, - } - - -def _class_policy_identity(value: type) -> dict[str, Any]: - source_path = None - try: - import inspect - - source = inspect.getsourcefile(value) - source_path = Path(source) if source is not None else None - except (OSError, TypeError): - source_path = None - return { - "module": getattr(value, "__module__", None), - "qualname": getattr(value, "__qualname__", value.__name__), - "rule_id": getattr(value, "rule_id", None), - "source_sha256": _file_sha256(source_path), - } - - -def _jsonable_policy_value(value: Any) -> Any: - if value is None or isinstance(value, str | int | float | bool): - return value - if isinstance(value, Enum): - return value.value - if isinstance(value, Mapping): - return {str(k): _jsonable_policy_value(v) for k, v in sorted(value.items(), key=lambda item: str(item[0]))} - if isinstance(value, tuple | list): - return [_jsonable_policy_value(v) for v in value] - if isinstance(value, set | frozenset): - rendered = [_jsonable_policy_value(v) for v in value] - return sorted(rendered, key=lambda item: json.dumps(item, sort_keys=True, default=str)) - if isinstance(value, type): - return _class_policy_identity(value) - if is_dataclass(value) and not isinstance(value, type): - return {field.name: _jsonable_policy_value(getattr(value, field.name)) for field in fields(value)} - if callable(value): - return _callable_policy_identity(value) - return repr(value) - - -def _pack_policy_identity(name: str, module: Any) -> dict[str, Any]: - if not isinstance(module, ModuleType): - return {"name": name, "loaded": False, "module_repr": repr(module)} - origin = _module_origin(module) - return { - "name": name, - "loaded": True, - "module": getattr(module, "__name__", name), - "version": getattr(module, "__version__", None), - "source_sha256": _file_sha256(origin), - "config": _jsonable_policy_value(getattr(module, "config", None)), - "grammar": _jsonable_policy_value(getattr(module, "grammar", None)), - } - - -def _effective_scan_policy(config: WardlineConfig) -> dict[str, Any]: - return { - "schema": "wardline-effective-scan-policy-v1", - "wardline_version": __version__, - "source_roots": list(config.source_roots), - "exclude": list(config.exclude), - "rules": { - "enable": sorted(config.rules_enable), - "severity": {str(k): str(v) for k, v in sorted(config.rules_severity.items())}, - }, - "provenance_clash": config.provenance_clash, - "untrusted_sources": sorted(config.untrusted_sources), - "sanitisers": sorted(config.sanitisers), - "packs": [_pack_policy_identity(name, config.pack_modules.get(name)) for name in config.packs], - } - - -def ruleset_hash(config: WardlineConfig) -> str: - """A deterministic ``"sha256:"`` over the effective scan policy. - - The signed identity covers the analyzer version, source scope, excludes, rule - enablement/severity, provenance policy, custom source/sanitiser trust semantics, - and trusted pack identity/config/grammar. Two attestations with the same hash are - therefore comparable evidence bundles under the policy inputs that materially shape - scan results. - """ - canonical = json.dumps(_effective_scan_policy(config), sort_keys=True, separators=(",", ":")) - digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() - return f"sha256:{digest}" - - def _canonical_bytes(payload: dict[str, Any]) -> bytes: """THE canonical serialization used for payload reproducibility. @@ -273,7 +149,9 @@ def _enrich_seis(boundaries: list[dict[str, Any]], loomweave_client: Any) -> str resolved_any = False for boundary in boundaries: try: - binding = resolve_entity_binding(loomweave_client, resolver, boundary["qualname"]) + # Boundaries come from the Python AnalysisContext (declared_qualnames), + # so the producer is known — send the ADR-036 plugin hint. + binding = resolve_entity_binding(loomweave_client, resolver, boundary["qualname"], plugin="python") except Exception: # noqa: BLE001 — per-boundary fail-soft, see docstring continue if binding is not None and binding.sei: diff --git a/src/wardline/core/attest_key.py b/src/wardline/core/attest_key.py index 63357afc..1ccbae43 100644 --- a/src/wardline/core/attest_key.py +++ b/src/wardline/core/attest_key.py @@ -9,12 +9,30 @@ import hashlib import os import secrets +import subprocess from contextlib import suppress from pathlib import Path +from wardline.core.errors import WardlineError from wardline.core.safe_paths import safe_project_file WARDLINE_ATTEST_KEY_ENV = "WARDLINE_ATTEST_KEY" +_SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") + + +def _git_tracks_path(root: Path, target: Path) -> bool: + try: + root_resolved = root.resolve() + relpath = target.resolve(strict=False).relative_to(root_resolved).as_posix() + result = subprocess.run( + ["git", *_SAFE_GIT_CONFIG, "ls-files", "--error-unmatch", "--", relpath], + cwd=root_resolved, + capture_output=True, + text=True, + ) + except (OSError, ValueError): + return False + return result.returncode == 0 def load_attest_key(root: Path) -> str | None: @@ -52,10 +70,16 @@ def mint_attest_key(root: Path) -> tuple[str, str]: if existing: return existing, "present" - key = secrets.token_hex(32) - # --- write to .env -------------------------------------------------- env_path = safe_project_file(root, root / ".env", label=".env") + if _git_tracks_path(root, env_path): + raise WardlineError( + "refusing to mint WARDLINE_ATTEST_KEY into tracked .env; " + "untrack .env or pass --no-attest-key and provide WARDLINE_ATTEST_KEY from the environment" + ) + + key = secrets.token_hex(32) + if env_path.exists(): text = env_path.read_text(encoding="utf-8") if not text.endswith("\n"): diff --git a/src/wardline/core/autofix.py b/src/wardline/core/autofix.py index b4939fd0..f8660bd3 100644 --- a/src/wardline/core/autofix.py +++ b/src/wardline/core/autofix.py @@ -98,12 +98,18 @@ def run_autofix( Returns a mapping of relative_path -> list of description strings of applied fixes. """ applied: dict[str, list[str]] = defaultdict(list) + # Resolve once up front: the MCP server passes the literal `--root .`, and + # relativizing a resolved file path against the UNRESOLVED root raises + # ValueError ("is not in the subpath of '.'") — dogfood-4 A1, the crash that + # made the only autofix verb unusable. Every comparison below works on the + # resolved root. + root = root.resolve() # Group findings by file path (resolved relative to root) by_file: dict[Path, list[Finding]] = defaultdict(list) for f in findings: if f.rule_id == "PY-WL-111" and f.location.path: full_path = (root / f.location.path).resolve() - if full_path.is_relative_to(root.resolve()): + if full_path.is_relative_to(root): by_file[full_path].append(f) exception_name = config.boundary_exception diff --git a/src/wardline/core/baseline.py b/src/wardline/core/baseline.py index 18e78b00..c0b4055c 100644 --- a/src/wardline/core/baseline.py +++ b/src/wardline/core/baseline.py @@ -15,10 +15,17 @@ from typing import Any from wardline.core.errors import ConfigError -from wardline.core.finding import Finding, Kind, Severity, SuppressionState +from wardline.core.finding import ( + FINGERPRINT_SCHEME, + Finding, + Kind, + Severity, + SuppressionState, + require_fingerprint_scheme, +) from wardline.core.optional_deps import require_yaml from wardline.core.paths import baseline_path as baseline_file -from wardline.core.safe_paths import safe_project_file +from wardline.core.safe_paths import safe_write_text, write_text_no_follow BASELINE_VERSION: int = 1 """Bumped on a format change; validated on load (mirrors STDLIB_TAINT_VERSION).""" @@ -52,6 +59,7 @@ def build_baseline_document(findings: Iterable[Finding]) -> dict[str, Any]: key=lambda f: (_SEVERITY_SORT[f.severity], f.rule_id, f.location.path, f.fingerprint), ) return { + "fingerprint_scheme": FINGERPRINT_SCHEME, "version": BASELINE_VERSION, "entries": [ {"fingerprint": f.fingerprint, "rule_id": f.rule_id, "path": f.location.path, "message": f.message} @@ -62,13 +70,13 @@ def build_baseline_document(findings: Iterable[Finding]) -> dict[str, Any]: def write_baseline(path: Path, findings: Iterable[Finding], root: Path | None = None) -> None: yaml = require_yaml("writing baseline.yaml") - if root is not None: - path = safe_project_file(root, path, label=path.name) - path.parent.mkdir(parents=True, exist_ok=True) text = yaml.safe_dump( build_baseline_document(findings), sort_keys=False, default_flow_style=False, allow_unicode=True ) - path.write_text(text, encoding="utf-8") + if root is not None: + safe_write_text(root, path, text, label=path.name) + else: + write_text_no_follow(path, text, label=path.name) def collect_and_write_baseline( @@ -111,7 +119,13 @@ def collect_and_write_baseline( strict_defaults=strict_defaults, ) to_baseline = [f for f in result.findings if f.kind is Kind.DEFECT and f.suppressed is not SuppressionState.WAIVED] - write_baseline(baseline_path, to_baseline, root=root) + # baseline_path is root-PREFIXED (weft_state_dir(root)/baseline.yaml). Pass it to the + # root-confined writer as an ABSOLUTE path: a relative `root` (e.g. `wardline baseline + # create pkg`) makes baseline_path `pkg/.weft/.../baseline.yaml`, which safe_write_text + # would resolve under `pkg` AGAIN (`pkg/pkg/.weft/...`) — writing a baseline the next + # scan of `pkg` never loads. .resolve() is idempotent for the absolute store_dir-override + # form. run_scan still gets the original `root`, so finding paths are unchanged. + write_baseline(baseline_path.resolve(), to_baseline, root=root) return to_baseline @@ -160,6 +174,8 @@ def _build_baseline(raw: Any, name: str = "baseline.yaml") -> Baseline: raise ConfigError(f"{name}: must be a mapping at top level") if not raw: return Baseline(frozenset()) + # Loader order is load-bearing: empty-guard (above) → scheme → version. + require_fingerprint_scheme(raw, store_name=name) if raw.get("version") != BASELINE_VERSION: raise ConfigError(f"{name}: version mismatch — expected {BASELINE_VERSION}, got {raw.get('version')!r}") entries = raw.get("entries") diff --git a/src/wardline/core/config.py b/src/wardline/core/config.py index 87b23a2c..64d3212b 100644 --- a/src/wardline/core/config.py +++ b/src/wardline/core/config.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import keyword import os import tomllib @@ -19,6 +20,7 @@ legacy_sibling_dir, sibling_state_dir, ) +from wardline.core.safe_paths import safe_read_text_if_regular def validate_boundary_exception_name(value: str) -> str: @@ -266,10 +268,10 @@ def _read_published_port(root: Path, sibling: str) -> int | None: during the federation transition window. Returns a valid port or ``None`` (missing / unreadable / malformed / out-of-range) — fail-soft.""" for base in (sibling_state_dir(root, sibling), legacy_sibling_dir(root, sibling)): - try: - raw = (base / "ephemeral.port").read_text(encoding="ascii").strip() - except (OSError, UnicodeDecodeError): + text = safe_read_text_if_regular(root, base / "ephemeral.port", label="ephemeral.port", encoding="ascii") + if text is None: continue + raw = text.strip() # Guard int(): isdigit() is a superset of what int() parses, so an # all-digit payload over CPython's 4300-digit cap raises ValueError (the # ascii read above already excludes Unicode digits). Catch it so a planted @@ -301,20 +303,112 @@ def _loomweave_published_url(root: Path) -> str | None: return f"http://127.0.0.1:{port}" if port is not None else None +def _filigree_server_config_path() -> Path: + """Path to Filigree's server-mode global registry (its own ``SERVER_CONFIG_FILE``). + + Server mode runs ONE daemon for many projects on a single shared port, so it + publishes no per-project ``ephemeral.port``; instead it records each project's + store dir → ``{prefix}`` and the shared port here. Mirrors + ``filigree.server.SERVER_CONFIG_FILE`` and ``filigree.scanner_callback``'s + server-mode resolution. A function (not a constant) so tests can redirect it + hermetically.""" + return Path.home() / ".config" / "filigree" / "server.json" + + +def _filigree_server_scope(root: Path) -> tuple[int, str] | None: + """``(port, prefix)`` for *root* from Filigree's server-mode registry, or ``None``. + + Reads :func:`_filigree_server_config_path` and matches *root*'s Filigree store + dir (``.weft/filigree`` preferred, legacy ``.filigree`` tolerated) against the + registered project paths. Returns the shared daemon port and this project's + routing prefix when the project is server-registered; ``None`` when the file is + absent/corrupt or the project is not registered (ethereal/solo). We *read* the + registry — never derive. Fail-soft: never raises.""" + try: + data = json.loads(_filigree_server_config_path().read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + port = data.get("port") + # bool is an int subclass; a JSON ``true`` is not a port. + if not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535): + return None + projects = data.get("projects") + if not isinstance(projects, dict): + return None + # Only an in-root, NON-symlink sibling store may claim a registered scope. A + # legitimately server-registered project's `.weft/filigree` is a real directory + # filigree created; a repository-controlled SYMLINK there could point at another + # registered project's store, making this scan auto-select that project's scoped + # URL and emit its findings into the wrong project. Skip symlinked stores and any + # whose resolved path escapes root (a `.weft` parent-symlink escape). + root_resolved = root.resolve() + candidates: set[Path] = set() + for base in (sibling_state_dir(root, "filigree"), legacy_sibling_dir(root, "filigree")): + if base.is_symlink(): + continue + try: + resolved = base.resolve() + except (OSError, RuntimeError): + continue + if resolved == root_resolved or resolved.is_relative_to(root_resolved): + candidates.add(resolved) + for store_path, meta in projects.items(): + if not isinstance(store_path, str) or not isinstance(meta, dict): + continue + try: + registered = Path(store_path).resolve() + except (OSError, ValueError, RuntimeError): + continue + if registered in candidates: + prefix = meta.get("prefix") + if isinstance(prefix, str) and prefix.strip(): + return port, prefix.strip() + return None + + +def filigree_server_scoped_url(root: Path) -> str | None: + """The project-scoped Weft scan-results URL when Filigree runs in *server* mode + for *root*, else ``None``. + + Server-mode Filigree fail-closes an unscoped ``/api/weft/scan-results`` write + (its N1 ``ProjectMiddleware``), so the federation write MUST carry the project + scope ``/api/p/{prefix}/``. ``wardline install`` persists this into ``.mcp.json`` + so the scoped target is explicit/inspectable; :func:`_filigree_published_url` + re-derives it live for a bare entry. ``None`` in ethereal/solo mode (the + per-project ``ephemeral.port`` rung's unscoped URL is correct there).""" + scope = _filigree_server_scope(root) + if scope is None: + return None + port, prefix = scope + return f"http://localhost:{port}/api/p/{prefix}/weft/scan-results" + + def _filigree_published_url(root: Path) -> str | None: - """Filigree's live Weft scan-results URL from its published ``ephemeral.port``. + """Filigree's live Weft scan-results URL, project-scoped when it runs in server mode. + + Server mode first: one shared daemon serves many projects on a single port and + publishes NO per-project ``ephemeral.port``, recording each project's scope in + the home-global registry instead (:func:`filigree_server_scoped_url`). The scope + is mandatory there — an unscoped write fail-closes (N1) — so the registry rung + outranks the bare per-project port. - Twin of :func:`_loomweave_published_url` (Loomweave **ADR-044**): Filigree - writes its live bound port on a successful loopback bind. We *read* it — never - derive or guess. Prefers ``.weft/filigree/ephemeral.port`` and falls back to - the legacy ``.filigree/ephemeral.port``. Fail-soft on any defect. + Otherwise, the per-project published-port half of Loomweave **ADR-044** applies: + Filigree writes its live bound port on a successful loopback bind; we *read* it — + never derive. Prefers ``.weft/filigree/ephemeral.port`` and falls back to the + legacy ``.filigree/ephemeral.port``. Fail-soft on any defect. Unlike Loomweave's bare-origin contract, Filigree's URL carries the full Weft route, so this returns the route-suffixed - ``http://localhost:/api/weft/scan-results`` (loopback by construction). - The ``localhost`` host self-heals transparently over an install-stamped literal - — Filigree's loopback spelling, distinct from Loomweave's ``127.0.0.1``. + ``http://localhost:/api/[p//]weft/scan-results`` (loopback by + construction). The ``localhost`` host self-heals transparently over an + install-stamped literal — Filigree's loopback spelling, distinct from + Loomweave's ``127.0.0.1``. """ + scoped = filigree_server_scoped_url(root) + if scoped is not None: + return scoped port = _read_published_port(root, "filigree") return f"http://localhost:{port}/api/weft/scan-results" if port is not None else None diff --git a/src/wardline/core/decorator_coverage.py b/src/wardline/core/decorator_coverage.py index eded7f75..ce4d7744 100644 --- a/src/wardline/core/decorator_coverage.py +++ b/src/wardline/core/decorator_coverage.py @@ -139,7 +139,13 @@ def _locator_for(qualname: str) -> str: def _decorators_of(entity: Entity) -> list[str]: - return [f"@{ast.unparse(dec)}" for dec in entity.node.decorator_list] + decorators: list[str] = [] + for dec in entity.node.decorator_list: + try: + decorators.append(f"@{ast.unparse(dec)}") + except RecursionError: + decorators.append(f"@") + return decorators def _identity_for(provider: BindingProvider | None, qualname: str) -> tuple[IdentityCoverage, EntityBinding | None]: diff --git a/src/wardline/core/discovery.py b/src/wardline/core/discovery.py index 96df0ee2..dc7a9c2c 100644 --- a/src/wardline/core/discovery.py +++ b/src/wardline/core/discovery.py @@ -4,18 +4,71 @@ from __future__ import annotations import fnmatch +import os import warnings from collections.abc import Iterable from pathlib import Path from wardline.core.config import WardlineConfig from wardline.core.errors import ConfigError +from wardline.core.gitignore import GitignoreMatcher -_ALWAYS_SKIP = frozenset({"__pycache__", ".venv", "venv", ".git", ".mypy_cache"}) +_ALWAYS_SKIP = frozenset( + { + "__pycache__", + ".venv", + "venv", + ".git", + ".mypy_cache", + ".uv-cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".nox", + "node_modules", + ".eggs", + "build", + "dist", + } +) +# Glob-shaped floor entries (matched against a single directory NAME, not a path). +# ``*.egg-info`` is the canonical packaging-metadata tree that bloats a project-root +# scan; it is a name pattern, so it cannot live in the exact-name ``_ALWAYS_SKIP`` set. +_ALWAYS_SKIP_GLOBS = ("*.egg-info",) -def discover(root: Path, config: WardlineConfig, *, confine_to_root: bool = False) -> list[Path]: + +def _is_floored_dir(name: str, skip_dirs: frozenset[str]) -> bool: + return name in skip_dirs or any(fnmatch.fnmatch(name, pat) for pat in _ALWAYS_SKIP_GLOBS) + + +def discover( + root: Path, + config: WardlineConfig, + *, + confine_to_root: bool = False, + suffixes: frozenset[str] = frozenset({".py"}), +) -> list[Path]: + """Discover source files under the configured roots. + + ``suffixes`` selects the language: the default ``{".py"}`` is byte-identical to + the original Python-only sweep; a Rust frontend passes ``{".rs"}``. Files across + all requested suffixes are gathered and yielded in one combined sorted order, so + finding/entity order stays deterministic and the single-suffix Python case is + unchanged. + """ root = root.resolve() + # `target` is cargo build output — skip it only in `.rs` mode. It is a legitimate + # Python package name, so adding it to the global skip set would silently under-scan + # Python projects (the very failure wardline surfaces loudly elsewhere). + skip_dirs = _ALWAYS_SKIP | {"target"} if ".rs" in suffixes else _ALWAYS_SKIP + # Read the project-root .gitignore ONCE so a multi-GB gitignored tree (third-party + # deps, build output) is never descended. Per-directory .gitignore files are layered + # in as the top-down walk reaches them, mirroring git's nested-ignore semantics. The + # base is an empty matcher (not None) so a project with ONLY nested .gitignore files + # still gets directory pruning. + root_gitignore = root / ".gitignore" + root_ignore = GitignoreMatcher.from_file(root_gitignore) if root_gitignore.is_file() else GitignoreMatcher.empty() found: list[Path] = [] for src in config.source_roots: base = (root / src).resolve() @@ -29,9 +82,27 @@ def discover(root: Path, config: WardlineConfig, *, confine_to_root: bool = Fals if not base.exists(): warnings.warn(f"source root does not exist: {base}", stacklevel=2) continue - for path in sorted(base.rglob("*.py")): + ignore_under_root = root_ignore if base.is_relative_to(root) else None + # Per-directory ignore layers, keyed by the dir's POSIX path relative to root. + dir_ignores: dict[str, GitignoreMatcher] = {} + candidates: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(base): + current = Path(dirpath) + ignore = _ignore_for(current, root, ignore_under_root, dir_ignores) + kept: list[str] = [] + for dirname in sorted(dirnames): + if _is_floored_dir(dirname, skip_dirs): + continue + if ignore is not None and _gitignored_dir(current / dirname, root, ignore): + continue + kept.append(dirname) + dirnames[:] = kept + for filename in sorted(filenames): + if any(filename.endswith(suffix) for suffix in suffixes): + candidates.append(current / filename) + for path in candidates: rel_parts = path.relative_to(base).parts if path.is_relative_to(base) else path.parts - if any(part in _ALWAYS_SKIP for part in rel_parts): + if any(_is_floored_dir(part, skip_dirs) for part in rel_parts): continue if confine_to_root and not path.resolve().is_relative_to(root): # A *.py symlink inside a legitimate source_root can point at an @@ -48,6 +119,37 @@ def discover(root: Path, config: WardlineConfig, *, confine_to_root: bool = Fals return found +def _ignore_for( + current: Path, + root: Path, + base_ignore: GitignoreMatcher | None, + dir_ignores: dict[str, GitignoreMatcher], +) -> GitignoreMatcher | None: + """Return the effective gitignore matcher for ``current``: the root .gitignore + layered with every parent-directory .gitignore reached so far, plus ``current``'s + own. Returns ``None`` when the directory is outside the gitignore base (root).""" + if base_ignore is None or not current.is_relative_to(root): + return None + relposix = current.relative_to(root).as_posix() + if relposix in dir_ignores: + return dir_ignores[relposix] + if relposix in (".", ""): + parent_matcher = base_ignore + else: + parent_rel = current.parent.relative_to(root).as_posix() + parent_matcher = dir_ignores.get(parent_rel, base_ignore) + local = current / ".gitignore" + matcher = parent_matcher.extend(GitignoreMatcher.from_file(local)) if local.is_file() else parent_matcher + dir_ignores[relposix] = matcher + return matcher + + +def _gitignored_dir(child: Path, root: Path, ignore: GitignoreMatcher) -> bool: + if not child.is_relative_to(root): + return False + return ignore.match(child.relative_to(root).as_posix(), is_dir=True) + + def missing_source_roots(root: Path, config: WardlineConfig, *, confine_to_root: bool = False) -> list[str]: """Return the configured ``source_roots`` that do not exist on disk. diff --git a/src/wardline/core/dossier.py b/src/wardline/core/dossier.py index 3e4307a8..fe8cf598 100644 --- a/src/wardline/core/dossier.py +++ b/src/wardline/core/dossier.py @@ -21,8 +21,9 @@ * **SEI is opaque.** It is carried verbatim as the binding key, never parsed. * **No false-green.** An absent/unreachable source yields an *honest partial* section (``available=False`` + reason), never fabricated data and never a crash. - Over-budget content is trimmed with an EXPLICIT, elision-honest truncation marker - (shown-of-total) — a silent cap reads as "covered everything" when it did not. + Over-budget content is trimmed or compacted with an EXPLICIT, elision-honest + truncation marker (shown-of-total for lists, a visible marker for scalar text) — + a silent cap reads as "covered everything" when it did not. * **Zero-dependency base.** Stdlib only; no tokenizer, no blake3, no extras. """ @@ -39,6 +40,7 @@ from wardline.core.errors import DossierError from wardline.core.finding import UNANALYZED_RULE_IDS, Kind, SuppressionState from wardline.core.identity import ContentStatus, EntityBinding, IdentityStatus +from wardline.core.paths import enclosing_project_root from wardline.core.run import run_scan from wardline.core.taints import TaintState @@ -350,8 +352,8 @@ def estimated_tokens(self) -> int: # --- token budgeting -------------------------------------------------------- # Lists are trimmed in this order (lowest value first) until the envelope fits. -# Identity/shape/trust-verdict are never trimmed — only the variable-length lists -# and finally the synthesis prose. +# Scalar identity/shape/trust-verdict fields are not dropped, but oversized strings +# are compacted after lower-value lists and synthesis have been trimmed. _LIST_TRIM_ORDER = ( "linkages.scc_peers", "linkages.callers", @@ -359,6 +361,8 @@ def estimated_tokens(self) -> int: "work.tickets", "trust.active_findings", ) +_CORE_TEXT_TRIM_STEPS = (512, 256, 128, 64) +_TRUNCATED_TEXT_MARKER = "......" def _list_for(dossier: EntityDossier, key: str) -> list[Any]: @@ -372,18 +376,80 @@ def _with_list(dossier: EntityDossier, key: str, items: list[Any]) -> EntityDoss return dataclasses.replace(dossier, **{section: new_section}) +def _truncate_text(value: str | None, max_chars: int) -> tuple[str | None, bool]: + if value is None or len(value) <= max_chars: + return value, False + if max_chars <= len(_TRUNCATED_TEXT_MARKER): + return _TRUNCATED_TEXT_MARKER[:max_chars], True + keep = max_chars - len(_TRUNCATED_TEXT_MARKER) + head = (keep + 1) // 2 + tail = keep // 2 + suffix = value[-tail:] if tail else "" + return f"{value[:head]}{_TRUNCATED_TEXT_MARKER}{suffix}", True + + +def _compact_core_text(dossier: EntityDossier, *, max_chars: int) -> tuple[EntityDossier, set[str]]: + changed: set[str] = set() + qualname, qualname_truncated = _truncate_text(dossier.identity.qualname, max_chars) + if qualname_truncated: + changed.add("identity.qualname") + kind, kind_truncated = _truncate_text(dossier.identity.kind, max_chars) + if kind_truncated: + changed.add("identity.kind") + path, path_truncated = _truncate_text(dossier.identity.path, max_chars) + if path_truncated: + changed.add("identity.path") + sei, sei_truncated = _truncate_text(dossier.identity.sei, max_chars) + if sei_truncated: + changed.add("identity.sei") + content_hash, content_hash_truncated = _truncate_text(dossier.identity.content_hash, max_chars) + if content_hash_truncated: + changed.add("identity.content_hash") + + signature, signature_truncated = _truncate_text(dossier.shape.signature, max_chars) + if signature_truncated: + changed.add("shape.signature") + + decorators: list[str] = [] + decorators_changed = False + for decorator in dossier.shape.decorators: + truncated, was_truncated = _truncate_text(decorator, max_chars) + decorators.append(truncated or "") + decorators_changed = decorators_changed or was_truncated + if decorators_changed: + changed.add("shape.decorators") + + identity = dataclasses.replace( + dossier.identity, + qualname=qualname or "", + kind=kind, + path=path, + sei=sei, + content_hash=content_hash, + ) + shape = dataclasses.replace( + dossier.shape, + signature=signature, + decorators=decorators if decorators_changed else dossier.shape.decorators, + ) + return dataclasses.replace(dossier, identity=identity, shape=shape), changed + + def bound_to_budget(dossier: EntityDossier, *, budget: int = DOSSIER_TOKEN_BUDGET) -> EntityDossier: """Return a copy of *dossier* whose estimated token size fits ``budget``, trimming variable-length lists (and finally the synthesis prose) in priority order and recording every elision honestly in :class:`Truncation`. - Identity and shape are never trimmed — they are the load-bearing minimum. If a - list is trimmed, ``Truncation.elided`` reports its shown-of-total counts so a - caller always knows something was dropped (no silent cap / false-green).""" + Identity and shape are never dropped — they are the load-bearing minimum — but + attacker-controlled scalar source text is compacted when it alone would exceed + the budget. If a list is trimmed, ``Truncation.elided`` reports its shown-of-total + counts so a caller always knows something was dropped (no silent cap / + false-green).""" if dossier.estimated_tokens() <= budget: return dataclasses.replace(dossier, truncation=Truncation.none()) totals = {key: len(_list_for(dossier, key)) for key in _LIST_TRIM_ORDER} + shape_decorator_total = len(dossier.shape.decorators) current = dossier # Pass 1: shrink lists, cheapest-value first, halving until the envelope fits. @@ -398,23 +464,51 @@ def bound_to_budget(dossier: EntityDossier, *, budget: int = DOSSIER_TOKEN_BUDGE current = dataclasses.replace(current, synthesis=None) synthesis_dropped = True + # Pass 3: decorators are shape, so keep them for synthesis-only overflow; trim + # them only when the remaining shape itself is still responsible for the excess. + while current.estimated_tokens() > budget and current.shape.decorators: + current = dataclasses.replace( + current, + shape=dataclasses.replace( + current.shape, + decorators=current.shape.decorators[: len(current.shape.decorators) // 2], + ), + ) + + # Pass 4: if the remaining load-bearing core is still too large, compact scalar + # text fields rather than returning an unbounded source-controlled envelope. + scalar_compacted: set[str] = set() + if current.estimated_tokens() > budget: + for max_chars in _CORE_TEXT_TRIM_STEPS: + current, compacted = _compact_core_text(current, max_chars=max_chars) + scalar_compacted.update(compacted) + if current.estimated_tokens() <= budget: + break + elided = [ ElidedSection(section=key, shown=len(_list_for(current, key)), total=totals[key]) for key in _LIST_TRIM_ORDER if len(_list_for(current, key)) < totals[key] ] + if len(current.shape.decorators) < shape_decorator_total: + elided.append( + ElidedSection(section="shape.decorators", shown=len(current.shape.decorators), total=shape_decorator_total) + ) note_bits = [] if elided: note_bits.append("lists trimmed to fit token budget") if synthesis_dropped: note_bits.append("synthesis dropped to fit token budget") - # Honest about a fit we did NOT achieve: identity/shape are never trimmed, so a - # pathologically long qualname/path can leave the core over budget. Say so rather - # than claiming "trimmed to fit" — a dishonest marker is itself a false-green. + if scalar_compacted: + fields = ", ".join(sorted(scalar_compacted)) + note_bits.append(f"core scalar fields compacted to fit token budget: {fields}") + # Honest about a fit we did NOT achieve after all trimmable content has been + # removed/compacted. This should be rare with the default budget, but the marker + # must not claim success if a caller supplies a smaller-than-schema budget. remaining = current.estimated_tokens() if remaining > budget: note = ( - f"EXCEEDS token budget: untrimmable identity/shape ~{remaining} of {budget} tokens; " + f"EXCEEDS token budget after all trimmable content: ~{remaining} of {budget} tokens; " "nothing further is trimmable" ) else: @@ -629,6 +723,41 @@ def _synthesize(identity: IdentitySection, trust: TrustSection, linkages: Linkag return " ".join(bits) +def _entity_not_found_message(entity: str, root: Path, context: AnalysisContext | None) -> str: + """The entity-not-found error, with the resolve-against-scan-root remedy. + + N-8 (folded into wardline-8669de3576): qualnames are minted relative to the + scan root, so a dossier rooted in a SUBDIRECTORY of a weft project rejects the + package-qualified qualname the project-rooted call (the MCP server's shape) + accepts. When that is detectable, teach the coupling and name both remedies — + the scan-relative form that DOES match under this root, and the project root + to rerun against. We deliberately do NOT auto-resolve: silently scanning a + different root would change suppression state and mint identities the caller + did not ask for (the exact silent divergence N-3 is about). + """ + base = f"entity not found in scanned set: {entity}" + enclosing = enclosing_project_root(root) + if enclosing is None: + return base + rel = root.resolve().relative_to(enclosing) + parts = rel.parts[1:] if rel.parts and rel.parts[0] == "src" else rel.parts + prefix = ".".join(parts) + coupling = ( + f"{base}. Qualnames are minted relative to the scan root, and {root.resolve()} is a " + f"subdirectory of the weft project at {enclosing}." + ) + stripped = entity[len(prefix) + 1 :] if prefix and entity.startswith(prefix + ".") else None + if stripped and context is not None and stripped in context.entities: + return ( + f"{coupling} '{stripped}' matches under this scan root; for the package-qualified " + f"form rerun against the project root: wardline dossier {entity} {enclosing}" + ) + return ( + f"{coupling} Rerun against the project root for package-qualified qualnames: " + f"wardline dossier {entity} {enclosing}" + ) + + def build_dossier( entity: str, *, @@ -654,7 +783,7 @@ def build_dossier( result = run_scan(root, config_path=config_path, confine_to_root=confine_to_root) context = result.context if context is None or entity not in context.entities: - raise DossierError(f"entity not found in scanned set: {entity}") + raise DossierError(_entity_not_found_message(entity, root, context)) target = context.entities[entity] identity = _build_identity(target, binding) diff --git a/src/wardline/core/emit.py b/src/wardline/core/emit.py index d6a8a709..3174ce1d 100644 --- a/src/wardline/core/emit.py +++ b/src/wardline/core/emit.py @@ -8,6 +8,7 @@ from typing import Protocol from wardline.core.finding import Finding +from wardline.core.safe_paths import safe_write_text, write_text_no_follow class Sink(Protocol): @@ -15,12 +16,13 @@ def write(self, findings: Sequence[Finding]) -> None: ... class JsonlSink: - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, *, root: Path | None = None) -> None: self._path = path + self._root = root def write(self, findings: Sequence[Finding]) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - with self._path.open("w", encoding="utf-8") as handle: - for finding in findings: - handle.write(finding.to_jsonl()) - handle.write("\n") + content = "".join(f"{finding.to_jsonl()}\n" for finding in findings) + if self._root is not None: + safe_write_text(self._root, self._path, content, label=self._path.name) + else: + write_text_no_follow(self._path, content, label=self._path.name) diff --git a/src/wardline/core/errors.py b/src/wardline/core/errors.py index 12a4c1d7..b7c64f85 100644 --- a/src/wardline/core/errors.py +++ b/src/wardline/core/errors.py @@ -9,6 +9,30 @@ class ConfigError(WardlineError): """Raised when weft.toml [wardline] is malformed or invalid.""" +class SchemeMismatchError(ConfigError): + """A store's fingerprint scheme does not match this build's (P1 scheme-infra). + + Raised at store-LOAD time when a baseline/judged/waivers file carries a + ``fingerprint_scheme`` header that differs from (or is absent vs.) the scheme + this build computes. Joining stale-scheme fingerprints would silently orphan + every verdict, so we fail LOUD with the file name and the actionable next + step. Subclasses ``ConfigError`` so existing ``except ConfigError`` handling + and the CLI exit-2 mapping apply unchanged. + """ + + def __init__(self, *, store_name: str, found: str | None, expected: str) -> None: + self.store_name = store_name + self.found = found + self.expected = expected + found_desc = "missing" if found is None else repr(found) + super().__init__( + f"{store_name}: fingerprint scheme {found_desc} does not match this build's " + f"{expected!r}. Run `wardline rekey` to migrate this project's stores " + f"(or `wardline rekey --resume` to finish an interrupted migration; " + f"pre-1.0: no automatic upgrade)." + ) + + class DiscoveryError(WardlineError): """Raised when source discovery cannot proceed.""" @@ -49,6 +73,13 @@ class LegisArtifactError(WardlineError): operator must act on (CLI → exit 2; MCP → isError result).""" +class RustToolingError(WardlineError): + """The Rust frontend's tree-sitter toolchain (the ``wardline[rust]`` extra) is + unavailable. Raised lazily by ``wardline.rust._tree_sitter.require_rust`` with + an actionable install hint — the base package and the ``scanner`` extra never + import tree-sitter, so a bare install stays zero-dependency.""" + + class DossierError(WardlineError): """A dossier tool-execution fault the agent must act on: the requested entity is not in the scanned set, or its module could not be analysed. Optional-source diff --git a/src/wardline/core/explain.py b/src/wardline/core/explain.py index 0d9bbe95..dd139c4b 100644 --- a/src/wardline/core/explain.py +++ b/src/wardline/core/explain.py @@ -11,17 +11,30 @@ from __future__ import annotations +import ast from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any from wardline.core.errors import LoomweaveError from wardline.core.run import run_scan +from wardline.core.taints import RAW_ZONE if TYPE_CHECKING: from wardline.core.finding import Finding from wardline.scanner.context import AnalysisContext +# The C-10(c) honest-degrade marker: when the taint source is not derivable from +# wardline's own single-scan analysis, the response names the capability that could +# resolve it further and how to enable it — never nulls that read as a +# complete-but-empty answer (dogfood-5 B7, weft-0d24cf9152). +LOOMWEAVE_CAPABILITY = "loomweave_taint_store" +LOOMWEAVE_ENABLEMENT = ( + "configure a Loomweave store (--loomweave-url / WARDLINE_LOOMWEAVE_URL / " + "weft.toml [loomweave].url) and call explain_taint with chain=true to walk " + "the stored cross-entity taint chain" +) + @dataclass(frozen=True, slots=True) class TaintExplanation: @@ -36,6 +49,81 @@ class TaintExplanation: source_boundary_qualname: str | None resolved_call_count: int unresolved_call_count: int + # Dotted name of the dangerous sink CALLABLE for call-site-anchored sink findings + # (properties["sink"], e.g. "logging.getLogger.info"); None for return-tier rules + # and on the store-served path (the blob carries no sink property). + sink: str | None = None + + +def _untrusted_project_callee(call: ast.Call, context: AnalysisContext) -> str | None: + """The resolved in-project callee of *call* when its RETURN taint is in the raw + zone (an untrusted source), else None.""" + callee = context.call_site_callees.get(id(call)) + if callee is None: + return None + taint = context.function_return_taints.get(callee, context.project_return_taints.get(callee)) + return callee if taint in RAW_ZONE else None + + +def _sink_arg_exprs(call: ast.Call) -> list[ast.expr]: + return [*call.args, *[kw.value for kw in call.keywords]] + + +def _sink_taint_source(finding: Finding, context: AnalysisContext) -> str | None: + """For a call-site-anchored sink finding, the in-project call that introduced the + untrusted value into the sink's arguments — derived from wardline's OWN analysis + (no store needed): either a raw-returning call INLINE in the sink's argument list + (``logger.info(read_raw(p))``) or the LAST raw-returning call assigned to a name + the sink's arguments use (``msg = read_raw(p); logger.info(msg)``). Returns the + callee's qualname, or None when the source is genuinely not derivable from the + single scan (imported/dynamic sources — the Loomweave chain walk's territory).""" + qualname, line = finding.qualname, finding.location.line_start + if qualname is None or line is None: + return None + entity = context.entities.get(qualname) + if entity is None: + return None + sink_calls = [n for n in ast.walk(entity.node) if isinstance(n, ast.Call) and getattr(n, "lineno", None) == line] + if not sink_calls: + return None + # (a) a raw-returning call nested directly in the sink's own arguments. + for sink in sink_calls: + for arg in _sink_arg_exprs(sink): + for sub in ast.walk(arg): + if isinstance(sub, ast.Call): + callee = _untrusted_project_callee(sub, context) + if callee is not None: + return callee + # (b) a Name argument assigned from a raw-returning call earlier in the function; + # the LAST such assignment before the sink line wins (mirrors L2's forward walk). + arg_names = { + sub.id + for sink in sink_calls + for arg in _sink_arg_exprs(sink) + for sub in ast.walk(arg) + if isinstance(sub, ast.Name) + } + if not arg_names: + return None + best: tuple[int, str] | None = None + for node in ast.walk(entity.node): + targets: list[ast.expr] + value: ast.expr | None + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = list(node.targets) if isinstance(node, ast.Assign) else [node.target] + value = node.value + elif isinstance(node, ast.NamedExpr): + targets, value = [node.target], node.value + else: + continue + if value is None or not isinstance(value, ast.Call) or node.lineno > line: + continue + if not any(isinstance(t, ast.Name) and t.id in arg_names for t in targets): + continue + callee = _untrusted_project_callee(value, context) + if callee is not None and (best is None or node.lineno > best[0]): + best = (node.lineno, callee) + return best[1] if best else None def explanation_from_context(finding: Finding, context: AnalysisContext) -> TaintExplanation: @@ -57,6 +145,23 @@ def explanation_from_context(finding: Finding, context: AnalysisContext) -> Tain candidate = f"{module}.{immediate_tainted_callee}" if candidate in context.entities and context.function_return_callee.get(candidate) is None: source_boundary_qualname = candidate + sink = finding.properties.get("sink") + if immediate_tainted_callee is None and isinstance(sink, str): + # A sink finding's tainted value reaches the sink ARGUMENT, never the return, + # so the return-callee map cannot explain it (B7, weft-0d24cf9152): derive the + # source from the sink's argument flow instead. + source = _sink_taint_source(finding, context) + if source is not None: + immediate_tainted_callee = source.rsplit(".", 1)[-1] + if source in context.entities and context.function_return_callee.get(source) is None: + source_boundary_qualname = source # a leaf source — the 1-hop boundary + tier_in = finding.properties.get("actual_return") + tier_out = finding.properties.get("declared_return") + if isinstance(sink, str): + # Sink findings carry their tier facts as arg_taint (what arrives at the sink) + # and tier (the enclosing declared tier) — project them, not nulls. + tier_in = tier_in or finding.properties.get("arg_taint") + tier_out = tier_out or finding.properties.get("tier") prov = context.taint_provenance.get(qualname) if qualname is not None else None return TaintExplanation( fingerprint=finding.fingerprint, @@ -64,12 +169,13 @@ def explanation_from_context(finding: Finding, context: AnalysisContext) -> Tain sink_qualname=qualname, path=finding.location.path, line=finding.location.line_start, - tier_in=finding.properties.get("actual_return"), - tier_out=finding.properties.get("declared_return"), + tier_in=tier_in, + tier_out=tier_out, immediate_tainted_callee=immediate_tainted_callee, source_boundary_qualname=source_boundary_qualname, resolved_call_count=prov.resolved_call_count if prov is not None else 0, unresolved_call_count=prov.unresolved_call_count if prov is not None else 0, + sink=sink if isinstance(sink, str) else None, ) @@ -125,29 +231,67 @@ def _explain_local( return explanation_from_context(finding, result.context) -def _is_fresh(view: Any) -> bool: - """Fresh iff: exists, a live current_content_hash is present, the blob is a - structurally sound dict (with a dict ``taint``), and the in-blob - content_hash_at_compute equals that live hash. Wardline decides freshness by - comparing the stamp IT wrote against the hash Loomweave read live; Loomweave never - asserts a verdict. Missing hash (file deleted/unreadable), exists:false, or a - malformed/skewed blob (wrong types after the HTTP round-trip) ⇒ stale, so the - caller falls through to the SP8 re-run rather than serving a broken fact.""" - if not view.exists or view.current_content_hash is None: +def _local_content_hash(root: Path, path: str) -> str | None: + """Return the local whole-file blake3 for an in-project relative path.""" + try: + from wardline.loomweave import require_blake3 + + blake3 = require_blake3() + root_resolved = root.resolve() + candidate = Path(path) + local = candidate if candidate.is_absolute() else root / candidate + resolved = local.resolve(strict=True) + if not resolved.is_file() or not resolved.is_relative_to(root_resolved): + return None + return str(blake3.blake3(resolved.read_bytes()).hexdigest()) + except (OSError, ValueError, LoomweaveError): + return None + + +def _is_fresh(view: Any, *, root: Path | None = None, path: str | None = None) -> bool: + """Fresh iff the stored Wardline stamp matches current file bytes. + + When a local ``root`` and selected finding ``path`` are available, freshness is + checked against the local file hash. The remote ``current_content_hash`` is kept + only for legacy chain reads that lack a path; it is not authoritative for + ``explain_finding`` blob serving. + """ + if not view.exists: return False blob = view.wardline_json if not isinstance(blob, dict) or not isinstance(blob.get("taint"), dict): return False stamped = blob.get("content_hash_at_compute") + if root is not None and path is not None: + local_hash = _local_content_hash(root, path) + return isinstance(stamped, str) and local_hash is not None and stamped == local_hash + if view.current_content_hash is None: + return False return stamped is not None and stamped == view.current_content_hash +def _opt_str(value: Any) -> str | None: + """Type-narrow an untrusted store-blob field to string-or-None. + + The blob is external input (hand-editable, version-skewable); the adjacent + fingerprint/rule_id/path/line fields are already isinstance-guarded — taint tiers + and callee qualnames get the same treatment so a type-skewed blob cannot put a + non-string into a payload published as string|null in the MCP outputSchema.""" + return value if isinstance(value, str) else None + + def _callee_leaf(callee_qualname: str | None) -> str | None: """The blob stores the resolved callee QUALNAME; SP8's immediate_tainted_callee is the bare trailing name. Project back for surface parity with the SP8 shape.""" return None if callee_qualname is None else callee_qualname.rsplit(".", 1)[-1] +def _opt_count(value: Any) -> int | None: + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + return None + + def _blob_finding_matches( finding: dict[str, Any], *, @@ -183,12 +327,14 @@ def _select_blob_finding( ), None, ) - return findings[0] if findings else {} + return findings[0] if findings else None def _explanation_from_blob( view: Any, *, + root: Path | None = None, + sink_qualname: str | None = None, fingerprint: str | None = None, path: str | None = None, line: int | None = None, @@ -208,24 +354,32 @@ def _explanation_from_blob( first = _select_blob_finding(finding_rows, fingerprint=fingerprint, path=path, line=line, rule_id=rule_id) if first is None: return None - callee_q = taint.get("contributing_callee_qualname") + callee_q = _opt_str(taint.get("contributing_callee_qualname")) + resolved_call_count = _opt_count(taint.get("resolved_call_count", 0)) + unresolved_call_count = _opt_count(taint.get("unresolved_call_count", 0)) + if resolved_call_count is None or unresolved_call_count is None: + return None stored_fingerprint = first.get("fingerprint") stored_rule_id = first.get("rule_id") stored_path = first.get("path") stored_line = first.get("line_start") qualname = blob.get("qualname") + if sink_qualname is not None and qualname != sink_qualname: + return None + if root is not None and (not isinstance(stored_path, str) or not _is_fresh(view, root=root, path=stored_path)): + return None return TaintExplanation( fingerprint=stored_fingerprint if isinstance(stored_fingerprint, str) else "", rule_id=stored_rule_id if isinstance(stored_rule_id, str) else "", sink_qualname=qualname if isinstance(qualname, str) else None, path=stored_path if isinstance(stored_path, str) else "", line=stored_line if isinstance(stored_line, int) else None, - tier_in=taint.get("actual_return"), - tier_out=taint.get("declared_return"), + tier_in=_opt_str(taint.get("actual_return")), + tier_out=_opt_str(taint.get("declared_return")), immediate_tainted_callee=_callee_leaf(callee_q), source_boundary_qualname=callee_q, - resolved_call_count=int(taint.get("resolved_call_count", 0) or 0), - unresolved_call_count=int(taint.get("unresolved_call_count", 0) or 0), + resolved_call_count=resolved_call_count, + unresolved_call_count=unresolved_call_count, ) @@ -275,12 +429,12 @@ def explain_chain( return TaintChain(hops=hops, truncated_at=current) blob = view.wardline_json or {} taint = blob.get("taint", {}) - next_q = taint.get("contributing_callee_qualname") + next_q = _opt_str(taint.get("contributing_callee_qualname")) hops.append( ChainHop( qualname=current, - tier_in=taint.get("actual_return"), - tier_out=taint.get("declared_return"), + tier_in=_opt_str(taint.get("actual_return")), + tier_out=_opt_str(taint.get("declared_return")), contributing_callee_qualname=next_q, ) ) @@ -322,11 +476,31 @@ def explain_finding( # load-bearing for explain: degrade to the SP8 re-run, never raise. (The # store read is optional enrichment; only the WRITE path surfaces a 4xx.) views = None - if views and views[0].qualname == sink_qualname and _is_fresh(views[0]): - served = _explanation_from_blob(views[0], fingerprint=fingerprint, path=path, line=line) - if served is not None: + if views and views[0].qualname == sink_qualname: + served = _explanation_from_blob( + views[0], + root=root, + sink_qualname=sink_qualname, + fingerprint=fingerprint, + path=path, + line=line, + ) + # Serve the cached fact only when it actually NAMES a source, OR when no + # re-run is possible (a pure-store read keyed on sink_qualname alone has + # no local match key). A blob whose contributing callee is null answers + # nothing for a sink-argument finding (the store records return-taint + # facts), while the SP8 re-run can derive the source from the sink's + # argument flow (B7, weft-0d24cf9152) — correctness over the cached read. + can_rerun = fingerprint is not None or (path is not None and line is not None) + if served is not None and ( + served.immediate_tainted_callee is not None + or served.source_boundary_qualname is not None + or not can_rerun + ): return served - # miss/stale/outage → fall through to the re-run + # miss/stale/outage/sourceless → fall through to the re-run + if fingerprint is None and path is None and line is None: + return None return _explain_local( root, fingerprint=fingerprint, @@ -335,3 +509,234 @@ def explain_finding( config_path=config_path, confine_to_root=confine_to_root, ) + + +def source_resolution_to_dict(exp: TaintExplanation, *, loomweave_configured: bool = False) -> dict[str, Any]: + """The C-10(c) honesty block: is the taint source named, and if not, WHY and what + capability would resolve it further. Fixed key set so an unresolved source is an + explicit degrade marker, never nulls that read as a complete-but-empty answer + (B7, weft-0d24cf9152).""" + if exp.immediate_tainted_callee is not None or exp.source_boundary_qualname is not None: + return {"status": "resolved", "reason": None, "missing_capability": None, "enablement": None} + where = f" in {exp.sink_qualname}" if exp.sink_qualname else "" + reason = ( + "wardline's single-scan analysis could not attribute the tainted value" + f"{where} to a resolvable in-project call (imported or dynamic sources are " + "beyond its single-scan reach)" + ) + if loomweave_configured: + return { + "status": "unresolved", + "reason": reason + "; the configured Loomweave store's facts also carried no contributing callee", + "missing_capability": None, + "enablement": None, + } + return { + "status": "unresolved", + "reason": reason, + "missing_capability": LOOMWEAVE_CAPABILITY, + "enablement": LOOMWEAVE_ENABLEMENT, + } + + +def explanation_to_dict(exp: TaintExplanation, *, loomweave_configured: bool = False) -> dict[str, Any]: + """The serialized explanation slice + remediation hint. Single source for the + MCP ``explain_taint`` result and the CLI ``wardline explain-taint`` output + (identical by construction — the N-2 dead-end was the CLI lacking this).""" + return { + "tier_in": exp.tier_in, + "tier_out": exp.tier_out, + "immediate_tainted_callee": exp.immediate_tainted_callee, + "source_boundary_qualname": exp.source_boundary_qualname, + "source_resolution": source_resolution_to_dict(exp, loomweave_configured=loomweave_configured), + "resolved_call_count": exp.resolved_call_count, + "unresolved_call_count": exp.unresolved_call_count, + "remediation": remediation_to_dict(exp), + } + + +# Rule-specific fix guidance for the call-site-anchored sink family — generic "review +# the finding" text on a SPECIFIC finding is part of the B7 defect. One sentence per +# rule, composed with the named source/sink below. +_SINK_FIX_HINTS: dict[str, str] = { + "PY-WL-106": ( + "Deserialize only trusted bytes: verify provenance or switch to a " + "schema-validated format (e.g. json.loads + validation) before the sink." + ), + "PY-WL-107": ( + "Never pass untrusted text to dynamic execution; replace it with an explicit " + "dispatch table or parser, or strictly allowlist the input first." + ), + "PY-WL-108": ( + "Run a fixed program path and pass untrusted values only as list-form " + "arguments (never through a shell); validate or allowlist them first." + ), + "PY-WL-112": ( + "Avoid shell=True with untrusted input: use list-form arguments without a " + "shell, or quote via shlex.quote after validating." + ), + "PY-WL-115": ( + "Do not import modules named by untrusted input; resolve the name through an " + "explicit allowlist of importable modules." + ), + "PY-WL-116": ( + "Resolve untrusted paths against a fixed base directory and reject escapes " + "(e.g. Path.resolve() + is_relative_to) before opening." + ), + "PY-WL-118": ("Use parameterized queries (placeholders) — never interpolate untrusted values into SQL text."), + "PY-WL-117": ( + "Validate the URL against an allowlist of schemes and hosts before fetching; never fetch a raw caller URL." + ), + "PY-WL-121": ("Parse untrusted XML with entity resolution disabled (e.g. defusedxml) instead of the raw parser."), + "PY-WL-122": ( + "Never compile untrusted text as a template; render untrusted values only as template DATA (context variables)." + ), + "PY-WL-123": ( + "Do not let untrusted input choose attribute names; map allowed names " + "explicitly instead of reflecting raw input." + ), + "PY-WL-124": ( + "Load native libraries only from fixed, vetted paths — never from a path derived from untrusted input." + ), + "PY-WL-125": ( + "Do not use untrusted data as the log message format string: use logging's " + "lazy parameterization (logger.info('value=%s', value)) or strip " + "newline/control characters first." + ), + "PY-WL-126": ("Validate and normalize recipient addresses and message bodies (reject CR/LF) before the mail API."), +} + + +def remediation_to_dict(exp: TaintExplanation) -> dict[str, Any]: + source = exp.source_boundary_qualname or exp.immediate_tainted_callee + hint = _SINK_FIX_HINTS.get(exp.rule_id) + if exp.rule_id != "PY-WL-101" and hint is not None: + sink_call = f"the {exp.sink} sink" if exp.sink else "the sink" + where = f" in {exp.sink_qualname}" if exp.sink_qualname else "" + origin = f"from {source} " if source else "" + return { + "kind": "sink_hygiene", + "rule_id": exp.rule_id, + "summary": f"Untrusted data {origin}reaches {sink_call}{where}. {hint}", + "sink_qualname": exp.sink_qualname, + "source_qualname": source, + "caveat": "This hint is advisory and does not replace the factual taint explanation.", + } + if exp.rule_id != "PY-WL-101": + return { + "kind": "review_required", + "rule_id": exp.rule_id, + "summary": ( + "Review the finding and apply the rule-specific fix; no automated remediation hint is available." + ), + "sink_qualname": exp.sink_qualname, + "source_qualname": source, + "caveat": "This hint is advisory and does not replace the factual taint explanation.", + } + + source = exp.source_boundary_qualname or exp.immediate_tainted_callee + sink = exp.sink_qualname + if source and sink: + summary = ( + f"Validate or normalize data from {source} before it reaches trusted producer {sink}. " + "Add or repair a @trust_boundary only on the function that actually rejects invalid data." + ) + elif sink: + summary = ( + f"Validate or normalize the raw input before it reaches trusted producer {sink}; " + "the taint source is unresolved in this explanation. Add or repair a @trust_boundary only where " + "the code actually rejects invalid data." + ) + else: + summary = ( + "Validate or normalize the raw input before it reaches the trusted producer; the taint source is " + "unresolved in this explanation. Add or repair a @trust_boundary only where the code actually " + "rejects invalid data." + ) + return { + "kind": "boundary_placement", + "rule_id": exp.rule_id, + "summary": summary, + "sink_qualname": sink, + "source_qualname": source, + "caveat": ( + "Do not use blind decorator insertion; mark a trust boundary only on code that validates " + "and rejects invalid data." + ), + } + + +def explain_taint_result( + root: Path, + *, + fingerprint: str | None = None, + path: str | None = None, + line: int | None = None, + config_path: Path | None = None, + confine_to_root: bool = True, + loomweave: Any | None = None, + sink_qualname: str | None = None, + chain: bool = False, + max_hops: int = 20, +) -> dict[str, Any] | None: + """The full ``explain_taint`` result dict shared by the MCP handler and the + CLI command. None means the fingerprint/location is not in the current scan + (the caller maps that to its own error channel — ToolError or exit 2). + + ``chain=True`` additionally walks the full taint chain when a Loomweave + store is configured; without one it degrades silently to the single-hop + explanation (no ``chain`` block), exactly as the MCP tool documents. + """ + exp = explain_finding( + root, + fingerprint=fingerprint, + path=path, + line=line, + config_path=config_path, + confine_to_root=confine_to_root, + loomweave=loomweave, + sink_qualname=sink_qualname, + ) + if exp is None: + return None + result: dict[str, Any] = { + "fingerprint": exp.fingerprint, + "rule_id": exp.rule_id, + "sink_qualname": exp.sink_qualname, + "location": {"path": exp.path, "line": exp.line}, + **explanation_to_dict(exp, loomweave_configured=loomweave is not None), + } + if chain and loomweave is not None and exp.sink_qualname: + ch = explain_chain(root, sink_qualname=exp.sink_qualname, loomweave=loomweave, max_hops=max_hops) + result["chain"] = { + "status": "walked", + "hops": [ + { + "qualname": h.qualname, + "tier_in": h.tier_in, + "tier_out": h.tier_out, + "contributing_callee_qualname": h.contributing_callee_qualname, + } + for h in ch.hops + ], + "truncated_at": ch.truncated_at, + "missing_capability": None, + "enablement": None, + } + elif chain: + # chain=true but the walk cannot run: say so EXPLICITLY (C-10(c)) — an absent + # block was a silent degrade an agent read as "no chain exists". + missing = LOOMWEAVE_CAPABILITY if loomweave is None else "sink_qualname" + enablement = ( + LOOMWEAVE_ENABLEMENT + if loomweave is None + else "this finding carries no qualname for the engine to anchor the walk on; re-scan and use a finding with one" # noqa: E501 + ) + result["chain"] = { + "status": "unavailable", + "hops": [], + "truncated_at": None, + "missing_capability": missing, + "enablement": enablement, + } + return result diff --git a/src/wardline/core/filigree_emit.py b/src/wardline/core/filigree_emit.py index 3da09687..21119ad5 100644 --- a/src/wardline/core/filigree_emit.py +++ b/src/wardline/core/filigree_emit.py @@ -5,13 +5,15 @@ HTTP emitter (``FiligreeEmitter``). stdlib-only; no runtime dependency on Filigree. Federation discipline: a *sibling-absent* network failure warns and continues; a 5xx outage and a 401/403 (Filigree present but refusing bearer auth) are likewise treated -as *enrichment unavailable* (warn + continue). A 400 (Wardline built a bad payload) is -the one loud band — that is our own bug, not the sibling's posture. +as *enrichment unavailable* (warn + continue). Client/protocol errors default loud +for callers that need strict reconciliation, but `wardline scan` opts into fail-soft +enrichment so an upload reject cannot preempt the local gate verdict. """ from __future__ import annotations import json +import os import urllib.error import urllib.parse import urllib.request @@ -20,11 +22,20 @@ from typing import Any, Protocol from wardline.core.errors import FiligreeEmitError -from wardline.core.finding import Finding, severity_to_filigree, to_filigree_metadata +from wardline.core.finding import ( + FINGERPRINT_SCHEME, + UNANALYZED_RULE_IDS, + Finding, + format_fingerprint, + severity_to_filigree, + to_filigree_metadata, +) from wardline.core.http import read_response_text _SUGGESTION_LIMIT = 10000 _ALLOWED_SCHEMES = ("http", "https") +_DEFAULT_MAX_FINDINGS_PER_REQUEST = 1000 +_MAX_FINDINGS_ENV = "WARDLINE_FILIGREE_MAX_FINDINGS_PER_REQUEST" def _safe_int(value: Any) -> int: @@ -48,7 +59,7 @@ def _finding_to_wire(finding: Finding) -> dict[str, Any]: "severity": severity_to_filigree(finding.severity), "line_start": finding.location.line_start, "line_end": finding.location.line_end, - "fingerprint": finding.fingerprint, + "fingerprint": format_fingerprint(FINGERPRINT_SCHEME, finding.fingerprint), "metadata": to_filigree_metadata(finding), "language": "python", } @@ -63,17 +74,26 @@ def build_scan_results_body( *, scan_source: str = "wardline", scanned_paths: Sequence[str] = (), + mark_unseen: bool | None = None, ) -> dict[str, Any]: """Build the ``POST /api/weft/scan-results`` request body. Emits ALL finding kinds. ``mark_unseen`` opts into Filigree's per-(file, scan_source) absent-fingerprint sweep: a fingerprint seen before but absent now in a scanned file enters ``unseen_in_latest``. Clean files are represented by ``scanned_paths`` so - close-on-fixed can reconcile a file whose last finding disappeared.""" + close-on-fixed can reconcile a file whose last finding disappeared. + + If any file was discovered but not analyzed, do not run the absent-fingerprint + sweep: a parse/file failure means missing findings are not proof of a fix. + """ findings_wire = [_finding_to_wire(f) for f in findings] scanned = list(dict.fromkeys(p for p in scanned_paths if p)) + has_unanalyzed = any(f.rule_id in UNANALYZED_RULE_IDS for f in findings) + if mark_unseen is None: + mark_unseen = bool(findings_wire or scanned) and not has_unanalyzed body = { "scan_source": scan_source, - "mark_unseen": bool(findings_wire or scanned), + "fingerprint_scheme": FINGERPRINT_SCHEME, + "mark_unseen": bool(mark_unseen) and not has_unanalyzed, "findings": findings_wire, } if scanned: @@ -81,6 +101,58 @@ def build_scan_results_body( return body +# --- destination echo (N1 / C-10(a)) ----------------------------------------- + + +def filigree_url_project(url: str | None) -> str | None: + """The destination project pinned in a Filigree Weft URL, or None when none is pinned. + + An unpinned URL means Filigree resolves the project server-side (ambient/default) — the + silent-misroute shape behind the lacuna→filigree contamination. Recognizes both the + ``?project=

`` query and the ``/api/p/

/`` path conventions.""" + if not url: + return None + parts = urllib.parse.urlsplit(url) + project = urllib.parse.parse_qs(parts.query).get("project", [None])[0] + if project: + return project + segments = [s for s in parts.path.split("/") if s] + for i, seg in enumerate(segments): + if seg == "p" and i + 1 < len(segments): + return segments[i + 1] + return None + + +def filigree_destination(url: str | None) -> dict[str, Any]: + """The destination echo for the emit status block (N1 / C-10(a)): name where findings + were sent so a wrong-project write is visible at the caller instead of reading as + success. ``project_pinned`` is False when Filigree will resolve the project itself.""" + project = filigree_url_project(url) + return {"url": url, "project": project, "project_pinned": project is not None} + + +def filigree_api_base_url(url: str) -> str: + """Normalize any accepted Filigree URL form — bare origin, ``/api`` base, or a + classic / project-scoped ``…/weft/scan-results`` endpoint, with or without + ``?project=`` — to the API base every sibling route derives from. + + When the input pins a project (either dialect), the base is the path-scoped + ``…/api/p/`` form: Filigree dual-mounts every route under it, whereas the + ``?project=`` query is honored only on weft-scoped paths — so the path dialect is + the only one that also scopes classic routes (entity-associations, the dossier + work-join). The single parser exists so the emit echo, the promote route, and the + work-join can never disagree about what one configured URL means (dogfood-4 A3/A4).""" + parts = urllib.parse.urlsplit(url) + if parts.scheme.lower() not in _ALLOWED_SCHEMES: + raise FiligreeEmitError(f"filigree URL must use http or https; got scheme {parts.scheme!r} in {url!r}") + segments = [s for s in parts.path.split("/") if s] + base = segments[: segments.index("api") + 1] if "api" in segments else [*segments, "api"] + project = filigree_url_project(url) + if project is not None and base[-2:] != ["p", project]: + base += ["p", project] + return urllib.parse.urlunsplit((parts.scheme, parts.netloc, "/" + "/".join(base), "", "")) + + # --- transport + emitter ----------------------------------------------------- @@ -90,12 +162,121 @@ class Response: body: str +# The machine-readable reason vocabulary for a per-finding emit failure (PDR-0023, the +# honesty invariant). A degraded ``failed`` entry MUST distinguish these cases so a caller +# can tell "M of N emitted, K rejected because " from a clean true-negative — a bare +# count cannot. The set mirrors the invariant's emit-side cases: +# rejected — Filigree accepted the request but refused this finding (its own report). +# validation_error — the finding body was malformed/unprocessable (Filigree said why). +# scheme_mismatch — the fingerprint scheme Filigree expects differs from what we sent +# (a wlfp2→wlfp3 drift would join-miss; never read as a real true-negative). +# partial — the whole chunk was rejected at the protocol layer (fail-soft emit), so +# every finding in it is un-ingested; the cause is the chunk, not the body. +_FAILURE_REASONS = frozenset({"rejected", "validation_error", "scheme_mismatch", "partial"}) + +# --- weft-reason vocabulary conformance (G1) --------------------------------- +# The canonical, cross-member reason vocabulary is the closed set of 11 reason_classes +# defined in /home/john/weft/contracts/weft-reason-vocab.json (relative to the suite hub: +# contracts/weft-reason-vocab.json). Every NON-clean federation carrier MUST emit a +# reason_class drawn from that closed set, plus a cause and a fix; a clean carrier omits +# cause+fix. wardline's shipped emit-failure ``reason`` field predates the canonical +# vocabulary and is NOT renamed (it is on the wire and consumed by the CLI/MCP/scan-job +# status blocks). Instead each shipped ``reason`` maps ADDITIVELY onto a canonical +# reason_class, keeping the domain term in ``reason``/``cause`` so the wire stays +# backward-compatible while becoming G1-conformant. +# +# rejected -> rejected (peer reached, refused the item) +# validation_error -> rejected (peer reached, refused a malformed body — peer-side +# refusal, not an internal wardline fault, so 'rejected' +# not 'error'; the domain term survives in cause) +# scheme_mismatch -> scheme_mismatch (identity/fingerprint scheme drift — join-miss risk) +# partial -> partial (chunk-wide bounded/some-failed ingest) +_REASON_CLASS_BY_REASON: dict[str, str] = { + "rejected": "rejected", + "validation_error": "rejected", + "scheme_mismatch": "scheme_mismatch", + "partial": "partial", +} + +# The mandatory ``fix`` for each canonical class a FailedFinding can carry (carrier rule: +# every non-clean carrier includes a fix). Keyed by the domain ``reason`` so a more specific +# remedy can be given than the class alone allows (validation_error vs a bare rejection). +_FIX_BY_REASON: dict[str, str] = { + "rejected": "inspect the per-finding reject cause in Filigree's report and re-emit once the finding is acceptable", + "validation_error": "correct the malformed finding body Filigree reported, then re-emit", + "scheme_mismatch": ( + "align the wardline fingerprint scheme to the scheme Filigree expects, then re-emit (a drift join-misses)" + ), + "partial": "resolve the chunk-level rejection (see cause/status), then re-emit the un-ingested findings", +} + + +@dataclass(frozen=True, slots=True) +class FailedFinding: + """One finding that did not land in Filigree, with a machine-readable reason. + + PDR-0023: the honesty surface of the emit seam. A clean run yields an empty + ``failures`` tuple — but that emptiness is now EARNED (no finding failed) rather + than a hardwired count, and a partial ingest names which findings failed and why, + so "all N emitted" is distinguishable from "M of N emitted, K rejected because R". + ``fingerprint`` is the wardline join key when Filigree reported it (None when the + failure is chunk-wide and not attributable to a single finding). + + weft-reason (G1): a FailedFinding is always a NON-clean carrier, so it exposes the + canonical carrier triple {reason_class, cause, fix} (see ``to_wire``) ALONGSIDE the + shipped domain ``reason``/``detail`` fields. ``reason_class`` is one of the canonical + 11 (contracts/weft-reason-vocab.json); the domain term stays in ``reason``/``cause``.""" + + reason: str + detail: str = "" + fingerprint: str | None = None + + def __post_init__(self) -> None: + if self.reason not in _FAILURE_REASONS: + raise ValueError(f"unknown emit-failure reason {self.reason!r}; expected one of {sorted(_FAILURE_REASONS)}") + + @property + def reason_class(self) -> str: + """The canonical weft-reason class (one of the 11) this domain ``reason`` maps to.""" + return _REASON_CLASS_BY_REASON[self.reason] + + @property + def cause(self) -> str: + """The carrier ``cause``: the human-readable why. Filigree's ``detail`` when present, + else the domain ``reason`` itself (a FailedFinding is never clean, so cause is always + non-empty).""" + return self.detail or self.reason + + @property + def fix(self) -> str: + """The carrier ``fix`` (MANDATORY on a non-clean carrier): the remedial action.""" + return _FIX_BY_REASON[self.reason] + + def to_wire(self) -> dict[str, Any]: + # Shipped fields (reason/detail) are preserved verbatim; the canonical weft-reason + # carrier triple {reason_class, cause, fix} is ADDED alongside (G1, additive/non-breaking). + wire: dict[str, Any] = { + "reason": self.reason, + "detail": self.detail, + "reason_class": self.reason_class, + "cause": self.cause, + "fix": self.fix, + } + if self.fingerprint is not None: + wire["fingerprint"] = self.fingerprint + return wire + + @dataclass(frozen=True, slots=True) class EmitResult: reachable: bool created: int = 0 updated: int = 0 - failed: int = 0 + # Per-finding emit failures, each carrying a machine-readable reason (PDR-0023). The + # scalar ``failed`` count is DERIVED from this so the two can never disagree — the same + # construction-time-consistency idiom as ``auth_rejected`` over ``status`` below. A + # hardwired ``failed=0`` is now unrepresentable: the count is earned from real records. + failures: tuple[FailedFinding, ...] = () warnings: tuple[str, ...] = () # Discriminate WHY enrichment was unavailable so the caller can say the actionable # thing instead of a flat "could not reach" (dogfood #5). ``status`` is the HTTP status @@ -104,6 +285,22 @@ class EmitResult: # and a 2xx success. It is the *error* status: a reached/success result carries none. # All of these stay SOFT (reachable=False); only the message differs. status: int | None = None + # Whether a bearer token was actually sent on the attempt. A 401 means different things + # by this flag: token_sent=False → none configured (set one); token_sent=True → the value + # was REJECTED (it is wrong; align it to the canonical source). The original "set + # WEFT_FEDERATION_TOKEN" message implied absence and is what steered F1's wrong root-cause + # (weft-23574069a1 / C-7). ``url`` is the endpoint attempted, so the actionable message can + # name WHERE it tried without the caller threading it separately. + token_sent: bool = False + url: str | None = None + + @property + def failed(self) -> int: + # The count of findings that did not land, DERIVED from ``failures`` so a caller's + # "K failed" can never disagree with "here are the K reasons". Preserves every existing + # integer-count consumer (CLI line, status blocks, scan-job enrichment gate) while the + # honest detail lives in ``failures``. + return len(self.failures) @property def auth_rejected(self) -> bool: @@ -122,7 +319,173 @@ def __post_init__(self) -> None: raise ValueError("an unreachable EmitResult must have zero created/updated/failed") -def filigree_disabled_reason(*, reachable: bool, status: int | None) -> str | None: +@dataclass(frozen=True, slots=True) +class ProbeResult: + """Outcome of an auth probe (verify_token). ``accepted`` is True ONLY on a status that + proves the bearer passed the middleware auth check — a 2xx or the 400 the sentinel body + earns once authed. 401/403 is reachable-but-rejected. ``reachable`` is False on a + transport failure (connection refused / timeout) AND on an inconclusive status (5xx + outage, wrong-route 404, redirect) that never exercised auth — so a transient error is + never mistaken for an accepted token.""" + + reachable: bool + accepted: bool + status: int | None = None + + +@dataclass(frozen=True, slots=True) +class _ScanResultChunk: + findings: tuple[Finding, ...] + scanned_paths: tuple[str, ...] + mark_unseen: bool + + +def _scan_result_chunks( + findings: Sequence[Finding], + scanned_paths: Sequence[str], + max_findings_per_request: int, +) -> tuple[_ScanResultChunk, ...]: + """Split large emits without corrupting Filigree's per-file unseen sweep. + + Filigree's ``mark_unseen`` reconciliation is scoped to scanned file paths, so a + 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. + """ + 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) + if len(findings) <= max_findings_per_request: + return ( + _ScanResultChunk( + tuple(findings), + deduped_scanned_paths, + bool(findings or deduped_scanned_paths) and can_mark_unseen, + ), + ) + + by_path: dict[str, list[Finding]] = {} + path_order: list[str] = [] + for finding in findings: + path = finding.location.path or "" + if path not in by_path: + by_path[path] = [] + path_order.append(path) + by_path[path].append(finding) + + chunks: list[_ScanResultChunk] = [] + current_findings: list[Finding] = [] + current_paths: list[str] = [] + paths_with_findings = set(path_order) + clean_scanned_paths = [p for p in deduped_scanned_paths if p not in paths_with_findings] + + def flush_current() -> None: + nonlocal current_findings, current_paths + if current_findings or current_paths: + chunks.append( + _ScanResultChunk( + tuple(current_findings), + tuple(dict.fromkeys(current_paths)), + bool(current_findings or current_paths) and can_mark_unseen, + ) + ) + current_findings = [] + current_paths = [] + + for path in path_order: + group = by_path[path] + if len(group) > max_findings_per_request: + flush_current() + for start in range(0, len(group), max_findings_per_request): + # Complete-file reconciliation is impossible for this path under the cap. + chunks.append(_ScanResultChunk(tuple(group[start : start + max_findings_per_request]), (path,), False)) + continue + if current_findings and len(current_findings) + len(group) > max_findings_per_request: + flush_current() + current_findings.extend(group) + if path: + current_paths.append(path) + + current_paths.extend(clean_scanned_paths) + flush_current() + return tuple(chunks) + + +def _normalize_failure_reason(raw: Any) -> str: + """Map a Filigree-reported per-finding reason onto the honesty vocabulary. + + Filigree's reject report is not contractually frozen to our reason set, so an + unrecognized (or absent) reason degrades to ``rejected`` — the honest "Filigree + refused this finding but did not say a more specific why" — rather than being + dropped. A drift-shaped reason (the scheme-mismatch the seam-health map flags as + the join-miss that cascade-closes issues) is recognized so an agent can tell a + fingerprint-scheme drift from a routine rejection.""" + if not isinstance(raw, str): + return "rejected" + token = raw.strip().lower().replace("-", "_").replace(" ", "_") + if token in _FAILURE_REASONS: + return token + if "scheme" in token and ("mismatch" in token or "drift" in token or "unknown" in token): + return "scheme_mismatch" + if "valid" in token or "schema" in token or "unprocessable" in token: + return "validation_error" + return "rejected" + + +def _parse_failed_entry(entry: Any) -> FailedFinding: + """Coerce one element of Filigree's ``failed`` array into a FailedFinding. + + Filigree reports rejects as objects (``{"fingerprint": ..., "reason": ..., ...}``) + but tolerates a bare id string for forward/backward wire compatibility. Either way a + machine-readable reason is preserved (defaulting to ``rejected``) so a partial ingest + is never flattened back into an opaque count.""" + if isinstance(entry, Mapping): + fingerprint = entry.get("fingerprint") or entry.get("id") + detail = entry.get("detail") or entry.get("message") or entry.get("error") or "" + return FailedFinding( + reason=_normalize_failure_reason(entry.get("reason")), + detail=str(detail), + fingerprint=str(fingerprint) if fingerprint is not None else None, + ) + # A bare scalar (id string): Filigree refused it but gave no structured reason. + return FailedFinding(reason="rejected", fingerprint=str(entry) if entry is not None else None) + + +def _parse_success_response(resp: Response) -> EmitResult: + # 2xx success. Parse defensively: a 2xx with an unreadable body means the POST was + # accepted but the report is unparseable — surface a warning, never crash (charter). + warnings: list[str] = [] + try: + parsed = json.loads(resp.body) if resp.body else {} + except json.JSONDecodeError: + parsed = None + payload: dict[str, Any] = parsed if isinstance(parsed, dict) else {} + if not isinstance(parsed, dict): + warnings.append(f"Filigree returned {resp.status} with a non-JSON-object body; stats unavailable.") + raw_stats = payload.get("stats") + stats: dict[str, Any] = raw_stats if isinstance(raw_stats, dict) else {} + raw_warnings = payload.get("warnings") + if isinstance(raw_warnings, list): + warnings.extend(str(w) for w in raw_warnings) + raw_failed = payload.get("failed") + # PDR-0023: preserve Filigree's PER-FINDING reject reasons instead of flattening to a + # count. A 2xx where Filigree silently dropped K findings is now distinguishable from a + # clean emit — the empty ``failures`` tuple is earned, not assumed. + failures = tuple(_parse_failed_entry(e) for e in raw_failed) if isinstance(raw_failed, list) else () + return EmitResult( + reachable=True, + created=_safe_int(stats.get("findings_created")), + updated=_safe_int(stats.get("findings_updated")), + failures=failures, + warnings=tuple(warnings), + ) + + +def filigree_disabled_reason( + *, reachable: bool, status: int | None, token_sent: bool = False, url: str | None = None +) -> str | None: """The ``disabled_reason`` for an emit attempt, or None when Filigree was reached. Single source of the auth-rejected (401/403) vs server-error (5xx) vs unreachable @@ -138,14 +501,23 @@ def filigree_disabled_reason(*, reachable: bool, status: int | None) -> str | No """ if reachable: return None + at = f" at {url}" if url else "" if status in (401, 403): - # 401 → set a token; 403 → token present but lacks access (a token won't help). + # 403 → token present but lacks access (a token won't help). 401 → split by whether a + # token was actually SENT: absent (set one) vs rejected (the value is wrong). The old + # flat "set WEFT_FEDERATION_TOKEN" implied absence even when a token was sent and + # rejected — the C-7 misdiagnosis (weft-23574069a1). if status == 403: - return "filigree forbidden (403); token present but lacks access / blocked" - return f"filigree auth-rejected ({status}); set WEFT_FEDERATION_TOKEN" + return f"filigree forbidden (403){at}; token present but lacks access / blocked" + if token_sent: + return ( + f"filigree rejected the token (401){at}; a token WAS sent but its value is wrong — " + "align WEFT_FEDERATION_TOKEN (env or .env) to the canonical federation token" + ) + return f"filigree auth-rejected (401){at}; no token sent — set WEFT_FEDERATION_TOKEN (env or .env)" if status is not None: - return f"filigree server error ({status})" - return "filigree unreachable" + return f"filigree server error ({status}){at}" + return f"filigree unreachable{at}" class Transport(Protocol): @@ -173,60 +545,228 @@ def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: with exc: return Response(status=exc.code, body=read_response_text(exc)) + def get(self, url: str, headers: Mapping[str, str]) -> Response: + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in _ALLOWED_SCHEMES: + raise FiligreeEmitError(f"filigree URL must use http or https; got scheme {scheme!r} in {url!r}") + request = urllib.request.Request(url, headers=dict(headers), method="GET") + try: + with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310 + return Response(status=resp.status, body=read_response_text(resp)) + except urllib.error.HTTPError as exc: + with exc: + return Response(status=exc.code, body=read_response_text(exc)) + + +def _resolve_operator_max_findings_per_request(explicit: int | None) -> int | None: + if explicit is not None: + if explicit < 1: + raise ValueError("max_findings_per_request must be at least 1") + return explicit + raw = os.environ.get(_MAX_FINDINGS_ENV) + if raw: + try: + value = int(raw) + except ValueError as exc: + raise ValueError(f"{_MAX_FINDINGS_ENV} must be an integer") from exc + if value < 1: + raise ValueError(f"{_MAX_FINDINGS_ENV} must be at least 1") + return value + return None + + +def _limit_from_mapping(node: Mapping[str, Any]) -> int | None: + for key in ( + "max_findings_per_request", + "max_findings", + "findings_per_request", + "findings_per_request_limit", + "finding_limit", + "findings_limit", + ): + value = node.get(key) + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return value + for nested_key in ("limits", "request_limits", "chunking_guidance", "scan_results"): + nested = node.get(nested_key) + if isinstance(nested, Mapping): + found = _limit_from_mapping(nested) + if found is not None: + return found + return None + + +def _is_scan_results_node(key: str, node: Mapping[str, Any]) -> bool: + haystacks = [key] + for field in ("path", "url", "endpoint", "route", "name"): + value = node.get(field) + if isinstance(value, str): + haystacks.append(value) + return any("scan-results" in item or "scan_results" in item for item in haystacks) + + +def _extract_scan_results_limit(schema: Mapping[str, Any]) -> int | None: + for key in ("scan_results", "scan-results", "scan_results_limits", "scan-results-limits"): + value = schema.get(key) + if isinstance(value, Mapping): + found = _limit_from_mapping(value) + if found is not None: + return found + + endpoints = schema.get("endpoints") + if isinstance(endpoints, Mapping): + for key, value in endpoints.items(): + if isinstance(value, Mapping) and _is_scan_results_node(str(key), value): + found = _limit_from_mapping(value) + if found is not None: + return found + elif isinstance(endpoints, list): + for value in endpoints: + if isinstance(value, Mapping) and _is_scan_results_node("", value): + found = _limit_from_mapping(value) + if found is not None: + return found + return None + + +def _scan_results_schema_url(url: str) -> str: + return f"{filigree_api_base_url(url).rstrip('/')}/files/_schema" + + +def _fetch_scan_results_limit(url: str, transport: Transport, headers: Mapping[str, str]) -> int | None: + get = getattr(transport, "get", None) + if get is None: + return None + try: + resp = get(_scan_results_schema_url(url), headers) + except (FiligreeEmitError, urllib.error.URLError, OSError, json.JSONDecodeError): + return None + if not 200 <= resp.status < 300: + return None + try: + parsed = json.loads(resp.body) if resp.body else {} + except json.JSONDecodeError: + return None + return _extract_scan_results_limit(parsed) if isinstance(parsed, Mapping) else None + class FiligreeEmitter: """POST findings to a Filigree Weft scan-results URL with an injectable transport.""" - def __init__(self, url: str, *, transport: Transport | None = None, token: str | None = None) -> None: + def __init__( + self, + url: str, + *, + transport: Transport | None = None, + token: str | None = None, + max_findings_per_request: int | None = None, + protocol_errors_loud: bool = True, + ) -> None: self._url = url self._transport: Transport = transport if transport is not None else UrllibTransport() self._token = token + self._operator_max_findings_per_request = _resolve_operator_max_findings_per_request(max_findings_per_request) + self._protocol_errors_loud = protocol_errors_loud def emit(self, findings: Sequence[Finding], *, scanned_paths: Sequence[str] = ()) -> EmitResult: - body = json.dumps(build_scan_results_body(findings, scanned_paths=scanned_paths)).encode("utf-8") headers = {"Content-Type": "application/json"} - if self._token: + token_sent = bool(self._token) + if token_sent: headers["Authorization"] = f"Bearer {self._token}" + max_findings_per_request = ( + self._operator_max_findings_per_request + 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)) + created = 0 + updated = 0 + failures: list[FailedFinding] = [] + warnings: list[str] = [] try: - resp = self._transport.post(self._url, body, headers) + for chunk_index, chunk in enumerate(chunks, start=1): + body = json.dumps( + build_scan_results_body( + chunk.findings, + scanned_paths=chunk.scanned_paths, + mark_unseen=chunk.mark_unseen, + ) + ).encode("utf-8") + resp = self._transport.post(self._url, body, headers) + if resp.status in (401, 403): + # Filigree is present but its opt-in bearer auth is on and refusing us. + # Stays SOFT (enrichment unavailable, never exit-2) — but distinguished + # as auth so the caller can say the actionable thing. + return EmitResult(reachable=False, status=resp.status, token_sent=token_sent, url=self._url) + if resp.status >= 500: + # Server-side outage (5xx) — the sibling is degraded, not a Wardline + # payload bug. Treat like absent (warn + continue), carrying the status. + return EmitResult(reachable=False, status=resp.status, token_sent=token_sent, url=self._url) + if not 200 <= resp.status < 300: + message = f"Filigree rejected scan-results ({resp.status}) at {self._url}: {resp.body}" + if self._protocol_errors_loud: + raise FiligreeEmitError(message) + # Fail-soft: the chunk (and every chunk after it) is un-ingested. PDR-0023 — + # record EACH still-pending finding as a ``partial`` failure carrying the + # rejecting status, so the caller reads "K findings failed because the chunk + # was rejected ()" instead of an opaque count that looks like success + # minus a number. ``partial`` (chunk-wide) is named distinctly from a + # per-finding ``rejected`` because the cause is the request, not the body. + detail = f"chunk rejected at protocol layer ({resp.status}): {resp.body}" + for pending_chunk in chunks[chunk_index - 1 :]: + for finding in pending_chunk.findings: + failures.append( + FailedFinding( + reason="partial", + detail=detail, + fingerprint=format_fingerprint(FINGERPRINT_SCHEME, finding.fingerprint), + ) + ) + warnings.append(message) + break + chunk_result = _parse_success_response(resp) + created += chunk_result.created + updated += chunk_result.updated + failures.extend(chunk_result.failures) + warnings.extend(chunk_result.warnings) except (urllib.error.URLError, OSError): # Connection refused / DNS / timeout — sibling absent. Enrichment is # non-load-bearing: warn (at the CLI) and continue. No status reached us, so # this is the genuine "could not reach" case (status=None). - return EmitResult(reachable=False) - if resp.status in (401, 403): - # Filigree is present but its opt-in bearer auth is on and refusing us. Stays - # SOFT (enrichment unavailable, never exit-2) — but distinguished as auth so the - # caller can say "401 (set WEFT_FEDERATION_TOKEN)" instead of "could not reach". - return EmitResult(reachable=False, status=resp.status) - if resp.status >= 500: - # Server-side outage (5xx) — the sibling is degraded, not a Wardline payload bug. - # Treat like absent (warn + continue), carrying the status for an honest message. - return EmitResult(reachable=False, status=resp.status) - if not 200 <= resp.status < 300: - # 3xx (a redirect reached the client) or any remaining 4xx (notably 400): Wardline - # sent a request the server would not accept — bad payload / wrong endpoint. Loud. - raise FiligreeEmitError(f"Filigree rejected scan-results ({resp.status}) at {self._url}: {resp.body}") - # 2xx success. Parse defensively: a 2xx with an unreadable body means the POST was - # accepted but the report is unparseable — surface a warning, never crash (charter). - warnings: list[str] = [] - try: - parsed = json.loads(resp.body) if resp.body else {} - except json.JSONDecodeError: - parsed = None - payload: dict[str, Any] = parsed if isinstance(parsed, dict) else {} - if not isinstance(parsed, dict): - warnings.append(f"Filigree returned {resp.status} with a non-JSON-object body; stats unavailable.") - raw_stats = payload.get("stats") - stats: dict[str, Any] = raw_stats if isinstance(raw_stats, dict) else {} - raw_warnings = payload.get("warnings") - if isinstance(raw_warnings, list): - warnings.extend(str(w) for w in raw_warnings) - failed = payload.get("failed") or [] + return EmitResult(reachable=False, token_sent=token_sent, url=self._url) return EmitResult( reachable=True, - created=_safe_int(stats.get("findings_created")), - updated=_safe_int(stats.get("findings_updated")), - failed=len(failed) if isinstance(failed, list) else 0, + created=created, + updated=updated, + failures=tuple(failures), warnings=tuple(warnings), + token_sent=token_sent, + url=self._url, ) + + def verify_token(self) -> ProbeResult: + """Probe whether the daemon accepts this emitter's bearer token, WITHOUT + recording anything. Auth runs in middleware before body validation, so a + deliberately-incomplete sentinel body yields 400 (auth passed) or 401/403 + (rejected). Never reuses emit() — that would POST a valid empty scan.""" + body = b"{}" # parses as JSON, missing required scan-results fields => 400 when authed + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + try: + resp = self._transport.post(self._url, body, headers) + except (urllib.error.URLError, OSError): + return ProbeResult(reachable=False, accepted=False) + status = resp.status + if status in (401, 403): + return ProbeResult(reachable=True, accepted=False, status=status) + if status == 400 or 200 <= status < 300: + # Auth runs in middleware BEFORE body validation, so these prove the bearer + # was accepted: a 2xx took the sentinel, or a 400 authed then rejected the + # deliberately-incomplete body. Either way auth passed. + return ProbeResult(reachable=True, accepted=True, status=status) + # Any other status (a 5xx outage, a wrong-route 404, a 3xx redirect) does NOT + # exercise the bearer check — the request may have failed before or independent + # of auth. Report INCONCLUSIVE (reachable=False) so doctor --repair never pins a + # local token into .env on an unverified probe. + return ProbeResult(reachable=False, accepted=False, status=status) diff --git a/src/wardline/core/filigree_issue.py b/src/wardline/core/filigree_issue.py index 51bc2d51..3b4098cd 100644 --- a/src/wardline/core/filigree_issue.py +++ b/src/wardline/core/filigree_issue.py @@ -21,30 +21,27 @@ from typing import Any, Protocol from wardline.core.errors import FiligreeEmitError +from wardline.core.filigree_emit import filigree_api_base_url +from wardline.core.finding import FINGERPRINT_SCHEME, format_fingerprint from wardline.core.http import read_response_text from wardline.loomweave.identity import SeiResolver _ALLOWED_SCHEMES = ("http", "https") -_WEFT_MARKER = "/api/weft/" def promote_url_from_weft(weft_url: str) -> str: - """Derive the promote route from the configured Weft scan-results URL — both - live under /api/weft/. Reject a URL that isn't a Weft endpoint (a clear config - error rather than a 404 against a wrong host).""" - idx = weft_url.find(_WEFT_MARKER) - if idx == -1: - raise FiligreeEmitError(f"filigree URL must be a Weft endpoint containing {_WEFT_MARKER!r}: {weft_url!r}") - base = weft_url[: idx + len(_WEFT_MARKER)] - return base + "findings/promote" + """Derive the promote route from the configured Filigree URL. Accepts every form + ``filigree_api_base_url`` does; a pinned project (``/api/p//…`` path or + ``?project=`` query) is preserved as the path-scoped dialect, so a scoped emit + config can no longer promote into the default project (dogfood-4 A3).""" + return api_base_url_from_weft(weft_url) + "/weft/findings/promote" def api_base_url_from_weft(weft_url: str) -> str: - """Normalize a Weft scan-results URL to Filigree's ``/api`` base.""" - idx = weft_url.find(_WEFT_MARKER) - if idx == -1: - raise FiligreeEmitError(f"filigree URL must be a Weft endpoint containing {_WEFT_MARKER!r}: {weft_url!r}") - return weft_url[:idx].rstrip("/") + "/api" + """Normalize the configured Filigree URL to its API base — project-scoped when the + input pins a project. Thin alias over the shared dialect parser in + ``core/filigree_emit.py`` so every derived route agrees with the emit destination.""" + return filigree_api_base_url(weft_url) def build_promote_body( @@ -54,7 +51,15 @@ def build_promote_body( priority: str | None = None, labels: Sequence[str] | None = None, ) -> dict[str, Any]: - body: dict[str, Any] = {"scan_source": scan_source, "fingerprint": fingerprint} + # The promote join key MUST match the form the scan-results INGEST wire writes + # (filigree_emit._finding_to_wire emits the scheme-prefixed value), or Filigree's + # exact-match promote lookup 404s against the finding it just ingested. Callers + # pass the BARE in-memory fingerprint (agent_summary / CLI arg); we prefix it here + # at the wire boundary, symmetric with ingest. + body: dict[str, Any] = { + "scan_source": scan_source, + "fingerprint": format_fingerprint(FINGERPRINT_SCHEME, fingerprint), + } if priority is not None: body["priority"] = priority if labels: @@ -184,7 +189,11 @@ def file( payload = json.loads(resp.body) if resp.body else {} except json.JSONDecodeError: payload = {} - issue_id = payload.get("issue_id") if isinstance(payload, dict) else None + # Type-narrow at the wire boundary like the emit path does (_safe_int): a 2xx + # body carrying a non-string issue_id must not flow verbatim into tool payloads + # that publish issue_id as string|null in their MCP outputSchema. + raw_issue_id = payload.get("issue_id") if isinstance(payload, dict) else None + issue_id = raw_issue_id if isinstance(raw_issue_id, str) else None created = bool(payload.get("created")) if isinstance(payload, dict) else False return FileResult(reachable=True, issue_id=issue_id, created=created) @@ -240,8 +249,19 @@ def identity_attach_result_to_json(result: IdentityAttachResult) -> dict[str, An } -def _locator_for_finding_qualname(qualname: str) -> str: - return f"python:function:{qualname}" +def plugin_for_finding(finding: Any) -> str: + """The ADR-049 plugin id that minted ``finding`` — the resolve-hint discriminator + (ADR-036). Rust rules are the ``RS-WL-`` family; everything else with a qualname is + the Python frontend. Derived from the rule id (findings carry no ``lang`` field; + the rule family IS the producer).""" + rule_id = getattr(finding, "rule_id", "") or "" + return "rust" if rule_id.startswith("RS-WL-") else "python" + + +def _locator_for_finding_qualname(qualname: str, plugin: str) -> str: + # Both frontends' callable findings carry function-kind qualnames (Wardline's + # semantic `method` maps to the id-kind `function` in both dialects). + return f"{plugin}:function:{qualname}" def _finding_for_fingerprint(fingerprint: str, root: Path, config_path: Path | None) -> Any | None: @@ -251,6 +271,88 @@ def _finding_for_fingerprint(fingerprint: str, root: Path, config_path: Path | N return next((finding for finding in result.findings if finding.fingerprint == fingerprint), None) +@dataclass(frozen=True, slots=True) +class EntityBindingInput: + """Outcome of resolving an inline entity reference supplied at a manual-entry surface + (the doctrine ``entity_id`` L1 / ``entity_symbol`` L2 inputs). On success ``entity_id`` + is the binding key (a ``loomweave:eid:`` SEI when one resolved, else the opaque value / + locator the caller supplied) and ``locator`` is the human-readable name. On failure the + weft-reason triple is populated and the caller MUST create nothing.""" + + resolved: bool + entity_id: str | None = None + locator: str | None = None + content_hash: str | None = None + binding_kind: str | None = None # "sei" | "locator" + # weft-reason carrier (unresolved_input); populated only when resolved is False. + reason_class: str | None = None + cause: str | None = None + fix: str | None = None + + @classmethod + def from_opaque(cls, entity_id: str) -> EntityBindingInput: + """L1: an opaque id the caller already holds. Carried verbatim, never re-resolved.""" + return cls( + resolved=True, + entity_id=entity_id, + locator=entity_id, + binding_kind="sei" if entity_id.startswith("loomweave:eid:") else "locator", + ) + + @classmethod + def unresolved(cls, *, cause: str, fix: str) -> EntityBindingInput: + return cls(resolved=False, reason_class="unresolved_input", cause=cause, fix=fix) + + +def resolve_entity_binding_input( + *, + entity_id: str | None, + entity_symbol: str | None, + loomweave_client: Any, + plugin: str = "python", +) -> EntityBindingInput | None: + """Resolve an inline entity reference for a manual-entry surface, doctrine-style. + + Returns ``None`` when NEITHER input was supplied (the surface stays entity-free, + fully back-compatible). With ``entity_id`` (L1) the value is carried opaque, no + transport touched. With ``entity_symbol`` (L2) the symbol is resolved to a SEI via + Loomweave (the existing :class:`SeiResolver` transport); a symbol that does not + resolve to a live SEI returns an ``unresolved_input`` carrier so the caller can + refuse to write a looks-bound-but-isn't record. ``entity_id`` wins if both given. + """ + if entity_id: + return EntityBindingInput.from_opaque(entity_id) + if not entity_symbol: + return None + if loomweave_client is None: + return EntityBindingInput.unresolved( + cause=f"entity_symbol {entity_symbol!r} supplied but no Loomweave URL is configured to resolve it", + fix="configure a Loomweave URL (--loomweave-url / loomweave.url), or pass an already-resolved entity_id", + ) + locator = _locator_for_finding_qualname(entity_symbol, plugin) + try: + resolver = SeiResolver.detect(loomweave_client) + binding = resolver.resolve_locator(locator) + except Exception as exc: + return EntityBindingInput.unresolved( + cause=f"Loomweave resolve of entity_symbol {entity_symbol!r} failed: {exc}", + fix="check Loomweave reachability, or pass an already-resolved entity_id", + ) + if binding.sei and binding.content_hash: + return EntityBindingInput( + resolved=True, + entity_id=binding.sei, + locator=binding.locator or locator, + content_hash=binding.content_hash, + binding_kind="sei", + ) + return EntityBindingInput.unresolved( + cause=f"Loomweave did not resolve entity_symbol {entity_symbol!r} to a live SEI " + f"(plugin={plugin}); the symbol may be unknown, renamed, or this Loomweave predates SEI", + fix="verify the qualname is indexed in Loomweave, or pass an already-resolved entity_id", + ) + + def attach_loomweave_identity_for_finding( *, fingerprint: str, @@ -279,6 +381,7 @@ def attach_loomweave_identity_for_finding( issue_id=issue_id, filer=filer, loomweave_client=loomweave_client, + plugin=plugin_for_finding(finding), ) @@ -288,12 +391,13 @@ def attach_loomweave_identity_for_qualname( issue_id: str | None, filer: FiligreeIssueFiler, loomweave_client: Any, + plugin: str = "python", ) -> IdentityAttachResult: if not issue_id: return IdentityAttachResult.not_attempted("no issue_id from Filigree promote") if loomweave_client is None: return IdentityAttachResult.not_attempted("no Loomweave URL configured") - locator = _locator_for_finding_qualname(qualname) + locator = _locator_for_finding_qualname(qualname, plugin) try: resolver = SeiResolver.detect(loomweave_client) binding = resolver.resolve_locator(locator) @@ -305,25 +409,29 @@ def attach_loomweave_identity_for_qualname( issue_id=issue_id, entity_id=binding.sei, content_hash=binding.content_hash, - entity_kind="python:function", + entity_kind=f"{plugin}:function", ) - legacy = _legacy_locator_binding(loomweave_client, qualname, fallback_locator=binding.locator or locator) + legacy = _legacy_locator_binding( + loomweave_client, qualname, fallback_locator=binding.locator or locator, plugin=plugin + ) if legacy.entity_id and legacy.content_hash: return filer.attach_entity_association( issue_id=issue_id, entity_id=legacy.entity_id, content_hash=legacy.content_hash, - entity_kind="python:function", + entity_kind=f"{plugin}:function", ) return legacy -def _legacy_locator_binding(loomweave_client: Any, qualname: str, *, fallback_locator: str) -> IdentityAttachResult: +def _legacy_locator_binding( + loomweave_client: Any, qualname: str, *, fallback_locator: str, plugin: str = "python" +) -> IdentityAttachResult: entity_id: str | None = fallback_locator content_hash: str | None = None try: - resolved = loomweave_client.resolve([qualname]) + resolved = loomweave_client.resolve([qualname], plugin=plugin) except Exception as exc: return IdentityAttachResult.skipped( f"Loomweave legacy locator resolve failed: {exc}", diff --git a/src/wardline/core/finding.py b/src/wardline/core/finding.py index a4a11289..a57664de 100644 --- a/src/wardline/core/finding.py +++ b/src/wardline/core/finding.py @@ -28,9 +28,9 @@ # Rule ids for files where analysis was ATTEMPTED OR EXPECTED but FAILED / never # happened — a genuine under-scan: parse/read failures, files too deep to walk -# (recursion), and missing source roots. These are Severity.NONE FACTs that never -# trip the severity gate, so callers count them separately (ScanSummary.unanalyzed) -# to surface the silent under-scan and (opt-in) gate on it. +# (recursion), and missing source roots. Some are gate-eligible DEFECTs and some +# are non-gating FACTs, so callers also count them separately +# (ScanSummary.unanalyzed) to expose the under-scan independent of severity. # # WLN-ENGINE-NO-MODULE is DELIBERATELY EXCLUDED: a file that maps to no module # (e.g. a top-level / src/__init__.py) is a benign layout artifact with nothing to @@ -44,6 +44,10 @@ "WLN-ENGINE-PARSE-ERROR", "WLN-ENGINE-FILE-SKIPPED", "WLN-ENGINE-SOURCE-ROOT-MISSING", + # A file that parsed but whose analysis raised (per-file isolation, e.g. the Rust + # frontend catching a RecursionError on a pathologically deep expression) — a + # genuine under-scan, counted so it never reads as a clean result. + "WLN-ENGINE-FILE-FAILED", } ) @@ -103,6 +107,16 @@ class Finding: suppressed: SuppressionState = SuppressionState.ACTIVE suppression_reason: str | None = None maturity: Maturity = Maturity.STABLE + # MIGRATION-ONLY breadcrumb (P4 / `wardline rekey`), NEVER serialized — no + # serializer references it (``to_jsonl``/SARIF/``to_filigree_metadata``/store-doc + # builders are all explicit-field dicts), so it stays out of the frozen identity + # corpus and every wire payload. It carries the OLD (wlfp1) ``taint_path`` string + # so the migration can recompute a finding's pre-rekey fingerprint + # (``compute_finding_fingerprint_v0``) from the same scan. ``None`` for rules whose + # old taint_path was ``None`` (singletons / PY-WL-103 / PY-WL-104 / PY-WL-120-return); + # set by the multi-emit rules whose old taint_path was non-empty. Removable once + # every project has migrated (a no-corpus-impact cleanup). + taint_path_v0: str | None = None def to_jsonl(self) -> str: payload: dict[str, Any] = { @@ -123,7 +137,7 @@ def to_jsonl(self) -> str: "confidence": self.confidence, "related_entities": list(self.related_entities), "properties": dict(self.properties), - "suppressed": self.suppressed.value, + "suppression_state": self.suppressed.value, "suppression_reason": self.suppression_reason, "maturity": self.maturity.value, } @@ -131,26 +145,112 @@ def to_jsonl(self) -> str: # --- Finding fingerprint (SP2 §7) -------------------------------------------- -# Stable cross-run identity that folds in qualname + a taint-path signature so -# two taint paths into one sink (same file/rule/line, different path) get -# DISTINCT fingerprints (Filigree drift constraint). Discrimination is only as -# fine as the supplied ``taint_path`` — callers derive it from ``taint_provenance`` -# (a single best-callee, not a full path), so two paths sharing best-callee AND -# returned taint will still collide. That is the spec's accepted granularity. +# Stable cross-run identity. The fingerprint is the cross-tool JOIN KEY: Filigree +# associates issues to it and the baseline/waiver stores key on it, so it MUST be +# invariant to taint-resolution drift — it must not move across builds while the +# source is byte-identical (weft-4a9d0f863c: it moved across three builds because +# resolved taint tiers and ``via_callee`` were folded in, and those legitimately +# change as the rule suite is extended/refined). +# +# INVARIANT (enforce at every call site — see tests/golden/identity): ``taint_path`` +# carries ONLY a SOURCE-DERIVED discriminator and exists SOLELY to separate two +# distinct findings that share (rule_id, path, line_start, qualname). A component +# may appear in ``taint_path`` only if it is BOTH (a) derived purely from source +# tokens / lexical position (a sink dotted-name, a callee spelling as written, a +# decorator marker/level token, a call's ``col_offset``) — NOT a resolved +# ``TaintState`` tier and NOT ``via_callee`` — AND (b) load-bearing: actually +# needed to tell two co-located findings apart. A rule that emits at most one +# finding per (rule_id, path, qualname) passes ``taint_path=None``. +# Resolved tiers belong in ``message``/``properties``, never the join key. +# This invariant is no longer convention-only: ``scanner.diagnostics.build_collision_findings`` +# enforces it at runtime over the full emitted set (wardline-8fb773a7af) — two DISTINCT +# findings sharing a fingerprint surface a loud WLN-ENGINE-FINGERPRINT-COLLISION DEFECT +# that trips the gate, instead of one silently masking the other on the joins. +# +# ``line_start`` is DELIBERATELY NOT hashed (wlfp2, wardline-8654423823): a benign +# comment above an entity shifts every line below it but is the same source, so it +# must not churn the cross-tool join key. Multi-emit rules therefore discriminate +# co-located findings with an ENTITY-RELATIVE position — ``node.lineno - +# entity.location.line_start`` plus the call's ``col_offset:end_col_offset`` — which +# is invariant to the whole entity moving (a comment above it). NOTE: it is +# entity-relative, NOT move-stable in the strong sense — a comment inserted INSIDE +# the entity above the node still shifts the relative offset (accepted; the contract +# is identical-source -> identical-fingerprint, and that edit is not identical source). +# ``line_start`` remains on ``Finding.location`` for SARIF regions and display. def compute_finding_fingerprint( *, rule_id: str, path: str, - line_start: int | None, qualname: str | None = None, taint_path: str | None = None, ) -> str: digest = hashlib.sha256() - parts = (rule_id, path, str(line_start), qualname or "", taint_path or "") + parts = (rule_id, path, qualname or "", taint_path or "") digest.update("\x00".join(parts).encode()) return digest.hexdigest() +# --- Self-describing fingerprint scheme (P1 scheme-infra) -------------------- +# The fingerprint is the cross-tool JOIN KEY (baseline / waiver / judged stores +# and the Filigree wire). Stamping a scheme onto it at the wire/store boundary +# lets a store that was written under a different hash formula LOUD-FAIL on load +# (``SchemeMismatchError``) instead of silently joining stale values and +# orphaning every verdict. The IN-MEMORY ``Finding.fingerprint`` stays bare +# 64-hex; the prefix is applied only when serialising to a store/wire and +# stripped (``parse_fingerprint``) when reading one back. ``wlfp1`` is this +# (line_start-IN) formula; the move-stability rekey will bump it to ``wlfp2``. +FINGERPRINT_SCHEME = "wlfp2" + +_HEX_DIGITS = frozenset("0123456789abcdef") + + +def format_fingerprint(scheme: str, fingerprint: str) -> str: + """Stamp a bare 64-hex fingerprint with its scheme for the wire/store. + + The inverse of :func:`parse_fingerprint`. Does not validate ``fingerprint`` + here — callers pass ``Finding.fingerprint``, already a bare digest. + """ + return f"{scheme}:{fingerprint}" + + +def parse_fingerprint(value: str) -> tuple[str, str]: + """Split a ``scheme:hex`` fingerprint into ``(scheme, bare_hex)``. + + Pure FORMAT parser: it returns whatever scheme token is present and does + NOT judge whether that scheme is the one this build expects — scheme + *mismatch* is the store loaders' concern (``SchemeMismatchError``). Raises + ``ValueError`` on a structurally malformed value: no colon, empty scheme, or + a hex part that is not exactly 64 lowercase hex characters. + """ + scheme, sep, hexpart = value.partition(":") + if sep != ":" or not scheme: + raise ValueError(f"not a scheme-prefixed fingerprint: {value!r}") + if len(hexpart) != 64 or any(c not in _HEX_DIGITS for c in hexpart): + raise ValueError(f"invalid fingerprint hex (need 64 lowercase hex): {hexpart!r}") + return scheme, hexpart + + +def require_fingerprint_scheme(document: Mapping[str, Any], *, store_name: str) -> None: + """Loud-fail (``SchemeMismatchError``) if ``document``'s ``fingerprint_scheme`` + header is absent or differs from this build's :data:`FINGERPRINT_SCHEME`. + + The baseline/judged/waivers loaders all call this. **Loader order is + load-bearing:** call it AFTER the empty-guard (a fresh/empty store must + return empty, never raise) and BEFORE the version check (a version-mismatch + raised first would hide the actionable ``wardline rekey`` hint). A non-string + header is treated as missing. + """ + # Imported lazily-at-module-load: errors imports nothing from wardline, so + # this top-of-module import would be acyclic, but the symbol is only needed + # here — keep it local to avoid widening finding.py's import surface. + from wardline.core.errors import SchemeMismatchError + + raw = document.get("fingerprint_scheme") + found = raw if isinstance(raw, str) else None + if found != FINGERPRINT_SCHEME: + raise SchemeMismatchError(store_name=store_name, found=found, expected=FINGERPRINT_SCHEME) + + # --- Weft wire mapping (pure; SP4 uses these to build the scan-results body) - _SEVERITY_TO_FILIGREE: dict[Severity, str] = { Severity.CRITICAL: "critical", @@ -160,21 +260,31 @@ def compute_finding_fingerprint( Severity.NONE: "info", } +_PROPERTY_ACCESSOR_QUALNAME_SUFFIXES = (":setter", ":deleter") + def severity_to_filigree(severity: Severity) -> str: """Map Wardline's 4-level (+NONE) vocabulary to Filigree's 5-level set.""" return _SEVERITY_TO_FILIGREE[severity] +def _to_wire_qualname(qualname: str) -> str: + """Return the cross-tool reconciliation qualname for Wardline metadata.""" + for suffix in _PROPERTY_ACCESSOR_QUALNAME_SUFFIXES: + if qualname.endswith(suffix): + return qualname.removesuffix(suffix) + return qualname + + def to_filigree_metadata(finding: Finding) -> dict[str, Any]: """Build the ``metadata.wardline.*`` subtree (semantic JSON, not byte-stable).""" wardline: dict[str, Any] = { - "fingerprint": finding.fingerprint, + "fingerprint": format_fingerprint(FINGERPRINT_SCHEME, finding.fingerprint), "internal_severity": finding.severity.value, "kind": finding.kind.value, } if finding.qualname is not None: - wardline["qualname"] = finding.qualname + wardline["qualname"] = _to_wire_qualname(finding.qualname) if finding.confidence is not None: wardline["confidence"] = finding.confidence if finding.related_entities: @@ -182,7 +292,7 @@ def to_filigree_metadata(finding: Finding) -> dict[str, Any]: if finding.properties: wardline["properties"] = dict(finding.properties) if finding.suppressed is not SuppressionState.ACTIVE: - wardline["suppressed"] = finding.suppressed.value + wardline["suppression_state"] = finding.suppressed.value if finding.suppression_reason is not None: wardline["suppression_reason"] = finding.suppression_reason return {"wardline": wardline} diff --git a/src/wardline/core/finding_identity.py b/src/wardline/core/finding_identity.py new file mode 100644 index 00000000..33e965db --- /dev/null +++ b/src/wardline/core/finding_identity.py @@ -0,0 +1,64 @@ +# src/wardline/core/finding_identity.py +"""The single fingerprint JOIN predicate (P1 scheme-infra). + +Every store join — baseline, judged, waivers — happens here, in ONE place, so +the suppression layer asks "what is this finding's identity verdict?" rather than +re-implementing the waiver > judged > baseline precedence inline. Factoring it +out lets the rekey migration (P4) populate ``drifted_from`` (the old-scheme +fingerprint a verdict carried from) without changing the suppression-layer +signature. In this phase ``drifted_from`` is always None — there is no second +scheme yet. + +Precedence (unchanged from the historical inline logic): an ACTIVE **waiver** +(explicit human intent, carries an expiry) wins over a **judged** FALSE_POSITIVE +verdict (carries the model rationale), which wins over a silent **baseline** +match. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from wardline.core.baseline import Baseline +from wardline.core.judged import JudgedSet +from wardline.core.waivers import WaiverSet + + +@dataclass(frozen=True, slots=True) +class IdentityResolution: + """The verdict of joining one fingerprint against the three stores. + + ``matched_on`` is ``"waiver"`` / ``"judged"`` / ``"baseline"`` / None. + ``reason`` is the waiver reason, then the judge rationale, then None + (baseline carries none). ``drifted_from`` is the old-scheme fingerprint the + verdict was carried from — always None until P4 wires the rekey. + """ + + matched: bool + matched_on: str | None + drifted_from: str | None + reason: str | None + + +def resolve_identity( + fingerprint: str, + *, + baseline: Baseline, + waivers: WaiverSet, + judged: JudgedSet, + today: date, +) -> IdentityResolution: + """Resolve one bare fingerprint against the stores, honouring waiver > judged + > baseline precedence. Pure; ``today`` is injected so waiver expiry is + hermetic. Invokes the stores' existing membership APIs — it is a predicate, + not a fourth store.""" + waiver = waivers.match(fingerprint, today) + if waiver is not None: + return IdentityResolution(matched=True, matched_on="waiver", drifted_from=None, reason=waiver.reason) + judged_fp = judged.match(fingerprint) + if judged_fp is not None: + return IdentityResolution(matched=True, matched_on="judged", drifted_from=None, reason=judged_fp.rationale) + if baseline.contains(fingerprint): + return IdentityResolution(matched=True, matched_on="baseline", drifted_from=None, reason=None) + return IdentityResolution(matched=False, matched_on=None, drifted_from=None, reason=None) diff --git a/src/wardline/core/finding_query.py b/src/wardline/core/finding_query.py index e018cb39..1c7a2cff 100644 --- a/src/wardline/core/finding_query.py +++ b/src/wardline/core/finding_query.py @@ -10,7 +10,7 @@ from fnmatch import fnmatch from typing import Any -from wardline.core.finding import Finding +from wardline.core.finding import Finding, Kind, Severity, SuppressionState # Property keys that carry a trust-tier value across the rule set: 101/109 -> # actual_return/declared_return; 106/107/108 -> tier/arg_taint; 104/105 -> @@ -20,6 +20,33 @@ _ALLOWED = frozenset({"rule_id", "qualname", "severity", "suppression", "kind", "path_glob", "sink", "tier"}) +# Closed-vocabulary predicate keys (N-5, wardline-dc6f44707d): their values come +# from an enum, so a wrong-case or out-of-domain value can NEVER match — a silent +# empty result there is a bad-error an agent cannot diagnose (filigree's lowercase +# severity habit was the live trip). Normalize case-insensitively to the canonical +# casing; reject anything outside the domain loudly, naming the vocabulary. +# rule_id/qualname/sink/tier stay OPEN (packs can extend tiers; rule ids are data). +_CLOSED_VOCAB: dict[str, tuple[str, ...]] = { + "severity": tuple(s.value for s in Severity), + "suppression": tuple(s.value for s in SuppressionState), + "kind": tuple(k.value for k in Kind), +} + + +def _normalize_closed_vocab(where: Mapping[str, Any]) -> dict[str, Any]: + normalized = dict(where) + for key, allowed in _CLOSED_VOCAB.items(): + value = normalized.get(key) + if value is None: + continue + if not isinstance(value, str): + raise ValueError(f"filter {key!r} must be a string; allowed (case-insensitive): {list(allowed)}") + canonical = next((a for a in allowed if a.lower() == value.lower()), None) + if canonical is None: + raise ValueError(f"unknown {key} {value!r}; allowed (case-insensitive): {list(allowed)}") + normalized[key] = canonical + return normalized + def _matches(f: Finding, where: Mapping[str, Any]) -> bool: if (v := where.get("rule_id")) is not None and f.rule_id != v: @@ -47,4 +74,5 @@ def filter_findings(findings: Sequence[Finding], where: Mapping[str, Any] | None unknown = set(where) - _ALLOWED if unknown: raise ValueError(f"unknown filter key(s): {sorted(unknown)}; allowed: {sorted(_ALLOWED)}") + where = _normalize_closed_vocab(where) return [f for f in findings if _matches(f, where)] diff --git a/src/wardline/core/fingerprint_v0.py b/src/wardline/core/fingerprint_v0.py new file mode 100644 index 00000000..12fff803 --- /dev/null +++ b/src/wardline/core/fingerprint_v0.py @@ -0,0 +1,33 @@ +"""FROZEN wlfp1 fingerprint formula — migration-only (`wardline rekey`, P4). + +A byte-exact copy of ``compute_finding_fingerprint`` as it stood BEFORE P3 +(commit ``966cd9f``) dropped ``line_start`` from the hash: the wlfp1 formula with +``str(line_start)`` IN the parts. The migration computes each finding's OLD +fingerprint with this and its NEW fingerprint with the live engine, from a single +scan, to carry baseline/judged/waiver verdicts across the wlfp1->wlfp2 value change. + +This module is NEVER imported by the production scan path — it lives apart so the +identity oracle stays byte-green and so this formula can never be "fixed" again. +It is frozen: do not edit it to track the live engine. ``FINGERPRINT_SCHEME_V0`` is +the scheme label a wlfp1 store carries (P1 stamped it). +""" + +from __future__ import annotations + +import hashlib + +FINGERPRINT_SCHEME_V0 = "wlfp1" + + +def compute_finding_fingerprint_v0( + *, + rule_id: str, + path: str, + line_start: int | None, + qualname: str | None = None, + taint_path: str | None = None, +) -> str: + digest = hashlib.sha256() + parts = (rule_id, path, str(line_start), qualname or "", taint_path or "") + digest.update("\x00".join(parts).encode()) + return digest.hexdigest() diff --git a/src/wardline/core/frontends.py b/src/wardline/core/frontends.py new file mode 100644 index 00000000..a913fc91 --- /dev/null +++ b/src/wardline/core/frontends.py @@ -0,0 +1,128 @@ +# src/wardline/core/frontends.py +"""Language frontend registry for ``run_scan``. + +A ``LanguageFrontend`` encapsulates every language-specific concern that +``run_scan`` previously hardcoded inline: + +- which file suffixes to discover (``.py`` / ``.rs`` / …) +- how to construct the language-specific ``Analyzer`` + +Adding a third language requires only: + +1. Write a class implementing ``LanguageFrontend``. +2. Add it to ``FRONTENDS``. + +``run_scan`` itself never changes. + +Lazy imports +------------ +Neither ``PythonFrontend`` nor ``RustFrontend`` import their analyzer packages +at module load time. All heavyweight imports happen inside ``build_analyzer`` +so that ``import wardline.core.frontends`` remains cheap and does not pull in +``wardline.scanner`` or ``wardline.rust`` eagerly. This preserves the layering +posture that ``run.py`` already had (function-local lazy imports). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from wardline.core.config import WardlineConfig + from wardline.core.protocols import Analyzer + from wardline.scanner.taint.summary_cache import SummaryCache + + +@runtime_checkable +class LanguageFrontend(Protocol): + """Plug-point for a language-specific scan frontend. + + ``name`` is the canonical key in ``FRONTENDS`` and the value callers + pass as ``run_scan(lang=...)``. + + ``suffixes`` is the set of file extensions (including the leading dot, + e.g. ``".py"``) that ``discover`` should collect for this language. + + ``build_analyzer`` constructs a fresh ``Analyzer`` instance. The two + parameters are those that vary per scan and are meaningful for the + language; implementors may safely ignore parameters they do not need + (e.g. ``RustFrontend`` ignores both since ``RustAnalyzer()`` is + zero-configuration). + """ + + name: str + suffixes: frozenset[str] + + def build_analyzer( + self, + *, + config: WardlineConfig, + summary_cache: SummaryCache | None, + ) -> Analyzer: ... + + +class PythonFrontend: + """The released Python frontend — grammar + build_analyzer encapsulated.""" + + name = "python" + suffixes: frozenset[str] = frozenset({".py"}) + + def build_analyzer( + self, + *, + config: WardlineConfig, + summary_cache: SummaryCache | None, + ) -> Analyzer: + from wardline.core.errors import ConfigError + from wardline.scanner.analyzer import build_analyzer + from wardline.scanner.grammar import TrustGrammar, default_grammar + + grammar = default_grammar() + for pack_name, pkg in config.pack_modules.items(): + pack_grammar = getattr(pkg, "grammar", None) + if pack_grammar is not None: + if not isinstance(pack_grammar, TrustGrammar): + raise ConfigError(f"pack {pack_name!r} attribute 'grammar' must be a TrustGrammar instance") + grammar = grammar.extend( + boundary_types=pack_grammar.boundary_types, + rules=pack_grammar.rules, + ) + return build_analyzer(grammar=grammar, summary_cache=summary_cache) + + +class RustFrontend: + """The preview Rust frontend — routes to ``RustAnalyzer``.""" + + name = "rust" + suffixes: frozenset[str] = frozenset({".rs"}) + + def build_analyzer( + self, + *, + config: WardlineConfig, + summary_cache: SummaryCache | None, + ) -> Analyzer: + from wardline.rust.analyzer import RustAnalyzer + + return RustAnalyzer() + + +#: Registry of supported language frontends, keyed by ``lang`` name. +#: +#: To register a third language:: +#: +#: from wardline.core.frontends import FRONTENDS, LanguageFrontend +#: +#: class GoFrontend: +#: name = "go" +#: suffixes: frozenset[str] = frozenset({".go"}) +#: +#: def build_analyzer(self, *, config, summary_cache): +#: from wardline.go.analyzer import GoAnalyzer +#: return GoAnalyzer() +#: +#: FRONTENDS["go"] = GoFrontend() +FRONTENDS: dict[str, LanguageFrontend] = { + "python": PythonFrontend(), + "rust": RustFrontend(), +} diff --git a/src/wardline/core/gitignore.py b/src/wardline/core/gitignore.py new file mode 100644 index 00000000..7537a3d4 --- /dev/null +++ b/src/wardline/core/gitignore.py @@ -0,0 +1,168 @@ +"""Stdlib-only ``.gitignore`` matcher for discovery-time directory pruning. + +This is a *pruning* matcher: ``discover`` consults it to decide whether to descend +into a directory during the ``os.walk``, so a multi-GB third-party tree listed in +``.gitignore`` (``.venv``, ``node_modules``, ``build``…) is never traversed instead of +being walked and post-filtered. It deliberately implements the subset of the +gitignore spec that governs *directory* decisions deterministically: + +* blank lines and ``#`` comments are ignored; +* a leading ``!`` negates (un-ignores) a later-matched pattern; +* a leading ``/`` anchors the pattern to the directory holding the ``.gitignore``; +* a trailing ``/`` restricts the pattern to directories; +* ``*`` matches within a path segment, ``?`` matches one non-``/`` char, and a + leading ``**/`` matches in any directory; +* later patterns win (git's last-match-wins ordering), with negation honoured. + +Patterns are accumulated per-directory as the top-down walk descends, exactly as git +layers nested ``.gitignore`` files. The matcher is intentionally conservative: it is +used ONLY to prune directories, never to drop individual analyzable files from a scan +(file-level exclusion stays the operator's explicit ``exclude`` globs), so a partial +gitignore-grammar gap can only ever UNDER-prune (walk a dir git would skip) — never +silently under-scan tracked source. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class _Pattern: + regex: re.Pattern[str] + negated: bool + dir_only: bool + + +def _translate(pattern: str) -> str: + """Translate one gitignore glob body (no anchor/dir/negation markers) to a regex + matching a single relative POSIX path with no leading slash.""" + out: list[str] = [] + i = 0 + n = len(pattern) + while i < n: + ch = pattern[i] + if ch == "*": + if i + 1 < n and pattern[i + 1] == "*": + # ``**`` — span across path separators. + j = i + 2 + if j < n and pattern[j] == "/": + out.append("(?:.*/)?") + i = j + 1 + continue + out.append(".*") + i = j + continue + out.append("[^/]*") + i += 1 + continue + if ch == "?": + out.append("[^/]") + i += 1 + continue + if ch == "[": + j = i + 1 + if j < n and pattern[j] in "!^": + j += 1 + if j < n and pattern[j] == "]": + j += 1 + while j < n and pattern[j] != "]": + j += 1 + if j >= n: + # Unterminated class — treat ``[`` literally. + out.append(re.escape("[")) + i += 1 + continue + cls = pattern[i + 1 : j] + if cls.startswith("!"): + cls = "^" + cls[1:] + out.append("[" + cls + "]") + i = j + 1 + continue + out.append(re.escape(ch)) + i += 1 + return "".join(out) + + +def _compile(raw: str) -> _Pattern | None: + line = raw.rstrip("\n") + # A trailing backslash escapes a space; otherwise trailing whitespace is trimmed. + if not line.endswith("\\ "): + line = line.rstrip() + if not line or line.startswith("#"): + return None + negated = False + if line.startswith("!"): + negated = True + line = line[1:] + if line.startswith("\\#") or line.startswith("\\!"): + line = line[1:] + dir_only = line.endswith("/") + if dir_only: + line = line[:-1] + if not line: + return None + anchored = line.startswith("/") + has_internal_slash = "/" in line.strip("/") + if anchored: + line = line[1:] + body = _translate(line) + if anchored or has_internal_slash: + # Anchored to the .gitignore's directory: match from the relative root. + regex = re.compile(r"\A" + body + r"\Z") + else: + # A bare name matches at any depth (git matches the basename anywhere). + regex = re.compile(r"(?:\A|/)" + body + r"\Z") + return _Pattern(regex=regex, negated=negated, dir_only=dir_only) + + +@dataclass(frozen=True, slots=True) +class GitignoreMatcher: + """An ordered, layered set of gitignore patterns rooted at a base directory.""" + + _patterns: tuple[_Pattern, ...] + + @classmethod + def empty(cls) -> GitignoreMatcher: + return cls(_patterns=()) + + @classmethod + def from_text(cls, text: str) -> GitignoreMatcher: + compiled = tuple(p for p in (_compile(line) for line in text.splitlines()) if p is not None) + return cls(_patterns=compiled) + + @classmethod + def from_file(cls, path: Path) -> GitignoreMatcher: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return cls.empty() + return cls.from_text(text) + + def extend(self, other: GitignoreMatcher) -> GitignoreMatcher: + """Layer ``other``'s patterns AFTER this matcher's (later wins, git ordering).""" + if not other._patterns: + return self + if not self._patterns: + return other + return GitignoreMatcher(_patterns=self._patterns + other._patterns) + + def __bool__(self) -> bool: + return bool(self._patterns) + + def match(self, relposix: str, *, is_dir: bool) -> bool: + """Return whether ``relposix`` (a POSIX path relative to the matcher base) is + ignored. Last matching pattern wins; a negation un-ignores.""" + ignored = False + matched = False + for pat in self._patterns: + if pat.dir_only and not is_dir: + continue + if pat.regex.search(relposix): + matched = True + ignored = not pat.negated + if not matched: + return False + return ignored diff --git a/src/wardline/core/judge.py b/src/wardline/core/judge.py index 2cc5c424..e7fbc9c7 100644 --- a/src/wardline/core/judge.py +++ b/src/wardline/core/judge.py @@ -104,31 +104,54 @@ class JudgeResponse: @trust_boundary(to_level=L) -> raw input in, trusted level L out (a validator). @trusted(level=L) -> the function is asserted to operate at level L. -The four rules you will see: - PY-WL-101 untrusted-reaches-trusted: a @trusted(level=L) PRODUCER whose ACTUAL - returned taint is strictly less-trusted than its declared level L. Note: - trust-RAISING validators (@trust_boundary, where the body is less-trusted - than the declared return) are EXEMPT from 101 and handled by 102 instead — - so 101 fires on @trusted/@external producers, NOT on @trust_boundary - validators. TRUE positive: a @trusted(level=ASSURED) function that actually - returns raw / MIXED_RAW data. FALSE positive: the engine could not narrow - taint through a guard or helper it cannot model, so the body looks raw though - it is in fact validated. - PY-WL-102 boundary-without-rejection: a trust-raising @trust_boundary that lacks - any raise / falsy-return rejection path. TRUE: a validator that declares it - raises input to GUARDED but never rejects (returns raw input unchanged). - FALSE: rejection happens via a helper the engine did not resolve. - PY-WL-103 broad-except: a broad `except Exception` / bare except at a trusted - tier. TRUE: swallowing errors at a trust boundary. FALSE: re-raised or - handled deliberately in a way the tier modulation over-weighted. - PY-WL-104 silent-except: an except handler that suppresses the error with no - re-raise / log / handling — tier-modulated like 103. TRUE: swallowing an - error at a trusted tier hides a real failure. FALSE: a deliberate, documented - swallow the tier modulation over-weighted. - -Why undecorated code is silent: 101/102 fire ONLY on explicitly anchored / -declared functions (the @trusted / @trust_boundary / @external decorators); -103/104 are silenced on undecorated code by tier modulation in the UNKNOWN_RAW +The rule families you will see (rule_id is in the finding): + Producer integrity — PY-WL-101 untrusted-reaches-trusted: a @trusted(level=L) + PRODUCER whose ACTUAL returned taint is strictly less-trusted than its + declared level L. Note: trust-RAISING validators (@trust_boundary, where the + body is less-trusted than the declared return) are EXEMPT from 101 and + handled by the boundary-integrity family instead — so 101 fires on + @trusted/@external producers, NOT on @trust_boundary validators. TRUE + positive: a @trusted(level=ASSURED) function that actually returns raw / + MIXED_RAW data. FALSE positive: the engine could not narrow taint through a + guard or helper it cannot model, so the body looks raw though it is in fact + validated. + Boundary integrity — a FOUR-WAY PARTITION (exactly one fires per defective + boundary): PY-WL-119 a bare degenerate `return ` boundary; + PY-WL-102 any other trust-raising @trust_boundary with no rejection path + (no raise — including one-hop same-module raising helpers and raising + conversions like int()/Enum lookup — and no falsy return); PY-WL-111 the + ONLY rejection is `assert` (vanishes under python -O); PY-WL-113 a real + rejection exists but a fail-open except handler swallows it and substitutes + a value. TRUE: the boundary really cannot reject (or its rejection is + defeated). FALSE: rejection happens via a helper/path the engine did not + resolve (cross-module helpers are NOT resolved). + Exception handling (tier-modulated) — PY-WL-103 broad `except Exception` / + bare except at a trusted tier; PY-WL-104 a handler that suppresses the + error with no re-raise / log / handling. TRUE: swallowing errors at a trust + boundary. FALSE: re-raised or handled deliberately in a way the tier + modulation over-weighted. + Tainted-sink rules — untrusted data reaching a dangerous sink inside a + trust-declared function: PY-WL-105 (trusted callee), 106 (deserialization: + pickle/yaml/marshal incl. Unpickler/shelve and curated third-party loaders), + 107 (eval/exec/compile), 108 (command/program execution: os.system family, + os.exec*/spawn*, pty.spawn; shlex.quote'd fragments are treated GUARDED), + 112 (subprocess shell=True), 115 (dynamic import/code load incl. runpy), + 116 (path traversal: open/mutation/Path-method/archive-extraction sinks), + 117 (SSRF — URL-position-aware, incl. instance methods on constructed + clients/sessions), 118 (SQL execution; parameterized queries and constant + sqlalchemy text() do NOT fire), 121 XML/XXE, 122 template injection (SSTI), + 123 setattr/getattr with a tainted NAME, 124 native-library load (ctypes), + 125 log injection (message-position only), 126 mail injection (smtplib). + TRUE: attacker-influenceable data reaches the sink. FALSE: the value is + provably constrained by validation the engine could not model, or the taint + is an UNKNOWN_RAW pessimism artefact (unresolved helper), not a real flow. + Declaration hygiene — PY-WL-109 (None leak from a trusted producer), + 110/114 (contradictory or spoofed trust markers), 120 (stored/persisted + data read at a trusted tier without re-validation). + +Why undecorated code is silent: anchored rules fire ONLY on explicitly declared +functions (the @trusted / @trust_boundary / @external decorators); the +tier-modulated rules are silenced on undecorated code in the UNKNOWN_RAW freedom zone. So a finding only exists where a trust boundary was declared. ================================================================ diff --git a/src/wardline/core/judge_run.py b/src/wardline/core/judge_run.py index c12a0e02..33f2689e 100644 --- a/src/wardline/core/judge_run.py +++ b/src/wardline/core/judge_run.py @@ -28,6 +28,7 @@ from wardline.core.paths import judged_path as judged_file from wardline.core.paths import weft_config_path from wardline.core.run import run_scan +from wardline.core.safe_paths import safe_project_file from wardline.core.source_excerpt import extract_excerpt from wardline.core.triage import TriageResult, run_triage @@ -64,7 +65,7 @@ def load_env_key(root: Path) -> None: """ if os.environ.get(_API_KEY_ENV): return - env_path = root / ".env" + env_path = safe_project_file(root, root / ".env", label=".env") if not env_path.is_file(): return for raw in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): diff --git a/src/wardline/core/judged.py b/src/wardline/core/judged.py index ebd470a4..e1c21d9c 100644 --- a/src/wardline/core/judged.py +++ b/src/wardline/core/judged.py @@ -17,6 +17,7 @@ from typing import Any from wardline.core.errors import ConfigError +from wardline.core.finding import FINGERPRINT_SCHEME, require_fingerprint_scheme from wardline.core.optional_deps import require_yaml from wardline.core.safe_paths import safe_project_file @@ -54,6 +55,7 @@ def build_judged_document(entries: Iterable[JudgedFP]) -> dict[str, Any]: unique[e.fingerprint] = e # last write wins (re-judge updates) ordered = sorted(unique.values(), key=lambda e: (e.rule_id, e.fingerprint)) return { + "fingerprint_scheme": FINGERPRINT_SCHEME, "version": JUDGED_VERSION, "findings": [ { @@ -94,6 +96,8 @@ def load_judged(path: Path) -> JudgedSet: raise ConfigError(f"{path.name}: must be a mapping at top level") if not raw: return JudgedSet([]) + # Loader order is load-bearing: empty-guard (above) → scheme → version. + require_fingerprint_scheme(raw, store_name=path.name) if raw.get("version") != JUDGED_VERSION: raise ConfigError(f"{path.name}: version mismatch — expected {JUDGED_VERSION}, got {raw.get('version')!r}") findings = raw.get("findings") or [] diff --git a/src/wardline/core/legis.py b/src/wardline/core/legis.py index f793c4f5..db6d5d69 100644 --- a/src/wardline/core/legis.py +++ b/src/wardline/core/legis.py @@ -42,9 +42,10 @@ from typing import TYPE_CHECKING, Any from wardline._version import __version__ -from wardline.core.attest import git_state, ruleset_hash +from wardline.core.attest import git_state from wardline.core.errors import LegisArtifactError -from wardline.core.finding import Finding, SuppressionState +from wardline.core.finding import FINGERPRINT_SCHEME, Finding, SuppressionState +from wardline.core.ruleset import ruleset_hash from wardline.core.safe_paths import safe_project_file from wardline.core.taints import TaintState @@ -56,6 +57,22 @@ SIG_PREFIX = "hmac-sha256:v2:" ARTIFACT_SIGNATURE_FIELD = "artifact_signature" +# Cross-member scan-artifact keys that legis reads with a DEFAULT, not a hard +# requirement (``findings`` -> empty list, ``dirty`` -> false). A silent rename of one +# of these routes zero defects into legis under a green ``verified`` status — the +# consumer never errors, it just governs an empty scan (weft foundation seam S8 / G1; +# the ``dirty``-key analog is the hub's dirty-freeze issue). The fail-open is legis's to +# close, but the trigger — a producer key drifting — is ours to prevent: the full +# emitted key-set is frozen by tests/conformance/test_legis_artifact_contract_freeze.py. +# Change a value here ONLY in lockstep with the legis hub. ``fingerprint_scheme`` is an +# ignored-unknown envelope field today, frozen alongside so it cannot silently become a +# drifted required key tomorrow. +FINDINGS_FIELD = "findings" +DIRTY_FIELD = "dirty" +FINGERPRINT_SCHEME_FIELD = "fingerprint_scheme" +SCAN_SCOPE_FIELD = "scan_scope" +SCAN_SCOPE_SCHEMA = "wardline-legis-scan-scope-1" + # The one shared vocabulary — legis carries these 8 tiers verbatim (TRUST_TIERS in # legis ingest.py). Sourced from the lattice so the two can never drift. TRUST_TIERS: frozenset[str] = frozenset(t.value for t in TaintState) @@ -169,7 +186,7 @@ def project_finding(finding: Finding) -> dict[str, Any]: "fingerprint": wire["fingerprint"], "qualname": wire["qualname"], "properties": properties, - "suppressed": suppressed, + "suppression_state": suppressed, } @@ -194,6 +211,55 @@ def _git_tree_sha(root: Path) -> str | None: return rev.stdout.strip() or None +def _git_repo_root(root: Path) -> Path | None: + """The containing git repository root, or None when unavailable.""" + try: + rev = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=root, + capture_output=True, + text=True, + ) + except OSError: + return None + if rev.returncode != 0: + return None + value = rev.stdout.strip() + if not value: + return None + try: + return Path(value).resolve() + except OSError: + return None + + +def _relative_posix(path: Path, base: Path) -> str: + """Return *path* relative to *base* in stable POSIX form, allowing ``..``.""" + try: + rel = path.relative_to(base) + except ValueError: + rel = Path(os.path.relpath(path, base)) + return rel.as_posix() if rel.parts else "." + + +def _scan_scope(result: ScanResult, *, root: Path, config: WardlineConfig) -> dict[str, Any]: + """Signed description of the exact scan scope carried by the artifact.""" + resolved_root = root.resolve() + repo_root = _git_repo_root(root) + scope_base = repo_root if repo_root is not None else resolved_root + resolved_source_roots = [ + _relative_posix((resolved_root / source_root).resolve(), scope_base) for source_root in config.source_roots + ] + return { + "schema": SCAN_SCOPE_SCHEMA, + "scan_root": _relative_posix(resolved_root, scope_base), + "is_git_root": repo_root is not None and resolved_root == repo_root, + "source_roots": list(config.source_roots), + "resolved_source_roots": resolved_source_roots, + "scanned_paths": list(result.scanned_paths), + } + + def build_legis_artifact( result: ScanResult, *, @@ -245,9 +311,18 @@ def build_legis_artifact( scan: dict[str, Any] = { "scanner_identity": f"wardline@{__version__}", "rule_set_version": ruleset_hash(config), - "findings": findings, + # Envelope scheme signal (legis ignores unknown top-level fields; it is part + # of the signed body so the artifact_signature covers it). Per-finding + # fingerprints stay BARE — legis reads them from to_jsonl (SARIF-style value). + FINGERPRINT_SCHEME_FIELD: FINGERPRINT_SCHEME, + FINDINGS_FIELD: findings, + # Bind the artifact to the requested and realized scope. This prevents a + # signed subdirectory or narrowed-source-root scan from being indistinguishable + # from a full-repository scan carrying the same commit/tree provenance. + SCAN_SCOPE_FIELD: _scan_scope(result, root=root, config=config), } commit, dirty = git_state(root) + repo_root = _git_repo_root(root) # Signing is CLEAN-TREE-ONLY. A key + clean tree produces the signed, verified # artifact. A key + dirty tree is refused loudly UNLESS ``allow_dirty`` — and even @@ -262,6 +337,11 @@ def build_legis_artifact( raise LegisArtifactError( "cannot sign legis artifact: not a git repository, so commit/tree provenance is unavailable" ) + if repo_root is None or root.resolve() != repo_root: + raise LegisArtifactError( + "cannot sign legis artifact: scan root is not the git repository root; " + "scan the repository root so commit/tree provenance and scan scope match" + ) tree = _git_tree_sha(root) if tree is None: raise LegisArtifactError("cannot sign legis artifact: tree SHA unavailable") @@ -285,7 +365,7 @@ def build_legis_artifact( if tree is not None: scan["tree_sha"] = tree if dirty: - scan["dirty"] = True + scan[DIRTY_FIELD] = True return scan @@ -313,7 +393,7 @@ def legis_artifact_outcome(artifact: Mapping[str, Any]) -> LegisArtifactOutcome: producer). ``signed`` is read from the presence of the signature field — the authoritative record of what :func:`build_legis_artifact` did — not re-computed from key presence.""" - dirty = bool(artifact.get("dirty")) + dirty = bool(artifact.get(DIRTY_FIELD)) signed = ARTIFACT_SIGNATURE_FIELD in artifact return LegisArtifactOutcome( signed=signed, diff --git a/src/wardline/core/node_id.py b/src/wardline/core/node_id.py new file mode 100644 index 00000000..fbf700a0 --- /dev/null +++ b/src/wardline/core/node_id.py @@ -0,0 +1,24 @@ +"""The shared ``NodeId`` contract (spec §5). + +A ``NodeId`` is a deterministic, per-file pre-order index identifying one syntax +node. It is the single correlation key shared across analysis passes +(callgraph ↔ variable-level ↔ rules): if two passes disagree on a node's +``NodeId``, correlation fails *quietly*, so every pass must obtain ids from one +authority over one parse and never re-derive them. + +The type lives here — neutral to any one frontend — so the Rust frontend +(``wardline.rust.nodeid.NodeIdMap``) and, at SP1, the Python frontend share it. +Python currently keys its call-site maps on CPython ``id(node)`` ints directly +(``scanner/taint/callgraph.py``); migrating those annotations to ``NodeId`` is +SP1's unification work, *not* WP0's: ``NewType`` is invariant, so annotating the +write sites alone would cascade type errors through every reader of those maps. +Defining the type here establishes the shared contract without that churn. +""" + +from __future__ import annotations + +from typing import NewType + +NodeId = NewType("NodeId", int) + +__all__ = ["NodeId"] diff --git a/src/wardline/core/paths.py b/src/wardline/core/paths.py index 1eca46fc..774fba36 100644 --- a/src/wardline/core/paths.py +++ b/src/wardline/core/paths.py @@ -85,6 +85,47 @@ def waivers_path(root: Path) -> Path: return weft_state_dir(root) / "waivers.yaml" +def migration_journal_path(root: Path) -> Path: + """The `wardline rekey` (P4) migration journal — remap + per-leg done-flags, + resumable. Lives in wardline's own state subtree alongside the stores it rekeys.""" + return weft_state_dir(root) / "migration_journal.yaml" + + +def _has_project_markers(candidate: Path) -> bool: + """Whether *candidate* looks like a weft project root for wardline's purposes. + + Markers are exactly the two surfaces that change wardline's behaviour: the + operator-authored ``weft.toml`` (config, severity overrides, federation URLs) + and the default ``.weft/wardline/`` state subtree (baseline/waivers/judged). + A ``.weft/`` holding only SIBLING members (filigree/loomweave) deliberately + does NOT count — there is no wardline config or suppression state to miss + there, and counting it would make wardline's own repo (``.weft/filigree``) + warn on every in-repo fixture scan. A non-default ``store_dir`` override can + only be set in ``weft.toml``, so the config marker already covers it. + """ + return (candidate / WEFT_CONFIG_FILE).is_file() or (candidate / _WEFT_DIR / WEFT_MEMBER).is_dir() + + +def enclosing_project_root(root: Path) -> Path | None: + """The nearest STRICT ancestor of ``root`` that is a weft project root, or None. + + The scan root governs finding identity: qualnames are minted relative to it + and baseline/waiver/judged state is read beneath it (N-3, wardline-8669de3576). + A non-None result therefore means ``root`` is a *subdirectory* of a weft + project — the scan would mint qualnames no federated tool matches and skip the + project's suppression state. Returns None when ``root`` itself carries project + markers (it IS a root, including a vendored project inside another) or when no + ancestor does (a fresh, unfederated tree — warning there would be pure noise). + """ + root = root.resolve() + if _has_project_markers(root): + return None + for ancestor in root.parents: + if _has_project_markers(ancestor): + return ancestor + return None + + def sibling_state_dir(root: Path, sibling: str) -> Path: """Preferred location of a sibling member's runtime subtree.""" return root / _WEFT_DIR / sibling diff --git a/src/wardline/core/rekey.py b/src/wardline/core/rekey.py new file mode 100644 index 00000000..b063d356 --- /dev/null +++ b/src/wardline/core/rekey.py @@ -0,0 +1,712 @@ +"""`wardline rekey` — one-shot scan-driven fingerprint migration (P4). + +Carries baseline/judged/waiver verdicts (+ best-effort Filigree) across the +wlfp1->wlfp2 value-rekey. From a SINGLE scan it computes, per finding, both the OLD +fingerprint (the frozen wlfp1 formula, ``line_start`` IN + the old ``taint_path`` +surfaced as ``Finding.taint_path_v0``) and the NEW fingerprint (the live wlfp2 +engine output, ``finding.fingerprint``). The resulting ``old_fp -> new_fp`` remap is +what re-keys the stores. + +This module is the migration brain; the CLI (`cli/rekey.py`) is a thin shell over +it. It never touches the production hash, the analyzer, or the rules. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from wardline.core import paths +from wardline.core.baseline import BASELINE_VERSION +from wardline.core.errors import ConfigError, FiligreeEmitError, WardlineError +from wardline.core.finding import FINGERPRINT_SCHEME, Finding, Kind +from wardline.core.fingerprint_v0 import compute_finding_fingerprint_v0 +from wardline.core.judged import JUDGED_VERSION +from wardline.core.optional_deps import require_yaml +from wardline.core.safe_paths import read_bytes_no_follow, safe_project_file, write_text_no_follow +from wardline.core.waivers import WAIVERS_VERSION + +SNAPSHOT_DIR_NAME = ".rekey_snapshot" +# Why a verdict can orphan (NOT only a source move) — the one explanation both +# surfaces (CLI rekey output, MCP rekey payload) attach to every dropped verdict. +ORPHAN_CAUSE = "source moved/deleted, or a custom multi-emit rule not surfacing taint_path_v0" +# Why a CURRENT-scheme entry can fail to match (NOT a migration orphan): the store is +# already at the live scheme, so a rekey would not touch it — a non-matching entry is +# baseline drift (the source changed since it was recorded), surfaced separately so a +# healthy-but-drifted store is never misread as a dead one (A7, weft-dda1a6d8dd). +STALE_CAUSE = "already at the current scheme but matches no current finding — baseline drift, not a rekey orphan" +# Bounded-by-default display: surfaces emit COUNTS plus at most this many example +# fingerprints with an explicit remainder marker (a bounded page never reads as the +# full set — agent_summary's convention). The full orphan list still lands verbatim +# in the migration journal on apply; the probe is advisory. +ORPHAN_SAMPLE_LIMIT = 10 +# (store filename, list-key inside the YAML doc, version constant) — the three YAML +# legs, in gate-criticality order (baseline first restores the local --fail-on gate). +_STORES: tuple[tuple[str, str, int], ...] = ( + ("baseline.yaml", "entries", BASELINE_VERSION), + ("judged.yaml", "findings", JUDGED_VERSION), + ("waivers.yaml", "waivers", WAIVERS_VERSION), +) + +# Mirror of scanner.rules._POLICY_CONFIG_RULE_ID (core must not import scanner — layering). +# A drift test (test_rekey_population.py) asserts the two stay equal. POLICY-CONFIG is the +# ONE engine rule whose fingerprint is compute_finding_fingerprint-based (line_start-sensitive +# under wlfp1), so it is v0-reconstructed, NOT identity-mapped, unlike the other engine +# diagnostics. Verified mechanically: no other WLN-ENGINE-*/WLN-L3-* DEFECT uses +# compute_finding_fingerprint (they use diagnostics._fingerprint, which is scheme-independent). +_POLICY_CONFIG_RULE_ID = "WLN-ENGINE-POLICY-CONFIG" + + +def is_join_population(f: Finding) -> bool: + """The findings the stores can key on. ``collect_and_write_baseline`` stores EVERY + ``Kind.DEFECT`` (no rule_id filter), and waivers/judged are bare-fingerprint-keyed, + so the remap MUST cover every DEFECT — not just ``PY-WL-*`` — or a stored engine + DEFECT (e.g. ``WLN-ENGINE-POLICY-CONFIG``, ``WLN-L3-MONOTONICITY-VIOLATION``, both + gating ERROR DEFECTs at ENGINE_PATH) silently orphans on migration and resurfaces + ACTIVE (the P4-review gate regression). + + ``RS-WL-*`` (Rust) is INCLUDED — P5-REVISIT decided 2026-06-10 (identity keystone): + Rust identity graduated to baseline-eligible, so an RS-WL DEFECT enters the stores + like any other and a stored RS-WL verdict must migrate, not orphan. (The former + hard exclusion was a no-op pre-merge but a live orphaning path post-graduation.)""" + return f.kind is Kind.DEFECT + + +def _is_scheme_independent(rule_id: str) -> bool: + """True iff the finding's fingerprint did NOT change across the wlfp1->wlfp2 rekey, + i.e. it was hashed by the engine's local ``diagnostics._fingerprint`` (which never + folded ``line_start``), so its ``old_fp == new_fp``. That is the engine-diagnostic + family (``WLN-ENGINE-*`` / ``WLN-L3-*``) EXCEPT ``WLN-ENGINE-POLICY-CONFIG``, which — + alone among engine rules — is hashed via ``compute_finding_fingerprint`` and so is + v0-reconstructed like the policy rules.""" + if rule_id == _POLICY_CONFIG_RULE_ID: + return False + return rule_id.startswith("WLN-ENGINE-") or rule_id.startswith("WLN-L3-") + + +def _old_fingerprint(f: Finding) -> str: + """The finding's pre-rekey (wlfp1) fingerprint. Scheme-independent engine + diagnostics kept their fingerprint, so ``old_fp == new_fp``; everything else + (``PY-WL-*``, ``WLN-ENGINE-POLICY-CONFIG``, and custom-grammar rules) was hashed via + ``compute_finding_fingerprint`` with ``line_start`` IN, so it is reconstructed from + ``finding.location.line_start`` (P3 preserved it as exactly the hashed line) + + ``finding.taint_path_v0`` (the old taint_path, ``None`` where it was ``None``). + + LIMITATION: a CUSTOM-grammar *multi-emit* rule that set a non-empty ``taint_path`` + but did NOT surface ``taint_path_v0`` will reconstruct the wrong ``old_fp`` and its + verdict will orphan. Built-in rules all set ``taint_path_v0`` at their non-None + sites; custom multi-emit rules must do likewise to be move-stable across a rekey.""" + if _is_scheme_independent(f.rule_id): + return f.fingerprint + return compute_finding_fingerprint_v0( + rule_id=f.rule_id, + path=f.location.path, + line_start=f.location.line_start, + qualname=f.qualname, + taint_path=f.taint_path_v0, + ) + + +@dataclass(frozen=True, slots=True) +class FingerprintRemap: + """One finding's identity across the rekey. ``old_fp`` is what the pre-rekey + stores recorded; ``new_fp`` is what the live engine now emits.""" + + old_fp: str + new_fp: str + rule_id: str + path: str + qualname: str | None + + +def compute_old_new_fingerprints(findings: Iterable[Finding]) -> list[FingerprintRemap]: + """The dual-fingerprint contract from one scan, over the join population (every + DEFECT). ``old_fp`` is ``_old_fingerprint(f)`` (v0 reconstruction for + scheme-sensitive rules, identity for scheme-independent engine diagnostics); ``new_fp`` + is the live ``finding.fingerprint``. The v0 reconstruction is validated NON-CIRCULARLY + against the real pre-P3 corpus in ``tests/unit/core/test_rekey_dual_fp.py``. + """ + remaps: list[FingerprintRemap] = [] + for f in findings: + if not is_join_population(f): + continue + remaps.append( + FingerprintRemap( + old_fp=_old_fingerprint(f), + new_fp=f.fingerprint, + rule_id=f.rule_id, + path=f.location.path, + qualname=f.qualname, + ) + ) + return remaps + + +# --- S3: injectivity — per-collision orphan-and-report (NOT a whole-run abort) ---- + + +@dataclass(frozen=True, slots=True) +class RekeyCollision: + """Two findings DISTINCT under wlfp1 (different ``old_fp``) that collapse to one + ``new_fp`` under wlfp2. P2/P3 guarantee no two CURRENT findings share a ``new_fp``, + so this can only mean a discriminator bug — it is reported LOUD (shares the + WLN-ENGINE-FINGERPRINT-COLLISION invariant) and BOTH old_fps are orphaned (neither + verdict is carried), but the rest of the migration proceeds. A whole-run abort + would brick a real project permanently, so we never abort.""" + + new_fp: str + old_fps: tuple[str, ...] + + @property + def message(self) -> str: + return ( + f"WLN-ENGINE-FINGERPRINT-COLLISION: {len(self.old_fps)} pre-rekey fingerprints collapse to " + f"{self.new_fp} under wlfp2 ({', '.join(self.old_fps)}); both verdicts orphaned, not carried." + ) + + +@dataclass(frozen=True, slots=True) +class RemapResult: + """The old_fp -> new_fp lookup the carry legs consume, plus any collisions.""" + + old_to_new: dict[str, str] + collisions: tuple[RekeyCollision, ...] + + +def build_remap(remaps: Iterable[FingerprintRemap]) -> RemapResult: + """Build the ``old_fp -> new_fp`` map. ``old_fp`` is a function of the finding's + inputs (incl. line_start), so it never maps to two new_fps. The inverse CAN + collide (wlfp2 dropped line_start): if >1 distinct old_fp shares a new_fp, ALL + those old_fps are excluded from the map and recorded as a collision.""" + new_to_olds: dict[str, set[str]] = {} + old_to_new: dict[str, str] = {} + for r in remaps: + new_to_olds.setdefault(r.new_fp, set()).add(r.old_fp) + old_to_new[r.old_fp] = r.new_fp + collisions = tuple( + RekeyCollision(new_fp=nf, old_fps=tuple(sorted(olds))) + for nf, olds in sorted(new_to_olds.items()) + if len(olds) > 1 + ) + for c in collisions: + for of in c.old_fps: + old_to_new.pop(of, None) + return RemapResult(old_to_new=old_to_new, collisions=collisions) + + +# --- S4: pre-flight snapshot (the SOLE provenance source on resume) --------------- + + +def snapshot_dir(root: Path) -> Path: + return paths.weft_state_dir(root) / SNAPSHOT_DIR_NAME + + +def snapshot_stores(root: Path) -> tuple[str, ...]: + """Copy each EXISTING YAML store into ``.rekey_snapshot/`` byte-identical. The + snapshot is the immutable provenance source the carry legs read — resume NEVER + re-reads the (already-rewritten) live store. Idempotent: an existing snapshot is + the pre-migration truth and is NEVER clobbered (a second invocation keeps it).""" + sdir = snapshot_dir(root) + state = paths.weft_state_dir(root) + present: list[str] = [] + for name, _key, _ver in _STORES: + live = state / name + # Read the live store WITHOUT following a symlink: an untrusted checkout could + # plant `.weft/wardline/.yaml` as a symlink to a user-readable file outside + # the repo, and a naive read would copy that target into the in-project snapshot + # (arbitrary file disclosure). A symlinked/non-regular/missing store is simply not + # snapshot-eligible. + data = read_bytes_no_follow(live) + if data is None: + continue + present.append(name) + dest = safe_project_file(root, sdir / name, label=name) + if dest.exists(): + continue # never clobber the pre-migration snapshot + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + return tuple(present) + + +# --- S5: carry verdicts from the SNAPSHOT, preserving ALL provenance -------------- + + +@dataclass(frozen=True, slots=True) +class CarryResult: + """A re-keyed store document plus the old_fps carried / orphaned producing it.""" + + document: dict[str, Any] + carried: tuple[str, ...] + orphaned: tuple[str, ...] + + +def _read_old_store(path: Path) -> dict[str, Any]: + """Read an OLD-scheme (wlfp1) store RAW — bypassing the scheme-enforcing loaders, + which would (correctly) reject the pre-rekey snapshot. The migration is the one + place that reads an old-scheme store on purpose.""" + if not path.is_file(): + return {} + yaml = require_yaml("reading the rekey snapshot") + try: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: # pragma: no cover - defensive + raise ConfigError(f"malformed snapshot {path.name}: {exc}") from exc + if not isinstance(loaded, dict): + raise ConfigError(f"snapshot {path.name} is not a mapping") + return loaded + + +def _carry_store(snapshot_path: Path, list_key: str, version: int, old_to_new: dict[str, str]) -> CarryResult: + """Remap one store: swap each entry's ``fingerprint`` old->new while byte-preserving + every OTHER field (rationale/reason/expires/rule_id/path/message/...), drop entries + whose old_fp is not in the remap (orphans), and re-stamp the wlfp2 scheme header. + Deterministic order: (rule_id, path, new fingerprint).""" + loaded = _read_old_store(snapshot_path) + raw_entries = loaded.get(list_key) or [] + # A snapshot store ALREADY at the live scheme needs no remap: its fingerprints are + # wlfp2 keys, and pushing them through the wlfp1->wlfp2 map would orphan every one + # (the mixed-scheme leg of A7, weft-dda1a6d8dd). Identity-carry it untouched. + already_current = loaded.get("fingerprint_scheme") == FINGERPRINT_SCHEME + carried: list[str] = [] + orphaned: list[str] = [] + new_entries: list[dict[str, Any]] = [] + for entry in raw_entries: + old_fp = entry.get("fingerprint") if isinstance(entry, dict) else None + if not isinstance(old_fp, str): + continue # not a valid entry — nothing to carry or orphan + new_fp = old_fp if already_current else old_to_new.get(old_fp) + if new_fp is None: + orphaned.append(old_fp) + continue + carried.append(old_fp) + new_entries.append({**entry, "fingerprint": new_fp}) # byte-preserve all provenance + new_entries.sort(key=lambda e: (str(e.get("rule_id") or ""), str(e.get("path") or ""), e["fingerprint"])) + document = {"fingerprint_scheme": FINGERPRINT_SCHEME, "version": version, list_key: new_entries} + return CarryResult(document=document, carried=tuple(carried), orphaned=tuple(orphaned)) + + +def carry_baseline_forward(snapshot_path: Path, old_to_new: dict[str, str]) -> CarryResult: + return _carry_store(snapshot_path, "entries", BASELINE_VERSION, old_to_new) + + +def carry_judged_forward(snapshot_path: Path, old_to_new: dict[str, str]) -> CarryResult: + return _carry_store(snapshot_path, "findings", JUDGED_VERSION, old_to_new) + + +def carry_waivers_forward(snapshot_path: Path, old_to_new: dict[str, str]) -> CarryResult: + return _carry_store(snapshot_path, "waivers", WAIVERS_VERSION, old_to_new) + + +# --- S6: journal — remap + per-leg done-flags ONLY (snapshot is the content source) - + +JOURNAL_SCHEMA_VERSION = 1 +# Legs in apply order: YAML first (gate-critical — baseline restores the local gate), +# Filigree last (reconciliation debt, no remap endpoint). +LEG_NAMES: tuple[str, ...] = ("baseline", "judged", "waivers", "filigree") +# Maps a YAML leg to (carry fn, snapshot filename, live-store path fn). +_YAML_LEGS: dict[str, tuple[Any, str, Any]] = { + "baseline": (carry_baseline_forward, "baseline.yaml", paths.baseline_path), + "judged": (carry_judged_forward, "judged.yaml", paths.judged_path), + "waivers": (carry_waivers_forward, "waivers.yaml", paths.waivers_path), +} + + +@dataclass +class Leg: + name: str + done: bool = False + carried: list[str] = field(default_factory=list) + orphaned: list[str] = field(default_factory=list) + # Filigree-only: recorded reconciliation debt when the leg soft-fails. + debt: str | None = None + + +@dataclass +class Journal: + """Resumable migration state. Holds the remap + per-leg done-flags + orphan/collision + lists ONLY — NOT the carried verdict content (the snapshot is the sole provenance + source; duplicating content here would let two copies diverge). Resume reads + ``remap`` from here + content from the snapshot, and NEVER re-scans.""" + + remap: dict[str, str] + collisions: list[RekeyCollision] = field(default_factory=list) + legs: list[Leg] = field(default_factory=lambda: [Leg(n) for n in LEG_NAMES]) + schema_version: int = JOURNAL_SCHEMA_VERSION + fingerprint_scheme_from: str = "wlfp1" + fingerprint_scheme_to: str = FINGERPRINT_SCHEME + # The snapshotted stores carried no scheme stamp (pre-P1) — orphans here MAY be a + # fingerprint-formula change (pre-705acfe), not source churn. Surfaced as a caution. + snapshot_prescheme: bool = False + + def leg(self, name: str) -> Leg: + return next(leg for leg in self.legs if leg.name == name) + + def next_pending_leg(self) -> str | None: + return next((leg.name for leg in self.legs if not leg.done), None) + + @property + def complete(self) -> bool: + return all(leg.done for leg in self.legs) + + +def new_journal(remaps: Iterable[FingerprintRemap]) -> Journal: + """Build a fresh journal from a single scan's dual-fingerprints.""" + result = build_remap(remaps) + return Journal(remap=result.old_to_new, collisions=list(result.collisions)) + + +def journal_to_doc(journal: Journal) -> dict[str, Any]: + return { + "schema_version": journal.schema_version, + "fingerprint_scheme_from": journal.fingerprint_scheme_from, + "fingerprint_scheme_to": journal.fingerprint_scheme_to, + "snapshot_prescheme": journal.snapshot_prescheme, + "remap": dict(journal.remap), + "collisions": [{"new_fp": c.new_fp, "old_fps": list(c.old_fps)} for c in journal.collisions], + "legs": [ + {"name": leg.name, "done": leg.done, "carried": leg.carried, "orphaned": leg.orphaned, "debt": leg.debt} + for leg in journal.legs + ], + } + + +def write_journal(path: Path, journal: Journal, *, root: Path) -> None: + # ``root`` is REQUIRED (confinement is non-optional, matching _write_store_doc). + yaml = require_yaml("writing the rekey journal") + path = safe_project_file(root, path, label=path.name) + path.parent.mkdir(parents=True, exist_ok=True) + # Atomic write: a crash mid-write must leave the OLD journal intact (or none) — never + # a truncated doc that load_journal rejects, which would brick --resume. + tmp = path.with_name(path.name + ".tmp") + # safe_project_file guarded `path` but NOT `tmp`; write the temp file no-follow so a + # pre-planted `.tmp` symlink cannot redirect the write to an arbitrary + # user-writable target before os.replace runs. + write_text_no_follow( + tmp, yaml.safe_dump(journal_to_doc(journal), sort_keys=False, allow_unicode=True), label=tmp.name + ) + os.replace(tmp, path) + + +def load_journal(path: Path) -> Journal: + yaml = require_yaml("loading the rekey journal") + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(loaded, dict) or "remap" not in loaded: + raise ConfigError(f"malformed migration journal {path.name}") + legs = [ + Leg( + name=str(d["name"]), + done=bool(d.get("done", False)), + carried=list(d.get("carried") or []), + orphaned=list(d.get("orphaned") or []), + debt=d.get("debt"), + ) + for d in loaded.get("legs") or [] + ] + collisions = [ + RekeyCollision(new_fp=str(c["new_fp"]), old_fps=tuple(c.get("old_fps") or [])) + for c in loaded.get("collisions") or [] + ] + return Journal( + remap=dict(loaded["remap"]), + collisions=collisions, + legs=legs or [Leg(n) for n in LEG_NAMES], + schema_version=int(loaded.get("schema_version", JOURNAL_SCHEMA_VERSION)), + fingerprint_scheme_from=str(loaded.get("fingerprint_scheme_from", "wlfp1")), + fingerprint_scheme_to=str(loaded.get("fingerprint_scheme_to", FINGERPRINT_SCHEME)), + snapshot_prescheme=bool(loaded.get("snapshot_prescheme", False)), + ) + + +# --- S7: per-leg-atomic, idempotent application (crash-safe; snapshot is the source) - + + +def _write_store_doc(root: Path, live_path: Path, document: dict[str, Any]) -> None: + yaml = require_yaml("writing a rekeyed store") + safe = safe_project_file(root, live_path, label=live_path.name) + safe.parent.mkdir(parents=True, exist_ok=True) + safe.write_text( + yaml.safe_dump(document, sort_keys=False, default_flow_style=False, allow_unicode=True), encoding="utf-8" + ) + + +def apply_pending_legs( + root: Path, journal: Journal, *, findings: Sequence[Finding] | None = None, filigree: Any = None +) -> Journal: + """Apply each not-done leg, crash-safely: carry from the SNAPSHOT -> write the live + store -> persist the done-flag. A crash after the write but before the flag leaves + the leg not-done, so resume re-carries from the (untouched) snapshot and reproduces + identical content — never an empty store, because carry NEVER reads the live store. + YAML legs are idempotent; the Filigree leg soft-fails into recorded debt and never + aborts the (already-complete) YAML migration.""" + jpath = paths.migration_journal_path(root) + sdir = snapshot_dir(root) + for leg in journal.legs: + if leg.done: + continue + if leg.name == "filigree": + _apply_filigree_leg(leg, findings, filigree) + write_journal(jpath, journal, root=root) + continue + carry_fn, snap_name, live_path_fn = _YAML_LEGS[leg.name] + snap = sdir / snap_name + if not snap.is_file(): + # The store never existed pre-migration — nothing to carry, create nothing. + leg.done = True + write_journal(jpath, journal, root=root) + continue + result = carry_fn(snap, journal.remap) + _write_store_doc(root, live_path_fn(root), result.document) + leg.carried = list(result.carried) + leg.orphaned = list(result.orphaned) + leg.done = True + write_journal(jpath, journal, root=root) # persist the flag AFTER the store write + return journal + + +# --- S8: Filigree leg — LAST, reconciliation debt, soft-fail, never aborts ---------- + + +def _apply_filigree_leg(leg: Leg, findings: Sequence[Finding] | None, filigree: Any) -> None: + """Re-emit the current scan's join-population findings under their NEW (wlfp2) + fingerprints; Filigree's ``mark_unseen`` sweep closes the now-absent old_fp + associations (there is no remap endpoint, so this is reconciliation debt, honestly). + Soft-fail: an unreachable / 401 / 5xx / bad-payload sibling records debt and leaves + the leg not-done — it NEVER aborts the already-complete YAML migration.""" + if findings is None: + # Pure --resume without a scan: cannot re-emit. Defer (debt), do not re-scan. + # This check is FIRST: a pending Filigree leg on resume must NOT be silently + # completed (the forward run already no-op-completes it when no URL was set). + leg.done = False + leg.debt = "Filigree reconciliation deferred — re-run `wardline rekey` (not --resume) to re-emit." + return + if filigree is None: + # No Filigree configured (forward run, no --filigree-url) — nothing to reconcile. + leg.done = True + leg.debt = None + return + population = [f for f in findings if is_join_population(f)] + scanned = sorted({f.location.path for f in population}) + try: + result = filigree.emit(population, scanned_paths=scanned) + except FiligreeEmitError as exc: + leg.done = False + leg.debt = f"Filigree rejected the re-emit (bad payload/endpoint): {exc}" + return + if result.reachable and not result.failed and not result.warnings: + leg.done = True + leg.debt = None + leg.carried = [f.fingerprint for f in population] + elif result.reachable: + # 2xx but the server rejected some findings (failed>0) or warned — NOT a clean + # reconciliation. Record debt and leave the leg pending so a re-run retries. + leg.done = False + leg.debt = ( + f"Filigree accepted the re-emit with {result.failed} rejected" + + (f" and warnings: {'; '.join(result.warnings)}" if result.warnings else "") + + " — re-run `wardline rekey` to reconcile the remainder." + ) + else: + leg.done = False + leg.debt = ( + f"Filigree unreachable (status={result.status}); old fingerprint associations may orphan. " + "Re-run `wardline rekey` to reconcile." + ) + + +# --- S9: --probe (read-only cross-check; writes NOTHING) -------------------------- + + +def _store_fingerprints(root: Path) -> dict[str, tuple[str | None, set[str]]]: + """Per live store: its ``fingerprint_scheme`` header (None when pre-scheme) and the + fingerprints it records, read RAW (a pre-migration store would SCHEME_MISMATCH the + enforcing loaders). The scheme is load-bearing: a store ALREADY at the live scheme + holds wlfp2 fingerprints, and judging it against the wlfp1-reconstructed remap keys + misreads every healthy entry as orphaned (A7, weft-dda1a6d8dd). Read-only.""" + out: dict[str, tuple[str | None, set[str]]] = {} + state = paths.weft_state_dir(root) + for name, key, _ver in _STORES: + p = state / name + if not p.is_file(): + continue + loaded = _read_old_store(p) + scheme = loaded.get("fingerprint_scheme") + fps = { + e["fingerprint"] + for e in (loaded.get(key) or []) + if isinstance(e, dict) and isinstance(e.get("fingerprint"), str) + } + if fps: + out[name] = (scheme if isinstance(scheme, str) else None, fps) + return out + + +def _dir_has_prescheme_store(dir_path: Path) -> bool: + """True iff a store in ``dir_path`` holds entries but carries NO ``fingerprint_scheme`` + header — i.e. it predates P1's scheme stamp. Such a store MAY also predate the + taint-resolution-drift fix (705acfe), in which case its fingerprints fold resolved-taint + values that v0 reconstruction cannot reproduce — so its verdicts orphan from a + fingerprint-FORMULA change, not source churn. The header alone can't distinguish the two + eras, so callers surface the possibility rather than mislabel every orphan a source move.""" + for name, key, _ver in _STORES: + p = dir_path / name + if not p.is_file(): + continue + loaded = _read_old_store(p) + if loaded.get(key) and not loaded.get("fingerprint_scheme"): + return True + return False + + +@dataclass(frozen=True, slots=True) +class ProbeReport: + scanned_findings: int + matched: int + orphaned: tuple[str, ...] + collisions: tuple[RekeyCollision, ...] + per_store: dict[str, int] # store name -> count of its old_fps with no current finding + prescheme: bool = False # a live store predates the scheme stamp (possible formula drift) + # Stores ALREADY stamped with the live scheme (sorted). A rekey is a no-op for + # them; their entries are matched against the CURRENT fingerprints, never the + # wlfp1 remap keys (A7, weft-dda1a6d8dd). + current_scheme_stores: tuple[str, ...] = () + # Current-scheme entries with no current finding — baseline drift (STALE_CAUSE), + # not migration orphans; they do not dirty the probe. + stale: tuple[str, ...] = () + # True when every populated store already carries the live scheme (vacuously when + # none holds fingerprints): no fingerprint migration is pending. + no_op: bool = False + + @property + def clean(self) -> bool: + return not self.orphaned and not self.collisions + + +def probe(root: Path, findings: Sequence[Finding]) -> ProbeReport: + """Read-only dry run: which stored verdicts will carry, which orphan, any collisions. + Each store is judged against ITS OWN scheme: a store still at wlfp1 (or pre-scheme) + against the reconstructed old-fingerprint remap keys, a store already at the live + scheme against the current scan's fingerprints (a rekey would not touch it, so a + healthy wlfp2 baseline reports matched=N / orphaned=0 / clean — A7, + weft-dda1a6d8dd). Writes nothing — no snapshot, no journal, no store rewrite.""" + remaps = compute_old_new_fingerprints(findings) + result = build_remap(remaps) + keys = set(result.old_to_new) + new_fps = {r.new_fp for r in remaps} + matched: set[str] = set() + orphaned: set[str] = set() + stale: set[str] = set() + per_store: dict[str, int] = {} + current_scheme_stores: list[str] = [] + migration_pending = False + for name, (scheme, fps) in sorted(_store_fingerprints(root).items()): + if scheme == FINGERPRINT_SCHEME: + current_scheme_stores.append(name) + matched |= fps & new_fps + stale |= fps - new_fps + continue + migration_pending = True + store_orphans = fps - keys + matched |= fps & keys + orphaned |= store_orphans + if store_orphans: + per_store[name] = len(store_orphans) + return ProbeReport( + scanned_findings=len(remaps), + matched=len(matched), + orphaned=tuple(sorted(orphaned)), + # Collisions stay LOUD even when no migration is pending: >1 old_fp collapsing + # to one new_fp means two CURRENT findings share a fingerprint — a discriminator + # bug (WLN-ENGINE-FINGERPRINT-COLLISION), not a migration artifact. A healthy + # baseline has none, so this never dirties the A7 clean-no-op verdict. + collisions=result.collisions, + per_store=per_store, + prescheme=_dir_has_prescheme_store(paths.weft_state_dir(root)), + current_scheme_stores=tuple(current_scheme_stores), + stale=tuple(sorted(stale)), + no_op=not migration_pending, + ) + + +# --- Orchestrators (scan-free: the CLI runs the scan and passes findings) ---------- + + +def run_rekey(root: Path, findings: Sequence[Finding], *, filigree: Any = None) -> Journal: + """Fresh migration: snapshot FIRST (pre-migration provenance), plan the remap from + the single scan, write the journal, then apply the legs. Idempotent via the snapshot.""" + # Refuse a forward re-run over an ALREADY-COMPLETE migration. The snapshot (wlfp1) and + # journal persist after success (only --rollback clears them), and the live stores are + # now wlfp2; re-snapshot never clobbers, so a second forward run would re-carry from the + # STALE wlfp1 snapshot and DROP any verdict added since the migration. (Incomplete — e.g. + # a deferred Filigree leg — still re-runs, preserving the converge/retry path.) + jpath = paths.migration_journal_path(root) + if jpath.is_file() and load_journal(jpath).complete: + raise WardlineError( + "this project's fingerprint migration is already complete — " + "use `wardline rekey --rollback` to undo it, or delete " + f"{snapshot_dir(root)} + {jpath} to migrate afresh." + ) + # Refuse a rekey when every populated store ALREADY carries the live scheme: there + # is nothing to migrate, and re-keying wlfp2 entries through the wlfp1 remap would + # orphan every healthy verdict (the destructive twin of the A7 probe misread, + # weft-dda1a6d8dd). Checked BEFORE the snapshot — a refused run writes nothing. + populated_schemes = [scheme for scheme, _fps in _store_fingerprints(root).values()] + if populated_schemes and all(s == FINGERPRINT_SCHEME for s in populated_schemes): + raise WardlineError( + f"every store is already at the {FINGERPRINT_SCHEME} fingerprint scheme — " + "no fingerprint migration is pending; a rekey would only orphan healthy " + "verdicts. Nothing to do (run `wardline rekey --probe` for the per-store view)." + ) + snapshot_stores(root) # must precede any store write + journal = new_journal(compute_old_new_fingerprints(findings)) + # Detect from the immutable snapshot (byte-identical to the pre-migration live stores) + # so the caution persists onto the journal for --resume display too. + journal.snapshot_prescheme = _dir_has_prescheme_store(snapshot_dir(root)) + write_journal(jpath, journal, root=root) + return apply_pending_legs(root, journal, findings=findings, filigree=filigree) + + +def resume_rekey(root: Path, *, findings: Sequence[Finding] | None = None, filigree: Any = None) -> Journal: + """Resume from the journal — applies only not-done legs, NEVER re-scans. YAML legs + re-carry from the snapshot; the Filigree leg defers (debt) if no findings are given.""" + jpath = paths.migration_journal_path(root) + if not jpath.is_file(): + raise WardlineError("no migration journal to resume — run `wardline rekey` first") + journal = load_journal(jpath) + return apply_pending_legs(root, journal, findings=findings, filigree=filigree) + + +# --- S10: forward-only rollback (YAML clean+complete; Filigree may orphan) --------- + + +@dataclass(frozen=True, slots=True) +class RollbackResult: + restored: tuple[str, ...] + + +def rollback(root: Path) -> RollbackResult: + """Restore the YAML stores byte-identical from the snapshot and remove the journal + + snapshot. YAML rollback is clean and complete. Filigree associations created by the + forward run are NOT reversed (no remap endpoint; re-emitting would need the old scan) + — the caller warns about that orphan risk.""" + sdir = snapshot_dir(root) + snap_files = [name for name, _k, _v in _STORES if (sdir / name).is_file()] + jpath = paths.migration_journal_path(root) + if not snap_files and not jpath.is_file(): + raise WardlineError(f"no rekey snapshot under {sdir} — nothing to roll back") + state = paths.weft_state_dir(root) + restored: list[str] = [] + for name in snap_files: + live = safe_project_file(root, state / name, label=name) + live.parent.mkdir(parents=True, exist_ok=True) + live.write_bytes((sdir / name).read_bytes()) + restored.append(name) + # Remove the journal, then the snapshot files + dir (best-effort cleanup). + jpath.unlink(missing_ok=True) + for name, _k, _v in _STORES: + (sdir / name).unlink(missing_ok=True) + if sdir.is_dir() and not any(sdir.iterdir()): + sdir.rmdir() + return RollbackResult(restored=tuple(restored)) diff --git a/src/wardline/core/ruleset.py b/src/wardline/core/ruleset.py new file mode 100644 index 00000000..66354e92 --- /dev/null +++ b/src/wardline/core/ruleset.py @@ -0,0 +1,159 @@ +# src/wardline/core/ruleset.py +"""Effective-scan-policy identity (``ruleset_hash``) — a low-tier, dependency-free home. + +``ruleset_hash`` derives a deterministic ``"sha256:"`` over the *effective scan +policy*: the analyzer version, source scope, excludes, rule enablement/severity, +provenance policy, custom source/sanitiser trust semantics, and trusted-pack +identity/config/grammar. Two scans (or two attestations) sharing a hash were shaped by +the same policy inputs that materially affect taint results. + +This module lives BELOW both the taint engine and the attestation layer on purpose. The +engine's project resolver and parse pipeline need the policy hash to key their summary +cache, and the attestation builder needs it for the signed payload — but ``core.attest`` +sits ABOVE the engine (it imports ``core.run``, which drives a scan), so the engine must +not reach UP into ``core.attest`` for the hash. Housing ``ruleset_hash`` here lets both +sides import DOWN without an engine→attest layering inversion (formerly masked by a +function-local deferred import in ``scanner.taint.project_resolver``). + +Zero-dependency: stdlib only, plus ``wardline._version`` and a TYPE_CHECKING-only +reference to :class:`wardline.core.config.WardlineConfig` (so this module pulls in no +config/engine/attest code at import time). +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import fields, is_dataclass +from enum import Enum +from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING, Any + +from wardline._version import __version__ + +if TYPE_CHECKING: + from wardline.core.config import WardlineConfig + + +def _file_sha256(path: Path | None) -> str | None: + if path is None or not path.is_file(): + return None + digest = hashlib.sha256() + try: + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + +def _module_origin(module: ModuleType) -> Path | None: + spec = getattr(module, "__spec__", None) + origin = getattr(spec, "origin", None) or getattr(module, "__file__", None) + if not isinstance(origin, str) or origin in {"built-in", "frozen"}: + return None + return Path(origin) + + +def _callable_policy_identity(value: Any) -> dict[str, Any]: + code = getattr(value, "__code__", None) + code_hash = None + if code is not None: + digest = hashlib.sha256() + digest.update(code.co_code) + digest.update(repr(code.co_consts).encode("utf-8", "backslashreplace")) + digest.update(repr(code.co_names).encode("utf-8", "backslashreplace")) + digest.update(repr(code.co_varnames).encode("utf-8", "backslashreplace")) + code_hash = digest.hexdigest() + return { + "module": getattr(value, "__module__", None), + "qualname": getattr(value, "__qualname__", getattr(value, "__name__", repr(value))), + "code_sha256": code_hash, + } + + +def _class_policy_identity(value: type) -> dict[str, Any]: + source_path = None + try: + import inspect + + source = inspect.getsourcefile(value) + source_path = Path(source) if source is not None else None + except (OSError, TypeError): + source_path = None + return { + "module": getattr(value, "__module__", None), + "qualname": getattr(value, "__qualname__", value.__name__), + "rule_id": getattr(value, "rule_id", None), + "source_sha256": _file_sha256(source_path), + } + + +def _jsonable_policy_value(value: Any) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, Enum): + return value.value + if isinstance(value, Mapping): + return {str(k): _jsonable_policy_value(v) for k, v in sorted(value.items(), key=lambda item: str(item[0]))} + if isinstance(value, tuple | list): + return [_jsonable_policy_value(v) for v in value] + if isinstance(value, set | frozenset): + rendered = [_jsonable_policy_value(v) for v in value] + return sorted(rendered, key=lambda item: json.dumps(item, sort_keys=True, default=str)) + if isinstance(value, type): + return _class_policy_identity(value) + if is_dataclass(value) and not isinstance(value, type): + return {field.name: _jsonable_policy_value(getattr(value, field.name)) for field in fields(value)} + if callable(value): + return _callable_policy_identity(value) + return repr(value) + + +def _pack_policy_identity(name: str, module: Any) -> dict[str, Any]: + if not isinstance(module, ModuleType): + return {"name": name, "loaded": False, "module_repr": repr(module)} + origin = _module_origin(module) + return { + "name": name, + "loaded": True, + "module": getattr(module, "__name__", name), + "version": getattr(module, "__version__", None), + "source_sha256": _file_sha256(origin), + "config": _jsonable_policy_value(getattr(module, "config", None)), + "grammar": _jsonable_policy_value(getattr(module, "grammar", None)), + } + + +def _effective_scan_policy(config: WardlineConfig) -> dict[str, Any]: + return { + "schema": "wardline-effective-scan-policy-v1", + "wardline_version": __version__, + "source_roots": list(config.source_roots), + "exclude": list(config.exclude), + "rules": { + "enable": sorted(config.rules_enable), + "severity": {str(k): str(v) for k, v in sorted(config.rules_severity.items())}, + }, + "provenance_clash": config.provenance_clash, + "untrusted_sources": sorted(config.untrusted_sources), + "sanitisers": sorted(config.sanitisers), + "packs": [_pack_policy_identity(name, config.pack_modules.get(name)) for name in config.packs], + } + + +def ruleset_hash(config: WardlineConfig) -> str: + """A deterministic ``"sha256:"`` over the effective scan policy. + + The signed identity covers the analyzer version, source scope, excludes, rule + enablement/severity, provenance policy, custom source/sanitiser trust semantics, + and trusted pack identity/config/grammar. Two attestations with the same hash are + therefore comparable evidence bundles under the policy inputs that materially shape + scan results. + """ + canonical = json.dumps(_effective_scan_policy(config), sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return f"sha256:{digest}" diff --git a/src/wardline/core/run.py b/src/wardline/core/run.py index 47861b70..9f618ad8 100644 --- a/src/wardline/core/run.py +++ b/src/wardline/core/run.py @@ -9,10 +9,11 @@ from __future__ import annotations import hashlib +from collections.abc import Callable from dataclasses import dataclass, replace from datetime import date from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from wardline.core import config as config_mod from wardline.core.baseline import Baseline, load_baseline @@ -28,10 +29,11 @@ Severity, SuppressionState, ) +from wardline.core.frontends import FRONTENDS from wardline.core.judged import load_judged -from wardline.core.paths import baseline_path, judged_path, weft_config_path +from wardline.core.paths import baseline_path, enclosing_project_root, judged_path, weft_config_path from wardline.core.protocols import Analyzer -from wardline.core.suppression import apply_suppressions, gate_trips, severity_gates +from wardline.core.suppression import SEVERITY_ORDER, apply_suppressions, gate_trips, severity_gates from wardline.core.waivers import WaiverSet, load_project_waivers if TYPE_CHECKING: @@ -52,11 +54,19 @@ class ScanSummary: baselined: int waived: int judged: int + # Every NON-DEFECT finding (facts, metrics, classifications). The defect buckets + # above (active/baselined/waived/judged) partition the DEFECTs; this is the rest, + # so the five together sum to ``total`` exactly (the buckets-sum-to-total invariant — + # weft-f506e5f845). Before this bucket existed, non-defect facts/metrics were silently + # uncounted and total != sum(buckets). + informational: int = 0 # Files DISCOVERED but NEVER analysed despite being analysable — a genuine # under-scan (parse errors, too-deep skips, missing source roots). Benign # no-module skips (WLN-ENGINE-NO-MODULE) are EXCLUDED — see UNANALYZED_RULE_IDS. - # These are Severity.NONE FACTs that never trip the severity gate, so they are - # counted separately to surface a silent under-scan / false-green. + # PARSE-ERROR/FILE-FAILED are gate-eligible ERROR DEFECTs (fail-closed: unscanned + # code must not read GREEN); FILE-SKIPPED/SOURCE-ROOT-MISSING stay non-gating FACTs. + # This is an OVERLAY counted by rule_id across both buckets, NOT a partition + # member — it is not added into the sum-to-total identity. unanalyzed: int = 0 @@ -82,18 +92,37 @@ class ScanResult: _SEVERITY_VALUES: frozenset[str] = frozenset(s.value for s in Severity) +_VERDICT_VALUES: frozenset[str] = frozenset({"NOT_EVALUATED", "PASSED", "FAILED"}) + + @dataclass(frozen=True, slots=True) class GateDecision: tripped: bool fail_on: str | None exit_class: int # 0 clean, 1 gate tripped, 2 reserved for tool errors (CLI layer) + # An explicit verdict so a bare scan (no --fail-on) never reads as a clean PASS: a + # vacuous green is the worst false signal for a governance suite (weft-b937e53854). + # NOT_EVALUATED — no threshold ran (fail_on is None); the gate did not judge. + # PASSED — a threshold ran and nothing tripped. + # FAILED — a threshold ran and tripped. + verdict: str # A human-readable verdict so "summary.active:0 + gate.tripped:true" never reads as - # a bug: ``reason`` names the count and class of defects that decided it (and the - # escape hatches when the trip is solely from suppressed-but-gated findings); - # ``evaluated`` names the population it judged (unsuppressed by default vs honored - # under --trust-suppressions). Both None when no threshold is set (no gate). + # a bug: ``reason`` names the count and class of defects that decided it (and, for + # NOT_EVALUATED, what WOULD trip); ``evaluated`` names the population it judged + # (unsuppressed by default vs honored under --trust-suppressions). ``would_trip_at`` is + # the highest severity at which the gate WOULD trip on that population (None if nothing + # would), computed in every branch so a bare scan still tells the agent the worst it found. reason: str | None = None evaluated: str | None = None + would_trip_at: str | None = None + # The unanalyzed sub-gate (A4, wardline-7fd0f3a82c). ``fail_on_unanalyzed`` is the knob + # state; the two ``*_tripped`` flags decompose ``tripped`` so consumers (CLI echo, MCP + # next_actions) can attribute a trip to its sub-gate without parsing ``reason``. The + # decomposition lives IN the decision — not as a surface-level exit-code OR — so the + # MCP gate block (which has no exit code) can express the unanalyzed gate at all. + fail_on_unanalyzed: bool = False + severity_tripped: bool = False + unanalyzed_tripped: bool = False def __post_init__(self) -> None: # Enforce the invariants the ``gate_decision`` factory upholds so a *second* @@ -102,18 +131,32 @@ def __post_init__(self) -> None: # GateDecision value. if self.exit_class != (1 if self.tripped else 0): raise ValueError(f"exit_class {self.exit_class} contradicts tripped={self.tripped}") - # A tripped gate must always carry its verdict — never silently None. - if self.tripped and self.reason is None: - raise ValueError("a tripped gate must carry a reason") - # No threshold (fail_on None) ⟺ no verdict; a threshold always produces both. - if (self.fail_on is None) != (self.reason is None): - raise ValueError("reason must be present iff fail_on is set") - if (self.fail_on is None) != (self.evaluated is None): - raise ValueError("evaluated must be present iff fail_on is set") + if self.verdict not in _VERDICT_VALUES: + raise ValueError(f"verdict {self.verdict!r} is not one of {sorted(_VERDICT_VALUES)}") + # The verdict is keyed to the gate state — these guards are what stop a tripped gate + # from ever serialising as a pass (the dogfood #2 regression). The gate is evaluated + # when EITHER sub-gate is configured (a severity threshold or the unanalyzed knob). + if (self.verdict == "NOT_EVALUATED") != (self.fail_on is None and not self.fail_on_unanalyzed): + raise ValueError("verdict NOT_EVALUATED iff neither --fail-on nor --fail-on-unanalyzed is set") + if (self.verdict == "FAILED") != self.tripped: + raise ValueError("verdict FAILED iff the gate tripped") + # Every decision carries its reason now — including NOT_EVALUATED (what would trip). + if self.reason is None: + raise ValueError("a gate decision must always carry a reason") # fail_on is always a Severity value (the factory passes Severity.value); an - # arbitrary string satisfies the iff-guards above but is still an illegal state. + # arbitrary string satisfies the guards above but is still an illegal state. if self.fail_on is not None and self.fail_on not in _SEVERITY_VALUES: raise ValueError(f"fail_on {self.fail_on!r} is not a valid Severity value") + if self.would_trip_at is not None and self.would_trip_at not in _SEVERITY_VALUES: + raise ValueError(f"would_trip_at {self.would_trip_at!r} is not a valid Severity value") + # The sub-trip decomposition must EXPLAIN the overall trip — an overall trip no + # sub-gate accounts for (or a sub-trip without its knob) is an illegal state. + if self.tripped != (self.severity_tripped or self.unanalyzed_tripped): + raise ValueError("tripped must equal (severity_tripped or unanalyzed_tripped)") + if self.severity_tripped and self.fail_on is None: + raise ValueError("severity_tripped requires a fail_on threshold") + if self.unanalyzed_tripped and not self.fail_on_unanalyzed: + raise ValueError("unanalyzed_tripped requires fail_on_unanalyzed") def run_scan( @@ -127,6 +170,9 @@ def run_scan( trusted_packs: tuple[str, ...] = (), strict_defaults: bool = False, trust_suppressions: bool = False, + skip_suppression: bool = False, + lang: str = "python", + progress_callback: Callable[[dict[str, Any]], None] | None = None, ) -> ScanResult: """Discover → analyze → apply suppressions. Pure function of (disk + config). @@ -146,10 +192,17 @@ def run_scan( trusted local / judge-DX behaviour, an explicit operator trust decision suitable only for a trusted checkout, never for enforcement on untrusted PR content. The secure CI ratchet is the operator-supplied, unforgeable ``--new-since`` instead. + + ``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``. """ - from wardline.scanner.analyzer import build_analyzer - from wardline.scanner.grammar import TrustGrammar, default_grammar - from wardline.scanner.taint.summary_cache import SummaryCache + 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}") + frontend = FRONTENDS[lang] + suffixes = frontend.suffixes + from wardline.scanner.taint.summary_cache import SummaryCache, summary_cache_auth_secret_from_env # An EXPLICIT --config path must NOT silently fall back to default policy # (dropping the operator's severity overrides/excludes) whether it is missing @@ -166,7 +219,7 @@ def run_scan( ) cache = None if cache_dir is not None: - cache = SummaryCache(cache_dir=cache_dir) + cache = SummaryCache(cache_dir=cache_dir, cache_auth_secret=summary_cache_auth_secret_from_env()) from wardline.core.taints import _PROVENANCE_CLASH token_clash = _PROVENANCE_CLASH.set(cfg.provenance_clash) @@ -178,8 +231,10 @@ def run_scan( with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - files = discover(root, cfg, confine_to_root=confine_to_root) + files = discover(root, cfg, confine_to_root=confine_to_root, suffixes=suffixes) captured_warnings = list(w) + if progress_callback is not None: + progress_callback({"phase": "discovered", "files_discovered": len(files)}) for warn in captured_warnings: msg = str(warn.message) if not msg.startswith("WLN-ENGINE-FILE-SKIPPED: "): @@ -189,19 +244,19 @@ def run_scan( warn.filename, warn.lineno, ) - grammar = default_grammar() - for pack_name, pkg in cfg.pack_modules.items(): - pack_grammar = getattr(pkg, "grammar", None) - if pack_grammar is not None: - if not isinstance(pack_grammar, TrustGrammar): - raise ConfigError(f"pack {pack_name!r} attribute 'grammar' must be a TrustGrammar instance") - grammar = grammar.extend( - boundary_types=pack_grammar.boundary_types, - rules=pack_grammar.rules, - ) - - analyzer: Analyzer = build_analyzer(grammar=grammar, summary_cache=cache) + analyzer: Analyzer = frontend.build_analyzer(config=cfg, summary_cache=cache) + if progress_callback is not None: + progress_callback({"phase": "analyzing", "files_discovered": len(files)}) raw = list(analyzer.analyze(files, cfg, root=root)) + if progress_callback is not None: + progress_callback( + { + "phase": "analyzed", + "files_discovered": len(files), + "files_analyzed": len(files), + "findings": len(raw), + } + ) for warn in captured_warnings: msg = str(warn.message) if msg.startswith("WLN-ENGINE-FILE-SKIPPED: "): @@ -232,26 +287,76 @@ def run_scan( properties={"source_root": src}, ) ) + # N-3 (wardline-8669de3576): the scan root GOVERNS finding identity — qualnames + # are minted relative to it, suppression state is read beneath it, and output + # defaults under it. A scan rooted in a SUBDIRECTORY of a weft project silently + # mints qualnames no federated tool (Loomweave/Filigree/dossier) matches and + # skips the project baseline. Surface it as a FACT so it reaches the CLI + # warning, the MCP result, and findings.jsonl alike. Not an under-scan (every + # discovered file WAS analysed), so it never counts toward unanalyzed. + enclosing = enclosing_project_root(root) + if enclosing is not None: + rel = root.resolve().relative_to(enclosing) + prefix_parts = rel.parts[1:] if rel.parts and rel.parts[0] == "src" else rel.parts + # module_dotted_name strips one leading src/ component, so scanning P/src + # mints the SAME qualnames as scanning P — the prefix is empty there and + # the message must not claim a phantom 'src.' prefix. + qualname_prefix = ".".join(prefix_parts) + qualname_clause = ( + f"qualnames are minted relative to the scan root (missing the '{qualname_prefix}.' " + "package prefix other Weft tools expect), " + if qualname_prefix + else "qualnames are minted relative to the scan root, " + ) + raw.append( + Finding( + rule_id="WLN-ENGINE-NESTED-SCAN-ROOT", + message=( + f"scan root '{rel.as_posix()}' is a subdirectory of the weft project at " + f"{enclosing}: {qualname_clause}the project's baseline/waivers/judged state " + "is not loaded, and output defaults under the subdirectory. Scan the project " + "root for federation-stable results." + ), + severity=Severity.NONE, + kind=Kind.FACT, + location=Location(path=rel.as_posix()), + fingerprint=_fp("WLN-ENGINE-NESTED-SCAN-ROOT", rel.as_posix()), + properties={ + "scan_root": str(root.resolve()), + "project_root": str(enclosing), + "qualname_prefix": qualname_prefix, + }, + ) + ) if cache is not None: cache.save() - baseline = load_baseline(baseline_path(root)) - waivers = WaiverSet(load_project_waivers(root)) - judged = load_judged(judged_path(root)) today = date.today() - # The emitted findings ALWAYS carry the full suppression annotations (baseline, - # waiver, judged) so ``suppressed=…`` is visible in output regardless of trust. - findings = apply_suppressions(raw, baseline, waivers, today=today, judged=judged) - # The gate population applies ZERO suppression but runs the SAME structural - # transforms apply_suppressions does (esp. the lineless-DEFECT→non-gating-FACT - # downgrade), so the only difference vs ``findings`` is the suppression sources — - # NOT ``list(raw)``, which would let a lineless DEFECT trip the gate. When the - # operator trusts repo suppressions, gate_findings is None and the gate falls back - # to the suppressed ``findings`` (None SENTINEL, never an accidental falsy-empty). gate_findings: list[Finding] | None - if trust_suppressions: + if skip_suppression: + # `wardline rekey` (P4) scans a project whose stores are still OLD-scheme; + # loading them would (correctly) SCHEME_MISMATCH. Skip the store files entirely + # and apply EMPTY suppression — the structural transforms (esp. the lineless- + # DEFECT→FACT downgrade) STILL run, so the result is exactly the join population + # the stores hold, derived without reading the stores it is about to migrate. + findings = apply_suppressions(raw, Baseline(frozenset()), WaiverSet([]), today=today, judged=None) gate_findings = None else: - gate_findings = apply_suppressions(raw, Baseline(frozenset()), WaiverSet([]), today=today, judged=None) + baseline = load_baseline(baseline_path(root)) + waivers = WaiverSet(load_project_waivers(root)) + judged = load_judged(judged_path(root)) + # The emitted findings ALWAYS carry the full suppression annotations (baseline, + # waiver, judged) so ``suppressed=…`` is visible in output regardless of trust. + findings = apply_suppressions(raw, baseline, waivers, today=today, judged=judged) + # The gate population applies ZERO suppression but runs the SAME structural + # transforms apply_suppressions does (esp. the lineless-DEFECT→non-gating-FACT + # downgrade), so the only difference vs ``findings`` is the suppression sources — + # NOT ``list(raw)``, which would let a lineless DEFECT trip the gate. When the + # operator trusts repo suppressions, gate_findings is None and the gate falls back + # to the suppressed ``findings`` (None SENTINEL, never an accidental falsy-empty). + if trust_suppressions: + gate_findings = None + else: + gate_findings = apply_suppressions(raw, Baseline(frozenset()), WaiverSet([]), today=today, judged=None) if new_since is not None: changed_files = get_changed_files_since(new_since, root) @@ -289,6 +394,7 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: baselined=sum(1 for f in defects if f.suppressed is SuppressionState.BASELINED), waived=sum(1 for f in defects if f.suppressed is SuppressionState.WAIVED), judged=sum(1 for f in defects if f.suppressed is SuppressionState.JUDGED), + informational=len(findings) - len(defects), unanalyzed=sum(1 for f in findings if f.rule_id in UNANALYZED_RULE_IDS), ) resolved_root = root.resolve() @@ -305,30 +411,93 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: ) -def gate_decision(result: ScanResult, fail_on: Severity | None) -> GateDecision: - """Translate a scan into a pass/fail verdict. A trip is data, not an error.""" - if fail_on is None: - return GateDecision(tripped=False, fail_on=None, exit_class=0) +def _would_trip_at(gate_population: list[Finding]) -> str | None: + """The HIGHEST severity at which the gate would trip on this population, or None. + + ``gate_trips`` is monotonic in the threshold (a lower threshold catches a superset), so + the highest tripping threshold equals the max severity of any active gating defect — the + single most useful "set --fail-on X to catch the worst thing here" signal. + """ + for sev in reversed(SEVERITY_ORDER): # CRITICAL → ERROR → WARN → INFO + if gate_trips(gate_population, sev): + return sev.value + return None + + +def _not_evaluated_reason(would_trip_at: str | None, evaluated: str, *, gate: str = "gate") -> str: + base = f"no --fail-on threshold set; {gate} did not evaluate" + if would_trip_at is None: + return f"{base}. No active defect would trip at any threshold; evaluated {evaluated}" + return ( + f"{base}. would_trip_at {would_trip_at} — pass --fail-on {would_trip_at} (or lower) to " + f"enforce; evaluated {evaluated}" + ) + + +def gate_decision(result: ScanResult, fail_on: Severity | None, *, fail_on_unanalyzed: bool = False) -> GateDecision: + """Translate a scan into a pass/fail verdict. A trip is data, not an error. + + Two independent sub-gates compose into one decision: the severity gate (``fail_on``) + and the unanalyzed gate (``fail_on_unanalyzed`` — trips when any file was discovered + but never analysed; benign no-module skips excluded, see ``ScanSummary.unanalyzed``). + Folding the unanalyzed gate in HERE (A4, wardline-7fd0f3a82c) is what lets both + 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). + # 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 assert gate_population is not None # narrow for mypy; the sentinel branch set findings - tripped = gate_trips(gate_population, fail_on) - sev = fail_on.value + would_trip_at = _would_trip_at(gate_population) evaluated = ( "post-suppression (repository baseline/waiver/judged honored — trusted-local)" if honors_suppressions else "unsuppressed (repository baseline/waiver/judged ignored)" ) - reason = _gate_reason(result, fail_on, tripped=tripped, honors_suppressions=honors_suppressions) + if fail_on is None and not fail_on_unanalyzed: + # NOT a clean pass — the gate never ran. The verdict says so; would_trip_at names the + # worst severity present so the agent's first bare scan is not a false green. + return GateDecision( + tripped=False, + fail_on=None, + exit_class=0, + verdict="NOT_EVALUATED", + reason=_not_evaluated_reason(would_trip_at, evaluated), + evaluated=evaluated, + would_trip_at=would_trip_at, + ) + severity_tripped = fail_on is not None and gate_trips(gate_population, fail_on) + unanalyzed_tripped = bool(fail_on_unanalyzed and result.summary.unanalyzed) + tripped = severity_tripped or unanalyzed_tripped + if fail_on is not None: + reason = _gate_reason(result, fail_on, tripped=severity_tripped, honors_suppressions=honors_suppressions) + else: + # Unanalyzed-only gate: the unanalyzed sub-gate evaluated but the severity gate + # never ran — the reason must say so, or a PASSED here is a vacuous severity green. + reason = _not_evaluated_reason(would_trip_at, evaluated, gate="the severity gate") + if fail_on_unanalyzed: + n = result.summary.unanalyzed + prefix = ( + f"{n} file(s) discovered but not analyzed (fail_on_unanalyzed tripped)" + if unanalyzed_tripped + else "0 files unanalyzed (fail_on_unanalyzed passed)" + ) + reason = f"{prefix}; {reason}" return GateDecision( tripped=tripped, - fail_on=sev, + fail_on=fail_on.value if fail_on is not None else None, exit_class=1 if tripped else 0, + verdict="FAILED" if tripped else "PASSED", reason=reason, evaluated=evaluated, + would_trip_at=would_trip_at, + fail_on_unanalyzed=fail_on_unanalyzed, + severity_tripped=severity_tripped, + unanalyzed_tripped=unanalyzed_tripped, ) diff --git a/src/wardline/core/safe_paths.py b/src/wardline/core/safe_paths.py index 4b15216e..2d8c5534 100644 --- a/src/wardline/core/safe_paths.py +++ b/src/wardline/core/safe_paths.py @@ -2,12 +2,14 @@ from __future__ import annotations +import os +import stat from pathlib import Path from wardline.core.errors import WardlineError -def safe_project_file(root: Path, target: Path, *, label: str | None = None) -> Path: +def safe_project_path(root: Path, target: Path, *, label: str | None = None) -> Path: """Return ``target`` only if writes to it stay under ``root``. Fixed install/write targets must not follow a final symlink out of a project. @@ -17,7 +19,7 @@ def safe_project_file(root: Path, target: Path, *, label: str | None = None) -> root_resolved = root.resolve() candidate = target if target.is_absolute() else root_resolved / target name = label or candidate.name - if candidate.exists() and candidate.is_symlink(): + if candidate.is_symlink(): raise WardlineError(f"{name}: refusing to write through a symlink") resolved = candidate.resolve(strict=False) if not (resolved == root_resolved or resolved.is_relative_to(root_resolved)): @@ -28,7 +30,80 @@ def safe_project_file(root: Path, target: Path, *, label: str | None = None) -> return candidate +def safe_project_file(root: Path, target: Path, *, label: str | None = None) -> Path: + """Return ``target`` only if file writes to it stay under ``root``.""" + return safe_project_path(root, target, label=label) + + def safe_write_text(root: Path, target: Path, content: str, *, label: str | None = None) -> None: """Safely write ``content`` to ``target`` under ``root``, resolving symlinks and boundary checks.""" safe_path = safe_project_file(root, target, label=label) - safe_path.write_text(content, encoding="utf-8") + safe_path.parent.mkdir(parents=True, exist_ok=True) + _write_text_no_follow(safe_path, content, label=label or safe_path.name) + + +def write_text_no_follow(target: Path, content: str, *, label: str | None = None) -> None: + """Write ``content`` without following a final-component symlink.""" + target.parent.mkdir(parents=True, exist_ok=True) + _write_text_no_follow(target, content, label=label or target.name) + + +def _write_text_no_follow(path: Path, content: str, *, label: str) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(path, flags, 0o666) + except OSError as exc: + if path.is_symlink(): + raise WardlineError(f"{label}: refusing to write through a symlink") from exc + raise + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + + +def safe_read_text_if_regular( + root: Path, + target: Path, + *, + label: str | None = None, + encoding: str = "utf-8", +) -> str | None: + """Read an optional fixed project file only when it is a regular in-root file. + + Returns ``None`` for missing, symlinked, non-regular, escaping, unreadable, or + undecodable paths. This is for fail-soft discovery/config rungs such as + sibling ``ephemeral.port`` files, where an attacker-controlled repository must + not be able to make Wardline block on a FIFO/device or follow a symlink. + """ + try: + safe_path = safe_project_file(root, target, label=label) + if not stat.S_ISREG(safe_path.stat(follow_symlinks=False).st_mode): + return None + return safe_path.read_text(encoding=encoding) + except (OSError, UnicodeDecodeError, WardlineError): + return None + + +def read_bytes_no_follow(path: Path) -> bytes | None: + """Read ``path`` as bytes without following a final-component symlink. + + Path-based twin of :func:`write_text_no_follow` (no ``root`` join — the caller owns + a concrete in-state path). Returns ``None`` for a missing, symlinked, non-regular, + or unreadable target, so a checkout that plants one of wardline's own state files as + a symlink can neither make wardline follow it off-box nor crash. Used for the + byte-identical store snapshot, where text decoding is not acceptable.""" + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + if not stat.S_ISREG(path.stat(follow_symlinks=False).st_mode): + return None + fd = os.open(path, flags) + except OSError: + return None + try: + with os.fdopen(fd, "rb") as handle: + return handle.read() + except OSError: + return None diff --git a/src/wardline/core/sarif.py b/src/wardline/core/sarif.py index c2219417..9e92a2cb 100644 --- a/src/wardline/core/sarif.py +++ b/src/wardline/core/sarif.py @@ -15,6 +15,7 @@ from wardline import __version__ from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState +from wardline.core.safe_paths import safe_write_text, write_text_no_follow from wardline.scanner.flow_trace import build_finding_code_flow if TYPE_CHECKING: @@ -107,7 +108,7 @@ def _result(finding: Finding, rule_index: int, context: AnalysisContext | None = "level": _LEVEL[finding.severity], "message": {"text": finding.message}, "locations": [{"physicalLocation": physical}], - "partialFingerprints": {"wardlineFingerprint/v1": finding.fingerprint}, + "partialFingerprints": {"wardlineFingerprint/v2": finding.fingerprint}, "properties": props, } if finding.suppressed is not SuppressionState.ACTIVE: @@ -160,10 +161,13 @@ def build_sarif(findings: Sequence[Finding], context: AnalysisContext | None = N class SarifSink: - def __init__(self, path: Path) -> None: + 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: - self._path.parent.mkdir(parents=True, exist_ok=True) content = json.dumps(build_sarif(findings, context), indent=2, ensure_ascii=False) - self._path.write_text(content, encoding="utf-8") + if self._root is not None: + safe_write_text(self._root, self._path, content, label=self._path.name) + else: + write_text_no_follow(self._path, content, label=self._path.name) diff --git a/src/wardline/core/scan_file_workflow.py b/src/wardline/core/scan_file_workflow.py index 29969fe1..14009ecf 100644 --- a/src/wardline/core/scan_file_workflow.py +++ b/src/wardline/core/scan_file_workflow.py @@ -7,12 +7,13 @@ from wardline.core.errors import WardlineError from wardline.core.explain import TaintExplanation, explanation_from_context -from wardline.core.filigree_emit import EmitResult +from wardline.core.filigree_emit import EmitResult, filigree_disabled_reason from wardline.core.filigree_issue import ( FileResult, IdentityAttachResult, attach_loomweave_identity_for_qualname, identity_attach_result_to_json, + plugin_for_finding, ) from wardline.core.finding import Finding, Kind, Severity, SuppressionState from wardline.core.run import gate_decision, run_scan @@ -52,6 +53,7 @@ def _emit_to_dict(result: EmitResult | None, *, configured: bool) -> dict[str, A "created": 0, "updated": 0, "failed": 0, + "failures": [], "warnings": [], "disabled_reason": None if configured else "not configured", } @@ -61,8 +63,17 @@ def _emit_to_dict(result: EmitResult | None, *, configured: bool) -> dict[str, A "created": result.created, "updated": result.updated, "failed": result.failed, + # PDR-0023: per-finding reject reasons so a partial ingest is distinguishable from clean. + "failures": [f.to_wire() for f in result.failures], "warnings": list(result.warnings), - "disabled_reason": None if result.reachable else "filigree unreachable", + # Delegate to the shared 401/403-vs-5xx-vs-transport ladder (dogfood #5) instead + # of flattening every soft failure to "filigree unreachable". + "disabled_reason": filigree_disabled_reason( + reachable=result.reachable, + status=result.status, + token_sent=result.token_sent, + url=result.url, + ), } @@ -174,11 +185,15 @@ def scan_file_findings( issue_id=file_result.issue_id, filer=filigree_filer, loomweave_client=loomweave_client, + plugin=plugin_for_finding(finding), ) else: identity_result = IdentityAttachResult.skipped("finding has no qualname") else: - identity_result = IdentityAttachResult.not_attempted("no issue_id from Filigree promote") + # No promote was ever attempted on this branch — name the actual cause, + # matching _file_to_dict's configured=False wording (the old "no issue_id + # from Filigree promote" misattributed the failure). + identity_result = IdentityAttachResult.not_attempted("no Filigree URL configured") item = _finding_base(finding, explanation) item["promotion"] = _file_to_dict(file_result, selected=selected_here, configured=filigree_filer is not None) item["identity_attach"] = identity_attach_result_to_json(identity_result) @@ -193,9 +208,16 @@ def scan_file_findings( "baselined": result.summary.baselined, "waived": result.summary.waived, "judged": result.summary.judged, + "informational": result.summary.informational, "unanalyzed": result.summary.unanalyzed, }, - "gate": {"tripped": decision.tripped, "fail_on": decision.fail_on, "exit_class": decision.exit_class}, + "gate": { + "tripped": decision.tripped, + "fail_on": decision.fail_on, + "exit_class": decision.exit_class, + "verdict": decision.verdict, + "would_trip_at": decision.would_trip_at, + }, "filigree_emit": _emit_to_dict(emit_result, configured=filigree_emitter is not None), "active_defects": active_out, "selected_count": len(selected & known_active), diff --git a/src/wardline/core/scan_jobs.py b/src/wardline/core/scan_jobs.py new file mode 100644 index 00000000..987d410e --- /dev/null +++ b/src/wardline/core/scan_jobs.py @@ -0,0 +1,547 @@ +"""File-backed Wardline scan jobs. + +The job surface is intentionally local and daemon-free: ``start`` writes a stable +handle under ``.weft/wardline/jobs/`` and a worker process updates status JSON as it +moves through scan, artifact write, optional enrichment, and gate evaluation. +""" + +from __future__ import annotations + +import json +import os +import re +import signal +import subprocess +import sys +import threading +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from wardline.core.agent_summary import build_agent_summary +from wardline.core.emit import JsonlSink +from wardline.core.errors import WardlineError +from wardline.core.filigree_emit import ( + EmitResult, + FiligreeEmitter, + filigree_destination, + filigree_disabled_reason, +) +from wardline.core.finding import Severity +from wardline.core.run import baseline_migration_hint, gate_decision, run_scan +from wardline.core.safe_paths import safe_project_path, safe_write_text, write_text_no_follow +from wardline.core.sarif import SarifSink + +_JOB_ID_RE = re.compile(r"^[0-9a-f]{32}$") +_JOB_STEPS_TOTAL = 4 +_HEARTBEAT_INTERVAL_SECONDS = 5.0 +_STALE_AFTER_SECONDS = 30.0 +DEFAULT_SCAN_JOB_TIMEOUT_SECONDS = 30 * 60 +_TERMINAL_STATUSES = {"completed", "completed_with_enrichment_failure", "failed", "cancelled"} + + +class _ScanJobTimeout(WardlineError): + """Internal terminal error for scan-job timeouts.""" + + +def _now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def jobs_dir(root: Path) -> Path: + return safe_project_path(root, root / ".weft" / "wardline" / "jobs", label="wardline jobs") + + +def job_dir(root: Path, job_id: str) -> Path: + if not _JOB_ID_RE.fullmatch(job_id): + raise WardlineError(f"invalid scan job id: {job_id!r}") + return safe_project_path(root, jobs_dir(root) / job_id, label="wardline scan job") + + +def status_path(root: Path, job_id: str) -> Path: + return job_dir(root, job_id) / "status.json" + + +def request_path(root: Path, job_id: str) -> Path: + return job_dir(root, job_id) / "request.json" + + +def read_scan_job_status(root: Path, job_id: str) -> dict[str, Any]: + root = root.resolve() + path = status_path(root, job_id) + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise WardlineError(f"scan job {job_id} not found under {root}") from exc + if not isinstance(parsed, dict): + raise WardlineError(f"scan job {job_id} status is malformed") + return _refresh_liveness(root, job_id, parsed) + + +def _pid_is_scan_job_worker(pid: int, job_id: str) -> bool: + """Best-effort confirmation that *pid* is THIS project's scan-job worker for *job_id* + before signaling it, so a forged/stale ``status.json`` (an untrusted checkout can + pre-create ``.weft/wardline/jobs//status.json`` with any ``pid``) cannot make + ``cancel`` ``killpg`` an unrelated same-user process group. + + The worker is spawned with ``start_new_session=True``, so it is its own + process-group leader: ``os.getpgid(pid) == pid`` is a portable necessary condition a + forged pid pointing at an arbitrary process almost never meets. On Linux we ALSO + require ``/proc//cmdline`` to name the worker module and this exact job id — + which a forged target process cannot fake. Where ``/proc`` is absent (non-Linux), the + group-leader check stands alone.""" + try: + if os.getpgid(pid) != pid: + return False + except OSError: + return False + try: + argv = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\x00") + except OSError: + return True # no /proc (non-Linux): rely on the group-leader check + return b"wardline.cli.scan_job_worker" in argv and job_id.encode() in argv + + +def cancel_scan_job(root: Path, job_id: str) -> dict[str, Any]: + """Cancel a non-terminal scan job and persist the terminal status.""" + root = root.resolve() + status = read_scan_job_status(root, job_id) + if str(status.get("status")) in _TERMINAL_STATUSES: + return status + pid = _status_pid(status) + if pid is not None and _pid_alive(pid) and _pid_is_scan_job_worker(pid, job_id): + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except PermissionError as exc: + raise WardlineError(f"scan job {job_id} worker could not be cancelled: permission denied") from exc + status.update( + { + "status": "cancelled", + "phase": "cancelled", + "failure_kind": "cancelled", + "error": "cancelled by operator", + } + ) + return _write_status(root, job_id, status) + + +def _write_status(root: Path, job_id: str, status: dict[str, Any]) -> dict[str, Any]: + timestamp = _now() + status.setdefault("created_at", timestamp) + status["updated_at"] = timestamp + status["heartbeat"] = timestamp + safe_write_text(root, status_path(root, job_id), json.dumps(status, indent=2, sort_keys=True) + "\n") + return status + + +def _parse_timestamp(value: object) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _status_pid(status: dict[str, Any]) -> int | None: + pid = status.get("pid") + if isinstance(pid, int) and pid > 0: + return pid + return None + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _refresh_liveness(root: Path, job_id: str, status: dict[str, Any]) -> dict[str, Any]: + if str(status.get("status")) in _TERMINAL_STATUSES: + return status + pid = _status_pid(status) + if pid is not None and not _pid_alive(pid): + status.update( + { + "status": "failed", + "phase": "failed", + "failure_kind": "stale_worker", + "error": f"scan job worker pid {pid} is no longer running", + } + ) + return _write_status(root, job_id, status) + heartbeat = _parse_timestamp(status.get("heartbeat")) + if str(status.get("status")) in {"running", "running_stale"} and heartbeat is not None: + stale_for = (datetime.now(UTC) - heartbeat).total_seconds() + if stale_for > _STALE_AFTER_SECONDS: + status["status"] = "running_stale" + status["stale_for_seconds"] = int(stale_for) + status.setdefault("warning", "scan job heartbeat is stale; worker may still be running") + return status + status.pop("stale_for_seconds", None) + if status.get("status") == "running_stale": + status["status"] = "running" + return status + + +def _start_heartbeat( + root: Path, + job_id: str, + status: dict[str, Any], + lock: threading.Lock, + stop: threading.Event, +) -> threading.Thread: + def beat() -> None: + while not stop.wait(_HEARTBEAT_INTERVAL_SECONDS): + with lock: + if str(status.get("status")) in _TERMINAL_STATUSES: + return + raw_progress = status.get("progress") + progress = dict(raw_progress) if isinstance(raw_progress, dict) else {} + progress.setdefault("message", "scan still running") + status["progress"] = progress + try: + _write_status(root, job_id, status) + except OSError: + return + + thread = threading.Thread(target=beat, name=f"wardline-scan-job-{job_id[:8]}-heartbeat", daemon=True) + thread.start() + return thread + + +def _progress(step: int, **extra: object) -> dict[str, object]: + data: dict[str, object] = {"steps_completed": step, "steps_total": _JOB_STEPS_TOTAL} + data.update(extra) + return data + + +def _base_status(job_id: str, request: dict[str, Any]) -> dict[str, Any]: + timestamp = _now() + return { + "job_id": job_id, + "status": "queued", + "phase": "queued", + "progress": _progress(0), + "created_at": timestamp, + "updated_at": timestamp, + "heartbeat": timestamp, + "request": request, + "artifacts": {}, + "failure_kind": None, + "error": None, + } + + +def _normalize_request(request: dict[str, Any]) -> dict[str, Any]: + normalized = dict(request) + timeout_seconds = normalized.get("timeout_seconds") + if timeout_seconds is None: + normalized["timeout_seconds"] = DEFAULT_SCAN_JOB_TIMEOUT_SECONDS + return normalized + + +def _filigree_status(result: EmitResult | None) -> dict[str, object]: + if result is None: + return { + "configured": False, + "reachable": None, + "created": 0, + "updated": 0, + "failed": 0, + "failures": [], + "warnings": [], + "disabled_reason": "not configured", + "destination": filigree_destination(None), + } + return { + "configured": True, + "reachable": result.reachable, + "created": result.created, + "updated": result.updated, + "failed": result.failed, + # PDR-0023: per-finding reject reasons so a partial ingest is distinguishable from clean. + "failures": [f.to_wire() for f in result.failures], + "warnings": list(result.warnings), + "disabled_reason": filigree_disabled_reason( + reachable=result.reachable, + status=result.status, + token_sent=result.token_sent, + url=result.url, + ), + "destination": filigree_destination(result.url), + } + + +def _write_scan_artifact( + root: Path, + output: Path, + fmt: str, + result: Any, + decision: Any, + *, + filigree_emit: dict[str, Any] | None = None, + migration_hint: str | None = None, +) -> None: + sink_root = root if output.is_relative_to(root.resolve()) else None + if fmt == "sarif": + SarifSink(output, root=sink_root).write(result.findings, result.context) + return + if fmt == "agent-summary": + # Embed the ALREADY-COMPUTED gate decision (which honors fail_on_unanalyzed) and the + # real Filigree emit block, so the artifact's gate + integration state match the + # terminal job status instead of an unanalyzed-blind, pre-enrichment snapshot. + summary = build_agent_summary(result, decision, filigree_emit=filigree_emit, migration_hint=migration_hint) + content = json.dumps(summary.to_dict(), sort_keys=True) + "\n" + # No-follow, matching the JSONL/SARIF sinks: an untrusted checkout could plant the + # worker's --output as a symlink and a raw write_text would truncate its target. + if sink_root is not None: + safe_write_text(sink_root, output, content, label=output.name) + else: + write_text_no_follow(output, content, label=output.name) + return + JsonlSink(output, root=sink_root).write(result.findings) + + +def start_scan_job(root: Path, request: dict[str, Any], *, foreground: bool = False) -> dict[str, Any]: + root = root.resolve() + request = _normalize_request(request) + job_id = uuid.uuid4().hex + directory = job_dir(root, job_id) + directory.mkdir(parents=True, exist_ok=True) + status = _base_status(job_id, request) + _write_status(root, job_id, status) + safe_write_text(root, request_path(root, job_id), json.dumps(request, indent=2, sort_keys=True) + "\n") + if foreground: + run_scan_job_worker(root, job_id) + return read_scan_job_status(root, job_id) + + stdout_path = directory / "stdout.log" + stderr_path = directory / "stderr.log" + with stdout_path.open("ab") as stdout, stderr_path.open("ab") as stderr: + proc = subprocess.Popen( # noqa: S603 + [sys.executable, "-m", "wardline.cli.scan_job_worker", str(root), job_id], + cwd=root, + stdout=stdout, + stderr=stderr, + start_new_session=True, + ) + status["status"] = "running" + status["phase"] = "starting" + status["pid"] = proc.pid + status["artifacts"] = {"stdout": str(stdout_path), "stderr": str(stderr_path)} + return _write_status(root, job_id, status) + + +def run_scan_job_worker(root: Path, job_id: str) -> None: + root = root.resolve() + request_file = request_path(root, job_id) + request = json.loads(request_file.read_text(encoding="utf-8")) + if not isinstance(request, dict): + raise WardlineError(f"scan job {job_id} request is malformed") + status = read_scan_job_status(root, job_id) + status.setdefault("pid", os.getpid()) + default_output = job_dir(root, job_id) / _default_output_name(str(request.get("format", "jsonl"))) + output = Path(str(request.get("output") or default_output)) + if not output.is_absolute(): + output = root / output + raw_artifacts = status.get("artifacts") + artifacts = dict(raw_artifacts) if isinstance(raw_artifacts, dict) else {} + artifacts["findings"] = str(output) + lock = threading.Lock() + heartbeat_stop = threading.Event() + heartbeat_thread: threading.Thread | None = None + previous_alarm: tuple[int, Any] | None = None + + def progress_update(event: dict[str, Any]) -> None: + with lock: + phase = event.get("phase") + if isinstance(phase, str): + status["phase"] = phase + raw_progress = status.get("progress") + progress = dict(raw_progress) if isinstance(raw_progress, dict) else {} + progress.update(event) + progress.setdefault("steps_completed", 1) + progress.setdefault("steps_total", _JOB_STEPS_TOTAL) + status["progress"] = progress + _write_status(root, job_id, status) + + def timeout_handler(signum: int, frame: object) -> None: + timeout_seconds = request.get("timeout_seconds") + raise _ScanJobTimeout(f"scan job timed out after {timeout_seconds} seconds") + + try: + status.update({"status": "running", "phase": "scanning", "progress": _progress(1), "artifacts": artifacts}) + with lock: + _write_status(root, job_id, status) + heartbeat_thread = _start_heartbeat(root, job_id, status, lock, heartbeat_stop) + timeout_seconds = request.get("timeout_seconds") + if isinstance(timeout_seconds, int | float) and timeout_seconds > 0: + previous_alarm = (signal.SIGALRM, signal.getsignal(signal.SIGALRM)) + signal.signal(signal.SIGALRM, timeout_handler) + signal.setitimer(signal.ITIMER_REAL, float(timeout_seconds)) + result = run_scan( + root, + config_path=Path(str(request["config"])) if request.get("config") else None, + cache_dir=Path(str(request["cache_dir"])) if request.get("cache_dir") else None, + new_since=str(request["new_since"]) if request.get("new_since") else None, + trust_local_packs=bool(request.get("trust_local_packs", False)), + trusted_packs=tuple(str(p) for p in request.get("trusted_packs", ())), + strict_defaults=bool(request.get("strict_defaults", False)), + trust_suppressions=bool(request.get("trust_suppressions", False)), + lang=str(request.get("lang", "python")), + progress_callback=progress_update, + ) + if previous_alarm is not None: + signal.setitimer(signal.ITIMER_REAL, 0.0) + signal.signal(previous_alarm[0], previous_alarm[1]) + previous_alarm = None + heartbeat_stop.set() + if heartbeat_thread is not None: + heartbeat_thread.join(timeout=1.0) + # Enrich (Filigree emit) and compute the gate decision BEFORE writing the artifact, + # so the agent-summary artifact carries the same gate (honoring fail_on_unanalyzed) + # and the same filigree_emit block as the terminal job status — not an + # unanalyzed-blind, pre-enrichment `configured: false` snapshot. + emit_result: EmitResult | None = None + if request.get("filigree_url") and not request.get("local_only"): + from wardline.filigree.config import load_filigree_token + + status.update({"phase": "emitting_filigree", "progress": _progress(2)}) + with lock: + _write_status(root, job_id, status) + explicit_cap = request.get("filigree_max_findings_per_request") + max_findings = int(explicit_cap) if explicit_cap is not None else None + emit_result = FiligreeEmitter( + str(request["filigree_url"]), + token=load_filigree_token(root), + max_findings_per_request=max_findings, + protocol_errors_loud=False, + ).emit(result.findings, scanned_paths=result.scanned_paths) + + fail_on = str(request["fail_on"]) if request.get("fail_on") else None + decision = gate_decision( + result, + Severity(fail_on) if fail_on is not None else None, + fail_on_unanalyzed=bool(request.get("fail_on_unanalyzed", False)), + ) + filigree_block = _filigree_status(emit_result) + migration_hint = baseline_migration_hint(result, decision, root=root, new_since=request.get("new_since")) + + status.update( + { + "phase": "writing_artifact", + "progress": _progress(3, files_scanned=result.files_scanned, findings=result.summary.total), + } + ) + with lock: + _write_status(root, job_id, status) + fmt = str(request.get("format", "jsonl")) + _write_scan_artifact( + root, output, fmt, result, decision, filigree_emit=filigree_block, migration_hint=migration_hint + ) + enrichment_failed = emit_result is not None and (not emit_result.reachable or emit_result.failed > 0) + terminal = "completed" + failure_kind: str | None = None + error: str | None = None + if decision.tripped: + terminal = "failed" + failure_kind = "gate" + error = decision.reason + elif enrichment_failed and emit_result is not None: + terminal = "completed_with_enrichment_failure" + failure_kind = "enrichment" + disabled_reason = filigree_block["disabled_reason"] + error = str(disabled_reason) if disabled_reason else f"{emit_result.failed} Filigree finding(s) failed" + status.update( + { + "status": terminal, + "phase": "complete", + "progress": _progress(4, files_scanned=result.files_scanned, findings=result.summary.total), + "failure_kind": failure_kind, + "error": error, + "artifacts": artifacts, + "summary": { + "total": result.summary.total, + "active": result.summary.active, + "baselined": result.summary.baselined, + "waived": result.summary.waived, + "judged": result.summary.judged, + "informational": result.summary.informational, + "unanalyzed": result.summary.unanalyzed, + }, + "gate": { + "tripped": decision.tripped, + "fail_on": decision.fail_on, + "fail_on_unanalyzed": decision.fail_on_unanalyzed, + "exit_class": decision.exit_class, + "verdict": decision.verdict, + "reason": decision.reason, + "evaluated": decision.evaluated, + "migration_hint": migration_hint, + }, + "filigree_emit": filigree_block, + } + ) + with lock: + _write_status(root, job_id, status) + except _ScanJobTimeout as exc: + heartbeat_stop.set() + if previous_alarm is not None: + signal.setitimer(signal.ITIMER_REAL, 0.0) + signal.signal(previous_alarm[0], previous_alarm[1]) + previous_alarm = None + if heartbeat_thread is not None: + heartbeat_thread.join(timeout=1.0) + status.update( + { + "status": "failed", + "phase": "failed", + "progress": _progress(4), + "failure_kind": "timeout", + "error": str(exc), + "artifacts": artifacts, + } + ) + with lock: + _write_status(root, job_id, status) + except WardlineError as exc: + heartbeat_stop.set() + if previous_alarm is not None: + signal.setitimer(signal.ITIMER_REAL, 0.0) + signal.signal(previous_alarm[0], previous_alarm[1]) + previous_alarm = None + if heartbeat_thread is not None: + heartbeat_thread.join(timeout=1.0) + status.update( + { + "status": "failed", + "phase": "failed", + "progress": _progress(4), + "failure_kind": "scan", + "error": str(exc), + "artifacts": artifacts, + } + ) + with lock: + _write_status(root, job_id, status) + finally: + heartbeat_stop.set() + if previous_alarm is not None: + signal.setitimer(signal.ITIMER_REAL, 0.0) + signal.signal(previous_alarm[0], previous_alarm[1]) + + +def _default_output_name(fmt: str) -> str: + if fmt == "sarif": + return "findings.sarif" + if fmt == "agent-summary": + return "findings.agent-summary.json" + return "findings.jsonl" diff --git a/src/wardline/core/sei_resolution.py b/src/wardline/core/sei_resolution.py index 4600cd8c..5c7031a7 100644 --- a/src/wardline/core/sei_resolution.py +++ b/src/wardline/core/sei_resolution.py @@ -26,6 +26,8 @@ def resolve_query_filters( root: Path, config_path: Path | None, loomweave_client: Any = None, + *, + strict_defaults: bool = False, ) -> dict[str, Any] | None: """Resolve a `qualname` filter starting with `sei:` in findings queries to its resolved qualname.""" if not where or "qualname" not in where: @@ -38,7 +40,7 @@ def resolve_query_filters( if loomweave_client is None: from wardline.core.config import resolve_loomweave_url - loomweave_url = resolve_loomweave_url(None, root, config_path) + loomweave_url = resolve_loomweave_url(None, root, config_path, strict_defaults=strict_defaults) if loomweave_url is not None: from wardline.loomweave.client import LoomweaveClient from wardline.loomweave.config import load_loomweave_token, resolve_project_name diff --git a/src/wardline/core/suppression.py b/src/wardline/core/suppression.py index c3071617..44a2c228 100644 --- a/src/wardline/core/suppression.py +++ b/src/wardline/core/suppression.py @@ -14,9 +14,16 @@ from wardline.core.baseline import Baseline from wardline.core.finding import ENGINE_PATH, Finding, Kind, Maturity, Severity, SuppressionState +from wardline.core.finding_identity import resolve_identity from wardline.core.judged import JudgedSet from wardline.core.waivers import WaiverSet +_MATCH_STATE: dict[str, SuppressionState] = { + "waiver": SuppressionState.WAIVED, + "judged": SuppressionState.JUDGED, + "baseline": SuppressionState.BASELINED, +} + # Ascending trust-cost order for the --fail-on threshold. NONE is absent — facts # and metrics never participate in the gate. SEVERITY_ORDER: tuple[Severity, ...] = (Severity.INFO, Severity.WARN, Severity.ERROR, Severity.CRITICAL) @@ -58,18 +65,16 @@ def apply_suppressions( ) ) continue - # Precedence: waiver (explicit human intent, carries expiry) > judged (LLM - # FP-verdict, carries the rationale) > baseline (silent). - waiver = waivers.match(f.fingerprint, today) - judged_fp = judged.match(f.fingerprint) - if waiver is not None: - out.append(replace(f, suppressed=SuppressionState.WAIVED, suppression_reason=waiver.reason)) - elif judged_fp is not None: - out.append(replace(f, suppressed=SuppressionState.JUDGED, suppression_reason=judged_fp.rationale)) - elif baseline.contains(f.fingerprint): + # Precedence (waiver > judged > baseline) lives in resolve_identity — the + # single JOIN predicate. BASELINED carries no reason (matches the historical + # inline behaviour); WAIVED/JUDGED carry the resolver's reason. + resolution = resolve_identity(f.fingerprint, baseline=baseline, waivers=waivers, judged=judged, today=today) + if resolution.matched_on is None: + out.append(f) + elif resolution.matched_on == "baseline": out.append(replace(f, suppressed=SuppressionState.BASELINED)) else: - out.append(f) + out.append(replace(f, suppressed=_MATCH_STATE[resolution.matched_on], suppression_reason=resolution.reason)) return out diff --git a/src/wardline/core/waivers.py b/src/wardline/core/waivers.py index 25986e7c..ccd61c84 100644 --- a/src/wardline/core/waivers.py +++ b/src/wardline/core/waivers.py @@ -19,9 +19,13 @@ from typing import Any from wardline.core.errors import ConfigError +from wardline.core.finding import FINGERPRINT_SCHEME, require_fingerprint_scheme from wardline.core.optional_deps import require_yaml from wardline.core.paths import waivers_path -from wardline.core.safe_paths import safe_project_file +from wardline.core.safe_paths import safe_project_file, safe_write_text, write_text_no_follow + +WAIVERS_VERSION: int = 1 +"""Bumped on a format change; validated on load (mirrors BASELINE_VERSION).""" _HEX = frozenset("0123456789abcdef") @@ -31,6 +35,15 @@ class Waiver: fingerprint: str reason: str expires: date | None = None + entity_sei: str | None = None + """Optional rename-surviving Loomweave SEI for the code entity this waiver names + (the doctrine spine — a waiver about a defect-in-a-function carries the function's + stable identity, so the suppression survives rename/move and joins federation + joins). Opaque; carried verbatim, never parsed.""" + entity_locator: str | None = None + """Optional human-readable locator (``{plugin}:function:{qualname}`` or the + resolved current-locator) captured alongside the SEI for legibility. Never the + join key — the SEI is.""" def is_active(self, today: date) -> bool: """Active through the expiry day; expired strictly after (today > expires).""" @@ -69,10 +82,49 @@ def parse_waivers(raw: Sequence[Mapping[str, Any]]) -> tuple[Waiver, ...]: reason = item.get("reason") if not isinstance(reason, str) or not reason.strip(): raise ConfigError(f"waivers[{idx}].reason is required (non-empty string)") - waivers.append(Waiver(fingerprint=fp, reason=reason, expires=_parse_expiry(item.get("expires"), idx))) + entity_sei = _parse_optional_str(item.get("entity_sei"), idx, "entity_sei") + entity_locator = _parse_optional_str(item.get("entity_locator"), idx, "entity_locator") + waivers.append( + Waiver( + fingerprint=fp, + reason=reason, + expires=_parse_expiry(item.get("expires"), idx), + entity_sei=entity_sei, + entity_locator=entity_locator, + ) + ) return tuple(waivers) +def _parse_optional_str(raw: Any, idx: int, field: str) -> str | None: + """An optional string field on a waiver entry: absent → None; a non-empty string + is kept; anything else (a number, a mapping, an empty string) is a malformed store + and fails loud — a waiver's entity binding must not be silently dropped.""" + if raw is None: + return None + if isinstance(raw, str) and raw: + return raw + raise ConfigError(f"waivers[{idx}].{field} must be a non-empty string when present, got {raw!r}") + + +def build_waivers_document(waivers: Iterable[Waiver]) -> dict[str, Any]: + """Pure: the YAML-shaped dict (scheme header + version + waivers) for the + given waivers, preserving caller order. ``add_waiver`` writes its own + header inline (it preserves existing raw entries verbatim); this is the + object→document path the rekey migration (P4) writes through.""" + entries: list[dict[str, Any]] = [] + for w in waivers: + entry: dict[str, Any] = {"fingerprint": w.fingerprint, "reason": w.reason} + if w.expires is not None: + entry["expires"] = w.expires.isoformat() + if w.entity_sei is not None: + entry["entity_sei"] = w.entity_sei + if w.entity_locator is not None: + entry["entity_locator"] = w.entity_locator + entries.append(entry) + return {"fingerprint_scheme": FINGERPRINT_SCHEME, "version": WAIVERS_VERSION, "waivers": entries} + + def load_project_waivers(root: Path) -> tuple[Waiver, ...]: """Read wardline's machine-written waivers from ``.weft/wardline/waivers.yaml``. @@ -90,6 +142,12 @@ def load_project_waivers(root: Path) -> tuple[Waiver, ...]: raise ConfigError(f"malformed {path.name}: {exc}") from exc if not isinstance(loaded, dict): raise ConfigError(f"{path.name} is not a mapping") + # Loader order is load-bearing: empty-guard → scheme → version → entries. + if not loaded: + return () + require_fingerprint_scheme(loaded, store_name=path.name) + if loaded.get("version") != WAIVERS_VERSION: + raise ConfigError(f"{path.name}: version mismatch — expected {WAIVERS_VERSION}, got {loaded.get('version')!r}") raw = loaded.get("waivers") if raw is not None and not isinstance(raw, list): raise ConfigError(f"malformed {path.name}: 'waivers' must be a list") @@ -103,6 +161,8 @@ def add_waiver( reason: str, expires: date | None, root: Path | None = None, + entity_sei: str | None = None, + entity_locator: str | None = None, ) -> Waiver: """Append a waiver to the ``waivers:`` list in ``path`` — wardline's machine/CLI state file ``.weft/wardline/waivers.yaml`` (creating it if absent). Validates via @@ -111,6 +171,11 @@ def add_waiver( ``expires`` is stored as an ISO string (``YYYY-MM-DD``); both the in-line validation parse and a later ``parse_waivers`` round-trip accept it. + + ``entity_sei`` / ``entity_locator`` (additive, doctrine spine) optionally bind the + waiver to the code entity it suppresses so the suppression survives rename/move and + joins federation joins. The SEI is carried verbatim (opaque); both are stored only + when present, so existing callers (and existing waiver files) are unaffected. """ config_path = path if root is not None: @@ -118,6 +183,10 @@ def add_waiver( entry: dict[str, object] = {"fingerprint": fingerprint, "reason": reason} if expires is not None: entry["expires"] = expires.isoformat() + if entity_sei is not None: + entry["entity_sei"] = entity_sei + if entity_locator is not None: + entry["entity_locator"] = entity_locator # Validate by parsing the single entry — reuses the canonical rules. Raises # ConfigError on a bad fingerprint/reason/expiry BEFORE the file is touched. waiver = parse_waivers((entry,))[0] @@ -131,6 +200,11 @@ def add_waiver( raise ConfigError(f"malformed {config_path.name}: {exc}") from exc if not isinstance(loaded, dict): raise ConfigError(f"{config_path.name} is not a mapping") + # A non-empty existing store must already carry the current scheme — else + # appending a current-scheme fingerprint to an old-scheme file would mint a + # mixed, silently-orphaning store. (Empty/absent → fresh write below.) + if loaded: + require_fingerprint_scheme(loaded, store_name=config_path.name) raw = loaded existing = raw.get("waivers") if existing is not None and not isinstance(existing, list): @@ -139,12 +213,13 @@ def add_waiver( if any(isinstance(w, Mapping) and w.get("fingerprint") == fingerprint for w in waivers): raise ConfigError(f"waiver for {fingerprint} already exists") waivers.append(entry) - raw["waivers"] = waivers - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( - yaml.safe_dump(raw, sort_keys=False, default_flow_style=False, allow_unicode=True), - encoding="utf-8", - ) + # Re-stamp the scheme header (idempotent) and place it first for readability. + document = {"fingerprint_scheme": FINGERPRINT_SCHEME, "version": WAIVERS_VERSION, "waivers": waivers} + content = yaml.safe_dump(document, sort_keys=False, default_flow_style=False, allow_unicode=True) + if root is not None: + safe_write_text(root, config_path, content, label=config_path.name) + else: + write_text_no_follow(config_path, content, label=config_path.name) return waiver diff --git a/src/wardline/filigree/config.py b/src/wardline/filigree/config.py index 95ac62f9..3d9c21ae 100644 --- a/src/wardline/filigree/config.py +++ b/src/wardline/filigree/config.py @@ -1,15 +1,27 @@ # src/wardline/filigree/config.py """Filigree bearer credential loader. Filigree's auth is opt-in bearer-token over -loopback (no HMAC); when the operator sets a token, every ``/api/weft/*`` call needs -``Authorization: Bearer ``. Like the Loomweave secret, the token comes from -env / ``.env`` ONLY, never from weft.toml — the same discipline as the OpenRouter -judge key. - -The credential is read from the federation-scoped ``WEFT_FEDERATION_TOKEN`` (adopted -for lockstep across the Weft federation). The legacy ``WARDLINE_FILIGREE_TOKEN`` is -still honored as a deprecated fallback so existing deployments keep working; the new -name is preferred and is what the operator-facing messages point at. Only the token -VALUE must match what the Filigree operator configured. +loopback (no HMAC); when a token is in play, every ``/api/weft/*`` call needs +``Authorization: Bearer ``. Only the token VALUE must match what the Filigree +daemon expects. + +This mirrors filigree's OWN inbound resolver (``filigree/federation_token.py``) so the +outbound (client) token wardline emits matches the daemon's with zero operator +ceremony on a same-host install. Resolution order (highest precedence first): + + 1. env ``WEFT_FEDERATION_TOKEN`` — operator override / cross-host + 2. legacy env ``WARDLINE_FILIGREE_TOKEN`` — deprecated operator fallback + 3. ``root/.env`` ``WEFT_FEDERATION_TOKEN`` — wardline's portable convention + 4. ``/.weft/filigree/federation_token`` — filigree's auto-minted 0600 file + 5. legacy ``root/.env`` ``WARDLINE_FILIGREE_TOKEN`` — deprecated file fallback + 6. None — auth stays off + +Rung 3 is the same project-store file filigree mints and validates against (the C-9e +same-host cross-member read; conventions.md C-3 + conflict-register §A-15): reading it +makes the client token match the per-project daemon with no env/.env/.mcp.json config. +This token is loopback deconfliction/identification plumbing, **not** a secret — the +0600 mode is filigree's to set; wardline only reads. The configured tokens otherwise +come from env / ``.env`` ONLY, never from weft.toml — the same discipline as +the Loomweave secret and the OpenRouter judge key. """ from __future__ import annotations @@ -17,24 +29,28 @@ import os from pathlib import Path +from wardline.core.safe_paths import safe_project_file + WEFT_FEDERATION_TOKEN_ENV = "WEFT_FEDERATION_TOKEN" # Deprecated fallback — read after the federation-scoped name so existing # deployments (e.g. lacuna's .env) keep working until they migrate. WARDLINE_FILIGREE_TOKEN_ENV = "WARDLINE_FILIGREE_TOKEN" -# Priority order: the new federation name fully (env then .env), then the legacy -# name fully. Preferring the new name everywhere is the correct deprecation behavior. -_TOKEN_ENV_NAMES = (WEFT_FEDERATION_TOKEN_ENV, WARDLINE_FILIGREE_TOKEN_ENV) +# Filigree's auto-minted token, relative to the project root. Mirrors filigree's +# single-project store_dir (``.weft/filigree/``) and the filename it persists +# (``federation_token``); kept in lockstep with filigree/federation_token.py. +_FILIGREE_MINT_RELPATH = (".weft", "filigree", "federation_token") -def _read_token(name: str, root: Path) -> str | None: - """Return the value of ``name`` from the environment, or from a single - ``KEY=VALUE`` line in ``root/.env``, or None. An already-set environment value - always wins over the file.""" +def _read_env_token(name: str) -> str | None: + """Return a non-empty process environment token, or None.""" value = os.environ.get(name) - if value: - return value - env_path = root / ".env" + return value or None + + +def _read_dotenv_token(name: str, root: Path) -> str | None: + """Return ``name`` from a single ``KEY=VALUE`` line in ``root/.env``, or None.""" + env_path = safe_project_file(root, root / ".env", label=".env") if not env_path.is_file(): return None for raw in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): @@ -45,11 +61,44 @@ def _read_token(name: str, root: Path) -> str | None: return None +def _read_filigree_mint(root: Path) -> str | None: + """Tier 3: filigree's auto-minted federation token under the project store + (``/.weft/filigree/federation_token``), stripped, or None. + + Strictly read-only — wardline never mints this file (filigree does, at daemon + boot / install / doctor). Missing or unreadable falls through cleanly to None so + the emit path degrades to the legacy/off rungs rather than crashing the scan. + """ + path = safe_project_file(root, root.joinpath(*_FILIGREE_MINT_RELPATH), label="federation_token") + try: + value = path.read_text(encoding="utf-8").strip() + except OSError: + return None + return value or None + + def load_filigree_token(root: Path) -> str | None: - """Return the bearer token from ``WEFT_FEDERATION_TOKEN`` (env or ``root/.env``), - falling back to the deprecated ``WARDLINE_FILIGREE_TOKEN``, or None.""" - for name in _TOKEN_ENV_NAMES: - value = _read_token(name, root) - if value: - return value + """Resolve the outbound Filigree bearer token (see the module docstring for the + six-rung order), or None when federation auth is off.""" + # Rungs 1-2: process environment is operator-controlled and outranks every + # project-local token file, including root/.env entries with newer names. + value = _read_env_token(WEFT_FEDERATION_TOKEN_ENV) + if value: + return value + value = _read_env_token(WARDLINE_FILIGREE_TOKEN_ENV) + if value: + return value + # Rung 3: the canonical federation name in root/.env. + value = _read_dotenv_token(WEFT_FEDERATION_TOKEN_ENV, root) + if value: + return value + # Rung 4: filigree's auto-minted project-store token (same-host, zero-ceremony). + value = _read_filigree_mint(root) + if value: + return value + # Rung 5: the deprecated legacy name in root/.env for un-migrated deployments. + value = _read_dotenv_token(WARDLINE_FILIGREE_TOKEN_ENV, root) + if value: + return value + # Rung 6: off. return None diff --git a/src/wardline/filigree/dossier_client.py b/src/wardline/filigree/dossier_client.py index 5f62599b..9e43ccbb 100644 --- a/src/wardline/filigree/dossier_client.py +++ b/src/wardline/filigree/dossier_client.py @@ -26,6 +26,7 @@ from wardline.core.dossier import TicketRef, WorkSection from wardline.core.errors import FiligreeEmitError +from wardline.core.filigree_emit import filigree_api_base_url from wardline.core.http import read_response_text from wardline.core.identity import ContentStatus, EntityBinding, content_status @@ -80,17 +81,11 @@ def _rows_of(parsed: Any) -> list[dict[str, Any]]: def _api_base_url(url: str) -> str: - """Normalize an origin/API/scan-results URL to the Filigree API base.""" - parsed = urllib.parse.urlsplit(url.rstrip("/")) - scheme = parsed.scheme.lower() - if scheme not in _ALLOWED_SCHEMES: - raise FiligreeEmitError(f"filigree dossier URL must use http or https; got scheme {scheme!r} in {url!r}") - path = parsed.path.rstrip("/") - if path.endswith("/api/weft/scan-results"): - path = path[: -len("/weft/scan-results")] - elif not path.endswith("/api"): - path = f"{path}/api" if path else "/api" - return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/") + """Normalize an origin/API/scan-results URL (classic or project-scoped, either + dialect) to the Filigree API base, via the shared parser in + ``core/filigree_emit.py``. Dogfood-4 A4 was this function appending ``/api`` to a + project-scoped endpoint, 404ing every work-join on the wired-up repo.""" + return filigree_api_base_url(url) class FiligreeWorkProvider: diff --git a/src/wardline/install/block.py b/src/wardline/install/block.py index 7649c29b..293ac8e1 100644 --- a/src/wardline/install/block.py +++ b/src/wardline/install/block.py @@ -1,13 +1,31 @@ -"""Render + idempotently inject the hash-fenced wardline instruction block.""" +"""Render + idempotently inject the hash-fenced wardline instruction block. + +The block lands in shared agent docs (``CLAUDE.md`` / ``AGENTS.md``) that may +also carry a co-resident sibling tool's managed block (filigree / legis / +loomweave). The injector therefore obeys the weft C-4 multi-owner managed-block +contract: it mutates only its own vendor-namespaced span, never lets a rewrite +cross a foreign-namespace fence, never reorders or relocates a foreign block, +canonicalises its own duplicates only when doing so cannot reach across a +foreign block (preserving + surfacing any duplicate beyond one), and writes +atomically with a refuse-to-empty guard so a crash can never truncate the shared +doc. +""" from __future__ import annotations import hashlib +import logging +import os import re +import stat +import tempfile from pathlib import Path +from wardline.core.errors import WardlineError from wardline.core.safe_paths import safe_project_file +logger = logging.getLogger(__name__) + _BLOCK_VERSION = "1" _BODY = ( @@ -19,43 +37,219 @@ "in `docs/agents.md`." ) +_OWN_NS = "wardline" +_END_MARKER = f"" +_WRITER_MARKER = f"" + +# A complete, well-formed wardline block (open .. close). Own-namespace only, so +# it can only ever match wardline's own spans (C-4 (b) own-namespace mutation). +# IGNORECASE so an uppercase-namespaced own duplicate (e.g. ``WARDLINE``) is still +# matched for canonicalisation, consistent with the case-insensitive namespace +# comparison used for boundary detection (C-4 (e)+(h)). _FENCE_RE = re.compile( r".*?", - re.DOTALL, + re.DOTALL | re.IGNORECASE, ) +# Recognises ANY tool's instruction fence (open OR close, via the optional +# leading ``/``) and captures its vendor namespace. wardline uses it to bound its +# own rewrite at a *foreign* fence and never delete a co-resident sibling block +# in a shared CLAUDE.md / AGENTS.md. The namespace is compared case-insensitively +# (via ``.lower()``) so an uppercase-namespaced sibling (e.g. ``FILIGREE``) still +# registers as a boundary (C-4 (h)). The cross-tool multi-owner block contract +# lives in weft conventions.md (C-4). +_INSTR_FENCE_RE = re.compile(r"\n{_BODY}\n" + return f"\n{_WRITER_MARKER}\n{_BODY}\n{_END_MARKER}" + + +def _first_real_foreign_block_pos(content: str, search_from: int) -> int: + """Index of the first *real* foreign block at/after *search_from*, else EOF. + + A real foreign block is a foreign-namespace OPEN fence that has a matching + foreign CLOSE fence somewhere after it — i.e. genuine co-resident sibling + content we must never delete or split. A *lone* foreign open (a marker quoted + in prose or inside wardline's own body) and a stray foreign close are NOT + boundaries: they are our own content, so a well-formed own block whose body + happens to mention a sibling's marker is replaced in place rather than + truncated at the quoted marker (C-4 (b)+(c)). Own-namespace fences are always + absorbed, so duplicate / unclosed wardline blocks still collapse. + + Returns ``len(content)`` when no real foreign block follows (bound at EOF). + The namespace match is case-insensitive (C-4 (h)). + """ + fences = list(_INSTR_FENCE_RE.finditer(content, search_from)) + for i, m in enumerate(fences): + ns = m.group("ns").lower() + if ns == _OWN_NS or m.group("close"): + continue + # Foreign open: a boundary only if a matching foreign close follows it. + if any(n.group("ns").lower() == ns and n.group("close") for n in fences[i + 1 :]): + return m.start() + return len(content) + + +def _first_own_open_fence_pos(content: str) -> int: + """Index of wardline's *own* top-level open instruction fence, or -1 if none. + + A wardline open marker quoted *inside* a co-resident sibling block (a worked + example, documentation) is textually identical to a real one, so a bare regex + anchor would splice there and gut the sibling. This walks fences in document + order, tracking the foreign block we are currently inside, and only returns a + wardline open fence found at top level (not enclosed by an unclosed foreign + block). An unclosed foreign block therefore shields any wardline marker + beyond it: we decline to claim content we cannot prove is ours, and the + caller falls back to an append (which deletes nothing). + """ + inside_foreign: str | None = None + for m in _INSTR_FENCE_RE.finditer(content): + ns = m.group("ns").lower() + is_close = bool(m.group("close")) + if inside_foreign is not None: + if is_close and ns == inside_foreign: + inside_foreign = None + continue + if ns == _OWN_NS and not is_close: + return m.start() + if ns != _OWN_NS and not is_close: + inside_foreign = ns + return -1 + + +def _canonicalise_tail(tail: str) -> tuple[str, bool]: + """Collapse duplicate own blocks in *tail* that precede any real foreign block. + + Returns ``(cleaned_tail, foreign_shielded_dup)``. Own blocks before the first + real foreign block are removed (own-duplicate canonicalisation, C-4 (e)). + Everything from that foreign block onward is preserved verbatim — including + any own duplicate beyond it, which foreign-safety forbids reaching across; the + bool flags such a shielded duplicate so the caller can surface it. + """ + foreign = _first_real_foreign_block_pos(tail, 0) + head, rest = tail[:foreign], tail[foreign:] + cleaned = _FENCE_RE.sub("", head) + shielded_dup = _first_own_open_fence_pos(rest) != -1 + return cleaned + rest, shielded_dup + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write *content* to *path* atomically (temp + ``os.replace``), preserving mode. + + Refuse-to-empty guard (C-4 (g)): every caller always has non-empty content (a + rendered block, or existing text plus a block), so an empty / whitespace-only + payload can only be corruption or a logic bug — refuse loudly rather than + rename an empty temp file over a populated CLAUDE.md / AGENTS.md. The + write-temp-then-replace makes truncation structurally impossible: a crash + leaves the prior file intact, never a partial shared agent doc. + + ``tempfile.mkstemp`` creates 0o600 files; without an explicit chmod the + rename would leak that owner-only mode onto a user-visible file, so the + destination's existing mode is preserved (or the process umask is respected + for a new file). + """ + if not content.strip(): + raise WardlineError(f"refusing to write empty content to {path}") + existing_mode: int | None + try: + existing_mode = stat.S_IMODE(path.stat().st_mode) + except FileNotFoundError: + existing_mode = None + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp", prefix=path.name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + if existing_mode is not None: + os.chmod(tmp, existing_mode) + else: + umask = os.umask(0) + os.umask(umask) + os.chmod(tmp, 0o666 & ~umask) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise def inject_block(file_path: Path) -> str: """Create / append / replace the block, collapsing to one canonical block. - Returns created|updated|unchanged. + Foreign-safe per the weft C-4 multi-owner managed-block contract: never + deletes, reorders, or relocates a co-resident sibling tool's block, and never + lets a rewrite span across a foreign-namespace fence. Returns + created|updated|unchanged. """ file_path = safe_project_file(file_path.parent, file_path, label=file_path.name) block = render_block() if not file_path.exists(): - file_path.write_text(block + "\n", encoding="utf-8") + _atomic_write_text(file_path, block + "\n") return "created" text = file_path.read_text(encoding="utf-8") - matches = list(_FENCE_RE.finditer(text)) - if not matches: + start = _first_own_open_fence_pos(text) + own_end = text.find(_END_MARKER, start) if start != -1 else -1 + + if start == -1 or own_end == -1: + # No own block we can claim — either none at all, an own open with no + # close (so we cannot prove its extent), or an open marker shielded + # inside an unclosed foreign block. Append a fresh block and preserve all + # existing text verbatim (C-4 (d) append-on-missing-end). This is NOT + # recovery-to-EOF: trailing user text after an unclosed own marker is + # kept, never deleted. + if block in text: + # An identical current block already exists but is unreachable to the + # claim logic (shielded inside an unclosed foreign block, whose own + # close we must not invent). Appending another copy each run would + # grow the file unboundedly across repeated hook invocations; decline + # instead. Purely read-only, so foreign-safety is untouched. + return "unchanged" sep = "" if text.endswith("\n") else "\n" - file_path.write_text(f"{text}{sep}\n{block}\n", encoding="utf-8") + _atomic_write_text(file_path, f"{text}{sep}\n{block}\n") return "updated" - # Canonicalise to a single block at the position of the first existing one; - # drop any additional blocks (e.g. from a botched prior write) so repeated - # calls converge. - prefix = text[: matches[0].start()] - suffix = _FENCE_RE.sub("", text[matches[0].end() :]) - candidate = prefix + block + suffix + + # An own block exists with a close marker. Bound the span we rewrite so it + # never crosses a *real* foreign block (C-4 (c)). A foreign marker merely + # quoted inside our own body is not a boundary (see + # _first_real_foreign_block_pos) — that block is replaced in place. + foreign = _first_real_foreign_block_pos(text, start) + if own_end < foreign: + # Well-formed own block, closing before any real foreign block: replace it + # in place, then canonicalise duplicate own blocks in the tail up to (but + # never across) the first real foreign block (C-4 (e)). + bound = own_end + len(_END_MARKER) + tail, shielded_dup = _canonicalise_tail(text[bound:]) + sep = "" + else: + # Bounded recovery (C-4 (c)): the own open is malformed, or its close lies + # beyond a real foreign block (so a naive open..close match would swallow + # the foreign block). Cut at the foreign block (or EOF) instead. Re-insert + # the separating newline we may have eaten so our close marker is never + # glued mid-line against a following foreign fence — keeping us + # independent of whether a sibling's detector is line-anchored. + bound = foreign + tail = text[bound:] + sep = "\n" if (bound < len(text) and not tail.startswith("\n")) else "" + shielded_dup = _first_own_open_fence_pos(tail) != -1 + + if shielded_dup: + # A second own block survives beyond a foreign block because + # canonicalising it would mean reaching across a block we don't own. It + # is STALE, conflicting guidance — not a harmless duplicate — so surface + # it instead of silently shipping a split brain (foreign-safety wins over + # own-dedup, C-4 (e)). + logger.warning( + "wardline instruction block in %s has a duplicate beyond another " + "tool's block that could not be canonicalised without crossing it; " + "the stale copy was left in place. Resolve it by hand.", + file_path, + ) + + candidate = text[:start] + block + sep + tail if candidate == text: return "unchanged" - file_path.write_text(candidate, encoding="utf-8") + _atomic_write_text(file_path, candidate) return "updated" diff --git a/src/wardline/install/detect.py b/src/wardline/install/detect.py index 2d99af2b..6f791d31 100644 --- a/src/wardline/install/detect.py +++ b/src/wardline/install/detect.py @@ -16,7 +16,9 @@ import shutil from pathlib import Path +from wardline.core.config import filigree_server_scoped_url from wardline.core.paths import legacy_sibling_dir, sibling_state_dir +from wardline.core.safe_paths import safe_read_text_if_regular def _strip_scalar(value: str) -> str: @@ -80,10 +82,10 @@ def _filigree_url_from_project(root: Path) -> str | None: # ascii read, mirroring core/config._read_published_port: ephemeral.port is # an ASCII integer by protocol, so non-ASCII bytes (incl. Unicode "digit" # chars that pass isdigit() but raise in int()) are rejected at decode time. - try: - text = (base / "ephemeral.port").read_text(encoding="ascii").strip() - except (OSError, UnicodeDecodeError): + port_text = safe_read_text_if_regular(root, base / "ephemeral.port", label="ephemeral.port", encoding="ascii") + if port_text is None: continue + text = port_text.strip() # Guard int(): isdigit() is a superset of what int() parses, so an all-digit # payload over CPython's 4300-digit cap raises ValueError (the ascii read # above already excludes Unicode digits). Catch it so a planted ephemeral.port @@ -111,6 +113,13 @@ def _detect_filigree(root: Path) -> tuple[bool, str | None, str | None]: url = os.environ.get("WARDLINE_FILIGREE_URL") or None if url: return True, url, "env" + # Server mode first: a multi-project daemon publishes no per-project ephemeral.port, + # so its project scope is discoverable only from the home-global registry. Report the + # SCOPED URL so install output matches the scoped target merge_mcp_entry persists + # (without it, install would print "filigree absent" right after wiring it). + scoped = filigree_server_scoped_url(root) + if scoped is not None: + return True, scoped, "server-mode scope" discovered = _filigree_url_from_project(root) present = discovered is not None or (root / ".filigree.conf").is_file() return present, discovered, "discovered" if discovered else None diff --git a/src/wardline/install/doctor.py b/src/wardline/install/doctor.py index b9bc44cd..dcf0fb19 100644 --- a/src/wardline/install/doctor.py +++ b/src/wardline/install/doctor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ipaddress import json import os import tomllib @@ -11,9 +12,12 @@ from typing import Any from urllib.parse import urlsplit -from wardline.core.config import load -from wardline.core.errors import ConfigError +from wardline.core.config import _filigree_published_url, load +from wardline.core.errors import ConfigError, WardlineError +from wardline.core.filigree_emit import FiligreeEmitter, Transport, UrllibTransport from wardline.core.paths import weft_config_path, weft_state_dir +from wardline.core.safe_paths import safe_read_text_if_regular, safe_write_text +from wardline.filigree.config import load_filigree_token from wardline.install.block import inject_block from wardline.install.detect import ( _detect_filigree, @@ -23,7 +27,7 @@ from wardline.install.mcp_json import ( _codex_config_path, _codex_mcp_entry, - _local_mcp_entry, + _desired_local_entry, install_codex_mcp, merge_mcp_entry, ) @@ -56,6 +60,50 @@ def to_dict(self) -> dict[str, Any]: return data +_DEFAULT_CONFIG_EXCLUDES = ( + ".git/**", + ".venv/**", + "venv/**", + ".uv-cache/**", + ".mypy_cache/**", + ".pytest_cache/**", + ".ruff_cache/**", + ".tox/**", + ".nox/**", + "node_modules/**", + "telemetry/**", + "data/**", +) + + +def _format_toml_array(values: tuple[str, ...]) -> str: + return "[" + ", ".join(json.dumps(value) for value in values) + "]" + + +def _default_source_roots(root: Path) -> tuple[str, ...]: + return ("src",) if (root / "src").is_dir() else (".",) + + +def _default_weft_config(root: Path) -> str: + source_roots = _default_source_roots(root) + return ( + "# Created by `wardline doctor --repair`.\n" + "# Keep the scan rooted at the project root for stable identity; bound the\n" + "# analyzed source here so agent gates do not traverse caches or run artifacts.\n" + "[wardline]\n" + f"source_roots = {_format_toml_array(source_roots)}\n" + f"exclude = {_format_toml_array(_DEFAULT_CONFIG_EXCLUDES)}\n" + ) + + +def _ensure_weft_config(root: Path) -> bool: + cfg_path = weft_config_path(root) + if cfg_path.exists(): + return False + safe_write_text(root, cfg_path, _default_weft_config(root), label="weft.toml") + return True + + def _has_instruction_block(path: Path) -> bool: if not path.is_file(): return False @@ -80,7 +128,11 @@ def _check_project_mcp(root: Path) -> CheckResult: if not isinstance(servers, dict): return CheckResult(".mcp.json", False, "missing mcpServers object") entry = servers.get("wardline") - if entry != _local_mcp_entry(): + # An entry carrying operator-pinned --loomweave-url/--filigree-url args is well-formed: + # compare against the canonical entry augmented with those preserved args (and the + # live server-mode filigree scope, if any), not the bare canonical entry (which would + # flag a configured emit target as misconfiguration). + if entry != _desired_local_entry(entry, root): return CheckResult(".mcp.json", False, "missing wardline server") return CheckResult(".mcp.json", True, "configured") @@ -115,8 +167,17 @@ def _check_config(root: Path, *, fixed: bool) -> DoctorCheck: cfg_path = weft_config_path(root) # C-9c makes load() silently fall back to defaults on an unparseable shared # weft.toml (a sibling's section may be broken). doctor restores the operator - # signal by distinguishing ABSENT (ok — defaults are intentional) from - # PRESENT-BUT-BROKEN (error — your policy is silently not applying). + # signal by flagging BOTH silent-default cases as a repairable error: ABSENT + # (built-in source_roots=['.'] make project-root scans broad/slow — the scan + # warns about this; --repair writes a bounded default policy) and + # PRESENT-BUT-BROKEN (your policy is silently not applying). + if not cfg_path.exists(): + return DoctorCheck( + "wardline.config", + "error", + fixed=False, + message="missing weft.toml; run `wardline doctor --repair` to create a bounded default policy", + ) if cfg_path.is_file(): try: parsed = tomllib.loads(cfg_path.read_text(encoding="utf-8")) @@ -165,18 +226,25 @@ def _valid_http_url(url: str) -> bool: return parsed.scheme.lower() in {"http", "https"} and bool(parsed.netloc) -def _check_url(root: Path, key: str, *, fixed: bool) -> DoctorCheck: - # Sibling-endpoint config keys were retired (pending the hub shared-endpoint - # schema); a fixed endpoint comes only from the env var now, so that is what we - # validate. Live local discovery (.weft//ephemeral.port) is dynamic and - # not a doctor concern. +def _check_url(root: Path, key: str, *, fixed: bool, effective_url: str | None = None) -> DoctorCheck: + # Doctor must vouch for the EFFECTIVE config of the process answering it + # (dogfood-4 B8: it said "not configured" while the same server was launched + # with --loomweave-url/--filigree-url and using them successfully). Precedence + # mirrors runtime resolution: the launch flag the caller threads in, then the + # env var. Each verdict names its provenance so two surfaces can never + # silently disagree about WHICH config they describe. Live local discovery + # (.weft//ephemeral.port) is dynamic and not a doctor concern. env_key = "WARDLINE_LOOMWEAVE_URL" if key == "loomweave" else "WARDLINE_FILIGREE_URL" - url = os.environ.get(env_key) check_id = f"{key}.url" + if effective_url: + if _valid_http_url(effective_url): + return DoctorCheck(check_id, "ok", fixed=fixed, message=f"from --{key}-url launch flag") + return DoctorCheck(check_id, "error", fixed=False, message=f"invalid URL (launch flag): {effective_url!r}") + url = os.environ.get(env_key) if not url: - return DoctorCheck(check_id, "ok", fixed=fixed, message="not configured") + return DoctorCheck(check_id, "ok", fixed=fixed, message="not configured (no launch flag, no env)") if _valid_http_url(url): - return DoctorCheck(check_id, "ok", fixed=fixed) + return DoctorCheck(check_id, "ok", fixed=fixed, message=f"from env {env_key}") return DoctorCheck(check_id, "error", fixed=False, message=f"invalid URL: {url!r}") @@ -216,23 +284,269 @@ def _check_auth_token(root: Path) -> DoctorCheck: return DoctorCheck("auth.token", "ok", message="optional Loomweave token not configured") -def machine_readable_doctor(root: Path, *, fix: bool = False) -> dict[str, Any]: +def _rewrite_env_token(env_path: Path, value: str) -> None: + """Surgically pin ``WEFT_FEDERATION_TOKEN=`` in *env_path*. Removes any + existing ``WEFT_FEDERATION_TOKEN`` or legacy ``WARDLINE_FILIGREE_TOKEN`` line, + preserves all other lines/order byte-for-byte, creates the file if absent, and + keeps mode 0600 (the file holds a secret). + + Operates on raw bytes: an unrelated line carrying a non-UTF8 byte (e.g. a sibling + secret) is preserved verbatim rather than corrupted to U+FFFD on a decode round-trip. + The file is created with mode 0600 from the outset (``os.open`` with O_CREAT), so the + secret is never briefly written to a world-readable file; the trailing ``chmod`` still + tightens an already-existing loose-permission file. + + Refuses a SYMLINKED ``.env``: an ``O_TRUNC`` open on a symlink would follow it and + clobber an arbitrary user-writable file outside the repo (and reading it would + disclose that target). We refuse before reading and open the write with O_NOFOLLOW + (defends the check->open race), raising ``WardlineError`` so doctor reports a refusal + rather than writing through the link.""" + if env_path.is_symlink(): + raise WardlineError(f"{env_path.name}: refusing to rewrite a symlinked .env") + drop = (b"WEFT_FEDERATION_TOKEN=", b"WARDLINE_FILIGREE_TOKEN=") + kept: list[bytes] = [] + if env_path.is_file(): + for raw in env_path.read_bytes().splitlines(): + if raw.strip().startswith(drop): + continue + kept.append(raw) + kept.append(b"WEFT_FEDERATION_TOKEN=" + value.encode("utf-8")) + payload = b"\n".join(kept) + b"\n" + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(env_path, flags, 0o600) + except OSError as exc: + if env_path.is_symlink(): + raise WardlineError(f"{env_path.name}: refusing to write through a symlink") from exc + raise + with os.fdopen(fd, "wb") as fh: + fh.write(payload) + env_path.chmod(0o600) + + +_FILIGREE_URL_ENV = "WARDLINE_FILIGREE_URL" + + +def _mcp_filigree_url(root: Path) -> str | None: + """The ``--filigree-url`` value from the wardline server entry in ``.mcp.json``, + or None. This is the URL the agent's MCP server actually emits to, and the only + place it is recorded in the common (MCP) setup.""" + path = root / ".mcp.json" + if not path.is_file(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + try: + args = raw["mcpServers"]["wardline"]["args"] + if not isinstance(args, list): + return None + idx = args.index("--filigree-url") + value = args[idx + 1] + except (KeyError, TypeError, ValueError, IndexError): + return None + return value if isinstance(value, str) else None + + +def _resolve_probe_url(root: Path, flag: str | None) -> str | None: + """Probe-URL precedence: flag > WARDLINE_FILIGREE_URL env > .mcp.json wardline + --filigree-url arg > Filigree's published port. None when nothing resolves. + + This mirrors the actual emit path (:func:`core.config.resolve_filigree_url`) + exactly: a scan auto-discovers a live Filigree daemon from its published + ``ephemeral.port`` (or the server-mode registry), so a project with a running + Filigree but no pinned ``--filigree-url`` (the common ethereal/per-project case) + *does* emit — and *does* need a valid token. The published-port rung is therefore + included so doctor verifies the credential the scan will really use rather than + reporting "nothing to verify" and leaving a 401 to surface only at emit time. The + rung is read-only and the token is sent only to loopback (the ``_is_loopback`` + guard in :func:`_check_filigree_auth`), and a published port implies a daemon that + bound it, so this still does no speculative network.""" + if flag: + return flag + env = os.environ.get(_FILIGREE_URL_ENV) + if env: + return env + return _mcp_filigree_url(root) or _filigree_published_url(root) + + +def _is_loopback(url: str) -> bool: + """True when *url*'s host is loopback — the only origins a bearer is probed against. + + Uses strict IP parsing, never a string-prefix test: ``127.attacker.com`` / + ``127.0.0.1.evil.com`` are registrable hostnames that resolve off-box via DNS, so + a ``startswith("127.")`` check would send the federation bearer to an attacker. Only + the literal ``localhost`` and addresses in the 127/8 + ``::1`` loopback ranges pass.""" + host = (urlsplit(url).hostname or "").lower() + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _filigree_token_candidates(root: Path) -> list[str]: + """Locally-readable federation-token mints, in precedence order: the server-mode + store (~/.config/filigree) then the project store (/.weft/filigree). Returns + distinct, non-empty values.""" + out: list[str] = [] + # Home store: the operator's own config — read normally. + home_mint = Path.home() / ".config" / "filigree" / "federation_token" + try: + candidates = [home_mint.read_text(encoding="utf-8").strip()] + except OSError: + candidates = [""] + # Project store: repo-controlled when wardline scans an untrusted checkout. A symlinked + # mint here would have its TARGET's bytes read and sent as a Bearer to the probed local + # service (token exfil). Read it regular-only / no-follow — a symlink is skipped. + proj = safe_read_text_if_regular(root, root / ".weft" / "filigree" / "federation_token", label="federation_token") + candidates.append((proj or "").strip()) + for value in candidates: + if value and value not in out: + out.append(value) + return out + + +def _repair_filigree_auth(root: Path, url: str, transport: Transport) -> DoctorCheck: + """A 401/403 was seen. Probe each locally-readable mint; if exactly one is + accepted, pin it as WEFT_FEDERATION_TOKEN in .env and confirm. Otherwise write + nothing and report (the daemon likely uses an env override we cannot read).""" + for candidate in _filigree_token_candidates(root): + probe = FiligreeEmitter(url, transport=transport, token=candidate).verify_token() + if probe.reachable and probe.accepted: + try: + _rewrite_env_token(root / ".env", candidate) + except WardlineError as exc: + return DoctorCheck("filigree.auth", "error", message=str(exc)) + return DoctorCheck( + "filigree.auth", + "ok", + fixed=True, + message="wrote WEFT_FEDERATION_TOKEN to .env (was a stale/mismatched token)", + ) + return DoctorCheck( + "filigree.auth", + "error", + message="no local federation_token matched the daemon — it likely uses a " + "WEFT_FEDERATION_TOKEN env override; set that same value in .env", + ) + + +def _same_target(a: str, b: str) -> bool: + """True when two URLs name the same write target up to host spelling — identical + port and path. A bad literal reads as non-matching rather than crashing the check.""" + try: + pa, pb = urlsplit(a), urlsplit(b) + return (pa.port, pa.path) == (pb.port, pb.path) + except ValueError: + return False + + +def _live_published_url_behind_stale_pin( + root: Path, probed_url: str, transport: Transport, token: str | None +) -> str | None: + """When the probed (pinned) Filigree URL is unreachable, return a DIFFERENT + published-port URL that IS reachable — the signature of a stale ``.mcp.json`` + ``--filigree-url`` pin shadowing a daemon that rotated to a new port (the common + legacy ``.filigree/ephemeral.port`` rung outliving a rotation). ``None`` when there + is no pin, no live published target, or the published target names the same port (a + genuinely-absent daemon — left as the soft "not reachable" result).""" + if _mcp_filigree_url(root) is None: + return None # no pin: the probed URL already IS the published one + published = _filigree_published_url(root) + if published is None or _same_target(probed_url, published) or not _is_loopback(published): + return None + probe = FiligreeEmitter(published, transport=transport, token=token).verify_token() + return published if probe.reachable else None + + +def _check_filigree_auth( + root: Path, + *, + repair: bool, + filigree_url: str | None = None, + transport: Transport | None = None, +) -> DoctorCheck: + """Verify the token wardline would emit is accepted by the configured Filigree + daemon. Read-only probe; under *repair*, recover the accepted token from local + mints and pin it in .env. The probe targets only loopback origins.""" + probe_transport = transport if transport is not None else UrllibTransport(timeout=2.0) + url = _resolve_probe_url(root, filigree_url) + if url is None: + return DoctorCheck("filigree.auth", "ok", message="filigree not configured; nothing to verify") + if not _is_loopback(url): + return DoctorCheck("filigree.auth", "ok", message="non-loopback filigree; token not probed") + token = load_filigree_token(root) # may be None — probe anyway (the daemon may have auth off) + probe = FiligreeEmitter(url, transport=probe_transport, token=token).verify_token() + if not probe.reachable: + live = _live_published_url_behind_stale_pin(root, url, probe_transport, token) + if live is not None: + return DoctorCheck( + "filigree.auth", + "error", + message=( + f"configured --filigree-url ({url}) is unreachable, but Filigree is live at " + f"{live} (published port); run `wardline doctor --repair` to drop the stale pin " + "so discovery follows the live port" + ), + ) + return DoctorCheck("filigree.auth", "ok", message="filigree daemon not reachable; token not verified") + if probe.accepted: + return DoctorCheck("filigree.auth", "ok") + # Rejected (401/403): filigree auth is on and our credential is not accepted. + if repair: + return _repair_filigree_auth(root, url, probe_transport) + if token: + return DoctorCheck( + "filigree.auth", + "error", + message=f"emit token rejected by filigree ({probe.status}); " + "the configured token is not what the daemon accepts", + ) + return DoctorCheck( + "filigree.auth", + "error", + message="filigree rejected an unauthenticated emit and no federation token is set; " + "export WEFT_FEDERATION_TOKEN or add it to .env", + ) + + +def machine_readable_doctor( + root: Path, + *, + fix: bool = False, + filigree_url: str | None = None, + loomweave_url: str | None = None, + transport: Transport | None = None, +) -> dict[str, Any]: """Return the shared machine-readable doctor shape, optionally repairing install bindings.""" before = {check.name: check for check in check_install(root)} + config_missing_before = not weft_config_path(root).exists() bindings_fixed = False if fix: repair_install(root) bindings_fixed = not before.get("bindings", CheckResult("bindings", True, "")).ok + # Resolve the probe URL AFTER repair: when Filigree runs in server mode, repair + # (merge_mcp_entry) rewrites a default-shaped/unscoped --filigree-url to the live + # project scope, so the post-repair value is the URL the agent will actually emit + # to — and the one whose auth the filigree-auth check should probe. Without fix, + # repair is a no-op and this is just the recorded emit target. + probe_url = _resolve_probe_url(root, filigree_url) checks: list[DoctorCheck] = [] - checks.append(_check_config(root, fixed=fix and not weft_config_path(root).exists())) + checks.append(_check_config(root, fixed=fix and config_missing_before and weft_config_path(root).exists())) checks.append(_check_mcp_registration(root, before=before)) checks.append(_check_marker_package()) - checks.append(_check_url(root, "loomweave", fixed=bindings_fixed)) - checks.append(_check_url(root, "filigree", fixed=bindings_fixed)) + checks.append(_check_url(root, "loomweave", fixed=bindings_fixed, effective_url=loomweave_url)) + checks.append(_check_url(root, "filigree", fixed=bindings_fixed, effective_url=filigree_url)) checks.append(_check_decorator_grammar()) checks.append(_check_scan_output_path(root)) checks.append(_check_auth_token(root)) + checks.append(_check_filigree_auth(root, repair=fix, filigree_url=probe_url, transport=transport)) next_actions = [f"{check.id}: {check.message}" for check in checks if not check.ok and check.message] return { @@ -278,7 +592,8 @@ def repair_install(root: Path) -> dict[str, str]: statuses["Codex MCP"] = "repaired" detect_siblings(root) statuses["bindings"] = "detected" - # doctor MAY create its OWN state subtree (never weft.toml, never a sibling's). + statuses["weft.toml"] = "created" if _ensure_weft_config(root) else "checked" + # doctor MAY create its OWN state subtree (never a sibling's). weft_state_dir(root).mkdir(parents=True, exist_ok=True) statuses["state_dir"] = "ensured" return statuses diff --git a/src/wardline/install/mcp_json.py b/src/wardline/install/mcp_json.py index 65a713cb..f1374112 100644 --- a/src/wardline/install/mcp_json.py +++ b/src/wardline/install/mcp_json.py @@ -9,7 +9,13 @@ import tomllib from pathlib import Path from typing import Any +from urllib.parse import urlsplit +from wardline.core.config import ( + _filigree_published_url, + _loomweave_published_url, + filigree_server_scoped_url, +) from wardline.core.errors import WardlineError from wardline.core.safe_paths import safe_project_file @@ -35,12 +41,151 @@ def _local_mcp_entry() -> dict[str, object]: return {"type": "stdio", "command": _find_wardline_command(), "args": ["mcp", "--root", "."]} +# Operator-pinned sibling-endpoint flags, reconciled on repair (see +# _desired_sibling_url): +# - a NON-loopback (remote) pin is the operator's deliberate endpoint — preserved. +# - a loopback pin that a live published-port rung can reconstruct is DROPPED, so +# runtime discovery owns the always-current port. A frozen pin to a rotated-away +# port otherwise shadows the live daemon (the legacy .filigree/ephemeral.port rung +# outliving a port rotation is the canonical way this happens). Filigree SERVER +# mode is the exception: an unscoped write fail-closes under a multi-project +# daemon, so a loopback pin is repaired to the live project scope rather than +# dropped. +# - a loopback pin with no live daemon is preserved verbatim (cannot be improved). +_PRESERVED_ARG_FLAGS = ("--filigree-url", "--loomweave-url") + +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def _flag_pairs(entry: object) -> list[tuple[str, str]]: + """Operator-pinned ``(flag, value)`` pairs from an existing wardline entry's args, + in the order the operator wrote them. Returns ``[]`` for any shape that isn't a + list-of-args. Original order is preserved so an already-correct entry is recognized + as ``unchanged`` and is never needlessly reordered on repair.""" + if not isinstance(entry, dict): + return [] + args = entry.get("args") + if not isinstance(args, list): + return [] + pairs: list[tuple[str, str]] = [] + i = 0 + while i < len(args): + flag = args[i] + if flag in _PRESERVED_ARG_FLAGS and i + 1 < len(args) and isinstance(args[i + 1], str): + pairs.append((flag, args[i + 1])) + i += 2 + continue + i += 1 + return pairs + + +def _same_scope_target(a: str, b: str) -> bool: + """True when two URLs name the same Filigree write target up to loopback host + spelling — identical port and path (the scope-bearing parts). Lets an already- + correct entry that merely spells the host ``127.0.0.1`` (vs our ``localhost``) be + recognised as correct and preserved verbatim, rather than churned every repair.""" + try: + pa, pb = urlsplit(a), urlsplit(b) + # .port lazily parses the authority; a malformed literal (http://localhost:notaport) + # raises ValueError HERE, not at urlsplit. Parse both inside the guard so a bad + # preserved URL reads as non-matching (and gets replaced) instead of crashing repair. + a_port, b_port = pa.port, pb.port + except ValueError: + return False + if pa.hostname not in _LOOPBACK_HOSTS or pb.hostname not in _LOOPBACK_HOSTS: + return False + return (a_port, pa.path) == (b_port, pb.path) + + +def _is_loopback_url(value: str) -> bool: + """True when *value* is a loopback HTTP URL (a default-shaped, locally-rebuildable + target). A non-loopback host is an operator's deliberate remote endpoint and is + never rewritten.""" + try: + host = urlsplit(value).hostname + except ValueError: + return False + return host in _LOOPBACK_HOSTS + + +def _desired_sibling_url(flag: str, existing: str | None, root: Path) -> str | None: + """The value to write for *flag* (``--filigree-url`` / ``--loomweave-url``), or + ``None`` to DROP the flag entirely. + + A non-loopback (remote) pin is the operator's deliberate endpoint and is always + preserved. A loopback pin is a locally-rebuildable target: when a live published + port exists for that sibling the pin is redundant-or-stale, so it is dropped and + runtime published-port discovery owns the (always-current) port — except in + Filigree server mode, where an unscoped write fail-closes (N1) and the pin is + repaired to the live project scope instead. With no live daemon the pin is left + verbatim (we cannot improve on it). A fresh entry (no pin) only gains a flag in + Filigree server mode, where the scoped target must be baked.""" + if existing is not None and not _is_loopback_url(existing): + return existing # remote endpoint — operator's deliberate choice, never touched + if flag == "--filigree-url": + scope = filigree_server_scoped_url(root) + if scope is not None: + if existing is None: + return scope # fresh server-mode install lands a working scoped target + return existing if _same_scope_target(existing, scope) else scope + published: str | None = _filigree_published_url(root) + else: + published = _loomweave_published_url(root) + if existing is None: + return None # per-project discovery owns the port; never bake a loopback pin + return None if published is not None else existing + + +def _desired_sibling_args(entry: object, root: Path) -> list[str]: + """Sibling-URL args for the desired entry: each operator-pinned ``--filigree-url`` + / ``--loomweave-url`` reconciled by :func:`_desired_sibling_url` (preserved, + repaired-to-scope, or DROPPED) in the operator's original order. A Filigree + server-mode scope with no existing flag is appended so a fresh install lands a + working scoped target out of the box.""" + pairs = _flag_pairs(entry) + existing = {flag: value for flag, value in pairs} + desired = {flag: _desired_sibling_url(flag, existing.get(flag), root) for flag in _PRESERVED_ARG_FLAGS} + + out: list[str] = [] + seen: set[str] = set() + for flag, _value in pairs: + seen.add(flag) + value = desired[flag] + if value is not None: + out.extend((flag, value)) + for flag in _PRESERVED_ARG_FLAGS: + if flag not in seen: + value = desired[flag] + if value is not None: + out.extend((flag, value)) + return out + + +def _desired_local_entry(existing: object, root: Path) -> dict[str, object]: + """The canonical local entry, augmented with the desired sibling-URL args (see + :func:`_desired_sibling_args`). Idempotent: re-running over the desired entry + reproduces it.""" + entry = _local_mcp_entry() + extra = _desired_sibling_args(existing, root) + if extra: + base_args = entry["args"] + assert isinstance(base_args, list) + entry["args"] = [*base_args, *extra] + return entry + + def merge_mcp_entry(root: Path) -> str: - """Add/replace the `wardline` entry under mcpServers. Returns created|updated|unchanged.""" + """Add/replace the `wardline` entry under mcpServers. Returns created|updated|unchanged. + + An existing entry's operator-pinned ``--loomweave-url`` and (remote) + ``--filigree-url`` args are preserved (they are the runtime emit/discovery target + when the published-port rung cannot reconstruct them). When Filigree runs in + server mode for *root*, a default-shaped (loopback) or absent ``--filigree-url`` is + set/repaired to the live project scope so a fresh install lands a working, + fail-close-safe emit target out of the box.""" path = safe_project_file(root, root / ".mcp.json", label=".mcp.json") - entry = _local_mcp_entry() if not path.exists(): - payload = {"mcpServers": {"wardline": entry}} + payload = {"mcpServers": {"wardline": _desired_local_entry(None, root)}} path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") return "created" try: @@ -55,6 +200,7 @@ def merge_mcp_entry(root: Path) -> str: data["mcpServers"] = servers if not isinstance(servers, dict): raise WardlineError(".mcp.json mcpServers must be an object") + entry = _desired_local_entry(servers.get("wardline"), root) if servers.get("wardline") == entry: return "unchanged" servers["wardline"] = entry diff --git a/src/wardline/install/skill.py b/src/wardline/install/skill.py index e7c1d74f..37b72e4a 100644 --- a/src/wardline/install/skill.py +++ b/src/wardline/install/skill.py @@ -5,6 +5,9 @@ import shutil from pathlib import Path +from wardline.core.errors import WardlineError +from wardline.core.safe_paths import safe_project_path + def _skill_source() -> Path: # src/wardline/install/skill.py -> src/wardline/skills/wardline-gate @@ -19,11 +22,17 @@ def install_skill(root: Path) -> dict[str, str]: src = _skill_source() results: dict[str, str] = {} for base in (".claude", ".agents"): - dest = root / base / "skills" / "wardline-gate" + label = f"{base}/skills/wardline-gate" + dest = safe_project_path(root, root / base / "skills" / "wardline-gate", label=label) existed = dest.exists() or dest.is_symlink() if existed: + if dest.is_symlink(): + raise WardlineError(f"{label}: refusing to overwrite a symlink") + if not dest.is_dir(): + raise WardlineError(f"{label}: expected a directory") shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) + dest = safe_project_path(root, dest, label=label) shutil.copytree(src, dest) results[base] = "overwritten" if existed else "created" return results diff --git a/src/wardline/loomweave/client.py b/src/wardline/loomweave/client.py index 35805caa..0f3cb10f 100644 --- a/src/wardline/loomweave/client.py +++ b/src/wardline/loomweave/client.py @@ -15,11 +15,12 @@ import json import logging +import math import secrets import urllib.error import urllib.parse import urllib.request -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass from typing import Any, Protocol @@ -31,6 +32,8 @@ _ALLOWED_SCHEMES = ("http", "https") +MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024 +_JSON_SEPARATORS = (",", ":") @dataclass(frozen=True, slots=True) @@ -124,9 +127,8 @@ def from_wire(cls, obj: Mapping[str, Any]) -> TaintFactBySeiView: ) -def _chunks(seq: Sequence[Any], size: int) -> Iterator[Sequence[Any]]: - for i in range(0, len(seq), size): - yield seq[i : i + size] +def _json_body(payload: dict[str, Any]) -> bytes: + return json.dumps(payload, separators=_JSON_SEPARATORS).encode("utf-8") def _error_code(body: str) -> str | None: @@ -137,6 +139,17 @@ def _error_code(body: str) -> str | None: return parsed.get("code") if isinstance(parsed, dict) else None +def _linkage_total(raw: object, fallback: int) -> int: + if isinstance(raw, bool): + return fallback + if isinstance(raw, int): + return raw if raw >= 0 else fallback + if isinstance(raw, float) and math.isfinite(raw): + total = int(raw) + return total if total >= 0 else fallback + return fallback + + class LoomweaveClient: def __init__( self, @@ -146,18 +159,49 @@ def __init__( project: str, transport: Transport | None = None, batch_max: int = 2000, + max_body_bytes: int = MAX_REQUEST_BODY_BYTES, ) -> None: self._base = base_url.rstrip("/") self._secret = secret self._project = project self._transport: Transport = transport if transport is not None else UrllibTransport() - self._batch_max = batch_max + self._batch_max = max(1, batch_max) + self._max_body_bytes = max(1, max_body_bytes) + + def _payload_chunks( + self, + items: Sequence[Any], + make_payload: Callable[[list[Any]], dict[str, Any]], + ) -> Iterator[list[Any]]: + chunk: list[Any] = [] + for item in items: + candidate = [*chunk, item] + over_count = len(candidate) > self._batch_max + over_bytes = len(_json_body(make_payload(candidate))) > self._max_body_bytes + if chunk and (over_count or over_bytes): + yield chunk + chunk = [item] + continue + chunk = candidate + if len(chunk) == 1 and len(_json_body(make_payload(chunk))) > self._max_body_bytes: + yield chunk + chunk = [] + if chunk: + yield chunk def _send(self, method: str, path_and_query: str, payload: dict[str, Any] | None) -> Response | None: """Sign + send. Returns the Response, or None on a SOFT failure (outage/5xx).""" import time - body = json.dumps(payload).encode("utf-8") if payload is not None else b"" + body = _json_body(payload) if payload is not None else b"" + if len(body) > self._max_body_bytes: + logger.warning( + "Loomweave request body for %s is %d bytes, over the %d-byte cap", + path_and_query, + len(body), + self._max_body_bytes, + ) + return None headers: dict[str, str] = {} if payload is not None: headers["Content-Type"] = "application/json" @@ -191,14 +235,39 @@ def _require_ok(self, resp: Response, path: str) -> dict[str, Any]: # an empty envelope rather than raising (mirrors filigree_emit's defensiveness). return parsed if isinstance(parsed, dict) else {} - def resolve(self, qualnames: list[str]) -> ResolveResult | None: + def resolve(self, qualnames: list[str], *, plugin: str | None = None) -> ResolveResult | None: + """POST /api/wardline/resolve — qualname -> locator, batched. + + ``plugin`` is the OPTIONAL batch-scoped producer hint (ADR-036 plugin-aware + resolution; shape agreed in docs/integration/2026-06-11-wardline-resolve- + plugin-hint-proposal.md): a CONSTRAINT, never a preference — resolution is + restricted to that plugin's namespace, so a hinted qualname owned only by + another plugin stays unresolved. Omitted = the unhinted cross-plugin behavior + (ambiguous degrades to unresolved server-side), legal forever. + + Fail-soft posture: a 4xx on a HINTED request downgrades the chunk to + unresolved — an older Loomweave whose ``ResolveRequest`` is + ``deny_unknown_fields`` 400s on the hint field, and identity enrichment must + degrade, not crash. An UNHINTED 4xx stays loud (it cannot be a version skew on + this field — it is a real request bug, e.g. ``INVALID_PATH``, and silence + would hide it). Outage/5xx stays ``None`` ("unreachable", ``_send``).""" resolved: dict[str, str] = {} unresolved: list[str] = [] - for chunk in _chunks(qualnames, self._batch_max): - payload = {"project": self._project, "qualnames": list(chunk)} + + def make_payload(chunk: list[Any]) -> dict[str, Any]: + payload: dict[str, Any] = {"project": self._project, "qualnames": list(chunk)} + if plugin is not None: + payload["plugin"] = plugin + return payload + + for chunk in self._payload_chunks(qualnames, make_payload): + payload = make_payload(chunk) resp = self._send("POST", "/api/wardline/resolve", payload) if resp is None: return None + if plugin is not None and 400 <= resp.status < 500: + unresolved.extend(str(q) for q in chunk) + continue data = self._require_ok(resp, "/api/wardline/resolve") r = data.get("resolved") if isinstance(r, dict): @@ -217,8 +286,12 @@ def write_taint_facts(self, facts: list[dict[str, Any]]) -> WriteResult: first failure — the caller's remedy is to re-run the whole scan/write.""" written = 0 unresolved: list[str] = [] - for chunk in _chunks(facts, self._batch_max): - payload = {"project": self._project, "facts": list(chunk)} + + def make_payload(chunk: list[Any]) -> dict[str, Any]: + return {"project": self._project, "facts": list(chunk)} + + for chunk in self._payload_chunks(facts, make_payload): + payload = make_payload(chunk) resp = self._send("POST", "/api/wardline/taint-facts", payload) if resp is None: return WriteResult(reachable=False) # soft outage @@ -244,8 +317,12 @@ def get_taint_fact(self, qualname: str) -> TaintFactView | None: def batch_get(self, qualnames: list[str]) -> list[TaintFactView] | None: views: list[TaintFactView] = [] - for chunk in _chunks(qualnames, self._batch_max): - payload = {"project": self._project, "qualnames": list(chunk)} + + def make_payload(chunk: list[Any]) -> dict[str, Any]: + return {"project": self._project, "qualnames": list(chunk)} + + for chunk in self._payload_chunks(qualnames, make_payload): + payload = make_payload(chunk) resp = self._send("POST", "/api/wardline/taint-facts:batch-get", payload) if resp is None: return None @@ -270,8 +347,12 @@ def batch_get_by_sei(self, seis: list[str]) -> list[TaintFactBySeiView] | None: Gate on :meth:`wardline.loomweave.identity.TaintStoreCapability` before calling — the route is absent on a pre-0006 Loomweave.""" views: list[TaintFactBySeiView] = [] - for chunk in _chunks(seis, self._batch_max): - payload = {"project": self._project, "seis": list(chunk)} + + def make_payload(chunk: list[Any]) -> dict[str, Any]: + return {"project": self._project, "seis": list(chunk)} + + for chunk in self._payload_chunks(seis, make_payload): + payload = make_payload(chunk) resp = self._send("POST", "/api/wardline/taint-facts/by-sei", payload) if resp is None: return None @@ -357,8 +438,8 @@ def _get_linkages(self, entity_id: str, direction: str, limit: int) -> LinkageRe total = data.get("total") return LinkageResult( neighbours=neighbours, - # accept int or a JSON float; any other shape → fall back to the count read - total=int(total) if isinstance(total, (int, float)) and not isinstance(total, bool) else len(neighbours), + # accept finite JSON numbers; malformed totals fall back to the count read + total=_linkage_total(total, len(neighbours)), truncated=bool(data.get("truncated", False)), ) diff --git a/src/wardline/loomweave/dossier_sources.py b/src/wardline/loomweave/dossier_sources.py index f2118fec..9df4ecf0 100644 --- a/src/wardline/loomweave/dossier_sources.py +++ b/src/wardline/loomweave/dossier_sources.py @@ -33,7 +33,7 @@ def get_callees(self, entity_id: str, *, limit: int = ...) -> LinkageResult | No class _ResolveClient(Protocol): - def resolve(self, qualnames: list[str]) -> ResolveResult | None: ... + def resolve(self, qualnames: list[str], *, plugin: str | None = None) -> ResolveResult | None: ... class _Resolver(Protocol): @@ -79,14 +79,22 @@ def linkages(self, binding: EntityBinding) -> LinkagesSection: ) -def resolve_entity_binding(client: _ResolveClient, resolver: _Resolver, qualname: str) -> EntityBinding | None: +def resolve_entity_binding( + client: _ResolveClient, resolver: _Resolver, qualname: str, *, plugin: str | None = None +) -> EntityBinding | None: """Resolve a Wardline qualname to its opaque SEI :class:`EntityBinding`. Two hops, both via existing Track-3 surfaces: ``resolve`` maps the qualname to its Loomweave locator, then the ``SeiResolver`` maps the locator to its SEI binding (the identity axis). Returns None when the qualname cannot be resolved to a locator (the - caller degrades to a no-binding, honest-unavailable dossier — never a guessed key).""" - rr = client.resolve([qualname]) + caller degrades to a no-binding, honest-unavailable dossier — never a guessed key). + + ``plugin`` is the batch-scoped producer hint (ADR-036): pass it when the caller + KNOWS which frontend minted the qualname (attest boundaries and decorator coverage + are Python-surface; a finding's producer derives from its rule id); omit it when + the language is genuinely unknown (a user-supplied dossier entity) — the contract + never fabricates a hint.""" + rr = client.resolve([qualname], plugin=plugin) locator = rr.resolved.get(qualname) if rr is not None else None if not locator: return None diff --git a/src/wardline/loomweave/facts.py b/src/wardline/loomweave/facts.py index 590e0742..d0ace9aa 100644 --- a/src/wardline/loomweave/facts.py +++ b/src/wardline/loomweave/facts.py @@ -21,6 +21,7 @@ from __future__ import annotations +import hashlib from pathlib import Path from typing import TYPE_CHECKING, Any @@ -57,7 +58,6 @@ def build_taint_facts(result: ScanResult, root: Path) -> list[dict[str, Any]]: context = result.context if context is None: return [] - blake3 = require_blake3() hash_cache: dict[str, str] = {} findings_by_qualname: dict[str, list[dict[str, Any]]] = {} @@ -76,22 +76,9 @@ def build_taint_facts(result: ScanResult, root: Path) -> list[dict[str, Any]]: facts: list[dict[str, Any]] = [] for qualname, entity in context.entities.items(): rel_path = entity.location.path - if rel_path not in hash_cache: - hash_cache[rel_path] = blake3.blake3(_read_bytes(root / rel_path)).hexdigest() - # RESIDUAL (builtin-marker shadow false-green): ``content_hash_at_compute`` - # is whole-file raw-byte blake3 ONLY — it cannot observe the shadow state of - # a builtin marker root. So identical file bytes scanned once UNSHADOWED then - # under a project that shadows ``wardline``/``weft_markers`` could serve a - # stale TRUSTED fact via the MCP explain_taint / Loomweave read path. We do NOT - # fold the shadow bit / provider fingerprint into this hash: it is a - # CROSS-TOOL contract value — Loomweave's read path INDEPENDENTLY recomputes - # the whole-file blake3 (loomweave_storage::current_file_hash) and compares it - # against the in-blob copy. Mixing in a Wardline-private bit would make every - # comparison mismatch and break fact reconciliation entirely; there is no - # separate Wardline-owned compute-key the freshness gate consults. Closing - # this fully needs a Loomweave read-path contract change. Lower impact: this - # path is opt-in (--loomweave-url) and not the scan gate. See CHANGELOG. - content_hash = hash_cache[rel_path] + content_hash = _content_hash_for_analyzed_file(root, rel_path, context, hash_cache) + if content_hash is None: + continue declared = context.project_return_taints.get(qualname) actual = context.function_return_taints.get(qualname) @@ -132,3 +119,24 @@ def _dead_code_root_blob(is_declared: bool) -> dict[str, Any]: "tags": ["entry-point"], "reason": _ROOT_REASON, } + + +def _content_hash_for_analyzed_file( + root: Path, + rel_path: str, + context: AnalysisContext, + hash_cache: dict[str, str], +) -> str | None: + if rel_path in hash_cache: + return hash_cache[rel_path] + try: + current_bytes = _read_bytes(root / rel_path) + except OSError: + return None + analyzed_sha256 = context.analyzed_source_sha256.get(rel_path) + if analyzed_sha256 is not None and hashlib.sha256(current_bytes).hexdigest() != analyzed_sha256: + return None + blake3 = require_blake3() + content_hash = str(blake3.blake3(current_bytes).hexdigest()) + hash_cache[rel_path] = content_hash + return content_hash diff --git a/src/wardline/mcp/freshness.py b/src/wardline/mcp/freshness.py new file mode 100644 index 00000000..6b37da05 --- /dev/null +++ b/src/wardline/mcp/freshness.py @@ -0,0 +1,84 @@ +"""Server self-identification + source-freshness verdict for the MCP `doctor` tool. + +The 2026-06-06 stale-server incident: long-lived `wardline mcp` processes (editable +install) kept serving code that predated the tree being edited, and nothing on the +wire said so — `initialize` reports only name+version, which does not change on an +editable re-install. The freshness verdict here is the mtime test that DOES catch it: +if any file under the imported ``wardline`` package is newer than this process's +start time, the running server is serving old code and must be restarted. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from wardline._version import __version__ + +__all__ = ["attach_server_identity", "server_identity"] + + +def _iso(ts: float) -> str: + return datetime.fromtimestamp(ts, tz=UTC).isoformat() + + +def _latest_source_mtime() -> tuple[float | None, str | None]: + """(mtime, relative path) of the newest ``*.py`` under the imported wardline + package, or (None, None) when nothing is statable. Walking the package the + PROCESS imported (not the project root) is the point: that is the code this + server is actually serving, wherever it is installed.""" + import wardline + + pkg_dir = Path(wardline.__file__).resolve().parent + latest: float | None = None + latest_path: str | None = None + for p in pkg_dir.rglob("*.py"): + try: + mtime = p.stat().st_mtime + except OSError: + continue + if latest is None or mtime > latest: + latest = mtime + latest_path = str(p.relative_to(pkg_dir)) + return latest, latest_path + + +def server_identity(*, root: Path, started_at: float) -> dict[str, Any]: + """The running server's self-identification block. ``fresh`` is False when the + on-disk package source changed after this process started.""" + latest, latest_path = _latest_source_mtime() + fresh = latest is None or latest <= started_at + identity: dict[str, Any] = { + "package_version": __version__, + "pid": os.getpid(), + "project_root": str(root), + "started_at": _iso(started_at), + "source_latest_mtime": _iso(latest) if latest is not None else None, + "source_latest_path": latest_path, + "fresh": fresh, + } + return identity + + +def attach_server_identity(payload: dict[str, Any], *, root: Path, started_at: float) -> dict[str, Any]: + """Merge the server block + a ``server.freshness`` check into a + ``machine_readable_doctor`` envelope (same check shape, so the agent reads one + uniform list). A stale server flips ``ok`` and lands in ``next_actions`` — it is + a health failure: every other verdict in the payload came from old code.""" + identity = server_identity(root=root, started_at=started_at) + payload["server"] = identity + if identity["fresh"]: + payload["checks"].append({"id": "server.freshness", "status": "ok", "fixed": False}) + return payload + message = ( + f"wardline source changed after this MCP server started " + f"({identity['source_latest_path']} at {identity['source_latest_mtime']}, " + f"server started {identity['started_at']}) — this server is serving OLD code; " + f"restart the wardline MCP server" + ) + payload["checks"].append({"id": "server.freshness", "status": "error", "fixed": False, "message": message}) + payload["ok"] = False + payload["next_actions"].append(f"server.freshness: {message}") + return payload diff --git a/src/wardline/mcp/protocol.py b/src/wardline/mcp/protocol.py index 065180ea..fba3cc26 100644 --- a/src/wardline/mcp/protocol.py +++ b/src/wardline/mcp/protocol.py @@ -11,7 +11,10 @@ from collections.abc import Callable from typing import Any, TextIO -PROTOCOL_VERSION = "2024-11-05" # MCP protocol revision this server speaks +# MCP protocol revisions this server speaks, newest first. structuredContent/outputSchema +# exist only in 2025-06-18; tool annotations/title arrived in 2025-03-26. +SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05") +PROTOCOL_VERSION = "2025-06-18" # the latest MCP protocol revision this server speaks Handler = Callable[[dict[str, Any]], Any] @@ -46,8 +49,12 @@ def register(self, method: str, handler: Handler) -> None: self._handlers[method] = handler def _initialize(self, params: dict[str, Any]) -> dict[str, Any]: + # Spec negotiation: echo the client's requested revision when we support it, + # otherwise answer with the latest revision we speak. + requested = params.get("protocolVersion") + version = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else PROTOCOL_VERSION return { - "protocolVersion": PROTOCOL_VERSION, + "protocolVersion": version, "capabilities": self.capabilities, "serverInfo": {"name": self._name, "version": self._version}, } diff --git a/src/wardline/mcp/server.py b/src/wardline/mcp/server.py index bd931312..8b212516 100644 --- a/src/wardline/mcp/server.py +++ b/src/wardline/mcp/server.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import time from dataclasses import replace from datetime import date from pathlib import Path @@ -20,14 +21,15 @@ from wardline.core.attest_key import load_attest_key from wardline.core.baseline import generate_baseline, load_baseline from wardline.core.errors import WardlineError -from wardline.core.explain import explain_chain, explain_finding, explanation_from_context -from wardline.core.filigree_emit import FiligreeEmitter, filigree_disabled_reason -from wardline.core.finding import Finding, Kind, Severity, SuppressionState +from wardline.core.explain import explain_taint_result, explanation_from_context, explanation_to_dict +from wardline.core.filigree_emit import FiligreeEmitter, filigree_destination, filigree_disabled_reason +from wardline.core.finding import Finding, Severity from wardline.core.finding_query import filter_findings from wardline.core.judge_run import run_judge from wardline.core.paths import baseline_path as baseline_file from wardline.core.paths import waivers_path, weft_config_path from wardline.core.run import baseline_migration_hint, gate_decision, run_scan +from wardline.core.scan_jobs import cancel_scan_job, read_scan_job_status, start_scan_job from wardline.core.sei_resolution import resolve_query_filters from wardline.core.waivers import add_waiver, load_project_waivers from wardline.mcp.prompts import get_prompt, list_prompts @@ -35,11 +37,23 @@ from wardline.mcp.resources import list_resources, read_resource from wardline.mcp.tooling import Tool, ToolCapability, ToolError, ToolPolicy from wardline.mcp.tooling import cfg as _cfg -from wardline.mcp.tooling import explanation_to_dict as _explanation_to_dict -from wardline.mcp.tooling import finding_to_dict as _finding_to_dict from wardline.mcp.tooling import require as _require from wardline.mcp.tooling import resolve_under_root as _resolve_under_root +# Gate thresholds are the four defect severities. Severity also defines NONE +# (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"] + +# Default ceiling on the number of active-defect provenances inlined by `explain: true` +# on the MCP `scan`. Bounds the one-shot payload (the dogfood report hit 56,820 chars on +# one line over a whole repo); an explicit `max_findings` tightens it further. +_EXPLAIN_DEFAULT_CAP = 10 +# The bounded-default page size for `scan` (weft-439d09fc8d). A bare scan returns at most +# this many finding bodies so an agent's first natural call cannot overflow its context; +# full=true lifts the cap and offset pages through the rest. +_DEFAULT_MAX_FINDINGS = 25 + def _emit_filigree( findings: list[Finding], filigree: Any, *, scanned_paths: tuple[str, ...] = () @@ -58,11 +72,20 @@ def _emit_filigree( "created": er.created, "updated": er.updated, "failed": er.failed, + # PDR-0023 honesty surface: per-finding reject reasons so a partial ingest is + # distinguishable from a clean emit. ``failed`` is the count; ``failures`` says which + # findings and why (rejected / validation_error / scheme_mismatch / partial). + "failures": [f.to_wire() for f in er.failures], "warnings": list(er.warnings), # Distinguish auth-rejected (401/403) from transport-unreachable so the agent reads - # an actionable reason, not a flat "unreachable" (dogfood #5). + # an actionable reason, not a flat "unreachable" (dogfood #5). token_sent + url further + # split a 401 into no-token vs token-rejected, naming where it tried (C-7). "status": er.status, "auth_rejected": er.auth_rejected, + "token_sent": er.token_sent, + "url": er.url, + # N1 / C-10(a): name where findings went so a wrong-project write is visible. + "destination": filigree_destination(er.url), } @@ -74,12 +97,16 @@ def _filigree_emit_status(block: dict[str, Any] | None) -> dict[str, Any]: "created": 0, "updated": 0, "failed": 0, + "failures": [], "warnings": [], "disabled_reason": "not configured", + "destination": filigree_destination(None), } disabled_reason = filigree_disabled_reason( reachable=bool(block.get("reachable")), status=block.get("status"), + token_sent=bool(block.get("token_sent")), + url=block.get("url"), ) return {"configured": True, "disabled_reason": disabled_reason, **block} @@ -130,6 +157,117 @@ def _file_finding(args: dict[str, Any], root: Path, filer: Any, loomweave: Any = return payload +_FILE_FINDING_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the file_finding tool: the outcome of promoting ONE finding (by fingerprint) " + "into a tracked Filigree issue, fail-soft on reachability.", + "properties": { + "reachable": { + "type": "boolean", + "description": "Whether Filigree's promote route was reachable. False on transport failure, 5xx outage, " + "or 401/403 auth refusal (all soft).", + }, + "issue_id": { + "type": ["string", "null"], + "description": "The Filigree issue id the fingerprint was promoted into; null when unreachable or the " + "fingerprint was not found.", + }, + "created": { + "type": "boolean", + "description": "True when the promote created a NEW issue (vs returning an existing one).", + }, + "not_found": { + "type": "boolean", + "description": "True when Filigree was reachable but the fingerprint is unknown to it (404 — emit " + "findings to Filigree first).", + }, + "fingerprint": {"type": "string", "description": "The fingerprint that was filed (echoed from the request)."}, + "disabled_reason": { + "type": ["string", "null"], + "description": "Why enrichment was unavailable (e.g. 'filigree unreachable', 'filigree 503'); null on " + "success.", + }, + "identity_attach": { + "type": "object", + "description": "Present only when attach_loomweave_identity=true was requested: the outcome of binding " + "the finding's Loomweave entity identity to the filed issue.", + "properties": { + "attempted": { + "type": "boolean", + "description": "Whether an identity attach was attempted at all (false when there is no issue_id " + "or no Loomweave URL configured).", + }, + "attached": { + "type": "boolean", + "description": "Whether the entity association was successfully attached to the Filigree issue.", + }, + "entity_id": { + "type": ["string", "null"], + "description": "The entity identifier used for the binding — a 'loomweave:eid:...' SEI or a " + "legacy '{plugin}:function:{qualname}' locator.", + }, + "content_hash": { + "type": ["string", "null"], + "description": "The entity content hash captured at attach time (drift-detection anchor); null " + "when unresolved.", + }, + "binding_kind": { + "type": ["string", "null"], + "enum": ["sei", "locator", None], + "description": "Whether the binding used a rename-stable SEI or a legacy locator; null when no " + "binding was attempted.", + }, + "reason": { + "type": ["string", "null"], + "description": "Human-readable reason when not attempted or skipped; null on success.", + }, + }, + "required": ["attempted", "attached", "entity_id", "content_hash", "binding_kind", "reason"], + "additionalProperties": False, + }, + }, + "required": ["reachable", "issue_id", "created", "not_found", "fingerprint", "disabled_reason"], + "additionalProperties": False, +} + + +_FILE_FINDING_TOOL: dict[str, Any] = { + "name": "file_finding", + "title": "File finding as Filigree issue", + "description": "File ONE finding (by `fingerprint`) into a tracked Filigree issue and " + "return its `issue_id`. Idempotent (re-filing returns the same issue). Emit findings " + "to Filigree first (scan with a configured Filigree URL) so the fingerprint is known; " + "a `not_found: true` result means it isn't. Reconciliation (close-on-fixed / " + "reopen-on-regress) happens automatically on later scans. Fail-soft.", + "input_schema": { + "type": "object", + "required": ["fingerprint"], + "properties": { + "fingerprint": {"type": "string"}, + "priority": {"type": "string", "description": "Filigree priority, e.g. P2"}, + "labels": {"type": "array", "items": {"type": "string"}}, + "attach_loomweave_identity": { + "type": "boolean", + "description": ( + "Opt in to resolving the finding qualname through Loomweave and attaching " + "a Filigree entity association." + ), + }, + "config": {"type": "string"}, + }, + }, + "output_schema": _FILE_FINDING_OUTPUT_SCHEMA, + "annotations": { + "title": "File finding as Filigree issue", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE, ToolCapability.NETWORK}), +} + + def _scan_file_findings( args: dict[str, Any], root: Path, @@ -166,6 +304,376 @@ def _scan_file_findings( ) +_SCAN_FILE_FINDINGS_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the scan_file_findings tool: one-shot scan -> (optionally) emit findings to " + "Filigree -> (optionally) promote selected active defects into tracked issues.", + "properties": { + "mode": { + "type": "string", + "enum": ["dry_run", "all_active", "fingerprints"], + "description": "Selection mode that ran: dry_run (nothing promoted), all_active (every active defect " + "selected), or fingerprints (explicit selection).", + }, + "files_scanned": {"type": "integer", "description": "Number of files the scan analyzed."}, + "summary": { + "type": "object", + "description": "Finding counts by suppression class for the whole scan.", + "properties": { + "total": {"type": "integer", "description": "Every finding (defects + facts/metrics)."}, + "active": {"type": "integer", "description": "Non-suppressed defects."}, + "baselined": {"type": "integer", "description": "Defects suppressed by the baseline."}, + "waived": {"type": "integer", "description": "Defects suppressed by waivers."}, + "judged": {"type": "integer", "description": "Defects suppressed by judge FALSE_POSITIVE records."}, + "informational": {"type": "integer", "description": "Informational (non-gating) findings."}, + "unanalyzed": { + "type": "integer", + "description": "Files discovered but never analyzed (benign no-module skips excluded).", + }, + }, + "required": ["total", "active", "baselined", "waived", "judged", "informational", "unanalyzed"], + "additionalProperties": False, + }, + "gate": { + "type": "object", + "description": "The pass/fail gate decision for this scan (a trip is data, not an error).", + "properties": { + "tripped": {"type": "boolean", "description": "Whether the gate tripped."}, + "fail_on": { + "type": ["string", "null"], + "description": "The severity threshold the gate evaluated (e.g. 'ERROR'); null when no threshold " + "was given.", + }, + "exit_class": { + "type": "integer", + "description": "CLI-equivalent exit class: 0 clean, 1 gate tripped (2 is reserved for tool errors " + "and never appears here).", + }, + "verdict": { + "type": "string", + "enum": ["NOT_EVALUATED", "PASSED", "FAILED"], + "description": "NOT_EVALUATED = no threshold ran; PASSED/FAILED = a threshold ran. Never reads a " + "bare scan as a clean pass.", + }, + "would_trip_at": { + "type": ["string", "null"], + "description": "Highest severity at which the gate WOULD trip on the evaluated population; null " + "when nothing would trip.", + }, + }, + "required": ["tripped", "fail_on", "exit_class", "verdict", "would_trip_at"], + "additionalProperties": False, + }, + "filigree_emit": { + "type": "object", + "description": "Outcome of bulk-emitting scan findings to Filigree (runs only when findings were selected " + "and an emitter is configured).", + "properties": { + "configured": { + "type": "boolean", + "description": "Whether a Filigree emitter is configured for this server.", + }, + "reachable": { + "type": ["boolean", "null"], + "description": "Whether Filigree was reachable for the emit; null when no emit was attempted.", + }, + "created": {"type": "integer", "description": "Findings newly created in Filigree."}, + "updated": {"type": "integer", "description": "Findings updated in Filigree."}, + "failed": { + "type": "integer", + "description": "Count of findings that did NOT land in Filigree (derived from `failures`). " + "0 here is earned from real per-finding records, not assumed — see `failures` for which and why.", + }, + "failures": { + "type": "array", + "description": "PDR-0023 honesty surface: one record per finding that failed to land, so a " + "PARTIAL ingest ('M of N emitted, K rejected because R') is distinguishable from a clean emit " + "('all N emitted'). Empty on a clean run — but earned, not hardwired.", + "items": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": ["rejected", "validation_error", "scheme_mismatch", "partial"], + "description": "Machine-readable failure case: rejected (Filigree refused this " + "finding), validation_error (malformed body), scheme_mismatch (fingerprint-scheme " + "drift — a join-miss, not a true-negative), partial (the whole chunk was rejected at " + "the protocol layer, so the cause is the request not the body).", + }, + "detail": {"type": "string", "description": "Filigree's per-finding reject explanation."}, + "reason_class": { + "type": "string", + "enum": ["rejected", "scheme_mismatch", "partial"], + "description": "weft-reason (G1): the canonical reason_class this failure maps to " + "(one of the closed 11 in contracts/weft-reason-vocab.json). validation_error maps to " + "rejected; the domain term stays in `reason`/`cause`.", + }, + "cause": { + "type": "string", + "description": "weft-reason carrier `cause`: the why (Filigree's detail, else the " + "domain reason). Always present on a failure (a failure is never clean).", + }, + "fix": { + "type": "string", + "description": "weft-reason carrier `fix` (MANDATORY on a non-clean carrier): the " + "remedial action.", + }, + "fingerprint": { + "type": "string", + "description": "The wardline join key for the failed finding (absent when the " + "failure is chunk-wide and not attributable to one finding).", + }, + }, + "required": ["reason", "detail", "reason_class", "cause", "fix"], + "additionalProperties": False, + }, + }, + "warnings": {"type": "array", "items": {"type": "string"}, "description": "Non-fatal emit warnings."}, + "disabled_reason": { + "type": ["string", "null"], + "description": "Why the emit failed soft — the discriminated 401/403-vs-5xx-vs-transport " + "ladder ('not configured', 'filigree rejected the token (401)...', 'filigree unreachable'). " + "null means success OR no emit was attempted (dry-run / nothing selected) — read `reachable` " + "to tell them apart (null = no attempt).", + }, + }, + "required": [ + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason", + ], + "additionalProperties": False, + }, + "active_defects": { + "type": "array", + "description": "Every active (non-suppressed) defect in the scan, each with its per-finding promotion and " + "identity-attach outcome.", + "items": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "description": "Stable finding fingerprint (the promotion join key).", + }, + "rule_id": {"type": "string", "description": "Rule that produced the finding (e.g. PY-WL-101)."}, + "severity": { + "type": "string", + "enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE"], + "description": "Finding severity.", + }, + "message": {"type": "string", "description": "Human-readable finding message."}, + "qualname": { + "type": ["string", "null"], + "description": "Dotted module-qualified name of the enclosing callable; null when the finding " + "has no callable anchor.", + }, + "path": {"type": "string", "description": "Repo-relative file path of the finding."}, + "line": {"type": ["integer", "null"], "description": "1-based start line; null when unknown."}, + "explanation": { + "type": "object", + "description": "One-hop taint provenance slice. Present only when the finding has a qualname " + "AND the scan produced an analysis context.", + "properties": { + "tier_in": { + "type": ["string", "null"], + "description": "Actual (untrusted) trust tier arriving at the sink.", + }, + "tier_out": { + "type": ["string", "null"], + "description": "Trust tier the sink declares it returns.", + }, + "immediate_tainted_callee": { + "type": ["string", "null"], + "description": "The directly-called function that contributed the taint, if resolved.", + }, + "source_boundary_qualname": { + "type": ["string", "null"], + "description": "Qualname of the boundary function the taint originated from (one hop " + "only).", + }, + "resolved_call_count": { + "type": "integer", + "description": "Calls inside the function the analyzer resolved.", + }, + "unresolved_call_count": { + "type": "integer", + "description": "Calls the analyzer could not resolve.", + }, + }, + "required": [ + "tier_in", + "tier_out", + "immediate_tainted_callee", + "source_boundary_qualname", + "resolved_call_count", + "unresolved_call_count", + ], + "additionalProperties": False, + }, + "promotion": { + "type": "object", + "description": "Per-finding Filigree promote outcome for this defect.", + "properties": { + "selected": { + "type": "boolean", + "description": "Whether this finding was in the selection set.", + }, + "attempted": { + "type": "boolean", + "description": "Whether a promote was actually attempted (false in dry_run, when " + "unselected, or when no filer is configured).", + }, + "reachable": { + "type": ["boolean", "null"], + "description": "Whether Filigree was reachable for the promote; null when not " + "attempted.", + }, + "issue_id": { + "type": ["string", "null"], + "description": "The Filigree issue id; null when not attempted, unreachable, or not " + "found.", + }, + "created": {"type": "boolean", "description": "True when the promote created a NEW issue."}, + "not_found": { + "type": "boolean", + "description": "True when Filigree was reachable but the fingerprint is unknown to it " + "(404).", + }, + "disabled_reason": { + "type": ["string", "null"], + "description": "Why the promote did not happen or failed soft; null on success.", + }, + }, + "required": [ + "selected", + "attempted", + "reachable", + "issue_id", + "created", + "not_found", + "disabled_reason", + ], + "additionalProperties": False, + }, + "identity_attach": { + "type": "object", + "description": "Outcome of binding the finding's Loomweave entity identity to the promoted " + "issue.", + "properties": { + "attempted": { + "type": "boolean", + "description": "Whether an identity attach was attempted at all.", + }, + "attached": { + "type": "boolean", + "description": "Whether the entity association was successfully attached.", + }, + "entity_id": { + "type": ["string", "null"], + "description": "The entity identifier used — a 'loomweave:eid:...' SEI or a legacy " + "locator.", + }, + "content_hash": { + "type": ["string", "null"], + "description": "Entity content hash captured at attach time; null when unresolved.", + }, + "binding_kind": { + "type": ["string", "null"], + "enum": ["sei", "locator", None], + "description": "Whether the binding used a rename-stable SEI or a legacy locator; " + "null when no binding was attempted.", + }, + "reason": { + "type": ["string", "null"], + "description": "Why the attach was not attempted or was skipped; null on success.", + }, + }, + "required": ["attempted", "attached", "entity_id", "content_hash", "binding_kind", "reason"], + "additionalProperties": False, + }, + }, + "required": [ + "fingerprint", + "rule_id", + "severity", + "message", + "qualname", + "path", + "line", + "promotion", + "identity_attach", + ], + "additionalProperties": False, + }, + }, + "selected_count": { + "type": "integer", + "description": "How many selected fingerprints matched known active defects.", + }, + "unknown_fingerprints": { + "type": "array", + "items": {"type": "string"}, + "description": "Explicitly-requested fingerprints that are not among the scan's active defects.", + }, + }, + "required": [ + "mode", + "files_scanned", + "summary", + "gate", + "filigree_emit", + "active_defects", + "selected_count", + "unknown_fingerprints", + ], + "additionalProperties": False, +} + + +_SCAN_FILE_FINDINGS_TOOL: dict[str, Any] = { + "name": "scan_file_findings", + "title": "Scan and file findings", + "description": "One-shot agent workflow: run a scan, list active defects first with " + "inline explanation summaries, optionally emit to Filigree, promote selected " + "fingerprints or all active defects, and attach Loomweave identity when available. " + "Defaults to dry-run unless fingerprints or all_active are supplied.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "subdir relative to project root"}, + "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, + "config": {"type": "string"}, + "cache_dir": { + "type": "string", + "description": "subdir relative to project root for summary cache", + }, + "fingerprints": {"type": "array", "items": {"type": "string"}}, + "all_active": {"type": "boolean"}, + "dry_run": {"type": "boolean"}, + "priority": {"type": "string", "description": "Filigree priority, e.g. P2"}, + "labels": {"type": "array", "items": {"type": "string"}}, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": {"type": "boolean"}, + "strict_defaults": {"type": "boolean"}, + }, + }, + "output_schema": _SCAN_FILE_FINDINGS_OUTPUT_SCHEMA, + "annotations": { + "title": "Scan and file findings", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE, ToolCapability.NETWORK}), +} + + def _trusted_packs_arg(args: dict[str, Any]) -> tuple[str, ...]: trusted_packs_raw = args.get("trust_packs") or [] if not isinstance(trusted_packs_raw, list) or not all(isinstance(p, str) for p in trusted_packs_raw): @@ -177,6 +685,10 @@ def _cache_dir_arg(args: dict[str, Any], root: Path) -> Path | None: return _resolve_under_root(root, args["cache_dir"]) if args.get("cache_dir") else None +def _path_arg(args: dict[str, Any], root: Path) -> Path: + return _resolve_under_root(root, args["path"]) if args.get("path") else root + + def _bool_arg(args: dict[str, Any], name: str, default: bool) -> bool: # Reject non-bool values loudly rather than ``bool(...)``-coercing them: a JSON string # like "false" would otherwise coerce to True, silently inverting intent. Matches the @@ -189,6 +701,60 @@ def _bool_arg(args: dict[str, Any], name: str, default: bool) -> bool: return val +def _scan_job_request(args: dict[str, Any], root: Path, filigree_url: str | None) -> dict[str, Any]: + fail_on = args.get("fail_on") + if fail_on is not None: + try: + Severity(str(fail_on)) + except ValueError as exc: + raise ToolError("fail_on must be one of CRITICAL/ERROR/WARN/INFO") from exc + lang = str(args.get("lang") or "python") + if lang not in {"python", "rust"}: + raise ToolError("lang must be one of python/rust") + fmt = str(args.get("format") or "jsonl") + if fmt not in {"jsonl", "sarif", "agent-summary"}: + raise ToolError("format must be one of jsonl/sarif/agent-summary") + local_only = _bool_arg(args, "local_only", False) + trusted_packs = _trusted_packs_arg(args) + output = _resolve_under_root(root, args["output"]) if args.get("output") else None + config = _cfg(args, root) + cache_dir = _cache_dir_arg(args, root) + return { + "config": str(config) if config is not None else None, + "format": fmt, + "output": str(output) if output is not None else None, + "fail_on": str(fail_on).upper() if fail_on else None, + "fail_on_unanalyzed": _bool_arg(args, "fail_on_unanalyzed", False), + "cache_dir": str(cache_dir) if cache_dir is not None else None, + "filigree_url": None if local_only else filigree_url, + "local_only": local_only, + "filigree_max_findings_per_request": args.get("filigree_max_findings_per_request"), + "timeout_seconds": args.get("timeout_seconds"), + "lang": lang, + "new_since": args.get("new_since"), + "trusted_packs": list(trusted_packs), + "trust_local_packs": _bool_arg(args, "trust_local_packs", False), + "strict_defaults": _bool_arg(args, "strict_defaults", False), + "trust_suppressions": _bool_arg(args, "trust_suppressions", False), + } + + +def _scan_job_start(args: dict[str, Any], root: Path, filigree_url: str | None = None) -> dict[str, Any]: + path = _path_arg(args, root) + request = _scan_job_request(args, root, filigree_url) + return start_scan_job(path, request) + + +def _scan_job_status(args: dict[str, Any], root: Path) -> dict[str, Any]: + job_id = str(_require(args, "job_id")) + return read_scan_job_status(_path_arg(args, root), job_id) + + +def _scan_job_cancel(args: dict[str, Any], root: Path) -> dict[str, Any]: + job_id = str(_require(args, "job_id")) + return cancel_scan_job(_path_arg(args, root), job_id) + + def _scan( args: dict[str, Any], root: Path, @@ -206,10 +772,19 @@ def _scan( # A bad enum value is agent-actionable — give it the valid set rather than # letting it surface as an opaque generic JSON-RPC -32603. raise ToolError("fail_on must be one of CRITICAL/ERROR/WARN/INFO") from exc + # 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") trusted_packs = _trusted_packs_arg(args) cache_dir = _cache_dir_arg(args, root) - trust_suppressions = bool(args.get("trust_suppressions") or False) + # _bool_arg, not bool(...): without jsonschema the handler runs unvalidated, and + # bool("false") is True — a client sending the STRING "false" would otherwise enable + # trusted-local suppression and let a repo baseline/waiver clear the gate (the scan-job + # path already uses _bool_arg). + trust_suppressions = _bool_arg(args, "trust_suppressions", False) + # 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" result = run_scan( path, config_path=_cfg(args, root), @@ -220,6 +795,7 @@ def _scan( trusted_packs=trusted_packs, strict_defaults=strict_defaults, trust_suppressions=trust_suppressions, + lang=lang, ) # Fail-soft Loomweave write: only when a client was injected (server has a URL). # An outage/403 yields a not-reachable WriteResult; never raises here. @@ -242,97 +818,120 @@ def _scan( "unresolved_qualnames": list(wr.unresolved_qualnames), "disabled_reason": wr.disabled_reason, } - decision = gate_decision(result, threshold) + 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) filigree_status = _filigree_emit_status(filigree_block) loomweave_status = _loomweave_write_status(loomweave_block) where = args.get("where") try: - resolved_where = resolve_query_filters(where, root, _cfg(args, root), loomweave) + resolved_where = resolve_query_filters( + where, + root, + _cfg(args, root), + loomweave, + strict_defaults=strict_defaults, + ) selected = filter_findings(result.findings, resolved_where) except (ValueError, WardlineError) as exc: # An unknown filter key or SEI resolution failure is agent-actionable -> isError result. raise ToolError(str(exc)) from exc - # Payload-shrinking controls (dogfood #4). The `summary`/`gate` blocks always - # describe the WHOLE project; these only bound the returned finding bodies. + # Payload-shrinking controls. The `summary`/`gate` blocks always describe the WHOLE + # project; these only bound the returned finding BODIES (which live solely in + # agent_summary now — there is no separate top-level findings array). The DEFAULT scan is + # BOUNDED (weft-439d09fc8d): a bare call returns at most _DEFAULT_MAX_FINDINGS bodies so + # an agent's first natural call cannot overflow its own context. full=true lifts the cap; + # offset pages through the rest via truncation.next_offset. summary_only = _bool_arg(args, "summary_only", False) include_suppressed = _bool_arg(args, "include_suppressed", True) + full = _bool_arg(args, "full", False) max_findings = args.get("max_findings") if max_findings is not None and ( - not isinstance(max_findings, int) or isinstance(max_findings, bool) or max_findings < 0 + not isinstance(max_findings, int) or isinstance(max_findings, bool) or max_findings < 1 ): - raise ToolError("max_findings must be a non-negative integer") + # A page size of 0 yields an empty window whose truncation cursor cannot advance + # (the paging agent would loop). Use summary_only for counts without a findings list. + raise ToolError("max_findings must be a positive integer (use summary_only for counts without findings)") + offset = args.get("offset", 0) + if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: + raise ToolError("offset must be a non-negative integer") explain = _bool_arg(args, "explain", False) - # include_suppressed:false drops the suppressed DEFECT bodies (counts stay whole). - if not include_suppressed: - selected = [f for f in selected if not (f.kind is Kind.DEFECT and f.suppressed is not SuppressionState.ACTIVE)] - findings_total = len(selected) - - # summary_only returns no finding bodies at all (the smallest "did the gate pass?" - # payload); otherwise an explicit max_findings bounds the list (default: uncapped). - display = [] if summary_only else selected - findings_truncated = False - if max_findings is not None and len(display) > max_findings: - display = display[:max_findings] - findings_truncated = True - - # explain has a DEFAULT ceiling: inlining EVERY active defect's provenance is the - # 56KB-on-one-line blowup the dogfood report hit. Cap the number of explanations (an - # explicit max_findings tightens it further); findings past the cap are still - # returned, just without inline provenance. The cut is announced, never silent. - explain_cap = max_findings if max_findings is not None else _EXPLAIN_DEFAULT_CAP - explanations_attached = 0 - explanations_truncated = False - findings_out: list[dict[str, Any]] = [] - for f in display: - d = _finding_to_dict(f) - if ( - explain - and f.kind is Kind.DEFECT - and f.suppressed is SuppressionState.ACTIVE - and f.qualname is not None - and result.context is not None - ): - if explanations_attached < explain_cap: - exp = explanation_from_context(f, result.context) - d["explanation"] = _explanation_to_dict(exp) - explanations_attached += 1 + # Effective page size: full=true → uncapped; explicit max_findings → that; else the + # bounded default. summary_only short-circuits to no bodies inside agent_summary. + if full: + limit: int | None = None + elif max_findings is not None: + limit = max_findings + else: + limit = _DEFAULT_MAX_FINDINGS + + from wardline.core.agent_summary import build_agent_summary + + agent_summary = build_agent_summary( + result, + decision, + filigree_emit=filigree_status, + loomweave_write=loomweave_status, + display_findings=selected, + summary_only=summary_only, + max_findings=limit, + offset=offset, + include_suppressed=include_suppressed, + migration_hint=migration_hint, + ).to_dict() + + # explain inlines each SHOWN active defect's provenance into its agent_summary entry (one + # call instead of an explain_taint per finding). Capped — inlining EVERY provenance is the + # 56KB-on-one-line blowup the dogfood report hit; the cut is announced in truncation. + if explain and result.context is not None: + explain_cap = max_findings if max_findings is not None else _EXPLAIN_DEFAULT_CAP + by_fp = {f.fingerprint: f for f in selected} + attached = 0 + explanations_truncated = False + for entry in agent_summary["active_defects"]: + f = by_fp.get(entry["fingerprint"]) + if f is None or f.qualname is None: + continue + if attached < explain_cap: + entry["explanation"] = explanation_to_dict( + explanation_from_context(f, result.context), loomweave_configured=loomweave is not None + ) + attached += 1 else: explanations_truncated = True - findings_out.append(d) - from wardline.core.agent_summary import build_agent_summary + agent_summary["truncation"]["explanations_truncated"] = explanations_truncated response: dict[str, Any] = { "files_scanned": result.files_scanned, - "findings": findings_out, "summary": { "total": result.summary.total, "active": result.summary.active, "baselined": result.summary.baselined, "waived": result.summary.waived, "judged": result.summary.judged, + # Non-defect findings (facts/metrics/classifications). active+baselined+ + # waived+judged+informational == total (the buckets-sum-to-total invariant, + # weft-f506e5f845); unanalyzed is an overlay (subset of informational), not a + # partition member. + "informational": result.summary.informational, # Files discovered but NOT analysed (parse error / too-deep / missing # source root — benign no-module skips are excluded). Surfaced so the # silent under-scan reaches the agent, not just the human-facing stderr. "unanalyzed": result.summary.unanalyzed, }, - # Make every cut explicit so a bounded payload never reads as "covered all". - "truncation": { - "summary_only": summary_only, - "include_suppressed": include_suppressed, - "max_findings": max_findings, - "findings_total": findings_total, - "findings_returned": len(findings_out), - "findings_truncated": findings_truncated, - "explanations_truncated": explanations_truncated, - }, "gate": { "tripped": decision.tripped, "fail_on": decision.fail_on, + "fail_on_unanalyzed": decision.fail_on_unanalyzed, "exit_class": decision.exit_class, + "verdict": decision.verdict, + # Sub-gate attribution: which knob(s) the overall trip came from, so an agent + # never has to parse `reason` to tell a severity trip from an unanalyzed one. + "severity_tripped": decision.severity_tripped, + "unanalyzed_tripped": decision.unanalyzed_tripped, + "would_trip_at": decision.would_trip_at, "reason": decision.reason, "evaluated": decision.evaluated, "migration_hint": migration_hint, @@ -341,17 +940,7 @@ def _scan( "filigree": filigree_block, "loomweave_write": loomweave_status, "filigree_emit": filigree_status, - "agent_summary": build_agent_summary( - result, - decision, - filigree_emit=filigree_status, - loomweave_write=loomweave_status, - display_findings=selected, - summary_only=summary_only, - max_findings=max_findings, - include_suppressed=include_suppressed, - migration_hint=migration_hint, - ).to_dict(), + "agent_summary": agent_summary, } _attach_legis_artifact( response, @@ -365,6 +954,1052 @@ def _scan( return response +_SCAN_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "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."}, + "summary": { + "type": "object", + "description": "Whole-project finding counts. active+baselined+waived+judged+informational == total; " + "unanalyzed is an overlay, not a partition member.", + "properties": { + "total": {"type": "integer", "description": "Every finding (defects + facts/metrics)."}, + "active": {"type": "integer", "description": "Non-suppressed defects."}, + "baselined": {"type": "integer"}, + "waived": {"type": "integer"}, + "judged": {"type": "integer"}, + "informational": { + "type": "integer", + "description": "All non-defect findings (facts, metrics, classifications).", + }, + "unanalyzed": { + "type": "integer", + "description": "Files discovered but never analysed (parse errors / too-deep / missing source " + "roots); overlay count.", + }, + }, + "required": ["total", "active", "baselined", "waived", "judged", "informational", "unanalyzed"], + "additionalProperties": False, + }, + "gate": { + "type": "object", + "description": "The pass/fail gate decision (a trip is data, not an error).", + "properties": { + "tripped": {"type": "boolean"}, + "fail_on": { + "description": "The severity threshold evaluated, or null when no --fail-on ran.", + "enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE", None], + }, + "fail_on_unanalyzed": { + "type": "boolean", + "description": "Whether the unanalyzed sub-gate knob was set.", + }, + "exit_class": { + "type": "integer", + "enum": [0, 1], + "description": "0 clean, 1 gate tripped (mirrors tripped).", + }, + "verdict": { + "type": "string", + "enum": ["NOT_EVALUATED", "PASSED", "FAILED"], + "description": "NOT_EVALUATED when neither sub-gate was configured; FAILED iff tripped.", + }, + "severity_tripped": { + "type": "boolean", + "description": "Sub-gate attribution: the severity threshold tripped.", + }, + "unanalyzed_tripped": { + "type": "boolean", + "description": "Sub-gate attribution: the unanalyzed gate tripped.", + }, + "would_trip_at": { + "description": "Highest severity at which the gate WOULD trip on the evaluated population, or " + "null if nothing would.", + "enum": ["CRITICAL", "ERROR", "WARN", "INFO", None], + }, + "reason": { + "type": "string", + "description": "Human-readable verdict naming the count/class of defects that decided it.", + }, + "evaluated": { + "type": ["string", "null"], + "description": "Which population the gate judged (unsuppressed default vs suppression-honoring).", + }, + "migration_hint": { + "type": ["string", "null"], + "description": "Secure-gate-default rollout hint, or null.", + }, + }, + "required": [ + "tripped", + "fail_on", + "fail_on_unanalyzed", + "exit_class", + "verdict", + "severity_tripped", + "unanalyzed_tripped", + "would_trip_at", + "reason", + "evaluated", + "migration_hint", + ], + "additionalProperties": False, + }, + "loomweave": { + "description": "Raw Loomweave taint-fact write result; null when no Loomweave client is configured.", + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "properties": { + "reachable": {"type": "boolean"}, + "written": {"type": "integer", "description": "Entity taint blobs written."}, + "unresolved_qualnames": {"type": "array", "items": {"type": "string"}}, + "disabled_reason": {"type": ["string", "null"]}, + }, + "required": ["reachable", "written", "unresolved_qualnames", "disabled_reason"], + "additionalProperties": False, + }, + ], + }, + "filigree": { + "description": "Raw Filigree emit result; null when no emitter is configured.", + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "properties": { + "reachable": {"type": "boolean"}, + "created": {"type": "integer"}, + "updated": {"type": "integer"}, + "failed": { + "type": "integer", + "description": "Count of un-ingested findings (derived from " + "`failures`); 0 is earned from real records, not assumed.", + }, + "failures": {"$ref": "#/$defs/filigree_emit_failures"}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "status": { + "type": ["integer", "null"], + "description": "HTTP error status (401/403/5xx) for soft failures; null on success or " + "transport failure.", + }, + "auth_rejected": { + "type": "boolean", + "description": "True when the emit was refused with 401/403.", + }, + "token_sent": { + "type": "boolean", + "description": "Whether a bearer token was actually sent (splits a 401 into absent vs " + "rejected).", + }, + "url": {"type": ["string", "null"], "description": "The endpoint attempted."}, + "destination": {"$ref": "#/$defs/filigree_destination"}, + }, + "required": [ + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "status", + "auth_rejected", + "token_sent", + "url", + "destination", + ], + "additionalProperties": False, + }, + ], + }, + "loomweave_write": {"$ref": "#/$defs/loomweave_write_status"}, + "filigree_emit": {"$ref": "#/$defs/filigree_emit_status"}, + "agent_summary": { + "type": "object", + "description": "The stable agent-oriented handoff block (schema wardline-agent-summary-1): active defects " + "first, suppressed debt visible, integration status explicit, suggested next tool calls.", + "properties": { + "schema": {"type": "string", "enum": ["wardline-agent-summary-1"]}, + "summary": { + "type": "object", + "description": "Whole-project counts (never affected by where/pagination filters).", + "properties": { + "files_scanned": {"type": "integer"}, + "total_findings": {"type": "integer"}, + "active_defects": {"type": "integer"}, + "suppressed_findings": {"type": "integer"}, + "engine_facts": { + "type": "integer", + "description": "Kind.FACT findings with a WLN-ENGINE-* rule_id.", + }, + "baselined": {"type": "integer"}, + "waived": {"type": "integer"}, + "judged": {"type": "integer"}, + "informational": { + "type": "integer", + "description": "ALL non-defect findings (engine facts included; the display array below " + "excludes them).", + }, + "unanalyzed": {"type": "integer"}, + }, + "required": [ + "files_scanned", + "total_findings", + "active_defects", + "suppressed_findings", + "engine_facts", + "baselined", + "waived", + "judged", + "informational", + "unanalyzed", + ], + "additionalProperties": False, + }, + "gate": { + "type": "object", + "description": "Gate echo inside the agent summary (no sub-gate attribution flags here; see the " + "top-level gate block for those).", + "properties": { + "tripped": {"type": "boolean"}, + "fail_on": {"enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE", None]}, + "exit_class": {"type": "integer", "enum": [0, 1]}, + "verdict": {"type": "string", "enum": ["NOT_EVALUATED", "PASSED", "FAILED"]}, + "would_trip_at": {"enum": ["CRITICAL", "ERROR", "WARN", "INFO", None]}, + "reason": {"type": "string"}, + "evaluated": {"type": ["string", "null"]}, + "migration_hint": {"type": ["string", "null"]}, + }, + "required": [ + "tripped", + "fail_on", + "exit_class", + "verdict", + "would_trip_at", + "reason", + "evaluated", + "migration_hint", + ], + "additionalProperties": False, + }, + "integrations": { + "type": "object", + "properties": { + "filigree_emit": {"$ref": "#/$defs/filigree_emit_status"}, + "loomweave_write": {"$ref": "#/$defs/loomweave_write_status"}, + }, + "required": ["filigree_emit", "loomweave_write"], + "additionalProperties": False, + }, + "active_defects": { + "type": "array", + "description": "Non-suppressed defects in the displayed page (severity-sorted). Each entry " + "carries explain/next_tool_calls hints; with explain:true an inlined explanation (capped — see " + "truncation.explanations_truncated).", + "items": {"$ref": "#/$defs/active_defect_entry"}, + }, + "suppressed_findings": { + "type": "array", + "description": "Suppressed (baselined/waived/judged) defects in the displayed page.", + "items": {"$ref": "#/$defs/finding_entry"}, + }, + "engine_facts": { + "type": "array", + "description": "Engine diagnostic facts (WLN-ENGINE-*) in the displayed page.", + "items": {"$ref": "#/$defs/finding_entry"}, + }, + "informational": { + "type": "array", + "description": "Non-defect, non-engine-fact findings (metrics, classifications, suggestions) in " + "the displayed page.", + "items": {"$ref": "#/$defs/finding_entry"}, + }, + "truncation": { + "type": "object", + "description": "Single pagination descriptor for the four display arrays (one ordered union: " + "active, suppressed, engine facts, informational).", + "properties": { + "summary_only": {"type": "boolean"}, + "include_suppressed": {"type": "boolean"}, + "max_findings": { + "type": ["integer", "null"], + "description": "Effective page size; null means uncapped (full:true).", + }, + "offset": {"type": "integer"}, + "findings_total": { + "type": "integer", + "description": "Size of the displayed union before paging.", + }, + "findings_returned": {"type": "integer"}, + "next_offset": { + "type": ["integer", "null"], + "description": "Pass as offset to fetch the next page; null when complete.", + }, + "findings_truncated": {"type": "boolean"}, + "explanations_truncated": { + "type": "boolean", + "description": "True when explain:true hit the inlining cap before covering every shown " + "active defect.", + }, + }, + "required": [ + "summary_only", + "include_suppressed", + "max_findings", + "offset", + "findings_total", + "findings_returned", + "next_offset", + "findings_truncated", + "explanations_truncated", + ], + "additionalProperties": False, + }, + "next_actions": { + "type": "array", + "description": "Gate-aware suggested next tool calls, driven by the whole-project active count " + "(not the displayed slice).", + "items": { + "type": "object", + "properties": { + "tool": {"type": "string", "enum": ["explain_taint", "file_finding", "scan"]}, + "reason": {"type": "string"}, + }, + "required": ["tool", "reason"], + "additionalProperties": False, + }, + }, + }, + "required": [ + "schema", + "summary", + "gate", + "integrations", + "active_defects", + "suppressed_findings", + "engine_facts", + "informational", + "truncation", + "next_actions", + ], + "additionalProperties": False, + }, + "legis_artifact": { + "type": "object", + "description": "OPTIONAL: the verbatim-postable signed scan object for legis POST /wardline/scan-results. " + "Present only when a WARDLINE_LEGIS_ARTIFACT_KEY is provisioned or legis_artifact:true was passed, AND " + "building it did not fail (a signing refusal omits it). Suppressed under summary_only:true unless " + "legis_artifact:true is passed explicitly — summary_only promises the smallest gate payload.", + "properties": { + "scanner_identity": {"type": "string", "description": "wardline@."}, + "rule_set_version": {"type": "string", "description": "Hash of the effective ruleset."}, + "fingerprint_scheme": {"type": "string"}, + "findings": { + "type": "array", + "description": "The gate population projected onto legis's accepted vocabulary.", + "items": { + "type": "object", + "properties": { + "rule_id": {"type": "string"}, + "message": {"type": "string"}, + "severity": {"type": "string", "enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE"]}, + "kind": { + "type": "string", + "enum": ["defect", "fact", "classification", "metric", "suggestion"], + }, + "fingerprint": {"type": "string"}, + "qualname": {"type": ["string", "null"]}, + "properties": { + "type": "object", + "description": "Trust-tier-valued properties only (plus suppression_reason proof on " + "non-active defects); diagnostics dropped.", + "additionalProperties": {"type": "string"}, + }, + "suppression_state": {"type": "string", "enum": ["active", "waived", "suppressed"]}, + }, + "required": [ + "rule_id", + "message", + "severity", + "kind", + "fingerprint", + "qualname", + "properties", + "suppression_state", + ], + "additionalProperties": False, + }, + }, + "scan_scope": { + "type": "object", + "description": ( + "Signed scope binding: scan root, configured/resolved source roots, and realized files." + ), + "properties": { + "schema": {"type": "string", "enum": ["wardline-legis-scan-scope-1"]}, + "scan_root": { + "type": "string", + "description": "Scan root relative to the git repository root when in git; otherwise '.'.", + }, + "is_git_root": { + "type": "boolean", + "description": "True only when the scan root is the containing git repository root.", + }, + "source_roots": {"type": "array", "items": {"type": "string"}}, + "resolved_source_roots": { + "type": "array", + "items": {"type": "string"}, + "description": "Configured source roots resolved relative to the signed scope base.", + }, + "scanned_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Files actually discovered and analyzed, relative to the scan root.", + }, + }, + "required": [ + "schema", + "scan_root", + "is_git_root", + "source_roots", + "resolved_source_roots", + "scanned_paths", + ], + "additionalProperties": False, + }, + "commit_sha": { + "type": "string", + "description": "Present when provenance was readable (always on the signed path).", + }, + "tree_sha": { + "type": "string", + "description": "Committed tree SHA; present on the signed path and best-effort otherwise.", + }, + "artifact_signature": { + "type": "string", + "description": "hmac-sha256:v2: over the canonical scan-minus-signature; present only on the " + "signed (key + clean tree) path.", + }, + "dirty": { + "type": "boolean", + "description": "Present (true) only when the working tree was dirty (unsigned dev artifact).", + }, + }, + "required": ["scanner_identity", "rule_set_version", "fingerprint_scheme", "findings", "scan_scope"], + "additionalProperties": False, + }, + "legis_artifact_status": { + "type": "object", + "description": "OPTIONAL: signed/dirty status of the legis artifact attempt. Present whenever the legis " + "block was activated (key provisioned or legis_artifact:true), including when signing was refused and " + "legis_artifact itself is absent.", + "properties": { + "configured": {"type": "boolean", "enum": [True]}, + "signed": {"type": "boolean"}, + "key_id": { + "type": ["string", "null"], + "description": "Non-secret short id of the HMAC key (first 8 hex of sha256), or null when unkeyed.", + }, + "reason": { + "type": ["string", "null"], + "description": "Refusal/unverified reason (e.g. dirty-tree refusal), or null.", + }, + "dirty": { + "type": "boolean", + "description": "Present only when the artifact was actually built (absent on a build refusal).", + }, + }, + "required": ["configured", "signed", "key_id", "reason"], + "additionalProperties": False, + }, + }, + "required": [ + "files_scanned", + "summary", + "gate", + "loomweave", + "filigree", + "loomweave_write", + "filigree_emit", + "agent_summary", + ], + "additionalProperties": False, + "$defs": { + "filigree_destination": { + "type": "object", + "description": "Where findings were (or would be) sent, so a wrong-project write is visible.", + "properties": { + "url": {"type": ["string", "null"]}, + "project": { + "type": ["string", "null"], + "description": "The project pinned in the URL, or null when Filigree resolves it server-side.", + }, + "project_pinned": {"type": "boolean"}, + }, + "required": ["url", "project", "project_pinned"], + "additionalProperties": False, + }, + "filigree_emit_failures": { + "type": "array", + "description": "PDR-0023 honesty surface: one record per finding that did NOT land in Filigree, so a " + "PARTIAL ingest ('M of N emitted, K rejected because R') is byte-distinguishable from a clean emit. " + "Empty on a clean run — but earned from real per-finding records, not hardwired.", + "items": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": ["rejected", "validation_error", "scheme_mismatch", "partial"], + "description": "Machine-readable failure case: rejected (Filigree refused this finding), " + "validation_error (malformed body), scheme_mismatch (fingerprint-scheme drift — a join-miss, " + "not a true-negative), partial (whole chunk rejected at the protocol layer; cause is the " + "request, not the body).", + }, + "detail": {"type": "string", "description": "Filigree's per-finding reject explanation."}, + "reason_class": { + "type": "string", + "enum": ["rejected", "scheme_mismatch", "partial"], + "description": "weft-reason (G1): the canonical reason_class this failure maps to (one of the " + "closed 11 in contracts/weft-reason-vocab.json). validation_error maps to rejected; the domain " + "term stays in `reason`/`cause`.", + }, + "cause": { + "type": "string", + "description": "weft-reason carrier `cause`: the why (Filigree's detail, else the domain " + "reason). Always present on a failure.", + }, + "fix": { + "type": "string", + "description": "weft-reason carrier `fix` (MANDATORY on a non-clean carrier): the remedy.", + }, + "fingerprint": { + "type": "string", + "description": "Wardline join key for the failed finding (absent when chunk-wide).", + }, + }, + "required": ["reason", "detail", "reason_class", "cause", "fix"], + "additionalProperties": False, + }, + }, + "filigree_emit_status": { + "type": "object", + "description": "Normalized Filigree emit status (always an object; configured:false when no emitter).", + "properties": { + "configured": {"type": "boolean"}, + "reachable": {"type": ["boolean", "null"], "description": "null when not configured."}, + "created": {"type": "integer"}, + "updated": {"type": "integer"}, + "failed": { + "type": "integer", + "description": "Count of un-ingested findings (derived from `failures`); 0 is earned, not assumed.", + }, + "failures": {"$ref": "#/$defs/filigree_emit_failures"}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "disabled_reason": { + "type": ["string", "null"], + "description": "Actionable reason (auth-rejected vs server error vs unreachable vs not " + "configured), or null when reached.", + }, + "destination": {"$ref": "#/$defs/filigree_destination"}, + "status": { + "type": ["integer", "null"], + "description": "HTTP error status for soft failures; absent when not configured.", + }, + "auth_rejected": {"type": "boolean", "description": "Absent when not configured."}, + "token_sent": {"type": "boolean", "description": "Absent when not configured."}, + "url": {"type": ["string", "null"], "description": "Absent when not configured."}, + }, + "required": [ + "configured", + "reachable", + "created", + "updated", + "failed", + "failures", + "warnings", + "disabled_reason", + "destination", + ], + "additionalProperties": False, + }, + "loomweave_write_status": { + "type": "object", + "description": "Normalized Loomweave taint-fact write status (always an object; configured:false when no " + "client).", + "properties": { + "configured": {"type": "boolean"}, + "reachable": {"type": ["boolean", "null"], "description": "null when not configured."}, + "written": {"type": "integer"}, + "unresolved_qualnames": {"type": "array", "items": {"type": "string"}}, + "disabled_reason": {"type": ["string", "null"]}, + }, + "required": ["configured", "reachable", "written", "unresolved_qualnames", "disabled_reason"], + "additionalProperties": False, + }, + "location": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Repo-relative POSIX path."}, + "line_start": {"type": ["integer", "null"]}, + "line_end": {"type": ["integer", "null"]}, + }, + "required": ["path", "line_start", "line_end"], + "additionalProperties": False, + }, + "finding_entry": { + "type": "object", + "description": "One finding (suppressed / engine-fact / informational display arrays).", + "properties": { + "fingerprint": {"type": "string"}, + "rule_id": {"type": "string"}, + "severity": {"type": "string", "enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE"]}, + "kind": {"type": "string", "enum": ["defect", "fact", "classification", "metric", "suggestion"]}, + "qualname": {"type": ["string", "null"]}, + "location": {"$ref": "#/$defs/location"}, + "message": {"type": "string"}, + "suppression_state": {"type": "string", "enum": ["active", "baselined", "waived", "judged"]}, + "suppression_reason": {"type": ["string", "null"]}, + }, + "required": [ + "fingerprint", + "rule_id", + "severity", + "kind", + "qualname", + "location", + "message", + "suppression_state", + "suppression_reason", + ], + "additionalProperties": False, + }, + "active_defect_entry": { + "type": "object", + "description": "One active defect, with explain availability and suggested next tool calls; explanation " + "is inlined only under scan(explain:true) for findings with a qualname, up to the cap.", + "properties": { + "fingerprint": {"type": "string"}, + "rule_id": {"type": "string"}, + "severity": {"type": "string", "enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE"]}, + "kind": {"type": "string", "enum": ["defect", "fact", "classification", "metric", "suggestion"]}, + "qualname": {"type": ["string", "null"]}, + "location": {"$ref": "#/$defs/location"}, + "message": {"type": "string"}, + "suppression_state": {"type": "string", "enum": ["active", "baselined", "waived", "judged"]}, + "suppression_reason": {"type": ["string", "null"]}, + "explain": { + "type": "object", + "properties": { + "available": {"type": "boolean"}, + "reason": { + "type": ["string", "null"], + "description": "Why explain is unavailable (no qualname), or null.", + }, + "suggested_call": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "properties": { + "tool": {"type": "string", "enum": ["explain_taint"]}, + "arguments": { + "type": "object", + "properties": {"fingerprint": {"type": "string"}}, + "required": ["fingerprint"], + "additionalProperties": False, + }, + }, + "required": ["tool", "arguments"], + "additionalProperties": False, + }, + ], + }, + }, + "required": ["available", "reason", "suggested_call"], + "additionalProperties": False, + }, + "next_tool_calls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": {"type": "string", "enum": ["explain_taint", "file_finding"]}, + "arguments": { + "type": "object", + "properties": {"fingerprint": {"type": "string"}}, + "required": ["fingerprint"], + "additionalProperties": False, + }, + }, + "required": ["tool", "arguments"], + "additionalProperties": False, + }, + }, + "explanation": {"$ref": "#/$defs/explanation"}, + }, + "required": [ + "fingerprint", + "rule_id", + "severity", + "kind", + "qualname", + "location", + "message", + "suppression_state", + "suppression_reason", + "explain", + "next_tool_calls", + ], + "additionalProperties": False, + }, + "explanation": { + "type": "object", + "description": "Inlined taint provenance (same shape as the explanation slice of explain_taint).", + "properties": { + "tier_in": {"type": ["string", "null"], "description": "Actual (untrusted) tier arriving at the sink."}, + "tier_out": {"type": ["string", "null"], "description": "Tier the sink declares it returns."}, + "immediate_tainted_callee": {"type": ["string", "null"]}, + "source_boundary_qualname": {"type": ["string", "null"]}, + "source_resolution": { + "type": "object", + "description": "C-10(c) honesty block: explicit resolved/unresolved verdict on the taint source, " + "with reason + missing capability + enablement when unresolved.", + "properties": { + "status": {"type": "string", "enum": ["resolved", "unresolved"]}, + "reason": {"type": ["string", "null"]}, + "missing_capability": {"type": ["string", "null"]}, + "enablement": {"type": ["string", "null"]}, + }, + "required": ["status", "reason", "missing_capability", "enablement"], + "additionalProperties": False, + }, + "resolved_call_count": {"type": "integer"}, + "unresolved_call_count": {"type": "integer"}, + "remediation": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["boundary_placement", "sink_hygiene", "review_required"]}, + "rule_id": {"type": "string"}, + "summary": {"type": "string"}, + "sink_qualname": {"type": ["string", "null"]}, + "source_qualname": {"type": ["string", "null"]}, + "caveat": {"type": "string"}, + }, + "required": ["kind", "rule_id", "summary", "sink_qualname", "source_qualname", "caveat"], + "additionalProperties": False, + }, + }, + "required": [ + "tier_in", + "tier_out", + "immediate_tainted_callee", + "source_boundary_qualname", + "source_resolution", + "resolved_call_count", + "unresolved_call_count", + "remediation", + ], + "additionalProperties": False, + }, + }, +} + + +_SCAN_TOOL: dict[str, Any] = { + "name": "scan", + "title": "Trust-boundary scan", + "description": "Whole-program taint scan of the project. Returns structured " + "findings, the suppression summary (active = unsuppressed defects; " + "by default the --fail-on gate evaluates the UNSUPPRESSED population so " + "repo-controlled baseline/waiver/judged annotate but do not clear it — " + "pass `trust_suppressions: true` for the trusted-local behaviour), " + "and the gate verdict. Pass `where` to filter the returned findings " + "(conjunctive; summary/gate stay whole-project) and `explain: true` to inline " + "each active defect's taint provenance — one call, no per-finding explain_taint. " + "When a Filigree URL is configured, also POSTs the " + "findings to Filigree (fail-soft: an unreachable sibling or rejected payload " + "is reported in the `filigree` block, never fails the scan).", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "subdir relative to project root"}, + "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, + "fail_on_unanalyzed": { + "type": "boolean", + "description": "Also trip the gate when any file was discovered but could " + "not be analyzed (parse error / too-deep skip / missing source root; benign " + "no-module skips excluded). Default false — same default as the CLI's " + "--fail-on-unanalyzed; summary.unanalyzed always reports the count either " + "way, and gate.unanalyzed_tripped attributes a trip to this knob.", + }, + "config": {"type": "string"}, + "lang": { + "type": "string", + "enum": ["python", "rust"], + "description": "Language frontend (default python). 'rust' sweeps .rs files " + "for the command-injection slice (RS-WL-108 program injection / RS-WL-112 " + "shell injection; frozen identity, baseline-eligible). Preview posture: " + "weft.toml severity overrides do not yet apply to Rust findings, and a tree " + "with no /// @trusted markers is vacuously green — read the WLN-RUST-COVERAGE " + "fact before trusting '0 active'. Requires the wardline[rust] extra.", + }, + "where": { + "type": "object", + "description": "Filter the returned findings (conjunctive). Keys: " + "rule_id, qualname, severity, suppression, kind, path_glob, sink, tier. " + "summary/gate still describe the whole project.", + "properties": { + "rule_id": {"type": "string"}, + "qualname": {"type": "string"}, + "severity": {"type": "string", "enum": _SEVERITY_ENUM}, + "suppression": {"type": "string", "enum": ["active", "baselined", "waived", "judged"]}, + "kind": { + "type": "string", + "enum": ["defect", "fact", "classification", "metric", "suggestion"], + }, + "path_glob": {"type": "string"}, + "sink": {"type": "string"}, + "tier": {"type": "string"}, + }, + }, + "explain": { + "type": "boolean", + "description": "Inline each active defect's taint provenance " + "(immediate tainted callee, source boundary, trust tiers, resolution " + "counts) — one call instead of an explain_taint per finding. Inlining is " + "capped at 10 provenances by default (raise/lower with max_findings); the cut " + "is reported at agent_summary.truncation.explanations_truncated.", + }, + "summary_only": { + "type": "boolean", + "description": "Return counts + gate only, no finding bodies — the smallest " + "'did the gate pass?' payload. summary/gate still describe the whole project.", + }, + "full": { + "type": "boolean", + "description": "Default false. The default scan is BOUNDED (≤25 finding bodies) so " + "it cannot overflow your context; set full=true to return ALL bodies in one call " + "(or page with offset). summary/gate counts are always whole-project.", + }, + "max_findings": { + "type": "integer", + "minimum": 0, + "description": "Override the page size for returned finding bodies (and the inlined-" + "explanation cap). Default 25; full=true ignores it. Must be a non-negative integer. " + "The cut + next page are reported in agent_summary.truncation; counts stay whole.", + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Pagination cursor into the ordered finding union (active → suppressed " + "→ engine_facts → informational). Pass agent_summary.truncation.next_offset from " + "the previous call to fetch the next page. Default 0.", + }, + "include_suppressed": { + "type": "boolean", + "description": "Default true. Set false to drop suppressed (baselined/waived/" + "judged) finding bodies from the response; the suppression counts stay in " + "summary.", + }, + "new_since": { + "type": "string", + "description": "PR-scoped 'new findings only' gate: only gate on findings in " + "files/entities changed since this git ref", + }, + "cache_dir": { + "type": "string", + "description": "subdir relative to project root for summary cache", + }, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": { + "type": "boolean", + "description": "Allow loading custom trust-grammar packs from the local project directory", + }, + "strict_defaults": { + "type": "boolean", + "description": "Ignore repository-supplied custom configuration overrides (weft.toml)", + }, + "trust_suppressions": { + "type": "boolean", + "description": "Let repository-controlled baseline/waiver/judged clear the gate " + "(they always annotate findings regardless). Default false — the gate " + "evaluates the unsuppressed population so a PR cannot self-suppress its " + "own defect. Use only on a trusted checkout; in CI prefer new_since.", + }, + "legis_artifact": { + "type": "boolean", + "description": "Attach the verbatim-postable legis scan-artifact " + "(`legis_artifact` block) even when no signing key is provisioned " + "(unsigned, for legis's optional-verify posture).", + }, + "allow_dirty": { + "type": "boolean", + "description": "For the legis artifact only: on a dirty tree emit an UNSIGNED, " + "clearly-marked (dirty: true) dev artifact instead of refusing to sign. " + "Signing stays clean-tree-only; legis records it unverified.", + }, + }, + }, + "output_schema": _SCAN_OUTPUT_SCHEMA, + "annotations": { + "title": "Trust-boundary scan", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + +_SCAN_JOB_STATUS_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "File-backed scan-job status. Non-terminal jobs include heartbeat/pid/progress; terminal jobs " + "include summary/gate/artifacts when the worker reached them.", + "properties": { + "job_id": {"type": "string"}, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "running_stale", + "completed", + "completed_with_enrichment_failure", + "failed", + "cancelled", + ], + }, + "phase": {"type": "string"}, + "progress": {"type": "object"}, + "heartbeat": {"type": "string"}, + "pid": {"type": "integer"}, + "artifacts": {"type": "object"}, + "failure_kind": {"type": ["string", "null"]}, + "error": {"type": ["string", "null"]}, + "request": {"type": "object"}, + }, + "required": ["job_id", "status", "phase", "progress", "heartbeat", "artifacts", "failure_kind", "error", "request"], + "additionalProperties": True, +} + + +_SCAN_JOB_START_INPUT_PROPERTIES: dict[str, Any] = { + "path": {"type": "string", "description": "scan root subdir relative to the MCP server project root"}, + "config": {"type": "string", "description": "config file relative to project root"}, + "format": {"type": "string", "enum": ["jsonl", "sarif", "agent-summary"], "description": "artifact format"}, + "output": {"type": "string", "description": "artifact output path relative to project root"}, + "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, + "fail_on_unanalyzed": { + "type": "boolean", + "description": "Trip the gate when any file was discovered but could not be analyzed.", + }, + "cache_dir": {"type": "string", "description": "summary-cache directory relative to project root"}, + "local_only": { + "type": "boolean", + "description": "Disable sibling emission even when a Filigree URL resolves from launch/env/install state.", + }, + "filigree_max_findings_per_request": {"type": "integer", "minimum": 1}, + "timeout_seconds": { + "type": "number", + "minimum": 0, + "description": "Fail the background scan job after this many seconds. Defaults to 1800; use 0 to disable.", + }, + "lang": {"type": "string", "enum": ["python", "rust"]}, + "new_since": { + "type": "string", + "description": "PR-scoped 'new findings only' gate: only gate on findings in files/entities changed " + "since this git ref.", + }, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": { + "type": "boolean", + "description": "Allow loading custom trust-grammar packs from the local project directory.", + }, + "strict_defaults": { + "type": "boolean", + "description": "Ignore repository-supplied custom configuration overrides (weft.toml).", + }, + "trust_suppressions": { + "type": "boolean", + "description": "Let repository-controlled baseline/waiver/judged files clear the gate.", + }, +} + + +_SCAN_JOB_START_TOOL: dict[str, Any] = { + "name": "scan_job_start", + "title": "Start scan job", + "description": "Start a file-backed Wardline scan job and return its stable job id plus initial status. " + "Use scan_job_status to poll heartbeat/progress and scan_job_cancel to stop it. This is the MCP-safe " + "surface for long scans; prefer it over synchronous scan when the project may take more than a short call.", + "input_schema": { + "type": "object", + "properties": _SCAN_JOB_START_INPUT_PROPERTIES, + }, + "output_schema": _SCAN_JOB_STATUS_OUTPUT_SCHEMA, + "annotations": { + "title": "Start scan job", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE}), +} + + +_SCAN_JOB_STATUS_TOOL: dict[str, Any] = { + "name": "scan_job_status", + "title": "Read scan-job status", + "description": "Read the current status JSON for a file-backed Wardline scan job. Reports stale heartbeat " + "or dead-worker terminal failure instead of leaving an apparently hung job ambiguous.", + "input_schema": { + "type": "object", + "required": ["job_id"], + "properties": { + "job_id": {"type": "string", "pattern": "^[0-9a-f]{32}$"}, + "path": {"type": "string", "description": "scan root subdir relative to the MCP server project root"}, + }, + }, + "output_schema": _SCAN_JOB_STATUS_OUTPUT_SCHEMA, + "annotations": { + "title": "Read scan-job status", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + "capabilities": frozenset({ToolCapability.READ}), +} + + +_SCAN_JOB_CANCEL_TOOL: dict[str, Any] = { + "name": "scan_job_cancel", + "title": "Cancel scan job", + "description": "Cancel a non-terminal file-backed Wardline scan job and return the persisted terminal status.", + "input_schema": { + "type": "object", + "required": ["job_id"], + "properties": { + "job_id": {"type": "string", "pattern": "^[0-9a-f]{32}$"}, + "path": {"type": "string", "description": "scan root subdir relative to the MCP server project root"}, + }, + }, + "output_schema": _SCAN_JOB_STATUS_OUTPUT_SCHEMA, + "annotations": { + "title": "Cancel scan job", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE}), +} + + def _attach_legis_artifact( response: dict[str, Any], result: Any, @@ -396,8 +2031,15 @@ def _attach_legis_artifact( ) key_str = load_legis_artifact_key(path) - if key_str is None and not bool(args.get("legis_artifact")): + explicit = bool(args.get("legis_artifact")) + if key_str is None and not explicit: return # not requested — default response unchanged + if _bool_arg(args, "summary_only", False) and not explicit: + # summary_only promises the smallest "did the gate pass?" payload; a + # provisioned key must not auto-attach a ~56KB verbatim artifact into it + # (dogfood-4 B6 blew the MCP token cap exactly this way). An explicit + # legis_artifact:true still wins when the caller asks for both. + return cfg = config_mod.load( _cfg(args, path) or weft_config_path(path), @@ -449,7 +2091,8 @@ def _explain_taint(args: dict[str, Any], root: Path, loomweave: Any = None) -> d match_path = args.get("path") if args.get("line") is not None else None if match_path is not None: _resolve_under_root(root, match_path) # reject escapes; result discarded - exp = explain_finding( + max_hops_raw = args.get("max_hops") + result_dict = explain_taint_result( root, fingerprint=args.get("fingerprint"), path=match_path, @@ -458,37 +2101,269 @@ def _explain_taint(args: dict[str, Any], root: Path, loomweave: Any = None) -> d confine_to_root=True, loomweave=loomweave, sink_qualname=args.get("sink_qualname"), + chain=bool(args.get("chain")), + max_hops=int(max_hops_raw) if max_hops_raw is not None else 20, ) - if exp is None: + if result_dict is None: raise ToolError( "fingerprint not in current scan; your code changed since the scan that produced it — re-scan.", ) - result_dict: dict[str, Any] = { - "fingerprint": exp.fingerprint, - "rule_id": exp.rule_id, - "sink_qualname": exp.sink_qualname, - "location": {"path": exp.path, "line": exp.line}, - **_explanation_to_dict(exp), - } - if args.get("chain") and loomweave is not None and exp.sink_qualname: - max_hops_raw = args.get("max_hops") - max_hops = int(max_hops_raw) if max_hops_raw is not None else 20 - ch = explain_chain(root, sink_qualname=exp.sink_qualname, loomweave=loomweave, max_hops=max_hops) - result_dict["chain"] = { - "hops": [ - { - "qualname": h.qualname, - "tier_in": h.tier_in, - "tier_out": h.tier_out, - "contributing_callee_qualname": h.contributing_callee_qualname, - } - for h in ch.hops - ], - "truncated_at": ch.truncated_at, - } return result_dict +_EXPLAIN_TAINT_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the explain_taint tool: the taint provenance slice for one finding (single " + "source: core/explain.explain_taint_result, shared with the CLI `wardline explain-taint`). Served either from a " + "fresh Loomweave store fact (no re-scan) or from an SP8 re-run; both paths produce this same key set. With " + "chain=true and a configured Loomweave store, a `chain` block is additionally attached.", + "properties": { + "fingerprint": { + "type": "string", + "description": "Stable finding fingerprint. May be the empty string on the store-served path when the " + "entity blob carries no per-finding rows (the entity is known, the specific finding is not).", + }, + "rule_id": { + "type": "string", + "description": "Rule that produced the finding (e.g. PY-WL-101). May be the empty string on the " + "store-served path when no per-finding row matched.", + }, + "sink_qualname": { + "type": ["string", "null"], + "description": "Qualified name of the sink function the tainted value reaches (null when the engine has " + "no qualname for the finding).", + }, + "location": { + "type": "object", + "description": "Source location of the finding. path may be empty and line null on the store-served path " + "when the blob has no per-finding rows.", + "properties": { + "path": { + "type": "string", + "description": "Root-relative posix path of the finding's file (empty string when unknown on the " + "store-served path).", + }, + "line": { + "type": ["integer", "null"], + "description": "1-based start line of the finding (null when unknown).", + }, + }, + "required": ["path", "line"], + "additionalProperties": False, + }, + "tier_in": { + "type": ["string", "null"], + "description": "Actual (untrusted) trust tier arriving at the sink, e.g. EXTERNAL_RAW (null when the " + "engine recorded none).", + }, + "tier_out": { + "type": ["string", "null"], + "description": "Trust tier the sink declares it returns, e.g. INTEGRAL (null when the engine recorded " + "none).", + }, + "immediate_tainted_callee": { + "type": ["string", "null"], + "description": "Bare trailing name of the call that introduced the untrusted return into the sink (null " + "when unresolved).", + }, + "source_boundary_qualname": { + "type": ["string", "null"], + "description": "Originating boundary resolved one hop from the sink: qualified name of the boundary " + "function the taint came from (null when not resolvable in one hop). On the store-served path this is the " + "blob's contributing_callee_qualname.", + }, + "source_resolution": { + "type": "object", + "description": "C-10(c) honesty block: whether the taint source is named above, and when it is NOT, why " + "and what capability would resolve it further — an explicit degrade marker, never nulls that read as a " + "complete-but-empty answer.", + "properties": { + "status": { + "type": "string", + "enum": ["resolved", "unresolved"], + "description": "resolved when immediate_tainted_callee or source_boundary_qualname is named; " + "unresolved otherwise.", + }, + "reason": { + "type": ["string", "null"], + "description": "unresolved only: why wardline's own single-scan analysis could not name the " + "source; null when resolved.", + }, + "missing_capability": { + "type": ["string", "null"], + "description": "unresolved only: capability that could resolve further — 'loomweave_taint_store' " + "when no store is configured; null when resolved or when nothing more would help.", + }, + "enablement": { + "type": ["string", "null"], + "description": "unresolved only: how to enable the missing capability; null otherwise.", + }, + }, + "required": ["status", "reason", "missing_capability", "enablement"], + "additionalProperties": False, + }, + "resolved_call_count": { + "type": "integer", + "description": "Number of calls inside the sink the engine resolved during taint computation.", + }, + "unresolved_call_count": { + "type": "integer", + "description": "Number of calls inside the sink the engine could NOT resolve (residual uncertainty in the " + "explanation).", + }, + "remediation": { + "type": "object", + "description": "Advisory fix-at-the-boundary hint derived from the explanation; never replaces the " + "factual taint fields above.", + "properties": { + "kind": { + "type": "string", + "enum": ["boundary_placement", "sink_hygiene", "review_required"], + "description": "boundary_placement for PY-WL-101 (place/repair a @trust_boundary at the " + "validating function); sink_hygiene for the dangerous-sink family (rule-specific fix guidance " + "naming the source and sink); review_required for rules with no automated hint.", + }, + "rule_id": { + "type": "string", + "description": "Rule the hint applies to (echoes the top-level rule_id).", + }, + "summary": {"type": "string", "description": "Human/agent-readable remediation guidance sentence."}, + "sink_qualname": { + "type": ["string", "null"], + "description": "Sink the hint refers to (null when the finding has no qualname).", + }, + "source_qualname": { + "type": ["string", "null"], + "description": "Taint source the hint refers to: the resolved boundary, else the immediate " + "tainted callee, else null when unresolved.", + }, + "caveat": { + "type": "string", + "description": "Standing warning against blind decorator insertion / over-trusting the hint.", + }, + }, + "required": ["kind", "rule_id", "summary", "sink_qualname", "source_qualname", "caveat"], + "additionalProperties": False, + }, + "chain": { + "type": "object", + "description": "Full N-hop taint chain from the sink to the originating boundary, walked from the " + "Loomweave store. Present whenever the call passed chain=true: status 'walked' carries the hops; status " + "'unavailable' is the explicit C-10(c) degrade marker (no Loomweave store configured, or no sink " + "qualname to anchor on) naming the missing capability and its enablement path — the walk never degrades " + "silently.", + "properties": { + "status": { + "type": "string", + "enum": ["walked", "unavailable"], + "description": "walked: the store walk ran (hops below). unavailable: the walk could not run; " + "see missing_capability/enablement.", + }, + "missing_capability": { + "type": ["string", "null"], + "description": "unavailable only: what the walk lacked ('loomweave_taint_store' or " + "'sink_qualname'); null when walked.", + }, + "enablement": { + "type": ["string", "null"], + "description": "unavailable only: how to enable the missing capability; null when walked.", + }, + "hops": { + "type": "array", + "description": "Ordered hops from the sink toward the boundary leaf. The walk stops cleanly at a " + "boundary (contributing_callee_qualname null on the last hop) or truncates explicitly (see " + "truncated_at).", + "items": { + "type": "object", + "properties": { + "qualname": { + "type": "string", + "description": "Qualified name of the function at this hop.", + }, + "tier_in": { + "type": ["string", "null"], + "description": "Actual trust tier arriving at this hop (from the stored fact; null " + "when absent).", + }, + "tier_out": { + "type": ["string", "null"], + "description": "Trust tier this hop declares it returns (from the stored fact; null " + "when absent).", + }, + "contributing_callee_qualname": { + "type": ["string", "null"], + "description": "Next hop toward the boundary; null at the boundary leaf (clean " + "finish).", + }, + }, + "required": ["qualname", "tier_in", "tier_out", "contributing_callee_qualname"], + "additionalProperties": False, + }, + }, + "truncated_at": { + "type": ["string", "null"], + "description": "Qualified name of the next hop the walk could NOT take (stale/absent fact, read " + "error, cycle, or max_hops reached) — truncation is always explicit; null means the chain reached " + "the boundary cleanly.", + }, + }, + "required": ["status", "hops", "truncated_at", "missing_capability", "enablement"], + "additionalProperties": False, + }, + }, + "required": [ + "fingerprint", + "rule_id", + "sink_qualname", + "location", + "tier_in", + "tier_out", + "immediate_tainted_callee", + "source_boundary_qualname", + "source_resolution", + "resolved_call_count", + "unresolved_call_count", + "remediation", + ], + "additionalProperties": False, +} + + +_EXPLAIN_TAINT_TOOL: dict[str, Any] = { + "name": "explain_taint", + "title": "Explain finding taint", + "description": "Explain ONE finding's taint: the immediate tainted callee, the " + "originating boundary, and the trust tiers at the sink. Call right " + "after scan and before editing — a stale fingerprint returns an error. " + "Pass the finding's `qualname` as `sink_qualname`: when a Loomweave store " + "is configured this serves the explanation from the store instead of " + "re-scanning. Pass `chain: true` (needs a configured Loomweave store) to " + "also walk the full taint chain from the sink to the originating boundary; " + "without a store the `chain` block is an explicit `status: unavailable` marker " + "naming the missing capability and its enablement path.", + "input_schema": { + "type": "object", + "properties": { + "fingerprint": {"type": "string"}, + "path": {"type": "string"}, + "line": {"type": "integer"}, + "sink_qualname": {"type": "string"}, + "chain": {"type": "boolean"}, + "max_hops": {"type": "integer"}, + "config": {"type": "string"}, + }, + }, + "output_schema": _EXPLAIN_TAINT_OUTPUT_SCHEMA, + "annotations": { + "title": "Explain finding taint", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + def _dossier( args: dict[str, Any], root: Path, loomweave: Any = None, filigree_url: str | None = None ) -> dict[str, Any]: @@ -509,6 +2384,284 @@ def _dossier( return dossier.to_dict() +_DOSSIER_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "One-call entity dossier envelope: Wardline's own trust posture (always re-derived fresh) plus " + "Loomweave linkages and Filigree open work, each cross-tool section degrading to an honest unavailable shape " + "(available=false + reason) when its source is absent. Token-bounded with an explicit truncation marker.", + "properties": { + "identity": { + "type": "object", + "description": "Who the entity is plus its two-axis freshness (identity axis / content axis). Never " + "trimmed by the budgeter.", + "properties": { + "qualname": { + "type": "string", + "description": "The entity's qualified name, minted relative to the scan root.", + }, + "kind": {"type": ["string", "null"], "description": "Entity kind (e.g. function); null when unknown."}, + "path": {"type": ["string", "null"], "description": "Source file path of the entity."}, + "line_start": {"type": ["integer", "null"]}, + "line_end": {"type": ["integer", "null"]}, + "sei": { + "type": ["string", "null"], + "description": "Opaque stable entity identifier (the cross-tool binding key); null when no " + "Loomweave binding was resolved.", + }, + "keyed_on_sei": { + "type": "boolean", + "description": "True when the cross-tool sections were keyed on the SEI rather than a locator.", + }, + "identity_status": { + "type": "string", + "enum": ["alive", "orphaned", "unavailable"], + "description": "Identity axis: is this the same entity? Never inferred from content.", + }, + "content_status": { + "type": "string", + "enum": ["fresh", "stale", "unknown"], + "description": "Content axis: has the entity's code changed? Never inferred from identity.", + }, + "content_hash": { + "type": ["string", "null"], + "description": "Current content hash from the binding, when available.", + }, + }, + "required": [ + "qualname", + "kind", + "path", + "line_start", + "line_end", + "sei", + "keyed_on_sei", + "identity_status", + "content_status", + "content_hash", + ], + "additionalProperties": False, + }, + "shape": { + "type": "object", + "description": "Signature and decorators as declared in source.", + "properties": { + "signature": {"type": ["string", "null"], "description": 'Rendered signature, e.g. "(p) -> str".'}, + "decorators": { + "type": "array", + "items": {"type": "string"}, + "description": "Decorators as declared, each prefixed with '@'.", + }, + }, + "required": ["signature", "decorators"], + "additionalProperties": False, + }, + "trust": { + "type": "object", + "description": "Wardline's OWN trust posture, re-derived from a live scan (fresh by construction).", + "properties": { + "declared_return": { + "type": ["string", "null"], + "description": "Declared return trust tier; null when undeclared.", + }, + "actual_return": { + "type": ["string", "null"], + "description": "Engine-computed actual return taint; null when not computed.", + }, + "gate_verdict": { + "type": "string", + "enum": ["defect", "clean", "unknown"], + "description": "Three-valued, fail-closed verdict: defect (active findings), clean (declared " + "posture that conforms), unknown (undeclared/unprovable/under-scanned).", + }, + "active_findings": { + "type": "array", + "description": "Active (non-suppressed) defect findings on the entity. May be trimmed by the " + "token budgeter (see truncation.elided).", + "items": { + "type": "object", + "properties": { + "rule_id": {"type": "string"}, + "severity": {"type": "string", "enum": ["CRITICAL", "ERROR", "WARN", "INFO", "NONE"]}, + "message": {"type": "string"}, + "line": {"type": ["integer", "null"]}, + }, + "required": ["rule_id", "severity", "message", "line"], + "additionalProperties": False, + }, + }, + "suppressed_findings": { + "type": "integer", + "description": "Count of accepted (baselined/waived/judged) defects — known debt a clean verdict " + "must not hide.", + }, + "unanalyzed_reason": { + "type": ["string", "null"], + "description": "Engine under-scan fact (parse error / recursion skip) when the body was not " + "analysed; else null.", + }, + "freshness": { + "type": "string", + "enum": ["fresh_by_construction"], + "description": "Constant: the trust section is re-derived on demand, never stale.", + }, + }, + "required": [ + "declared_return", + "actual_return", + "gate_verdict", + "active_findings", + "suppressed_findings", + "unanalyzed_reason", + "freshness", + ], + "additionalProperties": False, + }, + "linkages": { + "type": "object", + "description": "Call-graph neighbourhood from Loomweave. available=false with a reason when Loomweave is " + "not configured / unreachable / serves no HTTP linkages.", + "properties": { + "available": {"type": "boolean"}, + "callers": { + "type": "array", + "items": {"type": "string"}, + "description": "Caller entity locators; empty when unavailable. May be trimmed by the token " + "budgeter.", + }, + "callees": { + "type": "array", + "items": {"type": "string"}, + "description": "Callee entity locators; empty when unavailable. May be trimmed by the token " + "budgeter.", + }, + "scc_peers": { + "type": "array", + "items": {"type": "string"}, + "description": "Strongly-connected-component peers (currently always empty: SCC membership is not " + "served over HTTP).", + }, + "identity_status": {"type": "string", "enum": ["alive", "orphaned", "unavailable"]}, + "content_status": {"type": "string", "enum": ["fresh", "stale", "unknown"]}, + "reason": { + "type": ["string", "null"], + "description": "Why the section is unavailable or degraded (e.g. one-sided linkage failure); null " + "when fully available.", + }, + }, + "required": ["available", "callers", "callees", "scc_peers", "identity_status", "content_status", "reason"], + "additionalProperties": False, + }, + "work": { + "type": "object", + "description": "Open work from Filigree, keyed on the SEI. available=false with a reason when Filigree is " + "not configured / unreachable / there is no binding.", + "properties": { + "available": {"type": "boolean"}, + "tickets": { + "type": "array", + "description": "Filigree issues bound to the entity. May be trimmed by the token budgeter.", + "items": { + "type": "object", + "properties": { + "issue_id": {"type": "string"}, + "status": {"type": ["string", "null"]}, + "priority": {"type": ["string", "null"]}, + "title": {"type": ["string", "null"]}, + "drift": { + "type": "boolean", + "description": "True when the issue was bound to a PRIOR version of the entity " + "(content hash at attach no longer matches).", + }, + }, + "required": ["issue_id", "status", "priority", "title", "drift"], + "additionalProperties": False, + }, + }, + "identity_status": {"type": "string", "enum": ["alive", "orphaned", "unavailable"]}, + "content_status": { + "type": "string", + "enum": ["fresh", "stale", "unknown"], + "description": "stale when any ticket binding drifted; unknown when a compare was impossible.", + }, + "reason": { + "type": ["string", "null"], + "description": "Why the section is unavailable; null when available.", + }, + }, + "required": ["available", "tickets", "identity_status", "content_status", "reason"], + "additionalProperties": False, + }, + "synthesis": { + "type": ["string", "null"], + "description": "Best-effort one-paragraph actionable join of all sections; null when dropped to fit the " + "token budget.", + }, + "truncation": { + "type": "object", + "description": "Elision-honest truncation marker: truncated=false on a complete envelope; when true, " + "elided names every trimmed list with shown-of-total counts.", + "properties": { + "truncated": {"type": "boolean"}, + "elided": { + "type": "array", + "items": { + "type": "object", + "properties": { + "section": { + "type": "string", + "description": 'Dotted list path that was trimmed, e.g. "linkages.callers" or ' + '"trust.active_findings".', + }, + "shown": {"type": "integer"}, + "total": {"type": "integer"}, + }, + "required": ["section", "shown", "total"], + "additionalProperties": False, + }, + }, + "note": { + "type": ["string", "null"], + "description": "Human-readable trim summary; null when not truncated.", + }, + }, + "required": ["truncated", "elided", "note"], + "additionalProperties": False, + }, + }, + "required": ["identity", "shape", "trust", "linkages", "work", "synthesis", "truncation"], + "additionalProperties": False, +} + + +_DOSSIER_TOOL: dict[str, Any] = { + "name": "dossier", + "title": "Entity trust dossier", + "description": "One-call entity dossier for a function `entity` (a qualname): its " + "trust posture (declared vs actual taint, gate verdict, active findings — always " + "computed fresh), plus Loomweave call-graph linkages and Filigree open work joined on " + "the entity's opaque SEI. Every cross-tool section is freshness-stamped on BOTH axes " + "(identity alive/orphaned/unavailable + content fresh/stale/unknown) and degrades to " + "an honest `unavailable` when its source is absent. Token-bounded (~2k) with an " + "explicit truncation marker. Read the whole context without opening the source.", + "input_schema": { + "type": "object", + "required": ["entity"], + "properties": { + "entity": {"type": "string", "description": "the function qualname, e.g. pkg.mod.func"}, + "config": {"type": "string"}, + }, + }, + "output_schema": _DOSSIER_OUTPUT_SCHEMA, + "annotations": { + "title": "Entity trust dossier", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + def _assure(args: dict[str, Any], root: Path) -> dict[str, Any]: """Trust-surface COVERAGE posture — the pre-trust-decision read. How many declared trust boundaries the engine reached a definite verdict on vs. how many are honestly @@ -519,6 +2672,133 @@ def _assure(args: dict[str, Any], root: Path) -> dict[str, Any]: return posture.to_dict() +_ASSURE_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Trust-surface coverage posture: how many declared trust boundaries got a definite verdict vs. how " + "many are honestly unknown, plus waiver debt. Identical to the CLI `assure` JSON.", + "properties": { + "boundaries_total": { + "type": "integer", + "description": "Denominator: count of anchored (trust-declared) entities. proven + defect_total + " + "len(unknown) == boundaries_total.", + }, + "proven": {"type": "integer", "description": "Boundaries with a definite clean verdict."}, + "defect_total": { + "type": "integer", + "description": "Boundaries with a definite defect verdict (a defect counts as COVERED — the engine " + "reached a verdict).", + }, + "unknown": { + "type": "array", + "description": "The honesty gap: anchored entities whose trust could not be proven either way, sorted by " + "qualname.", + "items": { + "type": "object", + "properties": { + "qualname": {"type": "string", "description": "Qualified name of the anchored entity."}, + "tier": {"type": ["string", "null"], "description": "Declared trust tier, or null if undeclared."}, + "location": { + "type": "object", + "description": "Where the entity is declared; both fields null when no location is known.", + "properties": {"path": {"type": ["string", "null"]}, "line": {"type": ["integer", "null"]}}, + "required": ["path", "line"], + "additionalProperties": False, + }, + "reason": { + "type": ["string", "null"], + "description": "Engine under-scan FACT message when the body was not analysed " + "(parse/recursion skip), else null (undeclared / unprovable).", + }, + }, + "required": ["qualname", "tier", "location", "reason"], + "additionalProperties": False, + }, + }, + "engine_limited": { + "type": "integer", + "description": "Under-scan pressure: entity-level unknowns with an engine reason plus unanalyzed files.", + }, + "coverage_pct": { + "type": ["number", "null"], + "description": "Share of known boundaries with a definite verdict over known boundaries plus " + "unanalyzed files, rounded to 1 decimal. null when there are no boundaries and no unanalyzed files.", + }, + "unanalyzed_total": { + "type": "integer", + "description": "Files discovered but never analyzed. Each counts as at least one uncovered surface " + "item in coverage_pct.", + }, + "unanalyzed_rule_ids": { + "type": "array", + "description": "Distinct under-scan rule ids present in the scan findings, sorted lexicographically.", + "items": {"type": "string"}, + }, + "waiver_debt": { + "type": "array", + "description": "Configured waivers with days-to-expiry, sorted by fingerprint. Populated even when " + "nothing was analysable.", + "items": { + "type": "object", + "properties": { + "fingerprint": {"type": "string", "description": "Finding fingerprint the waiver covers."}, + "expires": { + "type": ["string", "null"], + "description": "ISO date the waiver expires, or null for no expiry.", + }, + "days_left": { + "type": ["integer", "null"], + "description": "Days until expiry; may be NEGATIVE for a lapsed waiver; null for no expiry.", + }, + "reason": {"type": "string", "description": "The waiver's recorded justification."}, + }, + "required": ["fingerprint", "expires", "days_left", "reason"], + "additionalProperties": False, + }, + }, + "baselined_total": {"type": "integer", "description": "Findings suppressed by the baseline in this scan."}, + "judged_total": {"type": "integer", "description": "Findings suppressed by judge verdicts in this scan."}, + }, + "required": [ + "boundaries_total", + "proven", + "defect_total", + "unknown", + "engine_limited", + "coverage_pct", + "unanalyzed_total", + "unanalyzed_rule_ids", + "waiver_debt", + "baselined_total", + "judged_total", + ], + "additionalProperties": False, +} + + +_ASSURE_TOOL: dict[str, Any] = { + "name": "assure", + "title": "Trust-surface coverage posture", + "description": "Trust-surface COVERAGE posture: how many declared trust boundaries the " + "engine reached a definite verdict on vs. how many are honestly unknown, plus " + "waiver-debt. Consult before deciding to trust a module.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "config": {"type": "string"}, + }, + }, + "output_schema": _ASSURE_OUTPUT_SCHEMA, + "annotations": { + "title": "Trust-surface coverage posture", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + def _decorator_coverage( args: dict[str, Any], root: Path, @@ -539,6 +2819,217 @@ def _decorator_coverage( return report.to_dict() +_DECORATOR_COVERAGE_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Row-level inventory of every trust-decorated entity under the project (the row-level sibling of " + "assure): each declared trust-surface entity with its current verdict, findings, identity binding, and open-work " + "state, plus a rollup summary.", + "properties": { + "summary": { + "type": "object", + "description": "Rollup counts over rows by finding_state.", + "properties": { + "total": {"type": "integer", "description": "Total declared trust-surface entities."}, + "clean": {"type": "integer", "description": "Rows with finding_state == clean."}, + "defect": {"type": "integer", "description": "Rows with finding_state == defect."}, + "unknown": {"type": "integer", "description": "Rows with finding_state == unknown."}, + "suppressed": {"type": "integer", "description": "Rows with finding_state == suppressed."}, + }, + "required": ["total", "clean", "defect", "unknown", "suppressed"], + "additionalProperties": False, + }, + "rows": { + "type": "array", + "description": "One row per declared trust-decorated entity, sorted by qualname. Empty when the scan " + "produced no analysis context.", + "items": { + "type": "object", + "properties": { + "qualname": { + "type": "string", + "description": "The entity's qualified name, minted relative to the scan root.", + }, + "path": { + "type": ["string", "null"], + "description": "Source file path; null when the declared qualname has no scanned entity.", + }, + "line": { + "type": ["integer", "null"], + "description": "Entity start line; null when the declared qualname has no scanned entity.", + }, + "decorators": { + "type": "array", + "items": {"type": "string"}, + "description": "Decorators as declared, each prefixed with '@'.", + }, + "declared_tier": { + "type": ["string", "null"], + "description": "Declared return trust tier; null when undeclared.", + }, + "actual_tier": { + "type": ["string", "null"], + "description": "Engine-computed actual return taint; null when not computed.", + }, + "verdict": { + "type": "string", + "enum": ["defect", "clean", "unknown"], + "description": "Three-valued, fail-closed trust verdict for the entity.", + }, + "finding_state": { + "type": "string", + "enum": ["defect", "suppressed", "unknown", "clean"], + "description": "defect when active findings exist; suppressed when only accepted findings " + "exist; unknown when the verdict is unknown; else clean.", + }, + "active_finding_fingerprints": { + "type": "array", + "items": {"type": "string"}, + "description": "Sorted fingerprints of active (non-suppressed) defect findings on the entity.", + }, + "suppressed_finding_fingerprints": { + "type": "array", + "items": {"type": "string"}, + "description": "Sorted fingerprints of accepted (baselined/waived/judged) defect findings.", + }, + "identity": { + "type": "object", + "description": "Cross-tool identity binding for the row. Degrades to available=false with a " + "reason when Loomweave is not configured / unreachable / resolved no SEI.", + "properties": { + "available": { + "type": "boolean", + "description": "True only when an SEI was resolved for the entity.", + }, + "locator": { + "type": "string", + "description": "The Loomweave-style locator, e.g. python:function:pkg.mod.func.", + }, + "sei": { + "type": ["string", "null"], + "description": "Opaque stable entity identifier; null when not resolved.", + }, + "identity_status": { + "type": "string", + "enum": ["alive", "orphaned", "unavailable"], + "description": "Identity axis: is this the same entity?", + }, + "content_status": { + "type": "string", + "enum": ["fresh", "stale", "unknown"], + "description": "Content axis: has the entity's code changed?", + }, + "content_hash": { + "type": ["string", "null"], + "description": "Current content hash from the binding, when available.", + }, + "reason": { + "type": ["string", "null"], + "description": "Why identity is unavailable (e.g. 'loomweave not configured', 'no SEI " + "resolved'); null when an SEI was resolved.", + }, + }, + "required": [ + "available", + "locator", + "sei", + "identity_status", + "content_status", + "content_hash", + "reason", + ], + "additionalProperties": False, + }, + "work": { + "type": "object", + "description": "Open work from Filigree, keyed on the SEI. available=false with a reason when " + "Filigree is not configured / unreachable / there is no SEI binding.", + "properties": { + "available": {"type": "boolean"}, + "tickets": { + "type": "array", + "description": "Filigree issues bound to the entity.", + "items": { + "type": "object", + "properties": { + "issue_id": {"type": "string"}, + "status": {"type": ["string", "null"]}, + "priority": {"type": ["string", "null"]}, + "title": {"type": ["string", "null"]}, + "drift": { + "type": "boolean", + "description": "True when the issue was bound to a PRIOR version of the " + "entity (content hash at attach no longer matches).", + }, + }, + "required": ["issue_id", "status", "priority", "title", "drift"], + "additionalProperties": False, + }, + }, + "identity_status": {"type": "string", "enum": ["alive", "orphaned", "unavailable"]}, + "content_status": { + "type": "string", + "enum": ["fresh", "stale", "unknown"], + "description": "stale when any ticket binding drifted; unknown when a compare was " + "impossible.", + }, + "reason": { + "type": ["string", "null"], + "description": "Why the section is unavailable; null when available.", + }, + }, + "required": ["available", "tickets", "identity_status", "content_status", "reason"], + "additionalProperties": False, + }, + }, + "required": [ + "qualname", + "path", + "line", + "decorators", + "declared_tier", + "actual_tier", + "verdict", + "finding_state", + "active_finding_fingerprints", + "suppressed_finding_fingerprints", + "identity", + "work", + ], + "additionalProperties": False, + }, + }, + }, + "required": ["summary", "rows"], + "additionalProperties": False, +} + + +_DECORATOR_COVERAGE_TOOL: dict[str, Any] = { + "name": "decorator_coverage", + "title": "Trust-decorator inventory", + "description": "Stable JSON inventory of every Wardline trust-decorated entity: " + "qualname, path/line, decorators, declared/actual tier, gate verdict, " + "active/suppressed finding fingerprints, optional SEI/content status, and " + "optional Filigree linked work status. Optional sources degrade explicitly.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "config": {"type": "string"}, + }, + }, + "output_schema": _DECORATOR_COVERAGE_OUTPUT_SCHEMA, + "annotations": { + "title": "Trust-decorator inventory", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.NETWORK}), +} + + def _attest(args: dict[str, Any], root: Path, loomweave: Any = None) -> dict[str, Any]: """Build a SIGNED, reproducible evidence bundle for the project — identical to the CLI `attest` by construction (both call ``build_attestation``). Path/config confined @@ -565,6 +3056,212 @@ def _attest(args: dict[str, Any], root: Path, loomweave: Any = None) -> dict[str ) +_ATTEST_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Signed, reproducible evidence bundle: a deterministic payload plus an HMAC-SHA256 signature under " + "the shared project key (tamper-evidence within the key-holding trust domain, not asymmetric proof).", + "properties": { + "schema": { + "type": "string", + "enum": ["wardline-attest-1"], + "description": "Wire-contract tag; bound into the HMAC so a relabel cannot verify.", + }, + "payload": { + "type": "object", + "description": "The signed, deterministic attestation payload (canonical compact key-sorted JSON is the " + "reproducibility target).", + "properties": { + "wardline_version": {"type": "string", "description": "Wardline version that produced the bundle."}, + "attested_at": { + "type": "string", + "description": "ISO date the bundle was built; re-derivation on verify uses this as `today`.", + }, + "commit": { + "type": ["string", "null"], + "description": "`git rev-parse HEAD` of the tree, or null for a non-git tree / missing git.", + }, + "dirty": { + "type": "boolean", + "description": "True iff the working tree had uncommitted changes (MCP refuses dirty trees unless " + "allow_dirty=true).", + }, + "ruleset_hash": { + "type": "string", + "description": "Deterministic 'sha256:' over the effective scan policy.", + }, + "posture": { + "type": "object", + "description": "The trust-surface coverage posture at attestation time (same shape as the " + "`assure` tool result).", + "properties": { + "boundaries_total": {"type": "integer"}, + "proven": {"type": "integer"}, + "defect_total": {"type": "integer"}, + "unknown": { + "type": "array", + "description": "Anchored entities with no definite verdict, sorted by qualname.", + "items": { + "type": "object", + "properties": { + "qualname": {"type": "string"}, + "tier": {"type": ["string", "null"]}, + "location": { + "type": "object", + "properties": { + "path": {"type": ["string", "null"]}, + "line": {"type": ["integer", "null"]}, + }, + "required": ["path", "line"], + "additionalProperties": False, + }, + "reason": {"type": ["string", "null"]}, + }, + "required": ["qualname", "tier", "location", "reason"], + "additionalProperties": False, + }, + }, + "engine_limited": {"type": "integer"}, + "coverage_pct": { + "type": ["number", "null"], + "description": "null when there are no boundaries and no unanalyzed files.", + }, + "unanalyzed_total": {"type": "integer"}, + "unanalyzed_rule_ids": {"type": "array", "items": {"type": "string"}}, + "waiver_debt": { + "type": "array", + "description": "Configured waivers with days-to-expiry (the only date-sensitive payload " + "field), sorted by fingerprint.", + "items": { + "type": "object", + "properties": { + "fingerprint": {"type": "string"}, + "expires": {"type": ["string", "null"]}, + "days_left": {"type": ["integer", "null"]}, + "reason": {"type": "string"}, + }, + "required": ["fingerprint", "expires", "days_left", "reason"], + "additionalProperties": False, + }, + }, + "baselined_total": {"type": "integer"}, + "judged_total": {"type": "integer"}, + }, + "required": [ + "boundaries_total", + "proven", + "defect_total", + "unknown", + "engine_limited", + "coverage_pct", + "unanalyzed_total", + "unanalyzed_rule_ids", + "waiver_debt", + "baselined_total", + "judged_total", + ], + "additionalProperties": False, + }, + "boundaries": { + "type": "array", + "description": "Per-boundary trust verdicts, sorted by qualname; empty when nothing was " + "analysable.", + "items": { + "type": "object", + "properties": { + "qualname": {"type": "string", "description": "Qualified name of the anchored entity."}, + "sei": { + "type": ["string", "null"], + "description": "Loomweave SEI resolved at build time; null without a Loomweave client " + "or when unresolvable.", + }, + "verdict": { + "type": "string", + "enum": ["clean", "defect", "unknown"], + "description": "The entity's trust verdict from the single source of truth " + "(classify_entity_trust).", + }, + "tier": { + "type": ["string", "null"], + "description": "Declared trust tier, or null if undeclared.", + }, + }, + "required": ["qualname", "sei", "verdict", "tier"], + "additionalProperties": False, + }, + }, + "sei_source": { + "type": "string", + "enum": ["loomweave", "unavailable"], + "description": "'loomweave' iff a client was supplied AND at least one SEI resolved; else " + "'unavailable'.", + }, + }, + "required": [ + "wardline_version", + "attested_at", + "commit", + "dirty", + "ruleset_hash", + "posture", + "boundaries", + "sei_source", + ], + "additionalProperties": False, + }, + "signature": { + "type": "object", + "description": "HMAC-SHA256 over the canonical {schema, payload} envelope bytes.", + "properties": { + "alg": {"type": "string", "enum": ["HMAC-SHA256"]}, + "value": {"type": "string", "description": "Hex HMAC digest."}, + "key_id": { + "type": "string", + "description": "Non-secret 8-hex short id of the signing key (distinguishes keys without " + "revealing them).", + }, + }, + "required": ["alg", "value", "key_id"], + "additionalProperties": False, + }, + }, + "required": ["schema", "payload", "signature"], + "additionalProperties": False, +} + + +_ATTEST_TOOL: dict[str, Any] = { + "name": "attest", + "title": "Build signed attestation", + "description": "Build a SIGNED, reproducible evidence bundle (commit, ruleset hash, " + "trust-surface posture, boundaries) for the project. HMAC-signed with the " + "install-minted project key. Refuses a dirty working tree unless allow_dirty=true. " + "SEI-keyed when a Loomweave store is configured.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "config": {"type": "string"}, + "allow_dirty": {"type": "boolean"}, + "cache_dir": { + "type": "string", + "description": "subdir relative to project root for summary cache", + }, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": {"type": "boolean"}, + "strict_defaults": {"type": "boolean"}, + }, + }, + "output_schema": _ATTEST_OUTPUT_SCHEMA, + "annotations": { + "title": "Build signed attestation", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + def _verify_attestation(args: dict[str, Any], root: Path, loomweave: Any = None) -> dict[str, Any]: """Verify an attestation bundle's signature (offline, needs the project key) and optionally re-derive it at the current tree (`reproduce=true`). Identical to the CLI @@ -592,6 +3289,73 @@ def _verify_attestation(args: dict[str, Any], root: Path, loomweave: Any = None) ) +_VERIFY_ATTESTATION_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Result of verifying an attestation bundle: signature check (always, offline) plus optional " + "reproducibility re-derivation against the current tree.", + "properties": { + "signature_valid": { + "type": "boolean", + "description": "True iff the recomputed HMAC over the recorded payload matches the stored signature " + "(schema tag, alg, and key_id must all match too).", + }, + "reproduced": { + "type": ["boolean", "null"], + "description": "null when reproduce was not requested (or no root); true when the re-derived payload's " + "canonical bytes equal the recorded payload's; false otherwise. A false may mean the tree moved on, not " + "tamper.", + }, + "mismatches": { + "type": "array", + "description": "Top-level payload keys that differ between the recorded and re-derived payloads. Empty " + "unless reproduced is false.", + "items": {"type": "string"}, + }, + "note": { + "type": "string", + "description": "Fixed caveat: reproducibility holds against the RECORDED commit; a mismatch may mean the " + "tree moved, not tamper.", + }, + }, + "required": ["signature_valid", "reproduced", "mismatches", "note"], + "additionalProperties": False, +} + + +_VERIFY_ATTESTATION_TOOL: dict[str, Any] = { + "name": "verify_attestation", + "title": "Verify attestation bundle", + "description": "Verify an attestation bundle's signature (offline, needs the project " + "key) and optionally its reproducibility (reproduce=true re-derives at the current " + "tree). Returns {signature_valid, reproduced, mismatches, note}.", + "input_schema": { + "type": "object", + "required": ["bundle"], + "properties": { + "bundle": {"type": "object"}, + "reproduce": {"type": "boolean"}, + "config": {"type": "string"}, + "path": {"type": "string"}, + "cache_dir": { + "type": "string", + "description": "subdir relative to project root for summary cache", + }, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": {"type": "boolean"}, + "strict_defaults": {"type": "boolean"}, + }, + }, + "output_schema": _VERIFY_ATTESTATION_OUTPUT_SCHEMA, + "annotations": { + "title": "Verify attestation bundle", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + def _judge(args: dict[str, Any], root: Path) -> dict[str, Any]: # No key/.env → run_judge's default caller raises JudgeConfigurationError (a # WardlineError) naming WARDLINE_OPENROUTER_API_KEY; _tools_call turns that into @@ -630,6 +3394,87 @@ def _judge(args: dict[str, Any], root: Path) -> dict[str, Any]: } +_JUDGE_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the judge tool: per-finding LLM triage verdicts plus the persistence outcome " + "of FALSE_POSITIVE records.", + "properties": { + "verdicts": { + "type": "array", + "description": "One flattened verdict per triaged finding.", + "items": { + "type": "object", + "properties": { + "fingerprint": {"type": "string", "description": "Stable fingerprint of the judged finding."}, + "rule_id": {"type": "string", "description": "Rule that produced the finding."}, + "path": {"type": "string", "description": "Repo-relative file path of the finding."}, + "line": {"type": ["integer", "null"], "description": "1-based start line; null when unknown."}, + "label": { + "type": "string", + "enum": ["TRUE_POSITIVE", "FALSE_POSITIVE"], + "description": "The judge's verdict: a real defect vs an analyzer over-approximation.", + }, + "confidence": { + "type": "number", + "description": "The judge's calibrated confidence in the verdict, 0.0 to 1.0.", + }, + "rationale": { + "type": "string", + "description": "The model's verbatim reasoning (the audit primitive); always a non-empty " + "string.", + }, + }, + "required": ["fingerprint", "rule_id", "path", "line", "label", "confidence", "rationale"], + "additionalProperties": False, + }, + }, + "wrote": { + "type": "integer", + "description": "FALSE_POSITIVE verdicts persisted to .wardline/judged.yaml (0 unless write=true).", + }, + "held_back": { + "type": "integer", + "description": "FALSE_POSITIVE verdicts NOT persisted because their confidence fell below the write floor.", + }, + }, + "required": ["verdicts", "wrote", "held_back"], + "additionalProperties": False, +} + + +_JUDGE_TOOL: dict[str, Any] = { + "name": "judge", + "title": "LLM triage of findings", + "description": "NETWORK: opt-in LLM triage of active defects via OpenRouter " + "(needs WARDLINE_OPENROUTER_API_KEY). Labels each TRUE/FALSE positive. " + "Never run automatically; never folded into scan.", + "input_schema": { + "type": "object", + "properties": { + "config": {"type": "string"}, + "model": {"type": "string"}, + "max_findings": {"type": "integer"}, + "write": {"type": "boolean", "description": "append above-floor FPs to judged.yaml"}, + "trust_judge_config": {"type": "boolean"}, + "trust_judge_policy": {"type": "boolean"}, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": {"type": "boolean"}, + "strict_defaults": {"type": "boolean"}, + "context_lines": {"type": "integer"}, + }, + }, + "output_schema": _JUDGE_OUTPUT_SCHEMA, + "annotations": { + "title": "LLM triage of findings", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": True, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.NETWORK}), +} + + def _baseline(args: dict[str, Any], root: Path) -> dict[str, Any]: reason = args.get("reason") baseline_path = baseline_file(root) @@ -661,7 +3506,69 @@ def _baseline(args: dict[str, Any], root: Path) -> dict[str, Any]: return payload -def _waiver_add(args: dict[str, Any], root: Path) -> dict[str, Any]: +_BASELINE_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the wardline MCP `baseline` tool: records the result of generating (or finding " + "an existing) suppression baseline for the project.", + "properties": { + "baselined_count": { + "type": "integer", + "description": "Number of finding fingerprints in the baseline. On a fresh generation this is the count " + "just written; when the baseline already existed (already_exists=true) it is the count in the existing " + "file.", + }, + "path": {"type": "string", "description": "Absolute path of the baseline file."}, + "reason": { + "type": ["string", "null"], + "description": "Caller-supplied reason for creating the baseline; null when not provided.", + }, + "already_exists": { + "type": "boolean", + "description": "Only present when overwrite was not requested: true if a baseline file already existed " + "(nothing was written; counts reflect the existing file), false if a new baseline was created. Absent " + "when overwrite=true succeeds.", + }, + }, + "required": ["baselined_count", "path", "reason"], + "additionalProperties": False, +} + + +_BASELINE_TOOL: dict[str, Any] = { + "name": "baseline", + "title": "Snapshot findings baseline", + "description": "Snapshot current defects as the baseline so only NEW findings surface. " + "Default overwrite=false refuses to clobber and returns already_exists=true. " + "Set overwrite=true to re-derive and overwrite the baseline. " + "Prefer FIXING a finding over baselining it. Optional reason.", + "input_schema": { + "type": "object", + "properties": { + "reason": {"type": "string"}, + "overwrite": {"type": "boolean"}, + "config": {"type": "string"}, + "cache_dir": { + "type": "string", + "description": "subdir relative to project root for summary cache", + }, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": {"type": "boolean"}, + "strict_defaults": {"type": "boolean"}, + }, + }, + "output_schema": _BASELINE_OUTPUT_SCHEMA, + "annotations": { + "title": "Snapshot findings baseline", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE}), +} + + +def _waiver_add(args: dict[str, Any], root: Path, loomweave: Any = None) -> dict[str, Any]: fp = _require(args, "fingerprint") reason = _require(args, "reason") expires_str = _require(args, "expires") # mandatory at the tool boundary @@ -672,23 +3579,693 @@ def _waiver_add(args: dict[str, Any], root: Path) -> dict[str, Any]: except ValueError as exc: # A malformed date is something the agent can fix and should see. raise ToolError("expires must be an ISO date (YYYY-MM-DD)") from exc + + entity_id_raw = args.get("entity_id") + entity_symbol_raw = args.get("entity_symbol") + if entity_id_raw is not None and not isinstance(entity_id_raw, str): + raise ToolError("entity_id must be a string") + if entity_symbol_raw is not None and not isinstance(entity_symbol_raw, str): + raise ToolError("entity_symbol must be a string") + plugin = args.get("entity_plugin") or "python" + if plugin not in ("python", "rust"): + raise ToolError("entity_plugin must be 'python' or 'rust'") + + # Doctrine spine: a waiver names a specific defect-in-a-function, so it may carry the + # entity's rename-surviving SEI. Resolve the inline reference BEFORE writing — an + # entity_symbol that does not resolve returns unresolved_input and we create NOTHING + # (never an unbound-but-looks-bound record). + from wardline.core.filigree_issue import resolve_entity_binding_input + + binding = resolve_entity_binding_input( + entity_id=entity_id_raw or None, + entity_symbol=entity_symbol_raw or None, + loomweave_client=loomweave, + plugin=plugin, + ) + if binding is not None and not binding.resolved: + return { + "fingerprint": fp, + "created": False, + "unresolved_input": { + "reason_class": binding.reason_class, + "cause": binding.cause, + "fix": binding.fix, + }, + } + entity_sei = binding.entity_id if binding is not None else None + entity_locator = binding.locator if binding is not None else None + for existing in load_project_waivers(root): if existing.fingerprint == fp: return { "fingerprint": existing.fingerprint, "reason": existing.reason, "expires": existing.expires.isoformat() if existing.expires else None, + "entity_sei": existing.entity_sei, + "entity_locator": existing.entity_locator, + "binding_kind": ( + None + if existing.entity_sei is None + else ("sei" if existing.entity_sei.startswith("loomweave:eid:") else "locator") + ), "already_exists": True, } - waiver = add_waiver(waivers_path(root), fingerprint=fp, reason=reason, expires=expires, root=root) + waiver = add_waiver( + waivers_path(root), + fingerprint=fp, + reason=reason, + expires=expires, + root=root, + entity_sei=entity_sei, + entity_locator=entity_locator, + ) return { "fingerprint": waiver.fingerprint, "reason": waiver.reason, "expires": waiver.expires.isoformat() if waiver.expires else None, + "entity_sei": waiver.entity_sei, + "entity_locator": waiver.entity_locator, + "binding_kind": binding.binding_kind if (binding is not None and binding.resolved) else None, "already_exists": False, } +_WAIVER_ADD_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the wardline MCP `waiver_add` tool: the waiver that now covers the fingerprint " + "(newly added, or the pre-existing one when a waiver for the fingerprint was already present). When an inline " + "entity_symbol was supplied but did NOT resolve to a SEI, the waiver is NOT written and only " + "{fingerprint, created:false, unresolved_input} is returned (never an unbound-but-looks-bound record).", + "properties": { + "fingerprint": {"type": "string", "description": "The finding fingerprint the waiver covers."}, + "reason": { + "type": "string", + "description": "The waiver's reason. When already_exists=true this is the EXISTING waiver's reason, which " + "may differ from the one passed in this call. Absent on an unresolved_input refusal (nothing was written).", + }, + "expires": { + "type": ["string", "null"], + "description": "Waiver expiry as an ISO date (YYYY-MM-DD). Null only when a pre-existing waiver loaded " + "from the waivers file carries no expiry (the tool itself requires expires on new waivers). Absent on an " + "unresolved_input refusal.", + }, + "entity_sei": { + "type": ["string", "null"], + "description": "The rename-surviving Loomweave SEI bound to this waiver's code entity, or the opaque " + "entity_id supplied verbatim; null when no inline entity binding was supplied.", + }, + "entity_locator": { + "type": ["string", "null"], + "description": "Human-readable locator for the bound entity (e.g. 'python:function:pkg.mod.fn'); null " + "when no inline entity binding was supplied.", + }, + "binding_kind": { + "type": ["string", "null"], + "enum": ["sei", "locator", None], + "description": "Whether the stored binding is a rename-stable SEI or a legacy locator; null when no " + "entity binding was supplied.", + }, + "created": { + "type": "boolean", + "description": "Present only on an unresolved_input refusal: always false (the waiver was NOT written).", + }, + "unresolved_input": { + "type": "object", + "description": "Present only when an inline entity_symbol was supplied but did not resolve to a SEI. The " + "waiver was NOT written. Canonical weft-reason carrier.", + "properties": { + "reason_class": {"type": "string", "enum": ["unresolved_input"]}, + "cause": {"type": "string", "description": "Why the supplied entity reference did not resolve."}, + "fix": {"type": "string", "description": "The actionable next step to make it resolve."}, + }, + "required": ["reason_class", "cause", "fix"], + "additionalProperties": False, + }, + "already_exists": { + "type": "boolean", + "description": "True when a waiver for this fingerprint already existed and the returned fields describe " + "that existing waiver; false when this call added a new waiver. Absent on an unresolved_input refusal.", + }, + }, + "required": ["fingerprint"], + "additionalProperties": False, +} + + +_WAIVER_ADD_TOOL: dict[str, Any] = { + "name": "waiver_add", + "title": "Add time-boxed waiver", + "description": "Waive ONE finding by fingerprint with a mandatory reason and expiry. " + "Prefer fixing; a waiver is an audited, time-boxed exception. " + "OPTIONALLY bind the waiver to the code entity it suppresses so the suppression " + "survives rename/move and joins federation: pass `entity_id` (an opaque SEI/locator " + "you already hold) or `entity_symbol` (a qualname resolved to a SEI through " + "Loomweave). An entity_symbol that does not resolve returns `unresolved_input` and " + "writes NOTHING.", + "input_schema": { + "type": "object", + "required": ["fingerprint", "reason", "expires"], + "properties": { + "fingerprint": {"type": "string"}, + "reason": {"type": "string"}, + "expires": {"type": "string", "description": "YYYY-MM-DD"}, + "entity_id": { + "type": "string", + "description": "L1 inline bind: an opaque entity id you already hold (a 'loomweave:eid:...' SEI or a " + "legacy locator). Carried verbatim, never re-resolved. Wins over entity_symbol if both are given.", + }, + "entity_symbol": { + "type": "string", + "description": "L2 inline bind: a qualname (e.g. 'pkg.mod.func') resolved to a rename-stable SEI " + "through Loomweave at entry time. A symbol that does not resolve returns unresolved_input and writes " + "nothing.", + }, + "entity_plugin": { + "type": "string", + "enum": ["python", "rust"], + "description": "Which frontend the entity_symbol belongs to (defaults to 'python'); selects the " + "resolve-hint dialect.", + }, + }, + }, + "output_schema": _WAIVER_ADD_OUTPUT_SCHEMA, + "annotations": { + "title": "Add time-boxed waiver", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE}), +} + + +def _doctor( + args: dict[str, Any], + root: Path, + *, + started_at: float, + filigree_url: str | None = None, + loomweave_url: str | None = None, +) -> dict[str, Any]: + """The CLI `doctor --fix` envelope over MCP (A2, wardline-2ee1bbda82's sibling): + install/federation health checks via the SAME machine_readable_doctor builder, + plus the running server's self-identification + source-freshness verdict so an + agent can detect a stale long-lived server without shelling out. Read-only by + default; `repair: true` is the explicit WRITE opt-in (mirrors CLI --fix). + + Both launch-flag URLs are threaded in so the url checks describe the EFFECTIVE + config of THIS server process, with provenance — not just env (dogfood-4 B8).""" + from wardline.install.doctor import machine_readable_doctor + from wardline.mcp.freshness import attach_server_identity + + repair = _bool_arg(args, "repair", False) + flag = args.get("filigree_url") + if flag is not None and not isinstance(flag, str): + raise ToolError("filigree_url must be a string") + payload = machine_readable_doctor(root, fix=repair, filigree_url=flag or filigree_url, loomweave_url=loomweave_url) + return attach_server_identity(payload, root=root, started_at=started_at) + + +_DOCTOR_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Doctor success payload: the CLI `doctor --fix` machine-readable envelope (install/federation " + "health checks) plus the running MCP server's self-identification block and a `server.freshness` check appended " + "to the check list.", + "properties": { + "ok": { + "type": "boolean", + "description": "True only when every check (including the appended server.freshness check) passed.", + }, + "checks": { + "type": "array", + "description": "Uniform health-check verdicts: wardline.config, mcp.registration, marker_package, " + "loomweave.url, filigree.url, decorator_grammar, scan.output_path, auth.token, filigree.auth, then " + "server.freshness last.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable check identifier, e.g. 'wardline.config' or 'server.freshness'.", + }, + "status": {"type": "string", "enum": ["ok", "error"], "description": "Check verdict."}, + "fixed": { + "type": "boolean", + "description": "True when this run's repair (repair: true) corrected the underlying condition.", + }, + "message": { + "type": "string", + "description": "Human/agent-readable detail. Present only when the check produced a non-empty " + "message (always on errors; sometimes on informational ok results).", + }, + }, + "required": ["id", "status", "fixed"], + "additionalProperties": False, + }, + }, + "next_actions": { + "type": "array", + "description": "One ': ' action line per failed check that carries a message; empty " + "when everything is healthy.", + "items": {"type": "string"}, + }, + "server": { + "type": "object", + "description": "The running MCP server's self-identification: detects a stale long-lived server (source " + "on disk newer than process start).", + "properties": { + "package_version": {"type": "string", "description": "Installed wardline package version."}, + "pid": {"type": "integer", "description": "Server process id."}, + "project_root": {"type": "string", "description": "Absolute project root this server is confined to."}, + "started_at": {"type": "string", "description": "ISO-8601 UTC timestamp of server process start."}, + "source_latest_mtime": { + "type": ["string", "null"], + "description": "ISO-8601 UTC mtime of the newest *.py file under the imported wardline package, " + "or null when nothing was statable.", + }, + "source_latest_path": { + "type": ["string", "null"], + "description": "Package-relative path of that newest source file, or null.", + }, + "fresh": { + "type": "boolean", + "description": "False when on-disk package source changed after this process started — the server " + "is serving OLD code and must be restarted.", + }, + }, + "required": [ + "package_version", + "pid", + "project_root", + "started_at", + "source_latest_mtime", + "source_latest_path", + "fresh", + ], + "additionalProperties": False, + }, + }, + "required": ["ok", "checks", "next_actions", "server"], + "additionalProperties": False, +} + + +_DOCTOR_TOOL: dict[str, Any] = { + "name": "doctor", + "title": "Install and federation health check", + "description": "Health-check the wardline install and federation wiring " + "(same checks as CLI `doctor --fix`: instruction blocks, skills, MCP " + "registration, config parseability, sibling URLs, Filigree emit auth) " + "PLUS this server's self-identification: package version, pid, start " + "time, and a source-FRESHNESS verdict. If `server.fresh` is false this " + "long-lived server predates the on-disk wardline code — its results are " + "stale; restart the MCP server. Read-only by default; `repair: true` " + "(write-gated) repairs install artifacts and re-pins a rejected " + "federation token.", + "input_schema": { + "type": "object", + "properties": { + "repair": { + "type": "boolean", + "description": "Default false (pure probe, writes nothing). true repairs " + "install artifacts (CLAUDE.md/AGENTS.md blocks, .claude/.agents skills, " + ".mcp.json + Codex registration, .weft state dir) and, when Filigree " + "rejected the emit token, re-pins the accepted local mint in .env.", + }, + "filigree_url": { + "type": "string", + "description": "Filigree URL to probe for emit auth (default: the server's " + "configured URL, then WARDLINE_FILIGREE_URL, then the .mcp.json arg). " + "Only loopback origins are ever probed with a token.", + }, + }, + }, + "output_schema": _DOCTOR_OUTPUT_SCHEMA, + "annotations": { + "title": "Install and federation health check", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + +def _rekey(args: dict[str, Any], root: Path, filigree: Any = None) -> dict[str, Any]: + """Fingerprint-scheme migration over MCP (A3): the same core.rekey the CLI drives — + no second migration path. Probe-by-default (read-only: report match/orphans/ + collisions, write NOTHING); `apply`/`resume`/`rollback` are explicit, mutually + exclusive, WRITE-gated. The injected Filigree emitter (apply only) re-emits under + the new fingerprints, best-effort like the CLI's --filigree-url leg.""" + from wardline.core.rekey import ( + ORPHAN_CAUSE, + ORPHAN_SAMPLE_LIMIT, + STALE_CAUSE, + Journal, + probe, + resume_rekey, + rollback, + run_rekey, + ) + + apply_ = _bool_arg(args, "apply", False) + do_resume = _bool_arg(args, "resume", False) + do_rollback = _bool_arg(args, "rollback", False) + if sum((apply_, do_resume, do_rollback)) > 1: + raise ToolError("apply, resume and rollback are mutually exclusive") + path = _resolve_under_root(root, args["path"]) if args.get("path") else root + + def journal_block(journal: Journal) -> dict[str, Any]: + # Lean payload: carried as a COUNT (can be the whole store), orphans as a count + # plus a BOUNDED sample (bounded-by-default; never silent — the FULL list is + # recorded verbatim in the migration journal on disk). + block: dict[str, Any] = { + "complete": journal.complete, + "fingerprint_scheme_from": journal.fingerprint_scheme_from, + "fingerprint_scheme_to": journal.fingerprint_scheme_to, + "snapshot_prescheme": journal.snapshot_prescheme, + "orphan_cause": ORPHAN_CAUSE, + "collisions": [ + {"new_fp": c.new_fp, "old_fps": list(c.old_fps), "message": c.message} for c in journal.collisions + ], + "legs": [ + { + "name": leg.name, + "done": leg.done, + "carried_count": len(leg.carried), + "orphaned_count": len(leg.orphaned), + "orphaned_sample": list(leg.orphaned[:ORPHAN_SAMPLE_LIMIT]), + "debt": leg.debt, + } + for leg in journal.legs + ], + "next_pending_leg": journal.next_pending_leg(), + } + if not journal.complete: + block["next_action"] = "re-run the rekey tool to finish pending leg(s)" + return block + + if do_rollback: + rolled = rollback(path) + return { + "mode": "rollback", + "restored": list(rolled.restored), + "note": "Filigree associations from the forward run are NOT reversed " + "(no remap endpoint); reconcile manually if needed.", + } + if do_resume: + # Resume NEVER re-scans — YAML legs re-carry from the snapshot; a pending + # Filigree leg is deferred as debt (re-run with apply to retry it). + return {"mode": "resume", **journal_block(resume_rekey(path))} + + # Probe (the default) and apply both need the suppression-free scan: migration + # scans WITHOUT loading the stores it is about to rekey (they are still + # old-scheme and would SCHEME_MISMATCH). EVERY frontend participates — the + # stores hold RS-WL verdicts too, and a python-only scan misreads each healthy + # Rust verdict as orphaned/stale (A7, weft-dda1a6d8dd). + findings: list[Any] = [] + for lang in ("python", "rust"): + result = run_scan( + path, + config_path=_cfg(args, root), + cache_dir=_cache_dir_arg(args, root), + confine_to_root=True, + trust_local_packs=bool(args.get("trust_local_packs", False)), + trusted_packs=_trusted_packs_arg(args), + strict_defaults=bool(args.get("strict_defaults", False)), + skip_suppression=True, + lang=lang, + ) + findings.extend(result.findings) + if not apply_: + report = probe(path, findings) + return { + "mode": "probe", + "scanned_findings": report.scanned_findings, + "matched": report.matched, + # Counts + a bounded sample (A7, weft-dda1a6d8dd): never the full + # fingerprint dump — the cut is explicit via the count. + "orphaned_count": len(report.orphaned), + "orphaned_sample": list(report.orphaned[:ORPHAN_SAMPLE_LIMIT]), + "orphan_cause": ORPHAN_CAUSE, + "stale_count": len(report.stale), + "stale_sample": list(report.stale[:ORPHAN_SAMPLE_LIMIT]), + "stale_cause": STALE_CAUSE, + "collisions": [ + {"new_fp": c.new_fp, "old_fps": list(c.old_fps), "message": c.message} for c in report.collisions + ], + "per_store": dict(report.per_store), + "prescheme": report.prescheme, + "current_scheme_stores": list(report.current_scheme_stores), + "no_op": report.no_op, + "clean": report.clean, + } + return {"mode": "apply", **journal_block(run_rekey(path, findings, filigree=filigree))} + + +_REKEY_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Rekey success payload. Four shapes share the 'mode' discriminator: 'probe' (default, read-only " + "dry run), 'apply' and 'resume' (journal block reporting the migration's per-leg state), and 'rollback' ({mode, " + "restored, note}). All non-'mode' fields are mode-specific.", + "properties": { + "mode": { + "type": "string", + "enum": ["probe", "apply", "resume", "rollback"], + "description": "Which rekey operation ran. 'probe' is the read-only default; 'apply'/'resume'/'rollback' " + "are explicit, mutually exclusive write modes.", + }, + "scanned_findings": { + "type": "integer", + "description": "probe only: number of findings the suppression-free scan produced (each carries an " + "old/new fingerprint pair).", + }, + "matched": { + "type": "integer", + "description": "probe only: count of distinct stored fingerprints that map to a current finding — each " + "store is judged against ITS OWN scheme (a wlfp1 store via the migration remap, a store already at the " + "live scheme via the current fingerprints).", + }, + "orphaned_count": { + "type": "integer", + "description": "probe only: number of stored OLD-SCHEME fingerprints with no current finding — these " + "verdicts would be dropped by an apply. Stores already at the live scheme never orphan (see stale_*).", + }, + "orphaned_sample": { + "type": "array", + "items": {"type": "string"}, + "description": "probe only: bounded sorted sample of the orphaned fingerprints (counts are authoritative; " + "an apply records the full list verbatim in the migration journal).", + }, + "orphan_cause": { + "type": "string", + "description": "probe/apply/resume: fixed explanation string for why a stored fingerprint can orphan " + "(source moved/deleted, or a custom multi-emit rule not surfacing taint_path_v0).", + }, + "stale_count": { + "type": "integer", + "description": "probe only: number of CURRENT-scheme store entries matching no current finding — baseline " + "drift, NOT migration orphans; a rekey would not touch them and they do not dirty the probe.", + }, + "stale_sample": { + "type": "array", + "items": {"type": "string"}, + "description": "probe only: bounded sorted sample of the stale fingerprints.", + }, + "stale_cause": { + "type": "string", + "description": "probe only: fixed explanation string for why a current-scheme entry can be stale.", + }, + "collisions": { + "type": "array", + "description": "probe/apply/resume: pre-rekey fingerprints that collapse to the same new fingerprint " + "under the new scheme; all involved old fingerprints are orphaned, not carried.", + "items": { + "type": "object", + "properties": { + "new_fp": { + "type": "string", + "description": "The new-scheme fingerprint that more than one old fingerprint maps onto.", + }, + "old_fps": { + "type": "array", + "items": {"type": "string"}, + "description": "The colliding pre-rekey fingerprints (sorted).", + }, + "message": { + "type": "string", + "description": "WLN-ENGINE-FINGERPRINT-COLLISION diagnostic explaining that the colliding " + "verdicts are orphaned.", + }, + }, + "required": ["new_fp", "old_fps", "message"], + "additionalProperties": False, + }, + }, + "per_store": { + "type": "object", + "description": "probe only: open map of store file name (e.g. 'baseline.yaml') -> count of its stored " + "fingerprints with no current finding. Only stores with orphans appear.", + "additionalProperties": {"type": "integer"}, + }, + "prescheme": { + "type": "boolean", + "description": "probe only: true when a live store predates the fingerprint-scheme stamp, so orphans MAY " + "be a fingerprint-formula change rather than source churn.", + }, + "current_scheme_stores": { + "type": "array", + "items": {"type": "string"}, + "description": "probe only: store file names ALREADY at the live fingerprint scheme — a rekey is a no-op " + "for them; their entries were matched against the current scan's fingerprints.", + }, + "no_op": { + "type": "boolean", + "description": "probe only: true when every populated store already carries the live scheme — no " + "fingerprint migration is pending and an apply would be refused.", + }, + "clean": { + "type": "boolean", + "description": "probe only: true when there are no orphans and no collisions — an apply would carry every " + "stored verdict (or, when no_op, there is simply nothing to migrate).", + }, + "complete": {"type": "boolean", "description": "apply/resume: true when every migration leg is done."}, + "fingerprint_scheme_from": { + "type": "string", + "description": "apply/resume: the fingerprint scheme being migrated from (e.g. 'wlfp1').", + }, + "fingerprint_scheme_to": { + "type": "string", + "description": "apply/resume: the fingerprint scheme being migrated to (e.g. 'wlfp2').", + }, + "snapshot_prescheme": { + "type": "boolean", + "description": "apply/resume: true when the snapshotted stores carried no scheme stamp (pre-scheme), so " + "orphans may be a formula change — surfaced as a caution.", + }, + "legs": { + "type": "array", + "description": "apply/resume: per-leg migration state, in application order (baseline, judged, waivers, " + "filigree).", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Leg name: one of 'baseline', 'judged', 'waivers', 'filigree' in a journal " + "this code wrote.", + }, + "done": {"type": "boolean", "description": "True when this leg has been applied and persisted."}, + "carried_count": { + "type": "integer", + "description": "Number of stored verdicts re-keyed and carried forward by this leg (count " + "only; can be the whole store).", + }, + "orphaned_count": { + "type": "integer", + "description": "Number of stored verdicts this leg dropped — never silent; the full verbatim " + "list is recorded in the on-disk migration journal.", + }, + "orphaned_sample": { + "type": "array", + "items": {"type": "string"}, + "description": "Bounded sample of the old fingerprints this leg dropped (counts are " + "authoritative; full list in the migration journal).", + }, + "debt": { + "type": ["string", "null"], + "description": "Filigree leg only: recorded reconciliation debt when the leg soft-failed " + "(re-run with apply to retry); null otherwise.", + }, + }, + "required": ["name", "done", "carried_count", "orphaned_count", "orphaned_sample", "debt"], + "additionalProperties": False, + }, + }, + "next_pending_leg": { + "type": ["string", "null"], + "description": "apply/resume: name of the first not-done leg, or null when the migration is complete.", + }, + "next_action": { + "type": "string", + "description": "apply/resume, only when the migration is INCOMPLETE: instruction to re-run the rekey tool " + "to finish pending leg(s).", + }, + "restored": { + "type": "array", + "items": {"type": "string"}, + "description": "rollback only: store file names restored from the pre-migration snapshot.", + }, + "note": { + "type": "string", + "description": "rollback only: caveat that Filigree associations from the forward run are NOT reversed " + "(no remap endpoint); reconcile manually if needed.", + }, + }, + "required": ["mode"], + "additionalProperties": False, +} + + +_REKEY_TOOL: dict[str, Any] = { + "name": "rekey", + "title": "Migrate fingerprint scheme", + "description": "Re-key baseline/waiver/judged verdicts across a fingerprint-scheme " + "change (after the engine's fingerprint formula migrates, NOT after ordinary " + "refactors — fingerprints are line-insensitive). DEFAULT is a read-only PROBE: " + "reports how many stored verdicts will carry, which orphan and why, and any " + "collisions — writes nothing. Pass `apply: true` to migrate (snapshots first, " + "resumable journal), `resume: true` to finish an interrupted migration without " + "re-scanning, `rollback: true` to restore the pre-migration stores. The three " + "are mutually exclusive and write-gated.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "subdir relative to project root"}, + "config": {"type": "string"}, + "cache_dir": { + "type": "string", + "description": "subdir relative to project root for summary cache", + }, + "trust_packs": {"type": "array", "items": {"type": "string"}}, + "trust_local_packs": { + "type": "boolean", + "description": "Allow loading custom trust-grammar packs from the local project directory", + }, + "strict_defaults": { + "type": "boolean", + "description": "Ignore repository-supplied custom configuration overrides (weft.toml)", + }, + "apply": { + "type": "boolean", + "description": "Default false (read-only probe). true RUNS the migration: " + "snapshot stores, write the resumable journal, carry verdicts to the " + "new fingerprints, best-effort re-emit to a configured Filigree.", + }, + "resume": { + "type": "boolean", + "description": "Finish an interrupted migration from the journal WITHOUT " + "re-scanning (YAML legs re-carry from the snapshot).", + }, + "rollback": { + "type": "boolean", + "description": "Restore the pre-migration stores byte-identical from the " + "snapshot and remove the journal. Filigree associations are NOT reversed.", + }, + }, + }, + "output_schema": _REKEY_OUTPUT_SCHEMA, + "annotations": { + "title": "Migrate fingerprint scheme", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, +} + + def _fix(args: dict[str, Any], root: Path) -> dict[str, Any]: """Scan the path and apply mechanical autofixes to findings in-place.""" path = _resolve_under_root(root, args["path"]) if args.get("path") else root @@ -717,15 +4294,60 @@ def _fix(args: dict[str, Any], root: Path) -> dict[str, Any]: } -# Gate thresholds are the four defect severities. Severity also defines NONE -# (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"] +_FIX_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": "Success payload of the wardline MCP `fix` tool: mechanical autofix results (PY-WL-111 " + "assert-at-boundary rewrites), either previewed (default / dry_run) or applied in-place (apply=true).", + "properties": { + "fixed": { + "type": "object", + "description": "Open map of file path (relative to the scanned `path` argument, NOT the project root) " + "-> list of human-readable descriptions of the fixes previewed/applied in that file. Empty object when " + "no fixable findings were found or no fixes could be produced.", + "additionalProperties": { + "type": "array", + "items": {"type": "string", "description": "Description of one fix in this file."}, + }, + }, + "applied": { + "type": "boolean", + "description": "True when fixes were written to disk (apply=true and not dry_run), false when this was a " + "preview. Absent on the early-return path where the scan found no fixable findings.", + }, + "message": { + "type": "string", + "description": "Human-readable summary, e.g. 'No fixable findings found.', 'Previewed fixes for N files.' " + "or 'Applied fixes for N files.'", + }, + }, + "required": ["fixed", "message"], + "additionalProperties": False, +} -# Default ceiling on the number of active-defect provenances inlined by `explain: true` -# on the MCP `scan`. Bounds the one-shot payload (the dogfood report hit 56,820 chars on -# one line over a whole repo); an explicit `max_findings` tightens it further. -_EXPLAIN_DEFAULT_CAP = 10 + +_FIX_TOOL: dict[str, Any] = { + "name": "fix", + "title": "Apply mechanical autofixes", + "description": "Scan and apply mechanical autofixes to findings (currently only PY-WL-111 is supported).", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "subdir relative to project root to scan and fix"}, + "config": {"type": "string"}, + "dry_run": {"type": "boolean", "description": "preview changes without modifying files"}, + "apply": {"type": "boolean", "description": "must be true to modify files"}, + }, + }, + "output_schema": _FIX_OUTPUT_SCHEMA, + "annotations": { + "title": "Apply mechanical autofixes", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, + "capabilities": frozenset({ToolCapability.READ, ToolCapability.WRITE}), +} class WardlineMCPServer: @@ -741,6 +4363,9 @@ def __init__( self.root = Path(root) self.loomweave_url = loomweave_url self.filigree_url = filigree_url + # Recorded once at construction: the doctor tool's freshness verdict compares + # on-disk source mtimes against this to expose a stale long-lived server. + self.started_at = time.time() self._tool_policy = ToolPolicy(allow_write=allow_write, allow_network=allow_network) self.rpc = JsonRpcServer(server_name="wardline", server_version=__version__) self._tools: dict[str, Tool] = {} @@ -811,110 +4436,12 @@ def _filigree_filer( return FiligreeIssueFiler(url, token=load_filigree_token(self.root)) def _register_tools(self) -> None: + # Each tool's full declaration (schemas, annotations, capabilities) lives + # module-level next to its handler; registration just binds it to the + # injected-client lambda. Order is the published surface order. self.add_tool( Tool( - name="scan", - description="Whole-program taint scan of the project. Returns structured " - "findings, the suppression summary (active = unsuppressed defects; " - "by default the --fail-on gate evaluates the UNSUPPRESSED population so " - "repo-controlled baseline/waiver/judged annotate but do not clear it — " - "pass `trust_suppressions: true` for the trusted-local behaviour), " - "and the gate verdict. Pass `where` to filter the returned findings " - "(conjunctive; summary/gate stay whole-project) and `explain: true` to inline " - "each active defect's taint provenance — one call, no per-finding explain_taint. " - "When a Filigree URL is configured, also POSTs the " - "findings to Filigree (fail-soft: an unreachable sibling or rejected payload " - "is reported in the `filigree` block, never fails the scan).", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string", "description": "subdir relative to project root"}, - "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, - "config": {"type": "string"}, - "where": { - "type": "object", - "description": "Filter the returned findings (conjunctive). Keys: " - "rule_id, qualname, severity, suppression, kind, path_glob, sink, tier. " - "summary/gate still describe the whole project.", - "properties": { - "rule_id": {"type": "string"}, - "qualname": {"type": "string"}, - "severity": {"type": "string", "enum": _SEVERITY_ENUM}, - "suppression": {"type": "string", "enum": ["active", "baselined", "waived", "judged"]}, - "kind": { - "type": "string", - "enum": ["defect", "fact", "classification", "metric", "suggestion"], - }, - "path_glob": {"type": "string"}, - "sink": {"type": "string"}, - "tier": {"type": "string"}, - }, - }, - "explain": { - "type": "boolean", - "description": "Inline each active defect's taint provenance " - "(immediate tainted callee, source boundary, trust tiers, resolution " - "counts) — one call instead of an explain_taint per finding. Inlining is " - "capped at 10 provenances by default (raise/lower with max_findings); the cut " - "is reported at truncation.explanations_truncated.", - }, - "summary_only": { - "type": "boolean", - "description": "Return counts + gate only, no finding bodies — the smallest " - "'did the gate pass?' payload. summary/gate still describe the whole project.", - }, - "max_findings": { - "type": "integer", - "minimum": 0, - "description": "Cap the number of returned finding bodies (and inlined " - "explanations). Must be a non-negative integer. The cut is reported in the " - "truncation block; summary counts stay whole-project.", - }, - "include_suppressed": { - "type": "boolean", - "description": "Default true. Set false to drop suppressed (baselined/waived/" - "judged) finding bodies from the response; the suppression counts stay in " - "summary.", - }, - "new_since": { - "type": "string", - "description": "PR-scoped 'new findings only' gate: only gate on findings in " - "files/entities changed since this git ref", - }, - "cache_dir": { - "type": "string", - "description": "subdir relative to project root for summary cache", - }, - "trust_packs": {"type": "array", "items": {"type": "string"}}, - "trust_local_packs": { - "type": "boolean", - "description": "Allow loading custom trust-grammar packs from the local project directory", - }, - "strict_defaults": { - "type": "boolean", - "description": "Ignore repository-supplied custom configuration overrides (weft.toml)", - }, - "trust_suppressions": { - "type": "boolean", - "description": "Let repository-controlled baseline/waiver/judged clear the gate " - "(they always annotate findings regardless). Default false — the gate " - "evaluates the unsuppressed population so a PR cannot self-suppress its " - "own defect. Use only on a trusted checkout; in CI prefer new_since.", - }, - "legis_artifact": { - "type": "boolean", - "description": "Attach the verbatim-postable legis scan-artifact " - "(`legis_artifact` block) even when no signing key is provisioned " - "(unsigned, for legis's optional-verify posture).", - }, - "allow_dirty": { - "type": "boolean", - "description": "For the legis artifact only: on a dirty tree emit an UNSIGNED, " - "clearly-marked (dirty: true) dev artifact instead of refusing to sign. " - "Signing stays clean-tree-only; legis records it unverified.", - }, - }, - }, + **_SCAN_TOOL, handler=lambda args, root: _scan( args, root, @@ -931,48 +4458,35 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="explain_taint", - description="Explain ONE finding's taint: the immediate tainted callee, the " - "originating boundary, and the trust tiers at the sink. Call right " - "after scan and before editing — a stale fingerprint returns an error. " - "Pass the finding's `qualname` as `sink_qualname`: when a Loomweave store " - "is configured this serves the explanation from the store instead of " - "re-scanning. Pass `chain: true` (needs a configured Loomweave store) to " - "also walk the full taint chain from the sink to the originating boundary; " - "without a store it degrades to the single-hop explanation (no `chain` block).", - input_schema={ - "type": "object", - "properties": { - "fingerprint": {"type": "string"}, - "path": {"type": "string"}, - "line": {"type": "integer"}, - "sink_qualname": {"type": "string"}, - "chain": {"type": "boolean"}, - "max_hops": {"type": "integer"}, - "config": {"type": "string"}, - }, - }, + **_SCAN_JOB_START_TOOL, + handler=lambda args, root: _scan_job_start( + args, + root, + None if bool(args.get("local_only") or False) else self._resolved_filigree_url_for_policy(args), + ), + ) + ) + self.add_tool( + Tool( + **_SCAN_JOB_STATUS_TOOL, + handler=_scan_job_status, + ) + ) + self.add_tool( + Tool( + **_SCAN_JOB_CANCEL_TOOL, + handler=_scan_job_cancel, + ) + ) + self.add_tool( + Tool( + **_EXPLAIN_TAINT_TOOL, handler=lambda args, root: _explain_taint(args, root, self._loomweave_client(_cfg(args, root))), ) ) self.add_tool( Tool( - name="dossier", - description="One-call entity dossier for a function `entity` (a qualname): its " - "trust posture (declared vs actual taint, gate verdict, active findings — always " - "computed fresh), plus Loomweave call-graph linkages and Filigree open work joined on " - "the entity's opaque SEI. Every cross-tool section is freshness-stamped on BOTH axes " - "(identity alive/orphaned/unavailable + content fresh/stale/unknown) and degrades to " - "an honest `unavailable` when its source is absent. Token-bounded (~2k) with an " - "explicit truncation marker. Read the whole context without opening the source.", - input_schema={ - "type": "object", - "required": ["entity"], - "properties": { - "entity": {"type": "string", "description": "the function qualname, e.g. pkg.mod.func"}, - "config": {"type": "string"}, - }, - }, + **_DOSSIER_TOOL, handler=lambda args, root: _dossier( args, root, @@ -983,35 +4497,13 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="assure", - description="Trust-surface COVERAGE posture: how many declared trust boundaries the " - "engine reached a definite verdict on vs. how many are honestly unknown, plus " - "waiver-debt. Consult before deciding to trust a module.", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string"}, - "config": {"type": "string"}, - }, - }, + **_ASSURE_TOOL, handler=lambda args, root: _assure(args, root), ) ) self.add_tool( Tool( - name="decorator_coverage", - capabilities=frozenset({ToolCapability.READ, ToolCapability.NETWORK}), - description="Stable JSON inventory of every Wardline trust-decorated entity: " - "qualname, path/line, decorators, declared/actual tier, gate verdict, " - "active/suppressed finding fingerprints, optional SEI/content status, and " - "optional Filigree linked work status. Optional sources degrade explicitly.", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string"}, - "config": {"type": "string"}, - }, - }, + **_DECORATOR_COVERAGE_TOOL, handler=lambda args, root: _decorator_coverage( args, root, @@ -1022,26 +4514,7 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="attest", - description="Build a SIGNED, reproducible evidence bundle (commit, ruleset hash, " - "trust-surface posture, boundaries) for the project. HMAC-signed with the " - "install-minted project key. Refuses a dirty working tree unless allow_dirty=true. " - "SEI-keyed when a Loomweave store is configured.", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string"}, - "config": {"type": "string"}, - "allow_dirty": {"type": "boolean"}, - "cache_dir": { - "type": "string", - "description": "subdir relative to project root for summary cache", - }, - "trust_packs": {"type": "array", "items": {"type": "string"}}, - "trust_local_packs": {"type": "boolean"}, - "strict_defaults": {"type": "boolean"}, - }, - }, + **_ATTEST_TOOL, handler=lambda args, root: _attest( args, root, @@ -1053,27 +4526,7 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="verify_attestation", - description="Verify an attestation bundle's signature (offline, needs the project " - "key) and optionally its reproducibility (reproduce=true re-derives at the current " - "tree). Returns {signature_valid, reproduced, mismatches, note}.", - input_schema={ - "type": "object", - "required": ["bundle"], - "properties": { - "bundle": {"type": "object"}, - "reproduce": {"type": "boolean"}, - "config": {"type": "string"}, - "path": {"type": "string"}, - "cache_dir": { - "type": "string", - "description": "subdir relative to project root for summary cache", - }, - "trust_packs": {"type": "array", "items": {"type": "string"}}, - "trust_local_packs": {"type": "boolean"}, - "strict_defaults": {"type": "boolean"}, - }, - }, + **_VERIFY_ATTESTATION_TOOL, handler=lambda args, root: _verify_attestation( args, root, @@ -1085,30 +4538,7 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="file_finding", - capabilities=frozenset({ToolCapability.READ, ToolCapability.WRITE, ToolCapability.NETWORK}), - description="File ONE finding (by `fingerprint`) into a tracked Filigree issue and " - "return its `issue_id`. Idempotent (re-filing returns the same issue). Emit findings " - "to Filigree first (scan with a configured Filigree URL) so the fingerprint is known; " - "a `not_found: true` result means it isn't. Reconciliation (close-on-fixed / " - "reopen-on-regress) happens automatically on later scans. Fail-soft.", - input_schema={ - "type": "object", - "required": ["fingerprint"], - "properties": { - "fingerprint": {"type": "string"}, - "priority": {"type": "string", "description": "Filigree priority, e.g. P2"}, - "labels": {"type": "array", "items": {"type": "string"}}, - "attach_loomweave_identity": { - "type": "boolean", - "description": ( - "Opt in to resolving the finding qualname through Loomweave and attaching " - "a Filigree entity association." - ), - }, - "config": {"type": "string"}, - }, - }, + **_FILE_FINDING_TOOL, handler=lambda args, root: _file_finding( args, root, @@ -1121,32 +4551,7 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="scan_file_findings", - capabilities=frozenset({ToolCapability.READ, ToolCapability.WRITE, ToolCapability.NETWORK}), - description="One-shot agent workflow: run a scan, list active defects first with " - "inline explanation summaries, optionally emit to Filigree, promote selected " - "fingerprints or all active defects, and attach Loomweave identity when available. " - "Defaults to dry-run unless fingerprints or all_active are supplied.", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string", "description": "subdir relative to project root"}, - "fail_on": {"type": "string", "enum": _SEVERITY_ENUM}, - "config": {"type": "string"}, - "cache_dir": { - "type": "string", - "description": "subdir relative to project root for summary cache", - }, - "fingerprints": {"type": "array", "items": {"type": "string"}}, - "all_active": {"type": "boolean"}, - "dry_run": {"type": "boolean"}, - "priority": {"type": "string", "description": "Filigree priority, e.g. P2"}, - "labels": {"type": "array", "items": {"type": "string"}}, - "trust_packs": {"type": "array", "items": {"type": "string"}}, - "trust_local_packs": {"type": "boolean"}, - "strict_defaults": {"type": "boolean"}, - }, - }, + **_SCAN_FILE_FINDINGS_TOOL, handler=lambda args, root: _scan_file_findings( args, root, @@ -1162,90 +4567,60 @@ def _register_tools(self) -> None: ) self.add_tool( Tool( - name="judge", - capabilities=frozenset({ToolCapability.READ, ToolCapability.NETWORK}), - description="NETWORK: opt-in LLM triage of active defects via OpenRouter " - "(needs WARDLINE_OPENROUTER_API_KEY). Labels each TRUE/FALSE positive. " - "Never run automatically; never folded into scan.", - input_schema={ - "type": "object", - "properties": { - "config": {"type": "string"}, - "model": {"type": "string"}, - "max_findings": {"type": "integer"}, - "write": {"type": "boolean", "description": "append above-floor FPs to judged.yaml"}, - "trust_judge_config": {"type": "boolean"}, - "trust_judge_policy": {"type": "boolean"}, - "trust_packs": {"type": "array", "items": {"type": "string"}}, - "trust_local_packs": {"type": "boolean"}, - "strict_defaults": {"type": "boolean"}, - "context_lines": {"type": "integer"}, - }, - }, + **_JUDGE_TOOL, handler=_judge, ) ) self.add_tool( Tool( - name="baseline", - capabilities=frozenset({ToolCapability.READ, ToolCapability.WRITE}), - description="Snapshot current defects as the baseline so only NEW findings surface. " - "Default overwrite=false refuses to clobber and returns already_exists=true. " - "Set overwrite=true to re-derive and overwrite the baseline. " - "Prefer FIXING a finding over baselining it. Optional reason.", - input_schema={ - "type": "object", - "properties": { - "reason": {"type": "string"}, - "overwrite": {"type": "boolean"}, - "config": {"type": "string"}, - "cache_dir": { - "type": "string", - "description": "subdir relative to project root for summary cache", - }, - "trust_packs": {"type": "array", "items": {"type": "string"}}, - "trust_local_packs": {"type": "boolean"}, - "strict_defaults": {"type": "boolean"}, - }, - }, + **_BASELINE_TOOL, handler=_baseline, ) ) self.add_tool( Tool( - name="waiver_add", - capabilities=frozenset({ToolCapability.READ, ToolCapability.WRITE}), - description="Waive ONE finding by fingerprint with a mandatory reason and expiry. " - "Prefer fixing; a waiver is an audited, time-boxed exception.", - input_schema={ - "type": "object", - "required": ["fingerprint", "reason", "expires"], - "properties": { - "fingerprint": {"type": "string"}, - "reason": {"type": "string"}, - "expires": {"type": "string", "description": "YYYY-MM-DD"}, - }, - }, - handler=_waiver_add, + **_WAIVER_ADD_TOOL, + handler=lambda args, root: _waiver_add( + args, + root, + # An entity_symbol needs Loomweave to resolve; an opaque entity_id does + # not. Build the client only when a symbol is present (None otherwise). + self._loomweave_client(_cfg(args, root)) if args.get("entity_symbol") else None, + ), ) ) self.add_tool( Tool( - name="fix", - capabilities=frozenset({ToolCapability.READ, ToolCapability.WRITE}), - description="Scan and apply mechanical autofixes to findings (currently only PY-WL-111 is supported).", - input_schema={ - "type": "object", - "properties": { - "path": {"type": "string", "description": "subdir relative to project root to scan and fix"}, - "config": {"type": "string"}, - "dry_run": {"type": "boolean", "description": "preview changes without modifying files"}, - "apply": {"type": "boolean", "description": "must be true to modify files"}, - }, - }, + **_FIX_TOOL, handler=_fix, ) ) + self.add_tool( + Tool( + **_DOCTOR_TOOL, + handler=lambda args, root: _doctor( + args, + root, + started_at=self.started_at, + filigree_url=self.filigree_url, + loomweave_url=self.loomweave_url, + ), + ) + ) + self.add_tool( + Tool( + **_REKEY_TOOL, + handler=lambda args, root: _rekey( + args, + root, + # The Filigree leg only runs under apply; building the emitter is + # cheap and returns None when no URL resolves. + self._filigree_emitter(_cfg(args, root), strict_defaults=bool(args.get("strict_defaults") or False)) + if args.get("apply") + else None, + ), + ) + ) def add_tool(self, tool: Tool) -> None: schema = dict(tool.input_schema) @@ -1266,17 +4641,23 @@ def _wire(self) -> None: self.rpc.register("prompts/get", self._prompts_get) def _tools_list(self, params: dict[str, Any]) -> dict[str, Any]: - return { - "tools": [ - { - "name": t.name, - "description": t.description, - "inputSchema": t.input_schema, - "capabilities": [cap.value for cap in sorted(t.capabilities, key=lambda c: c.value)], - } - for t in self._tools.values() - ] - } + # B1/B2: standard MCP metadata (title / outputSchema / annotations, with + # annotations.title for 2025-03-26 clients) ALONGSIDE the homegrown + # "capabilities" key — mapped, not replaced, so existing consumers keep working. + tools: list[dict[str, Any]] = [] + for t in self._tools.values(): + entry: dict[str, Any] = {"name": t.name} + if t.title is not None: + entry["title"] = t.title + entry["description"] = t.description + entry["inputSchema"] = t.input_schema + if t.output_schema is not None: + entry["outputSchema"] = t.output_schema + if t.annotations is not None: + entry["annotations"] = t.annotations + entry["capabilities"] = [cap.value for cap in sorted(t.capabilities, key=lambda c: c.value)] + tools.append(entry) + return {"tools": tools} def _resolved_loomweave_url_for_policy(self, arguments: dict[str, Any]) -> str | None: return config_mod.resolve_loomweave_url( @@ -1301,6 +4682,12 @@ def _effective_tool_capabilities(self, tool: Tool, arguments: dict[str, Any]) -> or self._resolved_filigree_url_for_policy(arguments) is not None ): capabilities.update({ToolCapability.NETWORK, ToolCapability.WRITE}) + if ( + tool.name == "scan_job_start" + and not bool(arguments.get("local_only", False)) + and self._resolved_filigree_url_for_policy(arguments) is not None + ): + capabilities.add(ToolCapability.NETWORK) if ( tool.name in {"explain_taint", "attest", "verify_attestation"} and self._resolved_loomweave_url_for_policy(arguments) is not None @@ -1313,6 +4700,22 @@ def _effective_tool_capabilities(self, tool: Tool, arguments: dict[str, Any]) -> capabilities.add(ToolCapability.NETWORK) if tool.name == "judge" and bool(arguments.get("write", False)): capabilities.add(ToolCapability.WRITE) + if tool.name == "doctor": + if bool(arguments.get("repair", False)): + capabilities.add(ToolCapability.WRITE) + from wardline.install.doctor import _resolve_probe_url + + flag = arguments.get("filigree_url") + probe_url = flag if isinstance(flag, str) and flag else None + if _resolve_probe_url(self.root, probe_url or self.filigree_url) is not None: + # The filigree-auth probe will touch the (loopback-only) network. + capabilities.add(ToolCapability.NETWORK) + if tool.name == "rekey": + if any(bool(arguments.get(k, False)) for k in ("apply", "resume", "rollback")): + capabilities.add(ToolCapability.WRITE) + if bool(arguments.get("apply", False)) and self._resolved_filigree_url_for_policy(arguments) is not None: + # apply's last leg re-emits the rekeyed findings to Filigree. + capabilities.add(ToolCapability.NETWORK) return frozenset(capabilities) def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]: @@ -1378,7 +4781,13 @@ def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]: traceback.print_exc(file=sys.stderr) return self._is_error(f"wardline internal error: {exc}") - return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} + # Dual emission (B1): the text block stays byte-identical to the pre-B1 shape so + # existing clients parsing it keep working; structuredContent carries the same + # payload for 2025-06-18 clients. isError results never carry structuredContent. + return { + "content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}], + "structuredContent": payload, + } def _resources_list(self, params: dict[str, Any]) -> dict[str, Any]: return {"resources": list_resources()} diff --git a/src/wardline/mcp/tooling.py b/src/wardline/mcp/tooling.py index 784bd3dd..cf87b57b 100644 --- a/src/wardline/mcp/tooling.py +++ b/src/wardline/mcp/tooling.py @@ -7,13 +7,10 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from wardline.core.finding import Finding -if TYPE_CHECKING: - from wardline.core.explain import TaintExplanation - class ToolError(Exception): """Tool-execution error returned to the MCP client as an isError result.""" @@ -54,6 +51,19 @@ class Tool: handler: Callable[[dict[str, Any], Path], Any] network: bool = False capabilities: frozenset[ToolCapability] = frozenset({ToolCapability.READ}) + # B1/B2 (wardline-47ff226ebe / wardline-e63204176b): MCP rev 2025-06-18 structured + # output + 2025-03-26 display metadata. ``annotations`` is the standard MCP + # ToolAnnotations object (title, readOnlyHint, destructiveHint, idempotentHint, + # openWorldHint). CONVENTION: the hints describe the tool's integration-free + # baseline posture — readOnlyHint mirrors the DECLARED capability set, and + # openWorldHint mirrors the declared NETWORK capability. Opt-in federation reach + # (a configured Filigree/Loomweave URL widening scan/dossier/attest at runtime) + # is deliberately NOT reflected here: hints are static, untrusted UX advisories, + # and ToolPolicy + _effective_tool_capabilities remain the enforcement authority + # over what a call may actually do. + title: str | None = None + output_schema: dict[str, Any] | None = None + annotations: dict[str, Any] | None = None def __post_init__(self) -> None: capabilities = set(self.capabilities) or {ToolCapability.READ} @@ -67,63 +77,6 @@ def finding_to_dict(finding: Finding) -> dict[str, Any]: return parsed -def explanation_to_dict(exp: TaintExplanation) -> dict[str, Any]: - return { - "tier_in": exp.tier_in, - "tier_out": exp.tier_out, - "immediate_tainted_callee": exp.immediate_tainted_callee, - "source_boundary_qualname": exp.source_boundary_qualname, - "resolved_call_count": exp.resolved_call_count, - "unresolved_call_count": exp.unresolved_call_count, - "remediation": remediation_to_dict(exp), - } - - -def remediation_to_dict(exp: TaintExplanation) -> dict[str, Any]: - if exp.rule_id != "PY-WL-101": - return { - "kind": "review_required", - "rule_id": exp.rule_id, - "summary": ( - "Review the finding and apply the rule-specific fix; no automated remediation hint is available." - ), - "sink_qualname": exp.sink_qualname, - "source_qualname": exp.source_boundary_qualname, - "caveat": "This hint is advisory and does not replace the factual taint explanation.", - } - - source = exp.source_boundary_qualname or exp.immediate_tainted_callee - sink = exp.sink_qualname - if source and sink: - summary = ( - f"Validate or normalize data from {source} before it reaches trusted producer {sink}. " - "Add or repair a @trust_boundary only on the function that actually rejects invalid data." - ) - elif sink: - summary = ( - f"Validate or normalize the raw input before it reaches trusted producer {sink}; " - "the taint source is unresolved in this explanation. Add or repair a @trust_boundary only where " - "the code actually rejects invalid data." - ) - else: - summary = ( - "Validate or normalize the raw input before it reaches the trusted producer; the taint source is " - "unresolved in this explanation. Add or repair a @trust_boundary only where the code actually " - "rejects invalid data." - ) - return { - "kind": "boundary_placement", - "rule_id": exp.rule_id, - "summary": summary, - "sink_qualname": sink, - "source_qualname": source, - "caveat": ( - "Do not use blind decorator insertion; mark a trust boundary only on code that validates " - "and rejects invalid data." - ), - } - - def resolve_under_root(root: Path, arg: str) -> Path: """Resolve a caller-supplied path/config arg against root and refuse escapes.""" candidate = (root / arg).resolve() diff --git a/src/wardline/rust/__init__.py b/src/wardline/rust/__init__.py new file mode 100644 index 00000000..a09515fb --- /dev/null +++ b/src/wardline/rust/__init__.py @@ -0,0 +1,11 @@ +"""The opt-in Rust language frontend (preview, Tier-A: command-injection slice). + +Everything here is behind the ``wardline[rust]`` extra. The base package and the +``scanner`` extra never import this package, so they stay zero-dependency; +``tree_sitter`` / ``tree_sitter_rust`` are imported lazily through +``wardline.rust._tree_sitter.require_rust`` (mirrors ``loomweave.require_blake3``). +Importing ``wardline.rust`` and its submodules for type-checking or wiring does +not require the extra — only calling ``require_rust`` does. +""" + +from __future__ import annotations diff --git a/src/wardline/rust/_tree_sitter.py b/src/wardline/rust/_tree_sitter.py new file mode 100644 index 00000000..5ecec9ef --- /dev/null +++ b/src/wardline/rust/_tree_sitter.py @@ -0,0 +1,38 @@ +"""Lazy loader for the tree-sitter Rust toolchain (the ``wardline[rust]`` extra). + +Keeping the ``tree_sitter`` import inside ``require_rust`` — not at module top — +is what lets the rest of ``wardline.rust`` be imported for type-checking and +wiring without the extra installed, exactly like ``loomweave.require_blake3``. +``RustToolingError`` is re-exported here (its canonical home is the error +hierarchy in ``wardline.core.errors``) so callers import the loader and its +failure mode from one place. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wardline.core.errors import RustToolingError + +if TYPE_CHECKING: + from tree_sitter import Language, Parser + +__all__ = ["RustToolingError", "require_rust"] + + +def require_rust() -> tuple[Language, Parser]: + """Return a ``(Language, Parser)`` pair ready to parse ``.rs`` bytes. + + A fresh ``Parser`` is returned on each call so no parser state leaks between + callers; the grammar ``Language`` is cheap to re-wrap. Raises + ``RustToolingError`` with an install hint if the extra is not present. + """ + try: + from tree_sitter import Language, Parser + from tree_sitter_rust import language as _rust_language + except ModuleNotFoundError as exc: + raise RustToolingError( + "the Rust frontend needs tree-sitter — install it with: pip install 'wardline[rust]'" + ) from exc + grammar = Language(_rust_language()) + return grammar, Parser(grammar) diff --git a/src/wardline/rust/analyzer.py b/src/wardline/rust/analyzer.py new file mode 100644 index 00000000..3490ef14 --- /dev/null +++ b/src/wardline/rust/analyzer.py @@ -0,0 +1,365 @@ +"""WP5/WP6: the Rust analyzer — index + provider + dataflow + rules, one tree/nmap. + +``analyze_source`` parses ONCE, mints ONE ``NodeIdMap``, and threads it through entity +indexing, per-function trust seeding (the ``@trusted`` provider), builder-dataflow, and the +verdict rules — so callgraph/dataflow/rule passes share the single keying authority (spec +§5; a re-parse would mint divergent NodeIds and fail quietly). + +WP6 adds ``analyze(files, config, *, root)`` — the engine ``Analyzer`` protocol method +``run_scan`` drives under ``--lang rust``. It discovers the tree's Cargo crate roots ONCE +(SP2, ``wardline.rust.crate_roots`` — the loomweave-oracle-mirroring whole-tree pass), +routes each ``.rs`` file to its real crate-prefixed module (``_module_for``), runs the +per-file pipeline, and surfaces a gate-eligible ``WLN-ENGINE-PARSE-ERROR`` defect for +any file tree-sitter cannot fully parse (then contributes no findings for it — never +half-analyze). + +``last_context`` is the engine-shaped ``AnalysisContext | None`` (None in slice-1: the +Rust-native context is incompatible with the delta/SARIF consumers). The Rust-native +context is exposed separately as ``last_rust_context``. +""" + +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING + +from wardline.core.finding import ENGINE_PATH, Finding, Kind, Location, Severity +from wardline.core.taints import TaintState +from wardline.rust import qualname as q +from wardline.rust.context import RustAnalysisContext, RustTriggerContext +from wardline.rust.crate_roots import CrateRoots, discover_crate_roots +from wardline.rust.dataflow import analyze_command_dataflow +from wardline.rust.index import index_entities +from wardline.rust.mounts import MountOverlay, build_mount_overlay +from wardline.rust.nodeid import mint_node_ids +from wardline.rust.parse import has_errors, parse_rust +from wardline.rust.provider import RustTrustProvider +from wardline.rust.rules import RustProgramInjectionRule, RustShellInjectionRule + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from tree_sitter import Tree + + from wardline.core.config import WardlineConfig + from wardline.scanner.context import AnalysisContext + +__all__ = ["RustAnalyzer"] + +_FAIL_CLOSED = TaintState.UNKNOWN_RAW # an unmarked fn declares no trust -> findings suppressed + + +def _fp(*parts: str) -> str: + digest = hashlib.sha256() + digest.update("\x00".join(parts).encode("utf-8")) + return digest.hexdigest() + + +class RustAnalyzer: + """Slice-1 Rust analyzer. Holds the rule set and the last computed contexts.""" + + def __init__(self) -> None: + self._provider = RustTrustProvider() + self._rules = (RustProgramInjectionRule(), RustShellInjectionRule()) + self._last_rust_context: RustAnalysisContext | None = None + + @property + def last_context(self) -> AnalysisContext | None: + """The engine-shaped context for ``run_scan``/SARIF. Always None in slice-1. + + The Rust-native ``RustAnalysisContext`` is NOT an ``AnalysisContext`` (no + ``project_edges``, different field shape), so returning it would crash the + delta-scope and SARIF code-flow consumers and fail the protocol's mypy floor. + The delta path degrades correctly to file-level scoping when this is None. + """ + return None + + @property + def last_rust_context(self) -> RustAnalysisContext | None: + """The Rust-native whole-source view of the most recent ``analyze_source`` / + per-file ``analyze`` pass — for introspection, not consumed by ``run_scan``.""" + return self._last_rust_context + + def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) -> Sequence[Finding]: + """Engine ``Analyzer`` protocol: scan each ``.rs`` file under ``root``. + + ``config`` is accepted for protocol parity but unused in slice-1 (the Rust rules + carry hardcoded base severities; ``weft.toml`` severity overrides are a preview + gap, surfaced in the docs). A file that does not fully parse yields a + gate-eligible ``WLN-ENGINE-PARSE-ERROR`` defect and no ``RS-WL-*`` findings. + """ + resolved_root = root.resolve() + # SP2 whole-tree pass: discover Cargo crate roots ONCE per scan; every file's + # module route resolves against this map (longest-prefix, symlink-safe walk). + crate_roots = discover_crate_roots(resolved_root) + # ADR-049 Amendment 8 pre-pass: read every file ONCE, then build each crate's + # #[path] mount overlay from its scanned in-src sources — class-1 module routes + # resolve mount-first (logical_module_path), filesystem-fallback otherwise. + sources: dict[Path, str] = {} + read_errors: dict[Path, str] = {} + for file in files: + try: + sources[file] = file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + read_errors[file] = str(exc) + overlays = _build_overlays(sources, resolved_root, crate_roots) + findings: list[Finding] = [] + functions_total = 0 + functions_declared = 0 + files_analyzed = 0 + for file in files: + relpath = _relpath(file, resolved_root) + if file in read_errors: + findings.append(_parse_error_finding(relpath, read_errors[file])) + continue + source = sources[file] + tree = parse_rust(source) + if has_errors(tree): + findings.append(_parse_error_finding(relpath, "tree-sitter recovered from a syntax error")) + continue + module = _module_for(file, resolved_root, crate_roots, overlays) + try: + file_findings, context, file_callables = self._analyze_tree(tree, module=module, path=relpath) + except Exception as exc: # noqa: BLE001 — per-file isolation, see below + # One pathological file (e.g. a RecursionError on a deeply-nested expression) + # must not abort the whole scan and lose every other file's findings. Mirror + # the Python engine's per-function isolation: degrade to a counted diagnostic + # DEFECT (WLN-ENGINE-FILE-FAILED ∈ UNANALYZED_RULE_IDS) and keep scanning. + findings.append(_file_failed_finding(relpath, f"{type(exc).__name__}: {exc}")) + continue + self._last_rust_context = context + files_analyzed += 1 + # The coverage METRIC counts CALLABLES only — the emission carries the full + # ten-kind surface (Phase 1b), but the trust-surface denominator is still + # "functions that could have declared @trusted". `_analyze_tree` counts them + # over the entity LIST (never the context mapping, which dict-ification + # could collapse). + functions_total += file_callables + # Declared = seeded from a `/// @trusted` marker (tier is a real trust level, not + # the fail-closed default). This is the trust SURFACE — the denominator that stops + # a default-clean scan over an un-annotated repo from reading as a clean PASS. + functions_declared += sum(1 for tier in context.project_taints.values() if tier is not _FAIL_CLOSED) + findings.extend(file_findings) + findings.append(_coverage_finding(functions_total, functions_declared, files_analyzed)) + return findings + + def analyze_source(self, source: str, *, module: str, path: str = "") -> list[Finding]: + """Analyze a single in-memory source string (the WP5 rule-test entry).""" + tree = parse_rust(source) + findings, context, _ = self._analyze_tree(tree, module=module, path=path) + self._last_rust_context = context + return findings + + def _analyze_tree(self, tree: Tree, *, module: str, path: str) -> tuple[list[Finding], RustAnalysisContext, int]: + """Run the per-file pipeline; returns ``(findings, context, callables_total)``. + + ``callables_total`` (the coverage-metric denominator) is counted over the + entity LIST, before dict-ification — the context mapping is keyed and a + pathological duplicate could collapse there. + """ + nmap = mint_node_ids(tree) + entities = index_entities(tree, nmap, module=module, path=path) + callables = [e for e in entities if e.kind in ("function", "method")] + + project_taints: dict[str, TaintState] = {} + triggers: list[RustTriggerContext] = [] + for entity in callables: + # Phase 1b: the index emits the full ten-kind surface; the taint path + # judges CALLABLES only (a module/struct/const has no body to seed or + # walk — feeding one to taint_for/dataflow would be a category error). + try: + seed = self._provider.taint_for(entity.node) + except ValueError: + # A typo'd @trusted marker must not abort the scan: fail closed for this fn + # (its findings suppressed). NOTE: a typo is currently swallowed silently — + # surfacing it as an operator-visible diagnostic FACT is tracked backlog + # (rust-bug-hunt-2026-06-09), not yet built. + seed = None + tier = seed.body_taint if seed is not None else _FAIL_CLOSED + project_taints[entity.qualname] = tier + body = entity.node.child_by_field_name("body") + if body is None: + continue + for trig in analyze_command_dataflow(body, nmap): + triggers.append( + RustTriggerContext( + trigger=trig, + qualname=entity.qualname, + tier=tier, + path=path, + # The entity's OWN anchors — the rules fold trigger positions into + # the fingerprint entity-relative (wlfp2 move-stability), so the + # containing fn's line/NodeId travel with each trigger. + entity_line_start=entity.location.line_start or 0, + entity_node_id=entity.node_id, + ) + ) + + context = RustAnalysisContext( + triggers=tuple(triggers), + project_taints=project_taints, + # Keyed by the kind-disambiguated FEDERATION id (`rust:{kind}:{qualname}`, + # semantic `method` mapped to id-kind `function` by entity_id itself) — a + # qualname-only key would silently drop one of `fn S` / `struct S`, whose + # qualnames legitimately collide (the per-kind twin counter never suffixes + # ACROSS kinds; the id's kind segment is what separates them). + entities={q.entity_id(e.kind, e.qualname): e for e in entities}, + ) + findings: list[Finding] = [] + for rule in self._rules: + findings.extend(rule.check(context)) + return findings, context, len(callables) + + +def _relpath(file: Path, resolved_root: Path) -> str: + resolved = file.resolve() + if resolved.is_relative_to(resolved_root): + return resolved.relative_to(resolved_root).as_posix() + return resolved.as_posix() + + +def _build_overlays(sources: dict[Path, str], resolved_root: Path, roots: CrateRoots) -> dict[Path, MountOverlay]: + """One ``#[path]`` mount overlay per crate (ADR-049 Amendment 8), discovered over + the scanned IN-SRC sources of that crate (class-2/3 files keep their ``#out`` + non-conformance routes — a mount declared outside ``src/`` is outside loomweave's + emittable scope and never overlays a class-1 route). Paths are project-root-relative + posix, matching the R5 sort rule ("declaring-file path relative to the project + root"). A mount declared in a file outside the scan list is invisible — the overlay + is the view of the scanned tree.""" + per_crate: dict[Path, tuple[str, dict[str, str]]] = {} + for file, source in sources.items(): + resolved = file.resolve() + crate_dir = roots.crate_dir_for(resolved) + crate_name = roots.crate_name_for(resolved) + if crate_dir is None or crate_name is None or not resolved.is_relative_to(crate_dir / "src"): + continue + if not resolved.is_relative_to(resolved_root): + continue # defensive: discover confines to root + per_crate.setdefault(crate_dir, (crate_name, {}))[1][resolved.relative_to(resolved_root).as_posix()] = source + return { + crate_dir: build_mount_overlay( + crate_sources, + crate=crate_name, + src_root=(crate_dir / "src").relative_to(resolved_root).as_posix(), + ) + for crate_dir, (crate_name, crate_sources) in per_crate.items() + } + + +def _module_for(file: Path, resolved_root: Path, roots: CrateRoots, overlays: dict[Path, MountOverlay]) -> str: + """The SP2 module route. Three file classes: + + 1. **In-src** (under a crate root's ``src/``): the ADR-049 oracle route — the + crate's ``#[path]`` mount overlay first (Amendment 8, + ``MountOverlay.logical_module_path``), whose default for an un-mounted file is + the unchanged pure-filesystem + ``rust_module_route(crate=, src_root=/src, file)``. + Conformance-bearing: byte-identical to loomweave's emission for the same file. + 2. **Under a crate root but OUTSIDE its src/** (``tests/``, ``benches/``, + ``build.rs``, ...): ``{crate}.#out.{}``. Loomweave's + ``emittable_scope`` emits NOTHING for these files, so this qualname carries no + cross-tool conformance claim; the reserved ``#out`` segment is structurally + impossible in loomweave's locator grammar (``#`` appears only inside + ``impl#<...>`` discriminators), so a class-2 route can never collide with a + class-1/loomweave locator (e.g. ``/tests/integration.rs`` vs + ``/src/tests/integration.rs`` -> ``rust_app.#out.tests.integration`` + vs ``rust_app.tests.integration``). Wardline scans these files anyway — + coverage is never narrowed to the entity surface. + 3. **Under no crate root** (a bare no-Cargo tree): the crate segment is the + CONSTANT ``"crate"`` (cargo forbids the keyword ``crate`` as a package name, + so it cannot collide with a class-1 crate) — route = + ``crate.#out.{}``. + Relpath-pure and scan-root-name-INDEPENDENT: renaming the scan-root + directory does not rekey fingerprints (e.g. ``bin/app.rs`` -> + ``crate.#out.bin.app`` whatever the root is called). Same + no-conformance-claim disclaimer as class 2. + """ + resolved = file.resolve() + crate_dir = roots.crate_dir_for(resolved) + crate_name = roots.crate_name_for(resolved) + if crate_dir is not None and crate_name is not None: + src_root = crate_dir / "src" + if resolved.is_relative_to(src_root): + overlay = overlays.get(crate_dir) + if overlay is not None and resolved.is_relative_to(resolved_root): + return overlay.logical_module_path(resolved.relative_to(resolved_root).as_posix()) + # No overlay built for this crate (defensive): the filesystem default. + return q.rust_module_route(crate=crate_name, src_root=str(src_root), file=str(resolved)) + return _out_route(crate_name, crate_dir, resolved) # class 2 + try: + return _out_route("crate", resolved_root, resolved) # class 3 + except ValueError: + # file outside root (should not happen — discover confines to root); degrade to crate. + return "crate" + + +def _out_route(crate: str, base: Path, file: Path) -> str: + """The class-2/3 non-conformance route: ``{crate}.#out.{relpath stems}``. + + Mechanical and relpath-pure: every path segment from ``base`` contributes its + LITERAL stem (only the final ``.rs`` is stripped — ``main``/``lib``/``mod`` are + NOT collapsed, unlike the ADR-049 in-src route, because there is no module tree + to mirror out here and literal stems keep distinct files distinct). The ``#out`` + segment brands the route as outside loomweave's emittable scope. + """ + rel = file.relative_to(base) + segments = [*rel.parts[:-1], file.stem] + return ".".join([crate, "#out", *segments]) + + +def _parse_error_finding(relpath: str, detail: str) -> Finding: + # Reuse the engine's parse-error rule id so it counts toward ScanSummary.unanalyzed + # while also tripping the default ERROR gate: unscanned code must not read green. + return _engine_defect("WLN-ENGINE-PARSE-ERROR", f"{relpath}: could not parse Rust source ({detail})", relpath) + + +def _file_failed_finding(relpath: str, detail: str) -> Finding: + # Analysis raised AFTER a clean parse — a per-file under-scan, counted toward unanalyzed. + return _engine_defect("WLN-ENGINE-FILE-FAILED", f"{relpath}: Rust analysis failed ({detail})", relpath) + + +def _coverage_finding(functions_total: int, functions_declared: int, files_analyzed: int) -> Finding: + """A whole-scan METRIC reporting the Rust trust-surface coverage. + + Rust analysis is default-clean: an un-``@trusted`` function modulates its findings to + NONE. So a scan over a repo with zero markers is *vacuously* green — ``0 active`` with + nothing actually in the trust surface. This FACT exposes ``functions_declared`` over + ``functions_total`` so that clean-because-analyzed-and-safe is distinguishable from + clean-because-nothing-was-analyzable (the anti-false-green posture the CLI surfaces). + The fingerprint is keyed on metric IDENTITY (fixed); the values drift per run. + """ + message = ( + f"Rust trust-surface coverage: {functions_declared} of {functions_total} function(s) " + f"declared @trusted across {files_analyzed} analyzed file(s)" + ) + return Finding( + rule_id="WLN-RUST-COVERAGE", + message=message, + severity=Severity.NONE, + kind=Kind.METRIC, + location=Location(path=ENGINE_PATH), + fingerprint=_fp("WLN-RUST-COVERAGE", ENGINE_PATH), + properties={ + "functions_total": functions_total, + "functions_declared": functions_declared, + "files_analyzed": files_analyzed, + "lang": "rust", + }, + ) + + +def _engine_defect(rule_id: str, message: str, relpath: str) -> Finding: + return Finding( + rule_id=rule_id, + message=message, + severity=Severity.ERROR, + kind=Kind.DEFECT, + # File-level under-scan defects need a concrete source anchor. A lineless, + # non-ENGINE_PATH DEFECT is intentionally downgraded before gate evaluation + # to avoid unsafe fingerprint joins, so use the stable file start. + location=Location(path=relpath, line_start=1, line_end=1), + fingerprint=_fp(rule_id, relpath), + properties={"lang": "rust"}, + ) diff --git a/src/wardline/rust/context.py b/src/wardline/rust/context.py new file mode 100644 index 00000000..7e35dc95 --- /dev/null +++ b/src/wardline/rust/context.py @@ -0,0 +1,65 @@ +"""WP5: the minimal analysis context the Rust rules judge. + +``RustAnalysisContext`` carries what a ``RustRule`` needs — the reconstructed command +triggers (each tagged with its containing fn's qualname and resolved trust tier) plus the +``project_taints`` map (qualname -> body taint) the analyzer also exposes as ``last_context`` +for ``run_scan``'s delta/ScanResult path. The rules are deliberately a SEPARATE protocol +from the Python ``Rule``/``AnalysisContext`` (they never plug into the Python ``RuleRegistry``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from wardline.core.finding import Finding + from wardline.core.node_id import NodeId + from wardline.core.taints import TaintState + from wardline.rust.dataflow import CommandTrigger + from wardline.rust.index import RustEntity + +__all__ = ["RustAnalysisContext", "RustRule", "RustTriggerContext"] + + +@dataclass(frozen=True, slots=True) +class RustTriggerContext: + """One command trigger bound to its containing function's identity and trust tier. + + ``entity_line_start`` / ``entity_node_id`` are the containing fn's own anchors + (``RustEntity.location.line_start`` / ``RustEntity.node_id``). The rules fold the + trigger's position into the fingerprint as DELTAS against them (wlfp2 + move-stability: an edit ABOVE the entity shifts every absolute line and pre-order + NodeId below it, but the entity-relative offsets are invariant).""" + + trigger: CommandTrigger + qualname: str # the containing fn (finding qualname + fingerprint key) + tier: TaintState # the containing fn's body taint — modulates rule severity + path: str # the source file path (Location + fingerprint key) + entity_line_start: int # the containing fn's first line (entity-relative line anchor) + entity_node_id: NodeId # the containing fn's pre-order NodeId (entity-relative NodeId anchor) + + +@dataclass(frozen=True, slots=True) +class RustAnalysisContext: + """The whole-source view a rule pass consumes.""" + + triggers: Sequence[RustTriggerContext] + project_taints: Mapping[str, TaintState] # qualname -> body taint + # Keyed by the kind-disambiguated federation entity id (`rust:{kind}:{qualname}`, + # qualname.entity_id — semantic `method` maps to id-kind `function`). NOT keyed by + # bare qualname: `fn S` and `struct S` legitimately share one (the per-kind twin + # counter never suffixes across kinds), and a qualname key would drop one of them. + entities: Mapping[str, RustEntity] + + +@runtime_checkable +class RustRule(Protocol): + """A Rust verdict rule. NOT the Python ``Rule`` protocol and NOT registered in the + Python ``RuleRegistry`` — it consumes a ``RustAnalysisContext``, not an ``AnalysisContext``.""" + + rule_id: str + + def check(self, context: RustAnalysisContext) -> Sequence[Finding]: ... diff --git a/src/wardline/rust/crate_roots.py b/src/wardline/rust/crate_roots.py new file mode 100644 index 00000000..df08363d --- /dev/null +++ b/src/wardline/rust/crate_roots.py @@ -0,0 +1,131 @@ +"""SP2 crate-root discovery — ``Cargo.toml [package].name`` as the crate name. + +Mirrors the loomweave oracle (``crates/loomweave-plugin-rust/src/crate_roots.rs``) +exactly; that source is the contract for behaviors the corpus does not pin: + +* **Manifest read:** a real TOML parse (stdlib ``tomllib``, mirroring the oracle's + ``toml::Value`` — ADR-049's "read as text" means *not cargo-metadata*, not a + hand-rolled scan). ``[package].name`` is taken only if the manifest parses AND + the name is a string: ``name.workspace = true`` parses as a table and falls + through; unparseable TOML falls through. +* **Two-branch registration:** a dir is a crate root iff (a) its manifest yields a + string ``[package].name`` -> that name ``-``->``_`` normalised; ELSE (b) + ``src/lib.rs`` or ``src/main.rs`` exists -> the directory name normalised. A + virtual workspace root (neither) registers NOTHING — member crates own their + files outright. +* **Walk:** symlinked directories are never followed (an out-of-tree escape would + register an outside crate; a cycle would re-register through an alias); an entry + whose type cannot be determined is not recursed into (can-not-determine => + do-not-recurse). Vendored/build/store dirs the host also skips are skipped. +* **Lookup:** file -> owning crate by longest directory-prefix match. + +SCAN-COVERAGE NOTE (the distinction from loomweave's ``scope.rs emittable_scope``): +loomweave additionally EXCLUDES out-of-src files (``tests/``, ``benches/``, +``examples/``, ``build.rs``), a ``src/main.rs`` shadowed by a sibling ``lib.rs``, +and files under no crate root — it emits no federation entity for them. That is +its *entity surface*, not a scan filter: wardline keeps scanning ALL discovered +``.rs`` files. Files outside any crate's ``src/`` tree get a wardline-local +``#out``-branded module route (see ``analyzer._module_for``: class 2 = +``{crate}.#out.{...}``, class 3 = ``crate.#out.{...}`` with the constant crate +segment) whose qualnames carry **no cross-tool conformance claim**. The reserved +``#out`` segment is structurally impossible in loomweave's locator grammar (``#`` +appears only inside ``impl#<...>`` discriminators) and cargo forbids the keyword +``crate`` as a package name, so neither route can collide with a class-1 / +loomweave locator. +""" + +from __future__ import annotations + +import os +import tomllib +from pathlib import Path + +__all__ = ["CrateRoots", "discover_crate_roots"] + +# Vendored / build / store directories the host also skips (oracle `is_ignored`). +_IGNORED_DIRS = frozenset({"target", ".git", ".weft", "node_modules"}) + + +def _normalise(name: str) -> str: + """Underscore a crate name the way Rust does (``a-b`` -> ``a_b``).""" + return name.replace("-", "_") + + +class CrateRoots: + """Crate roots discovered under a project root: each crate's root directory + mapped to its (underscored) crate name, longest-prefix matched.""" + + def __init__(self, roots: dict[Path, str]) -> None: + # Sorted by path so longest-prefix lookup is deterministic (oracle: BTreeMap). + self._roots: list[tuple[Path, str]] = sorted(roots.items()) + + def crate_name_for(self, file: Path) -> str | None: + """The crate name owning ``file``, by longest directory-prefix match.""" + owner = self._owning_root(file) + return owner[1] if owner is not None else None + + def crate_dir_for(self, file: Path) -> Path | None: + """The crate root directory owning ``file`` (the dir holding ``Cargo.toml`` + / ``src/``), by the same longest-prefix match as ``crate_name_for``. Join + ``src`` onto this to get the source root for ``rust_module_route``.""" + owner = self._owning_root(file) + return owner[0] if owner is not None else None + + def _owning_root(self, file: Path) -> tuple[Path, str] | None: + candidates = [(d, n) for d, n in self._roots if file.is_relative_to(d)] + if not candidates: + return None + return max(candidates, key=lambda item: len(str(item[0]))) + + +def discover_crate_roots(project_root: Path) -> CrateRoots: + """Walk ``project_root`` and discover every crate root directory and its + (underscored) crate name (oracle ``discover_crate_roots``).""" + roots: dict[Path, str] = {} + _visit(project_root, roots) + return CrateRoots(roots) + + +def _package_name(manifest: Path) -> str | None: + """``[package].name`` iff ``manifest`` parses as TOML and the name is a string. + + ``name.workspace = true`` parses as a TABLE -> ``None`` (falls through to the + dir-name branch); unparseable/unreadable TOML -> ``None`` likewise. + """ + try: + with manifest.open("rb") as fh: + value = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError): + return None + package = value.get("package") + if not isinstance(package, dict): + return None + name = package.get("name") + return name if isinstance(name, str) else None + + +def _visit(directory: Path, out: dict[Path, str]) -> None: + # Mirror the oracle's order: an unreadable dir registers nothing and is not walked. + try: + entries = list(os.scandir(directory)) + except OSError: + return + cargo = directory / "Cargo.toml" + name = _package_name(cargo) if cargo.is_file() else None + if name is not None: + out[directory] = _normalise(name) + elif ((directory / "src" / "lib.rs").is_file() or (directory / "src" / "main.rs").is_file()) and directory.name: + out.setdefault(directory, _normalise(directory.name)) + for entry in entries: + # Do NOT follow symlinked directories (oracle crate_roots.rs:83-94): a + # symlinked dir is an out-of-tree escape or a cycle. `entry.is_symlink()` + # reports the link itself; on an OSError we must not fall through to a + # follow-links check — can-not-determine => do-not-recurse. + try: + if entry.is_symlink() or not entry.is_dir(follow_symlinks=False): + continue + except OSError: + continue + if entry.name in _IGNORED_DIRS: + continue + _visit(Path(entry.path), out) diff --git a/src/wardline/rust/dataflow.py b/src/wardline/rust/dataflow.py new file mode 100644 index 00000000..60b13377 --- /dev/null +++ b/src/wardline/rust/dataflow.py @@ -0,0 +1,306 @@ +"""WP4: builder-dataflow L2 for ``std::process::Command`` (the genuinely-new core). + +Walks one function body's top-level statements and reconstructs each ``Command`` +invocation — both the stepwise form (``let mut c = Command::new(p); c.arg(a); c.output();``) +and the fluent chain (``Command::new(p).arg(a).output()``) — producing a ``CommandTrigger`` +per terminal (``.output()``/``.spawn()``/``.status()``) that the rules (WP5) judge. + +Taint model (slice-1, Tier-A): taint flows ONLY from known vocabulary sources and from +locals proven tainted by a prior ``let`` — default-clean, because a finding-producer +flags *provable* taint, not fail-closed unknowns (that would flood FPs). ``format!`` +contributes the worst taint of its **direct interpolation-arg tokens** only (the captured +``{x}`` form carries no arg token → a documented FN); ``.args`` is an opaque vec; a +sanitizer is invisible (an accepted bounded FP). Intra-function, single-block — nested +control flow is a documented limitation. tree-sitter types are TYPE_CHECKING-only. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from wardline.core.node_id import NodeId +from wardline.core.taints import TaintState, least_trusted +from wardline.rust.vocabulary import load_rust_taint + +if TYPE_CHECKING: + from tree_sitter import Node + + from wardline.rust.nodeid import NodeIdMap + +__all__ = ["CommandTrigger", "analyze_command_dataflow"] + +_TERMINALS = frozenset({"output", "spawn", "status"}) +# Shell command-string flags, compared case-folded: sh/bash -c, cmd /C, powershell -Command. +_SHELL_FLAGS = frozenset({"-c", "/c", "-command"}) +# Expression wrappers that sit between a statement/tail position and the call beneath — +# the dominant `Command::new(x).output()?` / `.await` / `return ...` idioms. Peeled so the +# command beneath is not silently invisible. +_WRAPPERS = frozenset({"try_expression", "await_expression", "return_expression"}) +_CLEAN = TaintState.ASSURED # the default "not proven tainted" tier (not in RAW_ZONE) +# Format-family macros whose value-taint = worst over their direct interpolation-arg tokens. +# `write!`/`writeln!` take a leading WRITER (the destination) before the format string — it is +# NOT a value-taint contributor, so it is dropped. `format!`/`format_args!` have no writer. +_FORMAT_MACROS = frozenset({"format", "write", "writeln", "format_args"}) +_WRITER_MACROS = frozenset({"write", "writeln"}) + + +@dataclass(frozen=True, slots=True) +class CommandTrigger: + """One terminal ``Command`` invocation's reconstructed state, for the rules to judge.""" + + trigger_node_id: NodeId # the .output()/.spawn()/.status() call node (anchor + fingerprint) + trigger_line: int + constructor_line: int # the Command::new(...) line — RS-WL-108 cites both + program_literal: str | None # "sh" for Command::new("sh"); None if the program is non-literal + program_taint: TaintState # taint of the Command::new(...) program argument + shell_flag_seen: bool # a literal "-c"/"/C" arg is present + arg_taints: tuple[tuple[NodeId, TaintState], ...] # (.arg node, its taint) for every .arg(...) + + +@dataclass(slots=True) +class _CmdAccum: + program_literal: str | None + program_taint: TaintState + constructor_line: int + shell_flag_seen: bool = False + arg_taints: list[tuple[NodeId, TaintState]] = field(default_factory=list) + + +def analyze_command_dataflow(fn_body: Node, nmap: NodeIdMap) -> list[CommandTrigger]: + """Reconstruct every ``Command`` invocation in ``fn_body`` (a ``block`` node).""" + return _Analyzer(nmap).run(fn_body) + + +class _Analyzer: + def __init__(self, nmap: NodeIdMap) -> None: + self._nmap = nmap + tables = load_rust_taint() + # Key sources/sinks on the CRATE-QUALIFIED full path (`std::env::var`, + # `std::process::Command::new`). Matching is crate-consistent (see `_call_matches`): + # the declared crate is part of the key, not discarded — so a foreign crate's like-named + # symbol (`mycrate::Command::new`, `myconfig::env::var`) cannot match the std entry. + self._sources: dict[str, TaintState] = { + f"{crate}::{path}": src.returns_taint for (crate, path), src in tables.sources.items() + } + self._command_fullpaths: set[str] = { + f"{crate}::{path}" for (crate, path), sink in tables.sinks.items() if sink.sink_kind == "command" + } + self._local_taints: dict[str, TaintState] = {} + self._commands: dict[str, _CmdAccum] = {} + self._triggers: list[CommandTrigger] = [] + + def run(self, fn_body: Node) -> list[CommandTrigger]: + for stmt in fn_body.named_children: + if stmt.type == "let_declaration": + self._let(stmt) + continue + # an expression in statement position, OR the block's tail expression (no `;`), + # under any number of try/await/return wrappers. + expr = stmt.named_children[0] if stmt.type == "expression_statement" and stmt.named_children else stmt + if expr.type == "assignment_expression": + # A re-assignment (`cmd = ...;`) re-binds the name exactly as a shadowing `let` + # does, so it MUST clear/replace the tracked builder too — otherwise the stale + # `_CmdAccum` survives and a later `cmd.output()` reconstructs a phantom trigger + # carrying the dead constructor's taint (a false RS-WL-108 at the gating severity). + self._bind(_name_of(expr.child_by_field_name("left")), expr.child_by_field_name("right")) + continue + call = _unwrap_to_call(expr) + if call is not None: + self._try_command_chain(call, bound_name=None) + return self._triggers + + def _let(self, let_node: Node) -> None: + self._bind(_name_of(let_node.child_by_field_name("pattern")), let_node.child_by_field_name("value")) + + def _bind(self, name: str | None, value: Node | None) -> None: + """(Re)bind ``name`` to ``value`` — the shared core of ``let`` and assignment. + + A fresh binding to a tracked name clears BOTH its prior taint and any stale Command + builder; if the new value is itself a builder, ``_try_command_chain`` re-adds it. This + symmetry is what keeps a shadowing/reassignment from stranding a dead constructor.""" + if value is None: + return + if name is not None: + self._local_taints.pop(name, None) + self._commands.pop(name, None) + call = _unwrap_to_call(value) + if call is not None and self._try_command_chain(call, bound_name=name): + return # a Command builder bound to `name` (or terminated inline) + if name is not None: + taint = self._expr_taint(value) # taint over the ORIGINAL value (wrappers and all) + if taint != _CLEAN: # record only proven taint + self._local_taints[name] = taint + + def _try_command_chain(self, call_node: Node, *, bound_name: str | None) -> bool: + """Process a (possible) Command builder chain. Returns True if it was one.""" + base, steps = _unwind(call_node) + if base.type == "call_expression" and self._is_command_new(base): + accum = self._accum_from_new(base) + self._apply_steps(accum, steps) + if bound_name is not None and not any(m in _TERMINALS for m, _ in steps): + self._commands[bound_name] = accum # a live builder bound to a local + return True + if base.type == "identifier": + tracked = self._commands.get(_text(base)) + if tracked is None: + return False # not a tracked command local + self._apply_steps(tracked, steps) + return True + return False + + def _apply_steps(self, accum: _CmdAccum, steps: list[tuple[str, Node]]) -> None: + for method, call_node in steps: + if method == "arg": + arg = _first_arg(call_node) + if arg is not None: + if arg.type == "string_literal" and _string_value(arg).lower() in _SHELL_FLAGS: + accum.shell_flag_seen = True + accum.arg_taints.append((self._nmap.node_id(arg), self._expr_taint(arg))) + elif method == "args": + continue # an opaque vec — not introspected in slice 1 + elif method in _TERMINALS: + self._triggers.append( + CommandTrigger( + trigger_node_id=self._nmap.node_id(call_node), + trigger_line=call_node.start_point[0] + 1, + constructor_line=accum.constructor_line, + program_literal=accum.program_literal, + program_taint=accum.program_taint, + shell_flag_seen=accum.shell_flag_seen, + arg_taints=tuple(accum.arg_taints), + ) + ) + + def _accum_from_new(self, new_call: Node) -> _CmdAccum: + prog = _first_arg(new_call) + literal = _string_value(prog) if prog is not None and prog.type == "string_literal" else None + taint = self._expr_taint(prog) if prog is not None else _CLEAN + return _CmdAccum(literal, taint, new_call.start_point[0] + 1) + + def _is_command_new(self, call_node: Node) -> bool: + path = _call_function_path(call_node) + return path is not None and any(_call_matches(path, full) for full in self._command_fullpaths) + + def _expr_taint(self, node: Node) -> TaintState: + """The proven taint of an expression; default ``_CLEAN`` (taint flows only from + known sources / tainted locals). Combines over sub-expressions, so taint reached + through ``.unwrap()``, an unmodelled call, or indexing still propagates.""" + kind = node.type + if kind == "identifier": + return self._local_taints.get(_text(node), _CLEAN) + if kind == "string_literal": + return _CLEAN + if kind == "macro_invocation": + return self._format_taint(node) + if kind == "call_expression": + path = _call_function_path(node) + if path is not None: + for full_path, taint in self._sources.items(): + if _call_matches(path, full_path): + return taint + worst = _CLEAN + for child in node.named_children: + worst = least_trusted(worst, self._expr_taint(child)) + return worst + + def _format_taint(self, macro_node: Node) -> TaintState: + name = macro_node.child_by_field_name("macro") + if name is None or _text(name) not in _FORMAT_MACROS: + return _CLEAN # only the format-family macros are modelled in slice 1 + tree = next((c for c in macro_node.named_children if c.type == "token_tree"), None) + if tree is None: + return _CLEAN + children = tree.named_children + if _text(name) in _WRITER_MACROS and children: + # write!/writeln! lead with a WRITER (the destination) — drop it; only the + # subsequent format string + interpolation args contribute value-taint. A simple + # `dst` identifier writer is one named child; a compound writer (`&mut s`) may leave + # a stray token (a bounded slice-1 limitation, like the captured-`{x}` FN). + children = children[1:] + worst = _CLEAN + for child in children: + if child.type == "string_literal": + continue # the format string (and any literal arg) is clean + worst = least_trusted(worst, self._expr_taint(child)) + return worst + + +# --------------------------------------------------------------------------- # +# tree-sitter helpers +# --------------------------------------------------------------------------- # + + +def _unwrap_to_call(node: Node | None) -> Node | None: + """Peel ``try_expression``/``await_expression``/``return_expression`` wrappers to the + ``call_expression`` beneath, or ``None``. ``Command::new(x).output()?`` is the dominant + Rust spawn idiom; without this the whole invocation is invisible to the rules.""" + depth = 0 + while node is not None and node.type in _WRAPPERS and depth < 8: + node = node.named_children[0] if node.named_children else None + depth += 1 + return node if node is not None and node.type == "call_expression" else None + + +def _unwind(call_node: Node) -> tuple[Node, list[tuple[str, Node]]]: + """Walk a method chain from the outer (terminal) call inward. Returns ``(base, steps)`` + where ``base`` is the chain root (a ``Command::new(...)`` call for a fluent chain, or an + identifier receiver for a stepwise ``c.arg(...)``), and ``steps`` is ``(method, call)`` + base→terminal.""" + steps: list[tuple[str, Node]] = [] + cur: Node | None = call_node + while cur is not None and cur.type == "call_expression": + fn = cur.child_by_field_name("function") + if fn is not None and fn.type == "field_expression": + method = fn.child_by_field_name("field") + steps.append((_text(method) if method is not None else "", cur)) + cur = fn.child_by_field_name("value") + else: + break # `cur` is the base call (e.g. Command::new(...)) + assert cur is not None + return cur, list(reversed(steps)) + + +def _call_function_path(call_node: Node) -> str | None: + """The ``::``-path of a call's function (``std::env::var``, ``Command::new``, + ``sanitize``), or ``None`` for a method call (``field_expression`` function).""" + fn = call_node.child_by_field_name("function") + if fn is not None and fn.type in ("scoped_identifier", "identifier"): + return _text(fn) + return None + + +def _call_matches(call_path: str, declared_full: str) -> bool: + """True iff ``call_path`` is a crate-consistent reference to the crate-qualified + ``declared_full`` path: a trailing segment-suffix of it, with at least two segments. + + ``std::process::Command::new`` is referenced as the fully-qualified path, as + ``process::Command::new`` (``use std::process``), or as bare ``Command::new`` + (``use std::process::Command``) — all trailing suffixes. A foreign-crate-rooted path + (``mycrate::Command::new``) is NOT a suffix of the std path, so it is rejected; the + two-segment floor stops a bare one-segment name (``new``/``var``) from matching loosely. + The single irreducible residue is a bare two-segment name re-imported from a *different* + crate (``use other::Command; Command::new``) — unresolvable without ``use``-resolution (SP2).""" + if "::" not in call_path: + return False + return call_path == declared_full or declared_full.endswith("::" + call_path) + + +def _first_arg(call_node: Node) -> Node | None: + args = call_node.child_by_field_name("arguments") + if args is None: + return None + return args.named_children[0] if args.named_children else None + + +def _string_value(node: Node) -> str: + content = next((c for c in node.named_children if c.type == "string_content"), None) + return _text(content) if content is not None else "" + + +def _name_of(node: Node | None) -> str | None: + return _text(node) if node is not None and node.type == "identifier" else None + + +def _text(node: Node) -> str: + return node.text.decode("utf-8") if node.text is not None else "" diff --git a/src/wardline/rust/edges.py b/src/wardline/rust/edges.py new file mode 100644 index 00000000..13586a9a --- /dev/null +++ b/src/wardline/rust/edges.py @@ -0,0 +1,366 @@ +"""Anchored Rust edges — ``imports`` + ``implements`` (changeset §6, Phase 1b). + +The shared conformance corpus is entity-only, so the contract here is changeset +``docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md`` §6 plus +the loomweave oracle source for what §6 leaves open +(``crates/loomweave-plugin-rust/src/{resolve.rs,extract.rs}``): + +* Both edge kinds are **anchored** (carry the source byte span) and therefore never + ``inferred`` confidence (ADR-026 decision 3); both are **resolved-or-dropped** — an + external or unresolvable target yields NO edge, never a dangling one (D1). +* ``imports``: one per module-scope ``use`` leaf. ``from_id`` is the ENCLOSING module + entity (a file-scope ``use`` → the file module; a ``use`` inside an inline ``mod`` → + that mod's entity; a fn-body ``use`` is not a module property and emits nothing). + Use-tree groups fan out, ``as`` aliases resolve the REAL imported path, a ``self`` + group leaf names the prefix module (extract.rs ``collect_use_leaves``). A glob + ``use a::*`` over an in-project module → ``ambiguous`` to that module entity, else + dropped (resolve.rs ``resolve_use_path``). Span = the whole ``use`` statement. +* ``implements``: one per trait-impl ENTITY whose trait resolves in-project — + merged same-key twin blocks share one impl entity and so emit exactly ONE edge + (extract.rs ``seen_impl_ids``). Trait lookup STRIPS generic args + (``trait_path_for_lookup``); a negative impl (``impl !Tr for Foo``) asserts + NON-implementation and emits nothing. Span = the implemented-trait path node only. +* Resolution (resolve.rs ``resolve_non_glob``/``resolve_ids``): attempt 1 looks the + normalized dotted path up as-is; attempt 2 (ONLY when attempt 1 found nothing AND + the original path is a BARE single segment) retries crate-root-relative — the + bare-segment gate is the H5 guard: a multi-segment miss (``serde::Serialize``) + stays dropped, never re-prefixed. 0 candidates → drop; exactly 1 → ``resolved``; + >1 (a legal value/type-namespace qualname collision) → ``ambiguous`` with ``to_id`` + = FIRST id by sorted order (deterministic, never null). +* ``crate::``/``self::``/``super::`` resolve against the module routes. Upstream 1b + maps ``self`` to the crate root and DEFERS ``super::`` to External because it does + not thread the defining module through (resolve.rs ``normalize_path``); wardline + DOES thread the enclosing module (it is the imports ``from_id``), so ``self::`` is + module-relative and ``super::`` pops module segments — the semantics that same + oracle comment names as correct (``super::a::S`` from ``c.m.n`` means ``c.m.a.S``). + A ``super::`` walking above the crate root drops. + +WIRING NOTE: edges are deliberately NOT in the analyzer/scan output surface yet. +The identity corpus (``tests/golden/identity/rust/``) captures them by calling +``index_rust_file`` + ``discover_rust_edges`` directly; runtime/federation wiring +(emitting them over the Weft wire) is future work. + +tree-sitter types appear only under ``TYPE_CHECKING`` so importing this module never +pulls the ``wardline[rust]`` extra. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from wardline.rust import qualname as q +from wardline.rust.index import RustEntity, index_entities +from wardline.rust.nodeid import mint_node_ids +from wardline.rust.parse import parse_rust + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Sequence + + from tree_sitter import Node, Tree + +__all__ = ["RustEdge", "RustParsedFile", "discover_rust_edges", "index_rust_file"] + +Confidence = Literal["resolved", "ambiguous"] +_RESOLVED: Confidence = "resolved" +_AMBIGUOUS: Confidence = "ambiguous" + +# tree-sitter node types that form a `use`/trait path; comments may interpose inside +# a use_list and are token-stream-invisible to the oracle (syn drops them). +_COMMENT_TYPES = frozenset({"line_comment", "block_comment"}) + + +@dataclass(frozen=True, slots=True) +class RustEdge: + """One anchored edge (the §6 ``RawEdge`` wire shape). ``confidence`` is only ever + ``resolved`` or ``ambiguous`` — an anchored edge may never be ``inferred``.""" + + kind: Literal["imports", "implements"] + from_id: str + to_id: str + source_byte_start: int + source_byte_end: int + confidence: Confidence + + +@dataclass(frozen=True, slots=True) +class RustParsedFile: + """One file's parse products: the tree (alive — entities' nodes point into it), + its SP2 module route (``analyzer._module_for`` / ``rust_module_route``), and the + entities ``index_entities`` emitted for it. Build via :func:`index_rust_file` + (or assemble from an existing tree/entity pass — never re-parse).""" + + tree: Tree + module: str + entities: tuple[RustEntity, ...] + + +def index_rust_file(source: str, *, module: str, path: str = "") -> RustParsedFile: + """Parse + index one file into a :class:`RustParsedFile` (ONE parse, one + ``NodeIdMap`` — the standalone helper the identity-corpus capture calls so it + gets the tree AND the entities without re-parsing).""" + tree = parse_rust(source) + entities = index_entities(tree, mint_node_ids(tree), module=module, path=path) + return RustParsedFile(tree=tree, module=module, entities=tuple(entities)) + + +def discover_rust_edges(files: Sequence[RustParsedFile]) -> list[RustEdge]: + """Discover the anchored ``imports``/``implements`` edges of a whole-tree pass. + + ``files`` are the per-file parse products of the SAME scan (one entry per ``.rs`` + file, each already parsed + indexed — see :class:`RustParsedFile`). The whole-tree + symbol table is built from the UNION of every file's entities, so cross-file + ``use crate::…`` paths resolve against the real crate-prefixed routes. Returns + edges per file in input order (each file's imports in source order, then its + implements in entity order). Resolved-or-dropped throughout. + """ + table = _symbol_table(file_entities for f in files for file_entities in f.entities) + edges: list[RustEdge] = [] + for f in files: + from_crate = f.module.split(".", 1)[0] + modules_by_node = {e.node.id: e for e in f.entities if e.kind == "module"} + file_module = modules_by_node.get(f.tree.root_node.id) + if file_module is not None: + _imports_in_scope(f.tree.root_node.children, file_module, from_crate, modules_by_node, table, edges) + for entity in f.entities: + if entity.kind == "impl": + _implements_for(entity, f.module, from_crate, table, edges) + return edges + + +# --------------------------------------------------------------------------- # +# Symbol table + resolution (mirrors resolve.rs) +# --------------------------------------------------------------------------- # + + +def _symbol_table(entities: Iterable[RustEntity]) -> dict[str, list[str]]: + """qualname -> sorted entity ids (the resolver's reverse index). Two entities may + legally share a qualname across kinds (``fn S`` / ``struct S`` — the id's kind + segment separates them); the sorted id list is what makes the multi-kind + Ambiguous target deterministic (resolve.rs ``resolve_ids``: first by sorted order).""" + table: dict[str, set[str]] = {} + for entity in entities: + table.setdefault(entity.qualname, set()).add(q.entity_id(entity.kind, entity.qualname)) + return {qualname: sorted(ids) for qualname, ids in table.items()} + + +def _normalize_path(path: str, from_crate: str, enclosing_module: str) -> str | None: + """``::``-path -> dotted qualname against the module routes, or ``None`` (drop). + + ``crate::a::B`` -> ``.a.B``; ``self::B`` -> ``.B``; + ``super::X`` pops one module segment per ``super`` (above the crate root -> + ``None``); any other leading segment is kept as-is (a real crate-qualified path + resolves, an external one misses the table -> dropped by the caller). + """ + segs = path.split("::") + if segs[0] == "crate": + return ".".join([from_crate, *segs[1:]]) + if segs[0] == "self": + return ".".join([enclosing_module, *segs[1:]]) + if segs[0] == "super": + supers = 0 + while supers < len(segs) and segs[supers] == "super": + supers += 1 + module_parts = enclosing_module.split(".") + if supers >= len(module_parts): # walked above the crate root + return None + return ".".join(module_parts[: len(module_parts) - supers] + segs[supers:]) + return ".".join(segs) + + +def _resolve_non_glob( + path: str, + from_crate: str, + enclosing_module: str, + table: dict[str, list[str]], + keep: Callable[[str], bool], +) -> tuple[Confidence, str] | None: + """resolve.rs ``resolve_non_glob`` + ``resolve_ids``: attempt 1 as-is; attempt 2 + (crate-root-relative) ONLY when attempt 1's RAW candidate slice is empty AND the + original path is a bare single segment (the H5 guard). Then 0 -> drop, 1 -> + resolved, >1 -> ambiguous(first by sorted order).""" + dotted = _normalize_path(path, from_crate, enclosing_module) + if dotted is None: + return None + ids = table.get(dotted, []) + if not ids and "::" not in path: + ids = table.get(f"{from_crate}.{dotted}", []) + matched = sorted(candidate for candidate in ids if keep(candidate)) + if not matched: + return None + if len(matched) == 1: + return (_RESOLVED, matched[0]) + return (_AMBIGUOUS, matched[0]) + + +def _resolve_use_path( + path: str, from_crate: str, enclosing_module: str, table: dict[str, list[str]] +) -> tuple[Confidence, str] | None: + """resolve.rs ``resolve_use_path``: a glob (``a::*``) over an in-project module -> + ambiguous(module id), else dropped; a non-glob path -> :func:`_resolve_non_glob` + with no kind filter.""" + if path.endswith("::*"): + dotted = _normalize_path(path[: -len("::*")], from_crate, enclosing_module) + if dotted is None: + return None + module_id = next( + (candidate for candidate in table.get(dotted, []) if candidate.startswith("rust:module:")), + None, + ) + return (_AMBIGUOUS, module_id) if module_id is not None else None + return _resolve_non_glob(path, from_crate, enclosing_module, table, lambda _candidate: True) + + +# --------------------------------------------------------------------------- # +# imports — module-scope use statements (mirrors extract.rs emit_use_edges) +# --------------------------------------------------------------------------- # + + +def _imports_in_scope( + children: Iterable[Node], + module_entity: RustEntity, + from_crate: str, + modules_by_node: dict[int, RustEntity], + table: dict[str, list[str]], + edges: list[RustEdge], +) -> None: + """Walk ONE module scope's item list: emit an edge per resolving use leaf, recurse + into inline-mod bodies under THAT mod's entity. Only module scopes are walked — + a use inside a fn/impl body is not a module property and emits nothing.""" + from_id = q.entity_id("module", module_entity.qualname) + for child in children: + if child.type == "use_declaration": + argument = child.child_by_field_name("argument") + if argument is None: + continue + leaves: list[str] = [] + _collect_use_leaves(argument, "", leaves) + for leaf in leaves: + resolution = _resolve_use_path(leaf, from_crate, module_entity.qualname, table) + if resolution is None: + continue # external / unresolvable — dropped, never dangling + confidence, to_id = resolution + # Span = the whole `use` statement (extract.rs source_range_of(it)). + edges.append(RustEdge("imports", from_id, to_id, child.start_byte, child.end_byte, confidence)) + elif child.type == "mod_item": + body = child.child_by_field_name("body") + nested = modules_by_node.get(child.id) + if body is None or nested is None: # `mod foo;` external decl / no entity + continue + _imports_in_scope(body.children, nested, from_crate, modules_by_node, table, edges) + + +def _collect_use_leaves(node: Node, prefix: str, out: list[str]) -> None: + """Flatten a use tree into ``::``-joined leaf paths (extract.rs + ``collect_use_leaves``): a Group fans out per branch under the shared prefix; a + Rename (``a::B as C``) contributes the REAL path ``a::B`` (alias dropped); a + Glob terminates a ``::*`` leaf; a ``self`` group leaf terminates the + prefix path UNCHANGED (it names the enclosing module — appending the literal + segment would miss the table and silently drop the module edge).""" + + def joined(seg: str) -> str: + return f"{prefix}::{seg}" if prefix else seg + + t = node.type + if t == "use_list": + for item in node.named_children: + if item.type not in _COMMENT_TYPES: + _collect_use_leaves(item, prefix, out) + elif t == "scoped_use_list": + path = node.child_by_field_name("path") + inner = node.child_by_field_name("list") + if inner is not None: + new_prefix = joined("::".join(_path_segments(path))) if path is not None else prefix + _collect_use_leaves(inner, new_prefix, out) + elif t == "use_as_clause": + path = node.child_by_field_name("path") + if path is not None: + out.append(joined("::".join(_path_segments(path)))) + elif t == "use_wildcard": + path = next((c for c in node.named_children if c.type not in _COMMENT_TYPES), None) + glob_prefix = joined("::".join(_path_segments(path))) if path is not None else prefix + out.append(f"{glob_prefix}::*" if glob_prefix else "*") + elif t == "self": + if prefix: # a bare `use self;` carries an empty prefix and contributes nothing + out.append(prefix) + else: # identifier / scoped_identifier / crate / super — a plain path leaf + out.append(joined("::".join(_path_segments(node)))) + + +def _path_segments(node: Node) -> list[str]: + """A (possibly scoped) path node -> its ``::`` segments, leading + ``crate``/``self``/``super`` kept verbatim for ``_normalize_path`` to map.""" + if node.type in ("scoped_identifier", "scoped_type_identifier"): + path = node.child_by_field_name("path") + name = node.child_by_field_name("name") + segments = _path_segments(path) if path is not None else [] + if name is not None: + segments.append(_text(name)) + return segments + return [_text(node)] + + +# --------------------------------------------------------------------------- # +# implements — one per trait-impl entity (mirrors extract.rs emit_impl) +# --------------------------------------------------------------------------- # + + +def _implements_for( + entity: RustEntity, + file_module: str, + from_crate: str, + table: dict[str, list[str]], + edges: list[RustEdge], +) -> None: + """Emit at most one ``implements`` edge for an ``impl`` ENTITY. The index already + merged same-key twin blocks to one entity (anchored at the FIRST block), so + per-entity emission IS the one-edge-per-impl rule (extract.rs ``seen_impl_ids``).""" + impl_node = entity.node + trait_node = impl_node.child_by_field_name("trait") + if trait_node is None: # inherent impl — nothing to implement + return + # A negative impl (`impl !Tr for Foo`) asserts NON-implementation: no edge + # (extract.rs bang guard). The `!` is a direct child between `impl` and the trait. + if any(child.type == "!" for child in impl_node.children): + return + lookup = "::".join(_trait_path_segments(trait_node)) + if not lookup: + return + # `self::`/`super::` in the trait path resolve against the impl's OWN enclosing + # module (its parent qualname — the module the index parented it to). + resolution = _resolve_non_glob( + lookup, + from_crate, + entity.parent or file_module, + table, + lambda candidate: candidate.startswith("rust:trait:"), + ) + if resolution is None: + return # external trait — dropped at emit + confidence, to_id = resolution + # Span = the implemented-trait path node ONLY (extract.rs source_range_of(trait_path)), + # generic args included (`MyTrait`) — never the whole impl block. + edges.append( + RustEdge( + "implements", + q.entity_id("impl", entity.qualname), + to_id, + trait_node.start_byte, + trait_node.end_byte, + confidence, + ) + ) + + +def _trait_path_segments(trait_node: Node) -> list[str]: + """The implemented-trait path's segments with generic args STRIPPED (extract.rs + ``trait_path_for_lookup``: the resolver keys on the trait's bare-ident qualname, + so ``impl MyTrait for Foo`` MUST look up ``MyTrait``).""" + if trait_node.type == "generic_type": + base = trait_node.child_by_field_name("type") + return _path_segments(base) if base is not None else [] + return _path_segments(trait_node) + + +def _text(node: Node) -> str: + return node.text.decode("utf-8") if node.text is not None else "" diff --git a/src/wardline/rust/index.py b/src/wardline/rust/index.py new file mode 100644 index 00000000..0fb230cf --- /dev/null +++ b/src/wardline/rust/index.py @@ -0,0 +1,409 @@ +"""The Rust index: parse tree -> full ADR-049 entity surface + NodeId stamping. + +Walks the module scope in source order, minting every entity's qualname via the +``qualname`` dialect (ADR-049) and stamping its ``NodeId`` from the shared keying +authority. **The full ten-kind surface is emitted** (Phase 1b): the file-scope +``module`` entity FIRST, then — at their source positions — free items over the +nine named kinds (function/struct/enum/trait/type_alias/const/static/macro plus +inline ``mod``), the merged ``impl`` entity (once, at its FIRST contributing +block), and impl methods re-parented onto the impl entity (``module -> impl -> +method`` containment, mirroring extract.rs ``emit_impl``). + +Not emitted, matching the oracle: closures and nested ``fn``s (the walk never +descends a ``function_item`` body — a finding inside one attributes to the +enclosing named fn via ``line_start``), trait-body items (extract.rs deliberately +never walks trait bodies — a trait definition is only its ``trait`` entity), bare +macro INVOCATIONS, external ``mod foo;`` declarations, ``union`` items (outside +the ten-kind set, the oracle's ``_ => None`` arm), and unnamed ``const _`` items +(ADR-049 Amendment 9 — unconditionally skipped on ``ident == "_"``: nothing can +ever name the item, so no discriminant can rescue it). + +cfg twins are counted per-(kind, name) over the nine named item kinds (extract.rs +``twin_counts``): ``fn S`` and ``struct S`` never interfere — the entity id's kind +segment already separates them — and the ``@cfg(...)`` suffix is applied only on a +within-kind collision. Impl qualnames are decided by the RESIDUAL-COLLISION LADDER +(ADR-049 Amendment 6, spanning Amendments 1/5/6/7): per scope, four stages each +keyed on the previous stage's output — ``@cfg`` (pre-cfg impl-segment twin counter) +-> stage S (self-type written-path qualification) -> stage T (trait written-path +qualification) -> method-``@cfg`` (keyed on the FINAL post-S/T impl qualname). + +This is the single-file, file-module-rooted view: the caller supplies ``module`` +(the SP2 whole-tree pass — ``Cargo.toml`` crate roots + cross-file routes — lives +in ``crate_roots.py``/``analyzer._module_for`` and feeds it in). +tree-sitter types appear only under ``TYPE_CHECKING`` so importing this module +never pulls the ``wardline[rust]`` extra. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from wardline.core.finding import Location +from wardline.core.node_id import NodeId +from wardline.rust import qualname as q +from wardline.rust.nodeid import mint_node_ids +from wardline.rust.parse import parse_rust + +if TYPE_CHECKING: + from collections.abc import Iterable + + from tree_sitter import Node, Tree + + from wardline.rust.nodeid import NodeIdMap + +__all__ = ["RustEntity", "discover_rust_entities", "index_entities"] + +# tree-sitter node type -> ADR-049 id-kind, for the free leaf items emitted directly +# at their source position. `mod_item` (recursed) and `impl_item` (merged) are handled +# by their own arms; a bare macro invocation is an `expression_statement`, never here. +_LEAF_KINDS: dict[str, str] = { + "function_item": "function", + "struct_item": "struct", + "enum_item": "enum", + "trait_item": "trait", + "type_item": "type_alias", + "const_item": "const", + "static_item": "static", + "macro_definition": "macro", +} +_ITEM_TYPES = frozenset(_LEAF_KINDS) | frozenset({"mod_item", "impl_item"}) + +# Comment nodes are token-stream-INVISIBLE to the oracle (syn/proc-macro2 drop them +# before extract.rs ever runs), so a comment interposed between a #[cfg] attribute +# and its item must not reset the pending-cfg accumulation. Covers `//`, `/* */`, +# AND `///` doc comments (tree-sitter parses a doc comment as a line_comment too; +# to syn it is a #[doc] attribute — either way, never a cfg). Corpus row: +# cfg_attr_comment_interposition. +_COMMENT_TYPES = frozenset({"line_comment", "block_comment"}) + + +@dataclass(frozen=True, slots=True) +class RustEntity: + """One emitted Rust entity. ``kind`` spans the full ADR-049 id-kind set + (``module``/``struct``/``function``/``enum``/``trait``/``type_alias``/``const``/ + ``static``/``macro``/``impl``) plus Wardline's *semantic* ``method`` for impl fns — + the qualname id-kind is ``function`` for both (``qualname.entity_id`` maps it). + + ``parent`` is the qualname of the containing module or impl entity (``None`` for + the file-scope module) — the ``module -> impl -> method`` containment chain. + + ``node`` is the entity's CST node (valid while the source tree is alive) — for + callables the analyzer reuses it (under the *same* ``NodeIdMap``, spec §5) to seed + the fn's trust tier and run dataflow over its body.""" + + qualname: str + kind: str + node_id: NodeId + location: Location + node: Node + parent: str | None + + +def index_entities(tree: Tree, nmap: NodeIdMap, *, module: str, path: str = "") -> list[RustEntity]: + """Emit the entities of an already-parsed ``tree`` under its ``nmap``. + + The analyzer calls this so index/dataflow/rules share ONE tree and ONE keying + authority (spec §5 — re-parsing would mint divergent NodeIds and fail quietly). + The file-scope ``module`` entity is emitted FIRST (corpus row order), spanning + the root node. + """ + root = tree.root_node + entities: list[RustEntity] = [_entity(module, "module", root, nmap, path, parent=None)] + _walk_scope(root.children, module, nmap, entities, path) + return entities + + +def discover_rust_entities(source: str, *, module: str, path: str = "") -> list[RustEntity]: + """Parse ``source`` and emit its entities, qualname-rooted at ``module``. + + The corpus-facing API: parses internally (``module`` is the supplied file-module root — + the scan path derives it via ``crate_roots``/``analyzer._module_for``; corpus cases + supply it directly). ``path`` only labels ``Location``. + """ + tree = parse_rust(source) + return index_entities(tree, mint_node_ids(tree), module=module, path=path) + + +def _walk_scope( + child_nodes: Iterable[Node], + module: str, + nmap: NodeIdMap, + entities: list[RustEntity], + path: str, +) -> None: + # Attributes are *preceding siblings* of the item they decorate, so accumulate + # every pending cfg predicate RAW onto the next item (mirrors extract.rs + # `cfg_predicates` — ALL stacked #[cfg]s feed the discriminant, normalisation + # happens exactly once in `cfg_discriminant`). Any non-attribute node resets it. + items: list[tuple[Node, list[str]]] = [] + pending_cfgs: list[str] = [] + for child in child_nodes: + if child.type in _COMMENT_TYPES: + # Token-stream-invisible (see _COMMENT_TYPES): skip WITHOUT resetting + # pending_cfgs — a `// note` between #[cfg] and the fn must not detach + # the cfg and hand two twins the same bare colliding path. + continue + if child.type == "attribute_item": + pred = q.cfg_predicate_of(child) + if pred is not None: + pending_cfgs.append(pred) + continue + if child.type in _ITEM_TYPES: + items.append((child, pending_cfgs)) + pending_cfgs = [] + + # The @cfg suffix is added only on a bare-path COLLISION (ADR-049): a lone + # cfg-gated item gets no suffix. Counting is per-(kind, name) over the nine + # NAMED item kinds (extract.rs `twin_counts`) — the id's kind segment already + # separates `fn S` from `struct S`, so a unique (kind, name) keeps the bare path. + twin_counts: Counter[tuple[str, str]] = Counter() + for node, _cfg in items: + key = _named_item_key(node) + if key is not None: + twin_counts[key] += 1 + + # ---- impl qualnames: the ADR-049 residual-collision LADDER (Amendments 1/5/6/7), + # decided per scope in four stages, each keyed on the previous stage's output: + # (1) @cfg -> (2) stage S (self-type written path) -> (3) stage T (trait written + # path) -> (4) method-@cfg. + # Twin-gated end to end: a lone impl never qualifies, un-fired groups change nothing, + # and cross-path cfg-twins (split at stage 1) leave S cold. Per-scope grouping IS the + # per-extraction-unit grouping: a qualname embeds the full module path, so groups can + # never span scopes. + + # Stage 1 (@cfg): pre-cfg impl-segment twin counts on the BARE keys, exactly as + # before the ladder existed (mirrors extract.rs `impl_twin_counts`) — already-@cfg- + # split twins keep their current ids byte-for-byte. + impl_keys: dict[int, _ImplKey] = {} + bare_counts: Counter[str] = Counter() + for node, cfgs in items: + if node.type == "impl_item": + ikey = _impl_key(node, cfgs) + if ikey is not None: + impl_keys[node.id] = ikey + bare_counts[ikey.key] += 1 + for k in impl_keys.values(): + if k.cfgs and bare_counts[k.key] > 1: + k.cfg_suffix = q.cfg_discriminant(k.cfgs) + + # Stage S (Amendment 6): a post-cfg group with >= 2 distinct self-type written-path + # witnesses re-renders every qself-free Type::Path member's base as the escaped + # written path; an A4-fallback member keeps its single-escaped render (its witness + # still counts toward distinctness). Identical-witness coherence-illegal twins do + # not fire — no witness can split them (`duplicate_ids()` is the alarm upstream). + for group in _impl_groups(impl_keys): + if len({m.self_witness for m in group}) > 1: + for m in group: + if m.self_is_path: + m.base = m.self_witness + + # Stage T (Amendment 7): a post-S group with >= 2 distinct trait written paths + # switches EVERY member's impl[...] fragment to the qualified rendering (a single- + # segment path renders byte-identically; inherent impls never fire — their #<> keys + # never group with [...] keys). Running T after S yields minimal qualification: a + # pair already split by S leaves T cold. + for group in _impl_groups(impl_keys): + if len({m.trait_witness for m in group if m.trait_witness is not None}) > 1: + for m in group: + if m.trait_node is not None: + m.fragment = f"impl[{q.render_trait_segment_qualified(m.trait_node)}]" + + # Stage 4 — method-level cfg-twin counts (ADR-049 Amendment 5): keyed on the FINAL + # (post-S/T) impl qualname + method name, counted across ALL merged blocks — so an + # impl-level cfg-twin (already split into distinct impl entities) gets no redundant + # method suffix, while methods merging across same-key blocks do. + final_impl_quals: dict[int, str] = {nid: f"{module}.{k.key}" for nid, k in impl_keys.items()} + method_twin_counts: Counter[tuple[str, str]] = Counter() + for node, _cfgs in items: + if node.type == "impl_item" and node.id in final_impl_quals: + final_qual = final_impl_quals[node.id] + for method, _mcfgs in _impl_methods_with_cfgs(node): + method_twin_counts[(final_qual, _name(method))] += 1 + + # First block with a given (cfg-augmented) impl qualname emits the ONE merged impl + # entity; later same-key blocks only append methods (extract.rs `seen_impl_ids`). + # The set is PER-INVOCATION (each nested scope gets a fresh one); that is sound + # because the impl qualname embeds the full module path, so two impls in different + # scopes can never share a key — dedup only ever needs to see one scope at a time. + seen_impl_quals: set[str] = set() + + for node, cfgs in items: + if node.type == "mod_item": + body = node.child_by_field_name("body") + if body is None: # `mod foo;` (external) has no body to descend + continue + name = _name(node) + nested = f"{module}.{name}" + if cfgs and twin_counts[("module", name)] > 1: + nested += q.cfg_discriminant(cfgs) + # The inline-mod entity is emitted AT its source position, BEFORE its + # members (corpus nested_inline_mod row order; extract.rs inline-mod arm). + entities.append(_entity(nested, "module", node, nmap, path, parent=module)) + _walk_scope(body.children, nested, nmap, entities, path) + elif node.type == "impl_item": + impl_qualname = final_impl_quals.get(node.id) + if impl_qualname is None: + continue + if impl_qualname not in seen_impl_quals: + seen_impl_quals.add(impl_qualname) + entities.append(_entity(impl_qualname, "impl", node, nmap, path, parent=module)) + _emit_impl_methods(node, impl_qualname, nmap, entities, path, method_twin_counts) + else: + kind = _LEAF_KINDS[node.type] + name = _name(node) + if kind == "const" and name == "_": + # ADR-049 Amendment 9: `const _` is NOT an entity — skipped + # UNCONDITIONALLY on `ident == "_"` (skip-only-when-twinned would make + # the emitted set sibling-dependent and churn SEI; nothing can ever name + # the item, so no discriminant can rescue it). No entity, no containment; + # a finding inside one attributes to the enclosing module by line. + continue + qualname = f"{module}.{name}" + if cfgs and twin_counts[(kind, name)] > 1: + qualname += q.cfg_discriminant(cfgs) + entities.append(_entity(qualname, kind, node, nmap, path, parent=module)) + + +def _named_item_key(node: Node) -> tuple[str, str] | None: + """The per-(kind, name) twin-counter key of a named item, or ``None`` for items + that don't participate (impl blocks have their own counter; an external ``mod foo;`` + emits nothing, matching extract.rs counting only ``content: Some(_)`` mods).""" + if node.type == "mod_item": + if node.child_by_field_name("body") is None: + return None + return ("module", _name(node)) + kind = _LEAF_KINDS.get(node.type) + if kind is None: + return None + name = _name(node) + if kind == "const" and name == "_": + return None # ADR-049 Amendment 9: never emitted, so never counted + return (kind, name) + + +@dataclass(slots=True) +class _ImplKey: + """One impl block's decomposed segment parts, MUTATED through the residual-collision + ladder (stage 1 sets ``cfg_suffix``; a fired stage S rewrites ``base``; a fired stage + T rewrites ``fragment``). ``key`` is the current ``.impl…@cfg`` segment — + before stage 1 it IS the bare pre-cfg key the @cfg twin counter runs on.""" + + cfgs: list[str] + base: str # self-type base render (last path segment / A4 fallback) + self_args: str # "<...>" args suffix, "" when none survive (stage-invariant) + fragment: str # "impl[...]" (bare) / "impl#<...>" + cfg_suffix: str # "" until stage 1 fires + self_witness: str # stage-S witness (escaped written path / A4 fallback render) + self_is_path: bool # qself-free Type::Path -> base re-renders on a fired S group + trait_node: Node | None + trait_witness: str | None # stage-T witness (written trait path), None for inherent + + @property + def key(self) -> str: + return f"{self.base}{self.self_args}.{self.fragment}{self.cfg_suffix}" + + +def _impl_key(impl_node: Node, cfgs: list[str]) -> _ImplKey | None: + """The decomposed pre-cfg ``.impl[...]`` / ``.impl#<...>`` parts, + or ``None`` if the impl has no self type. The self type carries its concrete generic + args (ADR-049 §2 self-type-args amendment — ``Foo`` vs ``Foo`` are distinct + keys, the impl's own params positional); no ordinal (ADR-049 amend Option b).""" + type_node = impl_node.child_by_field_name("type") + if type_node is None: + return None + base, self_args = q.render_self_type_parts(type_node, q.impl_type_param_names(impl_node)) + self_witness, self_is_path = q.self_type_witness(type_node) + trait_node = impl_node.child_by_field_name("trait") + if trait_node is not None: + fragment = f"impl[{q.render_trait_segment(trait_node)}]" + trait_witness = q.trait_written_path(trait_node) + else: + fragment = f"impl#<{q.render_positional_generics(impl_node)}>" + trait_witness = None + return _ImplKey( + cfgs=cfgs, + base=base, + self_args=self_args, + fragment=fragment, + cfg_suffix="", + self_witness=self_witness, + self_is_path=self_is_path, + trait_node=trait_node, + trait_witness=trait_witness, + ) + + +def _impl_groups(impl_keys: dict[int, _ImplKey]) -> list[list[_ImplKey]]: + """The current collision groups: impls sharing a ``key``, singletons dropped (the + ladder is twin-gated — a lone impl never qualifies).""" + by_key: dict[str, list[_ImplKey]] = {} + for k in impl_keys.values(): + by_key.setdefault(k.key, []).append(k) + return [g for g in by_key.values() if len(g) > 1] + + +def _impl_methods_with_cfgs(impl_node: Node) -> list[tuple[Node, list[str]]]: + """``(function_item, raw cfg predicates)`` pairs of an impl body, with the SAME + pending-cfg accumulation discipline as ``_walk_scope`` (comments transparent, + attributes accumulate, any other node resets).""" + body = impl_node.child_by_field_name("body") + if body is None: + return [] + out: list[tuple[Node, list[str]]] = [] + pending_cfgs: list[str] = [] + for child in body.children: + if child.type in _COMMENT_TYPES: + continue + if child.type == "attribute_item": + pred = q.cfg_predicate_of(child) + if pred is not None: + pending_cfgs.append(pred) + continue + if child.type == "function_item": + out.append((child, pending_cfgs)) + pending_cfgs = [] + return out + + +def _emit_impl_methods( + impl_node: Node, + impl_qualname: str, + nmap: NodeIdMap, + entities: list[RustEntity], + path: str, + method_twin_counts: Counter[tuple[str, str]], +) -> None: + for child, mcfgs in _impl_methods_with_cfgs(impl_node): + # Methods re-parent onto the impl ENTITY (module -> impl -> method), and the + # method qualname builds from the cfg-AUGMENTED impl qualname (extract.rs). + # A cfg-gated TWIN method (same final impl qualname + name, counted across + # merged blocks) carries its own @cfg suffix (ADR-049 Amendment 5). + name = _name(child) + qualname = f"{impl_qualname}.{name}" + if mcfgs and method_twin_counts[(impl_qualname, name)] > 1: + qualname += q.cfg_discriminant(mcfgs) + entities.append(_entity(qualname, "method", child, nmap, path, parent=impl_qualname)) + + +def _name(node: Node) -> str: + name = node.child_by_field_name("name") + if name is not None and name.text is not None: + return name.text.decode("utf-8") + return "" + + +def _entity(qualname: str, kind: str, node: Node, nmap: NodeIdMap, path: str, *, parent: str | None) -> RustEntity: + start = node.start_point + end = node.end_point + location = Location( + path=path, + line_start=start[0] + 1, + line_end=end[0] + 1, + col_start=start[1], + col_end=end[1], + ) + return RustEntity( + qualname=qualname, kind=kind, node_id=nmap.node_id(node), location=location, node=node, parent=parent + ) diff --git a/src/wardline/rust/mounts.py b/src/wardline/rust/mounts.py new file mode 100644 index 00000000..8405e8f0 --- /dev/null +++ b/src/wardline/rust/mounts.py @@ -0,0 +1,268 @@ +"""ADR-049 Amendment 8: the ``#[path]`` mount overlay — logical module routing. + +Two producers minting one module id was the clarion-bdb1eccf48 family: the file walk +routed a mounted file by filesystem path while the AST walk emitted the inline facade at +the same dotted path. The fix is a targeted mount overlay WITH a filesystem default: +every literal ``#[path = "…"] mod name;`` declaration is collected (rustc's +relative-path rule), resolved through a memoized fixed point (mounts chain; cycles drop +to the filesystem fallback; a doubly-claimed target resolves first-by-sorted- +(declaring-file, byte offset) — the R5 determinism pin), and ``logical_module_path`` +then routes every file: exact mount hit, else longest mounted-subtree prefix, else the +unchanged pure-filesystem ``qualname.rust_module_route``. + +Invisible BY DIALECT RULE (never resolved): a macro-wrapped mount (inside an unexpanded +macro invocation) and a ``#[cfg_attr(pred, path = "…")]``-delivered mount are NOT +mounts — only a literal ``#[path]`` attribute is. Their targets route by filesystem +fallback. No producer expands macros or evaluates cfg predicates. Both fall out of the +walk structurally: a token tree never surfaces ``attribute_item``/``mod_item`` siblings +at a walked item list, and ``_path_attr_of`` matches only the attribute literally named +``path``. A ``#[path]`` on an INLINE ``mod name { … }`` is likewise not a mount (the +normative rule covers the decl form only); the inline body still nests by name. + +Twin discipline (ADR-049 §3, extended): a mount's own segment appends the normalised +``@cfg(...)`` discriminant only when its module name is TWINNED in the declaring item +list — counted across BOTH inline-``mod`` and decl-``mod`` forms. A mount declared +INSIDE a cfg-twin inline mod composes that mod's ``@cfg``-suffixed segment into its +logical PREFIX — the SAME twin counting + rendering the AST walk applies to the inline +mod's own entity path (counted over inline-with-body mods only, matching +``index._named_item_key``), so file-walk and AST-walk agree byte-for-byte. The +``#[path]`` target itself always resolves against the BARE would-be directory, which +carries no cfg. + +This is SP2 scope (mount discovery needs the parent chain — a declaring file's +directory anchors the relative target); ``module_route`` rows keep driving +``rust_module_route`` directly, pinned as the no-mount-context FALLBACK. The corpus +``module_mounts`` section pins the mounted routes end-to-end. A file tree-sitter cannot +fully parse contributes NO mounts (fail-closed: no routing derived from a file we +refuse to analyze). + +All paths are project-root-relative POSIX strings (the R5 sort rule is "declaring-file +path relative to the project root"); ``build_mount_overlay`` is the pure corpus-facing +entry, ``analyzer.analyze`` builds one overlay per crate from the scanned in-src +sources. tree-sitter types appear only under ``TYPE_CHECKING`` so importing this module +never pulls the ``wardline[rust]`` extra. +""" + +from __future__ import annotations + +import posixpath +from collections import Counter +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from wardline.rust import qualname as q +from wardline.rust.parse import has_errors, parse_rust + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from tree_sitter import Node + +__all__ = ["MountOverlay", "build_mount_overlay"] + +# Files whose own stem contributes no module segment; for nesting they anchor the +# would-be directory at their OWN directory (a non-root file anchors at dir/stem). +_ROOT_BASENAMES = frozenset({"lib.rs", "main.rs", "mod.rs"}) + +# Token-stream-invisible to the oracle — never resets the pending-attribute run +# (mirrors index._COMMENT_TYPES; the corpus pins the discipline for cfg attrs). +_COMMENT_TYPES = frozenset({"line_comment", "block_comment"}) + + +@dataclass(frozen=True, slots=True) +class _Mount: + """One literal ``#[path = "…"] mod name;`` declaration, resolved to its target.""" + + declaring_file: str # project-root-relative posix path (R5 sort key 1) + offset: int # byte offset of the `mod name;` item (R5 sort key 2) + segments: tuple[str, ...] # inline-mod prefix segments (cfg-composed) + own segment + target: str # normalised posix path of the mounted file + + +class MountOverlay: + """The per-crate routing table: mounted files/subtrees overlay the filesystem route. + + ``crate``/``src_root`` parameterise the filesystem DEFAULT (``rust_module_route``); + every path handed to ``logical_module_path`` must use the same base the mounts were + discovered under (project-root-relative posix).""" + + def __init__(self, mounts: Iterable[_Mount], *, crate: str, src_root: str) -> None: + self._crate = crate + self._src_root = src_root + exact: dict[str, _Mount] = {} + prefixes: dict[str, _Mount] = {} + # R5 determinism: first by sorted (declaring-file, byte offset) wins a + # doubly-claimed target file (and likewise a doubly-claimed subtree prefix). + for mount in sorted(mounts, key=lambda m: (m.declaring_file, m.offset)): + exact.setdefault(mount.target, mount) + target_dir, basename = posixpath.split(mount.target) + if basename == "mod.rs": + # A

/mod.rs target registers / as a logical subtree prefix. + prefixes.setdefault(target_dir, mount) + elif basename.endswith(".rs"): + # An x.rs target registers /x/ for its child directory + # (rustc's non-mod-rs child rule). + prefixes.setdefault(posixpath.join(target_dir, basename[: -len(".rs")]), mount) + self._exact = exact + self._prefixes = prefixes + self._memo: dict[str, str] = {} + + def logical_module_path(self, file: str) -> str: + """Route ``file``: exact mount hit, else longest mounted-subtree prefix, else + the unchanged pure-filesystem ``rust_module_route``.""" + file = posixpath.normpath(file) + if file not in self._memo: + self._memo[file] = self._resolve(file, frozenset()) + return self._memo[file] + + def _resolve(self, file: str, resolving: frozenset[str]) -> str: + if file in resolving: + # Mount cycle: drop this link to the filesystem fallback (deterministic; + # the corpus does not pin cycles — unit-tested as Wardline behavior). + return self._fs_route(file) + mount = self._exact.get(file) + if mount is not None: + return self._mount_logical(mount, resolving | {file}) + hit = self._longest_prefix(file) + if hit is not None: + prefix_dir, mount = hit + base = self._mount_logical(mount, resolving | {file}) + # Children rewrite under the mount with the same stem discipline as the + # filesystem route (trailing `mod` stem collapses): reuse it verbatim, + # the mount's logical path standing in as the "crate" prefix. + return q.rust_module_route(crate=base, src_root=prefix_dir, file=file) + return self._fs_route(file) + + def _mount_logical(self, mount: _Mount, resolving: frozenset[str]) -> str: + # The mount's own logical path: the DECLARING file's logical path (which may + # itself route through a mount — the chained fixed point) + its segments. + return ".".join([self._resolve(mount.declaring_file, resolving), *mount.segments]) + + def _longest_prefix(self, file: str) -> tuple[str, _Mount] | None: + best: tuple[str, _Mount] | None = None + for prefix_dir, mount in self._prefixes.items(): + if file.startswith(prefix_dir + "/") and (best is None or len(prefix_dir) > len(best[0])): + best = (prefix_dir, mount) + return best + + def _fs_route(self, file: str) -> str: + return q.rust_module_route(crate=self._crate, src_root=self._src_root, file=file) + + +def build_mount_overlay(sources: Mapping[str, str], *, crate: str, src_root: str) -> MountOverlay: + """Discover every literal ``#[path]`` mount across ``sources`` (path -> source text, + paths project-root-relative posix) and build the crate's routing overlay.""" + mounts: list[_Mount] = [] + for file in sorted(sources): + if not file.endswith(".rs"): + continue + tree = parse_rust(sources[file]) + if has_errors(tree): + continue # fail-closed: no routing derived from a file we refuse to analyze + file_dir = posixpath.dirname(file) + # rustc's relative-path rule: a top-level #[path] resolves against the declaring + # FILE's directory; one declared inside inline mods resolves against the would-be + # directory of the nesting — anchored at the file's own dir for a mod-rs file + # (lib.rs/main.rs/mod.rs), at the file's stem directory otherwise. + stem_base = file_dir if posixpath.basename(file) in _ROOT_BASENAMES else file[: -len(".rs")] + _collect_mounts(tree.root_node.children, file, attr_dir=file_dir, nest_base=stem_base, prefix=(), out=mounts) + return MountOverlay(mounts, crate=crate, src_root=src_root) + + +def _collect_mounts( + children: Iterable[Node], + file: str, + *, + attr_dir: str, + nest_base: str, + prefix: tuple[str, ...], + out: list[_Mount], +) -> None: + items = _mod_items_with_attrs(children) + # The mount's OWN segment twin-gates its @cfg across BOTH forms per declaring item + # list; the inline-mod PREFIX segment twin-gates over inline-with-body mods only + # (the AST walk's _named_item_key rule) — so prefix composition matches the inline + # mod's own entity path byte-for-byte. + cross_form_counts: Counter[str] = Counter() + inline_counts: Counter[str] = Counter() + for node, _cfgs, _target in items: + name = _mod_name(node) + cross_form_counts[name] += 1 + if node.child_by_field_name("body") is not None: + inline_counts[name] += 1 + for node, cfgs, target in items: + name = _mod_name(node) + body = node.child_by_field_name("body") + if body is None: + if target is None: + continue # plain `mod foo;` — filesystem-routed, not a mount + segment = name + if cfgs and cross_form_counts[name] > 1: + segment += q.cfg_discriminant(cfgs) + resolved = posixpath.normpath(posixpath.join(attr_dir, target)) + out.append(_Mount(file, node.start_byte, (*prefix, segment), resolved)) + else: + segment = name + if cfgs and inline_counts[name] > 1: + segment += q.cfg_discriminant(cfgs) + # The #[path] target inside an inline mod resolves against the BARE + # would-be directory (no cfg ever reaches the filesystem side). + child_dir = posixpath.join(nest_base, name) + _collect_mounts( + body.children, file, attr_dir=child_dir, nest_base=child_dir, prefix=(*prefix, segment), out=out + ) + + +def _mod_items_with_attrs(children: Iterable[Node]) -> list[tuple[Node, list[str], str | None]]: + """``(mod_item, raw cfg predicates, #[path] target | None)`` triples of one item + list, with the SAME pending-attribute discipline as ``index._walk_scope`` (comments + transparent, attributes accumulate, any other node resets).""" + out: list[tuple[Node, list[str], str | None]] = [] + pending_cfgs: list[str] = [] + pending_path: str | None = None + for child in children: + if child.type in _COMMENT_TYPES: + continue + if child.type == "attribute_item": + pred = q.cfg_predicate_of(child) + if pred is not None: + pending_cfgs.append(pred) + else: + target = _path_attr_of(child) + if target is not None: + pending_path = target + continue + if child.type == "mod_item": + out.append((child, pending_cfgs, pending_path)) + pending_cfgs = [] + pending_path = None + return out + + +def _path_attr_of(attribute_item: Node) -> str | None: + """The literal target of a ``#[path = "…"]`` attribute, or ``None``. + + Only the literal form is a mount: a ``cfg_attr``-delivered ``path`` never matches + (its attribute path is ``cfg_attr``), and a path attribute without a string value + is not a mount either.""" + if not attribute_item.named_children: + return None + attribute = attribute_item.named_children[0] + if attribute.type != "attribute" or not attribute.named_children: + return None + if attribute.named_children[0].text != b"path": + return None + value = attribute.child_by_field_name("value") + if value is None or value.type != "string_literal": + return None + content = next((c for c in value.named_children if c.type == "string_content"), None) + if content is None or content.text is None: + return None + return content.text.decode("utf-8") + + +def _mod_name(node: Node) -> str: + name = node.child_by_field_name("name") + if name is not None and name.text is not None: + return name.text.decode("utf-8") + return "" diff --git a/src/wardline/rust/nodeid.py b/src/wardline/rust/nodeid.py new file mode 100644 index 00000000..03859ba2 --- /dev/null +++ b/src/wardline/rust/nodeid.py @@ -0,0 +1,87 @@ +"""The ``NodeId`` keying authority for the Rust frontend (spec §5). + +``mint_node_ids(tree)`` walks one tree-sitter parse tree in pre-order and assigns +every CST node (named *and* anonymous) a deterministic ``NodeId`` — its 0-based +pre-order index. The returned ``NodeIdMap`` is the *single* authority that later +passes (dataflow WP4, rules WP5) use to identify a node: they share the one parse +tree and look up through this map, never re-deriving an id. That is what makes +the callgraph↔dataflow↔rule correlation agree instead of failing quietly. + +Lookups key on ``node.id`` — the tree-sitter node identity, stable and unique for +the life of the parse tree. The *value* returned is the reproducible pre-order +index, which ``node.id`` (a process-local pointer value) is not: ids are +correlation-stable within a scan, the index is deterministic across runs. + +The tree-sitter types appear only under ``TYPE_CHECKING`` so importing this module +never pulls the ``wardline[rust]`` extra. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wardline.core.node_id import NodeId + +if TYPE_CHECKING: + from tree_sitter import Node, Tree + +__all__ = ["NodeIdMap", "mint_node_ids"] + + +class NodeIdMap: + """Maps tree-sitter CST nodes to their stable pre-order ``NodeId``s. + + Constructed only by :func:`mint_node_ids`. Callers pass the ``Node`` (never a + raw id), so the keying scheme stays encapsulated here — the single authority. + + The map **pins its source ``Tree``**. ``node.id`` is a process-local pointer + value that tree-sitter *reuses* once a tree is freed, so a map keyed on bare + ids that outlived its tree would return a *wrong* ``NodeId`` for a node from a + later tree instead of raising — the silent correlation failure (spec §5's + "NodeId hazard") this type exists to make loud. Holding the ``Tree`` keeps the + keyspace valid for the map's whole lifetime, so a foreign-tree lookup is + always a clean ``KeyError`` (distinct live pointers), never a false hit. + """ + + __slots__ = ("_by_node", "_tree") + + def __init__(self, by_node: dict[int, NodeId], tree: Tree) -> None: + self._by_node = by_node + self._tree = tree # pin the source tree so node.id keys stay valid (see docstring) + + def node_id(self, node: Node) -> NodeId: + """The ``NodeId`` minted for ``node``. + + Raises ``KeyError`` if ``node`` did not come from the tree this map was + minted over — a cross-tree lookup is a bug, surfaced loudly rather than + as a silent miss. + """ + return self._by_node[node.id] + + def get(self, node: Node) -> NodeId | None: + """The ``NodeId`` for ``node``, or ``None`` if it is not in this map.""" + return self._by_node.get(node.id) + + def __contains__(self, node: Node) -> bool: + return node.id in self._by_node + + def __len__(self) -> int: + return len(self._by_node) + + +def mint_node_ids(tree: Tree) -> NodeIdMap: + """Assign every node in ``tree`` a 0-based pre-order ``NodeId``. + + Iterative (explicit stack) so a deeply nested expression cannot exhaust the + Python recursion limit. Children are pushed reversed so the leftmost child is + visited next — i.e. classic pre-order, matching a tree-cursor walk. + """ + by_node: dict[int, NodeId] = {} + stack: list[Node] = [tree.root_node] + index = 0 + while stack: + node = stack.pop() + by_node[node.id] = NodeId(index) + index += 1 + stack.extend(reversed(node.children)) + return NodeIdMap(by_node, tree) diff --git a/src/wardline/rust/parse.py b/src/wardline/rust/parse.py new file mode 100644 index 00000000..ff5bdfc6 --- /dev/null +++ b/src/wardline/rust/parse.py @@ -0,0 +1,36 @@ +"""Parse Rust source into a tree-sitter CST (lazy-guarded, zero base dep). + +A thin seam over ``require_rust()`` so the rest of the frontend takes ``str``/``bytes`` +and gets back a ``Tree`` without each call site re-deriving the parser. tree-sitter +types appear only under ``TYPE_CHECKING`` so importing this module never pulls the +``wardline[rust]`` extra. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wardline.rust._tree_sitter import require_rust + +if TYPE_CHECKING: + from tree_sitter import Tree + +__all__ = ["has_errors", "parse_rust"] + + +def parse_rust(source: str | bytes) -> Tree: + """Parse Rust ``source`` (str is UTF-8 encoded) into a tree-sitter ``Tree``.""" + _, parser = require_rust() + data = source.encode("utf-8") if isinstance(source, str) else source + return parser.parse(data) + + +def has_errors(tree: Tree) -> bool: + """True if tree-sitter recovered from a syntax error anywhere in the parse. + + tree-sitter never fails to produce a tree: it wraps unparseable regions in ``ERROR`` + nodes (or drops loose tokens), so the item walk silently skips them and a malformed + ``.rs`` yields zero or PARTIAL entities — a false all-clear. A scan must consult this + and surface a diagnostic rather than report a clean result over a half-parsed file. + """ + return tree.root_node.has_error diff --git a/src/wardline/rust/provider.py b/src/wardline/rust/provider.py new file mode 100644 index 00000000..f784ec17 --- /dev/null +++ b/src/wardline/rust/provider.py @@ -0,0 +1,83 @@ +"""The Rust trust provider — ``/// @trusted(level=...)`` doc-comment markers. + +Rust attributes are compile errors on stable when applied as trust markers, so the +declared-trust signal rides an *outer doc comment* (``///``) instead: ``/// @trusted( +level=ASSURED)`` on a fn declares its body trusted at that tier. An unmarked fn yields +no opinion (``None``), which the L1 seeder turns into the fail-closed ``UNKNOWN_RAW`` +default. The provider fingerprint embeds ``RUST_TAINT_VERSION`` so a vocab bump +invalidates dependent summaries (the cache-version gap — a per-file source hash cannot +observe an out-of-source vocab edit). + +tree-sitter types appear only under ``TYPE_CHECKING`` so importing this module never +pulls the ``wardline[rust]`` extra. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from wardline.core.taints import TaintState +from wardline.rust import vocabulary +from wardline.scanner.taint.provider import FunctionTaint + +if TYPE_CHECKING: + from tree_sitter import Node + +__all__ = ["RustTrustProvider", "rust_provider_fingerprint"] + +# A trust marker may only declare a *trusted* tier (ASSURED/GUARDED); raw/unknown tiers +# are the fail-closed default, not something you declare. +_TRUSTED_TIERS: dict[str, TaintState] = { + "ASSURED": TaintState.ASSURED, + "GUARDED": TaintState.GUARDED, +} +# Anchored to the START of the doc text (`re.match` + leading `\s*` for the `/// ` space): +# only a directive that LEADS the doc-comment line declares trust. A `search` would also match +# the directive mentioned in prose ("do not use @trusted(level=ASSURED) here"), falsely seeding +# trust and spuriously un-suppressing the fn's findings. +_MARKER = re.compile(r"\s*@trusted\s*\(\s*level\s*=\s*(\w+)\s*\)") + + +def rust_provider_fingerprint(version: int) -> str: + """The provider's declaration-surface fingerprint for a given vocab version.""" + return f"rust-vocab:{version}" + + +class RustTrustProvider: + """Seeds a function's declared taint from its ``/// @trusted(level=...)`` marker.""" + + def taint_for(self, fn_node: Node) -> FunctionTaint | None: + """The declared :class:`FunctionTaint` for ``fn_node``, or ``None`` (no opinion). + + Raises ``ValueError`` if a ``@trusted`` marker is present but names a level that + is not a trusted tier — a typo'd marker is surfaced, not silently ignored. + """ + level = self._declared_level(fn_node) + if level is None: + return None + return FunctionTaint(body_taint=level, return_taint=level) + + def fingerprint(self) -> str: + return rust_provider_fingerprint(vocabulary.RUST_TAINT_VERSION) + + def _declared_level(self, fn_node: Node) -> TaintState | None: + # Outer doc comments and attributes are preceding siblings of the fn; walk back + # through the contiguous run of them looking for the marker. + node = fn_node.prev_named_sibling + while node is not None and node.type in ("line_comment", "attribute_item"): + if node.type == "line_comment": + outer = node.child_by_field_name("outer") + doc = node.child_by_field_name("doc") + if outer is not None and doc is not None and doc.text is not None: + match = _MARKER.match(doc.text.decode("utf-8")) + if match is not None: + word = match.group(1) + if word not in _TRUSTED_TIERS: + raise ValueError( + f"@trusted marker has an invalid level {word!r}; " + f"expected one of {sorted(_TRUSTED_TIERS)}" + ) + return _TRUSTED_TIERS[word] + node = node.prev_named_sibling + return None diff --git a/src/wardline/rust/qualname.py b/src/wardline/rust/qualname.py new file mode 100644 index 00000000..437ef263 --- /dev/null +++ b/src/wardline/rust/qualname.py @@ -0,0 +1,445 @@ +"""The Rust qualname dialect — Loomweave ADR-049 (Wardline is the *second producer*). + +Wardline MINTS the same locator string Loomweave's whole-tree ``syn`` extractor emits; +it never parses Loomweave's locator. The vendored corpus +(``tests/conformance/qualnames_rust.json``) is the byte-for-byte oracle. ``:`` is the +reserved separator and ``[ ] # < > @ $`` are legal segments of the dialect. + +ADR-049 AMENDMENT 4 (2026-06-11, implemented): every CONCRETE generic arg — type or const, +in both the trait fragment and the self-type prefix — renders through +``escape_reserved(strip_ws(arg))``: whitespace-stripped first (the oracle now strips const +args too, closing the proc-macro2-spacing gap), then the injective reserved-char escape +(``%`` -> ``%25``, then ``:`` -> ``%3A``). ``impl From for Foo`` -> +``Foo.impl[From]``; ``impl Foo<{ 1 + 2 }>`` -> ``Foo<{1+2}>.impl#<>``. +The same escape covers the NON-``Type::Path`` self-type fallback (reference/tuple/slice/ptr: +``impl Serializer for &mut fmt::Formatter`` -> ``&mutfmt%3A%3AFormatter``). The escape happens +in the producer BEFORE the id is assembled — ``entity_id``'s ``:`` rejection stays strict. +Corpus rows: ``path_typed_generic_arg_trait``/``_inherent``, ``const_generic_arg_spacing``, +``reference_self_type_path_escape``. + +ADR-049 AMENDMENTS 6+7 (2026-06-11, implemented): the residual-collision LADDER for impl +qualnames — ``@cfg -> stage S (self-type written path) -> stage T (trait written path) -> +method-@cfg`` — lives in ``index._walk_scope``; this module supplies the witness/render +primitives. ``written_path_of`` is the qself-free Type::Path detector (segment idents +joined ``::``, a leading ``::`` preserved, generic args ignored); ``self_type_witness`` +is the stage-S witness (escaped written path, or the Amendment-4 textual render for a +qself-bearing/non-path self type); ``trait_written_path`` + ``render_trait_segment_qualified`` +are the stage-T witness and fired-group rendering (escaped written trait path, final +segment keeping the existing generic-args rendering — a single-segment path renders +byte-identically to the bare fragment). All witnesses are SINGLE-FILE computable: written +paths as-typed, no name resolution (the second-producer constraint). + +ADR-049 AMENDMENT 8 (2026-06-11, implemented): ``rust_module_route`` stays the +PURE-FILESYSTEM route by design — ``#[path]``-aware routing is the SEPARATE entry point +``mounts.MountOverlay.logical_module_path`` (mount overlay first, this route as the +default), pinned by the corpus ``module_mounts`` section. Amendment 9 (``const _`` +skip-emission) lives in ``index._walk_scope``. + +This module holds the pure string/CST-node renderers; ``index.py`` walks the tree and +assembles full qualnames from them. tree-sitter types appear only under ``TYPE_CHECKING`` +so importing this module never pulls the ``wardline[rust]`` extra. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + from tree_sitter import Node + +__all__ = [ + "RUST_ONTOLOGY_VERSION", + "RUST_PLUGIN_ID", + "cfg_discriminant", + "cfg_predicate_of", + "entity_id", + "impl_type_param_names", + "normalize_cfg_predicate", + "render_positional_generics", + "render_self_type", + "render_self_type_parts", + "render_trait_segment", + "render_trait_segment_qualified", + "rust_module_route", + "self_type_witness", + "trait_written_path", + "written_path_of", +] + +_ROOT_STEMS = frozenset({"lib", "main", "mod"}) + +# The ADR-049 producer identity (mirrors loomweave plugin.toml: `plugin_id = "rust"`, +# `ontology_version = "0.4.0"`) — Wardline mints the SAME entity ids as the oracle. +RUST_PLUGIN_ID = "rust" +RUST_ONTOLOGY_VERSION = "0.4.0" + +# The ten ADR-049 id-kinds (plugin.toml `entity_kinds`). Wardline's semantic `method` +# is NOT an id-kind — `entity_id` maps it to `function` itself. +_ID_KINDS = frozenset( + {"module", "struct", "function", "enum", "trait", "type_alias", "const", "static", "macro", "impl"} +) + + +def entity_id(kind: str, qualname: str) -> str: + """The federation entity id ``{plugin}:{kind}:{qualname}`` for an emitted entity. + + Wardline's semantic ``method`` maps to the id-kind ``function`` HERE (callers pass + ``RustEntity.kind`` verbatim, never pre-mapping); any kind outside the ten-kind + ADR-049 set raises ``ValueError`` — mirroring loomweave's ``build_entity_id`` + validation posture (reject, never silently coin a new kind). + """ + if kind == "method": + kind = "function" + if kind not in _ID_KINDS: + msg = f"unknown Rust entity kind {kind!r} (not in the ADR-049 ten-kind set)" + raise ValueError(msg) + return f"{RUST_PLUGIN_ID}:{kind}:{qualname}" + + +def rust_module_route(*, crate: str, src_root: str, file: str) -> str: + """Route a source ``file`` to its dotted module path (ADR-049 §module_route). + + ``lib.rs``/``main.rs``/``mod.rs`` contribute no segment; every other ``.rs`` file + contributes its stem; directories nest. The crate name is the root segment. + ``#[path]`` is NOT honoured (a known SP2 gap — routing is purely by file path). + """ + from pathlib import PurePosixPath + + rel = PurePosixPath(file).relative_to(PurePosixPath(src_root)) + segments = list(rel.parts) + # drop the ".rs" stem of the file; a root stem contributes nothing + last = PurePosixPath(segments[-1]).stem if segments else "" + segments = segments[:-1] + ([] if last in _ROOT_STEMS else [last]) + return ".".join([crate, *segments]) + + +def normalize_cfg_predicate(text: str) -> str: + """Canonicalise a ``cfg(...)`` predicate, mirroring loomweave ``qualname.rs`` + ``normalise_pred`` BYTE-FOR-BYTE (the @cfg discriminant is a parity surface). + + ``text`` is the RAW argument token-tree text, with or without its outer parens + (``"(unix)"`` / ``"unix"`` / ``"(any(windows, unix))"``); the outer cfg-argument + parens (if present) are stripped to the bare predicate, then — in the oracle's + exact order: all whitespace removed; every reserved entity-id char escaped + (``_escape_reserved``: ``%`` -> ``%25`` first, then ``:`` -> ``%3A`` — injective, + so ``feature="a:b"`` and a literal source ``feature="a%3Ab"`` stay distinct); and + a single top-level ``any(...)``/``all(...)`` wrapper's args sorted by a **naive** + ``split(',')`` (NOT paren-aware — this is exactly the oracle's algorithm; deeper + nesting is left as the deterministic stripped string, even though that mangles, + because the contract is byte-equality with the oracle, not a "nicer" canonical + form). The escape runs on the whole stripped predicate BEFORE the any()/all() + split, exactly as in ``normalise_pred``. + """ + stripped = "".join(text.split()) + if stripped.startswith("(") and stripped.endswith(")"): + stripped = stripped[1:-1] + stripped = _escape_reserved(stripped) + for fn in ("any", "all"): + prefix = fn + "(" + if stripped.startswith(prefix) and stripped.endswith(")"): + inner = stripped[len(prefix) : -1] + parts = sorted(inner.split(",")) # naive split — matches the oracle verbatim + return f"{fn}({','.join(parts)})" + return stripped + + +def _escape_reserved(s: str) -> str: + """Escape every reserved entity-id char so a cfg predicate can never inject the + reserved ``:`` separator into a qualname (mirrors qualname.rs ``escape_reserved``). + Order matters for injectivity: the ``%`` introducer is encoded FIRST.""" + return s.replace("%", "%25").replace(":", "%3A") + + +def cfg_discriminant(predicates: Sequence[str]) -> str: + """Fold ALL of an item's RAW ``#[cfg(...)]`` predicates into the stable ``@cfg(...)`` + discriminant suffix (mirrors qualname.rs ``cfg_discriminant`` BYTE-FOR-BYTE): each + predicate normalised+escaped exactly once (``normalize_cfg_predicate``), the set + sorted (order-independent — NOT source order), joined with ``&``. Folding every + predicate is what keeps stacked cfg-twins (``#[cfg(feature="a")] #[cfg(unix)]`` vs + ``#[cfg(feature="b")] #[cfg(unix)]``) distinct. Applied by ``index.py`` only on a + bare-path COLLISION, exactly like the oracle's extract.rs twin counter. + + Raises ``ValueError`` on an empty input: ``@cfg()`` is never a meaningful + discriminant (the oracle's ``cfg_suffix`` guards the empty case BEFORE calling + ``cfg_discriminant``), and rendering it would silently collide every + "discriminated" twin onto one key — a caller bug, surfaced loudly.""" + if not predicates: + msg = "cfg_discriminant() requires at least one raw #[cfg] predicate" + raise ValueError(msg) + return f"@cfg({'&'.join(sorted(normalize_cfg_predicate(p) for p in predicates))})" + + +def cfg_predicate_of(attribute_item: Node) -> str | None: + """The RAW cfg predicate text of an ``attribute_item`` node (outer argument parens + included, source spacing intact — e.g. ``#[cfg(feature = "a")]`` -> + ``'(feature = "a")'``), or ``None`` if it is not a ``#[cfg(...)]`` attribute. + + Deliberately UN-normalised, mirroring extract.rs ``cfg_predicates`` (raw token + text): collection is raw, and ``cfg_discriminant`` is the single place predicates + are normalised + reserved-char-escaped — normalising here too would double-escape + (``a:b`` -> ``a%253Ab``). + + The ONE exception to "raw source span": comment nodes inside the predicate + (``#[cfg(any(unix, /* why */ windows))]``) are excised — the oracle's predicate + is ``list.tokens.to_string()`` and proc-macro2 token streams carry no comments, + so comment bytes were never part of the oracle's raw text either (corpus row + ``cfg_predicate_internal_comment``). Everything else (parens, spacing, unescaped + reserved chars) is kept verbatim. + """ + if not attribute_item.named_children: + return None + attribute = attribute_item.named_children[0] + if attribute.type != "attribute" or not attribute.named_children: + return None + path = attribute.named_children[0] + if path.text != b"cfg": + return None + args = attribute.child_by_field_name("arguments") + if args is None or args.text is None: + return None + return _text_excluding_comments(args) + + +# tree-sitter comment node types — `///` doc comments parse as line_comment too. +_COMMENT_TYPES = frozenset({"line_comment", "block_comment"}) + + +def _text_excluding_comments(node: Node) -> str: + """``node``'s raw source text with every comment node's byte span excised + (the comment's SURROUNDING whitespace survives — ``normalize_cfg_predicate`` + strips all whitespace downstream, so only the comment bytes themselves matter).""" + raw = node.text or b"" + base = node.start_byte + spans: list[tuple[int, int]] = [] + stack = [node] + while stack: + current = stack.pop() + if current.type in _COMMENT_TYPES: + spans.append((current.start_byte - base, current.end_byte - base)) + continue + stack.extend(current.children) + if not spans: + return raw.decode("utf-8") + spans.sort() + kept = bytearray() + pos = 0 + for start, end in spans: + kept += raw[pos:start] + pos = end + kept += raw[pos:] + return kept.decode("utf-8") + + +# Generic args that are NOT part of the locator key (qualname.rs trait_generic_args / +# self_ty_locator keep only GenericArgument::Type / ::Const): lifetimes and assoc-type +# bindings. Shared by the trait fragment and the self-type prefix. +_DROPPED_GENERIC_ARGS = frozenset({"lifetime", "type_binding"}) + + +def impl_type_param_names(impl_node: Node) -> list[str]: + """The impl's declared generic TYPE-parameter names in source (De Bruijn) order: + ``impl Foo`` -> ``["T", "U"]`` (mirrors qualname.rs ``declared_type_params`` = + syn ``generics.type_params()``). Lifetimes and const params are excluded — only the + positions used by the inherent ``#<...>`` signature AND by the self-type prefix to + recognise which self-type args are the impl's OWN params (rendered positionally).""" + tp = impl_node.child_by_field_name("type_parameters") + if tp is None: + return [] + names: list[str] = [] + for child in tp.named_children: + if child.type == "type_parameter": + name = child.child_by_field_name("name") + # A blank name only arises from error recovery (`impl<>`); syn yields no such + # param, so skipping it keeps the De Bruijn count true to the oracle. + if name is not None and (text := _text(name)): + names.append(text) + return names + + +def render_self_type(type_node: Node, type_params: Sequence[str]) -> str: + """The locator-relevant name of an impl's self type INCLUDING its concrete generic + args (mirrors qualname.rs ``self_ty_locator``, ADR-049 §2 self-type-args amendment). + + ``base + args`` of ``render_self_type_parts`` — see there for the full rule.""" + base, args = render_self_type_parts(type_node, type_params) + return base + args + + +def render_self_type_parts(type_node: Node, type_params: Sequence[str]) -> tuple[str, str]: + """``(base, args)`` decomposition of the self-type render — the Amendment-6 ladder + re-renders the BASE alone (stage S) while the ``<...>`` args suffix is stage-invariant. + + The base is the bare last path segment (``Foo`` in ``crate::m::Foo``); ``args`` is the + surviving generic args in ``<...>`` — comma-joined, ``""`` when none survive. An arg + that is exactly one of the impl's OWN declared params (``type_params``) renders + positionally (``$N``, rename-stable: ``impl Foo`` -> ``Foo<$0>``); a CONCRETE + arg (``i32``, ``Vec``, ``&T``) renders literally whitespace-free. Substitution is + TOP-LEVEL only — a param NESTED in another arg (``Foo>`` -> ``Foo>``, + NOT ``Foo>``) keeps its literal text (F2 nested-param rule; a nested-param + corpus row is owed by Loomweave, so the unit guard in test_qualname.py is the only + check against accidental recursive substitution). Lifetimes/bindings dropped; a + non-generic self type renders the bare name. A non-``Type::Path`` OR qself-bearing + self type (``&mut fmt::Formatter``, ``::C`` — ``written_path_of`` is None) + renders the Amendment-4 textual fallback as the base, args empty (Amendment 6 pins + qself with the fallback: the witness/render must not double-escape).""" + if type_node.type == "generic_type": + base_node = type_node.child_by_field_name("type") + if base_node is None or written_path_of(base_node) is None: + # qself-bearing generic path (`::C`): whole-node A4 fallback. + return _escape_reserved(_strip_ws(type_node)), "" + base = _last_path_segment(base_node) + targs = type_node.child_by_field_name("type_arguments") + if targs is None: + return base, "" + # Drop dropped-kinds AND any arg that renders empty: a malformed empty turbofish + # `Foo<>` error-recovers to a blank `type_identifier` (valid Rust never yields one, + # so this never touches a real arg). Keeps the pure producer from emitting `Foo<>`; + # the scan path gates such input on has_errors() before it reaches here anyway. + rendered = [ + r + for c in targs.named_children + if c.type not in _DROPPED_GENERIC_ARGS + if (r := _self_type_arg(c, type_params)) + ] + return (base, f"<{','.join(rendered)}>") if rendered else (base, "") + if type_node.type in ("type_identifier", "scoped_type_identifier") and written_path_of(type_node) is not None: + return _last_path_segment(type_node), "" + # Non-Type::Path fallback (reference/tuple/slice/raw-pointer self types): may carry a + # ``::``-path (`&mut fmt::Formatter`), so it escapes like a concrete generic arg + # (ADR-049 Amendment 4 self-type-fallback completion). A `:`-free fallback is unchanged. + return _escape_reserved(_strip_ws(type_node)), "" + + +def written_path_of(node: Node) -> str | None: + """The WRITTEN path of a qself-free Type::Path node — segment idents joined ``::``, + a leading ``::`` contributing a leading separator (``impl Tr for ::a::X`` and + ``impl Tr for a::X`` carry distinct witnesses), generic args never included — or + ``None`` for a non-path / qself-bearing node (``::C`` routes its + ``bracketed_type`` head here and falls out). Mirrors the Amendment-6 witness rule: + as-written, single-file, no name resolution.""" + if node.type in ("type_identifier", "identifier", "crate", "super", "self", "metavariable"): + return _text(node) + if node.type in ("scoped_type_identifier", "scoped_identifier"): + name = node.child_by_field_name("name") + if name is None: + return None + path = node.child_by_field_name("path") + if path is None: + # `::X` — a leading-:: anchor with no prefix path before the separator. + return f"::{_text(name)}" if any(c.type == "::" for c in node.children) else _text(name) + prefix = written_path_of(path) + return None if prefix is None else f"{prefix}::{_text(name)}" + return None + + +def self_type_witness(type_node: Node) -> tuple[str, bool]: + """The stage-S witness of an impl's self type (ADR-049 Amendment 6) as + ``(witness, is_path)``. + + For a qself-free ``Type::Path`` (a ``generic_type`` contributes its base — args are + already normalized and MUST NOT affect the witness): ``escape_reserved`` over the + written path, ``is_path=True`` — a fired group re-renders this member's base to the + witness verbatim. Otherwise the Amendment-4 textual render (already escaped) with + ``is_path=False`` — the member participates in distinctness but its rendering is + never touched (re-applying the escape would double-escape).""" + base = type_node.child_by_field_name("type") if type_node.type == "generic_type" else type_node + written = written_path_of(base) if base is not None else None + if written is not None: + return _escape_reserved(written), True + return _escape_reserved(_strip_ws(type_node)), False + + +def trait_written_path(trait_node: Node) -> str | None: + """The stage-T witness (ADR-049 Amendment 7): the trait path AS WRITTEN — segment + idents joined ``::``, leading ``::`` preserved, generic args ignored — or ``None`` + for a non-path trait node (out-of-corpus; such a member never drives a fire).""" + base = trait_node.child_by_field_name("type") if trait_node.type == "generic_type" else trait_node + return written_path_of(base) if base is not None else None + + +def _self_type_arg(arg_node: Node, type_params: Sequence[str]) -> str: + """One self-type generic arg (mirrors qualname.rs ``self_ty_arg``): a bare + ``type_identifier`` matching one of the impl's declared params -> its positional ``$N`` + token (rename-stable); anything else (concrete type, nested generic, reference, scoped + path) -> a literal whitespace-free rendering. NOT recursive — only a top-level bare + param substitutes.""" + if arg_node.type == "type_identifier": + name = _text(arg_node) + if name in type_params: + return f"${type_params.index(name)}" + return _escape_reserved(_strip_ws(arg_node)) + + +def render_trait_segment(trait_node: Node) -> str: + """The trait discriminator (mirrors qualname.rs ``trait_impl`` + ``trait_generic_args``): + the trait path's last segment, plus its concrete type/const generic args — lifetimes + AND associated-type bindings dropped, each arg whitespace-stripped — with the + ``<...>`` omitted ENTIRELY when no args survive. ``std::fmt::Display`` -> ``Display``; + ``From`` -> ``From``; ``Iterator`` -> ``Iterator`` (binding dropped); + ``Trait<'a>`` -> ``Trait`` (lifetime dropped, no empty brackets).""" + if trait_node.type == "generic_type": + base = trait_node.child_by_field_name("type") + trait_name = _last_path_segment(base) if base is not None else "" + args = _trait_generic_args(trait_node) + return f"{trait_name}<{','.join(args)}>" if args else trait_name + return _last_path_segment(trait_node) + + +def render_trait_segment_qualified(trait_node: Node) -> str: + """The Amendment-7 FIRED-group trait fragment: ``escape_reserved`` over the + ``::``-joined written trait path (leading ``::`` -> leading ``%3A%3A``), the final + segment keeping the existing ``trait_generic_args`` rendering. A single-segment + written path renders byte-identically to ``render_trait_segment``. Applied to EVERY + member of a fired post-S group — never outside one (the corpus-pinned bare fragment + is untouched for un-fired groups).""" + written = trait_written_path(trait_node) + if written is None: # non-path trait node (out-of-corpus): bare rendering, defensively + return render_trait_segment(trait_node) + qualified = _escape_reserved(written) + args = _trait_generic_args(trait_node) if trait_node.type == "generic_type" else [] + return f"{qualified}<{','.join(args)}>" if args else qualified + + +def _trait_generic_args(trait_node: Node) -> list[str]: + """The surviving (concrete type/const) generic args of a ``generic_type`` trait node, + each whitespace-stripped + escaped. Drops dropped-kinds AND empty-rendering args (a + malformed ``From<>`` error-recovers to a blank arg; valid Rust never yields one).""" + targs = trait_node.child_by_field_name("type_arguments") + if targs is None: + return [] + return [ + _escape_reserved(s) for c in targs.named_children if c.type not in _DROPPED_GENERIC_ARGS if (s := _strip_ws(c)) + ] + + +def render_positional_generics(impl_node: Node) -> str: + """The impl's TYPE params rendered positionally (De Bruijn): ``impl`` -> ``$0``; + ``impl`` / ``impl<'a>`` / ``impl`` -> ``""``. Only ``type_parameter``s + count (mirrors syn ``generics.type_params()``); lifetime and const params do not. Keyed + on the SAME name list as the self-type prefix (``impl_type_param_names``) so positions + agree between ``Foo<$0>`` and ``impl#<$0>``.""" + count = len(impl_type_param_names(impl_node)) + return ",".join(f"${i}" for i in range(count)) + + +def _text(node: Node) -> str: + return node.text.decode("utf-8") if node.text is not None else "" + + +def _strip_ws(node: Node) -> str: + """A whitespace-free textual rendering of a node's source text (mirrors qualname.rs + ``strip_ws`` = ``to_token_stream().to_string()`` with all whitespace removed — + removing whitespace from the source span converges to the same string for paths/types, + where the only inter-token difference is spacing).""" + return "".join(_text(node).split()) + + +def _last_path_segment(node: Node) -> str: + """The final identifier of a (possibly scoped) type path: ``a::b::Foo`` -> ``Foo``.""" + if node.type == "scoped_type_identifier": + name = node.child_by_field_name("name") + if name is not None: + return _text(name) + return _text(node) diff --git a/src/wardline/rust/rules.py b/src/wardline/rust/rules.py new file mode 100644 index 00000000..220796d0 --- /dev/null +++ b/src/wardline/rust/rules.py @@ -0,0 +1,170 @@ +"""WP5: the two slice-1 command-injection rules (the verdict layer). + +RS-WL-108 (program injection) — tainted data reaches the *program* of ``Command::new`` — +is a NEW threat class (attacker-chosen executable), base **ERROR**. RS-WL-112 (shell +injection) — tainted data reaches a ``sh -c`` style shell command line — base **WARN**. +Both modulate by the containing fn's declared trust tier (``modulate``: an unmarked / +fail-closed fn yields ``NONE`` and is suppressed) and key on RAW_ZONE membership of the +*selected* taint. De-confliction: when the program itself is tainted (108's territory), +112 stays silent so one boundary yields one finding. CWE-78 rides the message prose. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wardline.core.finding import Finding, Kind, Location, Severity, compute_finding_fingerprint +from wardline.core.taints import RAW_ZONE, TaintState, least_trusted +from wardline.scanner.rules.severity_model import modulate + +if TYPE_CHECKING: + from collections.abc import Sequence + + from wardline.rust.context import RustAnalysisContext, RustTriggerContext + +__all__ = ["RustProgramInjectionRule", "RustShellInjectionRule"] + +# Programs that interpret their argument as a command line — the RS-WL-112 gate. A +# non-shell program with a tainted arg is the argv-list flood (a hard FP), so it never fires. +_SHELL_PROGRAMS = frozenset( + {"sh", "bash", "zsh", "dash", "ksh", "fish", "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh"} +) + + +class RustProgramInjectionRule: + """RS-WL-108 — untrusted data chooses the executable spawned by ``Command::new``.""" + + rule_id = "RS-WL-108" + base_severity = Severity.ERROR + + def check(self, context: RustAnalysisContext) -> Sequence[Finding]: + findings: list[Finding] = [] + for tc in context.triggers: + if tc.trigger.program_taint not in RAW_ZONE: + continue + severity = modulate(self.base_severity, tc.tier) + if severity is Severity.NONE: + continue + findings.append(_program_finding(tc, severity)) + return findings + + +class RustShellInjectionRule: + """RS-WL-112 — untrusted data reaches a ``sh -c`` style shell command line.""" + + rule_id = "RS-WL-112" + base_severity = Severity.WARN + + def check(self, context: RustAnalysisContext) -> Sequence[Finding]: + findings: list[Finding] = [] + for tc in context.triggers: + trig = tc.trigger + # De-confliction: a tainted PROGRAM is RS-WL-108's finding; do not double-report. + if trig.program_taint in RAW_ZONE: + continue + if not (trig.shell_flag_seen and _is_shell(trig.program_literal)): + continue + worst = _worst_arg_taint(tc) + if worst not in RAW_ZONE: + continue + severity = modulate(self.base_severity, tc.tier) + if severity is Severity.NONE: + continue + findings.append(_shell_finding(tc, severity, worst)) + return findings + + +def _is_shell(program_literal: str | None) -> bool: + # Basename + case-fold: `/bin/sh`, `C:\Windows\System32\cmd.exe`, and `BASH` are all shells. + if program_literal is None: + return False + basename = program_literal.replace("\\", "/").rsplit("/", 1)[-1].lower() + return basename in _SHELL_PROGRAMS + + +def _worst_arg_taint(tc: RustTriggerContext) -> TaintState: + worst = TaintState.ASSURED + for _node_id, taint in tc.trigger.arg_taints: + worst = least_trusted(worst, taint) + return worst + + +def _program_finding(tc: RustTriggerContext, severity: Severity) -> Finding: + trig = tc.trigger + taint_path = ( + f"{trig.program_taint.value}->Command::new(program)@L{trig.constructor_line}->exec@L{trig.trigger_line}" + ) + message = ( + f"Untrusted data selects the program executed by Command::new " + f"(constructed at line {trig.constructor_line}, run at line {trig.trigger_line}): " + f"an attacker controls which executable runs (CWE-78)." + ) + # Fingerprint discriminator (NOT the display path): source-derived + entity-relative + # only (wlfp2 — see _fp_discriminant). The constructor's relative line is folded so + # two stepwise builders terminating on one line stay distinct by construction site. + fp_disc = f"Command::new(program)@L+{trig.constructor_line - tc.entity_line_start}" + return _finding(RustProgramInjectionRule.rule_id, tc, severity, taint_path, fp_disc, message) + + +def _shell_finding(tc: RustTriggerContext, severity: Severity, worst: TaintState) -> Finding: + trig = tc.trigger + taint_path = f"{worst.value}->arg->'{trig.program_literal} -c'->exec@L{trig.trigger_line}" + message = ( + f"Untrusted data reaches a shell command line " + f"('{trig.program_literal} -c ...', run at line {trig.trigger_line}): " + f"an attacker can inject shell syntax (CWE-78)." + ) + # The program literal is a source token (the spelling as written), so it is a legal + # wlfp2 discriminator component; the resolved `worst` tier is NOT (display-only). + fp_disc = f"arg->'{trig.program_literal} -c'" + return _finding(RustShellInjectionRule.rule_id, tc, severity, taint_path, fp_disc, message) + + +def _fp_discriminant(tc: RustTriggerContext, fp_disc: str) -> str: + """The wlfp2 ``taint_path`` fingerprint component for one trigger. + + Every folded position is ENTITY-RELATIVE (wlfp2 move-stability, the + rust-sp2-2026-06-10 keystone rekey — see core/finding.py:170): the trigger line + folds as ``line - entity_line_start`` and the trigger NodeId as + ``trigger_node_id - entity_node_id``. Both anchors are the containing fn's own + (its first line, its pre-order index), so an edit ABOVE the entity — a comment + at the top of the file, a sibling fn inserted above — shifts absolute lines and + pre-order indices in lockstep and leaves both deltas invariant. An edit INSIDE + the fn above the trigger moves the deltas and rekeys (accepted: that is the + same entity-relative limitation the Python sink rules carry). + + The relative-NodeId fold is the SOLE same-line discriminant: two DISTINCT + triggers on one physical line (identical rule/path/qualname/relative-line) get + distinct fingerprints — the no-collision invariant. Resolved taint tiers never + appear here (they drift across builds: weft-4a9d0f863c); the human-readable + ``properties["taint_path"]`` keeps the absolute-line display form. + """ + trig = tc.trigger + rel_line = trig.trigger_line - tc.entity_line_start + rel_node = int(trig.trigger_node_id) - int(tc.entity_node_id) + return f"{fp_disc}->exec@L+{rel_line}@node+{rel_node}" + + +def _finding( + rule_id: str, tc: RustTriggerContext, severity: Severity, taint_path: str, fp_disc: str, message: str +) -> Finding: + trig = tc.trigger + return Finding( + rule_id=rule_id, + message=message, + severity=severity, + kind=Kind.DEFECT, + location=Location(path=tc.path, line_start=trig.trigger_line, line_end=trig.trigger_line), + fingerprint=compute_finding_fingerprint( + rule_id=rule_id, + path=tc.path, + qualname=tc.qualname, + taint_path=_fp_discriminant(tc, fp_disc), + ), + qualname=tc.qualname, + properties={ + "taint_path": taint_path, + "constructor_line": trig.constructor_line, + "trigger_node_id": int(trig.trigger_node_id), + }, + ) diff --git a/src/wardline/rust/rust_taint.yaml b/src/wardline/rust/rust_taint.yaml new file mode 100644 index 00000000..801a22af --- /dev/null +++ b/src/wardline/rust/rust_taint.yaml @@ -0,0 +1,68 @@ +# src/wardline/rust/rust_taint.yaml +# Bundled Rust trust vocabulary (preview, Tier-A command-injection slice). +# +# `sources` claim the taint of the value a std call RETURNS (externally controlled +# input crossing the trust boundary). `sinks` mark dangerous call targets by +# `sink_kind`; the slice-1 rules (RS-WL-108/112) consume `command`. Paths are the +# Rust call path; matching is by trailing segments (a `use std::env; env::var(..)` +# and a fully-qualified `std::env::var(..)` both match `crate: std, path: env::var`). +# +# `version` (RUST_TAINT_VERSION) is bumped when the table's shape/semantics change; +# it folds into the provider_fingerprint so a vocab change invalidates dependent +# summaries (a per-file source hash cannot observe an out-of-source vocab edit). + +# v2: dropped `io::stdin` — stdin data reaches a program via the `.read_line(&mut buf)` +# out-param, which the flat-local taint model does not track, so the source could never +# contribute a finding (misleading dead weight). Out-param sources are SP2. +# v3: added the async-runtime command sinks (tokio::process::Command::new, +# async_process::Command::new) — they genuinely spawn OS processes and dominate real async +# Rust; the crate-aware matcher (rust-bug-hunt-2026-06-09) only admits a sink's declared crate, +# so each runtime must be declared explicitly rather than caught by a crate-blind suffix. +version: 3 + +sources: + - crate: std + path: env::var + returns_taint: EXTERNAL_RAW + rationale: A process environment variable; externally controlled. + + - crate: std + path: env::var_os + returns_taint: EXTERNAL_RAW + rationale: A process environment variable (OsString); externally controlled. + + - crate: std + path: env::args + returns_taint: EXTERNAL_RAW + rationale: Command-line arguments; attacker-influenceable at launch. + + - crate: std + path: env::vars + returns_taint: EXTERNAL_RAW + rationale: The full process environment; externally controlled. + + - crate: std + path: fs::read_to_string + returns_taint: EXTERNAL_RAW + rationale: External file contents read as a string; unvalidated. + + - crate: std + path: fs::read + returns_taint: EXTERNAL_RAW + rationale: External file bytes; unvalidated. + +sinks: + - crate: std + path: process::Command::new + sink_kind: command + rationale: Spawns an OS process; a tainted program or shell argument is command injection. + + - crate: tokio + path: process::Command::new + sink_kind: command + rationale: tokio's async process spawner; spawns an OS process exactly like std::process::Command. + + - crate: async_process + path: Command::new + sink_kind: command + rationale: The async-process (smol/async-std) process spawner; spawns an OS process. diff --git a/src/wardline/rust/vocabulary.py b/src/wardline/rust/vocabulary.py new file mode 100644 index 00000000..bf76e232 --- /dev/null +++ b/src/wardline/rust/vocabulary.py @@ -0,0 +1,150 @@ +"""Loader for the bundled Rust trust vocabulary (``rust_taint.yaml``). + +Two frozen tables keyed by ``(crate, path)``: ``sources`` (a std call's returned-value +taint) and ``sinks`` (a dangerous call target's ``sink_kind``). ``_build_tables`` is +separated from IO so its validation is unit-testable without a fixture on disk +(mirrors ``scanner/taint/stdlib_taint.py``). ``RUST_TAINT_VERSION`` folds into the +provider fingerprint so a vocab edit invalidates dependent summaries. +""" + +from __future__ import annotations + +from collections.abc import Mapping # noqa: TC003 # runtime import for typing reflection +from dataclasses import dataclass +from functools import lru_cache +from importlib.resources import files +from types import MappingProxyType +from typing import Any + +from wardline.core.optional_deps import require_yaml +from wardline.core.taints import TaintState + +# A source returns data the project did not produce, so INTEGRAL (your own fully +# trusted data) is nonsensical, and the unreachable trio {MIXED_RAW, UNKNOWN_GUARDED, +# UNKNOWN_ASSURED} must never enter the pipeline (reachable-set invariant). Same +# constraint as the Python stdlib table. +_LEGAL_RETURN: frozenset[TaintState] = frozenset( + {TaintState.ASSURED, TaintState.GUARDED, TaintState.EXTERNAL_RAW, TaintState.UNKNOWN_RAW} +) +_LEGAL_SINK_KINDS: frozenset[str] = frozenset({"command"}) + +RUST_TAINT_VERSION: int = 3 +"""Bumped when the table's shape or entries change materially; folded into the +provider fingerprint so changes invalidate dependent summaries. v2 dropped the inert +``io::stdin`` source (out-param reads are unmodelled in slice-1). v3 added the async-runtime +command sinks (``tokio::process::Command::new``, ``async_process::Command::new``) so the +crate-aware matcher admits them (a crate-blind suffix used to catch tokio by accident).""" + +__all__ = [ + "RUST_TAINT_VERSION", + "RustSink", + "RustSource", + "RustTaintTables", + "load_rust_taint", +] + + +@dataclass(frozen=True, slots=True) +class RustSource: + """A std call whose returned value carries the given taint.""" + + returns_taint: TaintState + rationale: str + + +@dataclass(frozen=True, slots=True) +class RustSink: + """A dangerous call target, classified by ``sink_kind`` (slice-1: ``command``).""" + + sink_kind: str + rationale: str + + +@dataclass(frozen=True, slots=True) +class RustTaintTables: + sources: Mapping[tuple[str, str], RustSource] + sinks: Mapping[tuple[str, str], RustSink] + + +def _require_str(value: Any, where: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"rust_taint.yaml {where} must be a non-empty string") + return value + + +def _build_tables(raw: Any) -> RustTaintTables: + if not isinstance(raw, dict) or raw.get("version") != RUST_TAINT_VERSION: + got = raw.get("version") if isinstance(raw, dict) else raw + raise ValueError(f"rust_taint.yaml version mismatch: expected {RUST_TAINT_VERSION}, got {got!r}") + + sources: dict[tuple[str, str], RustSource] = {} + for idx, entry in enumerate(_as_list(raw.get("sources"), "sources")): + crate = _require_str(_get(entry, idx, "crate"), f"sources[{idx}].crate") + path = _require_str(_get(entry, idx, "path"), f"sources[{idx}].path") + returns_raw = _get(entry, idx, "returns_taint") + if not isinstance(returns_raw, str): + raise ValueError(f"rust_taint.yaml sources[{idx}].returns_taint must be a string") + try: + taint = TaintState(returns_raw) + except ValueError as exc: + raise ValueError( + f"rust_taint.yaml sources[{idx}].returns_taint={returns_raw!r} is not a canonical TaintState" + ) from exc + if taint not in _LEGAL_RETURN: + raise ValueError( + f"rust_taint.yaml sources[{idx}].returns_taint={returns_raw!r} is not a legal source " + f"return tier (allowed: {sorted(s.value for s in _LEGAL_RETURN)}); a state outside this " + f"set would inject an otherwise-unreachable taint and break the reachable-set invariant" + ) + rationale = _require_str(_get(entry, idx, "rationale"), f"sources[{idx}].rationale") + key = (crate, path) + if key in sources: + raise ValueError( + f"rust_taint.yaml sources[{idx}]: duplicate ({crate!r}, {path!r}) — " + f"a later entry would silently shadow an earlier one" + ) + sources[key] = RustSource(returns_taint=taint, rationale=rationale) + + sinks: dict[tuple[str, str], RustSink] = {} + for idx, entry in enumerate(_as_list(raw.get("sinks"), "sinks")): + crate = _require_str(_get(entry, idx, "crate"), f"sinks[{idx}].crate") + path = _require_str(_get(entry, idx, "path"), f"sinks[{idx}].path") + sink_kind = _require_str(_get(entry, idx, "sink_kind"), f"sinks[{idx}].sink_kind") + if sink_kind not in _LEGAL_SINK_KINDS: + raise ValueError( + f"rust_taint.yaml sinks[{idx}].sink_kind={sink_kind!r} is not a known sink_kind " + f"(allowed: {sorted(_LEGAL_SINK_KINDS)})" + ) + rationale = _require_str(_get(entry, idx, "rationale"), f"sinks[{idx}].rationale") + key = (crate, path) + if key in sinks: + raise ValueError( + f"rust_taint.yaml sinks[{idx}]: duplicate ({crate!r}, {path!r}) — " + f"a later entry would silently shadow an earlier one" + ) + sinks[key] = RustSink(sink_kind=sink_kind, rationale=rationale) + + return RustTaintTables(sources=MappingProxyType(sources), sinks=MappingProxyType(sinks)) + + +def _as_list(value: Any, name: str) -> list[Any]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"rust_taint.yaml: {name!r} must be a list") + return value + + +def _get(entry: Any, idx: int, field: str) -> Any: + if not isinstance(entry, dict): + raise ValueError(f"rust_taint.yaml entry[{idx}] must be a mapping") + return entry.get(field) + + +@lru_cache(maxsize=1) +def load_rust_taint() -> RustTaintTables: + """Return the bundled Rust trust vocabulary, immutable and cached once per process.""" + yaml = require_yaml("loading rust_taint.yaml") + yaml_path = files("wardline.rust").joinpath("rust_taint.yaml") + raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + return _build_tables(raw) diff --git a/src/wardline/scanner/__init__.py b/src/wardline/scanner/__init__.py index 7ca0f96a..b6c10d7b 100644 --- a/src/wardline/scanner/__init__.py +++ b/src/wardline/scanner/__init__.py @@ -1,20 +1,7 @@ -"""Wardline analysis engine. SP0 ships a no-op; SP1 replaces it.""" +"""Wardline analysis engine public API.""" from __future__ import annotations -from collections.abc import Sequence -from pathlib import Path - -from wardline.core.config import WardlineConfig -from wardline.core.finding import Finding from wardline.scanner.analyzer import WardlineAnalyzer - -class NoOpAnalyzer: - """Placeholder analyzer that performs no analysis (SP0).""" - - def analyze(self, files: Sequence[Path], config: WardlineConfig, *, root: Path) -> Sequence[Finding]: - return [] - - -__all__ = ["NoOpAnalyzer", "WardlineAnalyzer"] +__all__ = ["WardlineAnalyzer"] diff --git a/src/wardline/scanner/analyzer.py b/src/wardline/scanner/analyzer.py index 3fc88fc4..acc99cd1 100644 --- a/src/wardline/scanner/analyzer.py +++ b/src/wardline/scanner/analyzer.py @@ -17,24 +17,28 @@ from typing import TYPE_CHECKING from wardline.core.finding import ENGINE_PATH, Finding, Kind, Location, Severity -from wardline.core.taints import TaintState, combine +from wardline.core.taints import RAW_ZONE, TaintState, combine from wardline.scanner.context import AnalysisContext, RuleRegistry from wardline.scanner.diagnostics import ( + build_collision_findings, build_diagnostic_findings, build_metric_finding, build_unknown_import_findings, ) -from wardline.scanner.grammar import TrustGrammar, default_grammar +from wardline.scanner.grammar import TrustGrammar, build_sanitiser_collision_findings, default_grammar from wardline.scanner.index import Entity from wardline.scanner.pipeline import L2FunctionInput, ParseProjectInput, run_l2_function_stage, run_parse_project_stage from wardline.scanner.rules import build_default_registry +from wardline.scanner.rules._sink_helpers import SinkBindings, collect_sink_bindings from wardline.scanner.taint.call_taint_map import build_call_taint_map from wardline.scanner.taint.decorator_provider import ( DecoratorTaintSourceProvider, vocabulary_star_exports, ) +from wardline.scanner.taint.module_summariser import collect_module_global_raw_seeds, own_scope_global_names from wardline.scanner.taint.project_resolver import resolve_project_taints from wardline.scanner.taint.provider import TaintSourceProvider +from wardline.scanner.taint.variable_level import attribute_write_recording, project_attribute_writes if TYPE_CHECKING: from collections.abc import Sequence @@ -51,9 +55,14 @@ def _fp(*parts: str) -> str: _L2Record = tuple[Entity, TaintState, dict[str, TaintState], str, dict[str, str], str, bool] +# The L2 fixed-point memo key. ``seed`` and ``method_tm`` are FIXED per entity across +# iterations (computed once in pass 1), so the key carries only the iteration-VARYING +# inputs: the class-attribute overlay and the parameter meets — both O(per-function). +# Keying on the full sorted taint map was O(project) per function per iteration, +# the whole-scan O(n^2) hotspot (resource-exhaustion on large/adversarial trees). type _L2InputKey = tuple[ - TaintState, - tuple[tuple[str, TaintState], ...], + tuple[tuple[str, TaintState], ...] | None, + tuple[tuple[str, TaintState], ...] | None, tuple[tuple[str, TaintState], ...] | None, ] type _L2Result = tuple[ @@ -65,6 +74,143 @@ def _fp(*parts: str) -> str: dict[str, dict[str, TaintState]], ] +# Above this many candidate-key probes, fall back to the full (unpruned) per-function +# taint map — a sound, slower path for a single pathologically token-dense function. +_CANDIDATE_KEY_BUDGET = 50_000 + + +def _pruned_method_taint_map( + node: ast.AST, + alias_map: dict[str, str], + module_prefix: str, + call_tm: dict[str, TaintState], + project_return_taints: dict[str, TaintState], +) -> dict[str, TaintState]: + """Restrict the per-function taint map to keys the function can actually look up. + + Folding the whole project's return-taint map into EVERY function's taint map made + each per-function L2 run O(project): the map copy in ``analyze_function_variables``, + the per-call ``frozenset(taint_map.keys())``, and the fixed-point memo key all scale + with the map — an O(n^2) whole-scan blowup. The L2 resolver only ever looks a key up + by a form DERIVED FROM SOURCE TOKENS in the function body (variable_level.py): + + * a bare name / literal dotted chain as written (``foo`` / ``mod.fn`` / ``self.x``), + * an alias-resolved chain (``resolve_call_fqn`` / ``_resolve_expr_fqn``: + ``alias_map[root] + rest``), + * a module-local candidate (``{module_prefix}.{name}``), and + * a tracked-receiver-type method key (``{class_fqn}.{attr}`` where the class FQN + is itself one of the resolved forms above and ``attr`` is an attribute token). + + So the restriction of the merged map to those candidate forms is lookup-equivalent + to the full map: every key the body can derive is present with the same value + (project return taints win over ``call_tm`` on conflict, matching the previous + ``dict(call_tm); update(project_return_taints)`` precedence), and never-derivable + keys cannot be consulted. Extra candidates that happen to exist in the merged map + are included harmlessly (the full map contained them too). + """ + chains: set[str] = set() + attrs: set[str] = set() + for n in ast.walk(node): + if isinstance(n, ast.Name): + chains.add(n.id) + elif isinstance(n, ast.Attribute): + attrs.add(n.attr) + parts: list[str] = [] + cur: ast.expr = n + while isinstance(cur, ast.Attribute): + parts.append(cur.attr) + cur = cur.value + if isinstance(cur, ast.Name): + parts.append(cur.id) + chains.add(".".join(reversed(parts))) + forms: set[str] = set(chains) + for chain in chains: + root, _, rest = chain.partition(".") + target = alias_map.get(root) + if target is not None: + forms.add(f"{target}.{rest}" if rest else target) + if module_prefix: + forms.add(f"{module_prefix}.{chain}") + if len(forms) * (len(attrs) + 1) > _CANDIDATE_KEY_BUDGET: + merged = dict(call_tm) + merged.update(project_return_taints) + return merged + + tm: dict[str, TaintState] = {} + + def _take(key: str) -> None: + value = project_return_taints.get(key) + if value is None: + value = call_tm.get(key) + if value is not None: + tm[key] = value + + for form in forms: + _take(form) + for attr in attrs: + _take(f"{form}.{attr}") + return tm + + +def _with_module_global_params( + node: ast.FunctionDef | ast.AsyncFunctionDef, + param_meets: dict[str, TaintState] | None, + global_seeds: dict[str, TaintState] | None, +) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, dict[str, TaintState] | None]: + """Present raw MODULE GLOBALS to one function's L2 walk as implicit parameters. + + The L2 walk resolves a bare name as ``var_taints.get(name, function_taint)`` — + so an unassigned module-global read would otherwise inherit the trusted caller + seed (laundering the module-level taint, wardline-66b2c91470). Rather than + threading a new seed channel through the engine, the raw globals the body + references are appended as SYNTHETIC keyword-only parameters on a shallow + wrapper node — sharing the ORIGINAL body/decorator statement objects, so the + ``id()``-keyed call-site maps stay valid for every downstream consumer — and + their taints are delivered through the existing ``param_meets`` channel. + Semantically, module globals enter the function exactly like implicit + parameters carrying the module-level taint: a function-local assignment then + shadows the seed flow-sensitively, like any reassigned parameter. Names + already bound as real parameters are skipped (a parameter shadows the global + for the whole scope), as are globals the body never mentions (no dead + ``var_taints`` entries). + """ + if not global_seeds: + return node, param_meets + args = node.args + existing = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)} + if args.vararg is not None: + existing.add(args.vararg.arg) + if args.kwarg is not None: + existing.add(args.kwarg.arg) + referenced = {n.id for n in ast.walk(node) if isinstance(n, ast.Name)} + names = sorted(name for name in global_seeds if name in referenced and name not in existing) + if not names: + return node, param_meets + synthetic = [ast.copy_location(ast.arg(arg=name), node) for name in names] + new_args = ast.arguments( + posonlyargs=list(args.posonlyargs), + args=list(args.args), + vararg=args.vararg, + kwonlyargs=[*args.kwonlyargs, *synthetic], + kw_defaults=[*args.kw_defaults, *([None] * len(synthetic))], + kwarg=args.kwarg, + defaults=list(args.defaults), + ) + wrapper = type(node)( + name=node.name, + args=new_args, + body=node.body, + decorator_list=node.decorator_list, + returns=node.returns, + type_comment=node.type_comment, + type_params=list(getattr(node, "type_params", [])), + ) + ast.copy_location(wrapper, node) + merged = dict(param_meets or {}) + for name in names: + merged[name] = combine(merged[name], global_seeds[name]) if name in merged else global_seeds[name] + return wrapper, merged + class WardlineAnalyzer: """SP1 analyzer implementing core.protocols.Analyzer.""" @@ -127,6 +273,11 @@ def _analyze_inner(self, files: Sequence[Path], config: WardlineConfig, *, root: file_meta = parse_stage.files parse_findings = list(parse_stage.parse_findings) dirty_modules = set(parse_stage.dirty_modules) + # Entity-qualname seeds applied in the parse stage ARE the directive taking + # effect — count them as matched, or a working untrusted_sources entry that + # names a project function is misreported as WLN-CONFIG-UNUSED-SOURCE (only + # the import/alias path in build_call_taint_map recorded matches before). + matched_sources.update(parse_stage.matched_config_sources) # Use the SHADOW-AWARE provider fingerprint computed during the parse stage # for BOTH the dirty-detection key (above, inside the parse stage) AND the @@ -161,6 +312,27 @@ def _analyze_inner(self, files: Sequence[Path], config: WardlineConfig, *, root: # @trust_boundary validator) body != return, and using body here would # mis-read validated output as raw (over-taint -> PY-WL-101 false positive). project_return_taints = dict(result.return_taint_map) + + # Nested-def RETURN taints, bucketed by their enclosing entity — injected + # into the enclosing function's per-entity taint map under the helper's + # BARE name. The L2 bare-name resolution otherwise treats an already- + # analyzed local helper (``m.f..clean``) as an unknown callee and + # applies the worst-arg conservatism, turning every local validate/parse + # helper into a PY-WL-101 ERROR FP (2026-06-10 review; the launder-closing + # conservatism of wardline-93d608c997 is preserved for genuinely-unknown + # bare names). This pass-1 seed uses the L1 return tiers; the fixed-point + # loop below overlays the PRECISE L2 actual-return taints once they exist + # (an undecorated ``def clean(x): return 1`` is UNKNOWN_RAW at L1 but + # INTEGRAL at L2). Built once per scan — a per-entity scan of the project + # map would reintroduce the O(n^2) hotspot the pruned taint map removed. + # Lexically sound: a nested def shadows any module-level/imported callable + # of the same name for the whole enclosing scope. + nested_def_returns: dict[str, dict[str, TaintState]] = {} + for nested_qn, nested_taint in project_return_taints.items(): + enclosing_qn, sep, bare = nested_qn.rpartition("..") + if sep and "." not in bare: + nested_def_returns.setdefault(enclosing_qn, {})[bare] = nested_taint + project_by_module: dict[str, dict[str, TaintState]] = {} for parsed in file_meta: module = parsed.module @@ -331,6 +503,7 @@ def _run_l2( alias_map: dict[str, str], param_meets: dict[str, TaintState] | None = None, module_prefix: str | None = None, + global_seeds: dict[str, TaintState] | None = None, ) -> tuple[ dict[int, dict[str, TaintState]], dict[int, dict[int | str | None, TaintState]], @@ -338,6 +511,8 @@ def _run_l2( TaintState | None, str | None, ]: + # Module-global taint channel: raw module globals enter as implicit params. + node, param_meets = _with_module_global_params(node, param_meets, global_seeds) result = run_l2_function_stage( L2FunctionInput( node=node, @@ -373,8 +548,77 @@ def _store( function_return_taints.pop(qn, None) function_return_callee[qn] = ret_callee + # ── Module-scope channels (wardline-66b2c91470 / wardline-13cfdd7b31) ── + # (a) module-level name bindings (callable aliases / constructed instances) for + # the sink rules' binding-aware resolution, exposed on the context; + # (b) module-global RAW seeds — module-level names assigned from a raw source at + # import time, presented to each function's L2 walk as implicit raw parameters. + module_sink_bindings: dict[str, SinkBindings] = {} + module_global_taints: dict[str, dict[str, TaintState]] = {} + for parsed in file_meta: + module_sink_bindings[parsed.module] = collect_sink_bindings(parsed.tree, parsed.alias_map, parsed.module) + global_raw_seeds = collect_module_global_raw_seeds( + parsed.tree, + module=parsed.module, + alias_map=parsed.alias_map, + return_taints=project_return_taints, + local_fqns=frozenset(ent.qualname for ent in parsed.entities), + untrusted_sources=frozenset(config.untrusted_sources), + ) + if global_raw_seeds: + module_global_taints[parsed.module] = global_raw_seeds + # ── L2 pass 1 — per-method var/return taints + per-class attribute summary ── all_classes = frozenset(c for parsed in file_meta for c in parsed.class_qualnames) + failed_paths: set[str] = set() + function_skip_recorded: set[str] = set() + + def _record_file_failure(relpath: str, ent: Entity, exc: Exception) -> None: + # Per-file isolation, mirroring the Rust frontend's WLN-ENGINE-FILE-FAILED: + # an unexpected engine exception on ONE file's analysis must not abort the + # whole scan (losing every other file's findings) — and must not silently + # skip either. Fail closed: a gate-eligible ERROR DEFECT names the file + # (rule id already in UNANALYZED_RULE_IDS, so it counts toward + # ScanSummary.unanalyzed). One finding per file — the fingerprint is keyed + # on the relpath (the Rust contract), so a second failing entity in the + # same file must not mint a colliding distinct finding. ``line_start`` + # falls back to the entity line so the lineless-DEFECT downgrade + # (suppression.py) never demotes it back out of the gate. + l2_failed.add(ent.qualname) + if relpath in failed_paths: + return + failed_paths.add(relpath) + func_skip_findings.append( + Finding( + rule_id="WLN-ENGINE-FILE-FAILED", + message=f"{relpath}: analysis failed at {ent.qualname} ({type(exc).__name__}: {exc})", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=relpath, line_start=ent.location.line_start or 1), + fingerprint=_fp("WLN-ENGINE-FILE-FAILED", relpath), + qualname=ent.qualname, + properties={"reason": "analysis_exception", "exception": type(exc).__name__}, + ) + ) + + def _record_l2_recursion(ent: Entity) -> None: + l2_failed.add(ent.qualname) + if ent.qualname in function_skip_recorded: + return + function_skip_recorded.add(ent.qualname) + func_skip_findings.append( + Finding( + rule_id="WLN-ENGINE-FUNCTION-SKIPPED", + message=f"{ent.qualname}: skipped L2 — expression too deep to analyze safely", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=ent.location, + fingerprint=_fp("WLN-ENGINE-FUNCTION-SKIPPED", ent.qualname), + qualname=ent.qualname, + properties={"reason": "recursion_limit"}, + ) + ) + for parsed in file_meta: module = parsed.module entities = parsed.entities @@ -391,62 +635,97 @@ def _store( matched_sources=matched_sources, matched_sanitisers=matched_sanitisers, ) + # Per-class sibling RETURN-taint entries, built ONCE per module (the + # previous per-method rescan of all module entities was O(entities) per + # method). A caller observes a sibling's RETURN taint via self./cls. + sibling_tm: dict[str, dict[str, TaintState]] = {} + for ent in entities: + enclosing = ent.qualname.rsplit(".", 1)[0] + if enclosing in classes: + sib_name = ent.qualname[len(enclosing) + 1 :] + sib_taint = project_return_taints.get(ent.qualname, TaintState.UNKNOWN_RAW) + bucket = sibling_tm.setdefault(enclosing, {}) + bucket[f"self.{sib_name}"] = sib_taint + bucket[f"cls.{sib_name}"] = sib_taint for ent in entities: entity_index[ent.qualname] = ent seed = project_taints.get(ent.qualname, TaintState.UNKNOWN_RAW) - method_tm = dict(call_tm) - method_tm.update(project_return_taints) enclosing_class = ent.qualname.rsplit(".", 1)[0] is_method = enclosing_class in classes - if is_method: - sib_prefix = enclosing_class + "." - for sib in entities: - if sib.qualname.startswith(sib_prefix) and "." not in sib.qualname[len(sib_prefix) :]: - sib_name = sib.qualname[len(sib_prefix) :] - sib_taint = project_return_taints.get(sib.qualname, TaintState.UNKNOWN_RAW) - method_tm[f"self.{sib_name}"] = sib_taint - method_tm[f"cls.{sib_name}"] = sib_taint + # Attribute writes are recorded DURING the L2 walk (per-statement + # var_taints, branch-aware receiver types) — a post-hoc second walk + # against the FINAL var_taints laundered reassigned-after-write RHS + # variables and branch-rebound receivers (wardline-b369c7d06c). + recorded_writes: dict[str, dict[str, TaintState]] = {} + writes: dict[str, dict[str, TaintState]] = {} + method_tm: dict[str, TaintState] = {} try: - call_sites, call_args, var_taints, ret_taint, ret_callee = _run_l2( - ent.node, seed, method_tm, alias_map, module_prefix=module + method_tm = _pruned_method_taint_map(ent.node, alias_map, module, call_tm, project_return_taints) + if is_method: + method_tm.update(sibling_tm.get(enclosing_class, {})) + # Own nested defs shadow same-named module/imported callables + # for the whole scope, so they layer LAST (see nested_def_returns). + method_tm.update(nested_def_returns.get(ent.qualname, {})) + with attribute_write_recording(recorded_writes): + call_sites, call_args, var_taints, ret_taint, ret_callee = _run_l2( + ent.node, + seed, + method_tm, + alias_map, + module_prefix=module, + global_seeds=module_global_taints.get(module), + ) + writes = project_attribute_writes( + recorded_writes, all_classes, enclosing_class if is_method else None ) except RecursionError: - l2_failed.add(ent.qualname) - call_sites, call_args, var_taints, ret_taint, ret_callee = {}, {}, {}, None, None - func_skip_findings.append( - Finding( - rule_id="WLN-ENGINE-FUNCTION-SKIPPED", - message=f"{ent.qualname}: skipped L2 — expression too deep to analyze safely", - severity=Severity.NONE, - kind=Kind.FACT, - location=ent.location, - fingerprint=_fp("WLN-ENGINE-FUNCTION-SKIPPED", ent.qualname), - qualname=ent.qualname, - properties={"reason": "recursion_limit"}, - ) + _record_l2_recursion(ent) + call_sites, call_args, var_taints, ret_taint, ret_callee = ( + {}, + {}, + {}, + TaintState.UNKNOWN_RAW, + None, ) + writes = {} + except MemoryError: + raise # exhaustion is not a per-file condition — isolating it would thrash + except Exception as exc: # noqa: BLE001 — per-file isolation, see _record_file_failure + call_sites, call_args, var_taints, ret_taint, ret_callee = {}, {}, {}, None, None + writes = {} + _record_file_failure(parsed.relpath, ent, exc) _store(ent.qualname, call_sites, call_args, var_taints, ret_taint, ret_callee) project_call_site_arg_taints.update(call_args) l2_records.append((ent, seed, method_tm, enclosing_class, alias_map, module, is_method)) - if ent.qualname not in l2_failed: - from wardline.scanner.taint.variable_level import collect_attribute_writes - - writes = collect_attribute_writes( - ent.node, - seed, - dict(method_tm), - dict(var_taints), - all_classes, - alias_map, - module, - enclosing_class=enclosing_class if is_method else None, - ) - for target_class, cls_writes in writes.items(): - summary = class_attr_taints.setdefault(target_class, {}) - for attr_name, attr_taint in cls_writes.items(): - summary[attr_name] = ( - combine(summary[attr_name], attr_taint) if attr_name in summary else attr_taint - ) + for target_class, cls_writes in writes.items(): + summary = class_attr_taints.setdefault(target_class, {}) + for attr_name, attr_taint in cls_writes.items(): + summary[attr_name] = ( + combine(summary[attr_name], attr_taint) if attr_name in summary else attr_taint + ) + + # Module-global taint channel, WRITE direction: a function assigning raw to a + # declared ``global g`` marks the module global; the fixed-point loop below + # re-runs every function with the merged seeds, so OTHER functions reading + # ``g`` inherit. ONE merge hop only (documented approximation): the write + # taints are read from pass 1 and stay FIXED through the loop — a raw value + # routed global→function→second global needs a second hop and is a bounded + # FN. The write taint is the function's FINAL (exit-state) L2 taint for the + # name; only RAW_ZONE writes are recorded (the channel propagates raw, it + # never upgrades a module-level raw seed to clean), and seeds combine + # least-trusted-wins with the import-time seeds. + for ent, _seed, _tm, _enclosing_class, _alias_map, module, _is_method in l2_records: + if ent.qualname in l2_failed: + continue + global_names = own_scope_global_names(ent.node) + if not global_names: + continue + final_vars = function_var_taints.get(ent.qualname, {}) + for name in global_names: + write_taint = final_vars.get(name) + if write_taint is not None and write_taint in RAW_ZONE: + bucket = module_global_taints.setdefault(module, {}) + bucket[name] = combine(bucket[name], write_taint) if name in bucket else write_taint # Compute initial project-wide parameter meets from pass-1 call sites project_param_meets: dict[str, dict[str, TaintState]] = {} @@ -467,6 +746,19 @@ def _store( else: callee_meets[param] = taint + def _l2_nested_def_overlay() -> dict[str, dict[str, TaintState]]: + """Per-enclosing-entity nested-def bare-name map from the PRECISE L2 + actual-return taints (``function_return_taints``); see the pass-1 + ``nested_def_returns`` seed for the rationale. Recomputed per fixed- + point iteration so a helper chain converges; participates in the memo + key and the convergence check below.""" + overlay: dict[str, dict[str, TaintState]] = {} + for nested_qn, nested_taint in function_return_taints.items(): + enclosing_qn, sep, bare = nested_qn.rpartition("..") + if sep and "." not in bare: + overlay.setdefault(enclosing_qn, {})[bare] = nested_taint + return overlay + # ── Iterative Fixed-point L2 Loop to converge parameters and attributes ── l2_iteration_bound = _l2_iteration_bound(l2_records) l2_converged = False @@ -475,6 +767,7 @@ def _store( for _iteration in range(l2_iteration_bound): old_class_attr_taints = {k: dict(v) for k, v in class_attr_taints.items()} old_project_param_meets = {k: dict(v) for k, v in project_param_meets.items()} + nested_def_overlay = _l2_nested_def_overlay() # Run L2 pass on all functions with current class_attr_taints and project_param_meets class_attr_taints = {} @@ -482,39 +775,61 @@ def _store( for ent, seed, method_tm, enclosing_class, alias_map, module, is_method in l2_records: if ent.qualname in l2_failed: continue - tm_iter = dict(method_tm) attr_summary = old_class_attr_taints.get(enclosing_class) - if attr_summary: - for attr_name, attr_taint in attr_summary.items(): - tm_iter[f"self.{attr_name}"] = attr_taint - tm_iter[f"cls.{attr_name}"] = attr_taint param_meets = old_project_param_meets.get(ent.qualname) + nested_map = nested_def_overlay.get(ent.qualname) + # ``seed``/``method_tm`` are fixed per entity, so the memo key carries + # only the iteration-varying inputs (see ``_L2InputKey``) — and on a + # hit the O(per-function) ``tm_iter`` copy is skipped entirely. inputs_key = ( - seed, - tuple(sorted(tm_iter.items())), + tuple(sorted(attr_summary.items())) if attr_summary else None, tuple(sorted(param_meets.items())) if param_meets else None, + tuple(sorted(nested_map.items())) if nested_map else None, ) if last_l2_inputs.get(ent.qualname) == inputs_key: call_sites, call_args, var_taints, ret_taint, ret_callee, writes = last_l2_results[ent.qualname] else: + tm_iter = dict(method_tm) + if attr_summary: + for attr_name, attr_taint in attr_summary.items(): + tm_iter[f"self.{attr_name}"] = attr_taint + tm_iter[f"cls.{attr_name}"] = attr_taint + if nested_map: + # Own nested defs shadow same-named module/imported callables + # (and the pass-1 L1 seed) for the whole scope — layered last. + tm_iter.update(nested_map) + recorded_writes = {} try: - call_sites, call_args, var_taints, ret_taint, ret_callee = _run_l2( - ent.node, seed, tm_iter, alias_map, param_meets=param_meets, module_prefix=module - ) - from wardline.scanner.taint.variable_level import collect_attribute_writes - - writes = collect_attribute_writes( - ent.node, - seed, - dict(tm_iter), - dict(var_taints), - all_classes, - alias_map, - module, - enclosing_class=enclosing_class if is_method else None, + with attribute_write_recording(recorded_writes): + # ``module_global_taints`` is FIXED during this loop (merged + # once after pass 1), so the ``inputs_key`` memo stays valid. + call_sites, call_args, var_taints, ret_taint, ret_callee = _run_l2( + ent.node, + seed, + tm_iter, + alias_map, + param_meets=param_meets, + module_prefix=module, + global_seeds=module_global_taints.get(module), + ) + writes = project_attribute_writes( + recorded_writes, all_classes, enclosing_class if is_method else None ) except RecursionError: + _record_l2_recursion(ent) + call_sites, call_args, var_taints, ret_taint, ret_callee, writes = ( + {}, + {}, + {}, + TaintState.UNKNOWN_RAW, + None, + {}, + ) + except MemoryError: + raise # exhaustion is not a per-file condition — isolating it would thrash + except Exception as exc: # noqa: BLE001 — per-file isolation, see _record_file_failure + _record_file_failure(ent.location.path, ent, exc) continue last_l2_inputs[ent.qualname] = inputs_key last_l2_results[ent.qualname] = (call_sites, call_args, var_taints, ret_taint, ret_callee, writes) @@ -548,8 +863,14 @@ def _store( else: callee_meets[param] = taint - # Break if class_attr_taints and project_param_meets did not change - if class_attr_taints == old_class_attr_taints and project_param_meets == old_project_param_meets: + # Break if class_attr_taints, project_param_meets, and the nested-def + # return overlay did not change (the overlay feeds tm_iter, so a still- + # moving helper chain must keep iterating). + if ( + class_attr_taints == old_class_attr_taints + and project_param_meets == old_project_param_meets + and _l2_nested_def_overlay() == nested_def_overlay + ): l2_converged = True break @@ -580,6 +901,22 @@ def _store( ) ) + # The registry is built BEFORE the context so the selected rule-id set can + # ride on it (PY-WL-120's suppress-and-delegate consults enablement; a + # duck-typed registry seam without a ``rules`` property yields None — + # "unknown", the historical assume-enabled posture). + registry = ( + self._registry + if self._registry is not None + else build_default_registry(config, rules=(self._grammar.rules if self._grammar is not None else None)) + ) + registry_rules = getattr(registry, "rules", None) + enabled_rule_ids = ( + frozenset(str(getattr(rule, "rule_id", type(rule).__name__)) for rule in registry_rules) + if registry_rules is not None + else None + ) + context = AnalysisContext( project_taints=project_taints, project_return_taints=project_return_taints, @@ -587,15 +924,22 @@ def _store( function_call_site_taints=function_call_site_taints, function_call_site_arg_taints=function_call_site_arg_taints, call_site_callees=result.call_site_callees, + call_site_candidate_callees=result.call_site_candidate_callees, class_attr_taints=class_attr_taints, function_return_taints=function_return_taints, function_return_callee=function_return_callee, entities=entity_index, taint_provenance=dict(result.taint_provenance), declared_qualnames=frozenset(q for m in modules for q, s in m.seeds.items() if s.source == "provider"), + declared_body_taints={ + q: s.body_taint for m in modules for q, s in m.seeds.items() if s.source == "provider" + }, project_edges=result.project_edges, call_site_implicit_receivers=result.call_site_implicit_receivers, alias_maps={m.module_path: m.alias_map for m in modules}, + analyzed_source_sha256={parsed.relpath: parsed.source_sha256 for parsed in file_meta}, + module_bindings=module_sink_bindings, + enabled_rule_ids=enabled_rule_ids, ) self.last_context = context @@ -644,13 +988,71 @@ def _store( properties={"sanitiser": san}, ) ) + # A sanitiser colliding with a modelled serialisation sink would otherwise be + # dropped silently; the collision FACT speaks instead of UNUSED-SANITISER. + findings.extend(build_sanitiser_collision_findings(config.sanitisers)) - registry = ( - self._registry - if self._registry is not None - else build_default_registry(config, rules=(self._grammar.rules if self._grammar is not None else None)) - ) - findings.extend(registry.run(context)) + # Per-rule isolation: one crashing rule must not abort the whole scan and + # silently lose every other rule's findings. Each rule runs in its own + # single-rule registry (reusing RuleRegistry.run's maturity stamping); a + # raise becomes a gate-eligible ERROR DEFECT at ENGINE_PATH (the lineless + # downgrade exempts ENGINE_PATH, so it trips --fail-on ERROR — fail-closed, + # same posture as WLN-ENGINE-FINGERPRINT-COLLISION). Duck-typed registry + # seams without a ``rules`` property keep the undelegated single call. + if registry_rules is None: + findings.extend(registry.run(context)) + else: + for rule in registry_rules: + solo = RuleRegistry() + solo.register(rule) + try: + findings.extend(solo.run(context)) + except Exception as exc: # noqa: BLE001 — per-rule isolation, see above + rid = str(getattr(rule, "rule_id", type(rule).__name__)) + findings.append( + Finding( + rule_id="WLN-ENGINE-RULE-FAILED", + message=( + f"rule {rid} aborted ({type(exc).__name__}: {exc}) — " + "its findings are missing from this scan" + ), + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=ENGINE_PATH), + fingerprint=_fp("WLN-ENGINE-RULE-FAILED", rid), + properties={"rule": rid, "exception": type(exc).__name__}, + ) + ) + # Sink-argument resolution degraded to the pessimistic flow-INSENSITIVE + # fallback somewhere (an L2-skipped function): surface the recorded set as + # ONE NONE/FACT finding per scan, mirroring WLN-ENGINE-FUNCTION-SKIPPED. A + # finding, not a UserWarning — MCP/library consumers see the degradation, + # and a warnings-as-error embedder cannot turn the diagnostic into a + # rule-aborting raise (review 2026-06-10). + if context.flow_insensitive_fallbacks: + findings.append( + Finding( + rule_id="WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK", + message=( + "sink-argument taint resolution fell back to the pessimistic " + f"flow-insensitive map for {len(context.flow_insensitive_fallbacks)} " + "function(s) — their sink findings assume UNKNOWN_RAW arguments" + ), + severity=Severity.NONE, + kind=Kind.FACT, + location=Location(path=ENGINE_PATH), + # Keyed on the engine path only (never the affected qualnames) so the + # one-per-scan FACT keeps a stable identity as the set churns. + fingerprint=_fp("WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK", ENGINE_PATH), + properties={"qualnames": sorted(context.flow_insensitive_fallbacks)}, + ) + ) + # Proactive no-collision guard (wardline-8fb773a7af): every fingerprint + # consumer joins on Finding.fingerprint as a unique key, so two DISTINCT + # findings sharing one is a silent false-negative. Run last, over the full + # emitted set, and append a loud ENGINE DEFECT per collision. Its own input + # is the pre-append list, so the guard never sees (or collides with) itself. + findings.extend(build_collision_findings(findings)) return findings diff --git a/src/wardline/scanner/context.py b/src/wardline/scanner/context.py index 3031d8ab..d0b23270 100644 --- a/src/wardline/scanner/context.py +++ b/src/wardline/scanner/context.py @@ -23,6 +23,7 @@ from wardline.core.finding import Finding, Severity from wardline.core.taints import TaintState from wardline.scanner.index import Entity + from wardline.scanner.rules._sink_helpers import SinkBindings from wardline.scanner.taint.propagation import TaintProvenance @@ -47,13 +48,17 @@ class AnalysisContext: to a source dict) cannot mutate engine output. ``project_return_taints`` is the effective return tier per function (anchored: - declared; non-anchored: refined body). ``function_return_taints`` is the actual - least-trusted returned-value taint per function, computed from L2 variable - analysis — the precise input for PY-WL-101. ``function_return_callee`` is the - callee that contributed each function's actual (least-trusted) return taint, or - ``None`` when that worst return path is not a direct call (``return p`` / - ``return some_var`` — chain resolution is deferred to SP9). This is the property - ``explain_finding`` reports as the immediate tainted callee. + declared; non-anchored: refined body). ``declared_body_taints`` is the provider's + original body tier for declared functions only, before L3 propagation refines the + body map. Rules that need declaration shape (for example, distinguishing + ``@trust_boundary`` from a leaky ``@trusted`` producer) must read this snapshot, + not ``project_taints``. ``function_return_taints`` is the actual least-trusted + returned-value taint per function, computed from L2 variable analysis — the + precise input for PY-WL-101. ``function_return_callee`` is the callee that + contributed each function's actual (least-trusted) return taint, or ``None`` when + that worst return path is not a direct call (``return p`` / ``return some_var`` — + chain resolution is deferred to SP9). This is the property ``explain_finding`` + reports as the immediate tainted callee. """ project_taints: Mapping[str, TaintState] @@ -78,6 +83,11 @@ class AnalysisContext: # Bound method call sites whose explicit args start after an implicit receiver: # ``{id(call): "instance" | "class"}``. call_site_implicit_receivers: Mapping[int, str] = field(default_factory=dict) + # Branch-conditional dispatch: the FULL candidate callee set at a call site whose + # receiver may hold >1 project class (``{id(call): frozenset(callee_qn)}``). A + # superset of ``call_site_callees`` for those sites; sink rules consult it so they + # fire on any trusted-sink candidate regardless of AST order (wardline-499c22bbdd). + call_site_candidate_callees: Mapping[int, frozenset[str]] = field(default_factory=dict) # Cross-method class-attribute summary (closure A): ``{class_qualname: {attr: taint}}``, # the least-trusted value written to ``self.`` across the class's methods. Rules # resolve a ``self.``/``cls.`` read against it. Defaulted for direct @@ -88,11 +98,42 @@ class AnalysisContext: # denominator. Additive + defaulted so direct constructions/tests need not # supply it; frozenset is already immutable so no proxy wrap is needed. declared_qualnames: frozenset[str] = frozenset() + # Provider-declared body tier for trust-surface functions, before L3 callgraph + # refinement. ``project_taints`` is the propagated body tier and may become raw + # for a leaky ``@trusted`` producer; this declaration snapshot stays stable. + declared_body_taints: Mapping[str, TaintState] = field(default_factory=dict) # Inter-module call edges: ``{caller: frozenset({callees})}``. Defaulted for # direct constructions; absence means no project edges available. project_edges: Mapping[str, frozenset[str]] = field(default_factory=dict) # Import alias maps per module: ``{module: {alias: target_fqn}}``. alias_maps: Mapping[str, Mapping[str, str]] = field(default_factory=dict) + # SHA-256 of the exact source bytes parsed for each project-relative file. Optional + # consumers that stamp external facts use this to refuse post-scan disk races without + # making the scanner depend on optional BLAKE3. + analyzed_source_sha256: Mapping[str, str] = field(default_factory=dict) + # MODULE-SCOPE name bindings per module: ``{module: SinkBindings}`` — module-level + # callable aliases (``runner = subprocess.run``) and constructed instances + # (``client = httpx.Client()``), collected from each module's top-level scope by the + # analyzer. The sink machinery layers a function's own bindings OVER these + # (``resolved_sink_calls(..., module_bindings=...)``), closing the documented + # module-level false negatives (wardline-13cfdd7b31 / wardline-66b2c91470). + # Defaulted so direct constructions (tests) need not supply it; absence degrades to + # function-scope-only binding resolution. + module_bindings: Mapping[str, SinkBindings] = field(default_factory=dict) + # Rule ids selected for THIS run (``rules.enable``), or ``None`` when unknown + # (direct constructions / duck-typed registry seams without a ``rules`` + # property). A rule that suppresses-and-delegates to a sibling (PY-WL-120 → + # PY-WL-101) consults this so it never delegates to a rule that will not run; + # ``None`` preserves the historical assume-enabled behavior. + enabled_rule_ids: frozenset[str] | None = None + # Per-scan degradation channel: qualnames whose sink-argument resolution fell + # back to the pessimistic flow-INSENSITIVE map (no L2 snapshot — an L2-skipped + # function). ``resolved_arg_taints`` records here instead of warning from + # inside rule ``check()`` calls; the analyzer surfaces the collected set as ONE + # ``WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK`` NONE/FACT finding per scan. + # Deliberately a mutable set on a + # frozen context: it is a diagnostics side channel, not engine output. + flow_insensitive_fallbacks: set[str] = field(default_factory=set) def __post_init__(self) -> None: object.__setattr__(self, "project_taints", _freeze_mapping(self.project_taints)) @@ -114,7 +155,13 @@ def __post_init__(self) -> None: "call_site_implicit_receivers", _freeze_mapping(self.call_site_implicit_receivers), ) + object.__setattr__( + self, + "call_site_candidate_callees", + _freeze_mapping(self.call_site_candidate_callees), + ) object.__setattr__(self, "class_attr_taints", _freeze_mapping(self.class_attr_taints)) + object.__setattr__(self, "declared_body_taints", _freeze_mapping(self.declared_body_taints)) object.__setattr__( self, "project_edges", @@ -125,6 +172,9 @@ def __post_init__(self) -> None: "alias_maps", _freeze_mapping(self.alias_maps), ) + object.__setattr__(self, "analyzed_source_sha256", _freeze_mapping(self.analyzed_source_sha256)) + # SinkBindings values are frozen dataclasses — only the outer map needs the proxy. + object.__setattr__(self, "module_bindings", _freeze_mapping(self.module_bindings)) class _RuleClass(Protocol): diff --git a/src/wardline/scanner/diagnostics.py b/src/wardline/scanner/diagnostics.py index db435a17..6e126f84 100644 --- a/src/wardline/scanner/diagnostics.py +++ b/src/wardline/scanner/diagnostics.py @@ -28,26 +28,20 @@ "weft_markers": frozenset({"external_boundary", "trust_boundary", "trusted"}), } -# Declarative native / first-party module prefixes. An import whose dotted module -# equals one of these or sits under it resolves cleanly EVEN WHEN it has no Python -# AST in the scanned tree — e.g. a compiled ``wardline.core`` extension after the -# Rust migration, which is definitionally unresolvable to an AST import analyzer. -# This is the SEAM the Rust migration extends: add the compiled submodule's dotted -# prefix here. Distinct from _BUILTIN_MARKER_IMPORTS (alias-specific, for the -# statically-modelled marker decorators); this is "any import from this prefix is -# first-party, resolve it". Matching is on dotted-component boundaries, so -# ``wardline.core`` does NOT swallow an unrelated ``wardline.core_helpers``. -_NATIVE_FIRST_PARTY_PREFIXES: frozenset[str] = frozenset( - { - "wardline.core", - "wardline.decorators", - } -) - - -def _is_native_first_party(mod: str) -> bool: - """True if ``mod`` is, or is under, a declared native/first-party prefix.""" - return any(mod == prefix or mod.startswith(prefix + ".") for prefix in _NATIVE_FIRST_PARTY_PREFIXES) +# Declarative native / first-party imports. A compiled module may have no Python +# AST in the scanned tree, so exact known exports can resolve cleanly even when +# absent from ``project_modules``. This is alias-specific on purpose: a broad +# ``wardline.core`` or ``wardline.decorators`` prefix would hide misspelled exports +# and bogus submodules, which are real diagnostic coverage gaps. +_NATIVE_FIRST_PARTY_IMPORTS: dict[str, frozenset[str]] = { + "wardline.core.registry": frozenset({"REGISTRY", "REGISTRY_VERSION", "RegistryEntry"}), +} + + +def _is_native_first_party_import(mod: str, alias: str) -> bool: + """True for exact declared native/first-party imports.""" + names = _NATIVE_FIRST_PARTY_IMPORTS.get(mod) + return names is not None and alias in names # code -> (rule_id, severity, kind) @@ -55,6 +49,7 @@ def _is_native_first_party(mod: str) -> bool: "L3_CONVERGENCE_BOUND": ("WLN-L3-CONVERGENCE-BOUND", Severity.WARN, Kind.METRIC), "L3_MONOTONICITY_VIOLATION": ("WLN-L3-MONOTONICITY-VIOLATION", Severity.ERROR, Kind.DEFECT), "L3_LOW_RESOLUTION": ("WLN-L3-LOW-RESOLUTION", Severity.INFO, Kind.METRIC), + "DUPLICATE_FQN": ("WLN-ENGINE-DUPLICATE-FQN", Severity.ERROR, Kind.DEFECT), } @@ -108,6 +103,96 @@ def build_diagnostic_findings(diagnostics: list[tuple[str, str]]) -> list[Findin return findings +def build_collision_findings(findings: list[Finding]) -> list[Finding]: + """Defense-in-depth fingerprint-collision guard (wardline-8fb773a7af). + + The soundness bar "two DISTINCT findings must never share a fingerprint" is + otherwise asserted only by a COMMENT (``finding.py``) and by the REACTIVE + identity corpus (which only covers shapes a fixture happens to exercise — + PY-WL-114's collision lived undetected precisely because no fixture hit it). + This is the PROACTIVE guard: it runs over the full emitted finding set at the + single analyzer chokepoint and converts a silent mask into a loud signal. + + Why it matters: every fingerprint consumer treats ``Finding.fingerprint`` as a + UNIQUE join key. ``baseline.generate_baseline`` collapses same-fp findings with + ``setdefault`` (keep first); ``judged`` is last-write-wins; the baseline/waiver/ + judged YAML loaders REJECT a duplicate fingerprint outright; SARIF + (``partialFingerprints``) and Filigree dedup downstream. So when two findings + that a consumer would treat as DIFFERENT share a fingerprint, one silently + masks the other on all four joins — a real trust-boundary false-negative. + + Distinctness oracle: ``Finding.to_jsonl()`` (deterministic, ``sort_keys``) + captures exactly the consumer-visible surface. Two findings in a same-fp group + with differing ``to_jsonl()`` are a lossy collision; byte-identical findings are + a benign duplicate (collapsing loses nothing) and do NOT fire — a rule may + legitimately emit the same finding twice. + + Posture: one ``WLN-ENGINE-FINGERPRINT-COLLISION`` DEFECT per colliding + fingerprint, ``Severity.ERROR`` at ``ENGINE_PATH`` — the same engine-soundness + posture as ``WLN-L3-MONOTONICITY-VIOLATION``. A lineless DEFECT at + ``ENGINE_PATH`` is NOT downgraded to a non-gating FACT (``suppression.py`` only + downgrades lineless DEFECTs OFF ``ENGINE_PATH``), so this trips ``--fail-on + ERROR``: fail-loud / deconfliction-first. It is engine-diagnostic kind, so the + identity corpus (PY-WL-* ∧ DEFECT only) excludes it and the frozen contract is + untouched. Each diagnostic's own fingerprint is keyed on the colliding fp, so + the guard's own output can never collide. + + Scope boundary: this sees the analyzer-return population. The lineless-DEFECT + downgrade in ``suppression.py`` runs DOWNSTREAM, so this guard does not police + findings minted after ``analyze()`` returns. + """ + by_fp: dict[str, list[Finding]] = {} + for f in findings: + by_fp.setdefault(f.fingerprint, []).append(f) + + out: list[Finding] = [] + for fp in sorted(by_fp): + group = by_fp[fp] + if len(group) < 2: + continue + # Benign exact-duplicates collapse to one canonical form; a collision has >=2. + # ``to_jsonl()`` is the SINGLE distinctness key — both the count and the + # member list derive from it, so a collision that differs only in + # properties/severity (not in the 5-tuple below) is still counted AND listed. + reps: dict[str, Finding] = {} + for f in group: + reps.setdefault(f.to_jsonl(), f) + if len(reps) < 2: + continue + distinct = [reps[k] for k in sorted(reps)] + summary = "; ".join(f"{f.rule_id}@{f.location.path}:{f.location.line_start}" for f in distinct) + out.append( + Finding( + rule_id="WLN-ENGINE-FINGERPRINT-COLLISION", + message=( + f"{len(distinct)} distinct findings share fingerprint {fp} — one silently masks " + f"the other on the baseline/waiver/judge/Filigree joins: {summary}" + ), + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=_ENGINE_PATH), + fingerprint=_fingerprint("WLN-ENGINE-FINGERPRINT-COLLISION", fp), + properties={ + "colliding_fingerprint": fp, + "finding_count": len(distinct), + "members": [ + { + "rule_id": f.rule_id, + "path": f.location.path, + "line_start": f.location.line_start, + "qualname": f.qualname, + "severity": f.severity.value, + "message": f.message, + "properties": dict(f.properties), + } + for f in distinct + ], + }, + ) + ) + return out + + def build_unknown_import_findings( file_trees: list[tuple[str, str, ast.Module]], *, @@ -131,7 +216,7 @@ def build_unknown_import_findings( stdlib_keys=stdlib_keys, resolvable_star_modules=resolvable_star_modules, ): - package = detail.split()[1] if detail.startswith("from ") else detail + package = detail.split()[1] if detail.startswith(("from ", "import ")) else detail findings.append( Finding( rule_id="WLN-ENGINE-UNKNOWN-IMPORT", @@ -219,6 +304,8 @@ def diagnose_unknown_imports( import, de-duplicated by ``(source_module, target_package)``. Triggers: + * ``import X`` where ``X`` is not a project module, not a Python stdlib + module, and no stdlib_taint entry has X as its package. * ``from X import *`` where ``X`` is not a project module, not a Python stdlib module, and no stdlib_taint entry has X as its package. @@ -237,6 +324,30 @@ def diagnose_unknown_imports( findings: list[tuple[str, str, str]] = [] seen: set[tuple[str, str]] = set() for node in ast.walk(tree): + if isinstance(node, ast.Import): + if _is_type_checking_guarded(node, tree): + continue + for alias in node.names: + mod = alias.name + if not mod: + continue + if mod in project_modules: + continue + if _is_stdlib_module(mod): + continue + if any(key[0] == mod for key in stdlib_keys): + continue + key = (module_path, mod) + if key not in seen: + seen.add(key) + findings.append( + ( + module_path, + f"import {mod}", + f"external import {mod!r} cannot be resolved", + ) + ) + continue if not isinstance(node, ast.ImportFrom): continue # Skip relative imports entirely — they resolve inside the @@ -257,13 +368,6 @@ def diagnose_unknown_imports( continue if mod in project_modules: continue - # Skip declared native / first-party modules. A compiled wardline.core - # extension has no Python AST so it is absent from project_modules, but it - # is first-party, not an external precision gap — resolve it via the - # declarative allowlist. (Only DECLARED prefixes; a genuine unknown - # third-party import still falls through to a FACT below.) - if _is_native_first_party(mod): - continue # Skip Python stdlib modules — any import whose top-level name # appears in ``sys.stdlib_module_names`` is resolvable at runtime # by definition and is not a precision gap. @@ -294,6 +398,8 @@ def diagnose_unknown_imports( for alias in node.names: if _is_builtin_marker_import(mod, alias.name): continue + if _is_native_first_party_import(mod, alias.name): + continue if (mod, alias.name) in stdlib_keys: continue unresolved_aliases.append(alias.name) diff --git a/src/wardline/scanner/grammar.py b/src/wardline/scanner/grammar.py index 821e847a..b0962cf3 100644 --- a/src/wardline/scanner/grammar.py +++ b/src/wardline/scanner/grammar.py @@ -17,7 +17,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING @@ -28,6 +28,7 @@ if TYPE_CHECKING: # Annotation-only (lazy under `from __future__ import annotations`); kept out of # the runtime import surface so the zero-dep contract above stays literally true. + from wardline.core.finding import Finding from wardline.scanner.context import _RuleClass _VOCAB_PREFIX = "wardline.decorators" @@ -173,6 +174,52 @@ def extend( ) +def build_sanitiser_collision_findings(sanitisers: Iterable[str]) -> list[Finding]: + """WLN-CONFIG-* diagnostic: configured sanitisers shadowed by a serialisation sink. + + A config sanitiser whose dotted name IS a built-in serialisation sink (e.g. + ``json.loads``) can never take effect: the sink override is inserted into the + call-taint map before the config pass (which uses ``setdefault``), and + ``_resolve_call`` consults the sink set ahead of the taint map. Yet the + sanitiser still generates map keys and so counts as "matched", which + suppresses ``WLN-CONFIG-UNUSED-SANITISER`` — the declaration becomes a silent + no-op. This emits one ``WLN-CONFIG-SANITISER-SINK-COLLISION`` FACT per + colliding sanitiser (sorted, deterministic), naming the collision so the user + learns their suppression attempt was overridden, not honoured. + + Pure function of the config value (no scan state); the analyzer appends the + result alongside the other ``WLN-CONFIG-*`` diagnostics. + """ + # Local imports: keep this module's runtime import surface exactly the grammar + # meta-model (same pattern as default_grammar's rules import). + from wardline.core.finding import Finding, Kind, Location, Severity, compute_finding_fingerprint + from wardline.scanner.taint.variable_level import _SERIALISATION_SINKS + + findings: list[Finding] = [] + for san in sorted(set(sanitisers) & _SERIALISATION_SINKS): + findings.append( + Finding( + rule_id="WLN-CONFIG-SANITISER-SINK-COLLISION", + message=( + f"Configuration error: sanitiser '{san}' collides with the built-in " + "serialisation sink of the same name; the conservative sink " + "classification (UNKNOWN_RAW) takes precedence, so this sanitiser " + "declaration has no effect" + ), + severity=Severity.NONE, + kind=Kind.FACT, + location=Location(path="weft.toml"), + fingerprint=compute_finding_fingerprint( + rule_id="WLN-CONFIG-SANITISER-SINK-COLLISION", + path="weft.toml", + taint_path=san, + ), + properties={"sanitiser": san}, + ) + ) + return findings + + def default_grammar() -> TrustGrammar: """The builtin grammar: the 3 boundary types + the 4 rule classes, in today's exact order. The byte-identity oracle (design spec §5) pins this == today.""" diff --git a/src/wardline/scanner/pipeline.py b/src/wardline/scanner/pipeline.py index 0e165144..ed8ac58d 100644 --- a/src/wardline/scanner/pipeline.py +++ b/src/wardline/scanner/pipeline.py @@ -41,6 +41,7 @@ class ParsedFile: entities: tuple[Entity, ...] alias_map: dict[str, str] class_qualnames: frozenset[str] + source_sha256: str @dataclass(frozen=True, slots=True) @@ -60,6 +61,11 @@ class ParseProjectOutput: parse_findings: list[Finding] dirty_modules: frozenset[str] provider_fingerprint: str + # config.untrusted_sources entries that matched a PROJECT ENTITY QUALNAME and + # were applied as seeds here. The analyzer unions these into its matched-source + # bookkeeping — without this channel a WORKING directive was misreported as + # WLN-CONFIG-UNUSED-SOURCE (only the import/alias path recorded matches). + matched_config_sources: frozenset[str] = frozenset() def _provider_fingerprint_for_project(provider: TaintSourceProvider, project_modules: frozenset[str]) -> str: @@ -84,6 +90,7 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu parsed_files: list[ParsedFile] = [] parse_findings: list[Finding] = [] dirty_modules: set[str] = set() + matched_config_sources: set[str] = set() root = stage_input.root.resolve() # The set of dotted module names in the scan. Used to fail closed for builtin @@ -122,7 +129,9 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu try: source = path.read_text(encoding="utf-8") source_bytes = source.encode("utf-8") + source_sha256 = hashlib.sha256(source_bytes).hexdigest() + from wardline.core.ruleset import ruleset_hash from wardline.scanner.taint.project_resolver import _RESOLVER_VERSION from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION, compute_cache_key @@ -132,6 +141,7 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu schema_version=SUMMARY_SCHEMA_VERSION, resolver_version=_RESOLVER_VERSION, provider_fingerprint=provider_fingerprint, + scan_policy_hash=ruleset_hash(stage_input.config), ) if stage_input.summary_cache is None or not stage_input.summary_cache.has_current(cache_key): dirty_modules.add(module) @@ -155,6 +165,9 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu if ent.qualname in stage_input.config.untrusted_sources: from wardline.scanner.taint.function_level import FunctionSeed + # The seed below IS the directive taking effect — record the match + # so the analyzer's unused-source diagnostic does not contradict it. + matched_config_sources.add(ent.qualname) seeds[ent.qualname] = FunctionSeed( qualname=ent.qualname, body_taint=TaintState.EXTERNAL_RAW, @@ -163,15 +176,26 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu unprovable_boundaries=(), ) except (SyntaxError, UnicodeDecodeError, OSError) as exc: + # A discovered-but-unparseable file is a GATE-ELIGIBLE ERROR DEFECT, not a + # NONE FACT: its sinks were never analyzed, so a default `--fail-on ERROR` + # reading GREEN over it is a fail-open (e.g. a latin-1 coding cookie that + # CPython runs but this UTF-8 reader rejects hides live code from the scan). + # Severity ERROR so the documented agent loop (`scan . --fail-on ERROR`) + # trips — the secure-by-default posture (same as the suppression gate and + # WLN-ENGINE-FINGERPRINT-COLLISION). Repository baseline/waiver still + # ANNOTATE it but cannot clear the secure gate; `--trust-suppressions` can + # (an explicit operator trust decision). ``line_start`` falls back to 1 so + # the lineless-DEFECT downgrade (suppression.py) never demotes it back out + # of the gate for read/encoding errors that carry no line. msg = getattr(exc, "msg", None) or str(exc) lineno = exc.lineno if isinstance(exc, SyntaxError) else None parse_findings.append( Finding( rule_id="WLN-ENGINE-PARSE-ERROR", message=f"{relpath}: could not read/parse ({msg})", - severity=Severity.NONE, - kind=Kind.FACT, - location=Location(path=relpath, line_start=lineno), + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=relpath, line_start=lineno or 1), fingerprint=_fp("WLN-ENGINE-PARSE-ERROR", relpath), properties={"module": module}, ) @@ -182,14 +206,32 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu Finding( rule_id="WLN-ENGINE-FILE-SKIPPED", message=f"{relpath}: skipped — expression too deep to analyze safely", - severity=Severity.NONE, - kind=Kind.FACT, - location=Location(path=relpath), + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=relpath, line_start=1), fingerprint=_fp("WLN-ENGINE-FILE-SKIPPED", relpath), properties={"module": module, "reason": "recursion_limit"}, ) ) continue + except Exception as exc: # noqa: BLE001 — per-file isolation, mirrors the Rust frontend + # An UNEXPECTED exception while indexing/seeding one file must not abort + # the whole scan (losing every other file's findings) — and must not be a + # silent skip either. Fail closed: a WLN-ENGINE-FILE-FAILED ERROR DEFECT + # (gate-eligible, counted toward ScanSummary.unanalyzed) names the file, + # and the scan continues — the Rust frontend's per-file contract. + parse_findings.append( + Finding( + rule_id="WLN-ENGINE-FILE-FAILED", + message=f"{relpath}: analysis failed ({type(exc).__name__}: {exc})", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=relpath, line_start=1), + fingerprint=_fp("WLN-ENGINE-FILE-FAILED", relpath), + properties={"module": module, "reason": "analysis_exception", "exception": type(exc).__name__}, + ) + ) + continue modules.append( ModuleInput( @@ -209,6 +251,7 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu entities=entities, alias_map=alias_map, class_qualnames=classes, + source_sha256=source_sha256, ) ) for ent in entities: @@ -238,6 +281,7 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu parse_findings=parse_findings, dirty_modules=frozenset(dirty_modules), provider_fingerprint=provider_fingerprint, + matched_config_sources=frozenset(matched_config_sources), ) diff --git a/src/wardline/scanner/rules/__init__.py b/src/wardline/scanner/rules/__init__.py index fc96a9c9..aac657bb 100644 --- a/src/wardline/scanner/rules/__init__.py +++ b/src/wardline/scanner/rules/__init__.py @@ -35,8 +35,14 @@ from wardline.scanner.rules.untrusted_to_deserialization import UntrustedToDeserialization from wardline.scanner.rules.untrusted_to_exec import UntrustedToExec from wardline.scanner.rules.untrusted_to_import import UntrustedToImport +from wardline.scanner.rules.untrusted_to_log import UntrustedToLog +from wardline.scanner.rules.untrusted_to_mail import UntrustedToMail +from wardline.scanner.rules.untrusted_to_native import UntrustedToNative +from wardline.scanner.rules.untrusted_to_reflection import UntrustedToReflection from wardline.scanner.rules.untrusted_to_shell_subprocess import UntrustedToShellSubprocess +from wardline.scanner.rules.untrusted_to_template import UntrustedToTemplate from wardline.scanner.rules.untrusted_to_trusted_callee import UntrustedReachesTrustedCallee +from wardline.scanner.rules.untrusted_to_xml import UntrustedToXML if TYPE_CHECKING: from wardline.core.config import WardlineConfig @@ -64,6 +70,13 @@ SQLInjection, DegenerateBoundary, StoredTaint, + # PY-WL-121…126 — the 2026-06-10 coverage-gap sink families (all PREVIEW). + UntrustedToXML, + UntrustedToTemplate, + UntrustedToReflection, + UntrustedToNative, + UntrustedToLog, + UntrustedToMail, ) @@ -77,6 +90,7 @@ rule_id=_POLICY_CONFIG_RULE_ID, base_severity=Severity.ERROR, kind=Kind.DEFECT, + multi_emit=True, description="Project policy configuration weakens or disables Wardline policy rules.", ) @@ -101,9 +115,10 @@ def _policy_config_finding(message: str, *, reason: str, taint_path: str, **prop fingerprint=compute_finding_fingerprint( rule_id=_POLICY_CONFIG_RULE_ID, path=ENGINE_PATH, - line_start=None, taint_path=taint_path, ), + # OLD (wlfp1) taint_path == NEW (unchanged by P3), but ephemeral — recompute for rekey (P4). + taint_path_v0=taint_path, properties={"reason": reason, **properties}, ) diff --git a/src/wardline/scanner/rules/_ast_helpers.py b/src/wardline/scanner/rules/_ast_helpers.py index 88335c67..6cffd647 100644 --- a/src/wardline/scanner/rules/_ast_helpers.py +++ b/src/wardline/scanner/rules/_ast_helpers.py @@ -4,7 +4,10 @@ All helpers operate on a single function's *own* scope — they never descend into nested ``FunctionDef``/``AsyncFunctionDef``/``ClassDef`` bodies, so a finding is attributed to the function that lexically owns the construct (nested functions -are separate entities and are analysed in their own right). +are separate entities and are analysed in their own right). The single sanctioned +exception is :func:`rejecting_helper_calls`, a ONE-HOP, SAME-MODULE inspection of +a called helper's body — bounded interprocedural sight so factored-out validators +(``_require_nonempty(p)``) are not misread as "no rejection path". """ from __future__ import annotations @@ -13,10 +16,21 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Mapping + + from wardline.scanner.index import Entity _BROAD_NAMES: frozenset[str] = frozenset({"Exception", "BaseException"}) +# CURATED raising-conversion callables: constructors that raise (ValueError / +# decimal.InvalidOperation / ...) on EVERY invalid input and return a value of +# guaranteed shape — the canonical validate-by-construction idiom +# (``@trust_boundary def to_port(p): return int(p)``). Deliberately a small +# allowlist matched by (possibly dotted) final name: treating ARBITRARY calls as +# rejections would be an FN hole (any helper call would silence PY-WL-102). +# ``str``/``bool``/``repr`` are absent on purpose — they accept everything. +_RAISING_CONVERSION_NAMES: frozenset[str] = frozenset({"int", "float", "complex", "Decimal", "Fraction", "UUID"}) + def _own_statements(node: ast.AST) -> Iterator[ast.stmt]: """Yield every statement in *node*'s own scope, not descending into nested @@ -29,6 +43,86 @@ def _own_statements(node: ast.AST) -> Iterator[ast.stmt]: yield from _own_statements(child) +def _own_reachable_statements(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.stmt]: + yield from _reachable_statements_in_block(node.body) + + +def _own_reachable_nodes(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]: + for stmt in _own_reachable_statements(node): + yield from _own_nodes_in_reachable_stmt(stmt) + + +def _own_nodes_in_reachable_stmt(stmt: ast.stmt) -> Iterator[ast.AST]: + yield stmt + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return + yield from _walk_own_non_stmt_children(stmt) + + +def _walk_own_non_stmt_children(node: ast.AST) -> Iterator[ast.AST]: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + yield child + elif isinstance(child, ast.stmt): + continue + else: + yield child + yield from _walk_own_non_stmt_children(child) + + +def _reachable_statements_in_block(stmts: list[ast.stmt]) -> Iterator[ast.stmt]: + for stmt in stmts: + yield stmt + if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + for block in _child_statement_blocks(stmt): + yield from _reachable_statements_in_block(block) + if _stmt_always_terminates(stmt): + break + + +def _child_statement_blocks(stmt: ast.stmt) -> Iterator[list[ast.stmt]]: + if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While)): + yield stmt.body + yield stmt.orelse + elif isinstance(stmt, (ast.With, ast.AsyncWith)): + yield stmt.body + elif isinstance(stmt, (ast.Try, ast.TryStar)): + yield stmt.body + yield stmt.orelse + yield stmt.finalbody + for handler in stmt.handlers: + yield handler.body + elif isinstance(stmt, ast.Match): + for case in stmt.cases: + yield case.body + + +def _block_always_terminates(stmts: list[ast.stmt]) -> bool: + return any(_stmt_always_terminates(stmt) for stmt in stmts) + + +def _match_has_irrefutable_case(stmt: ast.Match) -> bool: + return any( + isinstance(case.pattern, ast.MatchAs) and case.pattern.pattern is None and case.guard is None + for case in stmt.cases + ) + + +def _stmt_always_terminates(stmt: ast.stmt) -> bool: + if isinstance(stmt, (ast.Return, ast.Raise)): + return True + if isinstance(stmt, ast.If): + return ( + bool(stmt.body) + and bool(stmt.orelse) + and _block_always_terminates(stmt.body) + and _block_always_terminates(stmt.orelse) + ) + if isinstance(stmt, ast.Match): + return _match_has_irrefutable_case(stmt) and all(_block_always_terminates(case.body) for case in stmt.cases) + return False + + 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): @@ -86,61 +180,286 @@ def _is_falsy_constant_return(value: ast.expr | None) -> bool: return False +def _is_raising_conversion(value: ast.expr) -> bool: + """True for a CURATED raising-expression: a :data:`_RAISING_CONVERSION_NAMES` + constructor applied to at least one non-constant argument (``int(p)`` — a + constant argument validates nothing), or a Subscript lookup with a + non-constant key (``Color[p]`` raises ``KeyError``; ``ALLOWED[p]`` likewise — + a CONSTANT index like ``parts[0]`` is positional access, not a validating + lookup of the input).""" + if isinstance(value, ast.Call): + func = value.func + if isinstance(func, ast.Name): + name = func.id + elif isinstance(func, ast.Attribute): + name = func.attr + else: + return False + return name in _RAISING_CONVERSION_NAMES and any(not isinstance(arg, ast.Constant) for arg in value.args) + if isinstance(value, ast.Subscript): + return isinstance(value.value, (ast.Name, ast.Attribute)) and not isinstance(value.slice, ast.Constant) + return False + + +def _is_rejection_return(value: ast.expr | None) -> bool: + """A ``return`` value that constitutes a rejection path: a falsy constant, a + conditional expression with a rejecting branch (``return m.group(0) if m else + None`` is the ternary form of ``if not m: return None``), or a curated + raising-conversion (``return int(p)`` rejects-by-construction).""" + if _is_falsy_constant_return(value): + return True + if isinstance(value, ast.IfExp): + return _is_rejection_return(value.body) or _is_rejection_return(value.orelse) + return value is not None and _is_raising_conversion(value) + + +def _stmt_is_real_rejection(stmt: ast.stmt) -> bool: + """A statement that rejects IN PRODUCTION: a ``raise`` or a rejection-shaped + ``return``. Excludes ``assert`` (stripped under ``python -O`` — PY-WL-111's + hazard, never a *real* rejection).""" + if isinstance(stmt, ast.Raise): + return True + return isinstance(stmt, ast.Return) and _is_rejection_return(stmt.value) + + +def has_real_rejection(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """True when *node*'s own scope contains a production-surviving rejection — + a ``raise`` or a rejection-shaped ``return`` — i.e. NOT counting ``assert``. + This is PY-WL-113's premise half: a rejection must EXIST (and survive ``-O``) + before a fail-open handler can be said to defeat it.""" + return any(_stmt_is_real_rejection(stmt) for stmt in _own_reachable_statements(node)) + + def has_rejection_path(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: - """True when *node* can reject: any ``raise``, any falsy-constant ``return``, - or any ``assert`` in its own scope. Deliberately generous — PY-WL-102 is - always-on, so we err toward SEEING a rejection path (risk a missed finding) - over firing on a real validator. + """True when *node* can reject: any ``raise``, any rejection-shaped ``return`` + (falsy constant, rejecting ternary branch, curated raising-conversion), or any + ``assert`` in its own scope. Deliberately generous — PY-WL-102 is always-on, + so we err toward SEEING a rejection path (risk a missed finding) over firing + on a real validator. ``assert`` counts as a rejection here so PY-WL-102 does NOT fire on a boundary whose only reject is an assert — that boundary DOES reject at runtime. The distinct hazard (asserts are stripped under ``python -O``, so the validation silently vanishes in production) is PY-WL-111's job, via - :func:`asserts_are_sole_rejection`. The two rules partition the space cleanly: - 102 fires on "no rejection of any shape", 111 on "the only rejection is an - assert" — never both on one boundary.""" - for stmt in _own_statements(node): - if isinstance(stmt, (ast.Raise, ast.Assert)): - return True - if isinstance(stmt, ast.Return) and _is_falsy_constant_return(stmt.value): - return True - return False + :func:`asserts_are_sole_rejection`. + + **The boundary-integrity family partitions FOUR ways** (wardline-718048a518), + at most one of which fires per boundary: + - PY-WL-119 — the bare degenerate shape (:func:`is_degenerate_boundary`); + - PY-WL-102 — any other shape with no rejection path of any kind; + - PY-WL-111 — the only rejection is ``assert``; + - PY-WL-113 — a real rejection exists but a fail-open handler defeats it. + """ + return any( + isinstance(stmt, ast.Assert) or _stmt_is_real_rejection(stmt) for stmt in _own_reachable_statements(node) + ) def asserts_are_sole_rejection(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: """True when *node*'s ONLY rejection mechanism is ``assert`` — at least one - ``assert`` in its own scope, and NO ``raise`` and NO falsy-constant ``return``. + ``assert`` in its own scope, and NO real rejection (``raise`` / + rejection-shaped ``return``). This is PY-WL-111's predicate: such a boundary validates in development but is stripped under ``python -O`` (CWE-617), so the rejection silently vanishes in production. Mutually exclusive with PY-WL-102 (which fires only when - :func:`has_rejection_path` is False, i.e. NO assert either).""" + :func:`has_rejection_path` is False, i.e. NO assert either). Callers that can + see the project context additionally consult :func:`rejecting_helper_calls` — + a raising same-module helper survives ``-O`` and rescues the boundary.""" has_assert = False - for stmt in _own_statements(node): - if isinstance(stmt, ast.Raise): - return False - if isinstance(stmt, ast.Return) and _is_falsy_constant_return(stmt.value): + for stmt in _own_reachable_statements(node): + if _stmt_is_real_rejection(stmt): return False if isinstance(stmt, ast.Assert): has_assert = True return has_assert +def is_degenerate_boundary(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """True for the bare degenerate boundary: the body is (modulo docstrings / + ``pass``) a single ``return ``. PY-WL-119's shape — a strict subset of + "no rejection path", carved out of PY-WL-102's domain so the family partitions + cleanly (119 wins on this shape; 102 owns every other no-rejection shape).""" + param_names = {arg.arg for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)} + if node.args.vararg: + param_names.add(node.args.vararg.arg) + if node.args.kwarg: + param_names.add(node.args.kwarg.arg) + + non_trivial_stmts = [ + stmt for stmt in node.body if not isinstance(stmt, ast.Pass) and not _is_ellipsis_or_constant(stmt) + ] + if len(non_trivial_stmts) == 1 and isinstance(non_trivial_stmts[0], ast.Return): + ret_val = non_trivial_stmts[0].value + if isinstance(ret_val, ast.Name) and ret_val.id in param_names: + return True + return False + + +def _resolve_one_hop_callee( + call: ast.Call, + entity: Entity, + entities: Mapping[str, Entity], + call_site_callees: Mapping[int, str], +) -> Entity | None: + """Resolve *call* to a SAME-MODULE project entity, or None. + + The engine's resolved target (``call_site_callees``) wins when present; a + resolved CROSS-module callee is deliberately discarded (one-hop stays + same-module — cheap and conservative). Otherwise a lexical fallback maps a + bare ``helper(...)`` or single-attribute ``Cls.method(...)`` callee onto the + boundary's enclosing qualname prefixes, shortest first (module scope is what + a bare name actually resolves to; class scopes are tried later only as a + static/class-method courtesy).""" + resolved = call_site_callees.get(id(call)) + if resolved is not None: + callee = entities.get(resolved) + if callee is not None and callee.location.path == entity.location.path and callee.qualname != entity.qualname: + return callee + return None + func = call.func + if isinstance(func, ast.Name): + suffix = func.id + elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): + suffix = f"{func.value.id}.{func.attr}" + else: + return None + parts = entity.qualname.split(".") + for i in range(1, len(parts)): + candidate = entities.get(".".join((*parts[:i], suffix))) + if ( + candidate is not None + and candidate.location.path == entity.location.path + and candidate.qualname != entity.qualname + ): + return candidate + return None + + +def rejecting_helper_calls( + entity: Entity, + entities: Mapping[str, Entity], + call_site_callees: Mapping[int, str], +) -> frozenset[int]: + """The ``id()``s of own-scope ``Call`` nodes in *entity* that resolve (one hop, + same module) to a callee whose OWN body has a real rejection — a factored-out + validator (``_require_nonempty(p)``), a raising staticmethod helper, or + wholesale delegation to another raising boundary (``return inner(p)``). + + Bounded interprocedural sight for the boundary-integrity family: such a call + IS the boundary's rejection path, so PY-WL-102/111 must stay silent and + PY-WL-113 can locate the rejection inside a ``try``. Strictly ONE hop (the + callee's body is inspected with the own-scope :func:`has_real_rejection`, + never recursively) and strictly same-module (same ``location.path``). + + SOUNDNESS: the callee must have a REAL rejection — a helper that cannot raise + (logs and returns) never counts, and an assert-only helper never counts (its + assert vanishes under ``python -O`` exactly like an inline one, which would + falsely silence PY-WL-111).""" + ids: set[int] = set() + for n in _own_reachable_nodes(entity.node): + if isinstance(n, ast.Call): + callee = _resolve_one_hop_callee(n, entity, entities, call_site_callees) + if callee is not None and has_real_rejection(callee.node): + ids.add(id(n)) + return frozenset(ids) + + +def _own_reachable_nodes_in_blocks(stmts: list[ast.stmt]) -> Iterator[ast.AST]: + for stmt in _reachable_statements_in_block(stmts): + yield from _own_nodes_in_reachable_stmt(stmt) + + +def block_has_real_rejection(stmts: list[ast.stmt], rejecting_call_ids: frozenset[int] = frozenset()) -> bool: + """True when the statement list *stmts* (a ``try`` body or handler body) + lexically contains a reachable real rejection — a ``raise`` / rejection-shaped + ``return`` in its own scope, or a reachable call whose ``id()`` is in + *rejecting_call_ids* (a one-hop rejecting helper, see + :func:`rejecting_helper_calls`). PY-WL-113's per-``try`` premise: a handler + can only swallow a rejection that lives inside its own ``try``.""" + for stmt in _reachable_statements_in_block(stmts): + if _stmt_is_real_rejection(stmt): + return True + if rejecting_call_ids: + for n in _own_reachable_nodes_in_blocks(stmts): + if isinstance(n, ast.Call) and id(n) in rejecting_call_ids: + return True + return False + + def _contains_reraise(handler: ast.ExceptHandler) -> bool: """True if *handler* re-raises anywhere in its own body (bare ``raise`` / ``raise X`` / ``raise X from e``). Does not descend into nested def/class.""" return any(isinstance(stmt, ast.Raise) for stmt in _own_statements(handler)) -def handler_substitutes_on_failure(handler: ast.ExceptHandler) -> bool: - """FAIL-OPEN: does not re-raise and returns a value-bearing (non-rejection) - result. A falsy/bare ``return`` is a REJECTION signal, not substitution, so it - does not match; a (even conditional) re-raise never matches. PY-WL-113.""" +def returned_var_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> frozenset[str]: + """The set of local names *node* returns by value (``return result``) anywhere in + its own scope. Used to recognise the assign-then-fall-through substitution shape + in :func:`handler_substitutes_on_failure` — a handler that rebinds a name the + function later returns substitutes a value just as ``return name`` would.""" + return frozenset( + stmt.value.id + for stmt in _own_statements(node) + if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.Name) + ) + + +def _is_self_assignment(stmt: ast.Assign) -> bool: + """True for an idempotent ``x = x`` — the RHS is a bare Name equal to a target. + Such an assignment substitutes nothing new, so it is not a fail-open substitution.""" + return isinstance(stmt.value, ast.Name) and any( + isinstance(t, ast.Name) and t.id == stmt.value.id for t in stmt.targets + ) + + +def handler_substitutes_on_failure(handler: ast.ExceptHandler, returned_names: frozenset[str] = frozenset()) -> bool: + """FAIL-OPEN: does not re-raise and substitutes a value-bearing (non-rejection) + result. Two substitution shapes match, both bypassing the boundary: + + - an in-handler ``return X`` of a non-falsy value (``return p`` / ``return DEFAULT``); + - an in-handler ASSIGNMENT of a non-falsy value to a name in *returned_names* — + a name the owning function then returns by FALL-THROUGH (``result = p`` here, + ``return result`` after the ``try``). Structurally identical to the return form. + + A falsy/bare ``return`` (or assignment of a falsy value) is a REJECTION signal, not + substitution, so it does not match; a (even conditional) re-raise never matches. + + The assignment shape is FALL-THROUGH only: it is gated on the handler having no + UNCONDITIONAL (top-level) ``return`` of its own, because a handler that always exits via + its own ``return`` (a rejecting ``return None`` or a value-return handled by the first + branch) never lets an in-handler assignment escape to the function's post-``try`` return + (wardline-c314a7140b panel-1: ``result = p`` then ``return None`` is fail-CLOSED). The + gate is TOP-LEVEL only (``handler.body``, NOT the depth-descending ``_own_statements``): + a merely CONDITIONAL ``return`` nested in an ``if`` does not prevent fall-through, so the + assignment can still escape on the other path and must match (panel-2: ``if flag: return + None`` then ``result = p``). A self-assignment (``result = result``) substitutes nothing + new and is excluded. *returned_names* defaults empty, so the assignment shape is inert + unless the caller supplies the function's fall-through-returned names. PY-WL-113.""" if _contains_reraise(handler): return False - return any( - isinstance(stmt, ast.Return) and not _is_falsy_constant_return(stmt.value) for stmt in _own_statements(handler) - ) + handler_returns = any(isinstance(s, ast.Return) for s in handler.body) + for stmt in _own_statements(handler): + if isinstance(stmt, ast.Return) and not _is_falsy_constant_return(stmt.value): + return True + if handler_returns: + continue # the handler exits via its own return — no assignment falls through + if ( + isinstance(stmt, ast.Assign) + and not _is_falsy_constant_return(stmt.value) + and not _is_self_assignment(stmt) + and any(isinstance(t, ast.Name) and t.id in returned_names for t in stmt.targets) + ): + return True + if ( + isinstance(stmt, ast.AnnAssign) + and stmt.value is not None + and not _is_falsy_constant_return(stmt.value) + and isinstance(stmt.target, ast.Name) + and stmt.target.id in returned_names + ): + return True + return False def own_nodes(node: ast.AST) -> Iterator[ast.AST]: diff --git a/src/wardline/scanner/rules/_sink_helpers.py b/src/wardline/scanner/rules/_sink_helpers.py index f830eac7..8b5076a8 100644 --- a/src/wardline/scanner/rules/_sink_helpers.py +++ b/src/wardline/scanner/rules/_sink_helpers.py @@ -1,10 +1,13 @@ # src/wardline/scanner/rules/_sink_helpers.py -"""Shared helpers for the dangerous-sink rules (PY-WL-106/107/108). +"""Shared machinery for the dangerous-sink rules (106/107/108/112/115/116/117/121-126). A "sink rule" fires when raw-zone data reaches a named dangerous call (a -deserialization / dynamic-exec / OS-command sink) inside a trusted-tier function. -These helpers find the sink calls in a function's own scope, canonicalize their -names through the module import alias map, and resolve the taint of their arguments. +deserialization / dynamic-exec / OS-command / SSRF / XML / template / native-load / +log / mail sink) inside a trusted-tier function. These helpers find the sink calls +in a function's own scope, canonicalize their names through the module import alias +map and the name-binding maps, and resolve the taint of their arguments; the +consolidated :class:`TaintedSinkRule` base (review 2026-06-10) is THE single check +loop the whole family runs on. **Argument-taint resolution is FLOW-SENSITIVE.** Each sink call reads the engine's per-call-site resolved argument taints (`function_call_site_arg_taints` — the taint of @@ -19,6 +22,7 @@ from __future__ import annotations import ast +from dataclasses import dataclass, field from typing import TYPE_CHECKING from wardline.core.finding import Finding, Kind, Location, Severity @@ -30,18 +34,61 @@ from collections.abc import Iterator, Mapping from wardline.scanner.context import AnalysisContext + from wardline.scanner.index import Entity from wardline.scanner.rules.metadata import RuleMetadata __all__ = [ "RAW_ZONE", + "ArgSpec", + "SinkBindings", "TaintedSinkRule", + "build_sink_finding", "canonical_call_name", + "collect_ctor_call_nodes", + "collect_sink_bindings", "dotted_name", + "enclosing_declared_tier", + "module_alias_map", + "module_for_qualname", + "receiver_ctor_call", + "resolve_bound_call_fqn", + "resolved_arg_taints", + "resolved_sink_calls", "sink_calls", + "sink_method_calls", "worst_arg_taint", + "worst_dangerous_arg_taint", ] +def enclosing_declared_tier( + qualname: str, + project_taints: Mapping[str, TaintState], + declared_qualnames: frozenset[str], +) -> TaintState: + """Trust tier governing *qualname*, honoring a nested def's OWN trust decorator. + + Walks outward through ``..`` enclosing scopes and returns the tier of the + nearest scope that carries an explicit trust DECLARATION (``declared_qualnames`` — the + trust surface). So a nested def with its own decorator uses its own tier + (wardline-bb8396f96e), while a genuinely undeclared nested def inherits the nearest + declared enclosing scope's tier (wardline-9b88ec5419). Falls back to the full qualname's + own tier (defaulting ``UNKNOWN_RAW`` → developer-freedom) when no scope in the lexical + chain is declared. + + Keying off the explicit-declaration set — not a tier heuristic — is what distinguishes + "this def declared its own trust" from "this def is undeclared and should inherit": an + undeclared nested def is registered in ``project_taints`` (typically ``UNKNOWN_RAW``) yet + is absent from ``declared_qualnames``, so the walk correctly steps past it to the parent. + """ + parts = qualname.split("..") + for i in range(len(parts), 0, -1): + candidate = "..".join(parts[:i]) + if candidate in declared_qualnames: + return project_taints.get(candidate, TaintState.UNKNOWN_RAW) + return project_taints.get(qualname, TaintState.UNKNOWN_RAW) + + def dotted_name(node: ast.expr) -> str | None: """Reconstruct a dotted call name: ``eval`` (Name) / ``pickle.loads`` (Attribute). @@ -87,6 +134,26 @@ def _own_calls(node: ast.AST) -> Iterator[ast.Call]: yield from _own_calls(child) +def _direct_sink_fqn( + call: ast.Call, + sink_names: frozenset[str], + aliases: dict[str, str], + module_prefix: str, +) -> str | None: + """Canonical sink FQN of *call* when its func is a direct dotted spelling + (``eval`` / ``pickle.loads`` / an import-aliased form) in *sink_names*, else None.""" + from wardline.scanner.ast_primitives import resolve_call_fqn + + fqn = resolve_call_fqn(call, aliases, frozenset(), module_prefix) + if fqn is not None and fqn in sink_names: + return fqn + dotted = dotted_name(call.func) + if dotted is None: + return None + canonical = canonical_call_name(dotted, aliases) + return canonical if canonical in sink_names else None + + def sink_calls( func_node: ast.AST, sink_names: frozenset[str], @@ -95,23 +162,295 @@ def sink_calls( ) -> Iterator[tuple[ast.Call, str]]: """Yield ``(call, dotted_name)`` for own-scope calls whose func resolves to a canonical name in *sink_names* (matches both ``eval`` and ``pickle.loads`` forms).""" - from wardline.scanner.ast_primitives import resolve_call_fqn - aliases = dict(alias_map or {}) for call in _own_calls(func_node): - fqn = resolve_call_fqn(call, aliases, frozenset(), module_prefix) - if fqn is not None and fqn in sink_names: + fqn = _direct_sink_fqn(call, sink_names, aliases, module_prefix) + if fqn is not None: yield call, fqn + + +def sink_method_calls(func_node: ast.AST, method_names: frozenset[str]) -> Iterator[ast.Call]: + """Yield own-scope calls whose func is an attribute access ``recv.`` whose method + name is in *method_names* (e.g. ``cursor.execute``). + + Descends into lambda bodies (via :func:`_own_calls`) so a sink wrapped in a lambda is not + missed — the same lambda-traversal the dotted-FQN :func:`sink_calls` path already has; does + not enter nested def/class scopes (those are indexed as their own entities). For rules that + key on the METHOD NAME regardless of receiver (PY-WL-118), as opposed to canonical dotted + sink FQNs.""" + for call in _own_calls(func_node): + if isinstance(call.func, ast.Attribute) and call.func.attr in method_names: + yield call + + +@dataclass(frozen=True) +class SinkBindings: + """Statically-known name bindings collected from one lexical scope. + + ``instance_classes`` maps a local var to the resolved FQN of the call that + constructed it (``c = httpx.Client()`` → ``{"c": "httpx.Client"}``); a method + call on the var then resolves to ``.``. The constructor FQN + is recorded WITHOUT verifying it names a class — a non-class FQN (``c = g()`` + → ``"m.g"``/``"g"``) yields method names like ``"g.get"`` that match no sink, + so the over-approximation is silent, never a false sink match. + + ``callable_aliases`` maps a local var assigned DIRECTLY from a resolvable + dotted callable (``runner = subprocess.run``) to that callable's FQN; a + bare-name call of the var then participates in sink matching under the FQN. + + A name lives in at most one of the two maps (binding one kind evicts the other). + """ + + instance_classes: Mapping[str, str] = field(default_factory=dict) + callable_aliases: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ArgSpec: + """Which argument slots of a sink are dangerous (taint-relevant). + + ``positions`` are 0-based positional indices; ``keywords`` are keyword names. + A sink with NO spec keeps the historical "worst of ALL args" behavior; a spec + that names no slots (empty tuples) means "no dangerous slots" and never + resolves a taint. Declare BOTH spellings of a slot that can be passed either + way (e.g. ``requests.get`` → ``positions=(0,), keywords=("url",)``). + """ + + positions: tuple[int, ...] = () + keywords: tuple[str, ...] = () + + +def _resolve_dotted(expr: ast.expr, aliases: Mapping[str, str]) -> str | None: + """Canonical dotted FQN of a pure Name/Attribute chain through the alias map, + else None (a dynamic expression — Call/Subscript receiver — never resolves).""" + dotted = dotted_name(expr) + return canonical_call_name(dotted, aliases) if dotted is not None else None + + +def collect_sink_bindings( + node: ast.AST, + alias_map: Mapping[str, str] | None = None, + module_prefix: str = "", # noqa: ARG001 — reserved: local-class constructor FQNs (not in v1) + parent: SinkBindings | None = None, +) -> SinkBindings: + """Collect *node*'s own-scope var→class and var→callable bindings. + + Works on a function OR a module node (nested def/class bodies are their own + scopes and are skipped). *parent* layers an outer scope's bindings underneath + (pass the module's bindings when collecting a function); a function-local + rebind shadows or invalidates the parent entry for that name. + + Binding forms: ``c = pkg.Cls()`` (direct construction), ``with pkg.Cls() as c`` + / ``async with`` (context targets), ``c: pkg.Cls`` (annotation, with or without + a value — the annotation wins over the value), ``def f(c: pkg.Cls)`` (a + function's OWN parameter annotations, seeded before the body so a body rebind + shadows/invalidates them — the most common spelling of an injected + logger/client receiver), ``r = pkg.fn`` (callable alias), and ``d = c`` (copy + of an existing binding). All resolve through the module's import alias map. + + NOT branch-aware (v1): the map is LAST-BINDING-WINS in source order across the + whole scope, so a var rebound to a different class resolves to the NEW class + everywhere — the stale class is never kept. Any rebind whose RHS does not + resolve (a non-dotted call, a constant, a tuple unpack, ``for`` targets, + augmented assignment, ``del``, a match capture) INVALIDATES the name. + """ + aliases = dict(alias_map or {}) + instances: dict[str, str] = dict(parent.instance_classes) if parent else {} + callables: dict[str, str] = dict(parent.callable_aliases) if parent else {} + + def invalidate(name: str) -> None: + instances.pop(name, None) + callables.pop(name, None) + + def invalidate_target(target: ast.expr) -> None: + for sub in ast.walk(target): + if isinstance(sub, ast.Name): + invalidate(sub.id) + + def bind_instance(name: str, fqn: str) -> None: + invalidate(name) + instances[name] = fqn + + def bind_callable(name: str, fqn: str) -> None: + invalidate(name) + callables[name] = fqn + + def bind_value(name: str, value: ast.expr) -> None: + if isinstance(value, ast.NamedExpr): # c = (d := Cls()) — classify the inner value + value = value.value + if isinstance(value, ast.Call): + fqn = _resolve_dotted(value.func, aliases) + bind_instance(name, fqn) if fqn is not None else invalidate(name) + elif isinstance(value, ast.Name) and value.id in instances: + bind_instance(name, instances[value.id]) # d = c — copy the binding + elif isinstance(value, ast.Name) and value.id in callables: + bind_callable(name, callables[value.id]) + elif isinstance(value, (ast.Name, ast.Attribute)): + fqn = _resolve_dotted(value, aliases) + bind_callable(name, fqn) if fqn is not None else invalidate(name) else: - dotted = dotted_name(call.func) - if dotted is None: + invalidate(name) + + def visit(stmts: list[ast.stmt]) -> None: + for stmt in stmts: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): continue - canonical = canonical_call_name(dotted, aliases) - if canonical in sink_names: - yield call, canonical + # a walrus anywhere in the statement binds in THIS scope + for sub in ast.walk(stmt): + if isinstance(sub, ast.NamedExpr) and isinstance(sub.target, ast.Name): + bind_value(sub.target.id, sub.value) + if isinstance(stmt, ast.Assign): + if len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name): + bind_value(stmt.targets[0].id, stmt.value) + else: + for target in stmt.targets: + invalidate_target(target) + elif isinstance(stmt, ast.AnnAssign): + if isinstance(stmt.target, ast.Name): + ann_fqn = _resolve_dotted(stmt.annotation, aliases) + if ann_fqn is not None: + bind_instance(stmt.target.id, ann_fqn) + elif stmt.value is not None: + bind_value(stmt.target.id, stmt.value) + else: + invalidate(stmt.target.id) + else: + invalidate_target(stmt.target) + elif isinstance(stmt, ast.AugAssign): + invalidate_target(stmt.target) + elif isinstance(stmt, (ast.For, ast.AsyncFor)): + invalidate_target(stmt.target) + visit(stmt.body) + visit(stmt.orelse) + elif isinstance(stmt, (ast.With, ast.AsyncWith)): + for item in stmt.items: + if item.optional_vars is None: + continue + if isinstance(item.optional_vars, ast.Name): + if isinstance(item.context_expr, ast.Call): + fqn = _resolve_dotted(item.context_expr.func, aliases) + if fqn is not None: + bind_instance(item.optional_vars.id, fqn) + else: + invalidate(item.optional_vars.id) + else: + invalidate(item.optional_vars.id) + else: + invalidate_target(item.optional_vars) + visit(stmt.body) + elif isinstance(stmt, (ast.If, ast.While)): + visit(stmt.body) + visit(stmt.orelse) + elif isinstance(stmt, ast.Try): + visit(stmt.body) + for handler in stmt.handlers: + if handler.name is not None: + invalidate(handler.name) + visit(handler.body) + visit(stmt.orelse) + visit(stmt.finalbody) + elif isinstance(stmt, ast.Match): + for case in stmt.cases: + for sub in ast.walk(case.pattern): # capture patterns bind names + captured = getattr(sub, "name", None) or getattr(sub, "rest", None) + if isinstance(captured, str): + invalidate(captured) + visit(case.body) + elif isinstance(stmt, ast.Delete): + for target in stmt.targets: + invalidate_target(target) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # Parameter annotations seed instance bindings BEFORE the body walk, so a + # body rebind shadows/invalidates them like any other prior binding. + # ``*args``/``**kwargs`` are tuples/dicts of the annotated type, never the + # instance itself, so they are not seeded. An unresolvable annotation + # (subscripted generic, string literal) proves nothing and binds nothing. + for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs): + if arg.annotation is not None: + ann_fqn = _resolve_dotted(arg.annotation, aliases) + if ann_fqn is not None: + bind_instance(arg.arg, ann_fqn) + body = getattr(node, "body", None) + if isinstance(body, list): + visit(body) + return SinkBindings(instance_classes=instances, callable_aliases=callables) + + +def resolve_bound_call_fqn( + call: ast.Call, + bindings: SinkBindings, + alias_map: Mapping[str, str] | None = None, + module_prefix: str = "", # noqa: ARG001 — reserved: local-class constructor FQNs (not in v1) +) -> str | None: + """Resolve *call* through name BINDINGS (not import spellings), or None. + + Three forms: + * bare-name callable alias — ``runner(...)`` where ``runner = subprocess.run`` + → ``"subprocess.run"``; + * method on a bound instance — ``c.get(...)`` where ``c``'s class is in + ``bindings.instance_classes`` → ``".get"``; + * chained construction — ``httpx.Client().get(...)`` → ``"httpx.Client.get"`` + (single constructor→method hop only; deeper chains like ``a().b().c()`` do + not resolve — the intermediate return class is unknown). + + Dynamic receivers (subscript / nested attribute paths like ``self.client``) + never resolve in v1. + """ + aliases = dict(alias_map or {}) + func = call.func + if isinstance(func, ast.Name): + return bindings.callable_aliases.get(func.id) + if isinstance(func, ast.Attribute): + receiver = func.value + if isinstance(receiver, ast.Name): + cls_fqn = bindings.instance_classes.get(receiver.id) + return f"{cls_fqn}.{func.attr}" if cls_fqn is not None else None + if isinstance(receiver, ast.Call): + ctor_fqn = _resolve_dotted(receiver.func, aliases) + return f"{ctor_fqn}.{func.attr}" if ctor_fqn is not None else None + return None -def _module_for_qualname(qualname: str, context: AnalysisContext) -> str | None: +def resolved_sink_calls( + func_node: ast.AST, + sink_names: frozenset[str], + alias_map: Mapping[str, str] | None = None, + module_prefix: str = "", + *, + module_bindings: SinkBindings | None = None, +) -> Iterator[tuple[ast.Call, str]]: + """:func:`sink_calls` plus binding-aware resolution — a strict superset. + + Yields ``(call, canonical_fqn)`` for own-scope calls matching *sink_names* via + EITHER the direct dotted/import-aliased spelling (the :func:`sink_calls` path) + OR a name binding: construct-then-method (``c = httpx.Client(); c.get(u)`` / + ``with httpx.Client() as c`` / ``c: httpx.Client`` / chained + ``httpx.Client().get(u)``) and callable aliases (``runner = subprocess.run; + runner(...)``). Function-local bindings are collected from *func_node* and + layered over *module_bindings* (pass :func:`collect_sink_bindings` of the + module node to honor module-level assignments). + + Binding resolution is last-binding-wins (see :func:`collect_sink_bindings`), + so the yield for a rebound var reflects its FINAL statically-known class. + """ + aliases = dict(alias_map or {}) + bindings = collect_sink_bindings(func_node, aliases, module_prefix, parent=module_bindings) + for call in _own_calls(func_node): + fqn = _direct_sink_fqn(call, sink_names, aliases, module_prefix) + if fqn is None: + bound = resolve_bound_call_fqn(call, bindings, aliases, module_prefix) + if bound is not None and bound in sink_names: + fqn = bound + if fqn is not None: + yield call, fqn + + +def module_for_qualname(qualname: str, context: AnalysisContext) -> str | None: + """The longest module prefix of *qualname* known to ``context.alias_maps``. + + THE single longest-prefix module resolver for the rule layer (consolidation, + review 2026-06-10 — this used to exist as five per-rule private clones).""" modules = context.alias_maps.keys() for module in sorted(modules, key=len, reverse=True): if qualname == module or qualname.startswith(module + "."): @@ -119,46 +458,302 @@ def _module_for_qualname(qualname: str, context: AnalysisContext) -> str | None: return None +def module_alias_map(qualname: str, context: AnalysisContext) -> Mapping[str, str]: + """Import alias map of *qualname*'s module (longest module-prefix match), or empty.""" + module = module_for_qualname(qualname, context) + return context.alias_maps.get(module, {}) if module is not None else {} + + +def resolved_arg_taints(call: ast.Call, qualname: str, context: AnalysisContext) -> dict[int | str | None, TaintState]: + """Per-argument resolved taints for *call* — THE single fail-closed argument resolver. + + Returns the engine's flow-sensitive per-call-site snapshot + (``function_call_site_arg_taints`` — the resolved taint of each argument AT the call + line), keyed by positional index (``int``), keyword name (``str``), ``None`` + (``**kwargs``), and a ``"*{i}"`` marker alongside the int key for a ``Starred`` + positional. Empty dict when the call takes no arguments. + + Fail-closed: when no L2 snapshot exists for *call* (an L2-skipped function), the + qualname is recorded into ``context.flow_insensitive_fallbacks`` and a pessimistic + map marking every syntactic argument ``UNKNOWN_RAW`` is returned. The analyzer + surfaces the recorded set as ONE ``WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK`` + NONE/FACT finding per scan in addition to the gate-eligible function skip — a + finding, not a ``UserWarning``, so MCP/library consumers see the degradation and + a warnings-as-error embedder cannot turn the diagnostic into a rule-aborting raise + (review 2026-06-10). Each rule then + SELECTS over this result on its own terms (worst / any-provably-untrusted / + by-position), so the fail-closed contract lives in exactly one place and cannot + drift between rules. The pessimism is correctly direction-aware: a + RAW_ZONE-gate (sink rules / a SQL-string position) still fires on the + UNKNOWN_RAW fallback, while a *provably*-untrusted gate (PY-WL-105, which excludes + UNKNOWN_RAW) correctly stays silent — preserving its deliberate no-flood design.""" + snapshot = context.function_call_site_arg_taints.get(qualname, {}).get(id(call)) + if snapshot is not None: + return dict(snapshot) + + # Flow-sensitive snapshot is missing. Record the degradation (surfaced by the + # analyzer as one FACT finding per scan) and build a pessimistic per-arg map. + context.flow_insensitive_fallbacks.add(qualname) + pessimistic: dict[int | str | None, TaintState] = {} + for i, arg in enumerate(call.args): + pessimistic[i] = TaintState.UNKNOWN_RAW + if isinstance(arg, ast.Starred): + pessimistic[f"*{i}"] = TaintState.UNKNOWN_RAW + for kw in call.keywords: + pessimistic[kw.arg] = TaintState.UNKNOWN_RAW + return pessimistic + + def worst_arg_taint(call: ast.Call, qualname: str, context: AnalysisContext) -> TaintState | None: """The LEAST-trusted (highest TRUST_RANK) resolvable argument taint of *call*, or None when no argument resolves. Positional + keyword args are considered. - Resolves from the engine's flow-sensitive per-call-site argument taints - (``function_call_site_arg_taints`` — the resolved taint of each argument AT the call - line). When no snapshot exists for *call* (an L2-skipped function), fails closed: - warns and treats any call with arguments as ``UNKNOWN_RAW``.""" - # 1. Flow-sensitive resolved argument taints from L2 walk - arg_taints_map = context.function_call_site_arg_taints.get(qualname, {}).get(id(call)) - if arg_taints_map is not None: - worst_fs: TaintState | None = None - for ts in arg_taints_map.values(): - if ts is not None and (worst_fs is None or TRUST_RANK[ts] > TRUST_RANK[worst_fs]): - worst_fs = ts - return worst_fs - - # Flow-sensitive snapshot is missing. Warn and enforce pessimistic default. - import warnings - - warnings.warn(f"WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK: {qualname}", stacklevel=2) - if call.args or call.keywords: - return TaintState.UNKNOWN_RAW + Thin selector over :func:`resolved_arg_taints` (the shared fail-closed resolver): + when no snapshot exists, that resolver yields a pessimistic ``UNKNOWN_RAW`` per arg, + so a call with arguments still resolves to ``UNKNOWN_RAW`` (fail-closed).""" + worst: TaintState | None = None + for ts in resolved_arg_taints(call, qualname, context).values(): + if ts is not None and (worst is None or TRUST_RANK[ts] > TRUST_RANK[worst]): + worst = ts + return worst + + +def worst_dangerous_arg_taint( + call: ast.Call, + qualname: str, + context: AnalysisContext, + spec: ArgSpec | None = None, +) -> TaintState | None: + """The LEAST-trusted resolvable taint over ONLY the dangerous arg slots of *call*. + + ``spec is None`` → exactly :func:`worst_arg_taint` (worst of ALL args — the + backward-compatible default for sinks with no :class:`ArgSpec`). With a spec, + only the declared positional indices and keyword names are considered, so a + raw value in a non-dangerous slot (``requests.get(url, raw_body)`` with + ``positions=(0,)``) no longer drives the verdict. Returns None when no + dangerous slot resolves (including an empty spec — "no dangerous slots"). + + Fail-closed widening over the splat forms, where syntactic slots stop mapping + to runtime slots: a ``*args`` positional makes EVERY positional slot's taint + eligible when the spec names any position (the star may fill any of them); + a ``**kwargs`` taint is eligible when the spec names any keyword. The + underlying per-slot taints come from :func:`resolved_arg_taints`, so the + missing-snapshot pessimistic ``UNKNOWN_RAW`` fallback applies unchanged. + """ + if spec is None: + return worst_arg_taint(call, qualname, context) + taints = resolved_arg_taints(call, qualname, context) + worst: TaintState | None = None + for key in _eligible_slot_keys(call, taints, spec): + ts = taints[key] + if ts is not None and (worst is None or TRUST_RANK[ts] > TRUST_RANK[worst]): + worst = ts + return worst + + +def _eligible_slot_keys( + call: ast.Call, + taints: Mapping[int | str | None, TaintState], + spec: ArgSpec | None, +) -> set[int | str | None]: + """The slot keys of *taints* eligible for a sink's taint verdict under *spec*. + + ``spec is None`` → every key (the historical worst-of-ALL-args selector). + Otherwise the declared positional indices and keyword names, with the + fail-closed splat widening :func:`worst_dangerous_arg_taint` documents.""" + if spec is None: + return set(taints) + keys: set[int | str | None] = set() + keys.update(p for p in spec.positions if p in taints) + keys.update(kw for kw in spec.keywords if kw in taints) + if spec.positions and any(isinstance(arg, ast.Starred) for arg in call.args): + # positional indices are unreliable past a *args — widen to every positional slot + keys.update(k for k in taints if isinstance(k, int) or (isinstance(k, str) and k.startswith("*"))) + if spec.keywords and None in taints: # **kwargs may supply any keyword + keys.add(None) + return keys + + +def _slot_expr(call: ast.Call, key: int | str | None) -> ast.expr | None: + """The argument AST expression behind one :func:`resolved_arg_taints` slot key, + or None for the slots that cannot be syntactically attributed (``**kwargs`` → + ``None`` key, ``*args`` → ``"*{i}"`` marker).""" + if isinstance(key, int): + if 0 <= key < len(call.args) and not isinstance(call.args[key], ast.Starred): + return call.args[key] + return None + if isinstance(key, str) and not key.startswith("*"): + for kw in call.keywords: + if kw.arg == key: + return kw.value return None +def collect_ctor_call_nodes( + node: ast.AST, + alias_map: Mapping[str, str] | None = None, + ctor_fqns: frozenset[str] | None = None, +) -> dict[str, ast.Call]: + """Own-scope var → LAST constructor :class:`ast.Call` bound to it (source order). + + Companion to :func:`collect_sink_bindings`: that map resolves the var's class + FQN (and is the GATE — it invalidates on every unresolvable rebind), while this + one supplies the constructor call NODE a receiver-anchored taint is read from. + Binding forms that carry a call are recorded (``v = Cls(...)``, ``with Cls(...) + as v``, walrus, ``v: T = Cls(...)``) plus the ``w = v`` copy of an existing + entry; a non-call, non-copy rebind drops the entry. A residual stale entry is + harmless: the bindings gate already refused to resolve the method call. + + *ctor_fqns* optionally restricts recording to constructors whose canonical + dotted FQN (through *alias_map*) is in the set — ``None`` records every call. + """ + aliases = dict(alias_map or {}) + ctors: dict[str, ast.Call] = {} + + def bind(name: str, value: ast.expr) -> None: + if isinstance(value, ast.NamedExpr): + value = value.value + if isinstance(value, ast.Call): + if ctor_fqns is None: + ctors[name] = value + return + fqn = _resolve_dotted(value.func, aliases) + if fqn is not None and fqn in ctor_fqns: + ctors[name] = value + else: + ctors.pop(name, None) + elif isinstance(value, ast.Name) and value.id in ctors: + ctors[name] = ctors[value.id] # w = v — copy the binding + else: + ctors.pop(name, None) + + def walk(parent: ast.AST) -> None: + for child in ast.iter_child_nodes(parent): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue # nested scopes are their own entities + if isinstance(child, ast.Assign) and len(child.targets) == 1 and isinstance(child.targets[0], ast.Name): + bind(child.targets[0].id, child.value) + elif isinstance(child, ast.AnnAssign) and isinstance(child.target, ast.Name): + if child.value is not None: + bind(child.target.id, child.value) + elif isinstance(child, ast.NamedExpr) and isinstance(child.target, ast.Name): + bind(child.target.id, child.value) + elif isinstance(child, (ast.With, ast.AsyncWith)): + for item in child.items: + if isinstance(item.optional_vars, ast.Name): + bind(item.optional_vars.id, item.context_expr) + walk(child) + + walk(node) + return ctors + + +def receiver_ctor_call(call: ast.Call, ctor_nodes: Mapping[str, ast.Call]) -> ast.Call | None: + """The constructor call that built *call*'s receiver: the chained receiver itself + (``Cls(raw).method()``) or the bound var's recorded constructor, else None.""" + if not isinstance(call.func, ast.Attribute): + return None + receiver = call.func.value + if isinstance(receiver, ast.Call): + return receiver + if isinstance(receiver, ast.Name): + return ctor_nodes.get(receiver.id) + return None + + +def build_sink_finding( + *, + rule_id: str, + entity: Entity, + qualname: str, + call: ast.Call, + dotted: str, + severity: Severity, + tier: TaintState, + worst: TaintState, + sink_label: str, + message: str | None = None, +) -> Finding: + """THE shared sink-finding constructor — one message shape, one wlfp2 + discriminator, one properties dict for every call-site-anchored sink rule. + + Call-site-anchored: >1 finding per (rule, path, qualname) is possible (several + sinks in one function). Discriminate by SOURCE only — an ENTITY-RELATIVE line + offset (call line - the enclosing def's line, invariant to a comment ABOVE the + function: wlfp2/wardline-8654423823) plus the call's full lexical SPAN and the + sink dotted-name. The span (start:end), not the start column alone, separates + the outer/inner calls of a chain (``a.sink(x).sink(y)``), which share a start + column. Never the resolved arg taint (it drifts across builds: weft-4a9d0f863c). + + *message* overrides the standard message text only — fingerprint and + properties stay uniform (PathTraversal's receiver-anchored explanations). + """ + line = call.lineno + return Finding( + rule_id=rule_id, + message=message + or (f"{qualname}: {worst.value} (untrusted) data reaches the {sink_label} sink {dotted}() at line {line}"), + severity=severity, + kind=Kind.DEFECT, + location=Location(path=entity.location.path, line_start=line), + fingerprint=_fp( + rule_id=rule_id, + path=entity.location.path, + qualname=qualname, + taint_path=f"{line - (entity.location.line_start or 0)}:{call.col_offset}:{call.end_col_offset}:{dotted}", + ), + # OLD (wlfp1) taint_path, byte-exact, for `wardline rekey` (P4). + taint_path_v0=f"{dotted}@{call.col_offset}:{call.end_col_offset}", + qualname=qualname, + properties={"tier": tier.value, "sink": dotted, "arg_taint": worst.value}, + ) + + class TaintedSinkRule: - """Base for the dangerous-sink rules (106/107/108): raw-zone data reaching a named - sink inside a trusted-tier function. Tier-MODULATED — silent in the developer- - freedom zone (undecorated → UNKNOWN_RAW → modulate → NONE), speaking only where - trust is declared (the same opt-in/fail-closed discipline as 103/104). Subclasses - set ``rule_id``, ``metadata``, ``SINKS`` (the curated dotted sink names), and - ``sink_label`` (for the message). Plain (not ClassVar) annotations so the instances - satisfy the ``_Rule`` protocol (a settable instance ``rule_id``).""" + """THE base for the call-site-anchored dangerous-sink rules — raw-zone data + reaching a named sink inside a trusted-tier function. Tier-MODULATED — silent in + the developer-freedom zone (undecorated → UNKNOWN_RAW → modulate → NONE), + speaking only where trust is declared (the same opt-in/fail-closed discipline + as 103/104). + + Consolidated 2026-06-10 (review): this single ``check`` loop replaces the two + former mixins (108/112's ``BindingAwareSinkCheckMixin``, the 121–126 family's + ``SpecSinkCheckMixin``) and the SSRF/deserialization rule-local overrides. The + loop is binding-aware (:func:`resolved_sink_calls`, layered over + ``context.module_bindings`` — a strict superset of the old direct-spelling + match, so the historical base rules gain construct-then-method and + callable-alias resolution as NEW findings with zero fingerprint drift), and is + parameterized by: + + * ``SINK_SPECS`` — per-sink :class:`ArgSpec` slot precision (missing/``None`` + entry → the historical worst-of-all-args selector); + * ``SINK_SEVERITIES`` — per-sink base severity, applied BEFORE tier + modulation (PY-WL-121's lxml-vs-stdlib split). An operator + ``rules.severity`` override re-bases the WHOLE rule: the registry passes + ``base_severity`` only when the config carries an override, recorded as + ``self.severity_overridden`` — an explicit flag, never inferred by value + identity, so an override EQUAL to the metadata default still wins; + * hook :meth:`_accept_call` — extra per-call gate after the sink-name match + (112's literal ``shell=True``, 106's numpy/torch literal-keyword gates); + * hook :meth:`_arg_guarded` — per-slot syntactic neutralization (108's + ``shlex.quote`` concatenation guard); + * hook :meth:`_taint_anchor_call` — redirects the taint read to another call + (106's ``pickle.Unpickler(stream).load()`` reads the CONSTRUCTOR's stream + argument); ``None`` skips the call. + + Subclasses set ``rule_id``, ``metadata``, ``SINKS`` (the curated dotted sink + names), and ``sink_label`` (for the message). Plain (not ClassVar) annotations + so the instances satisfy the ``_Rule`` protocol (a settable instance + ``rule_id``).""" rule_id: str metadata: RuleMetadata SINKS: frozenset[str] sink_label: str + SINK_SPECS: Mapping[str, ArgSpec | None] = {} + SINK_SEVERITIES: Mapping[str, Severity] = {} + def __init_subclass__(cls, **kwargs: object) -> None: super().__init_subclass__(**kwargs) missing = [a for a in ("rule_id", "metadata", "SINKS", "sink_label") if not hasattr(cls, a)] @@ -166,48 +761,101 @@ def __init_subclass__(cls, **kwargs: object) -> None: raise TypeError(f"{cls.__name__} must define class attribute(s): {', '.join(missing)}") def __init__(self, base_severity: Severity | None = None) -> None: + # The registry passes base_severity ONLY for an operator rules.severity + # override (build_default_registry), so presence IS the override signal — + # value identity against the metadata default would silently ignore an + # explicit override equal to that default (review 2026-06-10). + self.severity_overridden = base_severity is not None self.base_severity = base_severity or self.metadata.base_severity - def _accept_call(self, call: ast.Call) -> bool: # noqa: PLR6301 + def _accept_call(self, call: ast.Call, fqn: str) -> bool: # noqa: ARG002, PLR6301 """Extra per-call gate after the SINK-name match. Default: accept.""" return True + def _arg_guarded(self, expr: ast.expr, fqn: str, alias_map: Mapping[str, str]) -> bool: # noqa: ARG002, PLR6301 + """Whether one argument slot of sink *fqn* is syntactically neutralized + (excluded from the worst-taint selection). Default: never.""" + return False + + def _taint_anchor_call( # noqa: PLR6301 + self, + call: ast.Call, + fqn: str, # noqa: ARG002 + entity_node: ast.AST, # noqa: ARG002 + alias_map: Mapping[str, str], # noqa: ARG002 + ) -> ast.Call | None: + """The call whose arguments carry the sink's dangerous data — *call* itself + by default. An override may redirect (a reader-method sink reads its + CONSTRUCTOR's arguments) or return ``None`` to skip (no resolvable anchor).""" + return call + + def _worst_sink_arg_taint( + self, + call: ast.Call, + fqn: str, + qualname: str, + context: AnalysisContext, + alias_map: Mapping[str, str], + ) -> TaintState | None: + """The LEAST-trusted taint over the sink's dangerous, unguarded arg slots. + + Slot selection honors the sink's :class:`ArgSpec` from ``SINK_SPECS`` + (missing/``None`` → every slot, the historical worst-of-all-args), then + :meth:`_arg_guarded` excludes syntactically neutralized slots. Slots with + no attributable expression (``*args`` / ``**kwargs``) are never guarded — + the fail-closed default.""" + taints = resolved_arg_taints(call, qualname, context) + worst: TaintState | None = None + for key in _eligible_slot_keys(call, taints, self.SINK_SPECS.get(fqn)): + ts = taints[key] + if ts is None: + continue + expr = _slot_expr(call, key) + if expr is not None and self._arg_guarded(expr, fqn, alias_map): + continue + if worst is None or TRUST_RANK[ts] > TRUST_RANK[worst]: + worst = ts + return worst + def check(self, context: AnalysisContext) -> list[Finding]: findings: list[Finding] = [] for qualname, entity in context.entities.items(): - lookup_name = qualname.split("..")[0] - tier = context.project_taints.get(lookup_name, TaintState.UNKNOWN_RAW) - severity = modulate(self.base_severity, tier) - if severity == Severity.NONE: - continue # freedom / fail-closed zone — suppressed - module = _module_for_qualname(lookup_name, context) + # Honor a nested def's OWN trust decorator, else inherit the nearest declared + # enclosing scope's tier (wardline-bb8396f96e / wardline-9b88ec5419). + tier = enclosing_declared_tier(qualname, context.project_taints, context.declared_qualnames) + if modulate(self.base_severity, tier) == Severity.NONE: + continue # freedom / fail-closed zone — NONE for every base, so skip the entity + module = module_for_qualname(qualname, context) alias_map = context.alias_maps.get(module, {}) if module is not None else {} - for call, dotted in sink_calls(entity.node, self.SINKS, alias_map, module or ""): - if not self._accept_call(call): + module_bindings = context.module_bindings.get(module or "") + for call, dotted in resolved_sink_calls( + entity.node, self.SINKS, alias_map, module or "", module_bindings=module_bindings + ): + if not self._accept_call(call, dotted): continue - worst = worst_arg_taint(call, qualname, context) + base = ( + self.base_severity + if self.severity_overridden + else self.SINK_SEVERITIES.get(dotted, self.base_severity) + ) + severity = modulate(base, tier) + anchor = self._taint_anchor_call(call, dotted, entity.node, alias_map) + if anchor is None: + continue # no resolvable taint anchor — bounded FN, never a guess + worst = self._worst_sink_arg_taint(anchor, dotted, qualname, context, alias_map) if worst is None or worst not in RAW_ZONE: continue - line = call.lineno findings.append( - Finding( + build_sink_finding( rule_id=self.rule_id, - message=( - f"{qualname}: {worst.value} (untrusted) data reaches the {self.sink_label} " - f"sink {dotted}() at line {line}" - ), - severity=severity, - kind=Kind.DEFECT, - location=Location(path=entity.location.path, line_start=line), - fingerprint=_fp( - rule_id=self.rule_id, - path=entity.location.path, - line_start=line, - qualname=qualname, - taint_path=f"{worst.value}->{dotted}", - ), + entity=entity, qualname=qualname, - properties={"tier": tier.value, "sink": dotted, "arg_taint": worst.value}, + call=call, + dotted=dotted, + severity=severity, + tier=tier, + worst=worst, + sink_label=self.sink_label, ) ) return findings diff --git a/src/wardline/scanner/rules/assert_only_boundary.py b/src/wardline/scanner/rules/assert_only_boundary.py index eff89b43..2423ca18 100644 --- a/src/wardline/scanner/rules/assert_only_boundary.py +++ b/src/wardline/scanner/rules/assert_only_boundary.py @@ -10,9 +10,20 @@ A PY-WL-102-adjacent refinement: 102 fires when a boundary cannot reject *at all*; 111 fires when it *appears* to reject but only via a guard that disappears in -production. The two partition the space — a boundary with a real ``raise`` or a -falsy-constant ``return`` trips neither (see ``asserts_are_sole_rejection`` / -``has_rejection_path`` in ``_ast_helpers``). +production. A boundary with a real ``raise`` / rejection-shaped ``return`` +(see ``asserts_are_sole_rejection`` / ``has_rejection_path`` in ``_ast_helpers``) +— or a ONE-HOP same-module call to a helper that itself raises (the helper's +``raise`` survives ``python -O``, so the CWE-617 claim would be factually false) +— trips neither. + +**The boundary-integrity family partitions FOUR ways** (wardline-718048a518) — +at most one of {102, 111, 113, 119} fires per boundary: + - PY-WL-119 — the bare degenerate shape (single ``return ``); + - PY-WL-102 — every other shape with no rejection path; + - PY-WL-111 — rejection only via ``assert`` (this rule). This includes an + assert INSIDE a ``try`` whose handler substitutes: the rejection is still + assert-only, so 111 wins over 113 (documented precedence); + - PY-WL-113 — a real rejection exists but a fail-open handler defeats it. """ from __future__ import annotations @@ -22,7 +33,7 @@ from wardline.core.finding import Finding, Kind, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import TRUST_RANK -from wardline.scanner.rules._ast_helpers import asserts_are_sole_rejection +from wardline.scanner.rules._ast_helpers import asserts_are_sole_rejection, rejecting_helper_calls from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: @@ -66,6 +77,10 @@ def check(self, context: AnalysisContext) -> list[Finding]: continue if not asserts_are_sole_rejection(entity.node): continue + # One-hop: a same-module raising helper survives `python -O`, so the + # assert is NOT the sole rejection and the CWE-617 claim would be false. + if rejecting_helper_calls(entity, context.entities, context.call_site_callees): + continue findings.append( Finding( rule_id=self.rule_id, @@ -80,9 +95,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=f"{body.value}->{ret.value}", + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, + # so (rule, path, line, qualname) is already unique. body/return tiers are + # resolved values that drift as the suite is extended — keep them off the key. + taint_path=None, ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/boundary_without_rejection.py b/src/wardline/scanner/rules/boundary_without_rejection.py index b1a21094..fc98e269 100644 --- a/src/wardline/scanner/rules/boundary_without_rejection.py +++ b/src/wardline/scanner/rules/boundary_without_rejection.py @@ -3,9 +3,28 @@ A trust-RAISING transition (declared return strictly MORE trusted than body — the taint shape unique to ``@trust_boundary`` among the vocabulary) that contains -no ``raise`` and no falsy-constant ``return`` cannot actually reject bad input, -so it is not validating. Declaration-gated (the decorator is the opt-in), so it -emits at base severity (NOT tier-modulated). +no rejection path of any recognised shape cannot actually reject bad input, so it +is not validating. Declaration-gated (the decorator is the opt-in), so it emits +at base severity (NOT tier-modulated). + +Recognised rejection shapes (any one keeps the rule silent): + - an own-scope ``raise`` or ``assert`` (the assert-only case is PY-WL-111's); + - a rejection-shaped ``return`` — falsy constant, conditional expression with a + rejecting branch (``return m.group(0) if m else None``), or a curated + raising-conversion / lookup (``return int(p)`` / ``return Color[p]`` / + ``return ALLOWED[p]`` — validate-by-construction); + - a ONE-HOP, SAME-MODULE call to a helper whose own body has a real rejection + (``_require_nonempty(p)``, a raising staticmethod, or wholesale delegation to + another raising boundary). A helper that cannot raise never counts. + +**The boundary-integrity family partitions FOUR ways** (wardline-718048a518) — +at most one of {102, 111, 113, 119} fires per boundary: + - PY-WL-119 — the bare degenerate shape (single ``return ``): the + more-specific rule wins, so 102 SUPPRESSES itself there (the suppression is + structural — keyed on the shape, not on whether 119 is enabled); + - PY-WL-102 — every OTHER shape with no rejection path (cannot reject at all); + - PY-WL-111 — rejection only via ``assert`` (stripped under ``python -O``); + - PY-WL-113 — a real rejection exists but a fail-open handler defeats it. """ from __future__ import annotations @@ -15,7 +34,11 @@ from wardline.core.finding import Finding, Kind, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import TRUST_RANK -from wardline.scanner.rules._ast_helpers import has_rejection_path +from wardline.scanner.rules._ast_helpers import ( + has_rejection_path, + is_degenerate_boundary, + rejecting_helper_calls, +) from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: @@ -30,7 +53,8 @@ "has no rejection path — no raise, no falsy-constant return — so it cannot " "validate." ), - examples_violation=("@trust_boundary(to_level='ASSURED')\ndef v(p):\n return p",), + # NOT the bare `return p` shape — that is PY-WL-119's (the family partitions). + examples_violation=("@trust_boundary(to_level='ASSURED')\ndef v(p):\n x = p\n return x",), examples_clean=( "@trust_boundary(to_level='ASSURED')\ndef v(p):\n if not p:\n raise ValueError\n return p", ), @@ -59,6 +83,12 @@ def check(self, context: AnalysisContext) -> list[Finding]: continue if has_rejection_path(entity.node): continue + # The bare degenerate shape is PY-WL-119's domain (more-specific wins). + if is_degenerate_boundary(entity.node): + continue + # One-hop: a same-module raising helper IS this boundary's rejection path. + if rejecting_helper_calls(entity, context.entities, context.call_site_callees): + continue findings.append( Finding( rule_id=self.rule_id, @@ -72,9 +102,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=f"{body.value}->{ret.value}", + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, + # so (rule, path, line, qualname) is already unique. body/return tiers are + # resolved values that drift as the suite is extended — keep them off the key. + taint_path=None, ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/broad_exception.py b/src/wardline/scanner/rules/broad_exception.py index d713d574..a2b8255d 100644 --- a/src/wardline/scanner/rules/broad_exception.py +++ b/src/wardline/scanner/rules/broad_exception.py @@ -13,8 +13,8 @@ from wardline.core.finding import Finding, Kind, Location, Severity from wardline.core.finding import compute_finding_fingerprint as _fp -from wardline.core.taints import TaintState from wardline.scanner.rules._ast_helpers import is_broad_except, own_except_handlers +from wardline.scanner.rules._sink_helpers import enclosing_declared_tier from wardline.scanner.rules.metadata import RuleMetadata from wardline.scanner.rules.severity_model import modulate @@ -25,6 +25,7 @@ rule_id="PY-WL-103", base_severity=Severity.WARN, kind=Kind.DEFECT, + multi_emit=True, description="A broad exception handler (bare except / Exception / BaseException) in a trusted-tier function.", examples_violation=("@trusted\ndef f():\n try:\n g()\n except Exception:\n h()",), examples_clean=("@trusted\ndef f():\n try:\n g()\n except ValueError:\n h()",), @@ -41,8 +42,11 @@ def __init__(self, base_severity: Severity | None = None) -> None: def check(self, context: AnalysisContext) -> list[Finding]: findings: list[Finding] = [] for qualname, entity in context.entities.items(): - lookup_name = qualname.split("..")[0] - tier = context.project_taints.get(lookup_name, TaintState.UNKNOWN_RAW) + # Nearest DECLARED enclosing scope governs a nested def (a nested def's own + # trust decorator wins; undeclared nested defs inherit) — the same + # enclosing_declared_tier semantics as the sink rule family, NOT the + # outermost-function strip (wardline-bb8396f96e / wardline-9b88ec5419). + tier = enclosing_declared_tier(qualname, context.project_taints, context.declared_qualnames) severity = modulate(self.base_severity, tier) if severity == Severity.NONE: continue # suppressed outside trusted/partial tiers @@ -56,13 +60,18 @@ def check(self, context: AnalysisContext) -> list[Finding]: message=f"{qualname}: broad exception handler at line {line}", severity=severity, kind=Kind.DEFECT, + # Location.line_start stays the HANDLER line (display/SARIF + the P4 + # migration's old-fp derivation), NOT the def line. location=Location(path=entity.location.path, line_start=line), fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=line, qualname=qualname, - taint_path=tier.value, + # Multi-emit: >1 broad handler per function. Discriminate ENTITY-RELATIVE + # (handler line - def line) + the handler's lexical span, so two handlers stay + # distinct after line_start left the hash (wlfp2/wardline-6102d4c833) yet a + # comment ABOVE the function does not churn it. Source-only; tier never joins. + taint_path=f"{handler.lineno - (entity.location.line_start or 0)}:{handler.col_offset}:{handler.end_col_offset}:except", # noqa: E501 ), qualname=qualname, properties={"tier": tier.value}, diff --git a/src/wardline/scanner/rules/contradictory_trust.py b/src/wardline/scanner/rules/contradictory_trust.py index 9c3c14fd..cb49f6fc 100644 --- a/src/wardline/scanner/rules/contradictory_trust.py +++ b/src/wardline/scanner/rules/contradictory_trust.py @@ -26,20 +26,21 @@ from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.scanner.grammar import BUILTIN_BOUNDARY_TYPES from wardline.scanner.rules.metadata import RuleMetadata +from wardline.scanner.taint.decorator_provider import _is_builtin_decorator_fqn if TYPE_CHECKING: from wardline.scanner.context import AnalysisContext -# The recognised trust-marker names (the grammar boundary types' canonical names) -# and the module prefixes they may be imported from. A custom grammar's markers are -# the agent's own concern; the builtin rule keys on the builtin vocabulary, which is -# the contract Wardline ships. Both names AND prefixes are derived from -# BUILTIN_BOUNDARY_TYPES so the rule cannot drift from the grammar — the prefix set -# is how ``wardline.decorators`` and the renamed ``weft_markers`` shim are BOTH -# recognised (wardline-d62845bb18: hardcoding only ``wardline.decorators`` silently -# missed contradictory stacks written against the recommended ``weft_markers`` shim). -_MARKER_NAMES: frozenset[str] = frozenset(bt.canonical_name for bt in BUILTIN_BOUNDARY_TYPES) -_MARKER_MODULE_PREFIXES: frozenset[str] = frozenset(bt.module_prefix for bt in BUILTIN_BOUNDARY_TYPES) +# A marker is recognised using the EXACT same predicate the engine's seeding uses +# (``_is_builtin_decorator_fqn``): for a builtin boundary type with prefix ``P``, only +# the public re-export ``P.`` and the implementation-module export +# ``P.trust.`` count. The rule MUST NOT recognise a marker the engine's seeding +# rejects, or it counts a "clash" the engine never actually resolved — an arbitrarily- +# nested path like ``wardline.decorators.sub.external_boundary`` is seeded by NEITHER, +# so it must not be counted here either (wardline-09c09f14df). Keying off the shared +# seeding predicate (not a looser names+prefix heuristic) is how the rule cannot drift +# from the grammar, and recognises both ``wardline.decorators`` and the renamed +# ``weft_markers`` shim (wardline-d62845bb18). METADATA = RuleMetadata( rule_id="PY-WL-110", @@ -77,9 +78,9 @@ def _marker_canonical_name(deco: ast.expr, alias_map: Mapping[str, str]) -> str fqn = _resolve_decorator_fqn(deco, alias_map) if fqn is None: return None - last = fqn.rsplit(".", 1)[-1] - if last in _MARKER_NAMES and any(fqn.startswith(prefix + ".") for prefix in _MARKER_MODULE_PREFIXES): - return last + for bt in BUILTIN_BOUNDARY_TYPES: + if bt.builtin and _is_builtin_decorator_fqn(fqn, bt.canonical_name, bt.module_prefix): + return bt.canonical_name return None @@ -114,12 +115,12 @@ def check(self, context: AnalysisContext) -> list[Finding]: if len(markers) < 2: continue - taint_path = "+".join(sorted(markers)) + markers_label = "+".join(sorted(markers)) findings.append( Finding( rule_id=self.rule_id, message=( - f"{qualname} carries contradictory trust markers ({taint_path}); the engine " + f"{qualname} carries contradictory trust markers ({markers_label}); the engine " f"resolves the clash to the least-trusted seed, silently ignoring the rest" ), severity=self.base_severity, @@ -128,12 +129,14 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=taint_path, + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, so + # (rule, path, line, qualname) is already unique; the marker set is source-derived + # but not load-bearing for the join key. It stays in message/properties only. + taint_path=None, ), qualname=qualname, - properties={"markers": taint_path}, + properties={"markers": markers_label}, ) ) return findings diff --git a/src/wardline/scanner/rules/degenerate_boundary.py b/src/wardline/scanner/rules/degenerate_boundary.py index 3b8247cd..3f72b86e 100644 --- a/src/wardline/scanner/rules/degenerate_boundary.py +++ b/src/wardline/scanner/rules/degenerate_boundary.py @@ -4,44 +4,29 @@ A trust boundary (raises declared trust on return) that simply returns its input parameter directly without any conditional checks, assertions, or validations is a degenerate boundary. It does not perform any validation. + +**The boundary-integrity family partitions FOUR ways** (wardline-718048a518) — +at most one of {102, 111, 113, 119} fires per boundary. The degenerate shape is a +strict structural subset of PY-WL-102's "no rejection path", so 119 WINS on it +(more-specific rule) and 102 suppresses itself there — the same boundary is never +counted twice at ERROR in the gate population. The shared shape predicate is +``_ast_helpers.is_degenerate_boundary``. """ from __future__ import annotations -import ast from typing import TYPE_CHECKING from wardline.core.finding import Finding, Kind, Maturity, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import TRUST_RANK +from wardline.scanner.rules._ast_helpers import is_degenerate_boundary from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: from wardline.scanner.context import AnalysisContext -def _is_degenerate(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: - param_names = {arg.arg for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)} - if node.args.vararg: - param_names.add(node.args.vararg.arg) - if node.args.kwarg: - param_names.add(node.args.kwarg.arg) - - non_trivial_stmts = [] - for stmt in node.body: - if isinstance(stmt, ast.Pass): - continue - if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant): - continue - non_trivial_stmts.append(stmt) - - if len(non_trivial_stmts) == 1 and isinstance(non_trivial_stmts[0], ast.Return): - ret_val = non_trivial_stmts[0].value - if isinstance(ret_val, ast.Name) and ret_val.id in param_names: - return True - return False - - METADATA = RuleMetadata( rule_id="PY-WL-119", base_severity=Severity.ERROR, @@ -75,7 +60,7 @@ def check(self, context: AnalysisContext) -> list[Finding]: # Trust-raising transition (== @trust_boundary): body less-trusted than return. if TRUST_RANK[body] <= TRUST_RANK[ret]: continue - if not _is_degenerate(entity.node): + if not is_degenerate_boundary(entity.node): continue findings.append( Finding( @@ -90,9 +75,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=f"{body.value}->{ret.value}", + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, + # so (rule, path, line, qualname) is already unique. body/return tiers are + # resolved values that drift as the suite is extended — keep them off the key. + taint_path=None, ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/failopen_boundary.py b/src/wardline/scanner/rules/failopen_boundary.py index 45df50bf..dd28954f 100644 --- a/src/wardline/scanner/rules/failopen_boundary.py +++ b/src/wardline/scanner/rules/failopen_boundary.py @@ -2,11 +2,13 @@ A trust-RAISING transition (declared return strictly MORE trusted than body — the taint shape unique to ``@trust_boundary``) that contains an ``except`` handler -which swallows the failure and SUBSTITUTES a value-bearing result (``return p``, -``return DEFAULT``, ``return cached``) instead of re-raising. Such a boundary can -be bypassed by *triggering* the exception: the validation it appears to perform is -discarded and a "valid-looking" value is returned in its place, so untrusted data -passes the boundary untouched. It fails open, not closed. +which swallows the failure and SUBSTITUTES a value-bearing result instead of +re-raising — either by returning it directly (``return p``, ``return DEFAULT``, +``return cached``) or by ASSIGNING it to a name the function then returns by +fall-through (``result = p`` in the handler, ``return result`` after the ``try``). +Such a boundary can be bypassed by *triggering* the exception: the validation it +appears to perform is discarded and a "valid-looking" value is returned in its +place, so untrusted data passes the boundary untouched. It fails open, not closed. The most insidious shape is the self-catch, where the handler catches the very exception the boundary's own rejection raises: @@ -23,10 +25,27 @@ def v(p): This is why the rule does NOT gate on a broad ``except`` — a *narrow* handler that names the rejection's own exception type is the worst case, not the mildest. -**The boundary-integrity trio partitions cleanly:** - - PY-WL-102 — boundary with NO rejection path (cannot reject at all); - - PY-WL-111 — rejection only via ``assert`` (stripped under ``python -O``); - - PY-WL-113 — rejection exists but is defeated by a fail-open handler. +**The boundary-integrity family partitions FOUR ways** (wardline-718048a518) — +at most one of {102, 111, 113, 119} fires per boundary: + - PY-WL-119 — the bare degenerate shape (single ``return ``); + - PY-WL-102 — every other shape with NO rejection path (cannot reject at all); + - PY-WL-111 — rejection only via ``assert`` (stripped under ``python -O``). + This precedence is deliberate: an assert inside a ``try`` caught by a + substituting handler is still 111's (the rejection is assert-only); + - PY-WL-113 — a REAL rejection exists but a fail-open handler defeats it. + +This rule enforces its premise in code, not just prose: + 1. a real (production-surviving) rejection must EXIST — an own-scope ``raise`` + / rejection-shaped ``return``, or a one-hop same-module raising helper call + (``rejecting_helper_calls``). No rejection → 102's domain; assert-only → + 111's domain; + 2. the rejection must be SWALLOWABLE by the matching handler — lexically inside + that handler's own ``try`` body (the self-catch), or inside the handler + itself (a handler that conditionally rejects but substitutes on the other + path). A rejection wholly OUTSIDE the ``try`` (validate-then-cache shapes) + cannot be defeated by the handler, so the boundary fails CLOSED and the rule + stays silent. + A handler that re-raises (even conditionally) is fail-closed and never matches (conservative, mirroring ``has_rejection_path``); a handler that returns a falsy/empty constant is signalling REJECTION, not substituting, and also never @@ -35,20 +54,28 @@ def v(p): Declaration-gated (the ``@trust_boundary`` decorator is the opt-in), so it emits at base severity — NOT tier-modulated — exactly like 102 and 111. -**Residual FP:** a declared boundary that legitimately catches an *unrelated* -exception and returns a default for a non-validation reason. Rare inside a thing -explicitly declared as a validator, and swallow-and-substitute in a validator is -itself the smell; tracked, not hidden. +**Residual FP:** a declared boundary whose ``try`` body holds its rejection AND an +unrelated exception source, with a narrow handler for only the unrelated type. +The rule does not match exception TYPES (the self-catch worst case is a *narrow* +handler), so it cannot tell that pair apart; tracked, not hidden. """ from __future__ import annotations +import ast from typing import TYPE_CHECKING from wardline.core.finding import Finding, Kind, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import TRUST_RANK -from wardline.scanner.rules._ast_helpers import handler_substitutes_on_failure, own_except_handlers +from wardline.scanner.rules._ast_helpers import ( + _own_statements, + block_has_real_rejection, + handler_substitutes_on_failure, + has_real_rejection, + rejecting_helper_calls, + returned_var_names, +) from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: @@ -96,7 +123,24 @@ def check(self, context: AnalysisContext) -> list[Finding]: # Trust-raising transition (== @trust_boundary): body less-trusted than return. if TRUST_RANK[body] <= TRUST_RANK[ret]: continue - if not any(handler_substitutes_on_failure(h) for h in own_except_handlers(entity.node)): + # 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. + rejecting_calls = rejecting_helper_calls(entity, context.entities, context.call_site_callees) + if not (has_real_rejection(entity.node) or rejecting_calls): + continue + # Premise 2: the rejection must be SWALLOWABLE by the substituting + # handler — inside its own try body, or inside the handler itself. + returned = returned_var_names(entity.node) + if not any( + handler_substitutes_on_failure(handler, returned) + and ( + block_has_real_rejection(try_stmt.body, rejecting_calls) + or block_has_real_rejection(handler.body, rejecting_calls) + ) + for try_stmt in _own_statements(entity.node) + if isinstance(try_stmt, (ast.Try, ast.TryStar)) + for handler in try_stmt.handlers + ): continue findings.append( Finding( @@ -112,9 +156,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=f"{body.value}->{ret.value}", + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, + # so (rule, path, line, qualname) is already unique. body/return tiers are + # resolved values that drift as the suite is extended — keep them off the key. + taint_path=None, ), qualname=qualname, properties={"body_taint": body.value, "return_taint": ret.value}, diff --git a/src/wardline/scanner/rules/invalid_decorator_level.py b/src/wardline/scanner/rules/invalid_decorator_level.py index e1a4b2c2..526fca71 100644 --- a/src/wardline/scanner/rules/invalid_decorator_level.py +++ b/src/wardline/scanner/rules/invalid_decorator_level.py @@ -15,18 +15,29 @@ from wardline.core.finding import Finding, Kind, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import TaintState +from wardline.scanner.grammar import BUILTIN_BOUNDARY_TYPES from wardline.scanner.rules.metadata import RuleMetadata +from wardline.scanner.taint.decorator_provider import _is_builtin_decorator_fqn, _shadowed_builtin_roots if TYPE_CHECKING: + from collections.abc import Mapping + from wardline.scanner.context import AnalysisContext _BOUNDARY_LEVELS = frozenset({TaintState.GUARDED, TaintState.ASSURED}) _TRUSTED_LEVELS = frozenset({TaintState.INTEGRAL, TaintState.ASSURED}) +# PY-WL-114 polices the LEVEL-bearing builtin markers only — ``trusted`` (``level=``) +# and ``trust_boundary`` (``to_level=``). Recognition uses the engine's OWN seeding +# predicate (``_is_builtin_decorator_fqn`` + shadowed-root fail-closed rejection), so +# the rule cannot recognise a marker the seeding rejects (wardline-09c09f14df). +_LEVEL_MARKER_NAMES: frozenset[str] = frozenset({"trusted", "trust_boundary"}) + METADATA = RuleMetadata( rule_id="PY-WL-114", base_severity=Severity.ERROR, kind=Kind.DEFECT, + multi_emit=True, description=( "A builtin trust decorator (@trusted or @trust_boundary) has a level argument " "that is statically readable but invalid or out-of-range." @@ -34,11 +45,17 @@ examples_violation=( "@trusted(level='ASURED')\ndef f(p):\n return p", "@trust_boundary(to_level='INTEGRAL')\ndef g(p):\n if not p: raise ValueError\n return p", + # An ALIASED builtin decorator with a typo'd level must still fire (the alias resolves + # to the builtin FQN) — otherwise the typo silently disables the gate (wardline-0267c31cd8). + "from wardline.decorators import trusted as t\n@t(level='ASURED')\ndef f(p):\n return p", ), examples_clean=( "@trusted(level='ASSURED')\ndef f(p):\n return p", "@trust_boundary(to_level='ASSURED')\ndef g(p):\n if not p: raise ValueError\n return p", "@trusted(level=cfg.LEVEL)\ndef h(p):\n return p", + # A FOREIGN decorator that merely happens to be spelled ``trusted`` is not the builtin + # marker, so an invalid level on it is not PY-WL-114's concern (no FP) (wardline-0267c31cd8). + "import other_pkg\n@other_pkg.trusted(level='BOGUS')\ndef f(p):\n return p", ), ) @@ -64,13 +81,42 @@ def _level_token(value: ast.expr) -> str | None: return None -def _marker_name(deco: ast.expr) -> str | None: - """The trailing identifier of a decorator (e.g. trusted or trust_boundary).""" - node = deco.func if isinstance(deco, ast.Call) else deco - if isinstance(node, ast.Attribute): - return node.attr - if isinstance(node, ast.Name): - return node.id +def _resolve_decorator_fqn(deco: ast.expr, alias_map: Mapping[str, str]) -> str | None: + """Resolve a decorator to its fully-qualified name through the module's import alias + map (mirrors PY-WL-110's resolver), so ``@t`` from ``import trusted as t`` resolves to + ``wardline.decorators.trusted``.""" + func = deco.func if isinstance(deco, ast.Call) else deco + dotted = _dotted_name(func) + if dotted is None: + return None + head, _, rest = dotted.partition(".") + head_fqn = alias_map.get(head, head) + return f"{head_fqn}.{rest}" if rest else head_fqn + + +def _builtin_level_marker(deco: ast.expr, alias_map: Mapping[str, str], shadowed_roots: frozenset[str]) -> str | None: + """The canonical builtin marker name (``trusted`` / ``trust_boundary``) iff *deco* + resolves to a builtin level-bearing trust decorator THE ENGINE'S SEEDING WOULD HONOUR. + Gating on the resolved FQN (not the trailing identifier) fixes both the alias-blind FN — + ``@t(level=...)`` where ``t`` aliases the builtin — and the foreign-name FP — a + non-wardline / locally-defined decorator that merely happens to be spelled ``trusted`` + (wardline-0267c31cd8). Matching uses the seeding predicate itself: only the exact + exports ``P.`` / ``P.trust.`` count (an arbitrarily-nested path like + ``wardline.decorators.evil.trusted`` is seeded by NEITHER, so a bad level on it never + disabled any gate), and a marker whose root the scanned project shadows is rejected + fail-closed exactly as the provider rejects it. PY-WL-110 sidesteps shadows via its + anchored-provenance gate; this rule fires precisely where seeding FAILED, so it must + thread the shadow set explicitly.""" + fqn = _resolve_decorator_fqn(deco, alias_map) + if fqn is None: + return None + for bt in BUILTIN_BOUNDARY_TYPES: + if not bt.builtin or bt.canonical_name not in _LEVEL_MARKER_NAMES: + continue + if bt.module_prefix.split(".")[0] in shadowed_roots: + continue + if _is_builtin_decorator_fqn(fqn, bt.canonical_name, bt.module_prefix): + return bt.canonical_name return None @@ -83,10 +129,22 @@ def __init__(self, base_severity: Severity | None = None) -> None: def check(self, context: AnalysisContext) -> list[Finding]: findings: list[Finding] = [] + modules = list(context.alias_maps.keys()) + # The scanned project's modules are the alias-map keys; the same shadow + # computation the provider runs (a project-local top-level ``wardline`` / + # ``weft_markers`` rejects every builtin marker under that root). + shadowed_roots = _shadowed_builtin_roots(frozenset(modules)) for qualname, entity in context.entities.items(): - for deco in entity.node.decorator_list: - name = _marker_name(deco) - if name not in ("trusted", "trust_boundary"): + # The alias map for the module that owns this entity — needed to resolve an + # aliased builtin decorator to its FQN (mirrors PY-WL-110). + mod_name = next( + (m for m in sorted(modules, key=len, reverse=True) if qualname == m or qualname.startswith(m + ".")), + None, + ) + alias_map = (context.alias_maps.get(mod_name) if mod_name is not None else None) or {} + for deco_ordinal, deco in enumerate(entity.node.decorator_list): + name = _builtin_level_marker(deco, alias_map, shadowed_roots) + if name is None: continue if not isinstance(deco, ast.Call): @@ -127,10 +185,27 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=f"{name}:{token}", + # Join-key collision (wardline-377b896a87): this rule emits >1 + # finding per (rule, path, qualname) — one per invalid decorator on a + # def. With ``line_start`` no longer hashed (wlfp2), the decorator's + # position must come ENTIRELY from the discriminator. Two STACKED + # IDENTICAL decorators share name AND token, so the only thing that + # tells them apart is their + # POSITION in the def's decorator_list. The load-bearing + # discriminator is that ORDINAL (``#``): a within-def index that + # is move-stable (invariant to the def moving vertically AND to + # column shifts — unlike an absolute line/column span) and + # collision-complete (at most one finding per decorator; a repeated + # ``level=``/``to_level=`` kwarg is a SyntaxError, so the inner + # kw-loop yields <=1 match per decorator). ``{name}:{token}`` is + # retained as informative source text only. Source-only (no resolved + # tier), honouring the §8 invariant (weft-4a9d0f863c). Forward- + # compatible with a future relative-span discriminator. + taint_path=f"{name}:{token}#{deco_ordinal}", ), + # OLD (wlfp1) taint_path == NEW (P3 unchanged) but ephemeral — recompute for rekey (P4). + taint_path_v0=f"{name}:{token}#{deco_ordinal}", qualname=qualname, properties={"decorator": name, "token": token}, ) diff --git a/src/wardline/scanner/rules/metadata.py b/src/wardline/scanner/rules/metadata.py index eaae5fd4..d2c3c352 100644 --- a/src/wardline/scanner/rules/metadata.py +++ b/src/wardline/scanner/rules/metadata.py @@ -20,3 +20,12 @@ class RuleMetadata: examples_violation: tuple[str, ...] = field(default_factory=tuple) examples_clean: tuple[str, ...] = field(default_factory=tuple) maturity: Maturity = Maturity.STABLE + # True iff the rule can emit >1 finding for one (rule_id, qualname) — i.e. it loops + # over handlers / calls / decorators / returns within one entity. Such a rule MUST + # carry a source-derived entity-relative discriminator in ``taint_path`` (a col span + # or PY-WL-114's ordinal), since ``line_start`` no longer separates co-located + # findings (wlfp2, wardline-8654423823). A singleton (<=1 finding per qualname) + # passes ``taint_path=None``. Default is the conservative SINGLETON; the + # ``test_discriminator_shape`` source-AST lint enforces multi_emit <-> taint_path + # so a multi-emit rule cannot silently ship a colliding ``None``. + multi_emit: bool = False diff --git a/src/wardline/scanner/rules/none_leak.py b/src/wardline/scanner/rules/none_leak.py index 90ef4335..26961e38 100644 --- a/src/wardline/scanner/rules/none_leak.py +++ b/src/wardline/scanner/rules/none_leak.py @@ -28,6 +28,7 @@ from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.taints import RAW_ZONE, TRUST_RANK from wardline.scanner.rules._ast_helpers import _own_statements +from wardline.scanner.rules._sink_helpers import module_for_qualname from wardline.scanner.rules.metadata import RuleMetadata if TYPE_CHECKING: @@ -50,14 +51,6 @@ ) -def _module_for_qualname(qualname: str, context: AnalysisContext) -> str | None: - modules = context.alias_maps.keys() - for module in sorted(modules, key=len, reverse=True): - if qualname == module or qualname.startswith(module + "."): - return module - return None - - def _is_none_return(stmt: ast.Return) -> bool: """A bare ``return`` (value is None) or an explicit ``return None``.""" return stmt.value is None or (isinstance(stmt.value, ast.Constant) and stmt.value.value is None) @@ -137,6 +130,21 @@ def _is_generator(node: ast.AST) -> bool: return False +def _has_loop_break(node: ast.AST) -> bool: + """True if *node* is, or contains, a ``break`` that binds to the loop *node* is + part of. Stops at nested loops (their breaks bind there) and at nested + function/class/lambda scopes (a break there is a separate construct).""" + if isinstance(node, ast.Break): + return True + if isinstance(node, (ast.For, ast.AsyncFor, ast.While)): + # A break in a nested loop's body binds to *that* loop; but a break in its + # ``else:`` clause binds outward (to ours), so recurse only into orelse. + return any(_has_loop_break(stmt) for stmt in node.orelse) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + return False # a break here belongs to a separate scope + return any(_has_loop_break(child) for child in ast.iter_child_nodes(node)) + + def _can_fall_through(stmts: list[ast.stmt]) -> bool: """True if there is any execution path through stmts that doesn't end with return or raise.""" if not stmts: @@ -144,6 +152,19 @@ def _can_fall_through(stmts: list[ast.stmt]) -> bool: for stmt in stmts: if isinstance(stmt, (ast.Return, ast.Raise)): return False + # A with block is transparent to control flow: it is terminal iff its body + # is terminal (the context manager runs, then the body executes). + if isinstance(stmt, (ast.With, ast.AsyncWith)) and not _can_fall_through(stmt.body): + return False + # A constant-true loop with no break targeting it never exits normally to + # fall through — the only way out is a return/raise in the body. + if ( + isinstance(stmt, ast.While) + and isinstance(stmt.test, ast.Constant) + and stmt.test.value + and not any(_has_loop_break(b) for b in stmt.body) + ): + return False if isinstance(stmt, ast.If): if not stmt.orelse: # If can fall through if condition is false @@ -195,7 +216,7 @@ def check(self, context: AnalysisContext) -> list[Finding]: continue # trust-raising shape -> PY-WL-102's territory, not 109's if _is_generator(entity.node): continue - module = _module_for_qualname(qualname, context) + module = module_for_qualname(qualname, context) alias_map = context.alias_maps.get(module, {}) if module is not None else {} if not _promises_non_none(entity.node, alias_map): continue # no explicit non-None contract -> not a provable leak (FP guard) @@ -222,9 +243,11 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=f"None->{declared.value}", + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, + # so (rule, path, line, qualname) is already unique. The declared tier is a + # resolved value that drifts as the suite is extended — keep it off the join key. + taint_path=None, ), qualname=qualname, properties={"declared_return": declared.value}, diff --git a/src/wardline/scanner/rules/path_traversal.py b/src/wardline/scanner/rules/path_traversal.py index f6355063..71496790 100644 --- a/src/wardline/scanner/rules/path_traversal.py +++ b/src/wardline/scanner/rules/path_traversal.py @@ -1,16 +1,60 @@ # src/wardline/scanner/rules/path_traversal.py """PY-WL-116 — untrusted data reaches a path/filesystem-traversal sink. -Passing untrusted data to filesystem APIs (``open``, ``os.open``, ``pathlib.Path``, -or helper functions like ``os.path.join``) can lead to path traversal (CWE-22). +Passing untrusted data to filesystem APIs can lead to path traversal (CWE-22). +Three sink families (wardline-04b65cf0be + the Zip Slip eval item): + +* **Direct dotted calls** with a tainted path argument — ``open``/``os.open``/ + ``os.path.join``/``pathlib.Path`` plus the filesystem-MUTATION APIs + (``os.remove``/``os.rename``/``shutil.rmtree``/``shutil.copy``/...), where a + tainted path is a destructive traversal (delete/move/copy outside the + intended directory). All argument slots are path-like, so the inherited + worst-of-all-args taint is the correct selector (a tainted destination is + traversal too) — no :class:`ArgSpec` needed. +* **Path METHODS** (``read_text``/``write_bytes``/``open``/``unlink``/...) on a + ``pathlib.Path`` CONSTRUCTED from tainted input, resolved through the shared + sink-binding machinery (``p = Path(raw); p.read_text()`` and the chained + ``pathlib.Path(raw).read_text()``). The dangerous data is the CONSTRUCTOR's + argument, so the taint is read from the constructor call, not the + (typically argument-less) method call. +* **Archive extraction** (Zip Slip / tarbomb): ``extractall``/``extract`` on a + ``tarfile.open``/``tarfile.TarFile``/``zipfile.ZipFile`` instance whose + ARCHIVE SOURCE is tainted — a malicious archive escapes the target directory + via ``../`` member names. Exemption: an extraction call passing tarfile's + safe filter as the literal ``filter="data"`` (blocks absolute paths, + traversal, and device members since 3.12) does not fire; any other filter + value (including ``"fully_trusted"`` or a dynamic expression) still fires. + +v1 method-sink scope: the constructor must be a function-local binding (an +assignment / ``with ... as`` / walrus in the same scope, or the chained +receiver). Module-level constructions and annotation-only bindings carry no +resolvable constructor call and stay silent. + Tier-modulated; fires only where trust is declared. """ from __future__ import annotations -from wardline.core.finding import Kind, Maturity, Severity -from wardline.scanner.rules._sink_helpers import TaintedSinkRule +import ast +from typing import TYPE_CHECKING + +from wardline.core.finding import Finding, Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ( + RAW_ZONE, + TaintedSinkRule, + build_sink_finding, + collect_ctor_call_nodes, + enclosing_declared_tier, + module_for_qualname, + receiver_ctor_call, + resolved_sink_calls, + worst_arg_taint, +) from wardline.scanner.rules.metadata import RuleMetadata +from wardline.scanner.rules.severity_model import modulate + +if TYPE_CHECKING: + from wardline.scanner.context import AnalysisContext _SINKS = frozenset( { @@ -19,28 +63,144 @@ "os.open", "os.path.join", "pathlib.Path", + # filesystem mutation — tainted paths here are destructive traversal + "os.remove", + "os.unlink", + "os.rmdir", + "os.makedirs", + "os.mkdir", + "os.rename", + "os.renames", + "os.replace", + "shutil.rmtree", + "shutil.copy", + "shutil.copy2", + "shutil.copyfile", + "shutil.copytree", + "shutil.move", } ) +_PATH_METHODS = ("read_text", "read_bytes", "write_text", "write_bytes", "open", "unlink", "rmdir", "mkdir") +_PATH_METHOD_SINKS = frozenset(f"pathlib.Path.{m}" for m in _PATH_METHODS) + +_ARCHIVE_CTORS = ("tarfile.open", "tarfile.TarFile", "zipfile.ZipFile") +_ARCHIVE_METHOD_SINKS = frozenset(f"{ctor}.{m}" for ctor in _ARCHIVE_CTORS for m in ("extractall", "extract")) + +_METHOD_SINKS = _PATH_METHOD_SINKS | _ARCHIVE_METHOD_SINKS + METADATA = RuleMetadata( rule_id="PY-WL-116", base_severity=Severity.WARN, kind=Kind.DEFECT, + multi_emit=True, description=( - "Untrusted data reaches a path/filesystem-traversal sink (open/os.path.join/pathlib.Path) " - "in a trusted-tier function." + "Untrusted data reaches a path/filesystem-traversal sink (open/os.path.join/pathlib.Path, " + "filesystem mutation via os.remove/os.rename/shutil.*, Path methods on a tainted pathlib.Path, " + "or tarfile/zipfile archive extraction — Zip Slip) in a trusted-tier function." ), examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trusted(level='ASSURED')\ndef f(p):\n open(read_raw(p))", + "import shutil\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n shutil.rmtree(read_raw(p))", + "import pathlib\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n q = pathlib.Path(read_raw(p))\n return q.read_text()", + "import tarfile\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n tf = tarfile.open(read_raw(p))\n tf.extractall('/dst')", + ), + examples_clean=( + "@trusted(level='ASSURED')\ndef f():\n open('safe_file.txt')", + "import tarfile\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n tf = tarfile.open(read_raw(p))\n" + " tf.extractall('/dst', filter='data')", ), - examples_clean=("@trusted(level='ASSURED')\ndef f():\n open('safe_file.txt')",), maturity=Maturity.PREVIEW, ) +def _has_safe_extraction_filter(call: ast.Call) -> bool: + """True iff the extraction call passes tarfile's safe filter as the LITERAL + ``filter="data"``. Any other value — ``"fully_trusted"``, ``"tar"`` (which still + permits in-tree hardlink tricks pre-3.12.x fixes), or a dynamic expression — is + not accepted as a mitigation.""" + return any( + kw.arg == "filter" and isinstance(kw.value, ast.Constant) and kw.value.value == "data" for kw in call.keywords + ) + + class PathTraversal(TaintedSinkRule): rule_id = METADATA.rule_id metadata = METADATA SINKS = _SINKS sink_label = "path-traversal" + + def check(self, context: AnalysisContext) -> list[Finding]: + findings = super().check(context) # direct dotted sinks (worst-of-all-args) + findings.extend(self._method_sink_findings(context)) + return findings + + def _method_sink_findings(self, context: AnalysisContext) -> list[Finding]: + """Construct-then-method sinks: Path methods and archive extraction. + + The method call itself is usually argument-less — the dangerous data is what + the RECEIVER was constructed from, so the taint verdict is the constructor + call's worst argument taint (flow-sensitive, at the construction site). + """ + findings: list[Finding] = [] + for qualname, entity in context.entities.items(): + tier = enclosing_declared_tier(qualname, context.project_taints, context.declared_qualnames) + severity = modulate(self.base_severity, tier) + if severity == Severity.NONE: + continue # freedom / fail-closed zone — suppressed + module = module_for_qualname(qualname, context) + alias_map = context.alias_maps.get(module, {}) if module is not None else {} + ctor_nodes = collect_ctor_call_nodes(entity.node) + module_bindings = context.module_bindings.get(module or "") + for call, fqn in resolved_sink_calls( + entity.node, _METHOD_SINKS, alias_map, module or "", module_bindings=module_bindings + ): + is_archive = fqn in _ARCHIVE_METHOD_SINKS + if is_archive and _has_safe_extraction_filter(call): + continue + ctor = receiver_ctor_call(call, ctor_nodes) + if ctor is None: + continue # no resolvable construction site (v1 scope) + worst = worst_arg_taint(ctor, qualname, context) + if worst is None or worst not in RAW_ZONE: + continue + line = call.lineno + if is_archive: + message = ( + f"{qualname}: {worst.value} (untrusted) archive opened at line {ctor.lineno} " + f"reaches the archive extraction (Zip Slip) sink {fqn}() at line {line}" + ) + else: + message = ( + f"{qualname}: {worst.value} (untrusted) data reaches the path-traversal sink " + f"{fqn}() at line {line} via a Path constructed at line {ctor.lineno}" + ) + # Shared constructor — identical wlfp2 discriminator shape as the base + # loop, keyed on the METHOD call + the resolved sink FQN. The FQN + # differs from the constructor sink's ("pathlib.Path.read_text" vs + # "pathlib.Path"), so the chained form's co-located ctor and method + # findings never collide. + findings.append( + build_sink_finding( + rule_id=self.rule_id, + entity=entity, + qualname=qualname, + call=call, + dotted=fqn, + severity=severity, + tier=tier, + worst=worst, + sink_label=self.sink_label, + message=message, + ) + ) + return findings diff --git a/src/wardline/scanner/rules/silent_exception.py b/src/wardline/scanner/rules/silent_exception.py index a25c9a5d..c1e89143 100644 --- a/src/wardline/scanner/rules/silent_exception.py +++ b/src/wardline/scanner/rules/silent_exception.py @@ -1,9 +1,10 @@ # src/wardline/scanner/rules/silent_exception.py """PY-WL-104 — silently swallowed exception in a trusted-tier function. -A handler whose body only ``pass``/``...``/``continue``/``break`` discards the -error with no logging, re-raise, or recovery. Tier-modulated (§5) — silent on -undecorated code. +A handler whose body is only ``pass``/``...``/``continue``/``break`` or a bare +constant expression (a string literal or number) discards the error with no +logging, re-raise, or recovery. Tier-modulated (§5) — silent on undecorated +code, downgraded one step on partial tiers. """ from __future__ import annotations @@ -12,8 +13,8 @@ from wardline.core.finding import Finding, Kind, Location, Severity from wardline.core.finding import compute_finding_fingerprint as _fp -from wardline.core.taints import TaintState from wardline.scanner.rules._ast_helpers import is_silent_handler, own_except_handlers +from wardline.scanner.rules._sink_helpers import enclosing_declared_tier from wardline.scanner.rules.metadata import RuleMetadata from wardline.scanner.rules.severity_model import modulate @@ -24,8 +25,11 @@ rule_id="PY-WL-104", base_severity=Severity.WARN, kind=Kind.DEFECT, - description="An exception handler that silently swallows the error " - "(only pass/.../continue/break) in a trusted-tier function.", + multi_emit=True, + description="An exception handler that silently swallows the error — body is " + "only pass/.../continue/break or a bare constant expression (e.g. a " + "docstring-like string literal or a number). Tier-modulated: fires on " + "trusted tiers, downgraded to INFO on partial tiers.", examples_violation=("@trusted\ndef f():\n try:\n g()\n except ValueError:\n pass",), examples_clean=("@trusted\ndef f():\n try:\n g()\n except ValueError:\n log(e)",), ) @@ -41,8 +45,11 @@ def __init__(self, base_severity: Severity | None = None) -> None: def check(self, context: AnalysisContext) -> list[Finding]: findings: list[Finding] = [] for qualname, entity in context.entities.items(): - lookup_name = qualname.split("..")[0] - tier = context.project_taints.get(lookup_name, TaintState.UNKNOWN_RAW) + # Nearest DECLARED enclosing scope governs a nested def (a nested def's own + # trust decorator wins; undeclared nested defs inherit) — the same + # enclosing_declared_tier semantics as the sink rule family, NOT the + # outermost-function strip (wardline-bb8396f96e / wardline-9b88ec5419). + tier = enclosing_declared_tier(qualname, context.project_taints, context.declared_qualnames) severity = modulate(self.base_severity, tier) if severity == Severity.NONE: continue @@ -56,13 +63,18 @@ def check(self, context: AnalysisContext) -> list[Finding]: message=f"{qualname}: exception silently swallowed at line {line}", severity=severity, kind=Kind.DEFECT, + # Location.line_start stays the HANDLER line (display/SARIF + the P4 + # migration's old-fp derivation), NOT the def line. location=Location(path=entity.location.path, line_start=line), fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=line, qualname=qualname, - taint_path=tier.value, + # Multi-emit: >1 silent handler per function. Discriminate ENTITY-RELATIVE + # (handler line - def line) + the handler's lexical span, so two handlers stay + # distinct after line_start left the hash (wlfp2/wardline-6102d4c833) yet a + # comment ABOVE the function does not churn it. Source-only; tier never joins. + taint_path=f"{handler.lineno - (entity.location.line_start or 0)}:{handler.col_offset}:{handler.end_col_offset}:except", # noqa: E501 ), qualname=qualname, properties={"tier": tier.value}, diff --git a/src/wardline/scanner/rules/sql_injection.py b/src/wardline/scanner/rules/sql_injection.py index 20c1f701..60a09c97 100644 --- a/src/wardline/scanner/rules/sql_injection.py +++ b/src/wardline/scanner/rules/sql_injection.py @@ -2,40 +2,290 @@ """PY-WL-118 — untrusted data reaches SQL/database execution sinks. Passing untrusted data directly to database queries (``cursor.execute``, -``cursor.executemany``) can lead to SQL Injection (SQLi) (CWE-89). -Tier-modulated; fires only where trust is declared. +``cursor.executemany``, ``cursor.executescript``) can lead to SQL Injection +(SQLi) (CWE-89). ``executescript`` (sqlite3 cursor AND connection) runs a +multi-statement script with NO parameter binding at all, so it is strictly more +dangerous than ``execute``. Tier-modulated; fires only where trust is declared. + +**Receiver heuristic (FP guard, fail-closed).** ``.execute`` is matched by method +name, so a receiver gate keeps non-database executors (task pools, command +objects) from firing a CWE-89 ERROR. Evidence is consulted strongest-first: + +1. *Binding evidence* — the receiver was provably constructed in this function + (``pool = sqlite3.connect(...)`` / chained ``Cls().execute``): a constructor + from a known DB driver module fires regardless of the receiver's name; one + from a known executor module (``concurrent.futures`` etc.) is suppressed. +2. *Name evidence* — exact token match on the receiver's simple name (a ``Name`` + id or the LAST attribute segment, underscore/camelCase split): a DB token + (``cursor``/``conn``/``db``/``session``/``engine``/...) fires and WINS over a + non-DB token; a non-DB token (``pool``/``executor``/``worker``/...) alone + suppresses. +3. *No evidence* — UNKNOWN receivers (opaque names like ``c``/``s``, dynamic + expressions) FIRE: when unsure we keep the finding, because an FN here is + worse than an FP. Token matching is exact (never substring), so ``secure`` + can never match ``cur``. + +**Text-clause constant exemption (FP guard).** The canonical SQLAlchemy +parameterized pattern ``conn.execute(text("... :id"), {"id": uid})`` wraps a +compile-time CONSTANT in a recognized text-clause constructor — it cannot carry +attacker bytes, so the operation slot is treated as clean. The exemption needs +BOTH the recognized constructor FQN (import-alias aware) and all-constant +arguments: ``text(tainted)`` / ``text(f"...")`` still fire (``text()`` is not a +sanitiser). """ from __future__ import annotations import ast +import re from typing import TYPE_CHECKING -from wardline.core.finding import Finding, Kind, Location, Maturity, Severity -from wardline.core.finding import compute_finding_fingerprint as _fp -from wardline.core.taints import RAW_ZONE, TaintState -from wardline.scanner.rules._ast_helpers import own_nodes -from wardline.scanner.rules._sink_helpers import TaintedSinkRule, worst_arg_taint +from wardline.core.finding import Finding, Kind, Maturity, Severity +from wardline.core.taints import RAW_ZONE, TRUST_RANK, TaintState +from wardline.scanner.rules._sink_helpers import ( + SinkBindings, + TaintedSinkRule, + build_sink_finding, + canonical_call_name, + collect_sink_bindings, + dotted_name, + enclosing_declared_tier, + module_alias_map, + resolved_arg_taints, + sink_method_calls, +) from wardline.scanner.rules.metadata import RuleMetadata from wardline.scanner.rules.severity_model import modulate if TYPE_CHECKING: + from collections.abc import Mapping + from wardline.scanner.context import AnalysisContext -_SINKS = frozenset({"execute", "executemany"}) +_SINKS = frozenset({"execute", "executemany", "executescript"}) + +# The DB-API call shape is ``cursor.execute(operation[, parameters])`` / +# ``cursor.executemany(operation, seq_of_params)``: the SQL string is the FIRST +# positional argument (or the ``operation`` keyword), and everything after it is the +# bound-parameter argument. SQLi (CWE-89) is a property of the SQL STRING only — +# untrusted data passed as a *bound parameter* cannot alter the query's structure (it is +# the canonical OWASP mitigation), so taint in the parameters position is NOT SQLi and +# must not fire (wardline-e0e44852e7). This is a deliberate behaviour decision, not just +# a test fix: we gate on the operation-string position and ignore the parameter position. +# ``sql_script`` is sqlite3's parameter name for the executescript slot (its only arg). +_SQL_STRING_KEYS: frozenset[int | str] = frozenset({0, "operation", "sql", "query", "statement", "sql_script"}) + +# Recognized text-clause constructors (canonical import-resolved FQNs). A call to one +# of these with ALL-constant arguments is a compile-time-fixed SQL string — not +# injectable, regardless of the engine's (unresolvable-third-party) UNKNOWN_RAW verdict. +_TEXT_CLAUSE_FQNS = frozenset( + { + "sqlalchemy.text", + "sqlalchemy.sql.text", + "sqlalchemy.sql.expression.text", + } +) + +# Receiver-heuristic vocabularies (see the module docstring). Tokens match EXACTLY +# against the underscore/camelCase-split words of the receiver's simple name. +_DB_RECEIVER_TOKENS = frozenset( + { + "cursor", "cur", "conn", "connection", "db", "database", "session", + "engine", "sql", "sqlite", "postgres", "psql", "mysql", "oracle", + "tx", "txn", "transaction", + } +) # fmt: skip +_NON_DB_RECEIVER_TOKENS = frozenset( + { + "pool", "executor", "thread", "threads", "scheduler", "worker", + "workers", "dispatcher", "queue", "task", "tasks", "job", "jobs", + } +) # fmt: skip +# Constructor-FQN module prefixes: provably-DB fires regardless of receiver name; +# provably-executor suppresses. Everything else falls through to the name heuristic. +_DB_MODULE_PREFIXES = ( + "sqlite3.", "aiosqlite.", "apsw.", "duckdb.", + "psycopg2.", "psycopg.", "asyncpg.", "pg8000.", + "pymysql.", "MySQLdb.", "mysql.", "mariadb.", + "cx_Oracle.", "oracledb.", "pyodbc.", "sqlalchemy.", +) # fmt: skip +_NON_DB_MODULE_PREFIXES = ("concurrent.", "multiprocessing.", "threading.", "asyncio.") + +_NAME_TOKEN_RE = re.compile(r"[a-z]+") +_CAMEL_SPLIT_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") + + +def _name_tokens(name: str) -> frozenset[str]: + """Exact-match word tokens of an identifier: underscore + camelCase split, + lowercased, digits dropped (``dbConn2`` → {"db", "conn"}).""" + return frozenset(_NAME_TOKEN_RE.findall(_CAMEL_SPLIT_RE.sub("_", name).lower())) + + +def _is_constant_text_clause(expr: ast.expr, alias_map: Mapping[str, str]) -> bool: + """Whether *expr* is a recognized text-clause constructor call whose every + argument is a compile-time constant (the SQL string itself a ``str``). + + Fail-closed everywhere else: a non-recognized wrapper, a non-constant argument + (Name / f-string / nested call), a ``*``/``**`` splat, or a zero-argument call + proves nothing and keeps the slot's engine taint.""" + if not isinstance(expr, ast.Call): + return False + dotted = dotted_name(expr.func) + if dotted is None or canonical_call_name(dotted, alias_map) not in _TEXT_CLAUSE_FQNS: + return False + if not (expr.args or expr.keywords): + return False + for arg in expr.args: + if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)): + return False + return all(kw.arg is not None and isinstance(kw.value, ast.Constant) for kw in expr.keywords) + + +def _receiver_is_non_db(call: ast.Call, bindings: SinkBindings, alias_map: Mapping[str, str]) -> bool: + """Whether the sink call's receiver is CLEARLY a non-database object. + + Returns True ONLY on positive non-DB evidence (binding to an executor-module + constructor, or a non-DB name token with no DB token); every unknown receiver + returns False so the rule keeps firing (fail-closed — FN is worse than FP here). + """ + func = call.func + assert isinstance(func, ast.Attribute) # guaranteed by sink_method_calls + receiver = func.value + ctor_fqn: str | None = None + if isinstance(receiver, ast.Name): + ctor_fqn = bindings.instance_classes.get(receiver.id) + elif isinstance(receiver, ast.Call): + dotted = dotted_name(receiver.func) + ctor_fqn = canonical_call_name(dotted, alias_map) if dotted is not None else None + if ctor_fqn is not None: + if ctor_fqn.startswith(_DB_MODULE_PREFIXES): + return False # provably a DB-driver object — definite sink + if ctor_fqn.startswith(_NON_DB_MODULE_PREFIXES): + return True # provably an executor/pool-family object + if isinstance(receiver, ast.Name): + hint = receiver.id + elif isinstance(receiver, ast.Attribute): + hint = receiver.attr # self.thread_pool → "thread_pool" + elif ctor_fqn is not None: + hint = ctor_fqn.rsplit(".", 1)[-1] # conn.cursor() → "cursor" + else: + return False # dynamic receiver, no evidence — fail closed, fire + tokens = _name_tokens(hint) + if tokens & _DB_RECEIVER_TOKENS: + return False # DB evidence wins over mixed names ("db_pool") + return bool(tokens & _NON_DB_RECEIVER_TOKENS) + + +def _kwargs_may_target_sql_string(call: ast.Call) -> bool: + """Whether a ``**`` unpack in *call* could supply the SQL-string operation. + + ``True`` when ANY ``**`` unpack is opaque/non-static (a name, comprehension, or dict with + a ``**``-spread or non-constant key) OR is a literal dict carrying an SQL-string key; + ``False`` only when EVERY ``**`` unpack is a static literal dict whose keys are all + non-SQL-string (provably bound-parameter only). Iterates all ``**`` keywords so a clean + literal followed by an opaque/SQL-keyed one still fails closed. Meaningful only when a + ``**`` unpack is present — the caller gates on the engine's ``None`` arg-key, which exists + iff the call has a ``**`` unpack.""" + for kw in call.keywords: + if kw.arg is not None: + continue # a named keyword, keyed by its own name — handled via _SQL_STRING_KEYS + value = kw.value + if ( + isinstance(value, ast.Dict) + and value.keys + and all(isinstance(k, ast.Constant) and isinstance(k.value, str) for k in value.keys) + ): + if any(k.value in _SQL_STRING_KEYS for k in value.keys): # type: ignore[union-attr] + return True # literal dict targets an SQL-string key + # literal dict with only non-SQL keys → bound-parameter only; keep checking others + else: + return True # opaque / non-static ** — cannot isolate; fail closed + return False + + +def _sql_string_taint( + call: ast.Call, + qualname: str, + context: AnalysisContext, + alias_map: Mapping[str, str], +) -> TaintState | None: + """The least-trusted taint reaching the SQL-STRING argument (the operation), ignoring + bound-parameter arguments. Fail-closed on positions that cannot be isolated from the + operation: a ``Starred`` first positional (``execute(*args)``) and a ``**`` unpack that + could supply the ``operation`` keyword (``execute(**kwargs)`` / ``**{"operation": ...}``). + + An SQL slot whose expression is a recognized text-clause constructor over constants + (:func:`_is_constant_text_clause`) is skipped — a compile-time-fixed operation string + is not injectable even when the engine's verdict for the unresolvable third-party + wrapper is ``UNKNOWN_RAW`` (the canonical SQLAlchemy parameterized-query FP). + + The engine collapses a ``**`` unpack to a single ``None``-keyed taint (the worst across the + unpacked dict's values), so per-key attribution is impossible at this layer: when a literal + ``**`` dict carries an SQL-string key, the ``None`` taint is treated as reaching the + operation even if a clean value occupied that key — a deliberate fail-closed + over-approximation (never an FN). See wardline-8c31463f9f / wardline-e0e44852e7.""" + taints = resolved_arg_taints(call, qualname, context) + kwargs_is_sql_slot = _kwargs_may_target_sql_string(call) + # Slot-key → source expression, so a slot's TAINT can be overridden by what the slot + # syntactically IS. Starred positionals keep only their "*i" fail-closed key; a ``**`` + # unpack (key None) has no single expression and is never exempted. + slot_exprs: dict[int | str | None, ast.expr] = {} + for i, arg in enumerate(call.args): + if not isinstance(arg, ast.Starred): + slot_exprs[i] = arg + for kw in call.keywords: + if kw.arg is not None: + slot_exprs[kw.arg] = kw.value + worst: TaintState | None = None + for key, ts in taints.items(): + if ts is None: + continue + is_sql_slot = ( + key in _SQL_STRING_KEYS # operation position 0 / operation-ish keyword + or key == "*0" # Starred first positional — cannot isolate; fail closed + or (key is None and kwargs_is_sql_slot) # ** unpack that may target the operation + ) + if not is_sql_slot: + continue + expr = slot_exprs.get(key) + if expr is not None and _is_constant_text_clause(expr, alias_map): + continue # constant text() clause — compile-time-fixed, not injectable + if worst is None or TRUST_RANK[ts] > TRUST_RANK[worst]: + worst = ts + return worst + METADATA = RuleMetadata( rule_id="PY-WL-118", base_severity=Severity.ERROR, kind=Kind.DEFECT, + multi_emit=True, description=( - "Untrusted data reaches a SQL/database execution sink (execute/executemany) in a trusted-tier function." + "Untrusted data reaches a SQL/database execution sink (execute/executemany/executescript) " + "in a trusted-tier function." ), examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trusted(level='ASSURED')\ndef f(p, cursor):\n cursor.execute(read_raw(p))", + # executescript runs a multi-statement script with NO parameter binding at all — + # the single most injection-prone DB-API method (wardline-1751b0fac6). + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p, cursor):\n cursor.executescript(read_raw(p))", + ), + examples_clean=( + "@trusted(level='ASSURED')\ndef f(cursor):\n cursor.execute('SELECT * FROM users')", + # Parameterized / bound-parameter query: untrusted data in the PARAMETER position + # (the OWASP-canonical mitigation) is not SQLi and must not fire (wardline-e0e44852e7). + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef g(p, cursor):\n" + " cursor.execute('SELECT * FROM t WHERE id = ?', (read_raw(p),))", + # The canonical SQLAlchemy parameterized query: a compile-time-constant operation + # string wrapped in text(), with the untrusted value bound — not injectable. + "from sqlalchemy import text\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef h(p, conn):\n" + " conn.execute(text('SELECT * FROM t WHERE id = :id'), {'id': read_raw(p)})", ), - examples_clean=("@trusted(level='ASSURED')\ndef f(cursor):\n cursor.execute('SELECT * FROM users')",), maturity=Maturity.PREVIEW, ) @@ -49,34 +299,43 @@ class SQLInjection(TaintedSinkRule): def check(self, context: AnalysisContext) -> list[Finding]: findings: list[Finding] = [] for qualname, entity in context.entities.items(): - tier = context.project_taints.get(qualname, TaintState.UNKNOWN_RAW) + # Honor a nested def's OWN trust decorator, else inherit the nearest declared + # enclosing scope's tier — matching the family-wide TaintedSinkRule base + # (_sink_helpers.enclosing_declared_tier). Without inheritance a tainted execute() + # in an undecorated nested def evaded the sink (wardline-9b88ec5419); an + # unconditional strip wrongly overrode a nested def's own tier (wardline-bb8396f96e). + tier = enclosing_declared_tier(qualname, context.project_taints, context.declared_qualnames) severity = modulate(self.base_severity, tier) if severity == Severity.NONE: continue # freedom / fail-closed zone — suppressed - for node in own_nodes(entity.node): - if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in self.SINKS: - worst = worst_arg_taint(node, qualname, context) - if worst is not None and worst in RAW_ZONE: - line = node.lineno - findings.append( - Finding( - rule_id=self.rule_id, - message=( - f"{qualname}: {worst.value} (untrusted) data reaches the {self.sink_label} " - f"sink {node.func.attr}() at line {line}" - ), - severity=severity, - kind=Kind.DEFECT, - location=Location(path=entity.location.path, line_start=line), - fingerprint=_fp( - rule_id=self.rule_id, - path=entity.location.path, - line_start=line, - qualname=qualname, - taint_path=f"{worst.value}->{node.func.attr}", - ), - qualname=qualname, - properties={"tier": tier.value, "sink": node.func.attr, "arg_taint": worst.value}, - ) - ) + alias_map = module_alias_map(qualname, context) + bindings: SinkBindings | None = None # collected lazily, once per entity + for node in sink_method_calls(entity.node, self.SINKS): + if bindings is None: + # Function-local bindings only: entities carry no module AST, so a + # module-level-constructed receiver falls to the name heuristic + # (which fails closed — fires — on anything not clearly non-DB). + bindings = collect_sink_bindings(entity.node, alias_map) + if _receiver_is_non_db(node, bindings, alias_map): + continue # clearly a task-pool/executor object, not a SQL surface + worst = _sql_string_taint(node, qualname, context, alias_map) + if worst is None or worst not in RAW_ZONE: + continue + assert isinstance(node.func, ast.Attribute) # guaranteed by sink_method_calls + # Shared constructor — identical message shape and wlfp2 discriminator + # as the base loop, keyed on the METHOD NAME (execute/executemany/ + # executescript), never the resolved receiver (drifts). + findings.append( + build_sink_finding( + rule_id=self.rule_id, + entity=entity, + qualname=qualname, + call=node, + dotted=node.func.attr, + severity=severity, + tier=tier, + worst=worst, + sink_label=self.sink_label, + ) + ) return findings diff --git a/src/wardline/scanner/rules/ssrf.py b/src/wardline/scanner/rules/ssrf.py index b6a0c4c7..5e23800d 100644 --- a/src/wardline/scanner/rules/ssrf.py +++ b/src/wardline/scanner/rules/ssrf.py @@ -1,55 +1,110 @@ # src/wardline/scanner/rules/ssrf.py """PY-WL-117 — untrusted data reaches an HTTP client sink. -Passing untrusted data to HTTP clients (``requests``, ``httpx``, ``urllib``) without -validation can lead to Server-Side Request Forgery (SSRF) (CWE-918). -Tier-modulated; fires only where trust is declared. +Passing untrusted data to HTTP clients (``requests``, ``httpx``, ``aiohttp``, +``urllib``) without validation can lead to Server-Side Request Forgery (SSRF) +(CWE-918). Tier-modulated; fires only where trust is declared. + +Two sharpenings over the generic :class:`TaintedSinkRule` machinery +(wardline-3002f63969 / wardline-66b2c91470): + +* **Construct-then-method** — production code overwhelmingly pools requests + through a constructed client (``client = httpx.Client(); client.get(url)``, + ``async with httpx.AsyncClient() as c``, ``requests.Session().get(url)``, + ``aiohttp.ClientSession``). Sink matching goes through + :func:`resolved_sink_calls`, which layers name-binding resolution over the + direct dotted/import-aliased spelling. + +* **URL-slot precision** — for an HTTP client, only the request-target slot is + an SSRF vector. Every sink carries an :class:`ArgSpec` naming its URL slot + (``base_url=`` for client constructors), so a tainted ``timeout=``/ + ``verify=``/``headers=`` with a clean literal URL no longer fires + (:func:`worst_dangerous_arg_taint` keeps the fail-closed splat widening). """ from __future__ import annotations +from typing import TYPE_CHECKING + from wardline.core.finding import Kind, Maturity, Severity -from wardline.scanner.rules._sink_helpers import TaintedSinkRule +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule from wardline.scanner.rules.metadata import RuleMetadata -_SINKS = frozenset( - { - "requests.get", - "requests.post", - "requests.put", - "requests.patch", - "requests.delete", - "requests.request", - "httpx.get", - "httpx.post", - "httpx.put", - "httpx.patch", - "httpx.delete", - "httpx.request", - "httpx.Client", - "httpx.AsyncClient", - "urllib.request.urlopen", - } -) +if TYPE_CHECKING: + from collections.abc import Mapping + +# The URL is the first positional / the ``url`` keyword (requests.get, client.get, ...). +_URL_FIRST = ArgSpec(positions=(0,), keywords=("url",)) +# ``(method, url, ...)`` signatures: the URL is the SECOND slot; a tainted method verb +# cannot redirect the request target (requests.request, client.request, client.stream). +_URL_SECOND = ArgSpec(positions=(1,), keywords=("url",)) +# httpx client constructors: the first positional is NOT a URL; ``base_url`` is the +# only SSRF-relevant constructor argument (and is keyword-only). +_BASE_URL_ONLY = ArgSpec(keywords=("base_url",)) + +_HTTP_VERBS = ("get", "post", "put", "patch", "delete", "head", "options") +_CLIENT_CLASSES = ("httpx.Client", "httpx.AsyncClient", "requests.Session", "aiohttp.ClientSession") + + +def _build_sink_specs() -> dict[str, ArgSpec]: + specs: dict[str, ArgSpec] = {} + for mod in ("requests", "httpx"): + for verb in _HTTP_VERBS: + specs[f"{mod}.{verb}"] = _URL_FIRST + specs[f"{mod}.request"] = _URL_SECOND + specs["httpx.stream"] = _URL_SECOND + specs["urllib.request.urlopen"] = _URL_FIRST + specs["urllib.request.Request"] = _URL_FIRST # carries the tainted URL into urlopen + specs["httpx.Client"] = _BASE_URL_ONLY + specs["httpx.AsyncClient"] = _BASE_URL_ONLY + # aiohttp.ClientSession(base_url=...) — base_url IS the first positional there. + specs["aiohttp.ClientSession"] = ArgSpec(positions=(0,), keywords=("base_url",)) + # Instance methods on a constructed client/session (requests.Session() itself + # takes no arguments, so the constructor is not a sink — only its methods are). + for cls in _CLIENT_CLASSES: + for verb in _HTTP_VERBS: + specs[f"{cls}.{verb}"] = _URL_FIRST + specs[f"{cls}.request"] = _URL_SECOND + for cls in ("httpx.Client", "httpx.AsyncClient"): + specs[f"{cls}.stream"] = _URL_SECOND + return specs + + +_SINK_SPECS: Mapping[str, ArgSpec] = _build_sink_specs() +_SINKS = frozenset(_SINK_SPECS) METADATA = RuleMetadata( rule_id="PY-WL-117", base_severity=Severity.WARN, kind=Kind.DEFECT, + multi_emit=True, description=( - "Untrusted data reaches an HTTP client sink (SSRF, requests/httpx/urllib) in a trusted-tier function." + "Untrusted data reaches the URL slot of an HTTP client sink " + "(SSRF, requests/httpx/aiohttp/urllib — module-level calls, constructed " + "client/session methods, and client base_url=) in a trusted-tier function." ), examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trusted(level='ASSURED')\ndef f(p):\n requests.get(read_raw(p))", + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n" + " client = httpx.Client()\n client.get(read_raw(p))", + ), + examples_clean=( + "@trusted(level='ASSURED')\ndef f():\n requests.get('https://example.com')", + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n" + " requests.get('https://example.com', timeout=read_raw(p))", ), - examples_clean=("@trusted(level='ASSURED')\ndef f():\n requests.get('https://example.com')",), maturity=Maturity.PREVIEW, ) class SSRF(TaintedSinkRule): + # Attribute-only since the 2026-06-10 consolidation: the base check IS the + # binding-aware loop, and SINK_SPECS carries the URL-slot precision. rule_id = METADATA.rule_id metadata = METADATA SINKS = _SINKS + SINK_SPECS = _SINK_SPECS sink_label = "HTTP-client" diff --git a/src/wardline/scanner/rules/stored_taint.py b/src/wardline/scanner/rules/stored_taint.py index 2ddf50fe..1137f1b4 100644 --- a/src/wardline/scanner/rules/stored_taint.py +++ b/src/wardline/scanner/rules/stored_taint.py @@ -5,6 +5,26 @@ ``read_text`` or database cursor fetches) reaches a trusted state (returned by a ``@trusted`` function or passed to a ``@trusted`` callee) without being validated (e.g., through a ``@trust_boundary``). + +**Receiver-aware matching.** The storage-read matcher is binding-aware (the shared +``_sink_helpers`` class-tracking): an ``io.StringIO``/``io.BytesIO`` receiver is an +in-memory buffer whose ``.read()`` returns data the process itself put there — never +*persistent* storage — so it is exempt (wardline-66b2c91470: the receiver-blind +``.read()`` match mislabeled an in-memory constant as "stored/persisted data"). Any +taint flowing THROUGH such a buffer is still tracked by the engine (the buffer var +itself propagates via ``_collect_stored_vars``), and PY-WL-101 still polices the +producer's return claim. + +**PY-WL-101 de-confliction on the return arm (documented winner: 101).** A matched +return whose taint is ``UNKNOWN_RAW``/``MIXED_RAW`` has UNRESOLVED provenance — the +"stored/persisted" label rests solely on the AST name match, which cannot justify +it — so when PY-WL-101 fires on the same producer this rule SUPPRESSES its return +finding and delegates to 101 (the mature, gate-eligible trust-claim check; this rule +is PREVIEW). A return whose taint is ``EXTERNAL_RAW`` is SUBSTANTIATED storage +provenance (the open()/Path.read_*/fetch* seeds), and there the deliberate +complementary pair stands: 101 reports the trust-claim violation, 120 adds the +storage-provenance annotation (pinned by the frozen identity corpus and the wlfp1 +migration oracle). The call-argument arm is untouched — 101 never covers it. """ from __future__ import annotations @@ -14,24 +34,45 @@ from wardline.core.finding import Finding, Kind, Location, Maturity, Severity from wardline.core.finding import compute_finding_fingerprint as _fp -from wardline.core.taints import RAW_ZONE, TaintState +from wardline.core.taints import RAW_ZONE, TRUST_RANK, TaintState from wardline.scanner.rules._ast_helpers import own_nodes -from wardline.scanner.rules._sink_helpers import worst_arg_taint +from wardline.scanner.rules._sink_helpers import ( + SinkBindings, + collect_sink_bindings, + dotted_name, + module_alias_map, + resolve_bound_call_fqn, + worst_arg_taint, +) from wardline.scanner.rules.metadata import RuleMetadata from wardline.scanner.rules.severity_model import modulate if TYPE_CHECKING: + from collections.abc import Mapping + from wardline.scanner.context import AnalysisContext +_READ_ATTR_METHODS = frozenset({"read", "read_text", "read_bytes", "fetchone", "fetchall", "fetchmany"}) +# In-memory buffer classes: a ``.read()`` on one returns data the PROCESS itself put +# there — never persistent storage — so the storage-provenance label cannot apply. +# Canonical FQNs (the binding machinery resolves ``from io import StringIO`` / +# ``import io as x`` spellings through the module alias map before the lookup). +_IN_MEMORY_BUFFER_FQNS = frozenset({"io.StringIO", "io.BytesIO"}) + -def _is_storage_read_call(node: ast.AST) -> bool: +def _is_storage_read_call(node: ast.AST, bindings: SinkBindings, alias_map: Mapping[str, str]) -> bool: if isinstance(node, ast.Call): if isinstance(node.func, ast.Name): if node.func.id in ("open", "read"): return True elif isinstance(node.func, ast.Attribute): - if node.func.attr in ("read", "read_text", "read_bytes", "fetchone", "fetchall", "fetchmany"): - return True + if node.func.attr in _READ_ATTR_METHODS: + # Receiver-awareness (wardline-66b2c91470): a statically-known + # in-memory buffer receiver (bound var / with-target / chained + # ctor, via the shared binding machinery) is NOT a storage read. + # An unresolvable receiver stays conservatively matched. + bound = resolve_bound_call_fqn(node, bindings, alias_map) + return not (bound is not None and bound.rsplit(".", 1)[0] in _IN_MEMORY_BUFFER_FQNS) if ( isinstance(node.func.value, ast.Name) and node.func.value.id == "os" @@ -41,13 +82,13 @@ def _is_storage_read_call(node: ast.AST) -> bool: return False -def _collect_stored_vars(node: ast.AST) -> set[str]: +def _collect_stored_vars(node: ast.AST, bindings: SinkBindings, alias_map: Mapping[str, str]) -> set[str]: stored_vars: set[str] = set() for child in own_nodes(node): if isinstance(child, ast.Assign): is_storage = False for val_node in own_nodes(child.value): - if _is_storage_read_call(val_node): + if _is_storage_read_call(val_node, bindings, alias_map): is_storage = True break if not is_storage: @@ -66,7 +107,7 @@ def _collect_stored_vars(node: ast.AST) -> set[str]: elif isinstance(child, ast.AnnAssign) and child.value is not None: is_storage = False for val_node in own_nodes(child.value): - if _is_storage_read_call(val_node): + if _is_storage_read_call(val_node, bindings, alias_map): is_storage = True break if not is_storage: @@ -79,15 +120,47 @@ def _collect_stored_vars(node: ast.AST) -> set[str]: return stored_vars +def _return_delegated_to_101(qualname: str, context: AnalysisContext) -> bool: + """True when PY-WL-101 fires on *qualname*'s return (mirrors 101's gate). + + Deliberate coupling: this must stay in lockstep with + ``untrusted_reaches_trusted.UntrustedReachesTrusted.check`` — suppression is + only sound when the delegate actually picks the defect up (otherwise the + return finding must stand, e.g. a non-anchored tier 101 cannot police). + That includes ENABLEMENT: under ``rules.enable`` without PY-WL-101 the + delegate never runs, so suppressing here would drop the raw-storage-return + defect from the scan entirely (review 2026-06-10). ``None`` (a direct + construction / duck-typed registry seam) preserves the historical + assume-enabled behavior. + """ + if context.enabled_rule_ids is not None and "PY-WL-101" not in context.enabled_rule_ids: + return False + prov = context.taint_provenance.get(qualname) + if prov is None or prov.source != "anchored": + return False + declared = context.project_return_taints.get(qualname) + if declared is None or declared in RAW_ZONE: + return False # 101's trust-claim gate + body = context.project_taints.get(qualname) + if body is not None and TRUST_RANK[body] > TRUST_RANK[declared]: + return False # trust-raising shape — 101 delegates that to PY-WL-102 + actual = context.function_return_taints.get(qualname) + return actual is not None and TRUST_RANK[actual] > TRUST_RANK[declared] + + METADATA = RuleMetadata( rule_id="PY-WL-120", base_severity=Severity.ERROR, kind=Kind.DEFECT, + multi_emit=True, description="Stored/persisted taint reaches trusted state without validation.", examples_violation=( "@trusted(level='ASSURED')\ndef get_config():\n data = open('config.txt').read()\n return data", ), examples_clean=( + # validate must be a REAL @trust_boundary: an undefined bare name no longer + # launders to the caller's seed, so the example defines its own validator. + "@trust_boundary(to_level='ASSURED')\ndef validate(x):\n if not x:\n raise ValueError\n return x\n" "@trusted(level='ASSURED')\ndef get_config():\n data = validate(open('config.txt').read())\n return data", ), maturity=Maturity.PREVIEW, @@ -110,14 +183,16 @@ def check(self, context: AnalysisContext) -> list[Finding]: if severity == Severity.NONE: continue - stored_vars = _collect_stored_vars(entity.node) + alias_map = module_alias_map(qualname, context) + bindings = collect_sink_bindings(entity.node, alias_map) + stored_vars = _collect_stored_vars(entity.node, bindings, alias_map) if not stored_vars: # Check if there is a direct return of a storage read call has_direct_read = False for node in own_nodes(entity.node): if isinstance(node, ast.Return) and node.value is not None: for val_node in own_nodes(node.value): - if _is_storage_read_call(val_node): + if _is_storage_read_call(val_node, bindings, alias_map): has_direct_read = True break if not has_direct_read: @@ -127,20 +202,32 @@ def check(self, context: AnalysisContext) -> list[Finding]: for node in own_nodes(entity.node): if isinstance(node, ast.Return) and node.value is not None: is_stored_return = False - if _is_storage_read_call(node.value): + if _is_storage_read_call(node.value, bindings, alias_map): is_stored_return = True else: for val_node in own_nodes(node.value): if isinstance(val_node, ast.Name) and val_node.id in stored_vars: is_stored_return = True break - if _is_storage_read_call(val_node): + if _is_storage_read_call(val_node, bindings, alias_map): is_stored_return = True break if is_stored_return: # Check return taint ret_taint = context.function_return_taints.get(qualname) + if ( + ret_taint is not None + and ret_taint is not TaintState.EXTERNAL_RAW + and ret_taint in RAW_ZONE + and _return_delegated_to_101(qualname, context) + ): + # UNKNOWN/MIXED_RAW return: provenance UNRESOLVED, so the + # "stored/persisted" label is unsubstantiated — suppress and + # delegate the trust-claim violation to PY-WL-101 (101 wins; + # module docstring). EXTERNAL_RAW (a recognized storage seed) + # keeps the documented complementary 120+101 pair. + continue if ret_taint is not None and ret_taint in RAW_ZONE: findings.append( Finding( @@ -155,9 +242,13 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=node.lineno, qualname=qualname, - taint_path="stored->return", + # >1 return per function is possible. Discriminate ENTITY-RELATIVE + # (return line - def line, invariant to a comment ABOVE the function: + # wlfp2/wardline-8654423823) + the return's lexical span + a ``return`` + # token. The ``:return`` token keeps this DISJOINT from the call-arg + # site below (which ends in a callee name), so the two never collide. + taint_path=f"{node.lineno - (entity.location.line_start or 0)}:{node.col_offset}:{node.end_col_offset}:return", # noqa: E501 ), qualname=qualname, properties={"return_taint": ret_taint.value}, @@ -174,39 +265,63 @@ def check(self, context: AnalysisContext) -> list[Finding]: if isinstance(val_node, ast.Name) and val_node.id in stored_vars: has_stored_arg = True break - if _is_storage_read_call(val_node): + if _is_storage_read_call(val_node, bindings, alias_map): has_stored_arg = True break if has_stored_arg: - # Resolve callee FQN - callee_qn = context.call_site_callees.get(id(node)) - if callee_qn is not None: - callee_tier = context.project_taints.get(callee_qn) - # Only flag if callee is a trusted producer or boundary - if callee_tier is not None and callee_tier not in RAW_ZONE: - worst = worst_arg_taint(node, qualname, context) - if worst is not None and worst in RAW_ZONE: - findings.append( - Finding( + # Resolve callee FQN(s). For a branch-conditional receiver, consult + # the full candidate set so this fires on any trusted candidate + # regardless of AST order (shares wardline-499c22bbdd's root cause); + # otherwise the single call_site_callees entry. + candidate_qns = context.call_site_candidate_callees.get(id(node)) + if candidate_qns: + callee_qns: list[str] = sorted(candidate_qns) + else: + single = context.call_site_callees.get(id(node)) + callee_qns = [single] if single is not None else [] + # Keep only candidates that are trusted producers/boundaries; emit + # ONE finding per call site (not one per candidate) deterministically + # keyed on the first, so a branch-conditional receiver with several + # trusted candidates is one defect (wardline-499c22bbdd panel). + firing_qns = [ + qn + for qn in callee_qns + if (ct := context.project_taints.get(qn)) is not None and ct not in RAW_ZONE + ] + if firing_qns: + worst = worst_arg_taint(node, qualname, context) + if worst is not None and worst in RAW_ZONE: + callee_qn = firing_qns[0] + others = firing_qns[1:] + also = f" (branch-conditional; also reaches {', '.join(others)})" if others else "" + findings.append( + Finding( + rule_id=self.rule_id, + message=( + f"{qualname} passes stored/persisted data " + f"({worst.value}) to trusted callee {callee_qn} " + f"without validation at line {node.lineno}{also}" + ), + severity=severity, + kind=Kind.DEFECT, + location=Location(path=entity.location.path, line_start=node.lineno), + fingerprint=_fp( rule_id=self.rule_id, - message=( - f"{qualname} passes stored/persisted data " - f"({worst.value}) to trusted callee {callee_qn} " - f"without validation at line {node.lineno}" - ), - severity=severity, - kind=Kind.DEFECT, - location=Location(path=entity.location.path, line_start=node.lineno), - fingerprint=_fp( - rule_id=self.rule_id, - path=entity.location.path, - line_start=node.lineno, - qualname=qualname, - taint_path=f"stored->{callee_qn}", - ), + path=entity.location.path, qualname=qualname, - properties={"callee": callee_qn, "arg_taint": worst.value}, - ) + # Call-site-anchored, >1 finding per (rule, path, qualname) + # possible. Discriminate SOURCE-only: an ENTITY-RELATIVE line + # offset (call line - def line, invariant to a comment ABOVE the + # function: wlfp2/wardline-8654423823) + the call's full lexical + # SPAN + the callee spelling AS WRITTEN. Never the RESOLVED callee + # qualname (drifts). The span separates a chain's outer/inner calls. + taint_path=f"{node.lineno - (entity.location.line_start or 0)}:{node.col_offset}:{node.end_col_offset}:{dotted_name(node.func)}", # noqa: E501 + ), + # OLD (wlfp1) taint_path, byte-exact, for `wardline rekey` (P4). + taint_path_v0=f"{dotted_name(node.func)}@{node.col_offset}:{node.end_col_offset}", + qualname=qualname, + properties={"callee": callee_qn, "arg_taint": worst.value}, ) + ) return findings diff --git a/src/wardline/scanner/rules/untrusted_reaches_trusted.py b/src/wardline/scanner/rules/untrusted_reaches_trusted.py index add9eeb8..1478c07c 100644 --- a/src/wardline/scanner/rules/untrusted_reaches_trusted.py +++ b/src/wardline/scanner/rules/untrusted_reaches_trusted.py @@ -9,15 +9,16 @@ is what makes the strict rank comparison safe. Declaration-gated, so it emits at base severity (NOT tier-modulated). -**Trust-boundary delegation.** A trust-RAISING transition — a function whose body -taint is strictly less-trusted than its declared return (the taint shape unique -to ``@trust_boundary``) — is EXEMPT from this rule and delegated to PY-WL-102. +**Trust-boundary delegation.** A declared trust-RAISING transition — a function +whose provider-declared body taint is strictly less-trusted than its declared +return (the taint shape unique to ``@trust_boundary``) — is EXEMPT from this rule +and delegated to PY-WL-102. Reason: such a validator's parameters seed at the raw body taint, and the engine does not narrow taint after a ``raise`` guard, so the L2 actual return is always the raw body taint — meaning *every* ``@trust_boundary`` (correct or not) would fire here. That is noise, not signal: the statically-decidable property for a validator is "can it reject at all", which is exactly PY-WL-102's check. So -PY-WL-101 polices ``@trusted`` producers (body == declared) only. +PY-WL-101 polices ``@trusted`` producers (declared body == declared return) only. """ from __future__ import annotations @@ -87,20 +88,22 @@ def check(self, context: AnalysisContext) -> list[Finding]: prov = context.taint_provenance.get(qualname) if prov is None or prov.source != "anchored": continue - body = context.project_taints.get(qualname) declared = context.project_return_taints.get(qualname) if declared is None or declared in RAW_ZONE: continue # trust-claim gate - # Trust-RAISING transition (body less trusted than return) is - # @trust_boundary's shape — covered by PY-WL-102, not PY-WL-101. - if body is not None and TRUST_RANK[body] > TRUST_RANK[declared]: + declared_body = context.declared_body_taints.get(qualname) + # Declared trust-RAISING transition (body less trusted than return) is + # @trust_boundary's shape — covered by PY-WL-102, not PY-WL-101. Do + # not read context.project_taints here: L3 propagation can make a + # leaky @trusted producer's body raw, but that is evidence for 101, + # not a declaration that turns the producer into a validator. + if declared_body is not None and TRUST_RANK[declared_body] > TRUST_RANK[declared]: continue actual = context.function_return_taints.get(qualname) if actual is None: continue # no value-bearing return -> nothing to police if TRUST_RANK[actual] <= TRUST_RANK[declared]: continue # returns data at-least-as-trusted as declared - taint_path = f"{actual.value}->{declared.value}|{prov.via_callee or ''}" findings.append( Finding( rule_id=self.rule_id, @@ -115,9 +118,12 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, - taint_path=taint_path, + # Join-key stability (weft-4a9d0f863c): one finding per anchored qualname, + # so (rule, path, line, qualname) is already unique. actual/declared tiers and + # via_callee are resolved values that drift across builds for identical source + # (the reported bug) — they live in message/properties, never the join key. + taint_path=None, ), qualname=qualname, properties={"declared_return": declared.value, "actual_return": actual.value}, diff --git a/src/wardline/scanner/rules/untrusted_to_command.py b/src/wardline/scanner/rules/untrusted_to_command.py index 214e0a39..8de8e949 100644 --- a/src/wardline/scanner/rules/untrusted_to_command.py +++ b/src/wardline/scanner/rules/untrusted_to_command.py @@ -1,25 +1,68 @@ # src/wardline/scanner/rules/untrusted_to_command.py -"""PY-WL-108 — untrusted data reaches an OS-command sink. +"""PY-WL-108 — untrusted data reaches a command/program-execution sink. + +**Charter (expanded 2026-06-10, wardline-13cfdd7b31 / wardline-c83b40c73a):** the +rule covers two CWE-78 sink shapes, both stdlib: + +* **always-shell string APIs** — ``os.system``, ``os.popen``, + ``subprocess.getoutput`` / ``getstatusoutput``: these take a shell *string*, + so an untrusted argument is directly injectable; +* **argv-style program execution** — ``os.exec*`` / ``os.spawn*`` / + ``os.posix_spawn`` / ``os.posix_spawnp`` / ``pty.spawn``: no shell mediates, + but an attacker-controlled program path or argv element IS arbitrary-program + execution — neither always-shell nor ``shell=True``, so previously covered by + neither 108 nor 112. -Passing untrusted data to an **always-shell** OS-command API — ``os.system``, -``os.popen``, ``subprocess.getoutput`` / ``getstatusoutput`` — is OS command injection -(CWE-78): these take a shell *string*, so an untrusted argument is directly injectable. Tier-modulated; fires only where trust is declared. -**Scope (FP-safe):** the ``subprocess.run`` / ``call`` / ``Popen`` / ``check_*`` family -is intentionally NOT in the sink set — with the default ``shell=False`` an argv-LIST is -safe (no shell), so firing on them floods false positives; only ``shell=True`` makes them -injectable, and detecting that keyword reliably is policed separately by PY-WL-112. -Covering the always-shell APIs catches the unambiguous case without the argv-list FP. +**Scope (FP-safe):** the ``subprocess.run`` / ``call`` / ``Popen`` / ``check_*`` +family is intentionally NOT in the sink set — with the default ``shell=False`` an +argv-LIST is safe (no shell), so firing on them floods false positives; only +``shell=True`` makes them injectable, and detecting that keyword reliably is +policed separately by PY-WL-112. + +**shlex.quote semantics (decided, wardline-13cfdd7b31):** ``shlex.quote(x)`` +neutralizes shell-string taint for the ALWAYS-SHELL sinks **in concatenation +context only**. The command argument is GUARDED when it is a string +concatenation (``+`` chain or f-string) with at least one constant fragment in +which every non-constant leaf is a ``shlex.quote(...)`` call — the attacker +bytes then enter the shell line solely as a single quoted token of a +constant-shaped command (``os.system("echo " + shlex.quote(raw))`` is clean). +A BARE whole-command quote (``os.system(shlex.quote(raw))``) still fires: a +fully-quoted single token handed to a shell executes that token as the program +name, so the attacker still picks what runs. The guard NEVER applies to the +argv program-execution sinks — no shell parses the value, so quoting protects +nothing there. ``%``-formatting and ``str.format`` are not recognized as +concatenation (bounded: they keep firing). + +The guard is INLINE-syntactic only: a quote result routed through a variable +(``safe = shlex.quote(raw); os.system("echo " + safe)``) still fires. That is +deliberate — resolving the name through the (non-branch-aware, +last-binding-wins) binding collector would let a LATER ``x = shlex.quote(x)`` +launder an EARLIER raw use of ``x``; for a sink MATCH that over-approximation +is silent, but for a NEUTRALIZER it would be a false-negative hole. Clearing +the variable-mediated form soundly needs a flow-sensitive context-encoder +taint (engine-level), not a rule-side syntax check. """ from __future__ import annotations +import ast +from typing import TYPE_CHECKING + from wardline.core.finding import Kind, Severity -from wardline.scanner.rules._sink_helpers import TaintedSinkRule +from wardline.scanner.rules._sink_helpers import ( + TaintedSinkRule, + canonical_call_name, + dotted_name, +) from wardline.scanner.rules.metadata import RuleMetadata -_SINKS = frozenset( +if TYPE_CHECKING: + from collections.abc import Mapping + +# The always-shell string APIs — the only sinks the shlex.quote guard applies to. +_SHELL_STRING_SINKS = frozenset( { "os.system", "os.popen", @@ -28,16 +71,114 @@ } ) +# Argv-style program execution: the value is a program path / argv, not a shell +# string. shlex.quote does NOT protect these (no shell parses the value). +_PROGRAM_EXEC_SINKS = frozenset( + { + "os.execl", + "os.execle", + "os.execlp", + "os.execlpe", + "os.execv", + "os.execve", + "os.execvp", + "os.execvpe", + "os.spawnl", + "os.spawnle", + "os.spawnlp", + "os.spawnlpe", + "os.spawnv", + "os.spawnve", + "os.spawnvp", + "os.spawnvpe", + "os.posix_spawn", + "os.posix_spawnp", + "pty.spawn", + } +) + +_SINKS = _SHELL_STRING_SINKS | _PROGRAM_EXEC_SINKS + + +def _is_shlex_quote_call(expr: ast.expr, alias_map: Mapping[str, str]) -> bool: + """True iff *expr* is a call whose func canonicalizes to ``shlex.quote`` + (covers ``shlex.quote(x)``, ``from shlex import quote``, module aliases).""" + if not isinstance(expr, ast.Call): + return False + dotted = dotted_name(expr.func) + return dotted is not None and canonical_call_name(dotted, dict(alias_map)) == "shlex.quote" + + +def _quote_guarded_concat(expr: ast.expr, alias_map: Mapping[str, str]) -> bool: + """True iff *expr* is a string CONCATENATION (``+`` chain / f-string) with at + least one constant fragment whose every non-constant leaf is + ``shlex.quote(...)`` — the GUARDED shape for a shell-string command. + + The constant-fragment requirement is what excludes the bare whole-command + quote: ``shlex.quote(raw)`` alone is not a concatenation, and ``f"{...}"`` + of nothing but quote calls has no constant command around the token. + """ + leaves: list[ast.expr] = [] + + def flatten(e: ast.expr) -> None: + if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Add): + flatten(e.left) + flatten(e.right) + elif isinstance(e, ast.JoinedStr): + for part in e.values: + if isinstance(part, ast.FormattedValue): + leaves.append(part.value) + else: + leaves.append(part) # the f-string's constant fragments + else: + leaves.append(e) + + flatten(expr) + has_const = any(isinstance(leaf, ast.Constant) for leaf in leaves) + has_quote = any(_is_shlex_quote_call(leaf, alias_map) for leaf in leaves) + if not (has_const and has_quote): + return False + if not all(isinstance(leaf, ast.Constant) or _is_shlex_quote_call(leaf, alias_map) for leaf in leaves): + return False + # The command WORD (the executable) must be constant. shlex.quote sanitizes shell + # metacharacters in an ARGUMENT; it does not constrain the identity of the program + # run. So `shlex.quote(raw) + ' --version'` is NOT guarded — the attacker still + # chooses the executable — whereas `'echo ' + shlex.quote(raw)` is. Require the + # first non-whitespace text to come from a string constant (the leading command + # word), not from a quoted leaf. Leading whitespace-only constants are skipped so + # `' ' + 'echo ' + shlex.quote(raw)` stays guarded without a false positive. + for leaf in leaves: + if isinstance(leaf, ast.Constant) and isinstance(leaf.value, str): + if not leaf.value.strip(): + continue # whitespace-only fragment: the command word starts later + return True # command word begins in a constant — genuinely guarded + return False # first non-whitespace contribution is a quote/non-str — unsanitized command + return False # only whitespace constants: no constant command word + + METADATA = RuleMetadata( rule_id="PY-WL-108", - base_severity=Severity.WARN, + # Calibrated with PY-WL-118 (SQLi): tainted command/program execution is the + # same exploit class (CWE-78 ≅ CWE-89 in blast radius), so the same base. + base_severity=Severity.ERROR, kind=Kind.DEFECT, - description="Untrusted data reaches an always-shell OS-command sink (os.system/os.popen/subprocess.getoutput).", + multi_emit=True, + description=( + "Untrusted data reaches a command/program-execution sink " + "(os.system/os.popen/subprocess.getoutput, os.exec*/os.spawn*/os.posix_spawn/pty.spawn)." + ), examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trusted(level='ASSURED')\ndef f(p):\n os.system(read_raw(p))", + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n os.execv(read_raw(p), ['prog'])", + ), + examples_clean=( + "@trusted(level='ASSURED')\ndef f():\n os.system('ls -la')", + # shlex.quote as a FRAGMENT of a constant command is the blessed remediation. + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n os.system('echo ' + shlex.quote(read_raw(p)))", ), - examples_clean=("@trusted(level='ASSURED')\ndef f():\n os.system('ls -la')",), ) @@ -46,3 +187,8 @@ class UntrustedToCommand(TaintedSinkRule): metadata = METADATA SINKS = _SINKS sink_label = "OS-command" + + def _arg_guarded(self, expr: ast.expr, fqn: str, alias_map: Mapping[str, str]) -> bool: # noqa: PLR6301 + # shlex.quote guards shell-STRING sinks only (see module docstring); a + # quoted value reaching an argv exec/spawn sink is still attacker-chosen. + return fqn in _SHELL_STRING_SINKS and _quote_guarded_concat(expr, alias_map) diff --git a/src/wardline/scanner/rules/untrusted_to_deserialization.py b/src/wardline/scanner/rules/untrusted_to_deserialization.py index 648afef9..63cc4e5b 100644 --- a/src/wardline/scanner/rules/untrusted_to_deserialization.py +++ b/src/wardline/scanner/rules/untrusted_to_deserialization.py @@ -6,47 +6,166 @@ developer-freedom zone, fires where trust is declared. The ``safe_*`` loaders and the *dump* direction are intentionally NOT sinks here. ``json.loads`` is excluded (it does not execute) to avoid noise. + +Sink families (ticket wardline-4299f07bb4): + +* **Stdlib direct loaders** — pickle/marshal/yaml ``load``/``loads`` spellings. These + keep the historical worst-of-ALL-args taint test (no :class:`ArgSpec`). +* **OO streaming-unpickle API** — ``pickle.Unpickler(stream).load()``, chained or + stored-instance (``u = pickle.Unpickler(stream); u.load()``), resolved through the + shared sink-binding machinery. ``load()`` itself takes no arguments; the dangerous + data is the stream handed to the CONSTRUCTOR, so the taint is read from the + constructor call's first argument. An annotation-only binding (``u: + pickle.Unpickler`` with no constructor in scope) has no stream argument to read — + a documented bounded false negative. ``marshal`` has no reader-object API, so there + is no marshal analogue. +* **``shelve.open``** — pickle-backed: opening a shelf at an attacker-controlled PATH + then reading keys unpickles attacker bytes. The taint shape differs from the blob + loaders — it is on the path argument only (``positions=(0,)`` / + ``keywords=("filename",)``), so a tainted flag/protocol slot does not fire. +* **Curated third-party CWE-502 table** — ``dill.load``/``loads``, + ``jsonpickle.decode``, ``joblib.load``, ``torch.load``, ``numpy.load``. Matching is + by canonical dotted name at the AST level (through the module's import-alias map); + the analyzer never imports these packages. Two literal-keyword gates, same + statically-visible-literal discipline as PY-WL-112's ``shell=True``: + ``numpy.load`` fires ONLY with a literal ``allow_pickle=True`` (the default is + False — safe — since numpy 1.16.3, so absent/False/dynamic stays silent); + ``torch.load`` is suppressed by a literal ``weights_only=True`` (the restricted + unpickler — the modern safe spelling) and fires otherwise, because older torch + defaults to the full unpickler. + +**Severity decision:** every entry here is RCE-equivalent (arbitrary object-graph +execution), so all carry the rule family's base severity (WARN, tier-modulated) — +exactly the standing of ``pickle.loads``. No per-sink severity split. + +**Why WARN and not the 118/108/112/124 ERROR class — a deliberate FP-economics +call (severity lattice review 2026-06-10).** The ERROR family members buy their +base with strong per-finding evidence (slot-precise ArgSpecs, literal-keyword +gates, an SQL-string position test); the classic blob loaders here keep the +worst-of-all-args test, and deserializing data from internal stores/caches the +engine cannot vouch for is a pervasive legitimate idiom — exploitability hinges +on the SOURCE being attacker-reachable, which a static worst-arg test cannot +establish. One class lower balances that weaker evidence. Promote via +``rules.severity`` per project, or revisit alongside the frozen identity corpus. """ from __future__ import annotations +import ast +from typing import TYPE_CHECKING + from wardline.core.finding import Kind, Severity -from wardline.scanner.rules._sink_helpers import TaintedSinkRule +from wardline.scanner.rules._sink_helpers import ( + ArgSpec, + TaintedSinkRule, + collect_ctor_call_nodes, + receiver_ctor_call, +) from wardline.scanner.rules.metadata import RuleMetadata -_SINKS = frozenset( - { - "pickle.loads", - "pickle.load", - "marshal.loads", - "marshal.load", - "yaml.load", - "yaml.load_all", - "yaml.unsafe_load", - "yaml.full_load", - } -) +if TYPE_CHECKING: + from collections.abc import Mapping + +# Direct-call sinks: the taint test runs over the SINK CALL's own arguments. +# ``None`` keeps the historical worst-of-all-args behavior for the original stdlib +# loaders; an ArgSpec narrows the test to the dangerous slot(s) for sinks whose +# non-data slots (flags, mmap modes, map_location) are taint-irrelevant. +_SINK_SPECS: dict[str, ArgSpec | None] = { + "pickle.loads": None, + "pickle.load": None, + "marshal.loads": None, + "marshal.load": None, + "yaml.load": None, + "yaml.load_all": None, + "yaml.unsafe_load": None, + "yaml.full_load": None, + "shelve.open": ArgSpec(positions=(0,), keywords=("filename",)), + "dill.load": ArgSpec(positions=(0,), keywords=("file",)), + "dill.loads": ArgSpec(positions=(0,), keywords=("str",)), + "jsonpickle.decode": ArgSpec(positions=(0,), keywords=("string",)), + "joblib.load": ArgSpec(positions=(0,), keywords=("filename",)), + "torch.load": ArgSpec(positions=(0,), keywords=("f",)), + "numpy.load": ArgSpec(positions=(0,), keywords=("file",)), +} + +# Stream-reader sinks: the sink is a no-arg METHOD on a reader object; the taint test +# runs over the CONSTRUCTOR call's stream argument (the ArgSpec addresses the ctor). +_READER_CTORS = frozenset({"pickle.Unpickler"}) +_READER_SINK_SPECS: dict[str, ArgSpec] = { + "pickle.Unpickler.load": ArgSpec(positions=(0,), keywords=("file",)), +} + +_SINKS = frozenset(_SINK_SPECS) | frozenset(_READER_SINK_SPECS) + + +def _has_literal_true_kw(call: ast.Call, name: str) -> bool: + """True iff *call* passes ``=True`` as a literal keyword. ``**kwargs``, + a non-constant value, or any constant other than ``True`` is not matched — + only the unambiguous, statically-visible case (the PY-WL-112 discipline).""" + return any(kw.arg == name and isinstance(kw.value, ast.Constant) and kw.value.value is True for kw in call.keywords) + METADATA = RuleMetadata( rule_id="PY-WL-106", base_severity=Severity.WARN, kind=Kind.DEFECT, - description="Untrusted data reaches a deserialization sink (pickle/marshal/yaml.load) in a trusted-tier function.", + multi_emit=True, + description=( + "Untrusted data reaches a deserialization sink (pickle/Unpickler/marshal/yaml.load/shelve " + "+ curated third-party: dill/jsonpickle/joblib/torch.load/numpy.load(allow_pickle=True)) " + "in a trusted-tier function." + ), examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trusted(level='ASSURED')\ndef f(p):\n pickle.loads(read_raw(p))", + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n return pickle.Unpickler(read_raw(p)).load()", ), examples_clean=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trust_boundary(to_level='ASSURED')\ndef validate(x):\n if not x:\n raise ValueError\n return x\n" "@trusted(level='ASSURED')\ndef f(p):\n blob = validate(read_raw(p))\n" " obj = pickle.loads(blob)\n return blob", + # numpy.load without allow_pickle=True is safe by default (no object unpickling). + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n return numpy.load(read_raw(p))", ), ) class UntrustedToDeserialization(TaintedSinkRule): + """Attribute-only since the 2026-06-10 consolidation, plus two hooks: + + * :meth:`_accept_call` — the numpy/torch literal-keyword gates; + * :meth:`_taint_anchor_call` — a reader-method sink (``Unpickler.load``, + argument-less) reads its taint from the CONSTRUCTOR call's stream + argument (the merged ``SINK_SPECS`` entry addresses the ctor's slots). + """ + rule_id = METADATA.rule_id metadata = METADATA SINKS = _SINKS + SINK_SPECS: Mapping[str, ArgSpec | None] = {**_SINK_SPECS, **_READER_SINK_SPECS} sink_label = "deserialization" + + def _accept_call(self, call: ast.Call, fqn: str) -> bool: # noqa: PLR6301 + if fqn == "numpy.load" and not _has_literal_true_kw(call, "allow_pickle"): + return False # safe-by-default: only a literal allow_pickle=True unpickles + return not (fqn == "torch.load" and _has_literal_true_kw(call, "weights_only")) + # weights_only=True → restricted unpickler, the modern safe spelling + + def _taint_anchor_call( # noqa: PLR6301 + self, + call: ast.Call, + fqn: str, + entity_node: ast.AST, + alias_map: Mapping[str, str], + ) -> ast.Call | None: + if fqn not in _READER_SINK_SPECS: + return call + # Reader-method sink: the dangerous data is the stream handed to the + # CONSTRUCTOR. Resolve the chained ``pickle.Unpickler(s).load()`` receiver + # or the bound var's recorded constructor; ``None`` (an annotation-only + # binding with no constructor in scope) is a documented bounded FN. + ctors = collect_ctor_call_nodes(entity_node, alias_map, ctor_fqns=_READER_CTORS) + return receiver_ctor_call(call, ctors) diff --git a/src/wardline/scanner/rules/untrusted_to_exec.py b/src/wardline/scanner/rules/untrusted_to_exec.py index a49a2b13..7180b253 100644 --- a/src/wardline/scanner/rules/untrusted_to_exec.py +++ b/src/wardline/scanner/rules/untrusted_to_exec.py @@ -3,7 +3,23 @@ ``eval`` / ``exec`` / ``compile`` on untrusted input is arbitrary code execution (CWE-95). Tier-modulated; fires only where trust is declared. Matches the bare -builtins (``eval(x)``) as well as ``builtins.eval`` forms. +builtins (``eval(x)``), the ``builtins.eval`` forms, and the ``__builtins__.eval`` +spelling. The ``__builtins__`` form is real but narrow: in ``__main__`` +``__builtins__`` is the builtins MODULE (so ``.eval`` executes), while in an +imported module it is a plain dict (attribute access fails) — it is matched +because where it does run, it is full arbitrary-code-execution +(wardline-c83b40c73a). + +**Severity: WARN — a deliberate FP-economics call, not an oversight (severity +lattice review 2026-06-10).** Blast-radius alone would argue ERROR alongside +108/112/118/124, but those rules buy their ERROR with strong per-finding +evidence: a slot-precise ArgSpec, a literal ``shell=True``, or an SQL-string +position gate. This rule's sinks take ONE polymorphic payload argument tested +worst-of-all-args, ``compile`` is pervasive in legitimate metaprogramming, and +``eval``/``exec`` payloads are routinely pre-validated in ways the engine cannot +prove — so its evidence per finding is one class weaker, and its base sits one +class lower. Promote via ``rules.severity`` per project, or revisit alongside +the frozen identity corpus when the rule gains slot/shape evidence. """ from __future__ import annotations @@ -12,12 +28,25 @@ from wardline.scanner.rules._sink_helpers import TaintedSinkRule from wardline.scanner.rules.metadata import RuleMetadata -_SINKS = frozenset({"eval", "exec", "compile", "builtins.eval", "builtins.exec", "builtins.compile"}) +_SINKS = frozenset( + { + "eval", + "exec", + "compile", + "builtins.eval", + "builtins.exec", + "builtins.compile", + "__builtins__.eval", + "__builtins__.exec", + "__builtins__.compile", + } +) METADATA = RuleMetadata( rule_id="PY-WL-107", base_severity=Severity.WARN, kind=Kind.DEFECT, + multi_emit=True, description="Untrusted data reaches a dynamic-code-execution sink (eval/exec/compile) in a trusted-tier function.", examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" diff --git a/src/wardline/scanner/rules/untrusted_to_import.py b/src/wardline/scanner/rules/untrusted_to_import.py index c36cd0a7..68da46db 100644 --- a/src/wardline/scanner/rules/untrusted_to_import.py +++ b/src/wardline/scanner/rules/untrusted_to_import.py @@ -1,8 +1,14 @@ # src/wardline/scanner/rules/untrusted_to_import.py -"""PY-WL-115 — untrusted data reaches a dynamic import sink in a trusted-tier function. +"""PY-WL-115 — untrusted data reaches a dynamic code/module-load sink in a trusted-tier function. -Fires when raw-zone data reaches a dynamic module load sink (importlib.import_module -or __import__) inside a trusted-tier function. +Fires when raw-zone data reaches a dynamic module-load or file-execution sink inside a +trusted-tier function. The sink family covers the import-and-execute class (CWE-829 / +CWE-94): ``importlib.import_module`` and ``__import__`` (attacker-chosen module name), +``runpy.run_path`` / ``runpy.run_module`` (import-and-EXECUTE an attacker-controlled +file path / module — blast radius equivalent to ``exec``), and +``importlib.util.spec_from_file_location`` (a tainted file-path arg builds a loader for +attacker-chosen code). Expanded from the original two-sink charter +(importlib.import_module / __import__) per wardline-c83b40c73a. """ from __future__ import annotations @@ -11,19 +17,31 @@ from wardline.scanner.rules._sink_helpers import TaintedSinkRule from wardline.scanner.rules.metadata import RuleMetadata -_SINKS = frozenset({"importlib.import_module", "__import__"}) +_SINKS = frozenset( + { + "importlib.import_module", + "__import__", + "runpy.run_path", + "runpy.run_module", + "importlib.util.spec_from_file_location", + } +) METADATA = RuleMetadata( rule_id="PY-WL-115", base_severity=Severity.WARN, kind=Kind.DEFECT, + multi_emit=True, description=( - "Untrusted data reaches a dynamic import sink (importlib.import_module / " - "__import__) in a trusted-tier function." + "Untrusted data reaches a dynamic code/module-load sink (importlib.import_module / " + "__import__ / runpy.run_path / runpy.run_module / " + "importlib.util.spec_from_file_location) in a trusted-tier function." ), examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" "@trusted(level='ASSURED')\ndef f(p):\n importlib.import_module(read_raw(p))", + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n runpy.run_path(read_raw(p))", ), examples_clean=("@trusted(level='ASSURED')\ndef f(p):\n importlib.import_module('sys')",), ) diff --git a/src/wardline/scanner/rules/untrusted_to_log.py b/src/wardline/scanner/rules/untrusted_to_log.py new file mode 100644 index 00000000..f9825b91 --- /dev/null +++ b/src/wardline/scanner/rules/untrusted_to_log.py @@ -0,0 +1,74 @@ +# src/wardline/scanner/rules/untrusted_to_log.py +"""PY-WL-125 — untrusted data as the log MESSAGE format string (CWE-117). + +Charter: log injection / log forging — a tainted value used as the message +FORMAT string of ``logging.debug/info/warning/error/critical/exception`` (the +module-level functions or the Logger-method form via the construct-then-method +machinery: ``logger = logging.getLogger(...); logger.info(raw)``) inside a +trusted-tier function. Newline-spoofed entries forge audit lines and seed +log-viewer XSS downstream. + +Only the message slot (position 0 / ``msg``) is dangerous. Tainted data in the +lazy ``%``-args parameters (``logging.info('user=%s', raw)``) is logging's OWN +parameterization — the canonical safe idiom — and must NOT fire; flagging it +would be an FP factory. ``logging.log(level, msg)`` is deliberately out of +scope for v1 (its message sits at position 1; charter names the fixed-level +methods only). + +Severity calibration: INFO (below the task ceiling of WARN). CWE-117 is a +recognised weakness class but is high-noise by nature — almost every service +logs request-derived data somewhere — and its blast radius is forgery/foothold, +not execution. INFO keeps the finding visible to agents (and to an explicit +``--fail-on INFO`` gate) without ever tripping the default gate; peer tools +(bandit, CodeQL) rate the class LOW for the same reason. +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule +from wardline.scanner.rules.metadata import RuleMetadata + +_METHODS = ("debug", "info", "warning", "error", "critical", "exception") +_MSG_SPEC = ArgSpec(positions=(0,), keywords=("msg",)) + +# Module-level functions + the Logger-method form. The binding machinery records +# a constructor FQN without verifying it names a class, so ``logging.getLogger.info`` +# is the canonical name a ``logger = logging.getLogger(...)`` method call resolves +# to; ``logging.Logger.info`` covers the ``log: logging.Logger`` annotation form. +_SINK_SPECS: dict[str, ArgSpec | None] = { + f"{prefix}.{method}": _MSG_SPEC + for prefix in ("logging", "logging.getLogger", "logging.Logger") + for method in _METHODS +} + +METADATA = RuleMetadata( + rule_id="PY-WL-125", + base_severity=Severity.INFO, + kind=Kind.DEFECT, + multi_emit=True, + description=( + "Untrusted data is used as the log MESSAGE format string " + "(logging.* / Logger methods) in a trusted-tier function (log injection)." + ), + examples_violation=( + "import logging\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n logging.info(read_raw(p))\n return 1", + ), + examples_clean=( + # Lazy %-parameterization is logging's own safe idiom — never a finding. + "import logging\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n logging.info('user input = %s', read_raw(p))\n return 1", + ), + maturity=Maturity.PREVIEW, +) + + +class UntrustedToLog(TaintedSinkRule): + rule_id = METADATA.rule_id + metadata = METADATA + SINKS = frozenset(_SINK_SPECS) + SINK_SPECS = _SINK_SPECS + sink_label = "log-message" diff --git a/src/wardline/scanner/rules/untrusted_to_mail.py b/src/wardline/scanner/rules/untrusted_to_mail.py new file mode 100644 index 00000000..926ec4b4 --- /dev/null +++ b/src/wardline/scanner/rules/untrusted_to_mail.py @@ -0,0 +1,70 @@ +# src/wardline/scanner/rules/untrusted_to_mail.py +"""PY-WL-126 — untrusted recipient/message reaches SMTP.sendmail (CWE-93). + +Charter: mail (CRLF/header) injection — tainted data in the ``to_addrs`` +(position 1) or ``msg`` (position 2) argument of ``smtplib.SMTP.sendmail`` / +``smtplib.SMTP_SSL.sendmail`` inside a trusted-tier function. The receiver is +matched through the construct-then-method machinery (``s = smtplib.SMTP(h)``, +``with smtplib.SMTP(h) as s``, ``s: smtplib.SMTP``, the chained form). +Newlines in a recipient or message inject spoofed headers / BCC recipients. + +Calibration: ``from_addr`` (position 0) is deliberately NOT a dangerous slot in +v1 — the recipient set and message body are the canonical CWE-93 injection +surfaces the gap report names; widening to the envelope sender can ride a later +calibration pass. ``send_message`` is out of scope (it takes an +``email.message.Message``, whose header serialization already rejects bare +newlines on supported Pythons). + +Severity: WARN. Real injection, but bounded blast radius (spam/spoofing, not +code execution) and gated on a constructed SMTP client — the mass-assignment +class, not the RCE class. +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule +from wardline.scanner.rules.metadata import RuleMetadata + +# sendmail(from_addr, to_addrs, msg, ...) — recipient + message are the slots. +_SENDMAIL_SPEC = ArgSpec(positions=(1, 2), keywords=("to_addrs", "msg")) + +_SINK_SPECS: dict[str, ArgSpec | None] = { + "smtplib.SMTP.sendmail": _SENDMAIL_SPEC, + "smtplib.SMTP_SSL.sendmail": _SENDMAIL_SPEC, +} + +METADATA = RuleMetadata( + rule_id="PY-WL-126", + base_severity=Severity.WARN, + kind=Kind.DEFECT, + multi_emit=True, + description=( + "Untrusted data reaches the recipient/message of smtplib SMTP.sendmail " + "in a trusted-tier function (mail/header injection)." + ), + examples_violation=( + "import smtplib\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n" + " s = smtplib.SMTP('localhost')\n" + " s.sendmail('from@example.com', 'to@example.com', read_raw(p))\n" + " return 1", + ), + examples_clean=( + "import smtplib\n" + "@trusted(level='ASSURED')\ndef f():\n" + " s = smtplib.SMTP('localhost')\n" + " s.sendmail('from@example.com', 'to@example.com', 'body')\n" + " return 1", + ), + maturity=Maturity.PREVIEW, +) + + +class UntrustedToMail(TaintedSinkRule): + rule_id = METADATA.rule_id + metadata = METADATA + SINKS = frozenset(_SINK_SPECS) + SINK_SPECS = _SINK_SPECS + sink_label = "SMTP-send" diff --git a/src/wardline/scanner/rules/untrusted_to_native.py b/src/wardline/scanner/rules/untrusted_to_native.py new file mode 100644 index 00000000..1746c42d --- /dev/null +++ b/src/wardline/scanner/rules/untrusted_to_native.py @@ -0,0 +1,63 @@ +# src/wardline/scanner/rules/untrusted_to_native.py +"""PY-WL-124 — untrusted path reaches a native-library load sink (CWE-114). + +Charter: a tainted library path/name reaching ``ctypes.CDLL`` / +``ctypes.WinDLL`` / ``ctypes.OleDLL`` / ``ctypes.PyDLL`` or +``ctypes.cdll.LoadLibrary`` inside a trusted-tier function. Loading an +attacker-controlled shared object is arbitrary NATIVE code execution +(CWE-114 process control / CWE-829 untrusted functionality). Tier-modulated; +fires only where trust is declared. + +Severity: ERROR. Same blast radius as the command/program-execution family +(PY-WL-108) and SQLi (PY-WL-118) — full process compromise with no further +preconditions — so the same ERROR base. + +Only the library NAME/PATH slot is dangerous (``ArgSpec`` slot precision, review +2026-06-10): a tainted ``mode=`` / ``use_errno=`` flag with a constant library +name is not a native-load injection and must not fire. +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule +from wardline.scanner.rules.metadata import RuleMetadata + +# The library path/name is the first positional / the ``name`` keyword. +_NAME_SPEC = ArgSpec(positions=(0,), keywords=("name",)) + +_SINK_SPECS: dict[str, ArgSpec | None] = { + "ctypes.CDLL": _NAME_SPEC, + "ctypes.WinDLL": _NAME_SPEC, + "ctypes.OleDLL": _NAME_SPEC, + "ctypes.PyDLL": _NAME_SPEC, + "ctypes.cdll.LoadLibrary": ArgSpec(positions=(0,)), +} + +_SINKS = frozenset(_SINK_SPECS) + +METADATA = RuleMetadata( + rule_id="PY-WL-124", + base_severity=Severity.ERROR, + kind=Kind.DEFECT, + multi_emit=True, + description=( + "Untrusted data reaches a native-library load sink (ctypes.CDLL/WinDLL/OleDLL/PyDLL, " + "ctypes.cdll.LoadLibrary) in a trusted-tier function." + ), + examples_violation=( + "import ctypes\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n return ctypes.CDLL(read_raw(p))", + ), + examples_clean=("import ctypes\n@trusted(level='ASSURED')\ndef f():\n ctypes.CDLL('libm.so.6')",), + maturity=Maturity.PREVIEW, +) + + +class UntrustedToNative(TaintedSinkRule): + rule_id = METADATA.rule_id + metadata = METADATA + SINKS = _SINKS + SINK_SPECS = _SINK_SPECS + sink_label = "native-library-load" diff --git a/src/wardline/scanner/rules/untrusted_to_reflection.py b/src/wardline/scanner/rules/untrusted_to_reflection.py new file mode 100644 index 00000000..28821a0b --- /dev/null +++ b/src/wardline/scanner/rules/untrusted_to_reflection.py @@ -0,0 +1,66 @@ +# src/wardline/scanner/rules/untrusted_to_reflection.py +"""PY-WL-123 — tainted attribute NAME reaches setattr/getattr (CWE-915). + +Charter: dynamic attribute injection — an untrusted NAME argument (position 1) +to the builtin ``setattr``/``getattr`` inside a trusted-tier function lets an +attacker pick which attribute is written/read (mass assignment, e.g. reaching +``__class__``-adjacent state). Tier-modulated; fires only where trust is +declared. + +Only the NAME slot is dangerous: an untrusted VALUE assigned to a fixed +attribute (``setattr(obj, 'name', raw)``), a tainted ``getattr`` default, or a +tainted receiver are ordinary data flow, not attribute injection — the +arg-position-aware :class:`ArgSpec` keeps them silent. + +Severity: WARN. Exploitation depends on the target object's shape (a +mass-assignment VECTOR, not direct code execution), so it sits below the +unconditional-RCE ERROR class (108/112/118/124). Note the WARN co-residents +106/107 are there for a DIFFERENT reason — they ARE direct-RCE sinks held at +WARN on FP economics (weaker worst-of-all-args evidence; see their module +docstrings) — so this rule does not cite them as a "non-RCE WARN convention". +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule +from wardline.scanner.rules.metadata import RuleMetadata + +# setattr/getattr are positional-only builtins — the NAME is position 1, always. +_NAME_SPEC = ArgSpec(positions=(1,)) + +_SINK_SPECS: dict[str, ArgSpec | None] = { + "setattr": _NAME_SPEC, + "getattr": _NAME_SPEC, + "builtins.setattr": _NAME_SPEC, + "builtins.getattr": _NAME_SPEC, +} + +METADATA = RuleMetadata( + rule_id="PY-WL-123", + base_severity=Severity.WARN, + kind=Kind.DEFECT, + multi_emit=True, + description=( + "Untrusted data is used as the attribute NAME in setattr/getattr in a trusted-tier function " + "(dynamic attribute injection / mass assignment)." + ), + examples_violation=( + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p, obj):\n setattr(obj, read_raw(p), 1)\n return 1", + ), + examples_clean=( + # Fixed attribute name: the untrusted VALUE slot is not the injection vector. + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p, obj):\n setattr(obj, 'name', read_raw(p))\n return 1", + ), + maturity=Maturity.PREVIEW, +) + + +class UntrustedToReflection(TaintedSinkRule): + rule_id = METADATA.rule_id + metadata = METADATA + SINKS = frozenset(_SINK_SPECS) + SINK_SPECS = _SINK_SPECS + sink_label = "attribute-reflection" diff --git a/src/wardline/scanner/rules/untrusted_to_shell_subprocess.py b/src/wardline/scanner/rules/untrusted_to_shell_subprocess.py index 90baa578..c20e5383 100644 --- a/src/wardline/scanner/rules/untrusted_to_shell_subprocess.py +++ b/src/wardline/scanner/rules/untrusted_to_shell_subprocess.py @@ -20,6 +20,11 @@ Tier-modulated and trusted-tier-gated exactly like 106/107/108 (silent in the undecorated developer-freedom zone, speaking only where trust is declared). + +Sink matching is BINDING-AWARE (the consolidated :class:`TaintedSinkRule` base): +a function-local callable alias — ``runner = subprocess.run; runner(raw, +shell=True)`` — resolves to the sink FQN and fires (wardline-13cfdd7b31). The +literal ``shell=True`` gate applies to binding-resolved calls identically. """ from __future__ import annotations @@ -55,8 +60,11 @@ def _has_literal_shell_true(call: ast.Call) -> bool: METADATA = RuleMetadata( rule_id="PY-WL-112", - base_severity=Severity.WARN, # matches the 108 OS-command family; ERROR is defensible + # Calibrated with 108 and PY-WL-118 (SQLi): tainted shell=True command + # execution is the same exploit class (CWE-78 ≅ CWE-89 in blast radius). + base_severity=Severity.ERROR, kind=Kind.DEFECT, + multi_emit=True, description=( "Untrusted data reaches a subprocess call with a literal shell=True " "(conditionally-shell OS-command injection, CWE-78)." @@ -80,7 +88,7 @@ class UntrustedToShellSubprocess(TaintedSinkRule): SINKS = _SINKS sink_label = "shell=True subprocess" - def _accept_call(self, call: ast.Call) -> bool: # noqa: PLR6301 + def _accept_call(self, call: ast.Call, fqn: str) -> bool: # noqa: ARG002, PLR6301 """Extra per-call gate beyond the SINK-name match: require literal shell=True so the safe argv-list default (shell=False) never trips this family.""" return _has_literal_shell_true(call) diff --git a/src/wardline/scanner/rules/untrusted_to_template.py b/src/wardline/scanner/rules/untrusted_to_template.py new file mode 100644 index 00000000..e4cc8c14 --- /dev/null +++ b/src/wardline/scanner/rules/untrusted_to_template.py @@ -0,0 +1,66 @@ +# src/wardline/scanner/rules/untrusted_to_template.py +"""PY-WL-122 — untrusted data compiled into a server-side template (SSTI, CWE-1336). + +Charter: a tainted string reaching a template COMPILATION sink +(``jinja2.Template``, ``jinja2.Environment.from_string`` — including the +construct-then-method form — and ``mako.template.Template``) inside a +trusted-tier function. Tier-modulated; fires only where trust is declared. + +Only the template SOURCE slot is dangerous: tainted data passed as a render +variable (``Template('{{ x }}').render(x=raw)``) is the safe idiom and must not +fire — autoescaping/render-time substitution is exactly the mitigation. Loading +a template BY NAME (``env.get_template(raw)``) is not SSTI either. + +Severity: ERROR. SSTI in Jinja2/Mako is RCE-adjacent (``{{ ''.__class__ ... }}`` +sandbox escapes are a documented exploitation primitive), the same blast-radius +class as the PY-WL-118/108 ERROR family. +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule +from wardline.scanner.rules.metadata import RuleMetadata + +# Only the template-source slot; render context / loader names are not SSTI. +_SINK_SPECS: dict[str, ArgSpec | None] = { + "jinja2.Template": ArgSpec(positions=(0,), keywords=("source",)), + "jinja2.Environment.from_string": ArgSpec(positions=(0,), keywords=("source",)), + "mako.template.Template": ArgSpec(positions=(0,), keywords=("text",)), +} + +METADATA = RuleMetadata( + rule_id="PY-WL-122", + base_severity=Severity.ERROR, + kind=Kind.DEFECT, + multi_emit=True, + description=( + "Untrusted data is compiled into a server-side template " + "(jinja2.Template/Environment.from_string, mako Template) in a trusted-tier function (SSTI)." + ), + examples_violation=( + "import jinja2\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n return jinja2.Template(read_raw(p)).render()", + "import jinja2\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n" + " env = jinja2.Environment()\n return env.from_string(read_raw(p))", + ), + examples_clean=( + # Tainted data as a RENDER variable is the safe idiom — only a tainted SOURCE is SSTI. + "import jinja2\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n" + " jinja2.Template('Hello {{ name }}').render(name=read_raw(p))", + ), + maturity=Maturity.PREVIEW, +) + + +class UntrustedToTemplate(TaintedSinkRule): + rule_id = METADATA.rule_id + metadata = METADATA + SINKS = frozenset(_SINK_SPECS) + SINK_SPECS = _SINK_SPECS + sink_label = "template-compilation" diff --git a/src/wardline/scanner/rules/untrusted_to_trusted_callee.py b/src/wardline/scanner/rules/untrusted_to_trusted_callee.py index f50f01a3..d9dfcbb0 100644 --- a/src/wardline/scanner/rules/untrusted_to_trusted_callee.py +++ b/src/wardline/scanner/rules/untrusted_to_trusted_callee.py @@ -16,7 +16,11 @@ - the callee must resolve to a **same-module** anchored entity with a trusted body (``@external_boundary`` / ``@trust_boundary`` callees are excluded — their body is raw, so raw input is expected); unresolved/cross-module callees are skipped; - - argument-taint resolution is the conservative shared ``worst_arg_taint``. + - argument-taint resolution fires when **any** resolved arg is provably untrusted, + not the single ``worst_arg_taint``: the ``_PROVABLY_UNTRUSTED`` predicate is not + upward-closed (a hole at ``UNKNOWN_RAW`` sits between ``EXTERNAL_RAW`` and + ``MIXED_RAW``), so a max-rank collapse would let an ``UNKNOWN_RAW`` co-arg mask a + provably-untrusted argument. Subsumption: distinct from PY-WL-101 (return anchor vs call-site anchor); a function that *returns* such a call is 101, the *pass-in* is 105 even if the result is discarded. @@ -29,12 +33,12 @@ from wardline.core.finding import Finding, Kind, Location, Severity from wardline.core.finding import compute_finding_fingerprint as _fp -from wardline.core.taints import TaintState +from wardline.core.taints import TRUST_RANK, TaintState from wardline.scanner.rules._sink_helpers import ( RAW_ZONE, _own_calls, dotted_name, - worst_arg_taint, + resolved_arg_taints, ) from wardline.scanner.rules.metadata import RuleMetadata @@ -48,6 +52,7 @@ rule_id="PY-WL-105", base_severity=Severity.ERROR, kind=Kind.DEFECT, + multi_emit=True, description="Untrusted data is passed as an argument to a trusted producer at a call site.", examples_violation=( "@external_boundary\ndef read_raw(p):\n return p\n" @@ -88,6 +93,20 @@ def _resolve_callee(call: ast.Call, module: str, context: AnalysisContext, *, ca return dotted if dotted in context.entities else None +def _resolve_callees(call: ast.Call, module: str, context: AnalysisContext, *, caller_qualname: str = "") -> set[str]: + """The SET of candidate callee qualnames for a call site. For a branch-conditional + receiver (``o`` assigned a project class in >1 arm), this is the full candidate set + so the rule fires on any trusted-sink candidate regardless of AST order + (wardline-499c22bbdd); otherwise it is the single ``_resolve_callee`` result.""" + candidates = context.call_site_candidate_callees.get(id(call)) + if candidates: + resolved = {c for c in candidates if c in context.entities} + if resolved: + return resolved + single = _resolve_callee(call, module, context, caller_qualname=caller_qualname) + return {single} if single is not None else set() + + class UntrustedReachesTrustedCallee: rule_id = METADATA.rule_id metadata = METADATA @@ -102,25 +121,46 @@ def check(self, context: AnalysisContext) -> list[Finding]: for qualname, entity in context.entities.items(): module = module_dotted_name(entity.location.path) or "" for call in _own_calls(entity.node): - callee = _resolve_callee(call, module, context, caller_qualname=qualname) - if callee is None: + callees = _resolve_callees(call, module, context, caller_qualname=qualname) + if not callees: continue - prov = context.taint_provenance.get(callee) - if prov is None or prov.source != "anchored": - continue # callee is not a trust-declared producer - callee_body = context.project_taints.get(callee) - if callee_body is None or callee_body in RAW_ZONE: - continue # @external_boundary / @trust_boundary body is raw — raw input expected - worst = worst_arg_taint(call, qualname, context) - if worst is None or worst not in _PROVABLY_UNTRUSTED: + # Arg gate (independent of which callee): fire when ANY resolved arg is + # provably untrusted. worst_arg_taint (max TRUST_RANK) is unsound here: + # _PROVABLY_UNTRUSTED is NOT upward-closed (hole at UNKNOWN_RAW=6 between + # EXTERNAL_RAW=5 and MIXED_RAW=7), so an UNKNOWN_RAW co-arg would mask a + # provably-untrusted arg by bumping the max into the hole. Computed once; + # only the callee-trust gate varies across branch-conditional candidates. + arg_taints = resolved_arg_taints(call, qualname, context).values() + untrusted = [ts for ts in arg_taints if ts in _PROVABLY_UNTRUSTED] + if not untrusted: continue + worst = max(untrusted, key=lambda ts: TRUST_RANK[ts]) line = call.lineno + # Collect every candidate callee that is a trust-declared producer. A + # branch-conditional receiver may have >1 trusted candidate at one call + # site; emit ONE finding per call site (not one per candidate) so a single + # taint flow is a single defect, deterministically keyed on the first + # candidate (wardline-499c22bbdd panel: avoid duplicate findings/fingerprints). + firing = [] + for callee in sorted(callees): + prov = context.taint_provenance.get(callee) + if prov is None or prov.source != "anchored": + continue # callee is not a trust-declared producer + callee_body = context.project_taints.get(callee) + if callee_body is None or callee_body in RAW_ZONE: + continue # @external_boundary / @trust_boundary body is raw — raw input expected + firing.append((callee, callee_body)) + if not firing: + continue + callee, callee_body = firing[0] + others = [c for c, _ in firing[1:]] + also = f" (branch-conditional; also reaches {', '.join(others)})" if others else "" findings.append( Finding( rule_id=self.rule_id, message=( f"{qualname}: {worst.value} (untrusted) data passed to trusted producer " - f"{callee}() at line {line}" + f"{callee}() at line {line}{also}" ), severity=self.base_severity, kind=Kind.DEFECT, @@ -128,12 +168,24 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=line, qualname=qualname, - taint_path=f"{worst.value}->{callee}", + # Call-site-anchored, >1 finding per (rule, path, qualname) possible (several + # calls in one function). Discriminate SOURCE-only: an ENTITY-RELATIVE line + # offset (call line - def line, invariant to a comment ABOVE the function: + # wlfp2/wardline-8654423823) + the call's full lexical SPAN + the callee spelling + # AS WRITTEN. Never the resolved arg taint or resolved callee qualname (both + # drift). The span (start:end) separates a chain's outer/inner calls. + taint_path=f"{line - (entity.location.line_start or 0)}:{call.col_offset}:{call.end_col_offset}:{dotted_name(call.func)}", # noqa: E501 ), + # OLD (wlfp1) taint_path, byte-exact, for `wardline rekey` (P4). + taint_path_v0=f"{dotted_name(call.func)}@{call.col_offset}:{call.end_col_offset}", qualname=qualname, - properties={"callee": callee, "arg_taint": worst.value, "callee_body": callee_body.value}, + properties={ + "callee": callee, + "arg_taint": worst.value, + "callee_body": callee_body.value, + **({"candidate_callees": [c for c, _ in firing]} if others else {}), + }, ) ) return findings diff --git a/src/wardline/scanner/rules/untrusted_to_xml.py b/src/wardline/scanner/rules/untrusted_to_xml.py new file mode 100644 index 00000000..2ff54b72 --- /dev/null +++ b/src/wardline/scanner/rules/untrusted_to_xml.py @@ -0,0 +1,87 @@ +# src/wardline/scanner/rules/untrusted_to_xml.py +"""PY-WL-121 — untrusted data reaches an XML parsing sink (XXE family, CWE-611). + +Charter: a tainted document/stream reaching an XML parser inside a trusted-tier +function. Tier-modulated; fires only where trust is declared. Only the DOCUMENT +slot (position 0 / its keyword spelling) is dangerous — taint in a ``parser=`` +or handler slot is not XXE. + +Severity is PER-SINK, calibrated to each parser's *default* posture (verified on +the project interpreter, 2026-06-10 scrub): + +* ``lxml.etree.*`` — **ERROR**: resolves external entities by default + (``resolve_entities=True``), so tainted XML is genuine XXE (local file + disclosure / SSRF). +* stdlib ``xml.etree.ElementTree`` / ``xml.dom.minidom`` / ``xml.sax`` — + **WARN**: external general entities have been disabled by default since + CPython 3.7.1 (``ET.fromstring`` raises on an external-entity payload), so + the default-on residual risk is the billion-laughs internal-entity-expansion + DoS shared by every pyexpat-based parser. NOT the ERROR class the gap report + originally claimed — the "stdlib resolves external entities by default" + premise was disproven by the verifier; all three stdlib families share the + same DoS-only default risk, hence the same WARN. + +``defusedxml`` is the blessed remediation and is deliberately not a sink. + +The 121…126 preview family's binding- and arg-slot-aware check machinery used to +live here as ``SpecSinkCheckMixin``; it graduated into the consolidated +:class:`TaintedSinkRule` base (``SINK_SPECS`` / ``SINK_SEVERITIES``, review +2026-06-10), so this module is now an attribute-only rule like its siblings. +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Maturity, Severity +from wardline.scanner.rules._sink_helpers import ArgSpec, TaintedSinkRule +from wardline.scanner.rules.metadata import RuleMetadata + +# Only the DOCUMENT slot is dangerous; ``parser=``/handler slots are not XXE. +_SINK_SPECS: dict[str, ArgSpec | None] = { + "xml.etree.ElementTree.fromstring": ArgSpec(positions=(0,), keywords=("text",)), + "xml.etree.ElementTree.parse": ArgSpec(positions=(0,), keywords=("source",)), + "xml.etree.ElementTree.XML": ArgSpec(positions=(0,), keywords=("text",)), + "xml.etree.ElementTree.iterparse": ArgSpec(positions=(0,), keywords=("source",)), + "xml.dom.minidom.parse": ArgSpec(positions=(0,), keywords=("file",)), + "xml.dom.minidom.parseString": ArgSpec(positions=(0,), keywords=("string",)), + "xml.sax.parse": ArgSpec(positions=(0,), keywords=("source",)), + "xml.sax.parseString": ArgSpec(positions=(0,), keywords=("string",)), + "lxml.etree.fromstring": ArgSpec(positions=(0,), keywords=("text",)), + "lxml.etree.parse": ArgSpec(positions=(0,), keywords=("source",)), + "lxml.etree.XML": ArgSpec(positions=(0,), keywords=("text",)), +} + +# Per-sink calibration (see module docstring): stdlib = billion-laughs DoS only +# since 3.7.1 → WARN; lxml = entity-resolving by default → the ERROR base. +_STDLIB_SEVERITIES: dict[str, Severity] = {fqn: Severity.WARN for fqn in _SINK_SPECS if fqn.startswith("xml.")} + +METADATA = RuleMetadata( + rule_id="PY-WL-121", + base_severity=Severity.ERROR, + kind=Kind.DEFECT, + multi_emit=True, + description=( + "Untrusted data reaches an XML parsing sink (XXE/billion-laughs: lxml.etree at ERROR, " + "stdlib etree/minidom/sax at WARN) in a trusted-tier function." + ), + examples_violation=( + "from lxml import etree\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n return etree.fromstring(read_raw(p))", + "import xml.etree.ElementTree as ET\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p):\n return ET.fromstring(read_raw(p))", + ), + examples_clean=( + "import xml.etree.ElementTree as ET\n@trusted(level='ASSURED')\ndef f():\n ET.fromstring('')", + ), + maturity=Maturity.PREVIEW, +) + + +class UntrustedToXML(TaintedSinkRule): + rule_id = METADATA.rule_id + metadata = METADATA + SINKS = frozenset(_SINK_SPECS) + SINK_SPECS = _SINK_SPECS + SINK_SEVERITIES = _STDLIB_SEVERITIES + sink_label = "XML-parsing" diff --git a/src/wardline/scanner/taint/call_taint_map.py b/src/wardline/scanner/taint/call_taint_map.py index cbb9d014..698c3f26 100644 --- a/src/wardline/scanner/taint/call_taint_map.py +++ b/src/wardline/scanner/taint/call_taint_map.py @@ -91,7 +91,14 @@ def build_call_taint_map( # module import: dotted ``local.func`` calls for func_name, taint in bucket.items(): tm[f"{local}.{func_name}"] = taint - continue + for module, module_bucket in project_by_module.items(): + if module.startswith(target + "."): + # ``import pkg.sub`` collapses the alias to ``pkg``; the call is + # written ``local..fn`` just like multi-component + # stdlib imports. + remainder = module[len(target) + 1 :] + for func_name, taint in module_bucket.items(): + tm[f"{local}.{remainder}.{func_name}"] = taint # from-import of a project function: target == "module.func_name" mod, _, leaf = target.rpartition(".") mod_bucket = project_by_module.get(mod) diff --git a/src/wardline/scanner/taint/callgraph.py b/src/wardline/scanner/taint/callgraph.py index 76ea742a..38c2908f 100644 --- a/src/wardline/scanner/taint/callgraph.py +++ b/src/wardline/scanner/taint/callgraph.py @@ -9,8 +9,17 @@ 3. same-project classmethod calls through a class object, such as ``Cls.helper(...)``. A call resolving to a project FQN becomes an edge; everything else (externals, -constructors, dynamic dispatch, closure-captured self) counts as unresolved — -the unresolved count raises the caller's pessimistic floor in the kernel. +constructors, closure-captured self) counts as unresolved — the unresolved count +raises the caller's pessimistic floor in the kernel. Variable-typed dispatch +(``o = Cls(); o.m()``) is resolved through a FLOW-SENSITIVE reaching-definitions pass +(``_candidate_receiver_classes``): the classes a receiver MAY hold on a path reaching +the call (branch arms unioned at joins, straight-line reassignment REPLACES, loop body +to a fixpoint). With one reaching class the call resolves single-valued (deterministic); +with >= 2 the full candidate set is recorded in ``call_site_candidate_callees`` so a +sink rule fires on any trusted-sink candidate regardless of AST order +(wardline-499c22bbdd). This replaces the former flat AST-order-dependent last-write-wins +pre-pass, so the single-valued ``call_site_callees`` (which drives edge/count/param-meet) +is now the deterministic reaching-set representative, not the textually-last assignment. Nested-scope calls are excluded via ``iter_calls_in_function_body`` (they belong to the nested entity). Module-scope header/decorator calls are not attributed to @@ -31,12 +40,181 @@ from wardline.scanner.index import Entity -def _own_scope_nodes(node: ast.AST) -> Iterator[ast.AST]: +def _own_nodes_in(node: ast.AST) -> Iterator[ast.AST]: + """Yield *node* and every descendant in its own scope (including *node* itself), not + descending into nested def/class/lambda scopes.""" + yield node for child in ast.iter_child_nodes(node): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): continue - yield child - yield from _own_scope_nodes(child) + yield from _own_nodes_in(child) + + +def _target_names(target: ast.expr) -> Iterator[str]: + """Yield the plain ``Name`` ids bound by an assignment/loop target (recursing into + tuple/list/starred destructuring); attribute/subscript targets bind no local name.""" + if isinstance(target, ast.Name): + yield target.id + elif isinstance(target, ast.Starred): + yield from _target_names(target.value) + elif isinstance(target, (ast.Tuple, ast.List)): + for elt in target.elts: + yield from _target_names(elt) + + +def _candidate_receiver_classes( + func: ast.AST, + *, + alias_map: dict[str, str], + module_prefix: str, + class_qualnames: frozenset[str], + known_fqns: frozenset[str], +) -> dict[int, frozenset[str]]: + """Flow-sensitive reaching-definitions pass over *func*'s own scope. + + Returns ``{id(call): frozenset(class_fqn)}`` for each ``recv.method()`` call site, + giving the set of project classes the receiver name MAY hold ON A PATH REACHING THAT + CALL. Branch arms are unioned only at their join; a straight-line reassignment REPLACES + (kills) the prior binding. So a linear ``o=A(); o=B(); o.m()`` resolves to ``{B}`` (no + spurious widening), and an in-arm ``if: o=A(); o.m()`` sees only ``{A}`` — eliminating + the flow-insensitive over-approximation that a flat whole-function union would produce + (wardline-499c22bbdd). Mirrors the merge discipline of ``variable_level``'s taint walk. + """ + candidates_at_call: dict[int, frozenset[str]] = {} + + def resolve_class(value: ast.expr | None, env: dict[str, set[str]]) -> set[str]: + if isinstance(value, ast.NamedExpr): # ``(x := expr)`` evaluates to expr + value = value.value + if isinstance(value, ast.Call): + fqn = resolve_call_fqn(value, alias_map, known_fqns, module_prefix) + return {fqn} if fqn in class_qualnames else set() + if isinstance(value, ast.Name): + return set(env.get(value.id, set())) + return set() + + def record(node: ast.AST, env: dict[str, set[str]]) -> None: + # Walk the expression in own scope, recording dispatch call sites and applying any + # walrus rebinds (``(o := Plain())`` REPLACES o's class binding, killing a stale + # earlier one — else a sink rule fires on a class o can no longer be, an FP). + for sub in _own_nodes_in(node): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and isinstance(sub.func.value, ast.Name) + ): + classes = env.get(sub.func.value.id) + if classes: + candidates_at_call[id(sub)] = frozenset(classes) + elif isinstance(sub, ast.NamedExpr) and isinstance(sub.target, ast.Name): + bind([sub.target], sub.value, env) + + def bind(targets: list[ast.expr], value: ast.expr | None, env: dict[str, set[str]]) -> None: + classes = resolve_class(value, env) + for target in targets: + for name in _target_names(target): + if classes: + env[name] = set(classes) # REPLACE — a reassignment kills the prior binding + else: + env.pop(name, None) # assigned a non-class / unknown value → unbind + + def unbind(targets: list[ast.expr], env: dict[str, set[str]]) -> None: + for target in targets: + for name in _target_names(target): + env.pop(name, None) + + def merge(into: dict[str, set[str]], *envs: dict[str, set[str]]) -> None: + names: set[str] = set(into) + for env in envs: + names |= set(env) + for name in names: + union: set[str] = set() + for env in envs: + union |= env.get(name, set()) + if union: + into[name] = union + else: + into.pop(name, None) + + def walk(stmts: list[ast.stmt], env: dict[str, set[str]]) -> None: + for stmt in stmts: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue # nested scope — a separate entity + if isinstance(stmt, ast.If): + record(stmt.test, env) + body_env = {k: set(v) for k, v in env.items()} + else_env = {k: set(v) for k, v in env.items()} + walk(stmt.body, body_env) + walk(stmt.orelse, else_env) + env.clear() + merge(env, body_env, else_env) + elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While)): + loop_targets = [stmt.target] if isinstance(stmt, (ast.For, ast.AsyncFor)) else [] + record(stmt.test if isinstance(stmt, ast.While) else stmt.iter, env) + pre_env = {k: set(v) for k, v in env.items()} + # Loop-carried fixpoint: a receiver rebound near the END of the body is + # visible to a call EARLIER in the body on the next iteration. Walk the body + # to convergence, feeding the end-of-body env back to the entry, so a + # top-of-body dispatch sees every class the receiver may carry across + # iterations (wardline-499c22bbdd). Monotone union over a finite class set → + # converges; the backstop is keyed to the COPY-CHAIN DEPTH (the number of + # names assigned in the body, the dimension a binding propagates one link per + # iteration along — NOT the class count, which is unrelated). The early break + # fires at real convergence; the backstop is a never-hit termination net. + loop_names = { + n.id + for s in stmt.body + for n in _own_nodes_in(s) + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) + } + entry = {k: set(v) for k, v in env.items()} + unbind(loop_targets, entry) # loop var binds an element, not a class + body_env = dict(entry) + for _ in range(len(loop_names) + 2): + body_env = {k: set(v) for k, v in entry.items()} + walk(stmt.body, body_env) + widened: dict[str, set[str]] = {} + merge(widened, entry, body_env) + unbind(loop_targets, widened) + if widened == entry: + break + entry = widened + env.clear() + merge(env, pre_env, body_env) # body may run 0+ times + unbind(loop_targets, env) + walk(stmt.orelse, env) + elif isinstance(stmt, (ast.With, ast.AsyncWith)): + for item in stmt.items: + record(item.context_expr, env) + if item.optional_vars is not None: + unbind([item.optional_vars], env) # `as o` binds the context manager + walk(stmt.body, env) + elif isinstance(stmt, (ast.Try, ast.TryStar)): + body_env = {k: set(v) for k, v in env.items()} + walk(stmt.body, body_env) + else_env = {k: set(v) for k, v in body_env.items()} + walk(stmt.orelse, else_env) + arm_envs = [else_env] + for handler in stmt.handlers: + h_env = {k: set(v) for k, v in env.items()} + walk(handler.body, h_env) + arm_envs.append(h_env) + env.clear() + merge(env, *arm_envs) + walk(stmt.finalbody, env) # always runs + elif isinstance(stmt, ast.Assign): + record(stmt, env) + bind(stmt.targets, stmt.value, env) + elif isinstance(stmt, ast.AnnAssign): + record(stmt, env) + if stmt.value is not None: + bind([stmt.target], stmt.value, env) + else: + record(stmt, env) + + body = getattr(func, "body", None) + if isinstance(body, list): + walk(body, {}) + return candidates_at_call def build_call_edges( @@ -46,23 +224,38 @@ def build_call_edges( alias_map: dict[str, str], module_prefix: str, project_fqns: frozenset[str], -) -> tuple[dict[str, frozenset[str]], dict[str, int], dict[str, int], dict[int, str], dict[int, str]]: +) -> tuple[ + dict[str, frozenset[str]], + dict[str, int], + dict[str, int], + dict[int, str], + dict[int, str], + dict[int, frozenset[str]], +]: """Resolve intra-/inter-module call edges for one module's entities. Returns ``(edges, resolved_counts, unresolved_counts, call_site_callees, - call_site_implicit_receivers)`` keyed by caller qualname. ``edges[caller]`` - is the set of resolved project callee FQNs; counts are per-call-site (a - callee reached twice counts twice toward ``resolved_counts`` but appears - once in the edge set). ``call_site_implicit_receivers`` records resolved - call sites whose explicit positional arguments start after an implicit + call_site_implicit_receivers, call_site_candidate_callees)`` keyed by caller + qualname. ``edges[caller]`` is the set of resolved project callee FQNs; counts + are per-call-site (a callee reached twice counts twice toward ``resolved_counts`` + but appears once in the edge set). ``call_site_implicit_receivers`` records + resolved call sites whose explicit positional arguments start after an implicit receiver parameter; values are ``"instance"`` or ``"class"``. + ``call_site_candidate_callees`` records, for a branch-conditional receiver assigned + a project class in more than one arm, the FULL set of candidate callee FQNs (a + superset of the single ``call_site_callees`` entry) so a sink rule can fire on any + trusted-sink candidate regardless of AST order (wardline-499c22bbdd). """ edges: dict[str, frozenset[str]] = {} resolved_counts: dict[str, int] = {} unresolved_counts: dict[str, int] = {} call_site_callees: dict[int, str] = {} call_site_implicit_receivers: dict[int, str] = {} + call_site_candidate_callees: dict[int, frozenset[str]] = {} entity_by_fqn = {entity.qualname: entity for entity in entities} + # Hoisted out of _candidate_receiver_classes: the union is O(project) and was + # rebuilt once PER FUNCTION, an O(n^2) whole-scan term on large trees. + known_fqns = project_fqns | class_qualnames # resolve_call_fqn resolves constructors here def _decorator_name(decorator: ast.expr) -> str | None: if isinstance(decorator, ast.Call): @@ -94,27 +287,19 @@ def _resolve_classmethod_call(call: ast.Call) -> str | None: if caller_class_fqn not in class_qualnames: caller_class_fqn = None - # Collect local variable type definitions via constructor calls - local_var_types: dict[str, str] = {} - for node in _own_scope_nodes(entity.node): - targets: list[ast.expr] = [] - value: ast.expr | None = None - if isinstance(node, ast.Assign): - targets = node.targets - value = node.value - elif isinstance(node, ast.AnnAssign): - targets = [node.target] - value = node.value - if value is not None: - class_candidate = None - if isinstance(value, ast.Call): - class_candidate = resolve_call_fqn(value, alias_map, project_fqns | class_qualnames, module_prefix) - elif isinstance(value, ast.Name): - class_candidate = local_var_types.get(value.id) - if class_candidate in class_qualnames: - for tgt in targets: - if isinstance(tgt, ast.Name): - local_var_types[tgt.id] = class_candidate + # Flow-sensitive reaching-definitions pass: the SET of project classes each receiver + # name MAY hold AT a given call site (branch arms unioned at joins, straight-line + # reassignment REPLACES — so a linear kill o=A();o=B() does NOT widen, and an in-arm + # call sees only that arm's binding). This is the SINGLE source for var-type call + # resolution, replacing the former flat last-write-wins pre-pass which was itself + # AST-order-dependent (the root of wardline-499c22bbdd, FP on trusted-last shapes). + call_candidate_classes = _candidate_receiver_classes( + entity.node, + alias_map=alias_map, + module_prefix=module_prefix, + class_qualnames=class_qualnames, + known_fqns=known_fqns, + ) callees: set[str] = set() resolved = 0 @@ -144,19 +329,28 @@ def _resolve_classmethod_call(call: ast.Call) -> str | None: and isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name) ): - var_name = call.func.value.id - if var_name in local_var_types: - class_fqn = local_var_types[var_name] - candidate = f"{class_fqn}.{call.func.attr}" - if candidate in project_fqns: - target = candidate - target_entity = entity_by_fqn.get(target) - if target_entity is not None and _has_decorator(target_entity, "staticmethod"): - implicit_receiver = None - elif target_entity is not None and _has_decorator(target_entity, "classmethod"): - implicit_receiver = "class" - else: - implicit_receiver = "instance" + # Flow-sensitive var-type dispatch: resolve against the project classes the + # receiver MAY hold AT this call site (reaching definitions). One class → + # single resolution; >= 2 → record the full candidate callee set so a sink + # rule fires on any trusted-sink candidate regardless of AST order, and pick + # a deterministic representative for the single-valued edge/count/param-meet + # (wardline-499c22bbdd). A linear reassignment or in-arm call yields exactly + # the class live there, so neither over-fires. + reaching = call_candidate_classes.get(id(call)) + cand_callees = ( + sorted({f"{cls}.{call.func.attr}" for cls in reaching} & project_fqns) if reaching else [] + ) + if len(cand_callees) >= 2: + call_site_candidate_callees[id(call)] = frozenset(cand_callees) + if cand_callees: + target = cand_callees[0] # deterministic single-valued representative + target_entity = entity_by_fqn.get(target) + if target_entity is not None and _has_decorator(target_entity, "staticmethod"): + implicit_receiver = None + elif target_entity is not None and _has_decorator(target_entity, "classmethod"): + implicit_receiver = "class" + else: + implicit_receiver = "instance" if target is not None and target in project_fqns: callees.add(target) resolved += 1 @@ -170,4 +364,11 @@ def _resolve_classmethod_call(call: ast.Call) -> str | None: resolved_counts[entity.qualname] = resolved unresolved_counts[entity.qualname] = unresolved - return edges, resolved_counts, unresolved_counts, call_site_callees, call_site_implicit_receivers + return ( + edges, + resolved_counts, + unresolved_counts, + call_site_callees, + call_site_implicit_receivers, + call_site_candidate_callees, + ) diff --git a/src/wardline/scanner/taint/decorator_provider.py b/src/wardline/scanner/taint/decorator_provider.py index c56380ad..f55acf4f 100644 --- a/src/wardline/scanner/taint/decorator_provider.py +++ b/src/wardline/scanner/taint/decorator_provider.py @@ -150,44 +150,125 @@ def _read_level( allowed: frozenset[TaintState], default: TaintState | None, alias_map: Mapping[str, str], + ignored_args: frozenset[str] = frozenset(), ) -> TaintState | None: """Read a level keyword arg from a decorator, normalised + allow-checked. Returns ``default`` when the decorator is not called or the arg is absent; ``None`` (fail-closed) when the arg is present but unreadable, an invalid - state, or outside ``allowed``. Positional args are intentionally ignored — - the real decorators are keyword-only, so a positional form is malformed - source and reads as ``default`` (fail-closed for required-arg decorators). + state, outside ``allowed``, duplicated, or mixed with malformed decorator + call syntax. The real level-bearing decorators are keyword-only; positional + args and unexpected keywords are not trusted as the default level. """ if not isinstance(deco, ast.Call): return default + if deco.args: + return None + values: list[ast.expr] = [] for kw in deco.keywords: - if kw.arg == arg: - token = _level_token(kw.value, alias_map) - if token is None: - return None - try: - level = TaintState(token) - except ValueError: + if kw.arg is None: + if not isinstance(kw.value, ast.Dict): return None - return level if level in allowed else None - return default + for key, value in zip(kw.value.keys, kw.value.values, strict=True): + if not isinstance(key, ast.Constant) or not isinstance(key.value, str): + return None + if key.value in ignored_args: + continue + if key.value != arg: + return None + values.append(value) + continue + if kw.arg == arg: + values.append(kw.value) + continue + if kw.arg in ignored_args: + continue + return None + if not values: + return default + if len(values) != 1: + return None + token = _level_token(values[0], alias_map) + if token is None: + return None + try: + level = TaintState(token) + except ValueError: + return None + return level if level in allowed else None + + +def _seed_value_identity(value: object) -> str: + if value is None or isinstance(value, (str, int, float, bool)): + return repr(value) + if isinstance(value, TaintState): + return f"TaintState:{value.value}" + if isinstance(value, FunctionTaint): + return ( + "FunctionTaint(" + f"body={_seed_value_identity(value.body_taint)}," + f"return={_seed_value_identity(value.return_taint)}" + ")" + ) + if isinstance(value, (tuple, list)): + return type(value).__name__ + "(" + ",".join(_seed_value_identity(v) for v in value) + ")" + if isinstance(value, dict): + parts = sorted((_seed_value_identity(k), _seed_value_identity(v)) for k, v in value.items()) + return "dict(" + ",".join(f"{k}:{v}" for k, v in parts) + ")" + + module = getattr(value, "__module__", None) + qualname = getattr(value, "__qualname__", None) + if isinstance(module, str) and isinstance(qualname, str): + return f"{module}.{qualname}" + name = getattr(value, "__name__", None) + if isinstance(module, str) and isinstance(name, str): + return f"{module}.{name}" + return repr(value) + + +def _closure_identity(seed: object) -> tuple[str, ...]: + items: list[str] = [] + for cell in getattr(seed, "__closure__", None) or (): + try: + items.append(_seed_value_identity(cell.cell_contents)) + except ValueError: + items.append("") + return tuple(items) def _seed_identity(seed: object) -> str: """A stable identity string for a boundary type's seed callable. - For a Python function/lambda, keys on the bytecode + constants - (``__code__.co_code`` + ``co_consts``) — so two DISTINCT lambda bodies that share - ``__qualname__ == ""`` get DISTINCT identities (closing the cache - cross-contamination false-green: two grammars differing only in a lambda seed - body must not share cached summaries). For a non-function callable (no - ``__code__``), falls back to ``__qualname__`` / ``repr``. This only ever - OVER-invalidates the summary cache (a changed seed body → a different identity → - a cold re-scan), never wrongly reuses — strictly safe.""" + For a Python function/lambda, keys on bytecode, constants, referenced names, + defaults, closures, and the stable identities of referenced globals. Bytecode + alone is not enough: ``return SAFE_SEED`` and ``return RAW_SEED`` can share + ``co_code``/``co_consts`` while differing only by ``co_names`` or the value bound + to that name. For a non-function callable (no ``__code__``), falls back to + ``__qualname__`` / ``repr``. This only ever OVER-invalidates the summary cache (a + changed seed body/dependency → a different identity → a cold re-scan), never + wrongly reuses — strictly safe.""" code = getattr(seed, "__code__", None) if code is not None: - return f"{code.co_code.hex()}|{code.co_consts!r}" + globals_map = getattr(seed, "__globals__", {}) + global_parts = [] + if isinstance(globals_map, dict): + for name in code.co_names: + global_parts.append(f"{name}={_seed_value_identity(globals_map.get(name, ''))}") + return "|".join( + ( + str(getattr(seed, "__module__", "")), + str(getattr(seed, "__qualname__", getattr(seed, "__name__", ""))), + code.co_code.hex(), + repr(code.co_consts), + repr(code.co_names), + repr(code.co_freevars), + repr(code.co_cellvars), + repr(getattr(seed, "__defaults__", None)), + _seed_value_identity(getattr(seed, "__kwdefaults__", None)), + repr(_closure_identity(seed)), + repr(tuple(global_parts)), + ) + ) return str(getattr(seed, "__qualname__", repr(seed))) @@ -316,7 +397,19 @@ def _match( levels: dict[str, TaintState] = {} unreadable = False for la in bt.level_args: - lvl = _read_level(deco, la.arg_name, allowed=la.allowed, default=la.default, alias_map=alias_map) + # Legacy review fixtures and older sample code sometimes supplied + # ``to_level`` on ``@trusted``. Treat it as inert compatibility + # only when the real ``level`` argument remains statically readable; + # genuinely unknown kwargs still fail closed. + ignored = frozenset({"to_level"}) if bt.canonical_name == "trusted" else frozenset() + lvl = _read_level( + deco, + la.arg_name, + allowed=la.allowed, + default=la.default, + alias_map=alias_map, + ignored_args=ignored, + ) if lvl is None: unreadable = True break diff --git a/src/wardline/scanner/taint/module_summariser.py b/src/wardline/scanner/taint/module_summariser.py index 58ac640f..ae378f76 100644 --- a/src/wardline/scanner/taint/module_summariser.py +++ b/src/wardline/scanner/taint/module_summariser.py @@ -1,5 +1,5 @@ # src/wardline/scanner/taint/module_summariser.py -"""Per-module FunctionSummary emission. +"""Per-module FunctionSummary emission + module-global taint seeds. Maps each L1 ``FunctionSeed`` + the callgraph's unresolved-call count into a ``FunctionSummary``. The seed's 2-valued source (``provider``/``default``) maps @@ -8,12 +8,22 @@ provider expresses a module-wide default yet); SP2's richer provider populates it. The cache key is computed once and shared by all functions in the module (module-granular invalidation). + +Also hosts the MODULE-GLOBAL TAINT CHANNEL's two collection helpers +(wardline-66b2c91470): :func:`collect_module_global_raw_seeds` (the read +direction's import-time seeds) and :func:`own_scope_global_names` (the write +direction's ``global g`` declarations). The analyzer threads both into the L2 +walk — see ``analyzer._with_module_global_params`` for how the seeds enter a +function's variable map. """ from __future__ import annotations +import ast from collections.abc import Mapping +from wardline.core.taints import RAW_ZONE, TaintState +from wardline.scanner.ast_primitives import resolve_call_fqn from wardline.scanner.taint.function_level import FunctionSeed from wardline.scanner.taint.summary import ( SUMMARY_SCHEMA_VERSION, @@ -36,6 +46,7 @@ def summarise_module( source_bytes: bytes, resolver_version: str, provider_fingerprint: str, + scan_policy_hash: str, ) -> tuple[FunctionSummary, ...]: """Emit one FunctionSummary per seeded function in this module.""" cache_key = compute_cache_key( @@ -44,6 +55,7 @@ def summarise_module( schema_version=SUMMARY_SCHEMA_VERSION, resolver_version=resolver_version, provider_fingerprint=provider_fingerprint, + scan_policy_hash=scan_policy_hash, ) summaries: list[FunctionSummary] = [] for fqn, seed in seeds.items(): @@ -59,3 +71,92 @@ def summarise_module( ) ) return tuple(summaries) + + +def collect_module_global_raw_seeds( + tree: ast.Module, + *, + module: str, + alias_map: Mapping[str, str], + return_taints: Mapping[str, TaintState], + local_fqns: frozenset[str], + untrusted_sources: frozenset[str] = frozenset(), +) -> dict[str, TaintState]: + """Module-level simple names assigned RAW at import time → ``{name: taint}``. + + The READ direction of the module-global taint channel (wardline-66b2c91470): + a module-level ``RAW = read_raw(...)`` — a direct call whose FQN resolves + (through the import alias map or as a module-local function) to either + + * a ``config.untrusted_sources`` entry (seeded ``EXTERNAL_RAW``), or + * a project function whose RESOLVED RETURN TAINT is in ``RAW_ZONE`` + (an ``@external_boundary`` producer — its return taint, ``EXTERNAL_RAW``, + is the seed), + + marks ``RAW`` as a raw module global. The analyzer then presents these names + to every function's L2 walk as implicit raw parameters. + + Scope (documented v1 approximation): only DIRECT top-level statements are + considered (no descent into module-level ``if``/``try`` bodies), single-Name + ``=``/annotated-``=`` targets only, LAST-BINDING-WINS in source order — a + later rebind whose RHS is not a resolvable raw call CLEARS the name (the + same discipline as ``collect_sink_bindings``). This under-approximates + (bounded FN on conditional module init), never over-approximates a clean + module constant into a raw seed. + """ + aliases = dict(alias_map) + + def raw_taint_of(value: ast.expr) -> TaintState | None: + if not isinstance(value, ast.Call): + return None + fqn = resolve_call_fqn(value, aliases, local_fqns, module) + if fqn is None: + return None + if fqn in untrusted_sources: + return TaintState.EXTERNAL_RAW + taint = return_taints.get(fqn) + return taint if taint is not None and taint in RAW_ZONE else None + + seeds: dict[str, TaintState] = {} + for stmt in tree.body: + if isinstance(stmt, ast.Assign): + if len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name): + taint = raw_taint_of(stmt.value) + if taint is not None: + seeds[stmt.targets[0].id] = taint + else: + seeds.pop(stmt.targets[0].id, None) + else: # tuple/multi-target rebind — clear every touched name + for target in stmt.targets: + for sub in ast.walk(target): + if isinstance(sub, ast.Name): + seeds.pop(sub.id, None) + elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + taint = raw_taint_of(stmt.value) if stmt.value is not None else None + if taint is not None: + seeds[stmt.target.id] = taint + else: + seeds.pop(stmt.target.id, None) + return seeds + + +def own_scope_global_names(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> frozenset[str]: + """Names a function declares ``global`` in its OWN scope. + + The WRITE direction of the module-global taint channel: the analyzer reads + each declared name's final L2 variable taint as the function's write to the + module global. Nested def/class bodies are skipped — they are their own + entities and carry their own ``global`` declarations. + """ + names: set[str] = set() + + def visit(node: ast.AST) -> None: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if isinstance(child, ast.Global): + names.update(child.names) + visit(child) + + visit(func_node) + return frozenset(names) diff --git a/src/wardline/scanner/taint/project_resolver.py b/src/wardline/scanner/taint/project_resolver.py index 89c39373..e5e95262 100644 --- a/src/wardline/scanner/taint/project_resolver.py +++ b/src/wardline/scanner/taint/project_resolver.py @@ -24,6 +24,9 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any +from wardline.core.config import WardlineConfig +from wardline.core.ruleset import ruleset_hash +from wardline.core.taints import combine from wardline.scanner.taint.callgraph import build_call_edges from wardline.scanner.taint.module_summariser import summarise_module from wardline.scanner.taint.propagation import propagate_callgraph_taints @@ -40,7 +43,10 @@ from wardline.scanner.taint.function_level import FunctionSeed from wardline.scanner.taint.summary import FunctionSummary -_RESOLVER_VERSION = "sp1d" +# Bumped sp1d→sp1e: engine taint behaviour changed (DB-fetch source seed, container-mutator +# write-back, loop fixpoint convergence, branch-conditional candidate callees) — invalidates +# persisted/warm summary caches so they cannot serve stale-CLEAN results (cf. wardline-9d6a81b9e7). +_RESOLVER_VERSION = "sp1e" @dataclass(frozen=True, slots=True) @@ -69,6 +75,70 @@ def _cached_summaries_match_module( return frozenset(actual) == expected +def _duplicate_fqn_diagnostics(modules: Sequence[ModuleInput]) -> tuple[tuple[str, str], ...]: + locations_by_fqn: dict[str, list[str]] = {} + for module in modules: + for entity in module.entities: + line = entity.location.line_start if entity.location.line_start is not None else 1 + locations_by_fqn.setdefault(entity.qualname, []).append( + f"{module.module_path} ({entity.location.path}:{line})" + ) + + diagnostics: list[tuple[str, str]] = [] + for fqn in sorted(locations_by_fqn): + locations = locations_by_fqn[fqn] + if len(locations) < 2: + continue + preview = ", ".join(locations[:5]) + if len(locations) > 5: + preview += f", ... ({len(locations)} total)" + diagnostics.append( + ( + "DUPLICATE_FQN", + ( + f"Duplicate function qualname {fqn!r} across {len(locations)} entities: {preview}; " + "project taint summaries are keyed by qualname and cannot disambiguate these definitions" + ), + ) + ) + return tuple(diagnostics) + + +def _merge_edges(target: dict[str, frozenset[str]], new: dict[str, frozenset[str]]) -> None: + for fqn, callees in new.items(): + existing = target.get(fqn) + target[fqn] = callees if existing is None else frozenset((*existing, *callees)) + + +def _add_counts(target: dict[str, int], new: dict[str, int]) -> None: + for fqn, count in new.items(): + target[fqn] = target.get(fqn, 0) + count + + +def _merge_taint_source(existing: TaintSourceClass, incoming: TaintSourceClass) -> TaintSourceClass: + if existing == incoming: + return existing + return "fallback" + + +def _merge_summary_maps( + summaries: Sequence[FunctionSummary], +) -> tuple[dict[str, TaintState], dict[str, TaintState], dict[str, TaintSourceClass]]: + taint_map: dict[str, TaintState] = {} + return_taint_map: dict[str, TaintState] = {} + taint_sources: dict[str, TaintSourceClass] = {} + for summary in summaries: + if summary.fqn in taint_map: + taint_map[summary.fqn] = combine(taint_map[summary.fqn], summary.body_taint) + return_taint_map[summary.fqn] = combine(return_taint_map[summary.fqn], summary.return_taint) + taint_sources[summary.fqn] = _merge_taint_source(taint_sources[summary.fqn], summary.taint_source) + continue + taint_map[summary.fqn] = summary.body_taint + return_taint_map[summary.fqn] = summary.return_taint + taint_sources[summary.fqn] = summary.taint_source + return taint_map, return_taint_map, taint_sources + + def resolve_project_taints( *, modules: Sequence[ModuleInput], @@ -91,6 +161,7 @@ def resolve_project_taints( "dirty_modules=frozenset() explicitly if nothing has changed" ) + resolver_diagnostics = list(_duplicate_fqn_diagnostics(modules)) project_fqns = frozenset(e.qualname for m in modules for e in m.entities) all_classes = frozenset(c for m in modules for c in m.class_qualnames) @@ -99,24 +170,36 @@ def resolve_project_taints( unresolved_counts: dict[str, int] = {} call_site_callees: dict[int, str] = {} call_site_implicit_receivers: dict[int, str] = {} + call_site_candidate_callees: dict[int, frozenset[str]] = {} for m in modules: - m_edges, m_resolved, m_unresolved, m_callees, m_implicit_receivers = build_call_edges( + ( + m_edges, + m_resolved, + m_unresolved, + m_callees, + m_implicit_receivers, + m_candidate_callees, + ) = build_call_edges( entities=m.entities, class_qualnames=all_classes, alias_map=m.alias_map, module_prefix=m.module_path, project_fqns=project_fqns, ) - edges.update(m_edges) - resolved_counts.update(m_resolved) - unresolved_counts.update(m_unresolved) + _merge_edges(edges, m_edges) + _add_counts(resolved_counts, m_resolved) + _add_counts(unresolved_counts, m_unresolved) call_site_callees.update(m_callees) call_site_implicit_receivers.update(m_implicit_receivers) + call_site_candidate_callees.update(m_candidate_callees) # Transitive dirty frontier (performance over-approximation — bounds which # clean modules skip provider re-invocation; NOT a correctness gate). if summary_cache is not None and dirty_modules is not None: - fqn_to_module = {e.qualname: m.module_path for m in modules for e in m.entities} + fqn_to_module: dict[str, str] = {} + for m in modules: + for e in m.entities: + fqn_to_module.setdefault(e.qualname, m.module_path) frontier = ReverseModuleIndex.from_forward_edges( {k: set(v) for k, v in edges.items()}, fqn_to_module=fqn_to_module, @@ -124,6 +207,12 @@ def resolve_project_taints( else: frontier = frozenset() + # The effective-scan-policy identity (untrusted_sources / sanitisers / provenance_clash + # shape summaries without changing source bytes). MUST match the dirty-detection key the + # parse stage computed from the same config, exactly as provider_fingerprint must — else a + # summary computed under one policy could be served under another (wardline-9d6a81b9e7). + scan_policy_hash = ruleset_hash(config if config is not None else WardlineConfig()) + # Per-module summaries: reuse cached for clean cache-hit modules, else fresh. summaries: list[FunctionSummary] = [] for m in modules: @@ -133,6 +222,7 @@ def resolve_project_taints( schema_version=SUMMARY_SCHEMA_VERSION, resolver_version=_RESOLVER_VERSION, provider_fingerprint=provider_fingerprint, + scan_policy_hash=scan_policy_hash, ) cached: tuple[FunctionSummary, ...] | None = None if summary_cache is not None and m.module_path not in frontier: @@ -150,14 +240,13 @@ def resolve_project_taints( source_bytes=m.source_bytes, resolver_version=_RESOLVER_VERSION, provider_fingerprint=provider_fingerprint, + scan_policy_hash=scan_policy_hash, ) summaries.extend(fresh) if summary_cache is not None: summary_cache.put(cache_key, fresh) - taint_map: dict[str, TaintState] = {s.fqn: s.body_taint for s in summaries} - return_taint_map: dict[str, TaintState] = {s.fqn: s.return_taint for s in summaries} - taint_sources: dict[str, TaintSourceClass] = {s.fqn: s.taint_source for s in summaries} + taint_map, return_taint_map, taint_sources = _merge_summary_maps(summaries) refined, provenance, diagnostics, scc_iteration_counts = propagate_callgraph_taints( edges={k: set(v) for k, v in edges.items()}, @@ -201,7 +290,8 @@ def resolve_project_taints( project_edges=MappingProxyType({fqn: frozenset(callees) for fqn, callees in edges.items()}), call_site_callees=MappingProxyType(call_site_callees), call_site_implicit_receivers=MappingProxyType(call_site_implicit_receivers), + call_site_candidate_callees=MappingProxyType(call_site_candidate_callees), taint_provenance=MappingProxyType(dict(provenance)), - diagnostics=tuple(diagnostics), + diagnostics=(*resolver_diagnostics, *diagnostics), metadata=metadata, ) diff --git a/src/wardline/scanner/taint/resolver_metadata.py b/src/wardline/scanner/taint/resolver_metadata.py index 5ea9a161..7ed6dcd7 100644 --- a/src/wardline/scanner/taint/resolver_metadata.py +++ b/src/wardline/scanner/taint/resolver_metadata.py @@ -74,6 +74,7 @@ class ResolverResult: diagnostics: tuple[tuple[str, str], ...] metadata: ResolverRunMetadata call_site_implicit_receivers: Mapping[int, str] = field(default_factory=dict) + call_site_candidate_callees: Mapping[int, frozenset[str]] = field(default_factory=dict) def __post_init__(self) -> None: object.__setattr__(self, "taint_map", MappingProxyType(dict(self.taint_map))) @@ -85,4 +86,9 @@ def __post_init__(self) -> None: "call_site_implicit_receivers", MappingProxyType(dict(self.call_site_implicit_receivers)), ) + object.__setattr__( + self, + "call_site_candidate_callees", + MappingProxyType(dict(self.call_site_candidate_callees)), + ) object.__setattr__(self, "taint_provenance", MappingProxyType(dict(self.taint_provenance))) diff --git a/src/wardline/scanner/taint/summary.py b/src/wardline/scanner/taint/summary.py index 5641553b..81bff89b 100644 --- a/src/wardline/scanner/taint/summary.py +++ b/src/wardline/scanner/taint/summary.py @@ -60,6 +60,7 @@ def compute_cache_key( schema_version: int, resolver_version: str, provider_fingerprint: str, + scan_policy_hash: str, ) -> str: """Content-addressed cache key for a module's summaries. @@ -72,6 +73,16 @@ def compute_cache_key( is what is added here: cross-module *dependency* changes are still handled by always recomputing the call graph fresh, never by this key. + Binds ``scan_policy_hash`` — the ``attest.ruleset_hash`` over the effective scan + policy — because seed-shaping config (``untrusted_sources`` declares extra + EXTERNAL_RAW sources, ``sanitisers``, ``provenance_clash``) changes a function's + computed summary without changing its source bytes. Omitting it let a warm/persisted + cache serve a stale-CLEAN summary when the policy newly named a source, suppressing a + real defect (wardline-9d6a81b9e7). Reusing attest's *single* policy identity (rather + than enumerating fields here) keeps the cache key and the attestation hash from + diverging; it is intentionally conservative (a severity/exclude change that cannot + alter a summary still invalidates — more recompute, never a stale serve). + Each component is length-prefixed before hashing so distinct inputs cannot collide (without it, ``(b"ab", "c")`` and ``(b"a", "bc")`` would hash alike). CRLF in ``source_bytes`` is rejected so Linux/Windows checkouts of the same @@ -85,6 +96,7 @@ def compute_cache_key( _write_len_prefixed(hasher, str(schema_version).encode("ascii")) _write_len_prefixed(hasher, resolver_version.encode("utf-8")) _write_len_prefixed(hasher, provider_fingerprint.encode("utf-8")) + _write_len_prefixed(hasher, scan_policy_hash.encode("utf-8")) return hasher.hexdigest() diff --git a/src/wardline/scanner/taint/summary_cache.py b/src/wardline/scanner/taint/summary_cache.py index 4bc2e7e8..a894bf8e 100644 --- a/src/wardline/scanner/taint/summary_cache.py +++ b/src/wardline/scanner/taint/summary_cache.py @@ -1,17 +1,20 @@ # src/wardline/scanner/taint/summary_cache.py -"""In-memory (+ optional disk) summary cache for L3 project-scope transitive taint. +"""In-memory (+ authenticated optional disk) summary cache for L3 project-scope transitive taint. Keyed on the SP1d module ``cache_key`` (every FunctionSummary in a module shares one key), so the store maps ``cache_key -> tuple[FunctionSummary, ...]``. -Disk persistence: construct with ``cache_dir=Path(...)`` then call ``load()`` -before analysis and ``save()`` after. Malformed / stale-schema / non-hex-stem -files are silently dropped on load (cold-cache fallback). Atomic write-then- -replace so a crash leaves the prior file or none — never a partial file. +Disk persistence: construct with ``cache_dir=Path(...)`` and a process-controlled +``cache_auth_secret`` then call ``load()`` before analysis and ``save()`` after. +Malformed / stale-schema / non-hex-stem / unauthenticated files are silently +dropped on load (cold-cache fallback). Atomic write-then-replace so a crash +leaves the prior file or none — never a partial file. -No governance: ``.old``'s CI-attestation / GOVERNANCE-CACHE-UNATTESTED path is -discarded outright. The 64-char hex key validation is retained: it is cheap and -guards the persistent-cache file path against traversal. +No repository governance: ``.old``'s CI-attestation / GOVERNANCE-CACHE-UNATTESTED +path is discarded outright. The 64-char hex key validation is retained: it is +cheap and guards the persistent-cache file path against traversal. Disk cache +files are integrity-checked with an operator-held HMAC key before they can +rehydrate summaries, so repository-controlled JSON cannot become analyzer truth. Correctness note: this cache memoizes only the source-determined taint contract (body/return/source), which ``cache_key`` fully captures. The resolver always @@ -22,11 +25,14 @@ from __future__ import annotations import contextlib +import hashlib +import hmac import json import logging import os import re import tempfile +from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, ClassVar, cast @@ -41,6 +47,11 @@ _logger = logging.getLogger(__name__) +SUMMARY_CACHE_KEY_ENV = "WARDLINE_SUMMARY_CACHE_KEY" +"""Process environment key used to authenticate disk summary-cache entries.""" + +_CACHE_FILE_SCHEMA_VERSION = 1 + # The full reachable taint set for an analyzed function's cached body/return # taint. Unlike the stdlib table, a cached summary CAN be INTEGRAL (a @trusted # function produces INTEGRAL), so INTEGRAL is legal here. What must never be @@ -93,16 +104,21 @@ class SummaryCache: # its directory via a crafted key. _CACHE_KEY_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$") - def __init__(self, *, cache_dir: Path | None = None) -> None: + def __init__(self, *, cache_dir: Path | None = None, cache_auth_secret: bytes | None = None) -> None: self._entries: dict[str, tuple[FunctionSummary, ...]] = {} self._hits: int = 0 self._misses: int = 0 self._cache_dir: Path | None = cache_dir + self._cache_auth_secret: bytes | None = cache_auth_secret @property def cache_dir(self) -> Path | None: return self._cache_dir + @property + def has_disk_auth(self) -> bool: + return self._cache_auth_secret is not None + @property def schema_version(self) -> int: return SUMMARY_SCHEMA_VERSION @@ -191,14 +207,19 @@ def save(self) -> None: """Atomically write every in-memory entry to ``/.json``. Write-temp-then-os.replace, so a crash leaves the prior file or none — - never a partial file. Raises ValueError if no cache_dir was set. + never a partial file. Raises ValueError if no cache_dir was set. Without + a ``cache_auth_secret``, disk persistence is disabled and save is a no-op + after ensuring the directory exists. """ if self._cache_dir is None: raise ValueError("SummaryCache.save() requires cache_dir") self._cache_dir.mkdir(parents=True, exist_ok=True) + if self._cache_auth_secret is None: + return for cache_key, summaries in self._entries.items(): target = self._cache_dir / f"{cache_key}.json" - payload = [_serialise_summary(s) for s in summaries] + payload = _cache_payload(cache_key, summaries) + payload["mac"] = _cache_payload_mac(self._cache_auth_secret, payload) # Opened outside a `with` so temp_path is known for cleanup-on-failure; # the `with tf:` below is the actual context manager. (SIM115) tf = tempfile.NamedTemporaryFile( # noqa: SIM115 @@ -211,7 +232,7 @@ def save(self) -> None: temp_path = Path(tf.name) try: with tf: - json.dump(payload, tf) + json.dump(payload, tf, sort_keys=True, separators=(",", ":")) os.replace(temp_path, target) except (OSError, ValueError, TypeError): # Clean up the temp file on ANY failure (dump or replace) so the @@ -221,12 +242,18 @@ def save(self) -> None: raise def load(self) -> None: - """Populate the store from ``/*.json``. Malformed / stale / - non-hex-stem files are silently dropped (cold-cache fallback). Raises - ValueError if no cache_dir was set.""" + """Populate the store from ``/*.json``. + + Malformed / stale / non-hex-stem / unauthenticated files are silently + dropped (cold-cache fallback). Raises ValueError if no cache_dir was set. + Without a ``cache_auth_secret``, disk loading is disabled and load is a + no-op after ensuring the directory exists. + """ if self._cache_dir is None: raise ValueError("SummaryCache.load() requires cache_dir") self._cache_dir.mkdir(parents=True, exist_ok=True) + if self._cache_auth_secret is None: + return for path in sorted(self._cache_dir.iterdir()): if path.suffix != ".json": continue @@ -235,7 +262,11 @@ def load(self) -> None: continue try: payload = json.loads(path.read_text(encoding="utf-8")) - summaries = tuple(_deserialise_summary(d) for d in payload) + summaries = _deserialise_cache_payload( + payload, + expected_cache_key=cache_key, + cache_auth_secret=self._cache_auth_secret, + ) _validate_loaded_entry(cache_key, summaries) except (json.JSONDecodeError, ValueError, KeyError, TypeError) as exc: _logger.warning("SummaryCache.load: dropping malformed entry %s: %s", path, exc) @@ -250,6 +281,13 @@ def load(self) -> None: self._entries[cache_key] = summaries +def summary_cache_auth_secret_from_env() -> bytes | None: + value = os.environ.get(SUMMARY_CACHE_KEY_ENV) + if value is None or value == "": + return None + return value.encode("utf-8") + + def _validate_loaded_entry(cache_key: str, summaries: tuple[FunctionSummary, ...]) -> None: mismatched = [s.fqn for s in summaries if s.cache_key != cache_key] if mismatched: @@ -283,3 +321,43 @@ def _deserialise_summary(d: dict[str, object]) -> FunctionSummary: schema_version=int(cast("int", d["schema_version"])), cache_key=str(d["cache_key"]), ) + + +def _cache_payload(cache_key: str, summaries: tuple[FunctionSummary, ...]) -> dict[str, object]: + return { + "schema_version": _CACHE_FILE_SCHEMA_VERSION, + "cache_key": cache_key, + "summaries": [_serialise_summary(s) for s in summaries], + } + + +def _cache_payload_mac(cache_auth_secret: bytes, payload: Mapping[str, object]) -> str: + payload_without_mac = {key: value for key, value in payload.items() if key != "mac"} + encoded = json.dumps(payload_without_mac, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return hmac.new(cache_auth_secret, encoded, hashlib.sha256).hexdigest() + + +def _deserialise_cache_payload( + payload: object, + *, + expected_cache_key: str, + cache_auth_secret: bytes, +) -> tuple[FunctionSummary, ...]: + if not isinstance(payload, dict): + raise ValueError("cache entry is not an authenticated envelope") + mac = payload.get("mac") + if not isinstance(mac, str): + raise ValueError("cache entry missing mac") + expected_mac = _cache_payload_mac(cache_auth_secret, cast("Mapping[str, object]", payload)) + if not hmac.compare_digest(mac, expected_mac): + raise ValueError("cache entry mac mismatch") + if payload.get("schema_version") != _CACHE_FILE_SCHEMA_VERSION: + raise ValueError(f"cache file schema_version={payload.get('schema_version')!r} != {_CACHE_FILE_SCHEMA_VERSION}") + if payload.get("cache_key") != expected_cache_key: + raise ValueError( + f"envelope cache_key={payload.get('cache_key')!r} does not match filename key {expected_cache_key!r}" + ) + raw_summaries = payload.get("summaries") + if not isinstance(raw_summaries, list): + raise ValueError("cache entry summaries must be a list") + return tuple(_deserialise_summary(cast("dict[str, object]", item)) for item in raw_summaries) diff --git a/src/wardline/scanner/taint/variable_level.py b/src/wardline/scanner/taint/variable_level.py index 25b8dbd2..056c083c 100644 --- a/src/wardline/scanner/taint/variable_level.py +++ b/src/wardline/scanner/taint/variable_level.py @@ -3,8 +3,11 @@ Given a function AST node and its Level 1 (function-level) taint, walks the body tracking taint per variable through assignments, control-flow joins, and call -sites. Pure (returns a new dict); conservative (unknown expressions inherit the -function's L1 taint). Both expression/value combiners (BinOp, IfExp, BoolOp, +sites. Pure (returns a new dict); conservative (an unknown NON-CALL expression +inherits the function's L1 taint; an unresolved bare-name CALL propagates the +worst of the caller seed and its argument taints, so a trusted seed cannot +launder a raw argument through an unmodeled callee). Both expression/value +combiners (BinOp, IfExp, BoolOp, containers, ``.get`` defaults, ``+=``, container writes) AND control-flow MERGES (if/else, loop back-edges, match arms, try/except handlers) use the rank-meet ``least_trusted`` (weakest-link): at a merge a variable holds the value of ONE @@ -23,10 +26,11 @@ import ast import contextvars +from contextlib import contextmanager from dataclasses import dataclass from typing import TYPE_CHECKING -from wardline.core.taints import _PROVENANCE_CLASH, TRUST_RANK, TaintState, combine +from wardline.core.taints import _PROVENANCE_CLASH, RAW_ZONE, TRUST_RANK, TaintState, combine if TYPE_CHECKING: from collections.abc import Iterator @@ -64,22 +68,91 @@ } ) -# Curated taint-PROPAGATING builtins. A small explicit table (NOT a general rule -# — bare unknown calls still fall back to function_taint, so len/int/validate stay -# unaffected and there is no false-positive explosion). These return the join of -# their argument taints: a string conversion / iterator advance carries whatever -# taint went in. ``next`` closes the ``next(genexp)`` shape (the iterator arg). -_PROPAGATING_BUILTINS: frozenset[str] = frozenset({"str", "repr", "ascii", "bytes", "bytearray", "format", "next"}) +# Curated taint-PROPAGATING builtins. These return the join of their argument +# taints (no args → INTEGRAL): a string/container conversion or iterator advance +# carries whatever taint went in. ``next`` closes the ``next(genexp)`` shape (the +# iterator arg); the container conversions (list/tuple/set/frozenset/dict/sorted/ +# reversed) return a container holding the SAME untrusted elements, so omitting +# them laundered ``subprocess.run(sorted(raw))`` — the idiomatic argv shape. +_PROPAGATING_BUILTINS: frozenset[str] = frozenset( + { + "str", + "repr", + "ascii", + "bytes", + "bytearray", + "format", + "next", + "list", + "tuple", + "set", + "frozenset", + "dict", + "sorted", + "reversed", + } +) + +# Curated NON-propagating builtins: validators/measurers whose result is derived +# FROM the data but does not carry it (a length, a parsed number, a predicate). +# These keep the caller-seed fallback; every OTHER unresolved bare-name call +# propagates the worst of (caller seed, argument taints) — an unknown callee +# cannot be assumed to clean a raw argument (see _resolve_call's bare-name +# fallback; matches the imported-but-unmodeled path's conservatism). +_NON_PROPAGATING_BUILTINS: frozenset[str] = frozenset( + { + "len", + "int", + "float", + "bool", + "complex", + "ord", + "hash", + "id", + "abs", + "round", + "isinstance", + "issubclass", + "callable", + "hasattr", + "any", + "all", + } +) -# Curated taint-PROPAGATING methods, keyed by attribute name. ``.format``/``.join`` -# combine the receiver with the arguments (``"sep".join(parts)`` carries both); +# Curated taint-PROPAGATING methods, keyed by attribute name. ``.format``/ +# ``.format_map``/``.join`` combine the receiver with the arguments +# (``"sep".join(parts)`` carries both; ``.format_map(mapping)`` embeds the +# mapping's values exactly like ``.format``); # ``.get``/``.pop``/``.setdefault`` carry the RECEIVER's taint — a container access # is the same shape as the ``Subscript`` read handler (``d['k']`` propagating while # ``d.get('k')`` did not was an inconsistency, not a feature). These do NOT add a # new SOURCE: ``.get`` returns the container's existing taint, nothing more. -_PROPAGATING_METHODS_WITH_ARGS: frozenset[str] = frozenset({"format", "join"}) +_PROPAGATING_METHODS_WITH_ARGS: frozenset[str] = frozenset({"format", "format_map", "join"}) _PROPAGATING_METHODS_RECEIVER: frozenset[str] = frozenset({"get", "pop", "setdefault"}) +# Curated DB-API storage-read methods. A ``cursor.fetchone/fetchall/fetchmany()`` loads +# stored/external data the same way ``open()``/``Path.read_text()`` do, so it seeds +# ``EXTERNAL_RAW`` — without this the PY-WL-120 fetch* matcher was a dead branch (the +# RAW_ZONE gate was unsatisfiable: the result was never seeded raw — wardline-e7c7cda31a). +# Scoped to the three DBAPI-specific names (near-zero collision); bare ``.read`` is +# deliberately NOT seeded (BytesIO/response/buffer ``.read()`` would over-fire, and the +# file case already fires via receiver propagation below). +# Residual FP (accepted, documented): a receiver whose type is NOT statically resolvable +# (a bare param, a chained ``.query(...).fetchall()``) and is NOT a DB cursor but has a +# coincidental fetch* method (SQLAlchemy Result, custom paginators) is seeded raw. A +# project-typed receiver is shadowed by the var_types/taint_map lookups above (they run +# first), so the common case is correct; the residual cuts toward soundness. +_STORAGE_READ_METHODS: frozenset[str] = frozenset({"fetchone", "fetchall", "fetchmany"}) + +# Curated in-place container MUTATORS. Calling one with a tainted argument contaminates +# the RECEIVER (``box.append(raw)`` makes ``box`` carry ``raw``'s taint), mirroring the +# container-literal ``box = [raw]`` which already taints ``box``. Without this, receiver +# mutation was unmodelled — the mutator form silently dropped the taint the literal form +# kept (wardline-67c7498931). A curated set (NOT a general "any attribute call mutates its +# receiver" rule, which would over-fire on ``.strip()``/``.lower()``/in-place validators). +_RECEIVER_MUTATING_METHODS: frozenset[str] = frozenset({"append", "add", "extend", "update", "insert"}) + _CURRENT_ALIAS_MAP: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar( "_CURRENT_ALIAS_MAP", default=None ) @@ -88,14 +161,57 @@ contextvars.ContextVar("_CURRENT_CALL_SITE_ARG_TAINTS", default=None) ) -_CURRENT_VAR_TYPES: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar( +# Maps a local name to the CANDIDATE SET of class/type FQNs it MAY hold. A single +# slot per name lost a receiver class rebound in one branch arm only: the write +# ``box.token = raw`` after ``box = Vault(); if flag: box = Ledger()`` attributed +# only to Ledger while on the no-flag path the receiver is still the Vault +# instance (wardline-b369c7d06c). Straight-line rebind is a STRONG update (the +# set is replaced); a branch join UNIONS the arms' sets. ``list`` not ``set`` for +# deterministic iteration order (mirrors _CURRENT_LAMBDA_BINDINGS). +_CURRENT_VAR_TYPES: contextvars.ContextVar[dict[str, list[str]] | None] = contextvars.ContextVar( "_CURRENT_VAR_TYPES", default=None ) -_CURRENT_LAMBDA_BINDINGS: contextvars.ContextVar[dict[str, ast.Lambda] | None] = contextvars.ContextVar( +# Side channel for attribute-write recording DURING the main statement walk — +# ``{receiver_key: {attr: least_trusted write taint}}`` where receiver_key is a +# class FQN candidate (from _CURRENT_VAR_TYPES) or SELF_ATTRIBUTE_KEY for the +# implicit ``self``/``cls`` receiver. Recording at the write statement resolves +# the RHS against the CURRENT per-statement var_taints — a later reassignment of +# the RHS variable can no longer launder the recorded taint, which the post-hoc +# final-state second walk allowed (wardline-b369c7d06c). +_CURRENT_ATTR_WRITES: contextvars.ContextVar[dict[str, dict[str, TaintState]] | None] = contextvars.ContextVar( + "_CURRENT_ATTR_WRITES", default=None +) + +# Recording key for writes through the implicit instance/class receiver +# (``self.x = ...`` / ``cls.x = ...``). Not a valid dotted FQN, so it can never +# collide with a class-candidate key. +SELF_ATTRIBUTE_KEY = "" + +# Poison candidate marking a receiver-type set where SOME branch arm rebound the +# name to an untypeable value (see :func:`_merge_branch_types`). Not a valid +# dotted FQN, so it never resolves in any taint map — its presence makes the +# typed method dispatch's all-candidates completeness check fail, forcing the +# conservative generic resolution on the maybe-untyped path. +UNTYPED_ARM_CANDIDATE = "" + +# Maps a local name to the CANDIDATE SET of lambda bodies it MAY hold. A single +# slot per name would lose a sink-lambda bound in a non-last branch arm when a later +# arm rebinds the same name (wardline-383f83fafe): only one survived the merge, so a +# post-branch ``cb(raw)`` resolved the wrong body and missed the sink. Within a linear +# scope a name holds exactly one lambda (a rebind REPLACES the list); the set grows +# only across mutually-exclusive branch arms at the merge, where the name MAY be any of +# them. Calls resolve against EVERY candidate (sound over-approximation: an extra body +# only records arg-taints, it never masks a sink). ``list`` not ``set`` — ast nodes are +# id-hashed, so set iteration is non-deterministic and would destabilise the golden corpora. +_CURRENT_LAMBDA_BINDINGS: contextvars.ContextVar[dict[str, list[ast.Lambda]] | None] = contextvars.ContextVar( "_CURRENT_LAMBDA_BINDINGS", default=None ) +_CURRENT_ACTIVE_LAMBDAS: contextvars.ContextVar[set[int] | None] = contextvars.ContextVar( + "_CURRENT_ACTIVE_LAMBDAS", default=None +) + _CURRENT_MODULE_PREFIX: contextvars.ContextVar[str | None] = contextvars.ContextVar( "_CURRENT_MODULE_PREFIX", default=None ) @@ -109,6 +225,24 @@ } ) +# Curated WORST-ARG constructors: in-memory buffer ctors whose result holds +# exactly the data poured in. ``io.StringIO("const")`` is NOT external data — +# the unresolved-import UNKNOWN_RAW default was a documented PY-WL-101 FP on +# returning an in-memory constant read — while ``io.StringIO(raw)`` must keep +# carrying the raw content so a later ``.read()`` propagates it (the receiver +# RAW_ZONE path). No args → INTEGRAL (an empty buffer holds nothing). +_WORST_ARG_CONSTRUCTORS: frozenset[str] = frozenset({"io.StringIO", "io.BytesIO"}) + + +def _worst_arg_taint(arg_taints: list[TaintState]) -> TaintState: + """The weakest-link (least-trusted) of *arg_taints*; INTEGRAL when empty.""" + if not arg_taints: + return TaintState.INTEGRAL + result = arg_taints[0] + for at in arg_taints[1:]: + result = combine(result, at) + return result + @dataclass(frozen=True, slots=True) class VariableTaintContext: @@ -129,6 +263,71 @@ class VariableTaintResult: return_callee: str | None +@contextmanager +def attribute_write_recording(out: dict[str, dict[str, TaintState]]) -> Iterator[dict[str, dict[str, TaintState]]]: + """Record attribute writes into *out* for the duration of the block. + + Wrap around a :func:`compute_variable_taints` / L2-stage run. Every + ``. = ...`` (plain, annotated, or augmented) the statement + walk processes is recorded with its RHS resolved against the CURRENT + per-statement ``var_taints`` (branch-local inside arms), keyed by the + receiver's class-FQN candidates — or :data:`SELF_ATTRIBUTE_KEY` for + ``self``/``cls`` — and joined per attribute via :func:`combine` + (least-trusted wins). Map keys onto class qualnames with + :func:`project_attribute_writes`.""" + token = _CURRENT_ATTR_WRITES.set(out) + try: + yield out + finally: + _CURRENT_ATTR_WRITES.reset(token) + + +def project_attribute_writes( + recorded: dict[str, dict[str, TaintState]], + class_qualnames: frozenset[str], + enclosing_class: str | None, +) -> dict[str, dict[str, TaintState]]: + """Map a recording's receiver keys onto class qualnames. + + :data:`SELF_ATTRIBUTE_KEY` writes attribute to *enclosing_class* (dropped when + ``None`` — outside a method there is no instance to attribute to); candidate + keys are kept only when they name a known project class. Buckets landing on + the same class (a ``self`` write and a typed-receiver write) join per + attribute via :func:`combine`.""" + out: dict[str, dict[str, TaintState]] = {} + for key, attrs in recorded.items(): + target = enclosing_class if key == SELF_ATTRIBUTE_KEY else (key if key in class_qualnames else None) + if target is None: + continue + bucket = out.setdefault(target, {}) + for attr, taint in attrs.items(): + bucket[attr] = combine(bucket[attr], taint) if attr in bucket else taint + return out + + +def _record_attribute_write(target: ast.Attribute, rhs: TaintState) -> None: + """Record one attribute write into the active side channel (no-op when off). + + A ``self``/``cls`` receiver records under :data:`SELF_ATTRIBUTE_KEY`; a + tracked receiver records under EVERY class candidate it may hold — a write + through a branch-joined receiver is factually a write to whichever class the + taken path bound, so attributing to all candidates is the sound + over-approximation (wardline-b369c7d06c). Only a direct ``Name.attr`` target + is a class-attribute write (deeper chains would need container modelling).""" + out = _CURRENT_ATTR_WRITES.get() + if out is None or not isinstance(target.value, ast.Name): + return + receiver = target.value.id + keys: list[str] = [SELF_ATTRIBUTE_KEY] if receiver in ("self", "cls") else [] + var_types = _CURRENT_VAR_TYPES.get() + if var_types is not None: + keys.extend(var_types.get(receiver, ())) + attr = target.attr + for key in keys: + bucket = out.setdefault(key, {}) + bucket[attr] = combine(bucket[attr], rhs) if attr in bucket else rhs + + def analyze_function_variables( func_node: ast.FunctionDef | ast.AsyncFunctionDef, function_taint: TaintState, @@ -152,8 +351,31 @@ def analyze_function_variables( param_meets=context.param_meets, provenance_clash=context.provenance_clash, ) - return_taint = compute_return_taint(func_node, function_taint, dict(taint_map), variable_taints) - return_callee = compute_return_callee(func_node, function_taint, dict(taint_map), dict(variable_taints)) + return_taint = compute_return_taint( + func_node, + function_taint, + dict(taint_map), + variable_taints, + call_site_taints, + ) + # compute_return_callee is PROVENANCE-ONLY: _assignment_callee re-resolves + # every direct-call assignment RHS against the FINAL var_taints, and letting + # that re-resolution record would COMBINE post-call taint into the at-call + # snapshot (a clean-at-the-call value re-read as raw → PY-WL-105/108 FPs; + # review 2026-06-10). Suppress recording for its duration — return-position + # sink calls already recorded during compute_return_taint above, which is + # the only recording they get. + token_no_record = _CURRENT_CALL_SITE_ARG_TAINTS.set(None) + try: + return_callee = compute_return_callee( + func_node, + function_taint, + dict(taint_map), + dict(variable_taints), + call_site_taints, + ) + finally: + _CURRENT_CALL_SITE_ARG_TAINTS.reset(token_no_record) return VariableTaintResult( call_site_taints=call_site_taints, call_site_arg_taints=call_site_arg_taints, @@ -252,25 +474,87 @@ def _resolve_lambda_body_at_call( Recording the body here prevents a later clean assignment from laundering a raw value that was captured when the direct call actually ran. """ + active = _CURRENT_ACTIVE_LAMBDAS.get() + token_active = None + if active is None: + active = set() + token_active = _CURRENT_ACTIVE_LAMBDAS.set(active) + lam_id = id(lam) + if lam_id in active: + if token_active is not None: + _CURRENT_ACTIVE_LAMBDAS.reset(token_active) + return + active.add(lam_id) + try: + _resolve_lambda_body_at_call_inner(lam, function_taint, taint_map, var_taints, pos_taints, kw_taints) + finally: + active.discard(lam_id) + if token_active is not None: + _CURRENT_ACTIVE_LAMBDAS.reset(token_active) + + +def _resolve_lambda_body_at_call_inner( + lam: ast.Lambda, + function_taint: TaintState, + taint_map: dict[str, TaintState], + var_taints: dict[str, TaintState], + pos_taints: list[TaintState], + kw_taints: dict[str | None, TaintState], +) -> None: scope = dict(var_taints) args = lam.args + # A ``**mapping`` spread arrives as a keyword with ``arg is None``. Its keys + # are unknown statically, so it MAY bind ANY named parameter not otherwise + # supplied (and lands in ``**kw``) — the unmatched-parameter fallback is the + # spread's taint when one is present, else the neutral seed + # (wardline-93d608c997: ``(lambda **kw: ...)(**raw)`` previously laundered). + spread_taint = kw_taints.get(None) + unmatched_seed = spread_taint if spread_taint is not None else function_taint positional_params = [*args.posonlyargs, *args.args] + # A parameter OMITTED at the call site evaluates its DEFAULT, so the default + # expression's taint seeds the param (``cb = lambda x=raw: os.system(x); cb()`` + # must fire — the default previously fell back to the neutral seed, a fail-open + # FN). Defaults resolve against the pristine enclosing scope (def-time + # semantics, before any param binding shadows the names). When a ``**spread`` + # is present the param MAY be bound by it instead — either/or, so the two + # alternatives combine (weakest-link). A param actually SUPPLIED at the call + # site never evaluates its default, so the matched paths below are untouched + # (no FP: ``cb("clean")`` / ``cb(x="clean")`` stay clean). + default_taints: dict[str, TaintState] = {} + defaulted_params = positional_params[len(positional_params) - len(args.defaults) :] + for param, default in zip(defaulted_params, args.defaults, strict=True): + default_taints[param.arg] = _resolve_expr(default, function_taint, taint_map, scope) + for param, kw_default in zip(args.kwonlyargs, args.kw_defaults, strict=True): + if kw_default is not None: + default_taints[param.arg] = _resolve_expr(kw_default, function_taint, taint_map, scope) + + def _omitted_seed(param_name: str) -> TaintState: + default = default_taints.get(param_name) + if default is None: + return unmatched_seed + return combine(default, spread_taint) if spread_taint is not None else default + for param, taint in zip(positional_params, pos_taints, strict=False): scope[param.arg] = taint for param in positional_params[len(pos_taints) :]: - scope[param.arg] = function_taint + # A param supplied by KEYWORD below keeps the neutral unmatched seed here + # (its default never evaluates); the keyword taint combines in afterwards. + scope[param.arg] = unmatched_seed if param.arg in kw_taints else _omitted_seed(param.arg) if args.vararg is not None: extra = pos_taints[len(positional_params) :] scope[args.vararg.arg] = extra[0] if extra else function_taint for taint in extra[1:]: scope[args.vararg.arg] = combine(scope[args.vararg.arg], taint) for param in args.kwonlyargs: - scope[param.arg] = kw_taints.get(param.arg, function_taint) + kw_hit = kw_taints.get(param.arg) + scope[param.arg] = kw_hit if kw_hit is not None else _omitted_seed(param.arg) for param in positional_params: if param.arg in kw_taints: scope[param.arg] = combine(scope.get(param.arg, function_taint), kw_taints[param.arg]) if args.kwarg is not None: - keyword_values = [taint for name, taint in kw_taints.items() if name is not None] + # Every keyword value — named (the over-approximation: non-param names + # land in ``**kw``) AND the ``**spread`` itself — feeds the kwarg dict. + keyword_values = list(kw_taints.values()) scope[args.kwarg.arg] = keyword_values[0] if keyword_values else function_taint for taint in keyword_values[1:]: scope[args.kwarg.arg] = combine(scope[args.kwarg.arg], taint) @@ -297,9 +581,12 @@ def compute_variable_taints( taint_map: call-resolution map keyed by the call-site name AS WRITTEN — bare (``"foo"``) for ``foo()``, dotted (``"mod.fn"``) for ``mod.fn()`` — mapping to that call's return taint. Bare calls whose - name is absent fall back to ``function_taint``; imported-but-unmodeled - calls resolve to ``UNKNOWN_RAW`` so external code cannot inherit a - trusted caller seed. + name is absent resolve to the WORST of ``function_taint`` and their + argument taints (an unknown callee cannot be assumed to clean a raw + argument — only the curated ``_NON_PROPAGATING_BUILTINS`` validators/ + measurers keep the plain ``function_taint`` fallback); + imported-but-unmodeled calls resolve to ``UNKNOWN_RAW`` so external + code cannot inherit a trusted caller seed. call_site_taints: optional out-dict for FLOW-SENSITIVE reads. When given, records ``{id(stmt): snapshot}`` — the per-variable taint map AS IT IS on entry to each statement (in branches, the branch-local copy). A sink @@ -395,7 +682,7 @@ def handle_arg(arg: ast.arg) -> None: if arg.annotation and var_types is not None: fqn = _resolve_expr_fqn(arg.annotation, alias_map) if fqn: - var_types[arg.arg] = fqn + var_types[arg.arg] = [fqn] for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs): handle_arg(arg) @@ -415,6 +702,18 @@ def _dotted_name(node: ast.expr) -> str | None: return None +def _call_root_name(func: ast.expr) -> str | None: + """Root receiver/callee name of a call's ``func``: for an attribute call + ``X.Y.method()`` the chain root ``X``; for a bare call ``foo()`` the name + ``foo``. ``None`` when the root is not a plain Name (e.g. a subscript or call + receiver). Used to detect a raw local/parameter shadowing a module/import name + (wardline-f6a29ce23a).""" + cur = func + while isinstance(cur, ast.Attribute): + cur = cur.value + return cur.id if isinstance(cur, ast.Name) else None + + def _resolve_expr( node: ast.expr, function_taint: TaintState, @@ -461,7 +760,25 @@ def _resolve_expr( # (``(x := ...)``), so the False branch is unreachable by any parseable source. if isinstance(node.target, ast.Name): # pragma: no branch var_taints[node.target.id] = taint + # A walrus REBINDS the name in the enclosing scope, so the receiver-type + # candidate must follow: a ctor RHS is a typed strong update, anything + # else invalidates the stale candidate — leaving it let a clean @trusted + # method summary resolve on a rebound raw receiver (review 2026-06-10, + # the _assign_target/NamedExpr stale-type launder). + _update_var_type(node.target.id, node.value) return taint + if isinstance(node, ast.Compare): + # Resolve the left operand and every comparator so calls nested in a + # comparison position record their flow-sensitive arg-taint snapshots + # (``if store(read_raw(p)) == 1:`` previously never resolved, so + # PY-WL-105 missed it and constant-arg sink calls degraded to the + # pessimistic UNKNOWN_RAW fallback). The comparison RESULT is a bool — + # derived from the data but not carrying it — so it is INTEGRAL, the + # same posture as the curated validator/measurer builtins. + _resolve_expr(node.left, function_taint, taint_map, var_taints) + for comparator in node.comparators: + _resolve_expr(comparator, function_taint, taint_map, var_taints) + return TaintState.INTEGRAL if isinstance(node, ast.IfExp): # ``a if c else b`` evaluates to ONE of its arms — combine via the rank-meet # least_trusted (weakest-link), so two clean arms stay clean (no MIXED_RAW @@ -487,7 +804,16 @@ def _resolve_expr( # value path; the call-receiver path is handled in _resolve_call). dotted = _dotted_name(node) if dotted is not None and dotted in taint_map: - return taint_map[dotted] + # Same shadow guard as the call path (wardline-f6a29ce23a): a raw local + # shadowing a module name, read as ``cfg.attr``, must not inherit the + # module entry's clean taint. ``self``/``cls`` are excluded so the + # cross-method summary above is preserved even when ``self`` is raw, and a + # genuine module read (root not tracked in ``var_taints``) is untouched. + # No typed-object FP analog exists here — an instance read ``h.attr`` is + # never minted into ``taint_map`` (only module-/self-rooted keys are). + root = _call_root_name(node) + if not (root is not None and root not in ("self", "cls") and var_taints.get(root) in RAW_ZONE): + return taint_map[dotted] return _resolve_expr(node.value, function_taint, taint_map, var_taints) if isinstance(node, ast.Await): # Unwrap — the inner expression (typically a Call) is already handled. @@ -580,7 +906,9 @@ def _resolve_comprehension( local = dict(var_taints) for gen in node.generators: iter_t = _resolve_expr(gen.iter, function_taint, taint_map, local) - _assign_target(gen.target, iter_t, local) + # Comprehension targets bind a comprehension-LOCAL scope (Py3) — they must + # not invalidate the enclosing name's receiver-type candidate. + _assign_target(gen.target, iter_t, local, invalidate_types=False) for cond in gen.ifs: # Resolve conditions for walrus side-effects (PEP 572 → enclosing scope, # leaked back below). @@ -657,14 +985,18 @@ def _resolve_call( lambda_bindings = _CURRENT_LAMBDA_BINDINGS.get() if isinstance(node.func, ast.Name) and lambda_bindings is not None and node.func.id in lambda_bindings: - _resolve_lambda_body_at_call( - lambda_bindings[node.func.id], - function_taint, - taint_map, - var_taints, - pos_taints, - kw_arg_taints, - ) + # Resolve against EVERY candidate body the name MAY hold (one per linear scope; + # several only after a branch merge). Each records its own sink calls' arg-taints + # — distinct AST nodes, so the recordings never collide (wardline-383f83fafe). + for lam in lambda_bindings[node.func.id]: + _resolve_lambda_body_at_call( + lam, + function_taint, + taint_map, + var_taints, + pos_taints, + kw_arg_taints, + ) elif isinstance(node.func, ast.Lambda): _resolve_lambda_body_at_call( node.func, @@ -675,6 +1007,28 @@ def _resolve_call( kw_arg_taints, ) + # A dotted/imported/bare ``taint_map`` entry always describes a MODULE-LEVEL + # symbol (``mod.fn`` / from-imported func / config sanitiser / ``Type.method`` — + # every key minted by ``build_call_taint_map`` is rooted in an import alias or a + # top-level/bare callable, never a runtime object). When the call's RECEIVER or + # CALLEE name is instead a tracked LOCAL or PARAMETER holding RAW data, that local + # SHADOWS the module/import name: the clean entry does NOT describe the raw object, + # so returning it would launder the raw taint (wardline-f6a29ce23a). The + # discriminator is membership in ``var_taints`` (a real import is never tracked + # there; an assigned local / parameter is) plus the RAW_ZONE check, which limits + # suppression to the only unsound case — a genuine module sanitiser (root not in + # ``var_taints``, or tracked-but-clean) is untouched. The chain ROOT is used so a + # chained receiver (``a.b.method()``, root ``a``) is covered, not only one-level + # ``a.method()``. ``self``/``cls`` are EXCLUDED: their dotted keys are + # analyzer-injected cross-method summaries (analyzer.py), not module shadows, and + # must still be read even when the method's ``self`` seed is raw. The early + # ``taint_map`` short-circuits below defer to the RAW_ZONE receiver guard (and the + # bare-call path returns the raw callee taint) when this is set. + _call_root = _call_root_name(node.func) + root_shadows_raw_local = ( + _call_root is not None and _call_root not in ("self", "cls") and var_taints.get(_call_root) in RAW_ZONE + ) + alias_map = _CURRENT_ALIAS_MAP.get() imported_fqn: str | None = None if alias_map is not None: @@ -682,7 +1036,7 @@ def _resolve_call( module_prefix = _CURRENT_MODULE_PREFIX.get() or "" imported_fqn = resolve_call_fqn(node, alias_map, frozenset(taint_map.keys()), module_prefix) - if imported_fqn in _CONTEXT_ENCODERS: + if imported_fqn in _CONTEXT_ENCODERS and not root_shadows_raw_local: if not arg_taints: return TaintState.GUARDED result = arg_taints[0] @@ -691,8 +1045,13 @@ def _resolve_call( return combine(result, TaintState.GUARDED) if imported_fqn in _SERIALISATION_SINKS: return TaintState.UNKNOWN_RAW - if imported_fqn in taint_map: + if imported_fqn in taint_map and not root_shadows_raw_local: return taint_map[imported_fqn] + if imported_fqn in _WORST_ARG_CONSTRUCTORS and not root_shadows_raw_local: + # In-memory buffer ctor: result = worst arg taint (INTEGRAL when + # empty). Gated on the shadow guard — a raw local shadowing ``io`` + # must not launder through the clean ctor model. + return _worst_arg_taint(arg_taints) if isinstance(node.func, ast.Attribute): dotted = _dotted_name(node.func) @@ -703,17 +1062,75 @@ def _resolve_call( if dotted in _SERIALISATION_SINKS: return TaintState.UNKNOWN_RAW taint_hit = taint_map.get(dotted) - if taint_hit is not None: + if taint_hit is not None and not root_shadows_raw_local: + # ``not root_shadows_raw_local``: a raw local/param shadowing the + # module name must not inherit the module entry (see above). return taint_hit + if dotted in _WORST_ARG_CONSTRUCTORS and not root_shadows_raw_local: + # Dotted form without an alias-map resolution (degraded caller): + # same worst-arg in-memory buffer ctor model as the imported_fqn + # path above, behind the same shadow guard. + return _worst_arg_taint(arg_taints) if isinstance(node.func.value, ast.Name): var_types = _CURRENT_VAR_TYPES.get() if var_types is not None and node.func.value.id in var_types: - type_prefix = var_types[node.func.value.id] - resolved_dotted = f"{type_prefix}.{node.func.attr}" - taint_hit = taint_map.get(resolved_dotted) - if taint_hit is not None: - return taint_hit + # This path is reached ONLY for a receiver with a TRACKED TYPE + # (annotation or ``x = Type()`` constructor) — never a module shadow + # (a raw ``cfg = read_raw(p)`` carries no tracked type). It is + # deliberately NOT gated by ``root_shadows_raw_local``: a legitimately + # typed object routinely has a RAW-ZONE value taint (an unmodeled + # ``Type()`` constructor defaults to ``UNKNOWN_RAW``), and that object + # must still resolve ``Type.method`` via its type — gating on the + # whole RAW_ZONE false-positives the ``h = Helper(); h.get_assured()`` + # pattern (test_helper_method_returning_assured_does_not_false_positive). + # A DECLARED-raw receiver (EXTERNAL_RAW/MIXED_RAW — a boundary-seeded + # typed parameter, not a constructor default) is the launder case the + # provenance distinguishes: its clean ``Type.method`` summary describes + # a trustworthy instance, not the attacker-controlled object actually + # flowing in, so the receiver's own taint wins (wardline-03c8805449; + # closes the residual previously accepted under wardline-f6a29ce23a). + # The receiver may hold SEVERAL class candidates (branch-joined set, + # wardline-b369c7d06c): dispatch returns the candidates' combined + # summaries (least-trusted wins) only when EVERY candidate resolves; + # a partial hit falls through to the conservative generic handling + # (an unresolved candidate has no summary to honour, and the + # fall-through never launders — FN-safe). + candidates = var_types[node.func.value.id] + hits = [ + taint_map[resolved] + for prefix in candidates + if (resolved := f"{prefix}.{node.func.attr}") in taint_map + ] + if hits and len(hits) == len(candidates): + receiver_value = var_taints.get(node.func.value.id) + if receiver_value in (TaintState.EXTERNAL_RAW, TaintState.MIXED_RAW): + return receiver_value + result = hits[0] + for hit in hits[1:]: + result = combine(result, hit) + return result attr = node.func.attr + if attr in _STORAGE_READ_METHODS: + # DB-cursor fetch loads stored/external data → seed EXTERNAL_RAW, like a file + # read (wardline-e7c7cda31a). Placed AFTER the taint_map / var_types-resolved + # lookups above so a project-summarised ``self.fetchall``/typed receiver still + # wins; ahead of the generic propagating-method handling. + return TaintState.EXTERNAL_RAW + if attr in _RECEIVER_MUTATING_METHODS: + # In-place container mutation: write the worst (least-trusted) CONTENT-argument + # taint back onto the receiver variable, so a later read of the container sees + # it (wardline-67c7498931). Do NOT return — a mutator evaluates to None and the + # call's own value is discarded; let control fall through unchanged. + # ``list.insert(index, value)`` is the one outlier whose FIRST positional arg is + # an index (position metadata, not stored content), so a tainted index must not + # contaminate the container — skip it (panel finding wardline-67c7498931). + content_taints = (pos_taints[1:] if attr == "insert" else pos_taints) + kw_taints + base = _container_base_name(node.func.value) + if base is not None and content_taints: + worst = content_taints[0] + for at in content_taints[1:]: + worst = combine(worst, at) + var_taints[base] = combine(var_taints.get(base, function_taint), worst) if attr in _PROPAGATING_METHODS_WITH_ARGS: # ``.format``/``.join`` are string-BUILDING value flows: combine the # receiver with the args via the rank-meet least_trusted (weakest-link), @@ -744,6 +1161,12 @@ def _resolve_call( result = combine(result, default_taint) return result if isinstance(node.func, ast.Name): + if root_shadows_raw_local: + # ``foo()`` where ``foo`` is a raw local/param shadowing a clean import: + # calling raw data yields raw, never the import's clean ``taint_map`` + # entry (wardline-f6a29ce23a). ``var_taints`` holds ``foo`` (the root + # check passed), so the lookup is safe. + return var_taints[node.func.id] try: return taint_map[node.func.id] except KeyError: @@ -760,8 +1183,6 @@ def _resolve_call( result = combine(result, at) return result if isinstance(node.func, ast.Attribute): - from wardline.core.taints import RAW_ZONE - receiver_taint = _resolve_expr(node.func.value, function_taint, taint_map, var_taints) if receiver_taint in RAW_ZONE: return receiver_taint @@ -769,6 +1190,18 @@ def _resolve_call( # An imported call that was not modeled by project summaries, stdlib_taint, # config, sink handling, or propagation rules returns data we cannot prove. return TaintState.UNKNOWN_RAW + if isinstance(node.func, ast.Name) and node.func.id not in _NON_PROPAGATING_BUILTINS: + # Unresolved bare-name call (an unannotated parameter, a runtime-bound + # callable): the callee is unknown, so its result is the WORST of the + # caller seed and the argument taints — an unknown callee cannot be + # assumed to clean a raw argument (``os.system(transform(raw))`` with a + # bare-param ``transform`` previously inherited the trusted seed — + # wardline-93d608c997). Mirrors the imported-but-unmodeled conservatism; + # the curated validator/measurer builtins above keep the plain seed. + result = function_taint + for at in arg_taints: + result = combine(result, at) + return result return function_taint @@ -820,15 +1253,20 @@ def _process_stmt( lambda_bindings = _CURRENT_LAMBDA_BINDINGS.get() if lambda_bindings is not None: if isinstance(value, ast.Lambda): - lambda_bindings[stmt.target.id] = value + # Linear rebind REPLACES the candidate set (a fresh single-element + # list), never appends — only a branch merge unions arms. + lambda_bindings[stmt.target.id] = [value] else: lambda_bindings.pop(stmt.target.id, None) + _update_var_type(stmt.target.id, value) elif isinstance(stmt.target, (ast.Subscript, ast.Attribute)): # Annotated container/attribute write (``self.x: str = expr``, # ``d['k']: T = expr``) — same fail-open shape as the plain-Assign Part # B case: contaminate the base variable so a later read sees it. rhs = _resolve_expr(value, function_taint, taint_map, var_taints) _taint_container_base(stmt.target, rhs, function_taint, var_taints) + if isinstance(stmt.target, ast.Attribute): + _record_attribute_write(stmt.target, rhs) else: # pragma: no cover # Unreachable: an AnnAssign target is always Name/Attribute/Subscript by # the Python grammar, so the two branches above are exhaustive. Kept as a @@ -860,8 +1298,36 @@ def _process_stmt( elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): pass # Nested function/class — don't descend (separate scope). + elif isinstance(stmt, ast.Raise): + # Resolve the full exception/cause expressions (not just walruses) so calls + # nested in a raise position record their flow-sensitive arg-taint snapshots + # (``raise ValueError(store(read_raw(p)))`` was invisible to PY-WL-105 and + # degraded sink rules to the pessimistic fallback — review 2026-06-10). + if stmt.exc is not None: + _resolve_expr(stmt.exc, function_taint, taint_map, var_taints) + if stmt.cause is not None: + _resolve_expr(stmt.cause, function_taint, taint_map, var_taints) + + elif isinstance(stmt, ast.Assert): + # Same snapshot rationale as Raise: the test and message are real + # expression positions whose calls must record. + _resolve_expr(stmt.test, function_taint, taint_map, var_taints) + if stmt.msg is not None: + _resolve_expr(stmt.msg, function_taint, taint_map, var_taints) + + elif isinstance(stmt, ast.Delete): + for target in stmt.targets: + # Resolve the target expressions (a ``del d[key_call()]`` slice is an + # expression position) and drop the deleted NAME's receiver-type + # candidate — after ``del v`` any later rebind starts untyped. + _resolve_expr(target, function_taint, taint_map, var_taints) + if isinstance(target, ast.Name): + var_types = _CURRENT_VAR_TYPES.get() + if var_types is not None: + var_types.pop(target.id, None) + else: - # Return, Raise, Import, Pass, Break, Continue, etc. + # Return, Import, Pass, Break, Continue, etc. _walk_exprs_for_walrus(stmt, function_taint, taint_map, var_taints) @@ -887,6 +1353,9 @@ def _walk_exprs_for_walrus( # False branch is unreachable by any parseable source. if isinstance(child.target, ast.Name): # pragma: no branch var_taints[child.target.id] = taint + # Walrus rebind — receiver-type strong update / invalidation, the + # same discipline as _resolve_expr's NamedExpr branch. + _update_var_type(child.target.id, child.value) _walk_exprs_for_walrus(child, function_taint, taint_map, var_taints) @@ -905,6 +1374,35 @@ def _resolve_expr_fqn(node: ast.expr, alias_map: dict[str, str] | None) -> str | return None +def _update_var_type(target_id: str, value: ast.expr) -> None: + """Strong-update the receiver-type candidate set for a straight-line assignment. + + ``x = Type()`` binds the single candidate ``[Type_fqn]``; ``x = y`` copies + ``y``'s candidate set. Reassignment to an RHS we cannot type precisely + (Subscript / BinOp / IfExp / f-string / comprehension / unresolvable Call / + typeless Name) INVALIDATES any prior recorded type: keeping the stale type let + a method call on a now-raw value resolve a clean @trusted summary, laundering + raw past the RAW_ZONE receiver guard (wardline-5ba7ce0f98). Dropping it falls + back to conservative resolution (more FPs at worst, never an FN). Multi-class + candidate sets arise only at branch joins (:func:`_merge_branch_types`).""" + var_types = _CURRENT_VAR_TYPES.get() + if var_types is None: + return + new_types: list[str] | None = None + if isinstance(value, ast.Call): + fqn = _resolve_expr_fqn(value.func, _CURRENT_ALIAS_MAP.get()) + if fqn: + new_types = [fqn] + elif isinstance(value, ast.Name): + prior = var_types.get(value.id) + if prior: + new_types = list(prior) + if new_types is not None: + var_types[target_id] = new_types + else: + var_types.pop(target_id, None) + + def _handle_assign( stmt: ast.Assign, function_taint: TaintState, @@ -926,20 +1424,13 @@ def _handle_assign( lambda_bindings = _CURRENT_LAMBDA_BINDINGS.get() if lambda_bindings is not None: if isinstance(stmt.value, ast.Lambda): - lambda_bindings[target.id] = stmt.value + # Linear rebind REPLACES the candidate set (see _resolve_call / + # _merge_branch_bindings); only a branch merge unions arms. + lambda_bindings[target.id] = [stmt.value] else: lambda_bindings.pop(target.id, None) - var_types = _CURRENT_VAR_TYPES.get() - if var_types is not None: - alias_map = _CURRENT_ALIAS_MAP.get() - if isinstance(stmt.value, ast.Call): - fqn = _resolve_expr_fqn(stmt.value.func, alias_map) - if fqn: - var_types[target.id] = fqn - elif isinstance(stmt.value, ast.Name): - if stmt.value.id in var_types: - var_types[target.id] = var_types[stmt.value.id] + _update_var_type(target.id, stmt.value) elif isinstance(target, (ast.Tuple, ast.List)): # Tuple unpacking: a, b = ... @@ -962,6 +1453,10 @@ def _handle_assign( # read back at its creation taint — a fail-open under-taint. rhs = _resolve_expr(stmt.value, function_taint, taint_map, var_taints) _taint_container_base(target, rhs, function_taint, var_taints) + if isinstance(target, ast.Attribute): + # Class-attribute side channel: the RHS taint at THIS statement, + # before any later reassignment can launder it (wardline-b369c7d06c). + _record_attribute_write(target, rhs) def _handle_unpack( @@ -981,6 +1476,9 @@ def _handle_unpack( return else: # RHS is not a matching literal tuple — all targets get RHS taint. + # _assign_target recurses into nested Tuple/List targets (``a, (b, c) = + # raw`` must taint b and c — skipping them was a fail-open under-taint) + # and contaminates the base container of a Subscript/Attribute target. rhs_taint = _resolve_expr( value, function_taint, @@ -988,10 +1486,7 @@ def _handle_unpack( var_taints, ) for tgt in target.elts: - if isinstance(tgt, ast.Name): - var_taints[tgt.id] = rhs_taint - elif isinstance(tgt, ast.Starred) and isinstance(tgt.value, ast.Name): - var_taints[tgt.value.id] = rhs_taint + _assign_target(tgt, rhs_taint, var_taints) def _handle_literal_unpack( @@ -1006,16 +1501,12 @@ def _handle_literal_unpack( if len(value.elts) != len(target.elts): return False for tgt, val in zip(target.elts, value.elts, strict=False): - if isinstance(tgt, ast.Name): - taint = _resolve_expr( - val, - function_taint, - taint_map, - var_taints, - ) - var_taints[tgt.id] = taint - elif isinstance(tgt, (ast.Tuple, ast.List)): + if isinstance(tgt, (ast.Tuple, ast.List)): _handle_unpack(tgt, val, function_taint, taint_map, var_taints) + else: + # Name binds; a Subscript/Attribute target (``d['k'], _ = raw, 1``) + # contaminates its base container via _assign_target. + _assign_target(tgt, _resolve_expr(val, function_taint, taint_map, var_taints), var_taints) return True fixed_targets = len(target.elts) - 1 @@ -1024,10 +1515,10 @@ def _handle_literal_unpack( suffix_count = len(target.elts) - starred_idx - 1 for tgt, val in zip(target.elts[:starred_idx], value.elts[:starred_idx], strict=False): - if isinstance(tgt, ast.Name): - var_taints[tgt.id] = _resolve_expr(val, function_taint, taint_map, var_taints) - elif isinstance(tgt, (ast.Tuple, ast.List)): + if isinstance(tgt, (ast.Tuple, ast.List)): _handle_unpack(tgt, val, function_taint, taint_map, var_taints) + else: + _assign_target(tgt, _resolve_expr(val, function_taint, taint_map, var_taints), var_taints) slice_end = len(value.elts) - suffix_count if suffix_count else len(value.elts) captured = value.elts[starred_idx:slice_end] @@ -1044,10 +1535,10 @@ def _handle_literal_unpack( for offset in range(suffix_count): tgt = target.elts[starred_idx + 1 + offset] val = value.elts[len(value.elts) - suffix_count + offset] - if isinstance(tgt, ast.Name): - var_taints[tgt.id] = _resolve_expr(val, function_taint, taint_map, var_taints) - elif isinstance(tgt, (ast.Tuple, ast.List)): + if isinstance(tgt, (ast.Tuple, ast.List)): _handle_unpack(tgt, val, function_taint, taint_map, var_taints) + else: + _assign_target(tgt, _resolve_expr(val, function_taint, taint_map, var_taints), var_taints) return True @@ -1078,6 +1569,8 @@ def _handle_augassign( elif isinstance(stmt.target, (ast.Subscript, ast.Attribute)): # pragma: no branch # d[k] += expr / obj.x += expr — contaminate the base container. _taint_container_base(stmt.target, rhs_taint, function_taint, var_taints) + if isinstance(stmt.target, ast.Attribute): + _record_attribute_write(stmt.target, rhs_taint) def _container_base_name(target: ast.expr) -> str | None: @@ -1115,12 +1608,67 @@ def _taint_container_base( # ── Control flow handlers ──────────────────────────────────────── -def _branch_copy(parent: dict[str, ast.Lambda] | None) -> dict[str, ast.Lambda] | None: +def _branch_copy(parent: dict[str, list[ast.Lambda]] | None) -> dict[str, list[ast.Lambda]] | None: """An arm-local copy of the lambda-bindings map for one branch arm (``None`` when bindings are not being tracked — a degraded caller). Copying per arm is what keeps a lambda bound inside one arm from leaking into a mutually-exclusive sibling arm - (wardline-36016d26f3), mirroring how ``var_taints`` is copied per arm.""" - return dict(parent) if parent is not None else None + (wardline-36016d26f3), mirroring how ``var_taints`` is copied per arm. The candidate + LISTS are copied too, so an arm's rebind/removal cannot mutate the parent's or a + sibling's set in place (wardline-383f83fafe).""" + return {name: list(lams) for name, lams in parent.items()} if parent is not None else None + + +def _types_branch_copy(parent: dict[str, list[str]] | None) -> dict[str, list[str]] | None: + """An arm-local copy of the receiver-type candidate map for one branch arm + (``None`` when types are not being tracked). Candidate LISTS are copied too, + so an arm's strong update cannot mutate the parent's or a sibling's set in + place — branch-local exactly like ``var_taints`` (wardline-b369c7d06c).""" + return {name: list(types) for name, types in parent.items()} if parent is not None else None + + +def _merge_branch_types( + parent: dict[str, list[str]] | None, + arms: list[dict[str, list[str]] | None], +) -> None: + """Merge mutually-exclusive branch arms' receiver-type candidates back into + *parent* in place. Post-branch a receiver MAY hold whichever class the taken + arm bound, so its candidate set is the UNION of every arm's set (dedup by + equality — FQNs are strings). An arm that never touched a name carries its + pre-branch set (each arm is a full copy of *parent*), so a class binding made + before the branch survives an arm that rebound the name — that surviving + candidate is what attributes a post-branch attribute write to the + not-rebound class too (wardline-b369c7d06c). A name EVERY arm rebound to an + untypeable value drops out (absent from all arms — sound: no path still + holds a tracked class). Mirrors :func:`_merge_branch_bindings`, including the + absent-or-non-empty invariant. + + A name SOME-but-not-all arms dropped (rebound to an untypeable value — a + for/with/unpack/handler rebind) keeps the surviving arms' candidates for the + attribute-write channel but gains the :data:`UNTYPED_ARM_CANDIDATE` poison + marker: on the dropped arm's path the receiver holds an object of UNKNOWN + type, so a method-summary READ must not resolve cleanly through the other + arm's class (the launder PY-WL-101 closed in the 2026-06-10 review). The + marker never resolves in any taint map, so the dispatch's all-candidates + completeness check fails and resolution falls through to the conservative + generic handling; the write recorder's bucket for it is dropped by + :func:`project_attribute_writes` (not a known class).""" + if parent is None: + return + merged: dict[str, list[str]] = {} + tracked_arms = [arm for arm in arms if arm is not None] + for arm in tracked_arms: + for name, types in arm.items(): + if not types: + continue # uphold the absent-or-non-empty invariant + bucket = merged.setdefault(name, []) + for fqn in types: + if fqn not in bucket: + bucket.append(fqn) + for name, bucket in merged.items(): + if UNTYPED_ARM_CANDIDATE not in bucket and any(not arm.get(name) for arm in tracked_arms): + bucket.append(UNTYPED_ARM_CANDIDATE) + parent.clear() + parent.update(merged) def _walk_branch_body( @@ -1129,52 +1677,72 @@ def _walk_branch_body( taint_map: dict[str, TaintState], var_taints: dict[str, TaintState], call_site_taints: dict[int, dict[str, TaintState]] | None, - arm_bindings: dict[str, ast.Lambda] | None, + arm_bindings: dict[str, list[ast.Lambda]] | None, + arm_types: dict[str, list[str]] | None, ) -> None: - """Walk one branch arm's body with *arm_bindings* as the active (arm-local) - lambda-bindings map, so lambda assignments inside the arm mutate the copy, not the - shared parent. A plain ``_walk_body`` when bindings aren't tracked.""" - if arm_bindings is None: - _walk_body(body, function_taint, taint_map, var_taints, call_site_taints) - return - token = _CURRENT_LAMBDA_BINDINGS.set(arm_bindings) + """Walk one branch arm's body with *arm_bindings* / *arm_types* as the active + (arm-local) lambda-bindings and receiver-type maps, so lambda assignments and + class rebinds inside the arm mutate the copies, not the shared parent. A plain + ``_walk_body`` for whichever map isn't tracked.""" + token_bindings = _CURRENT_LAMBDA_BINDINGS.set(arm_bindings) if arm_bindings is not None else None + token_types = _CURRENT_VAR_TYPES.set(arm_types) if arm_types is not None else None try: _walk_body(body, function_taint, taint_map, var_taints, call_site_taints) finally: - _CURRENT_LAMBDA_BINDINGS.reset(token) + if token_types is not None: + _CURRENT_VAR_TYPES.reset(token_types) + if token_bindings is not None: + _CURRENT_LAMBDA_BINDINGS.reset(token_bindings) def _merge_branch_bindings( - parent: dict[str, ast.Lambda] | None, - arms: list[dict[str, ast.Lambda] | None], + parent: dict[str, list[ast.Lambda]] | None, + arms: list[dict[str, list[ast.Lambda]] | None], ) -> None: """Merge mutually-exclusive branch arms' lambda bindings back into *parent* in place. Each arm was walked against an arm-local *copy* of *parent*, so a binding made in one arm cannot leak into a sibling arm during the walk (wardline-36016d26f3); this re-converges the arms into the post-branch state. - We layer each arm's *delta relative to the pre-branch state* onto *parent* in - source order — we do NOT clear and re-union. The distinction is load-bearing: an - arm is a full copy of the pre-branch bindings, so a name an arm never touched still - carries its pre-branch lambda. A clear-then-union (or a union that lets the implicit - no-``else`` / no-match-catch-all fall-through arm win last) would let such an - untouched arm *revert* a rebinding done in another arm — silently dropping a binding - the engine kept before branch-locality was added, i.e. a NEW false negative for a - sink reached through the rebound name after the branch. Applying only net - added/changed bindings, last-arm-in-source-order wins, reproduces the prior - after-branch bindings for every rebinding case (so no new false negative) while - keeping the branch-local leak fix. A name an arm *removed* (rebound to a non-lambda) - is left in place: that can only over-approximate (an extra resolution), never miss a - sink.""" + Post-branch a name MAY hold whichever lambda the taken arm bound it to, so its + candidate set is the UNION of every arm's set for that name (wardline-383f83fafe). + We rebuild *parent* from that union rather than overwriting slot-by-slot: the old + single-slot map kept only the last arm's binding, dropping a sink-lambda bound in a + non-last arm and missing the post-branch sink (the FN this closes). + + The union also subsumes the no-false-negative guard the prior delta-merge protected + (wardline-36016d26f3): an arm that never touched a name still carries its pre-branch + set (each arm is a full copy of *parent*), and the implicit no-``else`` / + no-match-catch-all fall-through arm is exactly such a copy — so a rebinding made in + one arm is preserved in the union, never reverted by an untouched sibling. A name + that EVERY arm rebound to a non-lambda is absent from all arm sets and so drops out + of the union — sound, because on no post-branch path is it still a lambda. (The prior + delta-merge left such a name in place; that was a harmless over-approximation, not a + pinned behaviour.) Resolving an extra candidate only records arg-taints — it can + over-approximate, never mask a sink — so the union is FP-safe. + + Invariant: a name is either ABSENT from the map or maps to a NON-EMPTY list. An empty + list would make ``_resolve_call``'s ``name in bindings`` check pass while the + candidate loop does nothing — silently skipping all bodies, a latent FN. Writers + uphold it (linear rebind stores ``[lam]`` or pops; this merge never buckets an empty + arm list), so the map never holds an empty list.""" if parent is None: return - pre = dict(parent) + merged: dict[str, list[ast.Lambda]] = {} for arm in arms: if arm is None: continue - for name, lam in arm.items(): - if pre.get(name) is not lam: - parent[name] = lam + for name, lams in arm.items(): + if not lams: + continue # uphold the empty-list-never-stored invariant (see docstring) + bucket = merged.setdefault(name, []) + for lam in lams: + # Dedup by identity (ast nodes don't define __eq__): the same lambda + # carried unchanged through several arms should be resolved once. + if not any(lam is seen for seen in bucket): + bucket.append(lam) + parent.clear() + parent.update(merged) def _handle_if( @@ -1191,24 +1759,32 @@ def _handle_if( # Snapshot before branches. pre_if = dict(var_taints) parent_lambdas = _CURRENT_LAMBDA_BINDINGS.get() + parent_types = _CURRENT_VAR_TYPES.get() - # Walk the if-body with an arm-local lambda-bindings copy — branch-local like - # var_taints, so a lambda bound here cannot leak into the else arm. + # Walk the if-body with arm-local lambda-bindings and receiver-type copies — + # branch-local like var_taints, so a lambda bound or class rebound here cannot + # leak into the else arm. if_taints = dict(var_taints) if_lambdas = _branch_copy(parent_lambdas) - _walk_branch_body(stmt.body, function_taint, taint_map, if_taints, call_site_taints, if_lambdas) + if_types = _types_branch_copy(parent_types) + _walk_branch_body(stmt.body, function_taint, taint_map, if_taints, call_site_taints, if_lambdas, if_types) if stmt.orelse: - # Walk the else-body on its own arm-local bindings copy. + # Walk the else-body on its own arm-local copies. else_taints = dict(var_taints) else_lambdas = _branch_copy(parent_lambdas) - _walk_branch_body(stmt.orelse, function_taint, taint_map, else_taints, call_site_taints, else_lambdas) + else_types = _types_branch_copy(parent_types) + _walk_branch_body( + stmt.orelse, function_taint, taint_map, else_taints, call_site_taints, else_lambdas, else_types + ) else: - # No else — the "else" branch is the pre-if state with bindings unchanged. + # No else — the "else" branch is the pre-if state with bindings/types unchanged. else_taints = pre_if else_lambdas = _branch_copy(parent_lambdas) + else_types = _types_branch_copy(parent_types) _merge_branch_bindings(parent_lambdas, [if_lambdas, else_lambdas]) + _merge_branch_types(parent_types, [if_types, else_types]) # Merge: for each variable, combine the two branch values. The var holds ONE # branch's value (an alternative), so combine via the rank-meet least_trusted @@ -1245,8 +1821,37 @@ def _handle_for( # Snapshot pre-loop. pre_loop = dict(var_taints) - # Iterate walk until convergence (WLN-MED-09) - for _ in range(8): + # Lambda bindings are mutated in place on the shared map across iterations (no per-arm + # copy WITHIN the fixpoint — the body's in-place resolution is what gives a sound + # loop-carried binding: a cb(raw) call placed BEFORE an in-body rebind sees the prior + # iteration's candidate). A rebind REPLACES with the SAME ast.Lambda node every + # iteration (parsed once) and in-body branch merges dedup by identity, so the binding + # state is idempotent across iterations — the var_taints convergence check below is a + # sufficient fixpoint for the bindings too (no unbounded candidate growth, see + # wardline-383f83fafe). + # + # The loop body is, however, a CONDITIONALLY-executed arm: a reachable 0-iteration path + # means the post-loop binding for a name MAY still be its pre-loop value. Snapshot the + # pre-loop bindings (the "loop did not run" arm) and union them back AFTER the fixpoint + # (see below), exactly like a no-`else` ``if`` — otherwise a sink-lambda bound before + # the loop and rebound clean inside it is silently dropped on the zero-trip path, and a + # post-loop call through the name misses the sink (the FN this closes, + # wardline-d6af917bde). + pre_loop_lambdas = _branch_copy(_CURRENT_LAMBDA_BINDINGS.get()) + # Same zero-trip arm for receiver-type candidates: a class rebound inside the + # body MAY not have run, so the pre-loop candidates are unioned back below + # (wardline-b369c7d06c). + pre_loop_types = _types_branch_copy(_CURRENT_VAR_TYPES.get()) + + # Iterate the walk until var_taints genuinely converges (WLN-MED-09). The bound is + # num_vars × lattice_height, NOT lattice_height (8) alone: 8 caps a SINGLE variable's + # monotone rank climb, but a read-before-write loop-carried chain propagates taint one + # link per iteration, so an N-variable chain needs N iterations to reach the head — a + # range(8) cap silently dropped chains longer than 8 (a fail-open FN, wardline-e04db6e656). + # The convergence break below is the real terminator; the backstop is a never-hit-in- + # practice safety net (finite monotone lattice over a fixed local-name set guarantees it). + iterations = 0 + while True: current_state = dict(var_taints) # Assign the loop variable. _assign_target(stmt.target, iter_taint, var_taints) @@ -1255,8 +1860,27 @@ def _handle_for( # Merge body state with pre-loop for var in set(var_taints) | set(pre_loop): var_taints[var] = combine(var_taints.get(var, function_taint), pre_loop.get(var, function_taint)) + iterations += 1 if var_taints == current_state: break + if iterations >= len(var_taints) * len(TRUST_RANK) + 1: + break + + # Union the converged "loop ran" binding arm with the "loop did not run" arm so the + # post-loop candidate set covers BOTH the zero-trip path (pre_loop_lambdas) and the + # body's rebinds — the faithful mirror of a no-`else` ``if`` join (wardline-d6af917bde). + # _merge_branch_bindings preserves the dedup-by-identity and never-empty-list + # invariants. This lands BEFORE the orelse walk, so a `for...else` body that further + # rebinds the name still mutates the unioned state in place (accepted limitation: the + # break-vs-normal-exit distinction is not modelled — orelse is walked unconditionally). + parent_lambdas = _CURRENT_LAMBDA_BINDINGS.get() + if parent_lambdas is not None: + post_body_arm = _branch_copy(parent_lambdas) + _merge_branch_bindings(parent_lambdas, [post_body_arm, pre_loop_lambdas]) + parent_types = _CURRENT_VAR_TYPES.get() + if parent_types is not None: + post_body_types = _types_branch_copy(parent_types) + _merge_branch_types(parent_types, [post_body_types, pre_loop_types]) # Walk orelse (runs after normal loop completion). if stmt.orelse: @@ -1273,14 +1897,38 @@ def _handle_while( """Handle while loops — body merges with pre-loop state.""" pre_loop = dict(var_taints) - for _ in range(8): + # The body is a conditionally-executed arm (a reachable 0-iteration path), so snapshot + # the pre-loop lambda bindings (the "loop did not run" arm) and union them back after + # the fixpoint — the no-`else` ``if`` mirror that keeps a pre-loop sink-lambda visible + # on the zero-trip path (wardline-d6af917bde; see _handle_for for the full rationale). + pre_loop_lambdas = _branch_copy(_CURRENT_LAMBDA_BINDINGS.get()) + pre_loop_types = _types_branch_copy(_CURRENT_VAR_TYPES.get()) + + # Iterate to genuine convergence with a num_vars × lattice_height backstop — see + # _handle_for: a range(8) cap was unsound for loop-carried chains > 8 links + # (wardline-e04db6e656). + iterations = 0 + while True: current_state = dict(var_taints) _resolve_expr(stmt.test, function_taint, taint_map, var_taints) _walk_body(stmt.body, function_taint, taint_map, var_taints, call_site_taints) for var in set(var_taints) | set(pre_loop): var_taints[var] = combine(var_taints.get(var, function_taint), pre_loop.get(var, function_taint)) + iterations += 1 if var_taints == current_state: break + if iterations >= len(var_taints) * len(TRUST_RANK) + 1: + break + + # Union the converged "loop ran" arm with the "loop did not run" arm (wardline-d6af917bde). + parent_lambdas = _CURRENT_LAMBDA_BINDINGS.get() + if parent_lambdas is not None: + post_body_arm = _branch_copy(parent_lambdas) + _merge_branch_bindings(parent_lambdas, [post_body_arm, pre_loop_lambdas]) + parent_types = _CURRENT_VAR_TYPES.get() + if parent_types is not None: + post_body_types = _types_branch_copy(parent_types) + _merge_branch_types(parent_types, [post_body_types, pre_loop_types]) if stmt.orelse: _walk_body(stmt.orelse, function_taint, taint_map, var_taints, call_site_taints) @@ -1317,28 +1965,67 @@ def _handle_try( """Handle try/except/else/finally — snapshot-branch-join pattern.""" pre_try = dict(var_taints) parent_lambdas = _CURRENT_LAMBDA_BINDINGS.get() + parent_types = _CURRENT_VAR_TYPES.get() - # Walk try body on a copy (arm-local lambda bindings — branch-local like var_taints). + # Walk try body on a copy (arm-local lambda bindings/receiver types — + # branch-local like var_taints). + pre_try_snapshot_ids = set(call_site_taints) if call_site_taints is not None else None try_taints = dict(pre_try) try_lambdas = _branch_copy(parent_lambdas) - _walk_branch_body(stmt.body, function_taint, taint_map, try_taints, call_site_taints, try_lambdas) + try_types = _types_branch_copy(parent_types) + _walk_branch_body(stmt.body, function_taint, taint_map, try_taints, call_site_taints, try_lambdas, try_types) + + # A handler runs after an exception raised at ANY point in the try body, so + # it can observe pre-try state OR any try-body prefix's state — handler arms + # are NOT mutually exclusive with the try body's assignments. Seed each arm + # with the per-variable WORST over the pre-try snapshot, every per-statement + # snapshot the try-body walk recorded (each is a reachable prefix state, so a + # value assigned raw and cleaned mid-body stays visible), and the body's + # post-walk state (the degraded no-snapshot fallback). Seeding from + # ``dict(pre_try)`` alone dropped taint assigned before a raising call — a + # fail-open under-taint. Lambda-binding/receiver-type arms keep their + # pre-try copies: those channels resolve call shapes, not data values, and + # extra candidates only over-approximate (the var-taint seed is the + # soundness fix). + handler_seed = dict(pre_try) + prefix_states: list[dict[str, TaintState]] = [try_taints] + if call_site_taints is not None and pre_try_snapshot_ids is not None: + prefix_states.extend( + snapshot for stmt_id, snapshot in call_site_taints.items() if stmt_id not in pre_try_snapshot_ids + ) + for state in prefix_states: + for name, taint in state.items(): + current = handler_seed.get(name) + if current is None or TRUST_RANK[taint] > TRUST_RANK[current]: + handler_seed[name] = taint - # Walk each handler on separate copies (mutually exclusive with try body). + # Walk each handler on separate copies. handler_branches: list[dict[str, TaintState]] = [try_taints] # try-success is one branch - arm_bindings: list[dict[str, ast.Lambda] | None] = [try_lambdas] + arm_bindings: list[dict[str, list[ast.Lambda]] | None] = [try_lambdas] + arm_types: list[dict[str, list[str]] | None] = [try_types] for handler in stmt.handlers: - handler_taints = dict(pre_try) + handler_taints = dict(handler_seed) + handler_lambdas = _branch_copy(parent_lambdas) + handler_types = _types_branch_copy(parent_types) if handler.name: handler_taints[handler.name] = function_taint - handler_lambdas = _branch_copy(parent_lambdas) - _walk_branch_body(handler.body, function_taint, taint_map, handler_taints, call_site_taints, handler_lambdas) + # ``except E as name`` REBINDS name to the exception instance — any + # receiver-type candidate the name carried describes an object it no + # longer holds, so the handler arm's copy drops it (review 2026-06-10; + # mirrors collect_sink_bindings' handler-name invalidation). + if handler_types is not None: + handler_types.pop(handler.name, None) + _walk_branch_body( + handler.body, function_taint, taint_map, handler_taints, call_site_taints, handler_lambdas, handler_types + ) handler_branches.append(handler_taints) arm_bindings.append(handler_lambdas) + arm_types.append(handler_types) # Walk orelse on the try-success branch (runs only if no exception) — continue the - # try arm's bindings, not a fresh copy. + # try arm's bindings/types, not fresh copies. if stmt.orelse: - _walk_branch_body(stmt.orelse, function_taint, taint_map, try_taints, call_site_taints, try_lambdas) + _walk_branch_body(stmt.orelse, function_taint, taint_map, try_taints, call_site_taints, try_lambdas, try_types) # Merge all branches. all_vars: set[str] = set() @@ -1365,9 +2052,11 @@ def _handle_try( except KeyError: _taint_val = None # var absent from pre-try state — leave unset - # Lambda bindings: union the mutually-exclusive arms (try-success + each handler) - # back into the parent, mirroring the var_taints join above. + # Lambda bindings / receiver types: union the mutually-exclusive arms + # (try-success + each handler) back into the parent, mirroring the var_taints + # join above. _merge_branch_bindings(parent_lambdas, arm_bindings) + _merge_branch_types(parent_types, arm_types) # finalbody runs unconditionally after merge — with the merged bindings (in place, # the active contextvar dict, since the function body continues into it). @@ -1398,16 +2087,21 @@ def _handle_match( pre_match = dict(var_taints) parent_lambdas = _CURRENT_LAMBDA_BINDINGS.get() + parent_types = _CURRENT_VAR_TYPES.get() branches: list[dict[str, TaintState]] = [] - arm_bindings: list[dict[str, ast.Lambda] | None] = [] + arm_bindings: list[dict[str, list[ast.Lambda]] | None] = [] + arm_types: list[dict[str, list[str]] | None] = [] for case in stmt.cases: case_taints = dict(pre_match) for name in _collect_pattern_targets(case.pattern): case_taints[name] = subject_taint - # Arm-local lambda bindings (guard + body share the arm), branch-local like - # var_taints so a lambda bound in one case cannot leak into a sibling case. + # Arm-local lambda bindings / receiver types (guard + body share the arm), + # branch-local like var_taints so a lambda bound or class rebound in one + # case cannot leak into a sibling case. case_lambdas = _branch_copy(parent_lambdas) + case_types = _types_branch_copy(parent_types) token = _CURRENT_LAMBDA_BINDINGS.set(case_lambdas) if case_lambdas is not None else None + token_types = _CURRENT_VAR_TYPES.set(case_types) if case_types is not None else None try: if case.guard is not None: # The guard is tested with the arm's captures in scope; resolve it for @@ -1415,14 +2109,18 @@ def _handle_match( _resolve_expr(case.guard, function_taint, taint_map, case_taints) _walk_body(case.body, function_taint, taint_map, case_taints, call_site_taints) finally: + if token_types is not None: + _CURRENT_VAR_TYPES.reset(token_types) if token is not None: _CURRENT_LAMBDA_BINDINGS.reset(token) branches.append(case_taints) arm_bindings.append(case_lambdas) + arm_types.append(case_types) - # The implicit "no arm matched" path keeps the pre-match state and bindings. + # The implicit "no arm matched" path keeps the pre-match state, bindings, and types. branches.append(pre_match) arm_bindings.append(_branch_copy(parent_lambdas)) + arm_types.append(_types_branch_copy(parent_types)) all_vars: set[str] = set() for branch in branches: @@ -1434,8 +2132,10 @@ def _handle_match( merged = combine(merged, v) var_taints[var] = merged - # Lambda bindings: union the mutually-exclusive case arms (+ no-match) into parent. + # Lambda bindings / receiver types: union the mutually-exclusive case arms + # (+ no-match) into parent. _merge_branch_bindings(parent_lambdas, arm_bindings) + _merge_branch_types(parent_types, arm_types) # ── Helpers ────────────────────────────────────────────────────── @@ -1481,135 +2181,48 @@ def _assign_target( target: ast.expr, taint: TaintState, var_taints: dict[str, TaintState], + *, + invalidate_types: bool = True, ) -> None: - """Assign taint to a target node (Name, Tuple, or List).""" + """Assign taint to a target node (Name, Tuple/List, Starred, Subscript, Attribute). + + Nested Tuple/List targets recurse so every leaf Name inherits the taint; a + Subscript/Attribute target (``for d['k'] in raws`` / ``d['k'], _ = raw``) + is a container WRITE, so its root Name absorbs the taint via the + weakest-link combine — silently skipping it left the container clean, a + fail-open under-taint (wardline-93d608c997). An Attribute target ALSO + records into the attribute-write side channel: ``self.x, y = raw, 1`` is + the same class-attribute write as ``self.x = raw``, and the recorder only + saw direct ``ast.Attribute`` Assign targets — the unpack form silently + skipped the cross-method summary (a fail-open under-taint). + + Every Name leaf bound here is a REBIND through a form that carries no + statically-typeable RHS (a for/with target, a tuple-unpack leaf, a starred + slice), so its receiver-type candidate is INVALIDATED — keeping the stale + class let a clean ``@trusted`` method summary launder a rebound raw + receiver past PY-WL-101 (review 2026-06-10; mirrors the invalidation + discipline ``collect_sink_bindings`` applies to the same binding forms). + ``invalidate_types=False`` is for COMPREHENSION generator targets, which + bind a comprehension-local scope and must not drop the enclosing name's + candidate. + """ if isinstance(target, ast.Name): var_taints[target.id] = taint + if invalidate_types: + var_types = _CURRENT_VAR_TYPES.get() + if var_types is not None: + var_types.pop(target.id, None) elif isinstance(target, (ast.Tuple, ast.List)): for elt in target.elts: - _assign_target(elt, taint, var_taints) - elif isinstance(target, ast.Starred) and isinstance(target.value, ast.Name): - var_taints[target.value.id] = taint - - -def _self_attr_name(target: ast.expr) -> str | None: - """The attribute name of a ``self.`` / ``cls.`` write target, else None. - - Only a direct attribute of the instance/class receiver (``self.x = ...``) — a - subscript-into-attribute (``self.cache[k] = ...``) or deeper chain is NOT a direct - attribute write and is deliberately excluded (it would need container modelling).""" - if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id in ("self", "cls"): - return target.attr - return None - - -def collect_attribute_writes( - func_node: ast.FunctionDef | ast.AsyncFunctionDef, - function_taint: TaintState, - taint_map: dict[str, TaintState], - var_taints: dict[str, TaintState], - class_qualnames: frozenset[str], - alias_map: dict[str, str], - module_prefix: str, - enclosing_class: str | None = None, -) -> dict[str, dict[str, TaintState]]: - """Return ``{class_qualname: {attr_name: least_trusted RHS taint}}`` for every - instance attribute assignment in *func_node*'s body. - - Handles both internal ``self.x = ...`` writes (enclosing class) and external - ``obj.x = ...`` writes where ``obj`` was instantiated via a class constructor - call. - """ - from wardline.scanner.ast_primitives import resolve_call_fqn - - out: dict[str, dict[str, TaintState]] = {} - var_types: dict[str, str] = {} - - def _walk(node: ast.AST) -> None: - for child in ast.iter_child_nodes(node): - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): - continue - - # 1. Track variable types assigned to constructors or copied - targets: list[ast.expr] = [] - value: ast.expr | None = None - if isinstance(child, ast.Assign): - targets = child.targets - value = child.value - elif isinstance(child, ast.AnnAssign): - targets = [child.target] - value = child.value - - class_fqn = None - if value is not None: - if isinstance(value, ast.Call): - fqn = resolve_call_fqn(value, alias_map, class_qualnames, module_prefix) - if fqn in class_qualnames: - class_fqn = fqn - elif isinstance(value, ast.Name): - class_fqn = var_types.get(value.id) - - if class_fqn is not None: - for tgt in targets: - if isinstance(tgt, ast.Name): - var_types[tgt.id] = class_fqn - - # 2. Record attribute writes - targets_to_check: list[ast.expr] = [] - if isinstance(child, ast.Assign): - targets_to_check = child.targets - value = child.value - elif isinstance(child, (ast.AnnAssign, ast.AugAssign)): - targets_to_check = [child.target] - value = child.value - - for tgt in targets_to_check: - if isinstance(tgt, ast.Attribute) and isinstance(tgt.value, ast.Name): - var_name = tgt.value.id - attr_name = tgt.attr - target_class = None - if var_name in ("self", "cls") and enclosing_class: - target_class = enclosing_class - elif var_name in var_types: - target_class = var_types[var_name] - - if target_class is not None and value is not None: - rhs_taint = _resolve_expr(value, function_taint, taint_map, var_taints) - cls_writes = out.setdefault(target_class, {}) - cls_writes[attr_name] = ( - combine(cls_writes[attr_name], rhs_taint) if attr_name in cls_writes else rhs_taint - ) - - _walk(child) - - token_types = _CURRENT_VAR_TYPES.set(var_types) - token_alias = _CURRENT_ALIAS_MAP.set(alias_map) - try: - _walk(func_node) - finally: - _CURRENT_VAR_TYPES.reset(token_types) - _CURRENT_ALIAS_MAP.reset(token_alias) - return out - - -def collect_self_attr_writes( - func_node: ast.FunctionDef | ast.AsyncFunctionDef, - function_taint: TaintState, - taint_map: dict[str, TaintState], - var_taints: dict[str, TaintState], -) -> dict[str, TaintState]: - """Compatibility wrapper for internal-only writes.""" - writes = collect_attribute_writes( - func_node, - function_taint, - taint_map, - var_taints, - class_qualnames=frozenset(), - alias_map={}, - module_prefix="", - enclosing_class="dummy", - ) - return writes.get("dummy", {}) + _assign_target(elt, taint, var_taints, invalidate_types=invalidate_types) + elif isinstance(target, ast.Starred): + _assign_target(target.value, taint, var_taints, invalidate_types=invalidate_types) + elif isinstance(target, (ast.Subscript, ast.Attribute)): + base = _container_base_name(target) + if base is not None: + var_taints[base] = combine(var_taints.get(base, taint), taint) + if isinstance(target, ast.Attribute): + _record_attribute_write(target, taint) def compute_return_taint( @@ -1617,21 +2230,23 @@ def compute_return_taint( function_taint: TaintState, taint_map: dict[str, TaintState], var_taints: dict[str, TaintState], + return_snapshots: dict[int, dict[str, TaintState]] | None = None, ) -> TaintState | None: """Compute the *actual* taint a function returns (least-trusted of all paths). Resolves every value-bearing ``return`` statement in *func_node*'s own scope - (nested functions/lambdas excluded) against the already-computed ``var_taints`` - and the call-resolution ``taint_map``, and joins them with :func:`least_trusted` - — the worst (least-trusted) value any path can return. Returns ``None`` when the - function has no value-bearing ``return`` (implicit ``None`` / bare ``return`` / - pure side-effect): there is no returned data to police. + (nested functions/lambdas excluded) against the statement snapshot for that + return when available, falling back to the already-computed final + ``var_taints``. It then joins paths with :func:`least_trusted` — the worst + (least-trusted) value any path can return. Returns ``None`` when the function + has no value-bearing ``return`` (implicit ``None`` / bare ``return`` / pure + side-effect): there is no returned data to police. This is the precise input PY-WL-101 needs — distinct from ``project_taints`` (the function's anchored *body* taint, pinned to its declaration). """ returns: list[tuple[TaintState, str | None, ast.expr]] = [] - _collect_return_paths(list(func_node.body), function_taint, taint_map, var_taints, returns) + _collect_return_paths(list(func_node.body), function_taint, taint_map, var_taints, returns, return_snapshots) if not returns: return None result = returns[0][0] @@ -1645,6 +2260,7 @@ def compute_return_callee( function_taint: TaintState, taint_map: dict[str, TaintState], var_taints: dict[str, TaintState], + return_snapshots: dict[int, dict[str, TaintState]] | None = None, ) -> str | None: """Identify the callee that contributes a function's *actual* (least-trusted) return taint — the property ``explain_finding`` reports for a PY-WL-101 sink. @@ -1670,7 +2286,7 @@ def compute_return_callee( beyond one hop stay ``None`` (the N-hop walk lives in the Loomweave stored-fact path). """ returns: list[tuple[TaintState, str | None, ast.expr]] = [] - _collect_return_paths(list(func_node.body), function_taint, taint_map, var_taints, returns) + _collect_return_paths(list(func_node.body), function_taint, taint_map, var_taints, returns, return_snapshots) if not returns: return None worst = returns[0][0] @@ -1748,6 +2364,7 @@ def _collect_return_paths( taint_map: dict[str, TaintState], var_taints: dict[str, TaintState], out: list[tuple[TaintState, str | None, ast.expr]], + return_snapshots: dict[int, dict[str, TaintState]] | None = None, ) -> None: """Recurse the AST collecting ``(taint, callee_or_None, value_node)`` for each value-bearing return, descending into ALL children EXCEPT nested ``FunctionDef``/ @@ -1769,6 +2386,14 @@ def _collect_return_paths( if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): continue if isinstance(node, (ast.Return, ast.Yield, ast.YieldFrom)) and node.value is not None: - taint = _resolve_expr(node.value, function_taint, taint_map, var_taints) + snapshot = return_snapshots.get(id(node)) if return_snapshots is not None else None + taint = _resolve_expr(node.value, function_taint, taint_map, dict(snapshot or var_taints)) out.append((taint, _return_callee(node.value), node.value)) - _collect_return_paths(list(ast.iter_child_nodes(node)), function_taint, taint_map, var_taints, out) + _collect_return_paths( + list(ast.iter_child_nodes(node)), + function_taint, + taint_map, + var_taints, + out, + return_snapshots, + ) diff --git a/src/wardline/skills/wardline-gate/SKILL.md b/src/wardline/skills/wardline-gate/SKILL.md index 87ee134a..7a3d4aa8 100644 --- a/src/wardline/skills/wardline-gate/SKILL.md +++ b/src/wardline/skills/wardline-gate/SKILL.md @@ -20,11 +20,12 @@ receive validated data). When untrusted data reaches a trusted producer it raise 1. **Scan.** Run `wardline scan . --fail-on ERROR` (or call the `scan` MCP tool). Read the gate verdict and the active (non-suppressed) findings — `active` is the population the gate enforces on. -2. **Explain.** For each active defect, call `explain_taint` with the finding's - `fingerprint`, `path`+`line`, and its `qualname` as `sink_qualname`. Do this +2. **Explain.** For each active defect, call `explain_taint` (MCP) or run + `wardline explain-taint [PATH]` (CLI) with the finding's + `fingerprint`, and its `qualname` as `sink_qualname`. Do this right after the scan and before editing — a stale fingerprint returns an error. - With a Loomweave store configured, pass `chain: true` to walk the full taint - chain back to the originating boundary. + With a Loomweave store configured, pass `chain: true` (`--chain` on the CLI) + to walk the full taint chain back to the originating boundary. 3. **Fix at the BOUNDARY, not the sink.** Add validation or rejection at the hop where untrusted data should have been checked — not a band-aid at the sink. 4. **Re-scan.** Confirm the finding is gone. @@ -56,7 +57,9 @@ non-issue, always with a reason: ## CLI vs MCP -- **CLI:** `wardline scan`, `wardline judge`, `wardline baseline create/update`. +- **CLI:** `wardline scan`, `wardline explain-taint`, `wardline findings` + (read-only filtered query: `--rule-id` / `--severity` / `--sink` or a JSON + `--where`), `wardline judge`, `wardline baseline create/update`. Branch on the exit code; read the findings file it writes. - **MCP:** `wardline mcp` exposes `scan`, `explain_taint`, `fix`, `judge` (network), `baseline`, `waiver_add`; resources diff --git a/src/wardline/weft_decorator_coverage.py b/src/wardline/weft_decorator_coverage.py index e33448af..de61cd45 100644 --- a/src/wardline/weft_decorator_coverage.py +++ b/src/wardline/weft_decorator_coverage.py @@ -21,7 +21,9 @@ def __init__(self, loomweave_client: Any) -> None: self._resolver = SeiResolver(loomweave_client, SeiCapability.from_capabilities(capabilities)) def binding_for(self, qualname: str) -> EntityBinding | None: - return resolve_entity_binding(self._client, self._resolver, qualname) + # Decorator coverage is a Python-surface report (@trust_boundary/@trusted + # decorators), so the producer is known — send the ADR-036 plugin hint. + return resolve_entity_binding(self._client, self._resolver, qualname, plugin="python") def build_weft_decorator_coverage( diff --git a/src/wardline/weft_dossier.py b/src/wardline/weft_dossier.py index a8302a9a..118a3f48 100644 --- a/src/wardline/weft_dossier.py +++ b/src/wardline/weft_dossier.py @@ -42,7 +42,7 @@ class _LoomweaveClient(Protocol): composes with the narrower provider/resolver Protocols downstream).""" def capabilities(self) -> dict[str, Any] | None: ... - def resolve(self, qualnames: list[str]) -> Any: ... + def resolve(self, qualnames: list[str], *, plugin: str | None = None) -> Any: ... def resolve_identity(self, locator: str) -> dict[str, Any] | None: ... def resolve_sei(self, sei: str) -> dict[str, Any] | None: ... def get_callers(self, entity_id: str, *, limit: int = ...) -> LinkageResult | None: ... diff --git a/tests/conformance/filigree_suppression_filter_contract.json b/tests/conformance/filigree_suppression_filter_contract.json new file mode 100644 index 00000000..7bcb6993 --- /dev/null +++ b/tests/conformance/filigree_suppression_filter_contract.json @@ -0,0 +1,20 @@ +{ + "contract": "weft/wardline-filigree-suppression-filter", + "version": 1, + "owner": "wardline", + "description": "Wardline-owned suppression_state vocabulary consumed by Filigree's finding-list suppression filter. Filigree adds only the local 'all' no-filter sentinel; every other accepted filter value must come from Wardline's SuppressionState enum.", + "suppression_states": [ + "active", + "baselined", + "waived", + "judged" + ], + "filigree_filter_sentinel": "all", + "filigree_filter_values": [ + "active", + "all", + "baselined", + "judged", + "waived" + ] +} diff --git a/tests/conformance/fixtures/PROVENANCE.md b/tests/conformance/fixtures/PROVENANCE.md new file mode 100644 index 00000000..0c9f7b25 --- /dev/null +++ b/tests/conformance/fixtures/PROVENANCE.md @@ -0,0 +1,10 @@ +# Vendored SEI conformance oracle fixture + +`sei-conformance-oracle.json` is a verbatim copy of the shared, normative +fixture from: + + /home/john/loomweave/docs/federation/fixtures/sei-conformance-oracle.json + +It is vendored so Wardline CI can run the SEI consumer oracle without requiring +the Loomweave checkout. `tests/conformance/test_sei_oracle.py` compares this +copy against the sibling authority fixture when the checkout is present. diff --git a/tests/conformance/fixtures/sei-conformance-oracle.json b/tests/conformance/fixtures/sei-conformance-oracle.json new file mode 100644 index 00000000..0ea57702 --- /dev/null +++ b/tests/conformance/fixtures/sei-conformance-oracle.json @@ -0,0 +1,85 @@ +{ + "_meta": { + "contract": "weft-sei-conformance-oracle", + "standard": "Weft Stable Entity Identity (SEI) conformance standard §8", + "authority": "Loomweave ADR-038 (token/signature/persistence/reserved-namespace); SEI standard (suite-wide)", + "fixture_version": 1, + "stability": "normative", + "token_format_agnostic": true, + "verification": "cargo test -p loomweave-storage --test sei_conformance_oracle", + "updated": "2026-06-02", + "description": "The six shared SEI conformance scenarios every Weft tool runs against a reference Loomweave. Asserts BEHAVIOUR and OPACITY, never the SEI's internal form. A subsystem is SEI-conformant only when it passes all six (no grandfathering)." + }, + "invariants": [ + "SEI is opaque: a consumer never parses it. It carries the reserved `loomweave:eid:` prefix and is NOT a locator.", + "Fail-closed: when sameness cannot be PROVEN, mint a new SEI and orphan the old one — never silently re-point.", + "Lineage is append-only: born / locator_changed / moved / orphaned / superseded.", + "Identity is carried (never re-minted) for an unchanged locator; SEI values are not part of the byte-identical-run guarantee, but carry/mint decisions are deterministic given the same bindings + source." + ], + "scenarios": [ + { + "id": "identity_round_trip_and_opacity", + "given": "A function entity is analyzed for the first time.", + "when": "Mint an SEI; resolve(locator) → sei; resolve_sei(sei) → locator.", + "expect": { + "resolve_locator": { "sei": "", "current_locator": "", "content_hash": "", "alive": true }, + "resolve_sei": { "current_locator": "", "alive": true }, + "opacity": "the returned `sei` begins with `loomweave:eid:` and is treated as an opaque string by the consumer (never parsed); it is not equal to the locator" + } + }, + { + "id": "rename", + "given": "An entity with an alive SEI; its file/module is renamed so the locator prefix changes; the body is byte-identical; a git-rename signal maps old_locator → new_locator.", + "when": "Re-index.", + "expect": { + "carry": true, + "sei": "unchanged (same token as before)", + "current_locator": "the new locator", + "lineage_appends": "locator_changed", + "resolve_locator(old)": { "alive": false }, + "resolve_locator(new)": { "alive": true, "sei": "" } + } + }, + { + "id": "move", + "given": "An entity with an alive SEI is moved to a new module; body hash AND signature are identical at the new locator; exactly one vanished candidate matches; no git signal required.", + "when": "Re-index.", + "expect": { + "carry": true, + "sei": "unchanged", + "lineage_appends": "moved" + } + }, + { + "id": "ambiguous", + "given": "An entity is renamed WITH a body edit (the body hash changes), even if a git-rename signal is present.", + "when": "Re-index.", + "expect": { + "carry": false, + "new_entity": "minted a fresh SEI (born)", + "old_binding": "orphaned (resolve_sei → alive:false with an `orphaned` lineage event)", + "rationale": "the matcher cannot PROVE sameness → fail closed; a governance attestation on the old SEI is never silently carried across an unproven match" + } + }, + { + "id": "delete", + "given": "An entity present in a prior run is absent from the current run and was not rematched by a rename/move.", + "when": "Re-index.", + "expect": { + "old_binding": "orphaned", + "resolve_locator(old)": { "alive": false }, + "resolve_sei(old_sei)": { "alive": false, "lineage": "includes an `orphaned` event" } + } + }, + { + "id": "capability_absent", + "given": "A Loomweave instance that has not populated SEI (pre-SEI DB, or `_capabilities.sei.supported` false / absent).", + "when": "A consumer probes `_capabilities` and/or resolves.", + "expect": { + "consumer": "detects the absent capability and DEGRADES gracefully — keeps working on locators, no crash, honest 'identity unavailable'", + "resolve_locator(any)": { "alive": false }, + "resolve_sei(unknown)": { "alive": false, "lineage": [] } + } + } + ] +} diff --git a/tests/conformance/legis_dirty_scan_wire.golden.json b/tests/conformance/legis_dirty_scan_wire.golden.json new file mode 100644 index 00000000..ddf57d02 --- /dev/null +++ b/tests/conformance/legis_dirty_scan_wire.golden.json @@ -0,0 +1,43 @@ +{ + "contract": "weft/wardline-dirty-scan-artifact", + "version": 1, + "description": "Shared Weft conformance vector for the unsigned Wardline->legis dirty dev-artifact path. Wardline emits this shape when scan --format legis --allow-dirty is used on a dirty working tree; Legis consumes the same dirty key to distinguish keyless dev, CI skip, and explicit dev-mode governance. The artifact is intentionally unsigned because signing dirty working-tree content would assert false tree provenance.", + "dirty_key": "dirty", + "signature_key": "artifact_signature", + "signing": { + "key_utf8": "test-shared-secret-key", + "policy": "dirty dev artifacts are never signed; a configured consumer key without dev-mode must return SKIPPED_DIRTY_TREE" + }, + "valid": [ + { + "name": "dirty_unsigned_dev_artifact", + "description": "Unsigned dirty-tree dev artifact: dirty must be strict boolean true, artifact_signature must be absent, and findings remains present even when empty.", + "artifact": { + "scanner_identity": "wardline@CONFORMANCE", + "rule_set_version": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "fingerprint_scheme": "wlfp2", + "findings": [], + "scan_scope": { + "schema": "wardline-legis-scan-scope-1", + "scan_root": ".", + "is_git_root": true, + "source_roots": [ + "." + ], + "resolved_source_roots": [ + "." + ], + "scanned_paths": [ + "svc.py" + ] + }, + "commit_sha": "cccccccccccccccccccccccccccccccccccccccc", + "tree_sha": "dddddddddddddddddddddddddddddddddddddddd", + "dirty": true + }, + "expected_keyless_artifact_status": "dirty", + "expected_ci_allow_dirty_artifact_status": "dirty", + "expected_ci_reject_reason": "SKIPPED_DIRTY_TREE" + } + ] +} diff --git a/tests/conformance/legis_scan_wire.golden.json b/tests/conformance/legis_scan_wire.golden.json new file mode 100644 index 00000000..a418fee4 --- /dev/null +++ b/tests/conformance/legis_scan_wire.golden.json @@ -0,0 +1,37 @@ +{ + "scanner_identity": "wardline@CONFORMANCE", + "rule_set_version": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "fingerprint_scheme": "wlfp2", + "findings": [ + { + "rule_id": "PY-WL-101", + "message": "svc.leaky declares return trust INTEGRAL but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", + "severity": "ERROR", + "kind": "defect", + "fingerprint": "9a291cac4a30b2cd8353f89eb428e184b01cb3919563ebeffd672745bf9cc665", + "qualname": "svc.leaky", + "properties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "INTEGRAL" + }, + "suppression_state": "active" + } + ], + "scan_scope": { + "schema": "wardline-legis-scan-scope-1", + "scan_root": ".", + "is_git_root": true, + "source_roots": [ + "." + ], + "resolved_source_roots": [ + "." + ], + "scanned_paths": [ + "svc.py" + ] + }, + "commit_sha": "0000000000000000000000000000000000000000", + "tree_sha": "1111111111111111111111111111111111111111", + "artifact_signature": "hmac-sha256:v2:15ffe67f96e2298d608d901cfd72328b77ee7235b9418ff0c190a168f0e429b5" +} diff --git a/tests/conformance/qualnames_rust.json b/tests/conformance/qualnames_rust.json new file mode 100644 index 00000000..d81fb975 --- /dev/null +++ b/tests/conformance/qualnames_rust.json @@ -0,0 +1,814 @@ +{ + "_doc": "Shared RUST qualname conformance corpus (Loomweave <-> Wardline). Mirrors tests/conformance/qualnames.json (the Python corpus) but for the Rust dialect. For Rust, LOOMWEAVE IS AUTHORITATIVE: the dialect is fixed by ADR-049 (docs/loomweave/adr/ADR-049-rust-qualname-canonicalization.md) and these 'expected' values were GENERATED by running the loomweave-plugin-rust extractor (extract_file / module_path_for), never hand-authored. Loomweave hosts this file at fixtures/qualnames_rust.json; Wardline vendors a pinned copy to tests/conformance/qualnames_rust.json and must reproduce 'expected' BYTE-FOR-BYTE from its tree-sitter frontend. Inverts the Python arrangement (Wardline seeded qualnames.json; here Loomweave seeds) because Loomweave's whole-tree extractor is the oracle that defines the Rust locator. The parity test crates/loomweave-plugin-rust/tests/qualname_conformance.rs fails loudly on any divergence: fix the extractor or this fixture, never silently. Nothing imports this file at runtime. SECTIONS: `module_route` rows drive the pure-filesystem module_path_for(crate, src_root, file) directly (no real files, no declaring-file context); `module_mounts` rows (ADR-049 Amendment 8) pin #[path]-mounted routing — the harness materialises each row's `files` into a tempdir (synthesising a Cargo.toml from `crate`), runs discover_crate_roots + discover_mounts, and asserts logical_module_path(crate, src/, file, mounts) == `expect[file]` byte-for-byte; `entities` rows drive extract_file.", + "_dialect_summary": { + "delimiter": ".", + "crate_rooted": "first segment is the crate name, '-' normalised to '_' (loomweave-cli -> loomweave_cli); discovered from Cargo.toml [package].name read as TEXT, longest-path-prefix match (crate_roots.rs). Falls back to the dir holding src/lib.rs|src/main.rs.", + "module_route": "crate root + mod tree to the item; lib.rs/main.rs/mod.rs contribute NO segment; inline `mod foo {}` nests. #[path]-mounted modules route to their MOUNTED logical path (ADR-049 Amendment 8): a targeted mount overlay (exact file + mounted-subtree prefixes, twin mounts split by @cfg) with the filesystem route as the default — see the module_mounts section. Macro-wrapped mounts are invisible (filesystem fallback); out-of-src targets are ignored.", + "free_items": ".. for EVERY named free-item kind: function, struct, enum, trait, type_alias, const, static, macro (the macro_rules! definition). Trait BODIES are deliberately not walked: a trait definition emits only its own `trait` entity, never its method items.", + "self_type_generic_args": " in every impl locator carries the self type's CONCRETE generic arguments (ADR-049 §2 amendment): impl Foo -> Foo, impl Foo -> Foo, so the two impls (and their like-named methods) get distinct ids instead of colliding on a bare `Foo` key and being spuriously merged (silent data loss at the writer's ON CONFLICT(id) DO UPDATE). A self-type arg that is the impl's OWN declared generic param renders POSITIONALLY ($0,$1,... De Bruijn) — impl Foo -> Foo<$0> — so a param rename does not churn; concrete args (i32, String, const, nested generics) render whitespace-free; lifetimes/associated-type bindings dropped. A non-generic self type (impl Foo) renders bare `Foo`. Composition order: .|impl[Trait]>[@cfg(...)].", + "trait_impl_method": ".impl[]. e.g. Foo.impl[Display].fmt, Foo.impl[From].from, Foo.impl[Display].fmt. Lifetimes dropped; concrete type/const generic args KEPT in BOTH the self-type prefix and the trait fragment (they are part of the key).", + "inherent_impl_method": ".impl#. e.g. Foo.impl#<>.bar, Foo.impl#<$0>.get, Foo.impl#<>.get. The self-type prefix carries concrete instantiation args (Foo vs Foo are distinct) with the impl's own declared params rendered positionally ($0). The trailing #<...> signature renders the impl's declared generic params POSITIONALLY ($0,$1,... De Bruijn) so a param rename does not churn. There is NO source-order ordinal (ADR-049 amend, Option b): same-(self-type-args, positional-generic-signature, cfg) inherent impls MERGE into ONE `impl` entity and their methods all hang off it, so the discriminator is genuinely source-order-independent (reorder-stable). DIFFERENT concrete self-type args (Foo vs Foo) do NOT merge. cfg-twin inherent impls (same type+args+sig, mutually-exclusive cfgs) are split by an @cfg(...) suffix on the impl key (Foo.impl#<>@cfg(unix) vs Foo.impl#<>@cfg(windows)); their methods inherit it.", + "impl_entity": ".impl[] (trait) or .impl# (inherent), kind=impl. The impl block is its own entity, parented to the enclosing module (module->impl contains); each impl method is parented to the impl entity (impl->method contains), NOT the module. Same-(self-type-args,generic-sig,cfg) inherent impls collapse to ONE impl entity (the merge); DIFFERENT concrete self-type args stay distinct; cfg-twins split by @cfg.", + "cfg_twins": "an item whose bare path collides with a cfg-gated sibling (all cfg variants are visible) gets a normalised @cfg() suffix: f@cfg(unix). Predicate whitespace-stripped, any()/all() args sorted. A unique-path item gets NO suffix. cfg-twin discrimination is item-general AND impl-general: free items (cfg_twin, struct_cfg_twin), INHERENT impls (inherent_impl_cfg_twin -> impl#<>@cfg(unix)) and TRAIT impls (trait_impl_cfg_twin -> impl[Display]@cfg(unix)) all carry it. For impls the @cfg suffix lands on the impl key (after impl# / after impl[Trait]) and each method inherits it; post-ordinal-drop it is the SOLE thing preventing cfg-twin impls from merging into one entity and silently dropping a method.", + "async": "async fn renders IDENTICALLY to a sync fn (no suffix).", + "kind": "the entity-ID kind segment is 'function' for EVERY Rust callable (free fn, inherent method, associated fn, trait method) and 'struct'/'module' for those. There is NO 'method' id-kind. A semantic function/method distinction, if a consumer needs it, must ride Entity metadata, not the locator.", + "closures_and_nested_fns": "NOT emitted as entities. The extractor never descends into function bodies, so closures and nested `fn` items have no locator. A finding inside one must be attributed to the nearest enclosing named item. This deliberately avoids the positional {closure#N} churn that would destabilise the SEI locator.", + "macros": "macro-generated items are NOT indexed (syn does not expand; tree-sitter cannot see expansion). Both engines agree the generated items do not exist. The macro_rules! definition itself is Phase 1b.", + "reproducibility_tiers": "Each case carries 'reproducibility': 'slice-1' = the qualname SUFFIX (everything the single file's syntax determines: impl disc, cfg, within-file ordinal, generics, locals-folding) is reproducible by a one-file tree-sitter pass NOW. 'sp2' = needs Loomweave's whole-tree view (crate-name-from-Cargo.toml, cross-file module route, #[path], cross-file inherent-impl ordinals). The CRATE-ROOT PREFIX of every entity is itself sp2 until Wardline reads manifests; slice-1 cases are reproducible modulo that shared prefix caveat." + }, + "_consumer_comparison": "HOW A SECOND PRODUCER (e.g. Wardline) COMPARES AGAINST THIS CORPUS — read before vendoring. The `entities[].expected` list is Loomweave's FULL emission in source order: it includes `module` entities (the file-scope module + every nested inline `mod`) AND `impl` entities (one per impl block, post-merge), and its `kind` field is the entity-ID kind segment, which is `function` for EVERY callable (free fn, method, assoc fn) — there is no semantic `method` kind. A finding-producer that emits only function/method entities (as Wardline's discover_file_entities does — modules and impl blocks are scope-only, and §6.2 tags impl fns `kind=method`) MUST NOT compare with raw list-equality against `expected`, or it will fail on rows it never emits. The contract is: (1) the BYTE-EXACT obligation is the `qualname` of every NON-`module`, NON-`impl` row — that is the string folded into the fingerprint, and it must match character-for-character; (2) `kind` is informational here (the locator id-kind), NOT a semantic tag — Wardline's semantic `method` corresponds to this corpus's id-kind `function` for impl-scoped fns (the documented kind boundary, analogous to the `None <-> \"\"` module boundary in loomweave_qualname_parity.json), so either map `method->function` or compare qualname-only; (3) `module` rows are validated against the `module_route` section (the path->module routing), NOT re-emitted as entities; `impl` rows are scope entities that a method-only consumer likewise does not emit (skip them exactly as `module` rows). So the recommended Wardline parity test: extract the file, take your function/method entities, and assert each produces a `qualname` present in this case's non-`module`/non-`impl` `expected` qualnames. Do not edit the vendored copy to drop rows — apply this comparison rule instead.", + "module_route": [ + {"name": "lib_root", "crate": "demo", "src_root": "/p/src", "file": "/p/src/lib.rs", "expected_module": "demo", "reproducibility": "slice-1", "note": "lib.rs contributes no segment; module IS the crate root."}, + {"name": "main_root", "crate": "demo", "src_root": "/p/src", "file": "/p/src/main.rs", "expected_module": "demo", "reproducibility": "slice-1", "note": "main.rs contributes no segment."}, + {"name": "flat_file", "crate": "demo", "src_root": "/p/src", "file": "/p/src/config.rs", "expected_module": "demo.config", "reproducibility": "slice-1"}, + {"name": "nested_file", "crate": "demo", "src_root": "/p/src", "file": "/p/src/plugin/host.rs", "expected_module": "demo.plugin.host", "reproducibility": "slice-1"}, + {"name": "mod_rs_collapse", "crate": "demo", "src_root": "/p/src", "file": "/p/src/plugin/mod.rs", "expected_module": "demo.plugin", "reproducibility": "slice-1", "note": "mod.rs contributes no segment; collapses to its directory."}, + {"name": "path_attr_known_gap", "crate": "demo", "src_root": "/p/src", "file": "/p/src/renamed.rs", "expected_module": "demo.renamed", "reproducibility": "sp2", "note": "FALLBACK pin (ADR-049 Amendment 8). module_route rows drive module_path_for directly — a bare (crate, src_root, file) call with NO declaring-file context — and module_path_for stays the PURE-FILESYSTEM route by design: it emits demo.renamed even when some parent file carries `#[path=\"renamed.rs\"] mod logical;`. #[path]-aware routing is the SEPARATE entry point logical_module_path (mount overlay first, module_path_for default), pinned by the module_mounts section below; a route with no mount covering the file MUST be byte-identical to this row. Historical: this row was the pre-Amendment-8 known_gap pin of the then-wrong emission."} + ], + "module_mounts": [ + {"name": "path_mount_dir_module", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "#[cfg(unix)]\n#[path = \"unix/mod.rs\"]\nmod imp;\n\n#[cfg(windows)]\n#[path = \"windows/mod.rs\"]\nmod imp;\n\n#[cfg(unix)]\npub(crate) mod unix {\n pub(crate) use super::imp::*;\n}\n", + "src/unix/mod.rs": "pub(crate) fn spawn() {}\n", + "src/windows/mod.rs": "pub(crate) fn spawn() {}\n"}, + "expect": { + "src/lib.rs": "demo", + "src/unix/mod.rs": "demo.imp@cfg(unix)", + "src/windows/mod.rs": "demo.imp@cfg(windows)"}, + "note": "The tokio src/process shape (clarion-bdb1eccf48): cfg-twin `mod imp;` mounts plus an inline facade. The TWIN rule counts module-name occurrences in the declaring item list across BOTH inline `mod n { }` and decl `mod n;` forms; a twin mount appends the normalised @cfg(...) discriminant, so the two mounted files split. The inline facade `mod unix` is a unique name and emits demo.unix as an ENTITY (entity emission, not file routing — covered by the extractor, not this section)."}, + {"name": "path_mount_child_prefix", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "#[path = \"engine_v2/mod.rs\"]\nmod engine;\n#[path = \"compat_v3.rs\"]\nmod compat;\n", + "src/engine_v2/mod.rs": "pub mod worker;\n", + "src/engine_v2/worker.rs": "pub fn run() {}\n", + "src/compat_v3.rs": "pub mod extra;\n", + "src/compat_v3/extra.rs": "pub fn e() {}\n"}, + "expect": { + "src/engine_v2/mod.rs": "demo.engine", + "src/engine_v2/worker.rs": "demo.engine.worker", + "src/compat_v3.rs": "demo.compat", + "src/compat_v3/extra.rs": "demo.compat.extra"}, + "note": "Mounted SUBTREES: a /mod.rs target registers / as a logical prefix (children rewrite under the mount, trailing `mod` stem collapses); an x.rs target registers the exact file PLUS /x/ for its child directory (rustc's non-mod-rs child rule)."}, + {"name": "path_mount_chain", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "#[path = \"stage_a.rs\"]\nmod first;\n", + "src/stage_a.rs": "#[path = \"stage_b.rs\"]\nmod second;\npub fn a() {}\n", + "src/stage_b.rs": "pub fn b() {}\n"}, + "expect": { + "src/stage_a.rs": "demo.first", + "src/stage_b.rs": "demo.first.second"}, + "note": "Chained mounts resolve through the fixed point: stage_b.rs's logical parent is stage_a.rs's MOUNTED path (demo.first), not its filesystem path (demo.stage_a). A file-level #[path] target is relative to the DECLARING file's directory."}, + {"name": "path_mount_macro_invisible_fallback", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "macro_rules! mount {\n () => {\n #[path = \"hidden_impl.rs\"]\n mod hidden;\n };\n}\nmount!();\n", + "src/hidden_impl.rs": "pub fn h() {}\n"}, + "expect": { + "src/hidden_impl.rs": "demo.hidden_impl"}, + "note": "A #[path] inside an unexpanded macro invocation is INVISIBLE by dialect rule (no producer expands macros; the route must be reproducible from one file) — the target routes by filesystem. Load-bearing: do NOT attempt macro expansion."}, + {"name": "path_mount_inline_nested_decl", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "mod platform;\nmod host {\n #[path = \"generic.rs\"]\n mod imp;\n}\n", + "src/platform.rs": "mod backend {\n #[path = \"special.rs\"]\n mod imp;\n}\n", + "src/host/generic.rs": "pub fn g() {}\n", + "src/platform/backend/special.rs": "pub fn s() {}\n"}, + "expect": { + "src/platform.rs": "demo.platform", + "src/host/generic.rs": "demo.host.imp", + "src/platform/backend/special.rs": "demo.platform.backend.imp"}, + "note": "rustc's relative-path rule for #[path] INSIDE inline mod blocks: the target is relative to the would-be directory of the inline-module nesting — for a mod-rs file (lib.rs/main.rs/mod.rs) that starts at the file's own directory (src/ + host/ here); for a non-mod-rs file it starts at the file's stem directory (src/platform/ + backend/ here)."}, + {"name": "path_mount_inside_cfg_twin_inline_mod", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "#[cfg(feature = \"a\")]\nmod outer {\n #[path = \"x.rs\"]\n mod m;\n}\n\n#[cfg(not(feature = \"a\"))]\nmod outer {\n #[path = \"y.rs\"]\n mod m;\n}\n", + "src/outer/x.rs": "pub fn fx() {}\n", + "src/outer/y.rs": "pub fn fy() {}\n"}, + "expect": { + "src/outer/x.rs": "demo.outer@cfg(feature=\"a\").m", + "src/outer/y.rs": "demo.outer@cfg(not(feature=\"a\")).m"}, + "note": "The cfg-twin-inline-mod composition rule (ADR-049 Amendment 8, remediation): a mount declared INSIDE a cfg-twin inline mod composes the inline mod's @cfg-suffixed segment into its logical prefix — the SAME twin counting + cfg_discriminant rendering the AST walk applies to the inline mod's own entity path, so file-walk and AST-walk agree byte-for-byte and the two mounted files split. Composing the BARE inline name instead routes BOTH x.rs and y.rs to demo.outer.m (a duplicate-id family). The #[path] target still resolves against the BARE would-be directory (src/outer/), which carries no cfg."}, + {"name": "path_mount_lone_cfg_no_suffix", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "#[cfg(unix)]\n#[path = \"unix/mod.rs\"]\nmod imp;\n", + "src/unix/mod.rs": "pub(crate) fn spawn() {}\n"}, + "expect": { + "src/unix/mod.rs": "demo.imp"}, + "note": "The @cfg discriminant on a mount is TWIN-GATED (ADR-049 §3 discipline): a lone #[cfg(unix)] #[path] mod imp; with no same-name sibling in the declaring item list keeps the bare mounted path — NO @cfg suffix. Gate-off pin for the cross-producer corpus."}, + {"name": "path_mount_one_file_two_mounts_first_wins", "crate": "demo", "reproducibility": "sp2", + "files": { + "src/lib.rs": "#[path = \"shared_impl.rs\"]\nmod alpha;\n#[path = \"shared_impl.rs\"]\nmod beta;\nmod zeta;\n", + "src/zeta.rs": "#[path = \"shared2_impl.rs\"]\nmod gamma;\n", + "src/shared_impl.rs": "pub fn s() {}\n", + "src/shared2_impl.rs": "pub fn s2() {}\n"}, + "expect": { + "src/shared_impl.rs": "demo.alpha", + "src/shared2_impl.rs": "demo.zeta.gamma"}, + "note": "R5 determinism pin: one target file claimed by several mounts resolves to the FIRST by sorted (declaring-file path relative to the project root, byte offset of the declaration) — shared_impl.rs takes `alpha` (lower byte offset in lib.rs), never `beta`. shared2_impl.rs pins that an unconflicted mount from a later-sorting declaring file is untouched by the rule."} + ], + "entities": [ + { + "name": "free_fns_and_struct", + "crate": "demo", "module_path": "demo.config", "rel_path": "src/config.rs", + "reproducibility": "slice-1", + "source": "pub struct Widget { a: i32 }\npub fn helper(x: i32) -> bool { x > 0 }\n", + "expected": [ + {"qualname": "demo.config", "kind": "module"}, + {"qualname": "demo.config.Widget", "kind": "struct"}, + {"qualname": "demo.config.helper", "kind": "function"} + ] + }, + { + "name": "inherent_method", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "source": "struct Foo;\nimpl Foo { fn bar(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.bar", "kind": "function"} + ] + }, + { + "name": "trait_method", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "source": "struct Foo;\nimpl std::fmt::Display for Foo { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[Display]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Display].fmt", "kind": "function"} + ] + }, + { + "name": "trait_method_collision", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Display::fmt and Debug::fmt on the same type stay distinct because the method carries its impl's trait discriminator.", + "source": "struct Foo;\nimpl std::fmt::Display for Foo { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\nimpl std::fmt::Debug for Foo { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[Display]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Display].fmt", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[Debug]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Debug].fmt", "kind": "function"} + ] + }, + { + "name": "multiple_inherent_merge", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Two inherent impls of one type in one file MERGE to ONE `impl` entity (ADR-049 amend, Option b): no source-order ordinal, so both blocks share `demo.m.Foo.impl#<>` and their methods (`a`, `b`) both hang off that single impl entity. The `impl` entity is emitted once (at the first block); the second block contributes only its method. Reorder-stable: swapping the two blocks does not churn any id.", + "source": "struct Foo;\nimpl Foo { fn a(&self) {} }\nimpl Foo { fn b(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.a", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>.b", "kind": "function"} + ] + }, + { + "name": "generic_trait_impl_concrete_args", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Concrete trait generic args are part of the key: From and From are distinct impls.", + "source": "struct Foo;\nimpl From for Foo { fn from(x: i32) -> Self { Foo } }\nimpl From for Foo { fn from(x: u32) -> Self { Foo } }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[From]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[From].from", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[From]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[From].from", "kind": "function"} + ] + }, + { + "name": "generic_self_inherent_concrete_args", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "SILENT-DATA-LOSS trip-wire (ADR-049 §2 self-type-args amendment): two INHERENT impls on distinct CONCRETE instantiations of a generic self type. The concrete self-type arg is folded into the type-name prefix (Foo vs Foo), so the two impls get DISTINCT impl ids AND their like-named `get` methods get distinct ids. Without the self-type arg both would render `Foo.impl#<>` and seen_impl_ids would spuriously merge them, silently overwriting one `get` at the writer's ON CONFLICT(id) DO UPDATE.", + "source": "struct Foo(T);\nimpl Foo { fn get(&self) {} }\nimpl Foo { fn get(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.get", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.get", "kind": "function"} + ] + }, + { + "name": "generic_self_trait_concrete_args", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Trait-arm twin of generic_self_inherent_concrete_args: impl Display for Foo and impl Display for Foo. The trait fragment is identical (impl[Display]); the concrete self-type arg in the prefix (Foo vs Foo) is the SOLE thing keeping the two impls (and their like-named `fmt` methods) distinct.", + "source": "struct Foo(T);\nimpl std::fmt::Display for Foo { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\nimpl std::fmt::Display for Foo { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[Display]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Display].fmt", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[Display]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Display].fmt", "kind": "function"} + ] + }, + { + "name": "generic_self_same_concrete_two_blocks_merge", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "PRESERVES THE LEGITIMATE MERGE: two inherent impls on the SAME concrete instantiation (both Foo) split a type's methods across blocks. Same self-type-args + same generic-sig + same cfg => ONE impl key => the two blocks MERGE into one impl entity carrying both `a` and `b`. Only DIFFERENT concrete args (Foo vs Foo) stop merging; same args still merge exactly as the bare non-generic case (multiple_inherent_merge).", + "source": "struct Foo(T);\nimpl Foo { fn a(&self) {} }\nimpl Foo { fn b(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.a", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>.b", "kind": "function"} + ] + }, + { + "name": "positional_generic_param", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Inherent-impl generic params render positionally ($0) in BOTH the self-type-arg prefix (Foo<$0>) and the inherent #<…> signature. A self-type arg that is the impl's OWN declared param is rendered positionally and is rename-stable, so impl Foo and impl Foo are byte-identical — see positional_generic_param_renamed. Contrast generic_self_inherent_concrete_args, where i32/u32 are CONCRETE self-type args that split the key.", + "source": "struct Foo(T);\nimpl Foo { fn get(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo<$0>.impl#<$0>", "kind": "impl"}, + {"qualname": "demo.m.Foo<$0>.impl#<$0>.get", "kind": "function"} + ] + }, + { + "name": "positional_generic_param_renamed", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Same as positional_generic_param with T renamed to U; expected qualname is byte-identical (positional De Bruijn rendering in both the self-type-arg prefix Foo<$0> and the #<$0> signature), pinning rename-stability at the corpus level.", + "source": "struct Foo(U);\nimpl Foo { fn get(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo<$0>.impl#<$0>", "kind": "impl"}, + {"qualname": "demo.m.Foo<$0>.impl#<$0>.get", "kind": "function"} + ] + }, + { + "name": "generic_self_nested_param", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "F2 NESTED-PARAM trip-wire (ADR-049 §2 self-type-args amendment, rename-stability limitation). The impl's declared param T appears NESTED inside another type arg (Vec), not at top level. A nested declared param is NOT positionally substituted: the whole self-type arg renders via type_textual (literal `T`), so the self-type prefix is Foo>, NOT recursive Foo>. Contrast positional_generic_param, where the TOP-LEVEL declared param renders positionally ($0). The impl's own inherent signature still renders its declared param positionally (#<$0>). CROSS-TOOL CONTRACT: a second producer (Wardline) MUST mirror this type_textual rendering of nested params; implementing recursive positional substitution (Foo>) would diverge, and THIS row is the conformance trip-wire that catches it. KNOWN LIMITATION: nested-param rendering is therefore NOT rename-stable (impl Foo> renders the distinct key Foo>) — strictly better than the pre-amendment behaviour (which silently dropped a method), but full recursive positional rendering is a deferred follow-up. See ADR-049 §2 and the federation change-set F2.", + "source": "struct Foo(T);\nimpl Foo> { fn get(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo>.impl#<$0>", "kind": "impl"}, + {"qualname": "demo.m.Foo>.impl#<$0>.get", "kind": "function"} + ] + }, + { + "name": "cfg_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Two same-path fns gated on mutually-exclusive cfgs; each gets a normalised @cfg() suffix. All cfg variants are visible (spec §5).", + "source": "#[cfg(unix)]\nfn f() {}\n#[cfg(windows)]\nfn f() {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.f@cfg(unix)", "kind": "function"}, + {"qualname": "demo.m.f@cfg(windows)", "kind": "function"} + ] + }, + { + "name": "struct_cfg_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "cfg-twin discrimination is ITEM-general, not functions-only (ADR-049 §3): a struct gated on mutually-exclusive cfgs gets the @cfg suffix too. Without it both variants emit `demo.m.S` and the writer's ON CONFLICT silently drops one. The same holds for inline `mod` twins. Counted per-kind, so `fn S` and `struct S` do not interfere.", + "source": "#[cfg(unix)]\nstruct S;\n#[cfg(windows)]\nstruct S;\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.S@cfg(unix)", "kind": "struct"}, + {"qualname": "demo.m.S@cfg(windows)", "kind": "struct"} + ] + }, + { + "name": "inherent_impl_cfg_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "cfg-twin INHERENT impls (same type, same positional-generic sig, mutually-exclusive cfgs). Post-ordinal-drop (ADR-049 amend, Option b) the @cfg(...) suffix on the impl KEY is the SOLE discriminant that stops these two blocks merging into one entity and silently dropping a method. The suffix lands ON the impl segment (impl#<>@cfg(unix)) and each block's method inherits it (impl#<>@cfg(unix).go). Without it both `go` methods collapse to demo.m.Foo.impl#<>.go and the writer's ON CONFLICT drops one — the exact data-loss family the @cfg discriminator exists to prevent. A Wardline impl-extractor that omits the @cfg impl suffix would silently merge these; this case is the conformance trip-wire.", + "source": "struct Foo;\n#[cfg(unix)] impl Foo { fn go(&self){} }\n#[cfg(windows)] impl Foo { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(unix)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(unix).go", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(windows)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(windows).go", "kind": "function"} + ] + }, + { + "name": "trait_impl_cfg_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "cfg-twin TRAIT impls (same type, same trait+concrete-generics, mutually-exclusive cfgs). The @cfg(...) suffix sits AFTER the impl[Trait] bracket (impl[Display]@cfg(unix)) and the method inherits it (impl[Display]@cfg(unix).fmt). Without it both `fmt` methods collapse to demo.m.Foo.impl[Display].fmt and one is dropped. Trait-impl cfg-twins are split by @cfg exactly like inherent ones (ADR-049 §1, amended: cfg-twins now apply to trait impls too). Pins that a Wardline trait-impl extractor must render @cfg on the trait-impl key, not just the inherent one.", + "source": "struct Foo;\n#[cfg(unix)] impl std::fmt::Display for Foo { fn fmt(&self,_:&mut std::fmt::Formatter)->std::fmt::Result{Ok(())} }\n#[cfg(windows)] impl std::fmt::Display for Foo { fn fmt(&self,_:&mut std::fmt::Formatter)->std::fmt::Result{Ok(())} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[Display]@cfg(unix)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Display]@cfg(unix).fmt", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[Display]@cfg(windows)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Display]@cfg(windows).fmt", "kind": "function"} + ] + }, + { + "name": "async_fn_renders_identically", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "async fn carries no suffix; afn and sfn differ only by name. Wardline's index walk must not skip the async modifier node.", + "source": "pub async fn afn() {}\npub fn sfn() {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.afn", "kind": "function"}, + {"qualname": "demo.m.sfn", "kind": "function"} + ] + }, + { + "name": "nested_fn_is_not_an_entity", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "DIVERGENCE FROM WARDLINE'S PROPOSAL: `fn inner` nested in `fn outer` is NOT emitted (no demo.m.outer..inner). The extractor never descends into bodies. A finding in `inner` attributes to demo.m.outer.", + "source": "fn outer() {\n fn inner() {}\n}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.outer", "kind": "function"} + ] + }, + { + "name": "closure_is_not_an_entity", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "DIVERGENCE FROM WARDLINE'S PROPOSAL: a closure is NOT emitted (no demo.m.f..{closure#0}). Kills the positional churn problem. A finding in the closure attributes to demo.m.f.", + "source": "fn f() {\n let g = || 1;\n let _ = g();\n}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.f", "kind": "function"} + ] + }, + { + "name": "nested_inline_mod", + "crate": "demo", "module_path": "demo", "rel_path": "src/lib.rs", + "reproducibility": "slice-1", + "note": "Inline `mod inner {}` produces a nested module entity AND prefixes its items. (Crate-prefix `demo` is the shared sp2 caveat; the inline-mod nesting itself is single-file.)", + "source": "pub fn top() {}\nmod inner {\n pub fn g() {}\n}\n", + "expected": [ + {"qualname": "demo", "kind": "module"}, + {"qualname": "demo.top", "kind": "function"}, + {"qualname": "demo.inner", "kind": "module"}, + {"qualname": "demo.inner.g", "kind": "function"} + ] + }, + { + "name": "same_type_name_distinct_module_scopes", + "crate": "demo", "module_path": "demo", "rel_path": "src/lib.rs", + "reproducibility": "slice-1", + "note": "There is no source-order ordinal (ADR-049 amend, Option b), so the two top-level inherent blocks of `Foo` MERGE to one `demo.Foo.impl#<>` entity carrying both `a` and `b`. The `Foo` inside `mod inner` is a DIFFERENT type (distinct module prefix), so its impl is the separate entity `demo.inner.Foo.impl#<>` carrying `c` — distinct by module path, not by ordinal. No counter to scope per module anymore; the module prefix alone keeps the two `Foo` types apart.", + "source": "struct Foo;\nimpl Foo { fn a(&self) {} }\nimpl Foo { fn b(&self) {} }\nmod inner {\n struct Foo;\n impl Foo { fn c(&self) {} }\n}\n", + "expected": [ + {"qualname": "demo", "kind": "module"}, + {"qualname": "demo.Foo", "kind": "struct"}, + {"qualname": "demo.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.Foo.impl#<>.a", "kind": "function"}, + {"qualname": "demo.Foo.impl#<>.b", "kind": "function"}, + {"qualname": "demo.inner", "kind": "module"}, + {"qualname": "demo.inner.Foo", "kind": "struct"}, + {"qualname": "demo.inner.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.inner.Foo.impl#<>.c", "kind": "function"} + ] + }, + { + "name": "macro_invocation_generates_no_entity", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "AGREEMENT: the `macro_rules! make` DEFINITION is a `macro` entity (Phase 1b, ADR-027 MINOR ontology bump). The COMPILE-TIME-GENERATED `generated` fn that `make!()` would expand to is NOT seen by either engine (syn does not expand; tree-sitter cannot), and the bare `make!()` INVOCATION emits nothing. So the file emits exactly the file-scope module and the macro definition; both engines must agree the generated item does not exist.", + "source": "macro_rules! make { () => { fn generated() {} } }\nmake!();\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.make", "kind": "macro"} + ] + }, + { + "name": "leaf_item_kinds", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Pins the five Phase 1b leaf kinds the corpus did not previously carry: enum, trait, type_alias, const, static (the macro_rules! definition is already pinned by macro_invocation_generates_no_entity). Each is a free item riding the same . qualname pattern as struct/function. The trait emits ONLY its own `trait` entity — trait bodies are deliberately not walked (extract.rs), so a trait method item would produce no entity.", + "source": "pub enum Color { Red }\npub trait Greet {}\npub type Alias = u8;\npub const LIMIT: u32 = 10;\npub static NAME: &str = \"x\";\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Color", "kind": "enum"}, + {"qualname": "demo.m.Greet", "kind": "trait"}, + {"qualname": "demo.m.Alias", "kind": "type_alias"}, + {"qualname": "demo.m.LIMIT", "kind": "const"}, + {"qualname": "demo.m.NAME", "kind": "static"} + ] + }, + { + "name": "stacked_cfg_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "STACKED-cfg twins: two inherent impls of one type each carry TWO #[cfg] attributes, differing only in the first. cfg_discriminant folds EVERY predicate (each normalised, the set sorted, joined with `&`), so the discriminants stay distinct (@cfg(feature=\"a\"&unix) vs @cfg(feature=\"b\"&unix)). Folding only the FIRST #[cfg] would hand both blocks the same suffix and merge them at the writer's ON CONFLICT, silently dropping one `go`. The suffix is order-independent: the predicates are sorted AFTER normalisation, not in source order. Each block's method inherits its impl key.", + "source": "struct Foo;\n#[cfg(feature = \"a\")]\n#[cfg(unix)]\nimpl Foo { pub fn go(&self) {} }\n#[cfg(feature = \"b\")]\n#[cfg(unix)]\nimpl Foo { pub fn go(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(feature=\"a\"&unix)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(feature=\"a\"&unix).go", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(feature=\"b\"&unix)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>@cfg(feature=\"b\"&unix).go", "kind": "function"} + ] + }, + { + "name": "cfg_escape_reserved_char", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "Reserved-char ESCAPE in cfg predicates: `feature = \"a:b\"` carries the reserved entity-id separator `:`; flowed verbatim into a qualname it would make build_entity_id reject the id and degrade the whole file. escape_reserved encodes injectively — `%` first (% -> %25), then `:` -> %3A — so feature=\"a:b\" renders feature=\"a%3Ab\" and a literal source `%3A` cannot alias it. Escape runs on the whitespace-stripped predicate BEFORE any any()/all() arg sorting.", + "source": "#[cfg(feature = \"a:b\")]\npub fn f() {}\n#[cfg(feature = \"c\")]\npub fn f() {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.f@cfg(feature=\"a%3Ab\")", "kind": "function"}, + {"qualname": "demo.m.f@cfg(feature=\"c\")", "kind": "function"} + ] + }, + { + "name": "leaf_kind_cfg_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "cfg-twin discrimination on a Phase 1b LEAF kind: the twin counter is per-(kind, name) across ALL nine named item kinds (extract.rs twin_counts), so two cfg-gated `const LIMIT` siblings get @cfg suffixes exactly like the function (cfg_twin) and struct (struct_cfg_twin) cases. Load-bearing for a second producer doing full-set comparison: a twin counter that only covers fn/struct would emit two colliding demo.m.LIMIT rows here.", + "source": "#[cfg(unix)]\npub const LIMIT: u32 = 1;\n#[cfg(windows)]\npub const LIMIT: u32 = 2;\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.LIMIT@cfg(unix)", "kind": "const"}, + {"qualname": "demo.m.LIMIT@cfg(windows)", "kind": "const"} + ] + }, + { + "name": "cfg_attr_comment_interposition", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "COMMENT INTERPOSITION between a #[cfg] attribute and its item: a `//` line comment (and, on the twin, a `///` doc comment) sits between the cfg and the fn. Comments are token-stream-invisible to syn — the cfg still attaches to the fn, so BOTH twins keep their @cfg discriminant. A `///` doc comment IS a syn attribute (#[doc = \"...\"], Meta::NameValue) but cfg_predicates filters Meta::List path=cfg only, so it contributes nothing. A producer whose attribute-to-item association resets on an interposed comment would emit a bare colliding `demo.m.f` here; this row is the conformance trip-wire.", + "source": "#[cfg(unix)]\n// platform note\npub fn f() {}\n#[cfg(windows)]\n/// doc comment\npub fn f() {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.f@cfg(unix)", "kind": "function"}, + {"qualname": "demo.m.f@cfg(windows)", "kind": "function"} + ] + }, + { + "name": "cfg_predicate_internal_comment", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "COMMENT INSIDE the cfg predicate: `any(unix, /* why */ windows)`. proc-macro2 token streams carry no comments, so cfg_predicates' tokens.to_string() never sees `/* why */`; normalise_pred then strips whitespace and sorts the any() args, yielding any(unix,windows). A producer that collects the predicate as raw source TEXT between the parens would smuggle the comment bytes into the discriminant and diverge; this row pins that the predicate is the TOKEN stream, not the source span.", + "source": "#[cfg(any(unix, /* why */ windows))]\npub fn g() {}\n#[cfg(target_os = \"macos\")]\npub fn g() {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.g@cfg(any(unix,windows))", "kind": "function"}, + {"qualname": "demo.m.g@cfg(target_os=\"macos\")", "kind": "function"} + ] + }, + { + "name": "path_typed_generic_arg_trait", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 4 (clarion-8245039f6b): a concrete trait generic arg that is itself a ::-path (impl From) carries the reserved ':' separator. Without escaping, build_entity_id rejects the id and degraded_aware collapses the WHOLE cleanly-parsed file to one syntax_error module. Every concrete generic arg renders through escape_reserved(strip_ws(arg)) — the same injective escape the cfg path pins — so std::io::Error renders std%3A%3Aio%3A%3AError in the trait fragment. Pins that a Wardline trait-impl extractor escapes path-typed generic args byte-for-byte.", + "source": "struct Foo;\nimpl From for Foo { fn from(_: std::io::Error) -> Self { Foo } }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[From]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[From].from", "kind": "function"} + ] + }, + { + "name": "path_typed_generic_arg_inherent", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 4 (clarion-8245039f6b): the path-typed concrete generic arg in the SELF-TYPE prefix (impl Foo) is escaped through the same escape_reserved(strip_ws(arg)) pipeline, so Foo renders Foo rather than dropping the file. Distinct paths stay distinct (injective).", + "source": "struct Foo(T);\nimpl Foo { fn get(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.get", "kind": "function"} + ] + }, + { + "name": "const_generic_arg_spacing", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 4 (clarion-8245039f6b): GenericArgument::Const was the dialect's one whitespace-bearing render (proc-macro2 token spacing: `{ 1 + 2 }`), unreproducible from a tree-sitter frontend. Amendment 4 routes const args through the SAME strip_ws as type args, yielding {1+2}. Pins that a second producer renders const generic args whitespace-free, not proc-macro2-spaced.", + "source": "struct Foo;\nimpl Foo<{ 1 + 2 }> { fn get(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo<{1+2}>.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo<{1+2}>.impl#<>.get", "kind": "function"} + ] + }, + { + "name": "method_cfg_twin_inherent", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 5 (clarion-dfeb905f46): two cfg-gated twin METHODS inside ONE inherent impl block. The impl-level @cfg cannot split them (one impl key, demo.m.Foo.impl#<>), so each method carries its OWN @cfg(...) suffix AFTER the method name (.go@cfg(unix)), mirroring the free-item rule. Without it both `go` render demo.m.Foo.impl#<>.go and the writer's ON CONFLICT silently keeps one. Pins that a second producer applies a method-level cfg suffix, not only an impl-level one.", + "source": "struct Foo;\nimpl Foo { #[cfg(unix)] fn go(&self){} #[cfg(windows)] fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.go@cfg(unix)", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>.go@cfg(windows)", "kind": "function"} + ] + }, + { + "name": "method_cfg_twin_trait", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 5 (clarion-dfeb905f46): cfg-gated twin methods inside ONE trait impl block. The method-level @cfg suffix lands after the method name on the trait-impl key (demo.m.Foo.impl[Tr].go@cfg(unix)). Pins that the method-level cfg discriminant applies to trait impls too, not only inherent ones.", + "source": "struct Foo;\nimpl Tr for Foo { #[cfg(unix)] fn go(&self){} #[cfg(windows)] fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[Tr]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Tr].go@cfg(unix)", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[Tr].go@cfg(windows)", "kind": "function"} + ] + }, + { + "name": "method_cfg_twin_cross_merged_block", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 5 (clarion-dfeb905f46), cross-merged-block variant: two SEPARATE inherent impl blocks on the same (type, sig, no-cfg) MERGE into one impl entity (Option b), each contributing a `go`. The merged `go` methods would collide unless the method-twin count spans ALL blocks sharing the final impl key (demo.m.Foo.impl#<>), not just one block. Pins that the method cfg discriminant is computed across merged blocks.", + "source": "struct Foo;\nimpl Foo { #[cfg(unix)] fn go(&self){} }\nimpl Foo { #[cfg(windows)] fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl#<>.go@cfg(unix)", "kind": "function"}, + {"qualname": "demo.m.Foo.impl#<>.go@cfg(windows)", "kind": "function"} + ] + }, + { + "name": "reference_self_type_path_escape", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 4 (self-type fallback completion, clarion-8245039f6b): a non-Type::Path self type (here a reference) that carries a ::-path falls through self_ty_locator's type_textual fallback. Without escaping it leaks a raw ':' and build_entity_id rejects the id, degrading the whole file (the serde ser/fmt.rs `impl Serializer for &mut fmt::Formatter` drop). The fallback now routes through the same escape_reserved(strip_ws(..)) pipeline as concrete generic args, so &core::cell::Cell renders &core%3A%3Acell%3A%3ACell. A ':'-free reference (&Foo) is unchanged (escape is a no-op).", + "source": "impl Tr for &core::cell::Cell { fn ok(&self) {} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.&core%3A%3Acell%3A%3ACell.impl[Tr]", "kind": "impl"}, + {"qualname": "demo.m.&core%3A%3Acell%3A%3ACell.impl[Tr].ok", "kind": "function"} + ] + }, + { + "name": "unnamed_const_skip", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 9 (clarion-83870dc534): an unnamed `const _` is NOT an entity \u2014 `_` is non-identifying (no cfg/ordinal/content discriminant can rescue a repeated `_` without churning SEI) and un-nameable (nothing can ever target it), so the extractor emits neither the entity, nor its contains edge, nor any references sites from its declared type/initializer. The skip is UNCONDITIONAL on ident == \"_\" (a lone anonymous const is skipped too \u2014 twin-gating would make the emitted set sibling-dependent). Pinned as ABSENT expected rows, precedent nested_fn_is_not_an_entity: no demo.m._ row may ever appear. A finding inside a `const _` body attributes to the enclosing module, the same convention as nested fns/closures. The named LIMIT sibling pins the skip as name-targeted, not kind-targeted.", + "source": "pub const LIMIT: u32 = 10;\nconst _: () = assert!(true);\nconst _: () = assert!(true);\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.LIMIT", "kind": "const"} + ] + }, + { + "name": "unnamed_const_cfg_twin_skip", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 9, the hard repro (all 15 rust-analyzer residuals): two `const _` items gated on the SAME cfg predicate. cfg-twin discrimination is useless here \u2014 byte-identical attrs hand both twins the IDENTICAL @cfg suffix \u2014 so the unconditional skip is the only duplicate-free rendering. Both items are skipped; only the module emits.", + "source": "#[cfg(target_pointer_width = \"64\")]\nconst _: () = ();\n#[cfg(target_pointer_width = \"64\")]\nconst _: () = ();\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"} + ] + }, + { + "name": "self_type_path_twin_trait", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 6 (clarion-8ff7f233fa), the Semaphore shape (tokio chan.rs bounded::Semaphore vs unbounded::Semaphore): two impls of the SAME trait for same-NAMED types from different modules. Pre-amendment both rendered demo.m.X.impl[T] (last-segment self-type base), seen_impl_ids spuriously merged them, and the like-named `go` methods collapsed at the writer's ON CONFLICT. Stage S of the residual-collision ladder (@cfg on bare keys -> S on post-cfg groups -> T on post-S groups -> method-@cfg on final keys) fires on the >=2 distinct written self-type-path witnesses and qualifies every member's base with its escaped written path (a%3A%3AX / b%3A%3AX). The witness carries NO generic args, so Amendment-3 positional normalization never falsely fires the gate. Pins that a second producer qualifies by the path AS WRITTEN, not a resolved/canonical path.", + "source": "pub trait T { fn go(&self); }\nmod a { pub struct X; }\nmod b { pub struct X; }\nimpl T for a::X { fn go(&self){} }\nimpl T for b::X { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.T", "kind": "trait"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.X", "kind": "struct"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.X", "kind": "struct"}, + {"qualname": "demo.m.a%3A%3AX.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.a%3A%3AX.impl[T].go", "kind": "function"}, + {"qualname": "demo.m.b%3A%3AX.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.b%3A%3AX.impl[T].go", "kind": "function"} + ] + }, + { + "name": "self_type_path_twin_inherent", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 6, the inherent variant: stage S keys on the post-cfg impl qualname, which for inherent impls is the impl# form -- the gate is impl-kind-agnostic. Two inherent blocks on same-named types from different modules share demo.m.X.impl#<> pre-amendment and (worse than a trait pair) MERGE under Option (b), chimera-parenting both methods under one entity. Distinct witnesses (a::X / b::X) fire S; both bases qualify.", + "source": "mod a { pub struct X; }\nmod b { pub struct X; }\nimpl a::X { pub fn f(&self){} }\nimpl b::X { pub fn g(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.X", "kind": "struct"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.X", "kind": "struct"}, + {"qualname": "demo.m.a%3A%3AX.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.a%3A%3AX.impl#<>.f", "kind": "function"}, + {"qualname": "demo.m.b%3A%3AX.impl#<>", "kind": "impl"}, + {"qualname": "demo.m.b%3A%3AX.impl#<>.g", "kind": "function"} + ] + }, + { + "name": "self_type_path_lone_unqualified_negative", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 6 NEGATIVE control (the S gate off): a LONE path-qualified self-type impl -- by far the common case (e.g. loomweave-cli guidance.rs `impl StorageResultExt for loomweave_storage::Result`) -- keeps the bare last-segment base demo.m.X.impl[T], byte-identical to pre-amendment. Qualification is paid ONLY inside a fired group; a lone impl never moves. Rejects the always-qualify alternative (mass SEI churn, ~2% of impls + their methods).", + "source": "pub trait T {}\nmod a { pub struct X; }\nimpl T for a::X {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.T", "kind": "trait"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.X", "kind": "struct"}, + {"qualname": "demo.m.X.impl[T]", "kind": "impl"} + ] + }, + { + "name": "self_type_path_mixed_bare_and_qualified", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 6, mixed bare/qualified group: `impl T for X` + `impl T for b::X` share the bare key, distinct witnesses (X / b::X) fire S -- but a single-segment witness renders BYTE-IDENTICALLY to the bare base (escape_reserved is a no-op on an ident), so the bare member's id demo.m.X.impl[T] is unchanged and only the multi-segment member moves to b%3A%3AX. Within a fired group, minimal movement.", + "source": "pub trait T {}\nstruct X;\nmod b { pub struct X; }\nimpl T for X {}\nimpl T for b::X {}\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.T", "kind": "trait"}, + {"qualname": "demo.m.X", "kind": "struct"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.X", "kind": "struct"}, + {"qualname": "demo.m.X.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.b%3A%3AX.impl[T]", "kind": "impl"} + ] + }, + { + "name": "trait_path_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 7 (clarion-fa8bcf8731), the Compat shape (tokio_util compat.rs `impl tokio::io::AsyncRead for Compat` vs `impl futures_io::AsyncRead for Compat`): two same-NAMED traits from different paths implemented for the SAME self type. Pre-amendment both rendered demo.m.Compat<$0>.impl[AsyncRead] (last-segment trait fragment) and both poll_read methods collapsed. Stage S is COLD (identical self-type witnesses); stage T groups by post-S qualname and fires on >=2 distinct qualified trait renderings, switching every member's impl[...] fragment to the full written trait path, escape_reserved over the ::-joined segment idents (impl[a%3A%3AAsyncRead] / impl[b%3A%3AAsyncRead]). Final-segment trait generic args keep the existing Amendment-4 rendering.", + "source": "struct Compat(T);\nmod a { pub trait AsyncRead { fn poll_read(&self); } }\nmod b { pub trait AsyncRead { fn poll_read(&self); } }\nimpl a::AsyncRead for Compat { fn poll_read(&self){} }\nimpl b::AsyncRead for Compat { fn poll_read(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Compat", "kind": "struct"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.AsyncRead", "kind": "trait"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.AsyncRead", "kind": "trait"}, + {"qualname": "demo.m.Compat<$0>.impl[a%3A%3AAsyncRead]", "kind": "impl"}, + {"qualname": "demo.m.Compat<$0>.impl[a%3A%3AAsyncRead].poll_read", "kind": "function"}, + {"qualname": "demo.m.Compat<$0>.impl[b%3A%3AAsyncRead]", "kind": "impl"}, + {"qualname": "demo.m.Compat<$0>.impl[b%3A%3AAsyncRead].poll_read", "kind": "function"} + ] + }, + { + "name": "trait_path_twin_single_vs_multi", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 7, single-vs-multi-segment fired pair: `impl Tr for Foo` + `impl other::Tr for Foo`. T fires (two distinct renderings), but the single-segment written path renders byte-identically to the bare fragment, so the bare member keeps demo.m.Foo.impl[Tr] and only the multi-segment member moves to impl[other%3A%3ATr]. The as-written spelling is the witness -- a second producer must NOT resolve `Tr` to its defining path.", + "source": "struct Foo;\npub trait Tr { fn go(&self); }\nmod other { pub trait Tr { fn go(&self); } }\nimpl Tr for Foo { fn go(&self){} }\nimpl other::Tr for Foo { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Tr", "kind": "trait"}, + {"qualname": "demo.m.other", "kind": "module"}, + {"qualname": "demo.m.other.Tr", "kind": "trait"}, + {"qualname": "demo.m.Foo.impl[Tr]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Tr].go", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[other%3A%3ATr]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[other%3A%3ATr].go", "kind": "function"} + ] + }, + { + "name": "trait_path_lone_multi_segment", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 7 NEGATIVE control (the T gate off): a LONE multi-segment trait path keeps the bare last-segment fragment demo.m.Foo.impl[Read] -- byte-identical to pre-amendment, reinforcing the trait_method row (impl std::fmt::Display -> impl[Display]). Rejects the always-qualify alternative (6-41% of trait impls churn).", + "source": "mod deep { pub mod io { pub trait Read { fn r(&self); } } }\nstruct Foo;\nimpl deep::io::Read for Foo { fn r(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.deep", "kind": "module"}, + {"qualname": "demo.m.deep.io", "kind": "module"}, + {"qualname": "demo.m.deep.io.Read", "kind": "trait"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.Foo.impl[Read]", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Read].r", "kind": "function"} + ] + }, + { + "name": "trait_path_cfg_twin_unqualified", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendments 6/7 LADDER-ORDERING pin: cfg-gated twins whose trait paths ALSO differ (#[cfg(unix)] impl a::Tr / #[cfg(windows)] impl b::Tr). Stage 1 (@cfg, computed on the BARE pre-cfg keys exactly as before Amendment 6) splits them first; the post-cfg groups are singletons, so S and T stay COLD and the ids are byte-identical to the pre-amendment @cfg form -- demo.m.Foo.impl[Tr]@cfg(unix), with NO path qualification. This no-churn invariant is WHY the ladder runs @cfg before S/T; an independent-gates design would have qualified (and churned) these.", + "source": "struct Foo;\nmod a { pub trait Tr { fn go(&self); } }\nmod b { pub trait Tr { fn go(&self); } }\n#[cfg(unix)] impl a::Tr for Foo { fn go(&self){} }\n#[cfg(windows)] impl b::Tr for Foo { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.Foo", "kind": "struct"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.Tr", "kind": "trait"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.Tr", "kind": "trait"}, + {"qualname": "demo.m.Foo.impl[Tr]@cfg(unix)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Tr]@cfg(unix).go", "kind": "function"}, + {"qualname": "demo.m.Foo.impl[Tr]@cfg(windows)", "kind": "impl"}, + {"qualname": "demo.m.Foo.impl[Tr]@cfg(windows).go", "kind": "function"} + ] + }, + { + "name": "impl_ladder_self_splits_before_trait", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendments 6/7 composition row: the MIXED pair (`impl a::Tr for c::X` + `impl b::Tr for d::X`) differs on BOTH the self-type path and the trait path. Stage S fires first (distinct self-type witnesses c::X / d::X) and already splits the group; the post-S qualnames are distinct singletons, so stage T stays COLD and the trait fragment keeps its bare last segment -- demo.m.c%3A%3AX.impl[Tr], NOT c%3A%3AX.impl[a%3A%3ATr]. Minimal qualification: each stage only fires on a residual collision left by the previous one.", + "source": "mod a { pub trait Tr { fn go(&self); } }\nmod b { pub trait Tr { fn go(&self); } }\nmod c { pub struct X; }\nmod d { pub struct X; }\nimpl a::Tr for c::X { fn go(&self){} }\nimpl b::Tr for d::X { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.Tr", "kind": "trait"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.Tr", "kind": "trait"}, + {"qualname": "demo.m.c", "kind": "module"}, + {"qualname": "demo.m.c.X", "kind": "struct"}, + {"qualname": "demo.m.d", "kind": "module"}, + {"qualname": "demo.m.d.X", "kind": "struct"}, + {"qualname": "demo.m.c%3A%3AX.impl[Tr]", "kind": "impl"}, + {"qualname": "demo.m.c%3A%3AX.impl[Tr].go", "kind": "function"}, + {"qualname": "demo.m.d%3A%3AX.impl[Tr]", "kind": "impl"}, + {"qualname": "demo.m.d%3A%3AX.impl[Tr].go", "kind": "function"} + ] + }, + { + "name": "self_type_path_leading_colon_twin", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 6 witness symmetry (remediation): `impl T for a::X` + `impl T for ::a::X` is valid Rust when a local `mod a` shadows an extern crate `a`. The leading `::` is PART of the written self-type-path witness (symmetric with the Amendment-7 trait-path rule), so the two witnesses differ, stage S fires, and the qualified bases render a%3A%3AX vs %3A%3Aa%3A%3AX (the leading `::` contributes a leading %3A%3A). Without the leading-colon contribution both witnesses read a::X, the gate stays cold, and the two impls + their `go` methods collide.", + "source": "pub trait T { fn go(&self); }\nmod a { pub struct X; }\nimpl T for a::X { fn go(&self){} }\nimpl T for ::a::X { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.T", "kind": "trait"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.X", "kind": "struct"}, + {"qualname": "demo.m.a%3A%3AX.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.a%3A%3AX.impl[T].go", "kind": "function"}, + {"qualname": "demo.m.%3A%3Aa%3A%3AX.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.%3A%3Aa%3A%3AX.impl[T].go", "kind": "function"} + ] + }, + { + "name": "impl_ladder_self_then_trait_residual", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendments 6/7 S-then-T residual row: three impls share the bare key demo.m.X.impl[Tr]. Stage S fires (witnesses c::X / d::X) and qualifies every base — but the two c::X members STILL collide post-S (demo.m.c%3A%3AX.impl[Tr] twice), so stage T fires on that post-S group and switches BOTH members' fragments to the qualified trait rendering (impl[a%3A%3ATr] / impl[b%3A%3ATr]). The d::X member's post-S group is a SINGLETON, so T stays cold for it and it keeps the bare impl[Tr] fragment — minimal qualification, each stage fires only on the residual the previous one left.", + "source": "mod a { pub trait Tr { fn go(&self); } }\nmod b { pub trait Tr { fn go(&self); } }\nmod c { pub struct X; }\nmod d { pub struct X; }\nimpl a::Tr for c::X { fn go(&self){} }\nimpl b::Tr for c::X { fn go(&self){} }\nimpl a::Tr for d::X { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.Tr", "kind": "trait"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.Tr", "kind": "trait"}, + {"qualname": "demo.m.c", "kind": "module"}, + {"qualname": "demo.m.c.X", "kind": "struct"}, + {"qualname": "demo.m.d", "kind": "module"}, + {"qualname": "demo.m.d.X", "kind": "struct"}, + {"qualname": "demo.m.c%3A%3AX.impl[a%3A%3ATr]", "kind": "impl"}, + {"qualname": "demo.m.c%3A%3AX.impl[a%3A%3ATr].go", "kind": "function"}, + {"qualname": "demo.m.c%3A%3AX.impl[b%3A%3ATr]", "kind": "impl"}, + {"qualname": "demo.m.c%3A%3AX.impl[b%3A%3ATr].go", "kind": "function"}, + {"qualname": "demo.m.d%3A%3AX.impl[Tr]", "kind": "impl"}, + {"qualname": "demo.m.d%3A%3AX.impl[Tr].go", "kind": "function"} + ] + }, + { + "name": "method_cfg_twin_in_s_fired_merged_blocks", + "crate": "demo", "module_path": "demo.m", "rel_path": "src/m.rs", + "reproducibility": "slice-1", + "note": "ADR-049 Amendment 5 composed with Amendment 6 (remediation): the method-@cfg twin counter keys on the FINAL post-S/T impl qualname. An S-fired group whose a::X member is TWO merged same-witness blocks carrying cfg-twin `go` methods: both blocks land on the single S-qualified impl entity demo.m.a%3A%3AX.impl[T], so the two `go` methods are twins on the FINAL key and each carries its own @cfg suffix on the %3A%3A-qualified method id. The b::X member's lone `go` collects no suffix.", + "source": "pub trait T { fn go(&self); }\nmod a { pub struct X; }\nmod b { pub struct X; }\nimpl T for a::X { #[cfg(unix)] fn go(&self){} }\nimpl T for a::X { #[cfg(windows)] fn go(&self){} }\nimpl T for b::X { fn go(&self){} }\n", + "expected": [ + {"qualname": "demo.m", "kind": "module"}, + {"qualname": "demo.m.T", "kind": "trait"}, + {"qualname": "demo.m.a", "kind": "module"}, + {"qualname": "demo.m.a.X", "kind": "struct"}, + {"qualname": "demo.m.b", "kind": "module"}, + {"qualname": "demo.m.b.X", "kind": "struct"}, + {"qualname": "demo.m.a%3A%3AX.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.a%3A%3AX.impl[T].go@cfg(unix)", "kind": "function"}, + {"qualname": "demo.m.a%3A%3AX.impl[T].go@cfg(windows)", "kind": "function"}, + {"qualname": "demo.m.b%3A%3AX.impl[T]", "kind": "impl"}, + {"qualname": "demo.m.b%3A%3AX.impl[T].go", "kind": "function"} + ] + } + ] +} diff --git a/tests/conformance/test_filigree_suppression_filter_contract.py b/tests/conformance/test_filigree_suppression_filter_contract.py new file mode 100644 index 00000000..7451ed74 --- /dev/null +++ b/tests/conformance/test_filigree_suppression_filter_contract.py @@ -0,0 +1,36 @@ +"""Wardline-owned suppression-state vocabulary consumed by Filigree. + +Filigree's finding-list ``suppression`` filter accepts Wardline's +``SuppressionState`` values plus its own local ``all`` no-filter sentinel. This +contract file is the producer-side anchor: if Wardline adds a suppression state, +the shared vector must change in the same commit and Filigree's consumer test +will fail until its filter grammar follows. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from wardline.core.finding import SuppressionState + +VECTOR_PATH = Path(__file__).parent / "filigree_suppression_filter_contract.json" + + +def _vector() -> dict: + return json.loads(VECTOR_PATH.read_text(encoding="utf-8")) + + +def test_vector_matches_suppression_state_enum() -> None: + vector = _vector() + + assert vector["contract"] == "weft/wardline-filigree-suppression-filter" + assert set(vector["suppression_states"]) == {state.value for state in SuppressionState} + + +def test_filigree_filter_values_are_enum_plus_all_sentinel() -> None: + vector = _vector() + expected = {state.value for state in SuppressionState} | {vector["filigree_filter_sentinel"]} + + assert vector["filigree_filter_sentinel"] == "all" + assert set(vector["filigree_filter_values"]) == expected diff --git a/tests/conformance/test_hostile_input_degrade.py b/tests/conformance/test_hostile_input_degrade.py new file mode 100644 index 00000000..77fa9d4b --- /dev/null +++ b/tests/conformance/test_hostile_input_degrade.py @@ -0,0 +1,105 @@ +"""C-13 — fail-degraded, never fail-dead: one hostile file never kills a run. + +Federation convention C-13 (hub `conventions.md`, ratified `b0eee6e`; hub issue +weft-b181c75e39): a member tool that walks or parses project source MUST NOT +hard-fail the whole run on one hostile input. Per file: skip + flag + continue — +the skip is an IN-BAND marker in the result envelope (a finding, never only a log +line), and the run completes over the remaining files. + +This is wardline's adoption fixture: the ``nesting_bomb``-class corpus that killed +legis's ``policy-boundary-check`` with an uncaught RecursionError (dogfood-4 A2) +and that loomweave's python extractor degrades to ``too_complex`` (the C-13 +reference, `d5baac5`). Reference implementations: loomweave +``plugins/python/.../extractor.py``; legis ``policy/boundary_scan.py`` 58-80 +(``POLICY_BOUNDARY_FILE_TOO_COMPLEX``). + +The bombs are GENERATED here (a 20,000-term line has no business in git history); +``lacuna/specimen/nesting_bomb.py`` is the live twin this corpus mirrors. +""" + +from __future__ import annotations + +from pathlib import Path + +from wardline.core.finding import Kind, Severity +from wardline.core.run import run_scan + +# In-band per-file degrade markers the engine may legitimately emit for a hostile +# file, in any stage: parse (PARSE-ERROR for parser-level rejections, +# FILE-SKIPPED for RecursionError), per-entity L2 (FUNCTION-SKIPPED), or the +# unexpected-exception isolation net (FILE-FAILED). +_DEGRADE_RULES = frozenset( + { + "WLN-ENGINE-PARSE-ERROR", + "WLN-ENGINE-FILE-SKIPPED", + "WLN-ENGINE-FUNCTION-SKIPPED", + "WLN-ENGINE-FILE-FAILED", + } +) + +# A healthy violating neighbour: its PY-WL-101 finding proves the scan kept going +# and kept ANALYZING after meeting the hostile files (alphabetically the bombs +# sort both before and after it). +_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" +) + +_BINOP_TERMS = 20_000 # parses fine; blows naive recursive AST visitors (killed legis) +_NESTING_DEPTH = 600 # deeply nested if blocks; rejected at the parser layer + + +def _hostile_corpus(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "middle_clean.py").write_text(_LEAKY, encoding="utf-8") + # (a) deeply nested if blocks — hostile to the parser/compiler layer. + nested = ["x = 1"] + nested += [" " * i + f"if x > {i}:" for i in range(_NESTING_DEPTH)] + nested += [" " * _NESTING_DEPTH + "pass"] + (proj / "aaa_nesting_bomb.py").write_text("\n".join(nested) + "\n", encoding="utf-8") + # (b) the 20,000-term BinOp chain, module-level (lacuna's specimen/nesting_bomb.py + # shape) and inside a function body (so the per-entity walks meet it too). + chain = "+".join(["1"] * _BINOP_TERMS) + (proj / "aab_binop_bomb_module.py").write_text(f"BOMB = {chain}\n", encoding="utf-8") + (proj / "zzz_binop_bomb_func.py").write_text(f"def f():\n return {chain}\n", encoding="utf-8") + return proj + + +_HOSTILE_FILES = ("aaa_nesting_bomb.py", "aab_binop_bomb_module.py", "zzz_binop_bomb_func.py") + + +def test_hostile_corpus_scan_completes_flags_and_continues(tmp_path: Path) -> None: + proj = _hostile_corpus(tmp_path) + + # COMPLETES: no RecursionError (or anything else) escapes the scan. + result = run_scan(proj, confine_to_root=True) + + # FLAGS: every hostile file carries an in-band degrade marker in the result + # envelope — a finding the consumer can see, never only a log line. + by_path = {p: [f for f in result.findings if f.location.path == p] for p in _HOSTILE_FILES} + for path, findings in by_path.items(): + markers = [f for f in findings if f.rule_id in _DEGRADE_RULES] + assert markers, f"{path}: hostile file skipped with NO in-band marker (C-13 violation)" + # Fail-closed posture: unscanned code must not read GREEN — the marker is a + # gate-eligible ERROR DEFECT, not an ignorable FACT. + assert all(m.kind is Kind.DEFECT and m.severity is Severity.ERROR for m in markers), path + + # CONTINUES: the healthy neighbour's findings are still reported. + leaky = [f for f in result.findings if f.rule_id == "PY-WL-101" and f.location.path == "middle_clean.py"] + assert leaky, "scan lost the healthy file's findings after meeting the hostile ones" + + # The degraded scope is visible in the envelope (count), per C-10(a). + assert result.summary.unanalyzed >= len(_HOSTILE_FILES) - 1 # FUNCTION-SKIPPED counts per entity, not per file + assert result.files_scanned == 4 # all four files were DISCOVERED, none aborted the run + + +def test_hostile_corpus_gate_trips_rather_than_reads_green(tmp_path: Path) -> None: + # C-13 + the fail-closed gate: a degraded scan must be able to TRIP an ERROR + # gate (the skipped files were never analyzed), never silently pass as green. + proj = _hostile_corpus(tmp_path) + result = run_scan(proj, confine_to_root=True) + degrade_markers = [f for f in result.findings if f.rule_id in _DEGRADE_RULES] + assert degrade_markers + assert {f.severity for f in degrade_markers} == {Severity.ERROR} diff --git a/tests/conformance/test_legis_artifact_contract_freeze.py b/tests/conformance/test_legis_artifact_contract_freeze.py new file mode 100644 index 00000000..21b3d91f --- /dev/null +++ b/tests/conformance/test_legis_artifact_contract_freeze.py @@ -0,0 +1,243 @@ +"""FROZEN cross-member wire contract — the exact key-set ``build_legis_artifact`` emits. + +⚠️ EDITING THE EXPECTED KEY-SETS BELOW IS A CROSS-REPO BREAKING CHANGE. ⚠️ + +The legis consumer (the Weft governance plugin) reads these keys, and it reads +several of them with a DEFAULT rather than a hard requirement — most importantly +``findings`` (``scan.get("findings", [])``) and ``dirty``. So a silent rename or +drop on *this* (producer) side routes ZERO defects into legis under a green +``verified`` status: the consumer never errors, it just governs an empty scan. +That fail-open is the weft foundation seam-S8 / G1 finding (the ``dirty``-key +analog is the hub's ``dirty``-freeze issue); the consumer-side validation gap is +legis's to close, but the *trigger* — a producer key drifting — is ours to prevent. + +The invariant this file freezes: **a producer that pins its WHOLE emitted key-set +protects the consumer regardless of which keys the consumer validates vs defaults.** +We do not have to know legis's required-field set; we promise never to silently +change our wire. ``build_legis_artifact`` is the sole producer of this wire — the +CLI (``scan --format legis``) and MCP (``legis_artifact``) both emit its dict +verbatim (``json.dumps(artifact)`` / ``response["legis_artifact"] = artifact``), +adding and stripping nothing — so freezing it here covers every emit path. + +This test failing on an UNPLANNED diff is the contract doing its job. If you are +changing the wire on purpose, change it here in LOCKSTEP with the legis hub, and +update the matching constants in ``wardline/core/legis.py``. + +What is frozen: the top-level key-set per emit mode; the per-finding top-level +key-set; AND the two legis-routing-critical sub-fields that live INSIDE a finding +and below the top-level freeze — the ``suppression_state`` VALUE (legis routes its +gate population on the exact strings ``active``/``waived``/``suppressed`` and 422s +an unrecognised one) and the nested proof key in ``properties`` (legis requires a +non-empty proof for any non-active defect). Those last two only ride the wire under +the opt-in ``--trust-suppressions`` posture (under the secure default every defect +rides as ``active``), so a drift there fails LOUD at legis rather than routing to +zero — but it still breaks the hop in the field, so it is frozen here too. + +Documented out-of-scope (low, cannot route-to-zero, intentionally not frozen): a +scalar value-TYPE change on an ignored-unknown envelope field (e.g. a non-string +``fingerprint_scheme``), and the degenerate commit-present-but-tree-unreadable path +(emits ``commit_sha`` without ``tree_sha`` — a strict SUBSET of a frozen mode on an +already-unsigned artifact, reachable only on a corrupt repo). + +The expected sets hardcode the literal key STRINGS (not the ``*_FIELD`` constants +in ``legis.py``) on purpose: a change to a constant's *value* must still trip this. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from wardline.core.config import load as load_config +from wardline.core.errors import LegisArtifactError +from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState +from wardline.core.legis import build_legis_artifact, project_finding +from wardline.core.run import run_scan + +# A leaky boundary→sink module: yields a real PY-WL defect so every mode under test +# carries at least one projected finding to freeze the per-finding key-set against. +_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" +) + +# --- The frozen contract ----------------------------------------------------- +# Always present, in every mode. +_BASE = frozenset({"scanner_identity", "rule_set_version", "fingerprint_scheme", "findings", "scan_scope"}) +# No git repo: no commit/tree provenance to read. +_NON_REPO = _BASE +# A clean committed repo, no signing key: best-effort committed provenance. +_REPO_CLEAN_UNSIGNED = _BASE | {"commit_sha", "tree_sha"} +# A clean committed repo + signing key: the verified artifact carries its signature. +_SIGNED_CLEAN = _BASE | {"commit_sha", "tree_sha", "artifact_signature"} +# A dirty repo (key absent, or present under allow_dirty): committed provenance plus +# the ``dirty`` marker — and NEVER a signature (the false-provenance guard). +_REPO_DIRTY_UNSIGNED = _BASE | {"commit_sha", "tree_sha", "dirty"} +# Every projected finding carries exactly these keys (project_finding). +_FINDING_KEYS = frozenset( + {"rule_id", "message", "severity", "kind", "fingerprint", "qualname", "properties", "suppression_state"} +) +_SCOPE_KEYS = frozenset( + {"schema", "scan_root", "is_git_root", "source_roots", "resolved_source_roots", "scanned_paths"} +) + + +def _proj(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +def _git_commit(root: Path) -> None: + for cmd in ( + ["git", "init", "-q"], + ["git", "config", "user.email", "t@example.com"], + ["git", "config", "user.name", "t"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "init"], + ): + subprocess.run(cmd, cwd=root, check=True, capture_output=True) + + +def _build(root: Path, *, key: bytes | None = None, allow_dirty: bool = False) -> dict: + result = run_scan(root) + cfg = load_config(root / "weft.toml") + return build_legis_artifact(result, root=root, config=cfg, key=key, allow_dirty=allow_dirty) + + +def test_non_repo_unsigned_key_set_is_frozen(tmp_path: Path) -> None: + scan = _build(_proj(tmp_path), key=None) + assert set(scan) == _NON_REPO + + +def test_repo_clean_unsigned_key_set_is_frozen(tmp_path: Path) -> None: + root = _proj(tmp_path) + _git_commit(root) + scan = _build(root, key=None) + assert set(scan) == _REPO_CLEAN_UNSIGNED + + +def test_signed_clean_key_set_is_frozen(tmp_path: Path) -> None: + root = _proj(tmp_path) + _git_commit(root) + scan = _build(root, key=b"shared-secret") + assert set(scan) == _SIGNED_CLEAN + + +def test_repo_dirty_unsigned_key_set_is_frozen(tmp_path: Path) -> None: + root = _proj(tmp_path) + _git_commit(root) + (root / "svc.py").write_text(_LEAKY + "# uncommitted edit\n", encoding="utf-8") + scan = _build(root, key=None) + assert set(scan) == _REPO_DIRTY_UNSIGNED + + +def test_dirty_tree_with_key_under_allow_dirty_is_unsigned_and_frozen(tmp_path: Path) -> None: + # key present + dirty + allow_dirty: the false-provenance guard means this is + # NEVER signed — it must produce the same dirty key-set as the unsigned path, + # and must carry no signature. + root = _proj(tmp_path) + _git_commit(root) + (root / "svc.py").write_text(_LEAKY + "# uncommitted edit\n", encoding="utf-8") + scan = _build(root, key=b"shared-secret", allow_dirty=True) + assert set(scan) == _REPO_DIRTY_UNSIGNED + assert "artifact_signature" not in scan + + +def test_projected_finding_key_set_is_frozen(tmp_path: Path) -> None: + scan = _build(_proj(tmp_path), key=None) + assert scan["findings"], "fixture must yield at least one defect to freeze finding keys" + for finding in scan["findings"]: + assert set(finding) == _FINDING_KEYS + + +def test_scan_scope_key_set_and_values_are_frozen(tmp_path: Path) -> None: + root = tmp_path / "proj" + (root / "src").mkdir(parents=True) + (root / "src" / "svc.py").write_text(_LEAKY, encoding="utf-8") + (root / "weft.toml").write_text('[wardline]\nsource_roots = ["src"]\n', encoding="utf-8") + + scan = _build(root, key=None) + scope = scan["scan_scope"] + + assert set(scope) == _SCOPE_KEYS + assert scope["schema"] == "wardline-legis-scan-scope-1" + assert scope["scan_root"] == "." + assert scope["is_git_root"] is False + assert scope["source_roots"] == ["src"] + assert scope["resolved_source_roots"] == ["src"] + assert scope["scanned_paths"] == ["src/svc.py"] + + +def test_signed_artifact_refuses_subdirectory_scan_root(tmp_path: Path) -> None: + root = _proj(tmp_path) + subdir = root / "safe" + subdir.mkdir() + (subdir / "svc.py").write_text(_LEAKY, encoding="utf-8") + _git_commit(root) + result = run_scan(subdir) + cfg = load_config(subdir / "weft.toml") + + with pytest.raises(LegisArtifactError, match="git repository root"): + build_legis_artifact(result, root=subdir, config=cfg, key=b"shared-secret") + + +def test_findings_is_a_list(tmp_path: Path) -> None: + # Guard the container TYPE, not just the key name: legis iterates findings; a + # key whose value silently became a dict would route nothing the same way a + # rename does. (json round-trip mirrors the on-wire shape the CLI writes.) + scan = json.loads(json.dumps(_build(_proj(tmp_path), key=None))) + assert isinstance(scan["findings"], list) + + +# --- The legis-routing-critical sub-fields inside a non-active finding --------- +# The mode/per-finding freezes above only ever exercise ACTIVE findings (the scan +# fixture yields no suppressions), so they never reach project_finding's non-active +# branch: the suppression_state VALUE mapping and the proof injected into properties. +# legis routes on those exact strings and requires the proof, so a silent drift there +# breaks the hop (loud 422) under --trust-suppressions. We build the non-active states +# directly (no git/baseline setup needed) and freeze both. The proof key literal +# "suppression_reason" is hardcoded so a change to SUPPRESSION_PROOF_KEY's value trips. +_SUPPRESSION_STATE_WIRE = { + SuppressionState.WAIVED: "waived", + SuppressionState.BASELINED: "suppressed", + SuppressionState.JUDGED: "suppressed", +} +_PROOF_KEY = "suppression_reason" + + +def _defect(state: SuppressionState) -> Finding: + return Finding( + rule_id="PY-WL-101", + message="leak", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="svc.py", line_start=6, line_end=7, col_start=0, col_end=0), + fingerprint="b" * 64, + qualname="svc.leaky", + suppressed=state, + suppression_reason="ticket-123", + ) + + +@pytest.mark.parametrize(("state", "wire"), list(_SUPPRESSION_STATE_WIRE.items())) +def test_nonactive_suppression_state_value_and_proof_key_are_frozen(state: SuppressionState, wire: str) -> None: + proj = project_finding(_defect(state)) + assert set(proj) == _FINDING_KEYS # top-level key-set is branch-invariant + assert proj["suppression_state"] == wire # the legis routing value + assert _PROOF_KEY in proj["properties"] # the nested proof KEY name legis reads + assert proj["properties"][_PROOF_KEY] # non-empty (legis 422s on empty proof) + + +def test_active_defect_injects_no_proof() -> None: + # The complement: proof is injected ONLY for non-active defects, so an active + # finding must NOT carry the proof key (else legis would see spurious proof and + # the active/suppressed split would blur). + proj = project_finding(_defect(SuppressionState.ACTIVE)) + assert proj["suppression_state"] == "active" + assert _PROOF_KEY not in proj["properties"] diff --git a/tests/conformance/test_legis_intake_contract.py b/tests/conformance/test_legis_intake_contract.py index 51aae85f..7af12c9e 100644 --- a/tests/conformance/test_legis_intake_contract.py +++ b/tests/conformance/test_legis_intake_contract.py @@ -126,9 +126,13 @@ def from_wire(cls, d: Mapping[str, Any]) -> _LegisFinding: qualname = d.get("qualname") if qualname is not None and not isinstance(qualname, str): raise _LegisPayloadError("finding qualname must be a string or null") - suppressed = d.get("suppressed", "active") + # W3 (weft-f506e5f845): the per-finding wire key was renamed suppressed -> + # suppression_state. This vendored guard tracks the NEW contract; the real legis + # ingest must adopt the same key (tracked in the W3 legis weft ticket) for the live + # legis_e2e oracle to pass again. + suppressed = d.get("suppression_state", "active") if not isinstance(suppressed, str): - raise _LegisPayloadError("finding suppressed must be a string") + raise _LegisPayloadError("finding suppression_state must be a string") for key in ("rule_id", "message", "kind", "fingerprint"): if not isinstance(d[key], str) or not d[key]: raise _LegisPayloadError(f"finding {key} must be a non-empty string") @@ -145,6 +149,18 @@ def from_wire(cls, d: Mapping[str, Any]) -> _LegisFinding: def active_defects(scan: Mapping[str, Any]) -> list[_LegisFinding]: + # KNOWN UPSTREAM FAIL-OPEN (weft foundation seam S8 / G1), mirrored FAITHFULLY: + # legis reads findings with an empty default, and verify_wardline_artifact (below) + # does NOT require the key, so a producer that renamed/dropped `findings` would + # verify `verified` with zero routed defects. Closing that is legis's job (add + # `findings` to the required set, validate before signature). We keep this mirror + # faithful-to-current-legis on purpose and freeze OUR side independently in + # test_legis_artifact_contract_freeze.py so the trigger can never originate here. + # When legis closes G1, invert HERE: require the key — `if "findings" not in scan: + # raise _LegisPayloadError(...)` then read `scan["findings"]` — dropping the empty + # default. NOT via _ARTIFACT_PROVENANCE_FIELDS (verify_wardline_artifact): that tuple + # is validated as non-empty STRINGS, and `findings` is a LIST, so it would reject + # every valid artifact; and the fail-open is this empty default, not that signature path. raw_findings = scan.get("findings", []) if not isinstance(raw_findings, list): raise _LegisPayloadError("scan findings must be a list") @@ -318,7 +334,7 @@ def test_secure_default_gate_defect_is_enforced_by_legis(tmp_path: Path) -> None scan = wl_legis.build_legis_artifact(result, root=repo, config=cfg, key=None) # gate_findings != findings here (active vs baselined) — that asymmetry is the point. (projected,) = scan["findings"] - assert projected["suppressed"] == "active" + assert projected["suppression_state"] == "active" legis_active = active_defects(scan) assert len(legis_active) == 1 assert legis_active[0].fingerprint == "b" * 64 @@ -342,7 +358,7 @@ def test_trust_suppressions_path_projects_the_suppressed_view(tmp_path: Path) -> cfg = load_config(repo / "weft.toml") scan = wl_legis.build_legis_artifact(result, root=repo, config=cfg, key=None) (projected,) = scan["findings"] - assert projected["suppressed"] == "suppressed" + assert projected["suppression_state"] == "suppressed" assert _has_suppression_proof(projected["properties"]) assert active_defects(scan) == [] diff --git a/tests/conformance/test_legis_scan_wire_golden.py b/tests/conformance/test_legis_scan_wire_golden.py new file mode 100644 index 00000000..92693848 --- /dev/null +++ b/tests/conformance/test_legis_scan_wire_golden.py @@ -0,0 +1,162 @@ +"""Shared cross-member golden vector for the wardline → legis scan wire (G1). + +``legis_scan_wire.golden.json`` is the ONE concrete instance of the legis scan +artifact that BOTH wardline (producer) and legis (consumer) load and assert +against — replacing the two *independent vendored mirrors* +(``test_legis_artifact_contract_freeze.py`` here, legis's own vendored ``from_wire``) +whose hand-copied drift is the federation-interface-audit **G1 / seam-S8** finding: +wardline could rename ``findings`` and stay green while legis silently governs an +EMPTY scan under a ``verified`` status (the consumer reads ``scan.get("findings", [])``). + +The vector is a deterministic, self-consistent **signed** artifact: its volatile +fields (``scanner_identity``, ``rule_set_version``, ``commit_sha``, ``tree_sha``) are +fixed sentinels and its ``artifact_signature`` is computed over that body with +:data:`GOLDEN_KEY`, so a consumer can verify it offline with no live scan. legis loads +the SAME file in its CI (its half of G1) and asserts its real ingest routes the one +active defect — so a required-field drift on EITHER side reds. + +This file is the wardline (producer) half: it pins the vector to wardline's LIVE emit +(:func:`build_legis_artifact`). Rename/drop/add a wire key and the structural +conformance below reds; regenerating the vector to match would then red legis's half. +That coupling is the whole point — it is the shared executable test the 2026-06-10 +federation remediation requires to ship WITH the contract, not a vendored copy. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from wardline.core.config import load as load_config +from wardline.core.legis import ( + ARTIFACT_SIGNATURE_FIELD, + DIRTY_FIELD, + FINDINGS_FIELD, + FINGERPRINT_SCHEME_FIELD, + SCAN_SCOPE_FIELD, + build_legis_artifact, + sign_artifact, +) +from wardline.core.run import run_scan + +# The fixed key the shared vector is signed under. Documented and stable so the +# consumer (legis) can verify the vector's signature offline. NOT a production secret. +GOLDEN_KEY = b"weft-shared-conformance-key" + +_VECTOR_PATH = Path(__file__).parent / "legis_scan_wire.golden.json" +_DIRTY_VECTOR_PATH = Path(__file__).parent / "legis_dirty_scan_wire.golden.json" + +# Same leaky boundary→sink fixture the freeze test uses: yields one real PY-WL-101 +# defect carrying every per-finding wire key. +_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 _vector() -> dict: + return json.loads(_VECTOR_PATH.read_text(encoding="utf-8")) + + +def _dirty_vector() -> dict: + return json.loads(_DIRTY_VECTOR_PATH.read_text(encoding="utf-8")) + + +def _signed_clean_artifact(tmp_path: Path) -> dict: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + for cmd in ( + ["git", "init", "-q"], + ["git", "config", "user.email", "t@example.com"], + ["git", "config", "user.name", "t"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "init"], + ): + subprocess.run(cmd, cwd=proj, check=True, capture_output=True) + result = run_scan(proj) + cfg = load_config(proj / "weft.toml") + return build_legis_artifact(result, root=proj, config=cfg, key=GOLDEN_KEY) + + +def _dirty_unsigned_artifact(tmp_path: Path) -> dict: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + for cmd in ( + ["git", "init", "-q"], + ["git", "config", "user.email", "t@example.com"], + ["git", "config", "user.name", "t"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "init"], + ): + subprocess.run(cmd, cwd=proj, check=True, capture_output=True) + (proj / "svc.py").write_text(_LEAKY + "\n# uncommitted edit\n", encoding="utf-8") + result = run_scan(proj) + cfg = load_config(proj / "weft.toml") + return build_legis_artifact(result, root=proj, config=cfg, key=GOLDEN_KEY, allow_dirty=True) + + +def test_golden_vector_is_a_valid_signed_artifact() -> None: + # The consumer verifies the vector offline; prove it round-trips under GOLDEN_KEY. + vector = _vector() + assert vector["artifact_signature"] == sign_artifact(vector, GOLDEN_KEY) + + +def test_golden_vector_keys_are_the_named_constants() -> None: + # Ties the vector's literal key strings to the shared constants: a constant VALUE + # change (the silent-rename vector) reds here. + vector = _vector() + assert FINDINGS_FIELD in vector + assert FINGERPRINT_SCHEME_FIELD in vector + assert SCAN_SCOPE_FIELD in vector + assert DIRTY_FIELD not in vector # clean signed artifact carries no dirty marker + assert isinstance(vector[FINDINGS_FIELD], list) and vector[FINDINGS_FIELD] + + +def test_live_emit_top_level_keys_match_the_vector(tmp_path: Path) -> None: + # The dangerous case: wardline renames/drops a top-level key. The live signed emit + # must carry EXACTLY the vector's top-level key-set. + live = _signed_clean_artifact(tmp_path) + assert set(live) == set(_vector()) + + +def test_live_emit_per_finding_keys_match_the_vector(tmp_path: Path) -> None: + # Every finding wardline emits must carry exactly the per-finding key-set the + # vector pins (so a per-finding key rename can't route a finding to a defaulted key). + expected_keys = set(_vector()[FINDINGS_FIELD][0]) + live = _signed_clean_artifact(tmp_path) + for finding in live[FINDINGS_FIELD]: + assert set(finding) == expected_keys + + +def test_vector_defect_routes_as_active(tmp_path: Path) -> None: + # The vector's one finding is an active defect — the thing legis must route. A + # producer that emitted it under a renamed state value (legis 422s an unknown + # state) or dropped it would diverge from this pinned instance. + vector = _vector() + defect = vector[FINDINGS_FIELD][0] + assert defect["kind"] == "defect" + assert defect["suppression_state"] == "active" + + +def test_dirty_vector_declares_the_named_dirty_contract() -> None: + vector = _dirty_vector() + assert vector["contract"] == "weft/wardline-dirty-scan-artifact" + assert vector["dirty_key"] == DIRTY_FIELD + assert vector["signature_key"] == ARTIFACT_SIGNATURE_FIELD + + +def test_live_dirty_emit_top_level_keys_match_the_dirty_vector(tmp_path: Path) -> None: + case = _dirty_vector()["valid"][0] + live = _dirty_unsigned_artifact(tmp_path) + expected = case["artifact"] + + assert set(live) == set(expected) + assert live[DIRTY_FIELD] is True + assert ARTIFACT_SIGNATURE_FIELD not in live + assert isinstance(live[FINDINGS_FIELD], list) + assert live[FINGERPRINT_SCHEME_FIELD] == expected[FINGERPRINT_SCHEME_FIELD] + assert set(live[SCAN_SCOPE_FIELD]) == set(expected[SCAN_SCOPE_FIELD]) diff --git a/tests/conformance/test_loomweave_rust_qualname_parity.py b/tests/conformance/test_loomweave_rust_qualname_parity.py new file mode 100644 index 00000000..0d6e1d74 --- /dev/null +++ b/tests/conformance/test_loomweave_rust_qualname_parity.py @@ -0,0 +1,294 @@ +# tests/conformance/test_loomweave_rust_qualname_parity.py +"""Pin Wardline's *Rust* qualname producer against Loomweave's normative corpus. + +For Rust, **Loomweave is authoritative** (its whole-tree ``syn`` extractor is the +oracle; the dialect is fixed by Loomweave ADR-049). This INVERTS the Python +arrangement: Wardline *vendors* ``qualnames_rust.json`` and reproduces its +``expected`` qualnames byte-for-byte from the tree-sitter frontend — Wardline is +the *second producer*, it MINTS the same locator string and never parses it. + +Provenance — re-vendor when Loomweave bumps the corpus: + source: loomweave rc4 @ 113c2e2217131fe67f7edb9ea42a2f9eeb48642b + (fixtures/qualnames_rust.json, blob d81fb97544ed1c26b50198556022662e5387a130, + extractor-generated, locked by + crates/loomweave-plugin-rust/tests/qualname_conformance.rs) + vendored byte-identical to tests/conformance/qualnames_rust.json (2026-06-11, + the Amendments 4-9 batch re-vendor — one blob covers the 4-5 AND 6-9 changeset + letters; 49 entity rows + 6 module_route rows + the NEW module_mounts section, + 8 rows). + +Drift alarm (two layers — wardline-868908944b): + 1. Byte-pin (default suite): ``UPSTREAM_BLOB_SHA`` below pins the vendored file's + git blob hash. ANY byte change to the vendored copy fails loudly — re-vendors + are deliberate, atomic, and update the constant in the same commit. + 2. Live recheck (opt-in, ``-m loomweave_drift``): byte-compares the vendored copy + against the sibling checkout (``WARDLINE_LOOMWEAVE_REPO``, default + ``/home/john/loomweave``); skips when the checkout is absent (CI). + +RE-VENDOR PROCEDURE — a RELEASE-GATE item (run ``pytest -m loomweave_drift -v`` +before every release; on drift, or on any deliberate corpus bump upstream): + 1. ``cp $WARDLINE_LOOMWEAVE_REPO/fixtures/qualnames_rust.json + tests/conformance/qualnames_rust.json`` — byte-verbatim. NEVER hand-edit the + vendored copy; the upstream extractor + its cargo gate are the only authors. + 2. Update ``UPSTREAM_BLOB_SHA`` to ``git hash-object tests/conformance/qualnames_rust.json`` + and refresh the provenance lines above (source commit + blob) — all in the + SAME commit as the new bytes. + 3. Re-run conformance (``pytest tests/conformance -q``) and CONFORM the producer + until byte-green — fix ``wardline.rust.*``, never weaken the comparison. + The cab95a1 re-vendor (keystone-panel rows) adds TWO cases pinning syn's + token-stream comment semantics: ``cfg_attr_comment_interposition`` (a ``//`` or + ``///`` comment between ``#[cfg]`` and its item never detaches the cfg — both + twins keep their ``@cfg`` discriminant) and ``cfg_predicate_internal_comment`` + (a ``/* ... */`` inside the predicate is invisible to the token stream — + ``any(unix, /* why */ windows)`` renders ``any(unix,windows)``). + The prior a209fc7 re-vendor (rust-sp2 sprint, Task 1 upstream) added FIVE cases: + ``generic_self_nested_param`` (the F2 nested-param trip-wire — the unit-only + guard now has its corpus row), ``leaf_item_kinds`` (enum/trait/type_alias/ + const/static), ``stacked_cfg_twin`` (ALL #[cfg] predicates folded — normalised, + sorted, ``&``-joined), ``cfg_escape_reserved_char`` (injective escape ``%``->``%25`` + then ``:``->``%3A``, applied to the whitespace-stripped predicate BEFORE any + any()/all() arg sort), and ``leaf_kind_cfg_twin`` (per-(kind, name) twin counter). + NOTE: this is the **ADR-049 amendment 3 (self-type generic args)** corpus, layered on + amendment Option b. The impl ```` segment now carries the self type's CONCRETE + generic args (``Foo`` vs ``Foo`` are distinct keys; the impl's own top-level + declared params render positionally, ``Foo<$0>``) — closing a silent-merge data-loss + family where like-named methods on different instantiations collided. The 8f4f85f + re-vendor changes 2 rows (``positional_generic_param``/``_renamed``: + ``Foo.impl#<$0>`` -> ``Foo<$0>.impl#<$0>``) and adds 3 (``generic_self_inherent_concrete_args``, + ``generic_self_trait_concrete_args``, ``generic_self_same_concrete_two_blocks_merge``). + Still present from prior amendments: the dropped inherent ordinal (``Foo.impl#<>.bar``, + not ``impl#<>#0.bar``), same-key inherent merge, and the two cfg-twin-on-impl trip-wires + (``inherent_impl_cfg_twin`` / ``trait_impl_cfg_twin``). KNOWN GAP — the F2 nested-param + rule (``impl Foo>`` -> literal ``Foo>``, NOT recursive ``Foo>``) + has NO corpus row yet (Loomweave owes one); it is guarded only by + tests/unit/rust/test_qualname.py::test_nested_self_type_param_renders_literal_not_positional. + ⚠️ The earlier federation handshake doc + (loomweave docs/federation/2026-06-09-rust-qualname-dialect-response.md) still + describes the *old* ordinal form — left intact as history, SUPERSEDED by the Phase 1b + change-set (docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md, + amended for amendment 3), which is the authoritative description of this corpus. Where + any doc and the live extractor + this corpus diverge, the extractor + corpus are the oracle. + The 113c2e2 re-vendor (ADR-049 Amendments 4-9, ONE batch covering both the 4-5 and + 6-9 changeset letters) adds 14 entity rows + the module_mounts section and de-gaps + `path_attr_known_gap` (now a FALLBACK pin of the unchanged pure-filesystem route): + **Amendments 4+5** — generic-arg escape pipeline (escape_reserved(strip_ws(arg)) at + every concrete-arg + non-Type::Path self-type render site) and method-level @cfg on + cfg-twin impl fns keyed on the FINAL impl qualname. **Amendments 6+7** — the + residual-collision LADDER (@cfg -> stage S self-type written path -> stage T trait + written path -> method-@cfg), twin-gated end to end (rows self_type_path_* / + trait_path_* / impl_ladder_* / method_cfg_twin_in_s_fired_merged_blocks). + **Amendment 8** — the #[path] mount overlay (module_mounts section, mounted routes + end-to-end incl. chains, macro-invisibility, cfg-twin composition, R5 first-wins). + **Amendment 9** — `const _` skip-emission (unnamed_const_skip pins the skip as an + ABSENT expected row; the ordered-equality gate self-enforces it). + +Status: the Rust frontend (``wardline.rust.*``) now EXISTS (slice-1 WP2 landed), so +the *producer-parity* tests below run live (``_rust_producer`` resolves +``wardline.rust.index`` — they no longer skip). The *structural* self-tests also run +and catch a malformed / stale re-vendor. SP2 rows assert for real (the whole-tree +crate-root pass landed — ``wardline.rust.crate_roots``); no xfail tier remains. + +The comparison rule — GRADUATED (Phase 1b): Wardline now emits the FULL ten-kind +ADR-049 surface (changeset §7 rule 1 for full-surface producers), so the gate is +**ordered-list equality of `(qualname, kind)` pairs** against the corpus +``expected`` — exactly loomweave's own ordered conformance gate. Wardline's +semantic ``method`` maps to the id-kind ``function`` (the only mapping applied); +``module`` rows are compared like every other row AND the ``module_route`` +section separately validates the file->module routing. The corpus +``_consumer_comparison`` subset rule (set-equality over function rows) remains +valid for function-only CONSUMERS; Wardline is no longer one. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import os +from pathlib import Path +from typing import Any + +import pytest + +_CORPUS_PATH = Path(__file__).parent / "qualnames_rust.json" +_CORPUS: dict[str, Any] = json.loads(_CORPUS_PATH.read_text("utf-8")) + +# The git blob hash of the vendored corpus as committed upstream (loomweave rc4 +# @ 113c2e2217131fe67f7edb9ea42a2f9eeb48642b). Re-vendors update this constant in +# the SAME commit as the new bytes — see the RE-VENDOR PROCEDURE in the header. +UPSTREAM_BLOB_SHA = "d81fb97544ed1c26b50198556022662e5387a130" + +_KNOWN_TIERS = {"slice-1", "sp2"} +# The a209fc7 corpus carries the FULL ten-kind ADR-049 surface (leaf_item_kinds / +# leaf_kind_cfg_twin pin enum/trait/type_alias/const/static; macro + impl were already +# present), and Wardline now compares its FULL ordered emission against it (Phase 1b +# graduation). The kinds must be *known* so test_expected_kinds_are_known stays a real +# guard, not a false failure. +_KNOWN_KINDS = { + "module", + "struct", + "function", + "enum", + "trait", + "type_alias", + "const", + "static", + "macro", + "impl", +} + + +def _expected_all_pairs(case: dict[str, Any]) -> list[tuple[str, str]]: + """The full-surface obligation: the case's ``expected`` rows as an ORDERED list of + ``(qualname, kind)`` pairs — order is part of the contract (loomweave's own gate + compares the emission list in order).""" + return [(row["qualname"], row["kind"]) for row in case["expected"]] + + +# --------------------------------------------------------------------------- # +# Structural self-tests — run NOW (no frontend / tree-sitter needed). These pin +# the vendored corpus so a malformed or stale re-vendor fails loudly in CI. +# --------------------------------------------------------------------------- # + + +def test_corpus_shape() -> None: + for key in ("_doc", "_dialect_summary", "_consumer_comparison", "module_route", "module_mounts", "entities"): + assert key in _CORPUS, f"vendored corpus is missing the '{key}' section" + assert _CORPUS["entities"], "corpus has no entity cases" + assert _CORPUS["module_route"], "corpus has no module_route cases" + assert _CORPUS["module_mounts"], "corpus has no module_mounts cases (ADR-049 Amendment 8)" + assert _CORPUS["_consumer_comparison"].strip(), "the comparison contract must travel with the data" + + +def test_reproducibility_tiers_are_known() -> None: + # Guard: a resync that introduces a new tier would silently bypass the + # slice-1-runs / sp2-xfails gating below. + tiers = {c["reproducibility"] for c in _CORPUS["entities"]} + tiers |= {r["reproducibility"] for r in _CORPUS["module_route"]} + tiers |= {r["reproducibility"] for r in _CORPUS["module_mounts"]} + assert tiers <= _KNOWN_TIERS, f"unhandled reproducibility tiers: {tiers - _KNOWN_TIERS}" + + +def test_expected_kinds_are_known() -> None: + # Guard: a new id-kind would silently fall through the function/struct/module + # comparison rule. + kinds = {row["kind"] for c in _CORPUS["entities"] for row in c["expected"]} + assert kinds <= _KNOWN_KINDS, f"unhandled expected kinds: {kinds - _KNOWN_KINDS}" + + +def test_corpus_exercises_functions() -> None: + # Non-vacuity at the corpus level: at least one function qualname exists to + # reproduce (a corpus of only struct/module rows would make the gate empty). + assert any(row["kind"] == "function" for c in _CORPUS["entities"] for row in c["expected"]), ( + "corpus exercises no function qualnames — the producer gate would be vacuous" + ) + + +# --------------------------------------------------------------------------- # +# Corpus drift alarm (wardline-868908944b) — layer 1 runs in the default suite; +# layer 2 is the opt-in live recheck against the sibling checkout. +# --------------------------------------------------------------------------- # + + +def test_vendored_corpus_matches_upstream_blob_pin() -> None: + """Layer 1: the vendored corpus byte-pins to the upstream git blob hash.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = _CORPUS_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored corpus changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit " + "and re-run conformance; if not, someone edited the vendored copy (forbidden — " + "the upstream extractor is the only author; see the RE-VENDOR PROCEDURE in this " + "module's header)" + ) + + +@pytest.mark.loomweave_drift +def test_vendored_corpus_matches_live_sibling_checkout() -> None: + """Layer 2 (opt-in, ``-m loomweave_drift``): the sibling loomweave checkout's + fixture must be byte-identical to the vendored copy — the release-gate drift + alarm. Absent checkout (CI) skips; drift FAILS.""" + repo = Path(os.environ.get("WARDLINE_LOOMWEAVE_REPO", "/home/john/loomweave")) + upstream = repo / "fixtures" / "qualnames_rust.json" + if not upstream.is_file(): + pytest.skip(f"no loomweave sibling checkout at {repo} (override via WARDLINE_LOOMWEAVE_REPO)") + if upstream.read_bytes() != _CORPUS_PATH.read_bytes(): + pytest.fail( + f"upstream {upstream} has drifted from the vendored tests/conformance/qualnames_rust.json — " + "re-vendor + conform: follow the RE-VENDOR PROCEDURE in this module's header " + "(byte-verbatim copy, bump UPSTREAM_BLOB_SHA in the same commit, re-run conformance)" + ) + + +# --------------------------------------------------------------------------- # +# Producer-parity tests — SKIP until the Rust frontend exists (slice-1 WP2). +# --------------------------------------------------------------------------- # + + +def _rust_producer() -> tuple[Any, Any]: + """Resolve the Rust frontend producer, or skip. WP2 wires the real API here; + the corpus then becomes a live byte-for-byte parity gate. + + Expected slice-1 surface (pinned in the plan's WP2): + - ``wardline.rust.index.discover_rust_entities(source: str, *, module: str) + -> Sequence[RustEntity]`` (parses internally; entities carry ``.qualname``; + the case supplies ``module`` directly — the scan path derives it from + Cargo.toml crate roots, ``wardline.rust.crate_roots``); + - ``wardline.rust.qualname.rust_module_route(*, crate: str, src_root: str, + file: str) -> str``. + + Imported dynamically (``importlib``) so the type-checker does not statically + resolve a module that does not exist until WP2 lands. + """ + pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + try: # pragma: no cover - exercised once the frontend lands + rust_index: Any = importlib.import_module("wardline.rust.index") + rust_qualname: Any = importlib.import_module("wardline.rust.qualname") + except ImportError: + pytest.skip("wardline.rust frontend not implemented yet (slice-1 WP2)") + return rust_index, rust_qualname + + +@pytest.mark.parametrize("case", _CORPUS["entities"], ids=lambda c: c["name"]) +def test_entity_qualnames(case: dict[str, Any]) -> None: + rust_index, _ = _rust_producer() + # SP2 landed: sp2 rows assert for real alongside slice-1 (no xfail tier remains). + # Phase 1b contract: the FULL ordered ten-kind emission for `source` rooted at + # `module_path`, kind-mapped semantic `method` -> id-kind `function` (the one + # legal projection; everything else must match the corpus byte-for-byte AND + # row-for-row in order). + found = [ + (e.qualname, "function" if e.kind == "method" else e.kind) + for e in rust_index.discover_rust_entities(case["source"], module=case["module_path"]) + ] + assert found == _expected_all_pairs(case) + + +@pytest.mark.parametrize("route", _CORPUS["module_route"], ids=lambda r: r["name"]) +def test_module_route(route: dict[str, Any]) -> None: + _, rust_qualname = _rust_producer() + # module_route rows drive the PURE-FILESYSTEM route directly — a bare + # (crate, src_root, file) call with NO declaring-file context. `path_attr_known_gap` + # is the Amendment-8 FALLBACK pin: #[path]-aware routing is the SEPARATE + # logical_module_path entry point (test_module_mounts below); a route with no mount + # covering the file MUST be byte-identical to this one. + got = rust_qualname.rust_module_route(crate=route["crate"], src_root=route["src_root"], file=route["file"]) + assert got == route["expected_module"] + + +@pytest.mark.parametrize("case", _CORPUS["module_mounts"], ids=lambda c: c["name"]) +def test_module_mounts(case: dict[str, Any]) -> None: + """ADR-049 Amendment 8: the #[path] mount overlay routes end-to-end — mount + discovery over the case's file map (rustc's relative-path rule, cross-form @cfg + twins, cfg-twin inline-mod prefix composition), fixed-point resolution (chains, + R5 first-wins), and the filesystem fallback for macro-invisible mounts.""" + _rust_producer() # tree-sitter gate (skips when the wardline[rust] extra is absent) + rust_mounts: Any = importlib.import_module("wardline.rust.mounts") + # Every module_mounts case lays its files under `src/` at the project root — + # src_root is the constant "src" by corpus construction. + overlay = rust_mounts.build_mount_overlay(case["files"], crate=case["crate"], src_root="src") + for file, expected in case["expect"].items(): + assert overlay.logical_module_path(file) == expected, f"{case['name']}: {file}" diff --git a/tests/conformance/test_mcp_handshake.py b/tests/conformance/test_mcp_handshake.py index 382439fb..4cd2ede2 100644 --- a/tests/conformance/test_mcp_handshake.py +++ b/tests/conformance/test_mcp_handshake.py @@ -53,10 +53,13 @@ def test_full_client_handshake_and_every_surface() -> None: assert init["protocolVersion"] == PROTOCOL_VERSION assert init["serverInfo"]["name"] == "wardline" assert {"tools", "resources", "prompts"} <= set(init["capabilities"]) - # tools/list: the eleven documented tools, no more no less + # tools/list: the eighteen documented tools, no more no less tool_names = {t["name"] for t in by_id[2]["result"]["tools"]} assert tool_names == { "scan", + "scan_job_start", + "scan_job_status", + "scan_job_cancel", "explain_taint", "dossier", "assure", @@ -69,6 +72,8 @@ def test_full_client_handshake_and_every_surface() -> None: "baseline", "fix", "waiver_add", + "doctor", + "rekey", } # resources/list: the four stable URIs resource_uris = {r["uri"] for r in by_id[3]["result"]["resources"]} @@ -80,7 +85,9 @@ def test_full_client_handshake_and_every_surface() -> None: call = by_id[5]["result"] assert call["content"][0]["type"] == "text" payload = json.loads(call["content"][0]["text"]) - assert {"findings", "summary", "gate"} <= set(payload) + assert {"agent_summary", "summary", "gate"} <= set(payload) + # B1 dual emission: structuredContent carries the SAME payload as the text block + assert call["structuredContent"] == payload # resources/read: the vocab resource round-trips non-empty text through the loop read = by_id[6]["result"] assert read["contents"][0]["uri"] == "wardline://vocab" @@ -135,6 +142,8 @@ def test_tool_execution_error_is_iserror_result_not_jsonrpc_error() -> None: assert "error" not in resp # NOT a JSON-RPC error assert resp["result"]["isError"] is True # IS an isError result assert "re-scan" in resp["result"]["content"][0]["text"].lower() + # B1: isError results never carry structuredContent + assert "structuredContent" not in resp["result"] def test_unknown_method_is_a_jsonrpc_error() -> None: diff --git a/tests/conformance/test_mcp_structured_output.py b/tests/conformance/test_mcp_structured_output.py new file mode 100644 index 00000000..bbbb1f17 --- /dev/null +++ b/tests/conformance/test_mcp_structured_output.py @@ -0,0 +1,442 @@ +"""B1+B2 conformance: MCP structured output + display metadata across all 18 tools. + +Three layers, each pinned for EVERY registered tool: + +1. ADVERTISEMENT — every tools/list entry carries a non-empty ``title``, a complete + standard ``annotations`` object (2025-03-26), an object-typed ``outputSchema`` + (2025-06-18), AND the legacy homegrown ``capabilities`` key (mapped, not replaced). + The annotation hints must be consistent with the declared capability sets. +2. EXECUTION — a representative SUCCESSFUL tools/call per tool: the returned + ``structuredContent`` validates against that tool's own declared outputSchema and + equals ``json.loads(content[0].text)`` (dual emission, byte-compatible text block). + isError results carry NO structuredContent. +3. NEGOTIATION — initialize echoes any supported protocolVersion verbatim and answers + the latest revision for an unknown one. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import jsonschema +import pytest + +from wardline.core.judge import JudgeResponse, JudgeVerdict +from wardline.mcp.protocol import PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS +from wardline.mcp.server import WardlineMCPServer +from wardline.mcp.tooling import ToolCapability + +FIXTURE = Path("tests/fixtures/sample_project") + +# The published 18-tool surface, in advertisement order. +EXPECTED_TOOLS = ( + "scan", + "scan_job_start", + "scan_job_status", + "scan_job_cancel", + "explain_taint", + "dossier", + "assure", + "decorator_coverage", + "attest", + "verify_attestation", + "file_finding", + "scan_file_findings", + "judge", + "baseline", + "waiver_add", + "fix", + "doctor", + "rekey", +) + +# B2 acceptance: the pure-read surface advertises readOnlyHint: true. +READ_ONLY_TOOLS = frozenset( + { + "scan", + "scan_job_status", + "explain_taint", + "dossier", + "assure", + "decorator_coverage", + "attest", + "verify_attestation", + } +) + +_ANNOTATION_KEYS = {"title", "readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"} +_HINT_KEYS = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint") + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + + +def _leaky_project(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + return proj + + +def _entries(server: WardlineMCPServer) -> dict[str, dict[str, Any]]: + resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + assert "error" not in resp, resp + return {t["name"]: t for t in resp["result"]["tools"]} + + +def _call(server: WardlineMCPServer, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + resp = server.rpc.dispatch( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}} + ) + assert "error" not in resp, resp + result: dict[str, Any] = resp["result"] + return result + + +def _validated(server: WardlineMCPServer, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """tools/call success → structuredContent validates against the tool's OWN declared + outputSchema AND equals the parsed text block (dual emission).""" + result = _call(server, name, arguments) + assert result.get("isError") is not True, result + schema = _entries(server)[name]["outputSchema"] + assert "structuredContent" in result, f"{name}: success result missing structuredContent" + structured: dict[str, Any] = result["structuredContent"] + jsonschema.validate(structured, schema) + assert json.loads(result["content"][0]["text"]) == structured, f"{name}: dual emission diverged" + return structured + + +@pytest.fixture(scope="module") +def fixture_server() -> WardlineMCPServer: + return WardlineMCPServer(root=FIXTURE) + + +# --------------------------------------------------------------------------- +# 1. Advertisement conformance +# --------------------------------------------------------------------------- + + +def test_advertises_exactly_the_published_surface(fixture_server: WardlineMCPServer) -> None: + assert tuple(_entries(fixture_server)) == EXPECTED_TOOLS + + +@pytest.mark.parametrize("name", EXPECTED_TOOLS) +def test_tools_list_entry_carries_b1_b2_metadata(fixture_server: WardlineMCPServer, name: str) -> None: + entry = _entries(fixture_server)[name] + # title: present and non-empty + assert isinstance(entry["title"], str) and entry["title"] + # annotations: the COMPLETE standard ToolAnnotations object, nothing else + ann = entry["annotations"] + assert set(ann) == _ANNOTATION_KEYS + for hint in _HINT_KEYS: + assert isinstance(ann[hint], bool), f"{name}.{hint} must be a bool" + assert ann["title"] == entry["title"] + # outputSchema: an object schema (2025-06-18 structured output contract) + out = entry["outputSchema"] + assert isinstance(out, dict) + assert out["type"] == "object" + # the legacy homegrown capabilities key is mapped, NOT replaced + assert isinstance(entry["capabilities"], list) and entry["capabilities"] + + +@pytest.mark.parametrize("name", EXPECTED_TOOLS) +def test_annotations_consistent_with_declared_capabilities(fixture_server: WardlineMCPServer, name: str) -> None: + """The standard hints must never CONTRADICT the homegrown capability declaration.""" + tool = fixture_server._tools[name] + ann = _entries(fixture_server)[name]["annotations"] + if ToolCapability.WRITE in tool.capabilities: + assert ann["readOnlyHint"] is False, f"{name}: WRITE capability with readOnlyHint true" + if ToolCapability.NETWORK in tool.capabilities: + assert ann["openWorldHint"] is True, f"{name}: NETWORK capability with openWorldHint false" + if ann["readOnlyHint"]: + assert ann["destructiveHint"] is False, f"{name}: read-only tools cannot be destructive" + + +def test_pure_read_surface_advertises_read_only(fixture_server: WardlineMCPServer) -> None: + entries = _entries(fixture_server) + for name in EXPECTED_TOOLS: + expected = name in READ_ONLY_TOOLS + assert entries[name]["annotations"]["readOnlyHint"] is expected, name + + +# --------------------------------------------------------------------------- +# 2. Execution conformance — one representative SUCCESS per tool +# --------------------------------------------------------------------------- + + +def test_scan_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "scan", {"fail_on": "ERROR"}) + assert out["gate"]["tripped"] is True + + +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 { + "job_id": job_id, + "status": status, + "phase": "scanning", + "progress": {"steps_completed": 1, "steps_total": 4}, + "heartbeat": "2026-06-13T00:00:00Z", + "artifacts": {}, + "failure_kind": None, + "error": None, + "request": {}, + } + + +def test_scan_job_start_structured_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Hermetic: never spawn a worker subprocess — pin the core start to a + # schema-valid status. local_only keeps the network-fenced default happy. + import wardline.mcp.server as server_mod + + monkeypatch.setattr(server_mod, "start_scan_job", lambda root, request, **kw: _job_status_stub()) + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "scan_job_start", {"local_only": True}) + assert out["status"] == "running" + + +def test_scan_job_status_structured_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import wardline.mcp.server as server_mod + + monkeypatch.setattr(server_mod, "read_scan_job_status", lambda root, job_id: _job_status_stub(job_id=job_id)) + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "scan_job_status", {"job_id": "b" * 32}) + assert out["job_id"] == "b" * 32 + + +def test_scan_job_cancel_structured_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import wardline.mcp.server as server_mod + + monkeypatch.setattr( + server_mod, "cancel_scan_job", lambda root, job_id: _job_status_stub(job_id=job_id, status="cancelled") + ) + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "scan_job_cancel", {"job_id": "c" * 32}) + assert out["status"] == "cancelled" + + +def test_explain_taint_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + scan_out = _validated(server, "scan", {}) + fp = next(e["fingerprint"] for e in scan_out["agent_summary"]["active_defects"] if e["rule_id"] == "PY-WL-101") + out = _validated(server, "explain_taint", {"fingerprint": fp}) + assert out["fingerprint"] == fp + + +def test_dossier_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "dossier", {"entity": "svc.leaky"}) + assert out["identity"]["qualname"] == "svc.leaky" + + +def test_assure_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "assure", {}) + assert out["boundaries_total"] >= 1 + + +def test_decorator_coverage_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "decorator_coverage", {}) + assert out["summary"]["total"] >= 1 + + +def test_attest_and_verify_attestation_structured_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from wardline.core.attest_key import WARDLINE_ATTEST_KEY_ENV, mint_attest_key + + monkeypatch.delenv(WARDLINE_ATTEST_KEY_ENV, raising=False) + proj = _leaky_project(tmp_path) + mint_attest_key(proj) # minted into proj/.env (non-git tree → dirty=False, strict default still builds) + server = WardlineMCPServer(root=proj) + + bundle = _validated(server, "attest", {}) + assert bundle["signature"]["key_id"] + + verified = _validated(server, "verify_attestation", {"bundle": bundle}) + assert verified["signature_valid"] is True + + +def test_file_finding_structured_output(tmp_path: Path) -> None: + from wardline.core.filigree_issue import FileResult + + class FakeFiler: + def file(self, fingerprint: str, *, scan_source: str = "wardline", priority=None, labels=None) -> FileResult: + return FileResult(reachable=True, issue_id="wardline-abc", created=True) + + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + # Inject the fake filer through the same seam the registered lambda resolves it. + server._filigree_filer = lambda *a, **k: FakeFiler() # type: ignore[method-assign] + out = _validated(server, "file_finding", {"fingerprint": "f" * 64}) + assert out["issue_id"] == "wardline-abc" + + +def test_scan_file_findings_structured_output(tmp_path: Path) -> None: + # Default invocation is a REAL dry-run scan (no Filigree configured): the + # fail-soft emit/file blocks report 'not configured'. + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "scan_file_findings", {}) + assert out["mode"] == "dry_run" + + +def test_judge_structured_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Network-fenced: patch the caller the default run_judge path imports. + fake = JudgeResponse( + verdict=JudgeVerdict.TRUE_POSITIVE, + rationale="genuinely reaches a trusted sink", + confidence=0.91, + model_id="fake/model", + recorded_at=datetime.now(UTC), + prompt_tokens_total=128, + prompt_tokens_cached=None, + policy_hash="deadbeef", + ) + monkeypatch.setattr("wardline.core.judge_run.call_judge", lambda *a, **k: fake) + monkeypatch.setenv("WARDLINE_OPENROUTER_API_KEY", "sk-or-test") + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "judge", {}) + assert out["verdicts"], out + + +def test_baseline_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "baseline", {"reason": "accept current debt"}) + assert out["baselined_count"] >= 1 + + +def test_waiver_add_structured_output(tmp_path: Path) -> None: + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated( + server, "waiver_add", {"fingerprint": "a" * 64, "reason": "validated upstream", "expires": "2026-12-31"} + ) + assert out["fingerprint"] == "a" * 64 + + +def test_fix_structured_output(tmp_path: Path) -> None: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "weft.toml").write_text('[wardline.autofix]\nboundary_exception = "ValueError"\n', encoding="utf-8") + (proj / "svc.py").write_text( + "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def check(val):\n" + " assert val is not None\n" + " return val\n", + encoding="utf-8", + ) + server = WardlineMCPServer(root=proj) + out = _validated(server, "fix", {"dry_run": True}) + assert "svc.py" in out["fixed"] + assert out["applied"] is False + + +def test_doctor_structured_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Hermetic: never read the real HOME or probe a real federation endpoint. + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: tmp_path / "home") + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + proj = tmp_path / "proj" + proj.mkdir() + server = WardlineMCPServer(root=proj) + out = _validated(server, "doctor", {}) + assert out["checks"][-1]["id"] == "server.freshness" + assert out["server"]["fresh"] is True + + +def test_rekey_structured_output(tmp_path: Path) -> None: + # Probe-by-default: read-only report on a store-less project — writes nothing. + server = WardlineMCPServer(root=_leaky_project(tmp_path)) + out = _validated(server, "rekey", {}) + assert out["mode"] == "probe" + assert out["clean"] is True + + +def test_execution_conformance_covers_every_advertised_tool(fixture_server: WardlineMCPServer) -> None: + """Tripwire: a 19th tool must add an execution-conformance case above.""" + assert set(_entries(fixture_server)) == set(EXPECTED_TOOLS) + + +def test_doctor_freshness_check_appears_in_structured_content(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # A STALE server (started before the on-disk source changed) must still produce a + # schema-valid payload: the error branch of the freshness check is schema-pinned too. + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: tmp_path / "home") + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + proj = tmp_path / "proj" + proj.mkdir() + server = WardlineMCPServer(root=proj) + server.started_at = 1.0 # 1970 — everything on disk is newer + out = _validated(server, "doctor", {}) + assert out["server"]["fresh"] is False + assert out["ok"] is False + + +# --------------------------------------------------------------------------- +# isError results never carry structuredContent +# --------------------------------------------------------------------------- + + +def test_tool_execution_error_has_no_structured_content() -> None: + # Stale/unknown fingerprint → isError result, text-only. + server = WardlineMCPServer(root=FIXTURE) + result = _call(server, "explain_taint", {"fingerprint": "0" * 64}) + assert result["isError"] is True + assert "structuredContent" not in result + + +def test_invalid_arguments_error_has_no_structured_content(tmp_path: Path) -> None: + # jsonschema argument rejection → isError result, text-only. + server = WardlineMCPServer(root=tmp_path) + result = _call( + server, + "waiver_add", + {"fingerprint": "a" * 64, "reason": "ok", "expires": "2026-12-31", "apply": True}, + ) + assert result["isError"] is True + assert "additional properties" in result["content"][0]["text"].lower() + assert "structuredContent" not in result + + +# --------------------------------------------------------------------------- +# 3. Protocol version negotiation +# --------------------------------------------------------------------------- + + +def test_supported_protocol_versions_are_pinned() -> None: + assert SUPPORTED_PROTOCOL_VERSIONS == ("2025-06-18", "2025-03-26", "2024-11-05") + assert PROTOCOL_VERSION == "2025-06-18" + + +def _initialize(server: WardlineMCPServer, requested: str) -> str: + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": requested, "capabilities": {}}, + } + ) + assert "error" not in resp, resp + version: str = resp["result"]["protocolVersion"] + return version + + +@pytest.mark.parametrize("requested", SUPPORTED_PROTOCOL_VERSIONS) +def test_initialize_echoes_each_supported_protocol_version(requested: str) -> None: + server = WardlineMCPServer(root=FIXTURE) + assert _initialize(server, requested) == requested + + +def test_initialize_answers_latest_for_unknown_protocol_version() -> None: + server = WardlineMCPServer(root=FIXTURE) + assert _initialize(server, "1999-01-01") == "2025-06-18" diff --git a/tests/conformance/test_sei_oracle.py b/tests/conformance/test_sei_oracle.py new file mode 100644 index 00000000..6db99d42 --- /dev/null +++ b/tests/conformance/test_sei_oracle.py @@ -0,0 +1,156 @@ +"""Weft SEI §8 conformance oracle — Wardline as consumer. + +The scenario list is loaded from the vendored ``sei-conformance-oracle.json`` +fixture, copied from Loomweave's authoritative fixture. Each scenario id is +claimed by one consumer assertion so a fixture change fails CI until Wardline +updates the corresponding behavior check. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import pytest + +from wardline.loomweave.identity import IdentityStatus, SeiCapability, SeiResolver + +ORACLE_PATH = Path(__file__).parent / "fixtures" / "sei-conformance-oracle.json" + + +def _load_oracle() -> dict[str, Any]: + return json.loads(ORACLE_PATH.read_text(encoding="utf-8")) + + +def _scenario(scenario_id: str) -> dict[str, Any]: + for item in _load_oracle()["scenarios"]: + if item["id"] == scenario_id: + return item + raise AssertionError(f"missing SEI oracle scenario {scenario_id!r}") + + +def _loomweave_oracle_source() -> Path | None: + candidates: list[Path] = [] + if env := os.environ.get("LOOMWEAVE_REPO"): + candidates.append(Path(env) / "docs" / "federation" / "fixtures" / "sei-conformance-oracle.json") + candidates.append( + Path(__file__).resolve().parents[3] + / "loomweave" + / "docs" + / "federation" + / "fixtures" + / "sei-conformance-oracle.json" + ) + return next((path for path in candidates if path.exists()), None) + + +COVERED_SCENARIOS = { + "identity_round_trip_and_opacity", + "rename", + "move", + "ambiguous", + "delete", + "capability_absent", +} + + +class FakeClient: + def __init__( + self, + *, + caps: dict[str, Any] | None = None, + resolve: dict[str, Any] | None = None, + resolve_sei: dict[str, Any] | None = None, + ) -> None: + self._caps = caps + self._resolve = resolve + self._resolve_sei = resolve_sei + self.resolve_calls: list[str] = [] + + def capabilities(self) -> dict[str, Any] | None: + return self._caps + + def resolve_identity(self, locator: str) -> dict[str, Any] | None: + self.resolve_calls.append(locator) + return self._resolve + + def resolve_sei(self, sei: str) -> dict[str, Any] | None: + return self._resolve_sei + + +def test_vendored_oracle_matches_loomweave_source() -> None: + source = _loomweave_oracle_source() + if source is None: + pytest.skip("Loomweave repo not found; set LOOMWEAVE_REPO to enable drift check") + assert _load_oracle() == json.loads(source.read_text(encoding="utf-8")) + + +def test_every_oracle_scenario_is_covered() -> None: + fixture_ids = {item["id"] for item in _load_oracle()["scenarios"]} + assert fixture_ids == COVERED_SCENARIOS + + +def test_identity_round_trip_and_opacity() -> None: + scenario = _scenario("identity_round_trip_and_opacity") + locator = "python:function:m.f" + sei = "loomweave:eid:0123456789abcdef0123456789abcdef" + client = FakeClient( + caps={"sei": {"supported": True, "version": 1}}, + resolve={"sei": sei, "current_locator": locator, "content_hash": "h", "alive": True}, + resolve_sei={"current_locator": locator, "content_hash": "h", "alive": True}, + ) + resolver = SeiResolver.detect(client) + + binding = resolver.resolve_locator(locator) + + assert scenario["expect"]["resolve_locator"]["alive"] is True + assert binding.identity is IdentityStatus.ALIVE + assert binding.sei == sei + assert binding.binding_key == sei + assert binding.keyed_on_sei is True + assert binding.sei.startswith("loomweave:eid:") + assert binding.sei != locator + assert resolver.resolve_identity_status(sei) is IdentityStatus.ALIVE + + +@pytest.mark.parametrize("scenario_id", ["rename", "move"]) +def test_carried_sei_remains_alive_for_rename_and_move(scenario_id: str) -> None: + scenario = _scenario(scenario_id) + sei = "loomweave:eid:carried" + client = FakeClient(caps={"sei": {"supported": True, "version": 1}}, resolve_sei={"sei": sei, "alive": True}) + resolver = SeiResolver.detect(client) + + assert scenario["expect"]["carry"] is True + assert resolver.resolve_identity_status(sei) is IdentityStatus.ALIVE + + +@pytest.mark.parametrize("scenario_id", ["ambiguous", "delete"]) +def test_orphaned_sei_surfaces_as_orphaned_for_ambiguous_and_delete(scenario_id: str) -> None: + scenario = _scenario(scenario_id) + sei = "loomweave:eid:orphaned" + client = FakeClient( + caps={"sei": {"supported": True, "version": 1}}, + resolve_sei={"sei": sei, "alive": False, "lineage": [{"event": "orphaned"}]}, + ) + resolver = SeiResolver.detect(client) + + assert "orphaned" in json.dumps(scenario["expect"]) + assert resolver.resolve_identity_status(sei) is IdentityStatus.ORPHANED + + +def test_capability_absent_degrades_gracefully() -> None: + scenario = _scenario("capability_absent") + locator = "python:function:any" + client = FakeClient(caps={"linkages": {"http": True}}) + resolver = SeiResolver.detect(client) + + binding = resolver.resolve_locator(locator) + + assert scenario["expect"]["resolve_locator(any)"]["alive"] is False + assert resolver.capability == SeiCapability(supported=False) + assert binding.identity is IdentityStatus.UNAVAILABLE + assert binding.sei is None + assert binding.binding_key == locator + assert client.resolve_calls == [] diff --git a/tests/conformance/test_weft_reason_vocab_conformance.py b/tests/conformance/test_weft_reason_vocab_conformance.py new file mode 100644 index 00000000..4a45b3c4 --- /dev/null +++ b/tests/conformance/test_weft_reason_vocab_conformance.py @@ -0,0 +1,135 @@ +# tests/conformance/test_weft_reason_vocab_conformance.py +"""weft-reason vocabulary conformance (G1) — the drift guard for wardline's emit-failure +reason surface. + +SOURCE OF TRUTH: the suite hub contract at contracts/weft-reason-vocab.json +(absolute on this machine: /home/john/weft/contracts/weft-reason-vocab.json). It defines a +CLOSED set of 11 canonical ``reason_class`` values and a carrier rule: + + every NON-clean carrier MUST include {reason_class, cause, fix} (fix MANDATORY); + a clean carrier omits cause + fix. + +wardline's reason surface is ``FailedFinding`` in ``wardline.core.filigree_emit``: it carries +a SHIPPED domain ``reason`` (one of {rejected, validation_error, scheme_mismatch, partial}) +that predates the canonical vocabulary and is NOT renamed (it is on the wire). G1 conformance +is ADDITIVE: every domain ``reason`` maps onto a canonical ``reason_class`` via +``_REASON_CLASS_BY_REASON``, and every emitted ``FailedFinding`` carries the canonical +carrier triple alongside the domain fields. + +These tests FAIL if the member ever drifts: + * a new domain reason is added without a canonical mapping, OR + * a domain reason maps to a class outside the canonical 11, OR + * a FailedFinding stops carrying reason_class / cause / fix on its wire (carrier rule). + +The canonical 11 are vendored below (faithful copy of the hub contract) so the guard is +hermetic; when the hub contract is reachable on disk, an extra assertion pins the vendored +copy to it so a hub-side change to the closed set surfaces here too. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from wardline.core.filigree_emit import ( + _FAILURE_REASONS, + _FIX_BY_REASON, + _REASON_CLASS_BY_REASON, + FailedFinding, +) + +# Faithful vendored copy of the closed canonical reason_class set +# (contracts/weft-reason-vocab.json -> reason_classes, version 1). +CANONICAL_REASON_CLASSES = frozenset( + { + "clean", + "disabled", + "unresolved_input", + "rejected", + "dead_path", + "unreachable", + "misrouted", + "error", + "scheme_mismatch", + "stale", + "partial", + } +) + +# The hub contract path (the suite source of truth). Resolved relative to this member's repo +# parent (../../../../weft/contracts/...) so a co-located ``weft`` checkout is found; the +# vendored copy above keeps the guard hermetic when the hub is not on disk. +# this file: //wardline/tests/conformance/ +# parents: 0=conformance 1=tests 2=wardline 3= -> sibling weft/ lives at parents[3]/weft +_HUB_CONTRACT = Path(__file__).resolve().parents[3] / "weft" / "contracts" / "weft-reason-vocab.json" + + +def test_vendored_canonical_set_is_exactly_eleven() -> None: + # The contract is a CLOSED 11-class set. A change to this count must be a deliberate edit + # tracked against the hub contract, never an accidental local addition. + assert len(CANONICAL_REASON_CLASSES) == 11 + + +def test_vendored_set_matches_hub_contract_when_present() -> None: + # When the suite hub is checked out alongside this member, pin the vendored copy to the + # real contract so a hub-side change to the closed set fails here instead of going unnoticed. + if not _HUB_CONTRACT.exists(): + pytest.skip(f"hub contract not present at {_HUB_CONTRACT}; vendored copy is authoritative") + contract = json.loads(_HUB_CONTRACT.read_text("utf-8")) + hub_classes = frozenset(contract["reason_classes"]) + assert hub_classes == CANONICAL_REASON_CLASSES, ( + "vendored canonical reason_class set has drifted from the hub contract " + f"({_HUB_CONTRACT}); reconcile this test with the contract." + ) + carrier = contract["carrier"] + assert set(carrier["required_on_non_clean"]) == {"reason_class", "cause", "fix"} + assert set(carrier["clean_omits"]) == {"cause", "fix"} + + +def test_every_shipped_reason_maps_to_a_canonical_class() -> None: + # The member's actual reason vocabulary is the shipped closed set ``_FAILURE_REASONS``. + # Every member of it MUST have a canonical mapping, and every mapped class MUST be one of + # the canonical 11. Drift in either direction (an unmapped new reason, or a mapping to a + # non-canonical class) trips here. + assert set(_REASON_CLASS_BY_REASON) == set(_FAILURE_REASONS), ( + "every shipped emit-failure reason must declare a canonical reason_class mapping; " + f"unmapped: {set(_FAILURE_REASONS) - set(_REASON_CLASS_BY_REASON)}" + ) + emitted_classes = set(_REASON_CLASS_BY_REASON.values()) + assert emitted_classes <= CANONICAL_REASON_CLASSES, ( + "wardline emits a reason_class outside the canonical weft-reason vocabulary: " + f"{emitted_classes - CANONICAL_REASON_CLASSES}" + ) + + +def test_every_shipped_reason_declares_a_fix() -> None: + # Carrier rule: ``fix`` is MANDATORY on every non-clean carrier. A FailedFinding is always + # non-clean, so every shipped reason must have a non-empty fix string. + assert set(_FIX_BY_REASON) == set(_FAILURE_REASONS) + for reason, fix in _FIX_BY_REASON.items(): + assert fix and fix.strip(), f"reason {reason!r} has an empty fix; the carrier rule requires a fix" + + +@pytest.mark.parametrize("reason", sorted(_FAILURE_REASONS)) +def test_failed_finding_carries_canonical_triple_on_wire(reason: str) -> None: + # Every FailedFinding is a non-clean carrier, so its wire MUST carry the canonical triple + # {reason_class, cause, fix}, with reason_class drawn from the canonical 11 and cause+fix + # non-empty. The shipped domain fields (reason/detail) are preserved alongside. + wire = FailedFinding(reason=reason, detail="x", fingerprint="wlfp2:abc").to_wire() + assert wire["reason_class"] in CANONICAL_REASON_CLASSES + assert wire["reason_class"] != "clean" # a failure is never the clean true-negative + assert wire["cause"] and wire["cause"].strip() + assert wire["fix"] and wire["fix"].strip() + # additive, not breaking: the shipped fields survive verbatim. + assert wire["reason"] == reason + assert wire["detail"] == "x" + + +def test_cause_is_non_empty_even_without_detail() -> None: + # A non-clean carrier must carry a cause even when Filigree gave no detail string; the + # domain reason itself is the fallback cause so the triple is never partial. + wire = FailedFinding(reason="rejected").to_wire() + assert wire["cause"] == "rejected" + assert wire["fix"] diff --git a/tests/corpus/MANIFEST.yaml b/tests/corpus/MANIFEST.yaml index fbf0ea53..4a169225 100644 --- a/tests/corpus/MANIFEST.yaml +++ b/tests/corpus/MANIFEST.yaml @@ -4,8 +4,13 @@ # FP rate = FALSE_POSITIVE / total active DEFECTs, gated <= 5%. # Every active DEFECT the engine produces over the fixtures MUST have an entry here # (an unaccounted finding fails the gate — that is how clean-shape regressions surface). -# Per the 2026-05-31 taint-combination audit the engine has 0 live FP, so every entry -# below is TRUE_POSITIVE today; the FALSE_POSITIVE label exists to capture a regression. +# `sentinels:` entries (files under tests/corpus/sentinels, a sibling scan root) are +# clean-shape FALSE_POSITIVE sentinels: the engine must NOT fire on them. A silent +# sentinel is passing (not stale); a fired sentinel is a live FP counted against the +# budget. Sentinels live OUTSIDE fixtures/ so the Track 2 byte-identity golden +# (tests/grammar, frozen over fixtures/ only) keeps its substrate. Per the 2026-05-31 +# taint-combination audit the engine has 0 live FP, so every sentinel is expected +# to stay silent. fixtures: contradictory.py: - {rule_id: PY-WL-110, qualname: "contradictory.conflicting", label: TRUE_POSITIVE, note: "@trusted + @external_boundary — two distinct trust markers"} @@ -17,6 +22,10 @@ fixtures: - {rule_id: PY-WL-107, qualname: "exec_sink.evals_untrusted", label: TRUE_POSITIVE, note: "EXTERNAL_RAW reaches eval() in a trusted-tier fn"} command_sink.py: - {rule_id: PY-WL-108, qualname: "command_sink.runs_untrusted", label: TRUE_POSITIVE, note: "EXTERNAL_RAW reaches os.system in a trusted-tier fn"} + shadow_launder.py: + - {rule_id: PY-WL-108, qualname: "shadow_launder.shadowed_sink", label: TRUE_POSITIVE, note: "raw local shadows the 'ast' stdlib module; ast.literal_eval (GUARDED) launder closed (wardline-f6a29ce23a) — discriminating: vanishes if the fix regresses"} + shadow_launder_bare.py: + - {rule_id: PY-WL-108, qualname: "shadow_launder_bare.bare_shadow_sink", label: TRUE_POSITIVE, note: "raw local shadows from-imported 'literal_eval'; bare-name-call launder closed (wardline-f6a29ce23a) — discriminating: vanishes if the fix regresses"} trusted_callee.py: - {rule_id: PY-WL-105, qualname: "trusted_callee.handler", label: TRUE_POSITIVE, note: "EXTERNAL_RAW passed to the trusted callee store()"} aliased_stdlib.py: @@ -28,8 +37,8 @@ fixtures: match_arms.py: - {rule_id: PY-WL-101, qualname: "match_arms.match_arm_leaks", label: TRUE_POSITIVE, note: "one match arm binds raw, then returned"} validators.py: - - {rule_id: PY-WL-102, qualname: "validators.no_rejection", label: TRUE_POSITIVE, note: "trust_boundary(ASSURED) with no rejection path"} - - {rule_id: PY-WL-102, qualname: "validators.no_rejection_guarded", label: TRUE_POSITIVE, note: "trust_boundary(GUARDED) with no rejection path"} + - {rule_id: PY-WL-102, qualname: "validators.no_rejection", label: TRUE_POSITIVE, note: "trust_boundary(ASSURED) with no rejection path (laundered shape; bare return-p belongs to PY-WL-119)"} + - {rule_id: PY-WL-102, qualname: "validators.no_rejection_guarded", label: TRUE_POSITIVE, note: "trust_boundary(GUARDED) with no rejection path (laundered shape)"} exceptions.py: - {rule_id: PY-WL-101, qualname: "exceptions.broad_handler", label: TRUE_POSITIVE, note: "returns undecorated work() (UNKNOWN_RAW) while declaring INTEGRAL"} - {rule_id: PY-WL-101, qualname: "exceptions.silent_handler", label: TRUE_POSITIVE, note: "returns undecorated work()/None while declaring INTEGRAL"} @@ -46,3 +55,13 @@ fixtures: - {rule_id: PY-WL-101, qualname: "more_shapes.augassign_raw", label: TRUE_POSITIVE, note: "augmented assignment merges raw in"} - {rule_id: PY-WL-101, qualname: "more_shapes.launders_through_broken_boundary", label: TRUE_POSITIVE, note: "declared ASSURED but body re-derives raw"} - {rule_id: PY-WL-102, qualname: "more_shapes.passthrough_no_check", label: TRUE_POSITIVE, note: "boundary that cannot reject"} +sentinels: + clean_sql_parameterized.py: + - {rule_id: PY-WL-118, qualname: "clean_sql_parameterized.parameterized_query", label: FALSE_POSITIVE, note: "sentinel: placeholder SQL with the raw value confined to the parameter tuple — must stay silent"} + clean_boundary_rejection.py: + - {rule_id: PY-WL-102, qualname: "clean_boundary_rejection.validate_token", label: FALSE_POSITIVE, note: "sentinel: boundary with a real raise-on-invalid rejection path — must stay silent"} + clean_exec_const.py: + - {rule_id: PY-WL-107, qualname: "clean_exec_const.const_eval", label: FALSE_POSITIVE, note: "sentinel: eval over a constant-only argument — must stay silent"} + clean_command_const.py: + - {rule_id: PY-WL-108, qualname: "clean_command_const.const_system", label: FALSE_POSITIVE, note: "sentinel: os.system over a literal command — must stay silent"} + - {rule_id: PY-WL-108, qualname: "clean_command_const.quoted_const_run", label: FALSE_POSITIVE, note: "sentinel: shlex-quoted constant through subprocess.run(shell=True) — must stay silent"} diff --git a/tests/corpus/fixtures/shadow_launder.py b/tests/corpus/fixtures/shadow_launder.py new file mode 100644 index 00000000..9719ae30 --- /dev/null +++ b/tests/corpus/fixtures/shadow_launder.py @@ -0,0 +1,25 @@ +# Corpus fixture for the receiver-shadow taint launder (wardline-f6a29ce23a). +# A raw LOCAL shadows the imported stdlib module ``ast``; the early taint_map +# short-circuit in _resolve_call used to return the module entry's clean taint for +# ``ast.literal_eval`` (GUARDED — the one reliably-minted clean stdlib dotted key), +# laundering the raw receiver past the os.system sink. This is a DISCRIMINATING +# regression guard: with the fix removed the finding disappears (manifest entry +# goes stale -> corpus gate fails), unlike a project-import shape whose dotted key +# is never minted clean (so it fires via the RAW_ZONE fallthrough either way). +import ast # noqa: F401 (shadowed on purpose below — that IS the fixture) +import os + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def read_raw(p): + return p + + +@trusted(level="ASSURED") +def shadowed_sink(p): # TP: raw local shadows the 'ast' module -> PY-WL-108 must fire + ast = read_raw(p) # noqa: F811 (intentional module-name shadow) + cmd = ast.literal_eval(p) + os.system(cmd) + return 1 diff --git a/tests/corpus/fixtures/shadow_launder_bare.py b/tests/corpus/fixtures/shadow_launder_bare.py new file mode 100644 index 00000000..f2b094d7 --- /dev/null +++ b/tests/corpus/fixtures/shadow_launder_bare.py @@ -0,0 +1,22 @@ +# Corpus fixture for the BARE-NAME receiver-shadow launder (wardline-f6a29ce23a). +# A raw LOCAL shadows the from-imported clean function ``literal_eval``; the +# bare-name call path used to return the import's clean taint_map entry (GUARDED), +# laundering the raw value past the os.system sink. Discriminating: with the fix +# removed the finding disappears (manifest entry goes stale -> corpus gate fails). +import os +from ast import literal_eval # noqa: F401 (shadowed on purpose below — that IS the fixture) + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def read_raw(p): + return p + + +@trusted(level="ASSURED") +def bare_shadow_sink(p): # TP: raw local shadows the imported 'literal_eval' -> PY-WL-108 + literal_eval = read_raw(p) # noqa: F811 (intentional import-name shadow) + cmd = literal_eval(p) + os.system(cmd) + return 1 diff --git a/tests/corpus/fixtures/validators.py b/tests/corpus/fixtures/validators.py index fe26dbd5..e798d111 100644 --- a/tests/corpus/fixtures/validators.py +++ b/tests/corpus/fixtures/validators.py @@ -5,13 +5,15 @@ @trust_boundary(to_level="ASSURED") -def no_rejection(p): # TP: cannot reject → PY-WL-102 - return p +def no_rejection(p): # TP: cannot reject → PY-WL-102 (laundered shape; bare return-p is PY-WL-119's) + cleaned = p + return cleaned @trust_boundary(to_level="GUARDED") -def no_rejection_guarded(p): # TP: cannot reject → PY-WL-102 - return p +def no_rejection_guarded(p): # TP: cannot reject → PY-WL-102 (laundered shape; bare return-p is PY-WL-119's) + cleaned = p + return cleaned @trust_boundary(to_level="ASSURED") diff --git a/tests/corpus/harness.py b/tests/corpus/harness.py index 017bab83..114b3a5f 100644 --- a/tests/corpus/harness.py +++ b/tests/corpus/harness.py @@ -1,10 +1,18 @@ -"""Labeled-corpus harness (T1.4): run the engine over tests/corpus/fixtures and -reconcile active DEFECT findings against MANIFEST.yaml ground truth. +"""Labeled-corpus harness (T1.4): run the engine over tests/corpus/fixtures plus +tests/corpus/sentinels and reconcile active DEFECT findings against MANIFEST.yaml +ground truth. -Matching key = (path relative to fixtures, rule_id, qualname). Line numbers are +Matching key = (path relative to the scan root, rule_id, qualname). Line numbers are deliberately NOT part of the key so line edits can't break the corpus. The FP rate is measured over *active DEFECT* findings only (the policy surface, PY-WL-*) — engine FACTs/metrics are not findings a user triages. + +Two scan roots: `fixtures/` carries the TRUE_POSITIVE defect shapes and is the +frozen substrate of the Track 2 byte-identity golden (tests/grammar) — it must not +grow casually. `sentinels/` carries the clean-shape FALSE_POSITIVE sentinels the +engine must stay silent on; it is reconciled into the same DEFECT pool but is +invisible to the golden, so sentinels can be added freely. File names must be +unique across both roots (the matching key is root-relative). """ from __future__ import annotations @@ -18,6 +26,7 @@ from wardline.core.run import run_scan CORPUS_ROOT = Path(__file__).parent / "fixtures" +SENTINEL_ROOT = Path(__file__).parent / "sentinels" MANIFEST_PATH = Path(__file__).parent / "MANIFEST.yaml" TRUE_POSITIVE = "TRUE_POSITIVE" @@ -39,7 +48,7 @@ class Reconciliation: active_defects: int false_positives: int unaccounted: list[tuple[str, str, str]] # (path, rule_id, qualname) findings with no manifest entry - stale: list[Expectation] # manifest entries that matched no finding + stale: list[Expectation] # TRUE_POSITIVE entries that matched no finding (silent FP sentinels are passing) @property def fp_rate(self) -> float: @@ -49,32 +58,39 @@ def fp_rate(self) -> float: def load_manifest() -> list[Expectation]: raw = yaml.safe_load(MANIFEST_PATH.read_text()) or {} out: list[Expectation] = [] - for path, entries in (raw.get("fixtures") or {}).items(): - for entry in entries or []: - label = entry["label"] - if label not in _LABELS: - raise ValueError(f"{path}: bad label {label!r} (want one of {sorted(_LABELS)})") - out.append( - Expectation( - path=path, - rule_id=entry["rule_id"], - qualname=entry["qualname"], - label=label, - note=entry.get("note", ""), + seen_paths: set[str] = set() + for section in ("fixtures", "sentinels"): + for path, entries in (raw.get(section) or {}).items(): + if path in seen_paths: + raise ValueError(f"{path}: file name reused across scan roots — the matching key is root-relative") + seen_paths.add(path) + for entry in entries or []: + label = entry["label"] + if label not in _LABELS: + raise ValueError(f"{path}: bad label {label!r} (want one of {sorted(_LABELS)})") + if section == "sentinels" and label != FALSE_POSITIVE: + raise ValueError(f"{path}: sentinels/ holds clean shapes only — label must be FALSE_POSITIVE") + out.append( + Expectation( + path=path, + rule_id=entry["rule_id"], + qualname=entry["qualname"], + label=label, + note=entry.get("note", ""), + ) ) - ) return out def reconcile() -> Reconciliation: - result = run_scan(CORPUS_ROOT) + findings = [f for root in (CORPUS_ROOT, SENTINEL_ROOT) for f in run_scan(root).findings] expectations = load_manifest() by_key: dict[tuple[str, str, str], Expectation] = {(e.path, e.rule_id, e.qualname): e for e in expectations} matched_keys: set[tuple[str, str, str]] = set() active_defects = 0 false_positives = 0 unaccounted: list[tuple[str, str, str]] = [] - for finding in result.findings: + for finding in findings: if finding.kind is not Kind.DEFECT or finding.suppressed is not SuppressionState.ACTIVE: continue if finding.maturity is Maturity.PREVIEW: @@ -88,7 +104,11 @@ def reconcile() -> Reconciliation: matched_keys.add(key) if expectation.label == FALSE_POSITIVE: false_positives += 1 - stale = [e for e in expectations if (e.path, e.rule_id, e.qualname) not in matched_keys] + # Staleness only applies to TRUE_POSITIVE entries: a FALSE_POSITIVE sentinel the + # engine stays silent on is the engine behaving correctly, not a dead manifest row. + stale = [ + e for e in expectations if e.label == TRUE_POSITIVE and (e.path, e.rule_id, e.qualname) not in matched_keys + ] return Reconciliation( active_defects=active_defects, false_positives=false_positives, diff --git a/tests/corpus/rust/clean_commands.rs b/tests/corpus/rust/clean_commands.rs new file mode 100644 index 00000000..a11db0fd --- /dev/null +++ b/tests/corpus/rust/clean_commands.rs @@ -0,0 +1,43 @@ +// WP6 hard-zero corpus: idiomatic, SAFE command usage that must yield ZERO RS-WL +// findings. Each fn is @trusted (so any spurious flag would surface, not be modulated +// away) and exercises an FP-prone shape the analyzer has full information to clear. + +use std::process::Command; + +/// @trusted(level=ASSURED) +fn all_literal() { + Command::new("git").arg("status").arg("--short").output(); +} + +/// @trusted(level=ASSURED) +fn literal_shell_literal_arg() { + // A shell with a LITERAL command line is not injection. + Command::new("sh").arg("-c").arg("echo hello").output(); +} + +/// @trusted(level=ASSURED) +fn nonshell_program_tainted_arg() { + // Tainted arg into a non-shell program: argv-list, no shell metacharacter risk. + let user = std::env::var("USER").unwrap(); + Command::new("id").arg(user).output(); +} + +/// @trusted(level=ASSURED) +fn rebound_to_literal_before_use() { + let prog = std::env::var("PROG").unwrap(); + let prog = "echo"; + Command::new(prog).arg("done").output(); +} + +/// @trusted(level=ASSURED) +fn tainted_value_never_reaches_command() { + // The boundary read is used elsewhere; the command is fully literal. + let _config = std::fs::read_to_string("config.toml").unwrap(); + Command::new("true").output(); +} + +/// @trusted(level=ASSURED) +fn shell_no_flag_literal() { + // sh WITHOUT -c (no shell command line) and a literal program path. + Command::new("/bin/sh").arg("script.sh").output(); +} diff --git a/tests/corpus/rust/command_sink.rs b/tests/corpus/rust/command_sink.rs new file mode 100644 index 00000000..270e41a6 --- /dev/null +++ b/tests/corpus/rust/command_sink.rs @@ -0,0 +1,90 @@ +// WP6 dense positive corpus: every @trusted fn below is an INTENDED command-injection +// sink (6 × RS-WL-108 program injection, 3 × RS-WL-112 shell injection) plus 3 benign +// neighbours that must NOT fire (the false-positive probes). The frontend must flag +// exactly the 9 intended sinks and none of the 3 benign ones (≤5% FP gate, target 0). +// +// All taint is sourced through the proven `.unwrap()`-wrapped std sources +// (env::var / env::var_os / fs::read_to_string / fs::read → EXTERNAL_RAW). + +use std::process::Command; + +// ---- RS-WL-108: untrusted data selects the program (6 sinks) ---- + +/// @trusted(level=ASSURED) +fn sink_env_var_output() { + let t = std::env::var("PROG").unwrap(); + Command::new(t).output(); +} + +/// @trusted(level=ASSURED) +fn sink_env_var_os_status() { + let t = std::env::var_os("PROG").unwrap(); + Command::new(t).status(); +} + +/// @trusted(level=ASSURED) +fn sink_fs_read_to_string_try() -> std::io::Result<()> { + let t = std::fs::read_to_string("prog.txt").unwrap(); + Command::new(t).output()?; + Ok(()) +} + +/// @trusted(level=ASSURED) +async fn sink_fs_read_await() { + let t = std::fs::read("prog.bin").unwrap(); + Command::new(t).output().await; +} + +/// @trusted(level=ASSURED) +fn sink_return_position() -> std::process::Output { + let t = std::env::var("PROG").unwrap(); + return Command::new(t).output().unwrap(); +} + +/// @trusted(level=ASSURED) +fn sink_stepwise() { + let t = std::fs::read_to_string("prog.txt").unwrap(); + let mut c = Command::new(t); + c.output(); +} + +// ---- RS-WL-112: untrusted data reaches a shell command line (3 sinks) ---- + +/// @trusted(level=ASSURED) +fn sink_sh_dash_c() { + let t = std::env::var("CMD").unwrap(); + Command::new("sh").arg("-c").arg(t).output(); +} + +/// @trusted(level=ASSURED) +fn sink_bin_bash_dash_c() { + let t = std::fs::read_to_string("cmd.txt").unwrap(); + Command::new("/bin/bash").arg("-c").arg(t).status(); +} + +/// @trusted(level=ASSURED) +fn sink_powershell_command() { + let t = std::env::var_os("CMD").unwrap(); + Command::new("powershell").arg("-Command").arg(t).spawn(); +} + +// ---- Benign neighbours: must NOT fire (false-positive probes) ---- + +/// @trusted(level=ASSURED) +fn benign_all_literal() { + Command::new("ls").arg("-la").output(); +} + +/// @trusted(level=ASSURED) +fn benign_nonshell_tainted_arg() { + // Tainted ARG into a NON-shell program is the argv-list flood, not injection. + let t = std::env::var("NAME").unwrap(); + Command::new("ls").arg(t).output(); +} + +/// @trusted(level=ASSURED) +fn benign_rebound_to_clean() { + let t = std::env::var("PROG").unwrap(); + let t = "safe"; + Command::new(t).output(); +} diff --git a/tests/corpus/sentinels/clean_boundary_rejection.py b/tests/corpus/sentinels/clean_boundary_rejection.py new file mode 100644 index 00000000..7624f064 --- /dev/null +++ b/tests/corpus/sentinels/clean_boundary_rejection.py @@ -0,0 +1,10 @@ +# FP sentinel for PY-WL-102: a trust boundary with a real raise-on-invalid rejection +# path CAN say "no", so the degenerate-boundary rule must stay silent. +from wardline.decorators import trust_boundary + + +@trust_boundary(to_level="ASSURED") +def validate_token(p): # FP sentinel: raise path = real rejection + if not isinstance(p, str) or not p.isalnum(): + raise ValueError("reject") + return p diff --git a/tests/corpus/sentinels/clean_command_const.py b/tests/corpus/sentinels/clean_command_const.py new file mode 100644 index 00000000..c6ca39a9 --- /dev/null +++ b/tests/corpus/sentinels/clean_command_const.py @@ -0,0 +1,21 @@ +# FP sentinels for PY-WL-108: constant-only command arguments — os.system over a +# literal, and a shlex-quoted constant through subprocess.run. No untrusted value +# reaches either command sink, so the engine must stay silent on both. +import os +import shlex +import subprocess + +from wardline.decorators import trusted + + +@trusted(level="ASSURED") +def const_system(p): # FP sentinel: literal command string + os.system("ls -l /tmp") + return 1 + + +@trusted(level="ASSURED") +def quoted_const_run(p): # FP sentinel: shlex-quoted constant command + cmd = shlex.quote("/usr/bin/true") + subprocess.run(cmd, shell=True) + return 1 diff --git a/tests/corpus/sentinels/clean_exec_const.py b/tests/corpus/sentinels/clean_exec_const.py new file mode 100644 index 00000000..b24cc156 --- /dev/null +++ b/tests/corpus/sentinels/clean_exec_const.py @@ -0,0 +1,8 @@ +# FP sentinel for PY-WL-107: eval over a constant-only argument — no untrusted value +# can reach the dynamic-exec sink, so the engine must stay silent. +from wardline.decorators import trusted + + +@trusted(level="ASSURED") +def const_eval(p): # FP sentinel: literal source text, nothing tainted flows in + return eval("1 + 1") diff --git a/tests/corpus/sentinels/clean_sql_parameterized.py b/tests/corpus/sentinels/clean_sql_parameterized.py new file mode 100644 index 00000000..0e24aca2 --- /dev/null +++ b/tests/corpus/sentinels/clean_sql_parameterized.py @@ -0,0 +1,18 @@ +# FP sentinel for PY-WL-118: a properly parameterized sqlite query — the raw value +# travels in the parameter tuple, never the SQL text, so the engine must stay silent. +import sqlite3 + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def read_raw(p): + return p + + +@trusted(level="ASSURED") +def parameterized_query(p): # FP sentinel: placeholder SQL, taint confined to params + name = read_raw(p) + conn = sqlite3.connect(":memory:") + conn.execute("SELECT * FROM users WHERE name = ?", (name,)) + return 1 diff --git a/tests/corpus/test_fp_rate.py b/tests/corpus/test_fp_rate.py index 8aad110b..cf40bfc7 100644 --- a/tests/corpus/test_fp_rate.py +++ b/tests/corpus/test_fp_rate.py @@ -2,11 +2,15 @@ FP rate = active DEFECTs labeled FALSE_POSITIVE / total active DEFECTs, gated <= 5%. The corpus is sized so a single mislabel cannot trivially breach the budget. + +FALSE_POSITIVE entries are clean-shape sentinels: the engine must NOT fire on them. +Silent sentinel = passing (not stale); fired sentinel = a live FP counted against the +budget. The corpus must carry sentinels so the gate exercises real reconciliation. """ from __future__ import annotations -from corpus.harness import reconcile # type: ignore[import-not-found] +from corpus.harness import FALSE_POSITIVE, load_manifest, reconcile # type: ignore[import-not-found] def test_fp_rate_within_budget(): @@ -24,6 +28,45 @@ def test_fp_rate_within_budget(): assert rec.fp_rate <= 0.05, f"FP rate {rec.fp_rate:.1%} exceeds the 5% budget" +def test_corpus_carries_false_positive_sentinels(): + # The gate only exercises real reconciliation if the corpus mixes labels: clean-shape + # sentinels (FALSE_POSITIVE) alongside the TRUE_POSITIVE defects. A silent sentinel is + # the engine behaving correctly, so it must NOT be reported stale. + expectations = load_manifest() + sentinels = [e for e in expectations if e.label == FALSE_POSITIVE] + assert len(sentinels) >= 3, ( + f"corpus has {len(sentinels)} FALSE_POSITIVE sentinels (need >= 3 so the FP-rate " + "gate computes over a mixed corpus, not a vacuous all-TP one)" + ) + rec = reconcile() + silent_stale = [e for e in rec.stale if e.label == FALSE_POSITIVE] + assert not silent_stale, ( + "silent FALSE_POSITIVE sentinels were reported stale — a sentinel the engine " + "does not fire on is PASSING, not stale: " + f"{[(e.path, e.rule_id, e.qualname) for e in silent_stale]}" + ) + + +def test_fired_sentinel_counts_against_budget(monkeypatch, tmp_path): + # End-to-end fired-sentinel path: relabel a known-firing fixture entry as + # FALSE_POSITIVE in a scratch manifest — the fired finding must be counted as a + # live FP (against the budget), not reported stale or unaccounted. + import corpus.harness as harness # type: ignore[import-not-found] + + original = harness.MANIFEST_PATH.read_text() + relabel_target = 'qualname: "deser_sink.loads_untrusted", label: TRUE_POSITIVE' + assert relabel_target in original, "relabel target drifted — pick another known-firing entry" + scratch = tmp_path / "MANIFEST.yaml" + scratch.write_text(original.replace(relabel_target, relabel_target.replace("TRUE_POSITIVE", "FALSE_POSITIVE"))) + monkeypatch.setattr(harness, "MANIFEST_PATH", scratch) + + rec = reconcile() + assert rec.false_positives == 1 + assert rec.fp_rate == 1 / rec.active_defects + assert not rec.unaccounted + assert "deser_sink.loads_untrusted" not in {e.qualname for e in rec.stale} + + def test_reconciliation_fp_rate_arithmetic(): # Directly exercise the FP-rate computation on the FALSE_POSITIVE path, which the # live corpus (all TRUE_POSITIVE today) never hits. Guards the gate's own math. diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index a9b086a8..e073f8e4 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -2,8 +2,8 @@ The glossary at ``docs/reference/finding-lifecycle-vocabulary.md`` is the single source of truth for the finding-state / gate-population vocabulary. These tests -keep it complete (every ``SuppressionState`` value documented) and wired into the -mkdocs nav (so ``mkdocs build --strict`` does not orphan it). +keep it complete (every ``SuppressionState`` value documented) and bound to the +code it cites (every ``file:line`` anchor still points at the right source line). """ from __future__ import annotations @@ -15,8 +15,6 @@ _REPO = Path(__file__).parents[2] _GLOSSARY = _REPO / "docs" / "reference" / "finding-lifecycle-vocabulary.md" -_MKDOCS = _REPO / "mkdocs.yml" -_NAV_PATH = "reference/finding-lifecycle-vocabulary.md" # The glossary promises "every claim cites a real `file:line`". Line anchors rot silently # when the cited code moves (an in-range / non-blank check would NOT catch it — the line @@ -26,43 +24,54 @@ # ``(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", 49, "total: int"), - ("src/wardline/core/run.py", 50, "active: int"), - ("src/wardline/core/run.py", 52, "baselined: int"), - ("src/wardline/core/run.py", 53, "waived: int"), - ("src/wardline/core/run.py", 54, "judged: int"), - ("src/wardline/core/run.py", 60, "unanalyzed: int"), - ("src/wardline/core/run.py", 79, "gate_findings:"), - ("src/wardline/core/run.py", 86, "class GateDecision"), - ("src/wardline/core/run.py", 254, "Baseline(frozenset())"), - ("src/wardline/core/run.py", 264, "def apply_delta_scope"), - ("src/wardline/core/run.py", 288, "active=sum"), - ("src/wardline/core/run.py", 315, "honors_suppressions"), + ("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/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 360, "suppressed"), - ("src/wardline/cli/scan.py", 361, "{s.active} active"), - ("src/wardline/cli/scan.py", 375, "gate: FAILED"), + ("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/mcp/server.py — MCP scan summary + gate block - ("src/wardline/mcp/server.py", 312, '"total": result.summary.total'), - ("src/wardline/mcp/server.py", 313, '"active": result.summary.active'), - ("src/wardline/mcp/server.py", 314, '"baselined": result.summary.baselined'), - ("src/wardline/mcp/server.py", 315, '"waived": result.summary.waived'), - ("src/wardline/mcp/server.py", 316, '"judged": result.summary.judged'), - ("src/wardline/mcp/server.py", 320, '"unanalyzed": result.summary.unanalyzed'), - ("src/wardline/mcp/server.py", 332, '"gate": {'), - ("src/wardline/mcp/server.py", 333, '"tripped": decision.tripped'), + ("src/wardline/mcp/server.py", 909, '"total": result.summary.total'), + ("src/wardline/mcp/server.py", 910, '"active": result.summary.active'), + ("src/wardline/mcp/server.py", 911, '"baselined": result.summary.baselined'), + ("src/wardline/mcp/server.py", 912, '"waived": result.summary.waived'), + ("src/wardline/mcp/server.py", 913, '"judged": result.summary.judged'), + ("src/wardline/mcp/server.py", 918, '"informational": result.summary.informational'), + ("src/wardline/mcp/server.py", 922, '"unanalyzed": result.summary.unanalyzed'), + ("src/wardline/mcp/server.py", 924, '"gate": {'), + ("src/wardline/mcp/server.py", 925, '"tripped": decision.tripped'), + ("src/wardline/mcp/server.py", 929, '"verdict": decision.verdict'), # src/wardline/core/agent_summary.py — agent-summary JSON keys - ("src/wardline/core/agent_summary.py", 98, '"total_findings"'), - ("src/wardline/core/agent_summary.py", 99, '"active_defects"'), - ("src/wardline/core/agent_summary.py", 100, '"suppressed_findings"'), - ("src/wardline/core/agent_summary.py", 102, '"baselined"'), - ("src/wardline/core/agent_summary.py", 103, '"waived"'), - ("src/wardline/core/agent_summary.py", 104, '"judged"'), - ("src/wardline/core/agent_summary.py", 105, '"unanalyzed"'), - ("src/wardline/core/agent_summary.py", 108, '"tripped": self.gate.tripped'), + ("src/wardline/core/agent_summary.py", 139, '"total_findings"'), + ("src/wardline/core/agent_summary.py", 140, '"active_defects"'), + ("src/wardline/core/agent_summary.py", 141, '"suppressed_findings"'), + ("src/wardline/core/agent_summary.py", 143, '"baselined"'), + ("src/wardline/core/agent_summary.py", 144, '"waived"'), + ("src/wardline/core/agent_summary.py", 145, '"judged"'), + ("src/wardline/core/agent_summary.py", 151, '"informational"'), + ("src/wardline/core/agent_summary.py", 152, '"unanalyzed"'), + ("src/wardline/core/agent_summary.py", 155, '"tripped": self.gate.tripped'), + ("src/wardline/core/agent_summary.py", 158, '"verdict": self.gate.verdict'), + # informational display array (new, W3 residual fix) + ("src/wardline/core/agent_summary.py", 176, '"informational": informational'), + # per-finding suppression_state output key (renamed from `suppressed`, weft-f506e5f845) + ("src/wardline/core/finding.py", 140, '"suppression_state"'), + ("src/wardline/core/finding.py", 295, 'wardline["suppression_state"]'), # stable-file anchors (lower churn, but locked for free) - ("src/wardline/core/finding.py", 68, 'ACTIVE = "active"'), - ("src/wardline/core/suppression.py", 70, "SuppressionState.BASELINED"), + ("src/wardline/core/finding.py", 72, 'ACTIVE = "active"'), + ("src/wardline/core/suppression.py", 24, "SuppressionState.BASELINED"), ) @@ -72,11 +81,6 @@ def test_glossary_defines_every_suppression_state() -> None: assert state.value in text, f"glossary is missing SuppressionState '{state.value}'" -def test_glossary_in_nav() -> None: - nav = _MKDOCS.read_text(encoding="utf-8") - assert _NAV_PATH in nav, f"{_NAV_PATH} is not wired into the mkdocs nav" - - def test_glossary_anchors_bind_to_code() -> None: """Each load-bearing ``file:line`` the glossary cites must point at the right code. diff --git a/tests/e2e/test_loomweave_live.py b/tests/e2e/test_loomweave_live.py index a78396c7..0525a6e4 100644 --- a/tests/e2e/test_loomweave_live.py +++ b/tests/e2e/test_loomweave_live.py @@ -94,6 +94,7 @@ def _wait_for_capabilities(proc: subprocess.Popen[bytes], log: Path) -> str: with the server log tail so the caller can skip with a specific reason.""" deadline = time.monotonic() + 20.0 base_url: str | None = None + url = "" last_err = "waiting for Loomweave to report HTTP bind address" while time.monotonic() < deadline: if proc.poll() is not None: diff --git a/tests/e2e/test_rust_live.py b/tests/e2e/test_rust_live.py new file mode 100644 index 00000000..24908138 --- /dev/null +++ b/tests/e2e/test_rust_live.py @@ -0,0 +1,66 @@ +"""WP6 live e2e: the real ``wardline scan --lang rust`` CLI over the ``.rs`` corpus. + +Deselected by default (marker ``rust_e2e``); run with: + .venv/bin/pytest -m rust_e2e -v + +Unlike the loomweave/legis/filigree e2es this needs no external server — only the +``wardline[rust]`` extra (tree-sitter). It shells out through the SAME interpreter +running the suite (``sys.executable``), so it exercises *this* worktree's wardline, not +whatever ``wardline`` console script happens to be on PATH (the editable-main hazard). +This is the only check that drives discovery → analyze → JSONL → gate → process exit +code as one real subprocess. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +pytestmark = pytest.mark.rust_e2e + +_CORPUS = Path(__file__).resolve().parents[1] / "corpus" / "rust" +# Run the CLI in-interpreter via -c so the worktree's wardline is imported (no PATH lookup). +_RUN_CLI = "from wardline.cli.entrypoint import main; main()" + + +def _scan(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", _RUN_CLI, "scan", *args], + capture_output=True, + text=True, + check=False, + ) + + +def test_live_scan_rust_corpus_trips_gate_and_emits_findings(tmp_path) -> None: + # The dense corpus file alone (clean file excluded so only true positives are present). + work = tmp_path / "proj" + work.mkdir() + (work / "command_sink.rs").write_text((_CORPUS / "command_sink.rs").read_text(), encoding="utf-8") + out = tmp_path / "findings.jsonl" + + proc = _scan([str(work), "--lang", "rust", "--fail-on", "ERROR", "--output", str(out)]) + + assert proc.returncode == 1, proc.stderr # gate tripped on the RS-WL-108 ERRORs + # The graduated posture banner (the old "(preview)" banner is gone): coverage is + # the command-injection slice; severity-override gap still surfaced. + assert "command-injection slice" in (proc.stdout + proc.stderr) + rule_ids = [json.loads(line)["rule_id"] for line in out.read_text().splitlines() if line.strip()] + assert rule_ids.count("RS-WL-108") == 6 + assert rule_ids.count("RS-WL-112") == 3 + + +def test_live_scan_rust_clean_corpus_exits_zero(tmp_path) -> None: + work = tmp_path / "proj" + work.mkdir() + (work / "clean_commands.rs").write_text((_CORPUS / "clean_commands.rs").read_text(), encoding="utf-8") + out = tmp_path / "findings.jsonl" + + proc = _scan([str(work), "--lang", "rust", "--fail-on", "ERROR", "--output", str(out)]) + assert proc.returncode == 0, proc.stderr diff --git a/tests/fixtures/self_hosting_violation/trust_boundary_violation.py b/tests/fixtures/self_hosting_violation/trust_boundary_violation.py new file mode 100644 index 00000000..592b6f1e --- /dev/null +++ b/tests/fixtures/self_hosting_violation/trust_boundary_violation.py @@ -0,0 +1,14 @@ +"""Non-vacuous self-hosting fixture: a real trust decorator plus a tainted return +that MUST fire a tier-gated rule (PY-WL-101). Kept OUT of src/ so the src/ self-scan +stays clean; this proves the self-hosting pipeline CAN catch a real violation.""" + +from wardline.decorators import trusted + + +def read_raw(p): # undecorated source surrogate -> UNKNOWN_RAW seed + return p + + +@trusted(level="ASSURED") +def leaks_untrusted(p): # TP: returns raw (UNKNOWN_RAW) from a @trusted producer + return read_raw(p) diff --git a/tests/golden/identity/_capture.py b/tests/golden/identity/_capture.py index d3800a6e..ac21ca55 100644 --- a/tests/golden/identity/_capture.py +++ b/tests/golden/identity/_capture.py @@ -108,7 +108,7 @@ def _sarif_result_sort_key(res: dict[str, Any]) -> tuple[str, int, str, str, str uri = res["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] region = res["locations"][0]["physicalLocation"].get("region", {}) start = region.get("startLine", -1) - fp = res["partialFingerprints"]["wardlineFingerprint/v1"] + fp = res["partialFingerprints"]["wardlineFingerprint/v2"] # Total tiebreaker (see _finding_sort_key) — fingerprint can collide. return (uri or "", start, res["ruleId"], fp, json.dumps(res, sort_keys=True, ensure_ascii=False)) diff --git a/tests/golden/identity/corpus/META.json b/tests/golden/identity/corpus/META.json index 909514fb..82fbc731 100644 --- a/tests/golden/identity/corpus/META.json +++ b/tests/golden/identity/corpus/META.json @@ -1,4 +1,5 @@ { - "corpus_version": 1, - "reason": "Federation rebrand: corpus docstring brand text changed the whole-file content_hash (no identity/structure change)" + "corpus_version": 4, + "fingerprint_scheme": "wlfp2", + "reason": "assure posture gains additive unanalyzed_total coverage field (pollable scan-job + coverage WIP)" } diff --git a/tests/golden/identity/corpus/assure.json b/tests/golden/identity/corpus/assure.json index cac7cf49..11643539 100644 --- a/tests/golden/identity/corpus/assure.json +++ b/tests/golden/identity/corpus/assure.json @@ -8,6 +8,20 @@ "judged_total": 0, "proven": 3, "unanalyzed_rule_ids": [], + "unanalyzed_total": 0, + "unknown": [], + "waiver_debt": [] + }, + "sinks": { + "baselined_total": 0, + "boundaries_total": 11, + "coverage_pct": 100.0, + "defect_total": 10, + "engine_limited": 0, + "judged_total": 0, + "proven": 1, + "unanalyzed_rule_ids": [], + "unanalyzed_total": 0, "unknown": [], "waiver_debt": [] }, @@ -20,6 +34,7 @@ "judged_total": 0, "proven": 3, "unanalyzed_rule_ids": [], + "unanalyzed_total": 0, "unknown": [], "waiver_debt": [] } diff --git a/tests/golden/identity/corpus/sampleapp.json b/tests/golden/identity/corpus/sampleapp.json index 55532705..7b2673b5 100644 --- a/tests/golden/identity/corpus/sampleapp.json +++ b/tests/golden/identity/corpus/sampleapp.json @@ -543,19 +543,20 @@ ], "explain": { "explanation": { - "fingerprint": "67252346d2858a1b6546cc6178f0582604a77fab1b429e4b9a36f650c8dbdd2a", + "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f", "immediate_tainted_callee": "read_raw_username", "line": 41, "path": "trust_flow.py", "resolved_call_count": 1, "rule_id": "PY-WL-101", + "sink": null, "sink_qualname": "trust_flow.unsafe_account_key", "source_boundary_qualname": "trust_flow.read_raw_username", "tier_in": "EXTERNAL_RAW", "tier_out": "ASSURED", "unresolved_call_count": 0 }, - "fingerprint": "67252346d2858a1b6546cc6178f0582604a77fab1b429e4b9a36f650c8dbdd2a" + "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f" }, "facts": [ { @@ -981,7 +982,7 @@ "qualname": "models.Money.__lt__", "schema_version": "wardline-taint-1", "taint": { - "actual_return": "UNKNOWN_RAW", + "actual_return": "INTEGRAL", "contributing_callee_qualname": null, "declared_return": "UNKNOWN_RAW", "resolved_call_count": 0, @@ -1149,7 +1150,7 @@ "qualname": "repository.Repository.__contains__", "schema_version": "wardline-taint-1", "taint": { - "actual_return": "UNKNOWN_RAW", + "actual_return": "INTEGRAL", "contributing_callee_qualname": null, "declared_return": "UNKNOWN_RAW", "resolved_call_count": 0, @@ -1825,7 +1826,7 @@ }, "findings": [ { - "fingerprint": "67252346d2858a1b6546cc6178f0582604a77fab1b429e4b9a36f650c8dbdd2a", + "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f", "line_start": 41, "path": "trust_flow.py", "rule_id": "PY-WL-101" @@ -1873,7 +1874,7 @@ "findings": [ { "confidence": null, - "fingerprint": "67252346d2858a1b6546cc6178f0582604a77fab1b429e4b9a36f650c8dbdd2a", + "fingerprint": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f", "kind": "defect", "location": { "col_end": 34, @@ -1893,8 +1894,8 @@ "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, - "suppressed": "active", - "suppression_reason": null + "suppression_reason": null, + "suppression_state": "active" } ], "sarif": { @@ -1971,7 +1972,7 @@ "text": "trust_flow.unsafe_account_key declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v1": "67252346d2858a1b6546cc6178f0582604a77fab1b429e4b9a36f650c8dbdd2a" + "wardlineFingerprint/v2": "a3664243f41893d7f4d5272d1b1a4f98a383bf67a78cd15b1e2f851782753b5f" }, "properties": { "internalSeverity": "ERROR", diff --git a/tests/golden/identity/corpus/sinks.json b/tests/golden/identity/corpus/sinks.json new file mode 100644 index 00000000..43e3bbb1 --- /dev/null +++ b/tests/golden/identity/corpus/sinks.json @@ -0,0 +1,2663 @@ +{ + "entity_spans": [ + { + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 61, + "line_start": 60, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks._refine_a" + }, + { + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 66, + "line_start": 65, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks._refine_b" + }, + { + "location": { + "col_end": 76, + "col_start": 0, + "line_end": 95, + "line_start": 88, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.chained_queries" + }, + { + "location": { + "col_end": 49, + "col_start": 0, + "line_end": 84, + "line_start": 79, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.double_deserialize" + }, + { + "location": { + "col_end": 47, + "col_start": 0, + "line_end": 75, + "line_start": 70, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.fan_out_stored" + }, + { + "location": { + "col_end": 29, + "col_start": 0, + "line_end": 34, + "line_start": 31, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.import_catalog_blob" + }, + { + "location": { + "col_end": 40, + "col_start": 0, + "line_end": 48, + "line_start": 45, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.load_report_plugin" + }, + { + "location": { + "col_end": 25, + "col_start": 0, + "line_end": 119, + "line_start": 111, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.lookup_member" + }, + { + "location": { + "col_end": 24, + "col_start": 0, + "line_end": 56, + "line_start": 52, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.open_catalog_file" + }, + { + "location": { + "col_end": 34, + "col_start": 0, + "line_end": 27, + "line_start": 25, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.read_admin_arg" + }, + { + "location": { + "col_end": 55, + "col_start": 0, + "line_end": 41, + "line_start": 38, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.run_export" + }, + { + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 107, + "line_start": 100, + "path": "wardline_sinks.py" + }, + "qualname": "wardline_sinks.stacked_invalid_levels" + } + ], + "explain": { + "explanation": { + "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190", + "immediate_tainted_callee": "pickle.loads", + "line": 31, + "path": "wardline_sinks.py", + "resolved_call_count": 1, + "rule_id": "PY-WL-101", + "sink": null, + "sink_qualname": "wardline_sinks.import_catalog_blob", + "source_boundary_qualname": null, + "tier_in": "UNKNOWN_RAW", + "tier_out": "ASSURED", + "unresolved_call_count": 2 + }, + "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190" + }, + "facts": [ + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks._refine_a", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "98f171afa38cd679d03c22f6e6e3666a58ded53adc51db37e91405c6798cdbd0", + "line_start": 60, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + } + ], + "qualname": "wardline_sinks._refine_a", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 0, + "source": "anchored", + "unresolved_call_count": 0 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks._refine_b", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "4c9d679f189c499a382f5765820958e5ae1c0875064c0b0bd800a26559af7a9c", + "line_start": 65, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + } + ], + "qualname": "wardline_sinks._refine_b", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 0, + "source": "anchored", + "unresolved_call_count": 0 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.chained_queries", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "2b283fa70a2a9d37ed414ef8f1140a60571eae6b0c79d373c6bd50764ed914eb", + "line_start": 88, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "1f9174a59ff725857a5574eb57a4f1b31f7212ece7589d1b46721f2fd31edc9d", + "line_start": 95, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-118" + }, + { + "fingerprint": "41d07bddca738298bd5d85c8db731766f44812033f01af7bc7d83941b2073759", + "line_start": 95, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-118" + } + ], + "qualname": "wardline_sinks.chained_queries", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "UNKNOWN_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 4 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.double_deserialize", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "411c3b7a79ca5aa3df4d8abfcacfcc987ffc12a3797c8c70340ccd90c88eef24", + "line_start": 79, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "3d8057c1001d1a882d3c85dbe04379d2a25bc6a4b5fca842497b19fddc4609fa", + "line_start": 84, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-106" + }, + { + "fingerprint": "8b74d4819f60cc2f30acecedbf63b69dd512f00178f19e3e43ee4d4b6026444f", + "line_start": 84, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-106" + } + ], + "qualname": "wardline_sinks.double_deserialize", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "UNKNOWN_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 3 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.fan_out_stored", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "5b727e8b47a7e7216468308658740001d5062d4292ce012f03a50ef96e523cbb", + "line_start": 75, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-105" + }, + { + "fingerprint": "a94cef80695a44e753a0f5dc71d632fb3d329a10da3acc62dc2f4151d8856179", + "line_start": 75, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-105" + }, + { + "fingerprint": "272b6408714850250d4890e81911e3db808b0aa8f894db916d5a0f73a4670215", + "line_start": 75, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-120" + }, + { + "fingerprint": "8d45425ca46245baa235e857f42513c1e77cd3e7b2bb0b19fc65ab3687f25e8a", + "line_start": 75, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-120" + } + ], + "qualname": "wardline_sinks.fan_out_stored", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "ASSURED", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 2, + "source": "anchored", + "unresolved_call_count": 1 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.import_catalog_blob", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190", + "line_start": 31, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "5ee6a352fd2713e30f8d3a8d5d2f374dfe486b5dc2a0299e77975bf4c9ea14bc", + "line_start": 34, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-106" + } + ], + "qualname": "wardline_sinks.import_catalog_blob", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "UNKNOWN_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 2 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.load_report_plugin", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "9840134f9529e2230a0f32f55afd1b7c47cdf9fc0fcb1a46348b9bd6059e8963", + "line_start": 45, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "0dcfc65cd0c5381cbd62bc4646ea5b34f358be072431e3877213362e587cf8e9", + "line_start": 48, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-115" + } + ], + "qualname": "wardline_sinks.load_report_plugin", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "UNKNOWN_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 1 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.lookup_member", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "954c291d52c66289019d668b49fd63a3fca2f43219278855b892174bb0a97582", + "line_start": 111, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "5d3200b5f9d4418398cda8f3f830f716794dae3c1e0a3ec97af0f526735dbee9", + "line_start": 118, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-118" + }, + { + "fingerprint": "b85f44e814c9271c6cd3129c2146bdf9b50e87c312cf862b9aa62c5b3f5c5768", + "line_start": 119, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-120" + } + ], + "qualname": "wardline_sinks.lookup_member", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 4 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.open_catalog_file", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "5426cf7f120d932b6d6f5e6894068278aed09df543f91602d568a850b121a972", + "line_start": 52, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "9284a27b61ef23301fa372ac36c6385ec711fd26ed22a455eeea993981d5002e", + "line_start": 55, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-116" + }, + { + "fingerprint": "1840d80d73b71ae6d77a5399229adb8b644508a7e8d4f400236a8a698a60a364", + "line_start": 56, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-120" + } + ], + "qualname": "wardline_sinks.open_catalog_file", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 2 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.read_admin_arg", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [], + "qualname": "wardline_sinks.read_admin_arg", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "EXTERNAL_RAW", + "resolved_call_count": 0, + "source": "anchored", + "unresolved_call_count": 0 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.run_export", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": true, + "reason": "Wardline trust-decorated entity is externally reachable or trust-significant.", + "source": "wardline_trust_decorator", + "tags": [ + "entry-point" + ] + }, + "findings": [ + { + "fingerprint": "235565113b5cbb65f508f16155d4ac0bba3624076b23d08fb7357bdc1738a847", + "line_start": 38, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-101" + }, + { + "fingerprint": "13c25150c1e15425e6ae5f9d99f67ec09dd5ffb4b60ac49e20367a0ad6240c6f", + "line_start": 41, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-112" + } + ], + "qualname": "wardline_sinks.run_export", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "EXTERNAL_RAW", + "contributing_callee_qualname": null, + "declared_return": "ASSURED", + "resolved_call_count": 1, + "source": "anchored", + "unresolved_call_count": 1 + } + } + }, + { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "qualname": "wardline_sinks.stacked_invalid_levels", + "wardline_json": { + "content_hash_at_compute": "6a065b812f9ebc7da0a1c2fbf9814226efc5180e3709ec7e134bce959a8069c1", + "dead_code_root": { + "is_root": false, + "reason": null, + "source": null, + "tags": [] + }, + "findings": [ + { + "fingerprint": "8e926ff1d64c4dd07a5704a50083ab6ba57d74012d4443443236220a4b772dee", + "line_start": 100, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-114" + }, + { + "fingerprint": "a17fb1a0b60e2f629adb50927db359f9dc711d499445bd13d7ad75ef9a9253f9", + "line_start": 100, + "path": "wardline_sinks.py", + "rule_id": "PY-WL-114" + } + ], + "qualname": "wardline_sinks.stacked_invalid_levels", + "schema_version": "wardline-taint-1", + "taint": { + "actual_return": "UNKNOWN_RAW", + "contributing_callee_qualname": null, + "declared_return": "UNKNOWN_RAW", + "resolved_call_count": 0, + "source": "fallback", + "unresolved_call_count": 0 + } + } + } + ], + "findings": [ + { + "confidence": null, + "fingerprint": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190", + "kind": "defect", + "location": { + "col_end": 29, + "col_start": 0, + "line_end": 34, + "line_start": 31, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.import_catalog_blob declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.import_catalog_blob", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "5ee6a352fd2713e30f8d3a8d5d2f374dfe486b5dc2a0299e77975bf4c9ea14bc", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 34, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.import_catalog_blob: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 34", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "pickle.loads", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.import_catalog_blob", + "related_entities": [], + "rule_id": "PY-WL-106", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "235565113b5cbb65f508f16155d4ac0bba3624076b23d08fb7357bdc1738a847", + "kind": "defect", + "location": { + "col_end": 55, + "col_start": 0, + "line_end": 41, + "line_start": 38, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.run_export declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.run_export", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "13c25150c1e15425e6ae5f9d99f67ec09dd5ffb4b60ac49e20367a0ad6240c6f", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 41, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.run_export: EXTERNAL_RAW (untrusted) data reaches the shell=True subprocess sink subprocess.run() at line 41", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "subprocess.run", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.run_export", + "related_entities": [], + "rule_id": "PY-WL-112", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "9840134f9529e2230a0f32f55afd1b7c47cdf9fc0fcb1a46348b9bd6059e8963", + "kind": "defect", + "location": { + "col_end": 40, + "col_start": 0, + "line_end": 48, + "line_start": 45, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.load_report_plugin declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.load_report_plugin", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "0dcfc65cd0c5381cbd62bc4646ea5b34f358be072431e3877213362e587cf8e9", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 48, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.load_report_plugin: EXTERNAL_RAW (untrusted) data reaches the dynamic import sink importlib.import_module() at line 48", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "importlib.import_module", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.load_report_plugin", + "related_entities": [], + "rule_id": "PY-WL-115", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "5426cf7f120d932b6d6f5e6894068278aed09df543f91602d568a850b121a972", + "kind": "defect", + "location": { + "col_end": 24, + "col_start": 0, + "line_end": 56, + "line_start": 52, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.open_catalog_file declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.open_catalog_file", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "9284a27b61ef23301fa372ac36c6385ec711fd26ed22a455eeea993981d5002e", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 55, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.open_catalog_file: EXTERNAL_RAW (untrusted) data reaches the path-traversal sink open() at line 55", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "open", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.open_catalog_file", + "related_entities": [], + "rule_id": "PY-WL-116", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "1840d80d73b71ae6d77a5399229adb8b644508a7e8d4f400236a8a698a60a364", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 56, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.open_catalog_file returns stored/persisted data (EXTERNAL_RAW) without validation at line 56", + "properties": { + "return_taint": "EXTERNAL_RAW" + }, + "qualname": "wardline_sinks.open_catalog_file", + "related_entities": [], + "rule_id": "PY-WL-120", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "98f171afa38cd679d03c22f6e6e3666a58ded53adc51db37e91405c6798cdbd0", + "kind": "defect", + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 61, + "line_start": 60, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks._refine_a declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks._refine_a", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "4c9d679f189c499a382f5765820958e5ae1c0875064c0b0bd800a26559af7a9c", + "kind": "defect", + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 66, + "line_start": 65, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks._refine_b declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks._refine_b", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "5b727e8b47a7e7216468308658740001d5062d4292ce012f03a50ef96e523cbb", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 75, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_b() at line 75", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_b", + "callee_body": "ASSURED" + }, + "qualname": "wardline_sinks.fan_out_stored", + "related_entities": [], + "rule_id": "PY-WL-105", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "a94cef80695a44e753a0f5dc71d632fb3d329a10da3acc62dc2f4151d8856179", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 75, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_a() at line 75", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_a", + "callee_body": "ASSURED" + }, + "qualname": "wardline_sinks.fan_out_stored", + "related_entities": [], + "rule_id": "PY-WL-105", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "272b6408714850250d4890e81911e3db808b0aa8f894db916d5a0f73a4670215", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 75, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_b without validation at line 75", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_b" + }, + "qualname": "wardline_sinks.fan_out_stored", + "related_entities": [], + "rule_id": "PY-WL-120", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "8d45425ca46245baa235e857f42513c1e77cd3e7b2bb0b19fc65ab3687f25e8a", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 75, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_a without validation at line 75", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_a" + }, + "qualname": "wardline_sinks.fan_out_stored", + "related_entities": [], + "rule_id": "PY-WL-120", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "411c3b7a79ca5aa3df4d8abfcacfcc987ffc12a3797c8c70340ccd90c88eef24", + "kind": "defect", + "location": { + "col_end": 49, + "col_start": 0, + "line_end": 84, + "line_start": 79, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.double_deserialize declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.double_deserialize", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "3d8057c1001d1a882d3c85dbe04379d2a25bc6a4b5fca842497b19fddc4609fa", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 84, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.double_deserialize: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 84", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "pickle.loads", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.double_deserialize", + "related_entities": [], + "rule_id": "PY-WL-106", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "8b74d4819f60cc2f30acecedbf63b69dd512f00178f19e3e43ee4d4b6026444f", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 84, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.double_deserialize: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 84", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "pickle.loads", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.double_deserialize", + "related_entities": [], + "rule_id": "PY-WL-106", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "2b283fa70a2a9d37ed414ef8f1140a60571eae6b0c79d373c6bd50764ed914eb", + "kind": "defect", + "location": { + "col_end": 76, + "col_start": 0, + "line_end": 95, + "line_start": 88, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.chained_queries declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.chained_queries", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "1f9174a59ff725857a5574eb57a4f1b31f7212ece7589d1b46721f2fd31edc9d", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 95, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.chained_queries: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 95", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "execute", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.chained_queries", + "related_entities": [], + "rule_id": "PY-WL-118", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "41d07bddca738298bd5d85c8db731766f44812033f01af7bc7d83941b2073759", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 95, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.chained_queries: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 95", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "execute", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.chained_queries", + "related_entities": [], + "rule_id": "PY-WL-118", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "8e926ff1d64c4dd07a5704a50083ab6ba57d74012d4443443236220a4b772dee", + "kind": "defect", + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 107, + "line_start": 100, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.stacked_invalid_levels has an invalid or out-of-range trust level 'BOGUS' on decorator @trusted", + "properties": { + "decorator": "trusted", + "token": "BOGUS" + }, + "qualname": "wardline_sinks.stacked_invalid_levels", + "related_entities": [], + "rule_id": "PY-WL-114", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "a17fb1a0b60e2f629adb50927db359f9dc711d499445bd13d7ad75ef9a9253f9", + "kind": "defect", + "location": { + "col_end": 12, + "col_start": 0, + "line_end": 107, + "line_start": 100, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.stacked_invalid_levels has an invalid or out-of-range trust level 'BOGUS' on decorator @trusted", + "properties": { + "decorator": "trusted", + "token": "BOGUS" + }, + "qualname": "wardline_sinks.stacked_invalid_levels", + "related_entities": [], + "rule_id": "PY-WL-114", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "954c291d52c66289019d668b49fd63a3fca2f43219278855b892174bb0a97582", + "kind": "defect", + "location": { + "col_end": 25, + "col_start": 0, + "line_end": 119, + "line_start": 111, + "path": "wardline_sinks.py" + }, + "maturity": "stable", + "message": "wardline_sinks.lookup_member declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer", + "properties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + }, + "qualname": "wardline_sinks.lookup_member", + "related_entities": [], + "rule_id": "PY-WL-101", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "5d3200b5f9d4418398cda8f3f830f716794dae3c1e0a3ec97af0f526735dbee9", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 118, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.lookup_member: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 118", + "properties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "execute", + "tier": "ASSURED" + }, + "qualname": "wardline_sinks.lookup_member", + "related_entities": [], + "rule_id": "PY-WL-118", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "b85f44e814c9271c6cd3129c2146bdf9b50e87c312cf862b9aa62c5b3f5c5768", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": null, + "line_start": 119, + "path": "wardline_sinks.py" + }, + "maturity": "preview", + "message": "wardline_sinks.lookup_member returns stored/persisted data (EXTERNAL_RAW) without validation at line 119", + "properties": { + "return_taint": "EXTERNAL_RAW" + }, + "qualname": "wardline_sinks.lookup_member", + "related_entities": [], + "rule_id": "PY-WL-120", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + } + ], + "sarif": { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": [ + { + "results": [ + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.import_catalog_blob declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 29, + "endLine": 34, + "startColumn": 0, + "startLine": 31 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 29, + "endLine": 34, + "startColumn": 0, + "startLine": 31 + } + } + } + ], + "message": { + "text": "wardline_sinks.import_catalog_blob declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "0af8d13dc6f1574542255f5171373e16dadbc0df8df5f1e614a39fc318c41190" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.import_catalog_blob", + "wardlineProperties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.import_catalog_blob: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 34" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 34 + } + } + } + } + ] + } + ] + } + ], + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 34 + } + } + } + ], + "message": { + "text": "wardline_sinks.import_catalog_blob: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 34" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "5ee6a352fd2713e30f8d3a8d5d2f374dfe486b5dc2a0299e77975bf4c9ea14bc" + }, + "properties": { + "internalSeverity": "WARN", + "kind": "defect", + "qualname": "wardline_sinks.import_catalog_blob", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "pickle.loads", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-106" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.run_export declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 55, + "endLine": 41, + "startColumn": 0, + "startLine": 38 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 55, + "endLine": 41, + "startColumn": 0, + "startLine": 38 + } + } + } + ], + "message": { + "text": "wardline_sinks.run_export declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "235565113b5cbb65f508f16155d4ac0bba3624076b23d08fb7357bdc1738a847" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.run_export", + "wardlineProperties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "Taint source: wardline_sinks.read_admin_arg" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 34, + "endLine": 27, + "startColumn": 0, + "startLine": 25 + } + } + } + }, + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.run_export: EXTERNAL_RAW (untrusted) data reaches the shell=True subprocess sink subprocess.run() at line 41" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 41 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 41 + } + } + } + ], + "message": { + "text": "wardline_sinks.run_export: EXTERNAL_RAW (untrusted) data reaches the shell=True subprocess sink subprocess.run() at line 41" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "13c25150c1e15425e6ae5f9d99f67ec09dd5ffb4b60ac49e20367a0ad6240c6f" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.run_export", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "subprocess.run", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-112" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.load_report_plugin declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 40, + "endLine": 48, + "startColumn": 0, + "startLine": 45 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 40, + "endLine": 48, + "startColumn": 0, + "startLine": 45 + } + } + } + ], + "message": { + "text": "wardline_sinks.load_report_plugin declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "9840134f9529e2230a0f32f55afd1b7c47cdf9fc0fcb1a46348b9bd6059e8963" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.load_report_plugin", + "wardlineProperties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "Taint source: wardline_sinks.read_admin_arg" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 34, + "endLine": 27, + "startColumn": 0, + "startLine": 25 + } + } + } + }, + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.load_report_plugin: EXTERNAL_RAW (untrusted) data reaches the dynamic import sink importlib.import_module() at line 48" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 48 + } + } + } + } + ] + } + ] + } + ], + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 48 + } + } + } + ], + "message": { + "text": "wardline_sinks.load_report_plugin: EXTERNAL_RAW (untrusted) data reaches the dynamic import sink importlib.import_module() at line 48" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "0dcfc65cd0c5381cbd62bc4646ea5b34f358be072431e3877213362e587cf8e9" + }, + "properties": { + "internalSeverity": "WARN", + "kind": "defect", + "qualname": "wardline_sinks.load_report_plugin", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "importlib.import_module", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-115" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.open_catalog_file declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 24, + "endLine": 56, + "startColumn": 0, + "startLine": 52 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 24, + "endLine": 56, + "startColumn": 0, + "startLine": 52 + } + } + } + ], + "message": { + "text": "wardline_sinks.open_catalog_file declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "5426cf7f120d932b6d6f5e6894068278aed09df543f91602d568a850b121a972" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.open_catalog_file", + "wardlineProperties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "Taint source: wardline_sinks.read_admin_arg" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 34, + "endLine": 27, + "startColumn": 0, + "startLine": 25 + } + } + } + }, + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.open_catalog_file: EXTERNAL_RAW (untrusted) data reaches the path-traversal sink open() at line 55" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 55 + } + } + } + } + ] + } + ] + } + ], + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 55 + } + } + } + ], + "message": { + "text": "wardline_sinks.open_catalog_file: EXTERNAL_RAW (untrusted) data reaches the path-traversal sink open() at line 55" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "9284a27b61ef23301fa372ac36c6385ec711fd26ed22a455eeea993981d5002e" + }, + "properties": { + "internalSeverity": "WARN", + "kind": "defect", + "qualname": "wardline_sinks.open_catalog_file", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "open", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-116" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.open_catalog_file returns stored/persisted data (EXTERNAL_RAW) without validation at line 56" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 56 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 56 + } + } + } + ], + "message": { + "text": "wardline_sinks.open_catalog_file returns stored/persisted data (EXTERNAL_RAW) without validation at line 56" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "1840d80d73b71ae6d77a5399229adb8b644508a7e8d4f400236a8a698a60a364" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.open_catalog_file", + "wardlineProperties": { + "return_taint": "EXTERNAL_RAW" + } + }, + "ruleId": "PY-WL-120" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 12, + "endLine": 61, + "startColumn": 0, + "startLine": 60 + } + } + } + ], + "message": { + "text": "wardline_sinks._refine_a declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "98f171afa38cd679d03c22f6e6e3666a58ded53adc51db37e91405c6798cdbd0" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks._refine_a", + "wardlineProperties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 12, + "endLine": 66, + "startColumn": 0, + "startLine": 65 + } + } + } + ], + "message": { + "text": "wardline_sinks._refine_b declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "4c9d679f189c499a382f5765820958e5ae1c0875064c0b0bd800a26559af7a9c" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks._refine_b", + "wardlineProperties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_b() at line 75" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + ], + "message": { + "text": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_b() at line 75" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "5b727e8b47a7e7216468308658740001d5062d4292ce012f03a50ef96e523cbb" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.fan_out_stored", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_b", + "callee_body": "ASSURED" + } + }, + "ruleId": "PY-WL-105" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_a() at line 75" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + ], + "message": { + "text": "wardline_sinks.fan_out_stored: EXTERNAL_RAW (untrusted) data passed to trusted producer wardline_sinks._refine_a() at line 75" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "a94cef80695a44e753a0f5dc71d632fb3d329a10da3acc62dc2f4151d8856179" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.fan_out_stored", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_a", + "callee_body": "ASSURED" + } + }, + "ruleId": "PY-WL-105" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_b without validation at line 75" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + ], + "message": { + "text": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_b without validation at line 75" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "272b6408714850250d4890e81911e3db808b0aa8f894db916d5a0f73a4670215" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.fan_out_stored", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_b" + } + }, + "ruleId": "PY-WL-120" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_a without validation at line 75" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 75 + } + } + } + ], + "message": { + "text": "wardline_sinks.fan_out_stored passes stored/persisted data (EXTERNAL_RAW) to trusted callee wardline_sinks._refine_a without validation at line 75" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "8d45425ca46245baa235e857f42513c1e77cd3e7b2bb0b19fc65ab3687f25e8a" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.fan_out_stored", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "callee": "wardline_sinks._refine_a" + } + }, + "ruleId": "PY-WL-120" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 49, + "endLine": 84, + "startColumn": 0, + "startLine": 79 + } + } + } + ], + "message": { + "text": "wardline_sinks.double_deserialize declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "411c3b7a79ca5aa3df4d8abfcacfcc987ffc12a3797c8c70340ccd90c88eef24" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.double_deserialize", + "wardlineProperties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 84 + } + } + } + ], + "message": { + "text": "wardline_sinks.double_deserialize: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 84" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "3d8057c1001d1a882d3c85dbe04379d2a25bc6a4b5fca842497b19fddc4609fa" + }, + "properties": { + "internalSeverity": "WARN", + "kind": "defect", + "qualname": "wardline_sinks.double_deserialize", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "pickle.loads", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-106" + }, + { + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 84 + } + } + } + ], + "message": { + "text": "wardline_sinks.double_deserialize: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 84" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "8b74d4819f60cc2f30acecedbf63b69dd512f00178f19e3e43ee4d4b6026444f" + }, + "properties": { + "internalSeverity": "WARN", + "kind": "defect", + "qualname": "wardline_sinks.double_deserialize", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "pickle.loads", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-106" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 76, + "endLine": 95, + "startColumn": 0, + "startLine": 88 + } + } + } + ], + "message": { + "text": "wardline_sinks.chained_queries declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "2b283fa70a2a9d37ed414ef8f1140a60571eae6b0c79d373c6bd50764ed914eb" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.chained_queries", + "wardlineProperties": { + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 95 + } + } + } + ], + "message": { + "text": "wardline_sinks.chained_queries: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 95" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "1f9174a59ff725857a5574eb57a4f1b31f7212ece7589d1b46721f2fd31edc9d" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.chained_queries", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "execute", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-118" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 95 + } + } + } + ], + "message": { + "text": "wardline_sinks.chained_queries: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 95" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "41d07bddca738298bd5d85c8db731766f44812033f01af7bc7d83941b2073759" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.chained_queries", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "execute", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-118" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 12, + "endLine": 107, + "startColumn": 0, + "startLine": 100 + } + } + } + ], + "message": { + "text": "wardline_sinks.stacked_invalid_levels has an invalid or out-of-range trust level 'BOGUS' on decorator @trusted" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "8e926ff1d64c4dd07a5704a50083ab6ba57d74012d4443443236220a4b772dee" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.stacked_invalid_levels", + "wardlineProperties": { + "decorator": "trusted", + "token": "BOGUS" + } + }, + "ruleId": "PY-WL-114" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 12, + "endLine": 107, + "startColumn": 0, + "startLine": 100 + } + } + } + ], + "message": { + "text": "wardline_sinks.stacked_invalid_levels has an invalid or out-of-range trust level 'BOGUS' on decorator @trusted" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "a17fb1a0b60e2f629adb50927db359f9dc711d499445bd13d7ad75ef9a9253f9" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.stacked_invalid_levels", + "wardlineProperties": { + "decorator": "trusted", + "token": "BOGUS" + } + }, + "ruleId": "PY-WL-114" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.lookup_member declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 25, + "endLine": 119, + "startColumn": 0, + "startLine": 111 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "endColumn": 25, + "endLine": 119, + "startColumn": 0, + "startLine": 111 + } + } + } + ], + "message": { + "text": "wardline_sinks.lookup_member declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "954c291d52c66289019d668b49fd63a3fca2f43219278855b892174bb0a97582" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.lookup_member", + "wardlineProperties": { + "actual_return": "EXTERNAL_RAW", + "declared_return": "ASSURED" + } + }, + "ruleId": "PY-WL-101" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.lookup_member: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 118" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 118 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 118 + } + } + } + ], + "message": { + "text": "wardline_sinks.lookup_member: EXTERNAL_RAW (untrusted) data reaches the SQL-injection sink execute() at line 118" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "5d3200b5f9d4418398cda8f3f830f716794dae3c1e0a3ec97af0f526735dbee9" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.lookup_member", + "wardlineProperties": { + "arg_taint": "EXTERNAL_RAW", + "sink": "execute", + "tier": "ASSURED" + } + }, + "ruleId": "PY-WL-118" + }, + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "important", + "location": { + "message": { + "text": "wardline_sinks.lookup_member returns stored/persisted data (EXTERNAL_RAW) without validation at line 119" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 119 + } + } + } + } + ] + } + ] + } + ], + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "wardline_sinks.py" + }, + "region": { + "startLine": 119 + } + } + } + ], + "message": { + "text": "wardline_sinks.lookup_member returns stored/persisted data (EXTERNAL_RAW) without validation at line 119" + }, + "partialFingerprints": { + "wardlineFingerprint/v2": "b85f44e814c9271c6cd3129c2146bdf9b50e87c312cf862b9aa62c5b3f5c5768" + }, + "properties": { + "internalSeverity": "ERROR", + "kind": "defect", + "qualname": "wardline_sinks.lookup_member", + "wardlineProperties": { + "return_taint": "EXTERNAL_RAW" + } + }, + "ruleId": "PY-WL-120" + } + ], + "tool": { + "driver": { + "informationUri": "https://github.com/foundryside/wardline", + "name": "wardline", + "rules": [ + { + "id": "PY-WL-101" + }, + { + "id": "PY-WL-105" + }, + { + "id": "PY-WL-106" + }, + { + "id": "PY-WL-112" + }, + { + "id": "PY-WL-114" + }, + { + "id": "PY-WL-115" + }, + { + "id": "PY-WL-116" + }, + { + "id": "PY-WL-118" + }, + { + "id": "PY-WL-120" + } + ], + "version": "" + } + } + } + ], + "version": "2.1.0" + } +} diff --git a/tests/golden/identity/corpus/stress.json b/tests/golden/identity/corpus/stress.json index 87e58b5d..9c467e9b 100644 --- a/tests/golden/identity/corpus/stress.json +++ b/tests/golden/identity/corpus/stress.json @@ -133,19 +133,20 @@ ], "explain": { "explanation": { - "fingerprint": "991cb740809062defefa229ba05a79d04570d46399c846be38224d4cb0f51f9b", + "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4", "immediate_tainted_callee": null, "line": 43, "path": "identity_stress.py", "resolved_call_count": 0, "rule_id": "PY-WL-111", + "sink": null, "sink_qualname": "identity_stress.assert_only_boundary", "source_boundary_qualname": null, "tier_in": null, "tier_out": null, "unresolved_call_count": 0 }, - "fingerprint": "991cb740809062defefa229ba05a79d04570d46399c846be38224d4cb0f51f9b" + "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4" }, "facts": [ { @@ -211,7 +212,7 @@ }, "findings": [ { - "fingerprint": "991cb740809062defefa229ba05a79d04570d46399c846be38224d4cb0f51f9b", + "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4", "line_start": 43, "path": "identity_stress.py", "rule_id": "PY-WL-111" @@ -342,7 +343,7 @@ "qualname": "identity_stress.outer", "schema_version": "wardline-taint-1", "taint": { - "actual_return": "UNKNOWN_RAW", + "actual_return": "INTEGRAL", "contributing_callee_qualname": null, "declared_return": "UNKNOWN_RAW", "resolved_call_count": 0, @@ -466,7 +467,7 @@ }, "findings": [ { - "fingerprint": "3db67626b9bcad79c89a95e905a7691cd87c31f8cfe6dd2e0f992edab5819a69", + "fingerprint": "7b49218a9f624f5d345f4947ce59746393d3d71bf98588abe823af6d789a945a", "line_start": 56, "path": "identity_stress.py", "rule_id": "PY-WL-101" @@ -488,7 +489,7 @@ "findings": [ { "confidence": null, - "fingerprint": "991cb740809062defefa229ba05a79d04570d46399c846be38224d4cb0f51f9b", + "fingerprint": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4", "kind": "defect", "location": { "col_end": 16, @@ -508,12 +509,12 @@ "rule_id": "PY-WL-111", "severity": "ERROR", "suggestion": null, - "suppressed": "active", - "suppression_reason": null + "suppression_reason": null, + "suppression_state": "active" }, { "confidence": null, - "fingerprint": "3db67626b9bcad79c89a95e905a7691cd87c31f8cfe6dd2e0f992edab5819a69", + "fingerprint": "7b49218a9f624f5d345f4947ce59746393d3d71bf98588abe823af6d789a945a", "kind": "defect", "location": { "col_end": 22, @@ -533,8 +534,8 @@ "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, - "suppressed": "active", - "suppression_reason": null + "suppression_reason": null, + "suppression_state": "active" } ], "sarif": { @@ -563,7 +564,7 @@ "text": "identity_stress.assert_only_boundary declares a trust boundary (EXTERNAL_RAW -> ASSURED) but its only rejection path is assert — stripped under python -O, so the validation vanishes in production" }, "partialFingerprints": { - "wardlineFingerprint/v1": "991cb740809062defefa229ba05a79d04570d46399c846be38224d4cb0f51f9b" + "wardlineFingerprint/v2": "9c3d7b71f8e1e06b7f22d24db966e05be8b4384d8d538a61e7221231d7d9c6d4" }, "properties": { "internalSeverity": "ERROR", @@ -645,7 +646,7 @@ "text": "identity_stress.untrusted_reaches_trusted declares return trust ASSURED but actually returns EXTERNAL_RAW (less trusted) — untrusted data reaches a trusted producer" }, "partialFingerprints": { - "wardlineFingerprint/v1": "3db67626b9bcad79c89a95e905a7691cd87c31f8cfe6dd2e0f992edab5819a69" + "wardlineFingerprint/v2": "7b49218a9f624f5d345f4947ce59746393d3d71bf98588abe823af6d789a945a" }, "properties": { "internalSeverity": "ERROR", diff --git a/tests/golden/identity/fixtures/sinks/wardline_sinks.py b/tests/golden/identity/fixtures/sinks/wardline_sinks.py new file mode 100644 index 00000000..8bfb93f0 --- /dev/null +++ b/tests/golden/identity/fixtures/sinks/wardline_sinks.py @@ -0,0 +1,119 @@ +"""Sink-family identity fixture for the parity oracle (weft-4a9d0f863c). + +Mirrors the shape that exposed the fingerprint-instability bug: a ``@trusted`` +"admin/export" surface where an operator-supplied string reaches a spread of +dangerous sinks. It exercises the call-site-anchored rules (PY-WL-106/108/116/ +117/118 + PY-WL-101 + PY-WL-120) whose fingerprints fold a per-call discriminator, +so the corpus freezes a *resolution-invariant* join key across many rules — not +just the two boundary rules the original two fixtures covered. + +Permanent demonstration fixtures — do NOT "fix" the planted flaws. +""" + +from __future__ import annotations + +import importlib +import pickle +import sqlite3 +import subprocess +from collections.abc import Sequence + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def read_admin_arg(argv: Sequence[str]) -> str: + """An operator-supplied string crossing the admin boundary (untrusted).""" + return argv[0] if argv else "" + + +@trusted(level="ASSURED") +def import_catalog_blob(argv: Sequence[str]) -> object: + """Untrusted bytes reach a deserialization sink (PY-WL-106).""" + blob = read_admin_arg(argv).encode() + return pickle.loads(blob) # noqa: S301 + + +@trusted(level="ASSURED") +def run_export(argv: Sequence[str]) -> object: + """Untrusted text reaches a shell/command sink (PY-WL-108).""" + cmd = read_admin_arg(argv) + return subprocess.run(cmd, shell=True, check=False) # noqa: S602 + + +@trusted(level="ASSURED") +def load_report_plugin(argv: Sequence[str]) -> object: + """Untrusted text reaches a dynamic-import sink (PY-WL-115).""" + name = read_admin_arg(argv) + return importlib.import_module(name) + + +@trusted(level="ASSURED") +def open_catalog_file(argv: Sequence[str]) -> str: + """Untrusted text reaches a filesystem-path sink (PY-WL-116).""" + path = read_admin_arg(argv) + with open(path, encoding="utf-8") as fh: # noqa: PTH123 + return fh.read() + + +@trusted(level="ASSURED") +def _refine_a(x: object) -> object: + return x + + +@trusted(level="ASSURED") +def _refine_b(x: object) -> object: + return x + + +@trusted(level="ASSURED") +def fan_out_stored(argv: Sequence[str], db: object) -> tuple[object, object]: + """Two trusted-callee call sites on ONE physical line — exercises the per-call + discriminator for PY-WL-105 (untrusted->trusted-callee) and PY-WL-120 (stored + arg) so the collision gate is non-vacuous for those rules, not only PY-WL-106/118.""" + stored = db.fetchone() # type: ignore[attr-defined] + return _refine_a(stored), _refine_b(stored) + + +@trusted(level="ASSURED") +def double_deserialize(argv: Sequence[str]) -> tuple[object, object]: + """Two deserialization sinks (same rule, same name) on ONE physical line — + distinct call nodes MUST get distinct fingerprints via the per-call column + discriminator, so neither finding is silently dropped on the join key.""" + blob = read_admin_arg(argv).encode() + return pickle.loads(blob), pickle.loads(blob) # noqa: S301 + + +@trusted(level="ASSURED") +def chained_queries(argv: Sequence[str]) -> object: + """Two SQL sinks CHAINED on ONE physical line — both ``execute`` calls share a + start column under CPython's whole-span anchor, so a start-column-only + discriminator would collapse them. The full-span discriminator must keep the + two distinct findings on distinct fingerprints (weft-4a9d0f863c collision gate).""" + name = read_admin_arg(argv) + conn = sqlite3.connect(":memory:") + return conn.cursor().execute(f"SELECT {name}").execute(f"DELETE {name}") # noqa: S608 + + +@trusted(level="BOGUS") +@trusted(level="BOGUS") +def stacked_invalid_levels(p: object) -> object: + """Two stacked identical invalid trust decorators on ONE def — PY-WL-114 emits one + finding per decorator, anchored at the ENTITY line (not the decorator). The two findings + share name, token, AND entity line, so the discriminator must carry each decorator's + POSITION in the decorator_list (its ordinal) — a move-stable within-def index that keeps + the two join keys distinct without using absolute lines or columns (wardline-377b896a87 + collision gate). Isolated trivial body — no sinks — so it perturbs no other finding.""" + return p + + +@trusted(level="ASSURED") +def lookup_member(argv: Sequence[str]) -> list[object]: + """Untrusted text reaches a SQL execution sink (PY-WL-118); the returned + cursor read is also stored/persisted data leaving a trusted producer + (PY-WL-101 / PY-WL-120) — the exact shape whose resolved return tier drifted.""" + name = read_admin_arg(argv) + conn = sqlite3.connect(":memory:") + cur = conn.cursor() + cur.execute(f"SELECT * FROM members WHERE name = '{name}'") # noqa: S608 + return cur.fetchall() diff --git a/tests/golden/identity/regen.py b/tests/golden/identity/regen.py index c5d07fd8..01a41a0d 100644 --- a/tests/golden/identity/regen.py +++ b/tests/golden/identity/regen.py @@ -18,13 +18,19 @@ from pathlib import Path from golden.identity import _capture as c # type: ignore[import-not-found] +from wardline.core.finding import FINGERPRINT_SCHEME _HERE = Path(__file__).parent _INPUTS = { "sampleapp": _HERE / "fixtures" / "sampleapp", "stress": _HERE / "fixtures" / "stress", + "sinks": _HERE / "fixtures" / "sinks", } -CORPUS_VERSION = 1 +# 2->3: P1 scheme-infra (format-only — fingerprint VALUES byte-identical). +# 3->4: P3 value-rekey (wardline-8654423823) — line_start dropped from the hash + +# move-stable entity-relative discriminators, so every PY-WL-*/RS-WL-* fingerprint +# VALUE changes and META.fingerprint_scheme advances wlfp1->wlfp2. +CORPUS_VERSION = 4 def main() -> None: @@ -41,7 +47,7 @@ def main() -> None: encoding="utf-8", ) (out / "META.json").write_text( - c.to_json({"corpus_version": CORPUS_VERSION, "reason": args.reason}), + c.to_json({"corpus_version": CORPUS_VERSION, "fingerprint_scheme": FINGERPRINT_SCHEME, "reason": args.reason}), encoding="utf-8", ) print(f"wrote identity corpus ({len(_INPUTS)} inputs + assure + META) to {out}") diff --git a/tests/golden/identity/rust/README.md b/tests/golden/identity/rust/README.md new file mode 100644 index 00000000..f34cf6d0 --- /dev/null +++ b/tests/golden/identity/rust/README.md @@ -0,0 +1,68 @@ +# Rust identity parity oracle (the SP2 completion gate) + +A byte-exact golden corpus of the Rust frontend's externally-observable +**identity** — the freeze on which `RS-WL-*` findings graduated from +provisional (baseline-ineligible) to real, baseline-eligible, crate-prefixed +identity. Any byte drift here is either a real regression or a deliberate, +`--reason`-stamped rekey — exactly the parent oracle's discipline +(`tests/golden/identity/README.md`, ADR +`docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md`). + +## What it covers — a PARTIAL mirror, by necessity + +`RustAnalyzer.last_context` is `None`: the Rust-native `RustAnalysisContext` is +**not** the Python `AnalysisContext`, so the parent oracle's SARIF code-flows, +taint facts, assure posture, and explain surfaces are *not capturable* for Rust. +The Rust identity surface, captured per fixture crate by `_capture.py`: + +- **findings** — the real wire format (`Finding.to_jsonl()`) for the + identity-bearing population (`RS-WL-* ∧ Kind.DEFECT`), produced by the REAL + analyzer path (`run_scan(root, lang="rust")`: discovery → Cargo crate roots → + module routes → per-file pipeline → suppression). +- **entities** — qualname, ADR-049 id-kind (via the `entity_id` mapping, so the + semantic `method` freezes as `function`), parent, and full span of **every** + emitted entity (the full ten-kind producer surface, `module → impl → method` + containment included). +- **edges** — every anchored `imports`/`implements` edge + (`discover_rust_edges` over the same whole-tree parse products). + +Engine diagnostics (`WLN-ENGINE-*`, `WLN-RUST-COVERAGE`, `Kind.METRIC`/`FACT`) +are excluded — same rationale as the parent oracle. + +## Inputs + +- `fixtures/rustapp/` — vendored crate (`Cargo.toml` `name = "rust-app"` → + crate `rust_app`; `src/main.rs` + `src/cmd/mod.rs` + `src/cmd/runner.rs`). + Exercises: RS-WL-108 (tainted program reaching `Command::new`, inside an impl + method), RS-WL-112 (tainted `sh -c` arg), `/// @trusted(level=ASSURED)` + markers, an inherent impl + a trait impl (`implements` edge), cross-file + `use crate::…` imports, a `#[cfg(unix)]`/`#[cfg(windows)]` twin, and the + `const`/`enum`/`trait`/`struct` leaf kinds. + +Fixtures carry **no** `.weft/` or `weft.toml` (a baseline/waiver would +date-poison the corpus via `date.today()`); `.gitattributes` pins them to LF so +tree-sitter byte offsets (frozen in edge spans) stay reproducible across OSes. + +### Reserved-colon constraint (do not "fix" this) + +The fixture contains **no path-typed generic args** (e.g. +`impl From for …`). That rendering is an **un-decided +cross-tool ADR-049 case**: today Wardline renders the `:`-bearing locator +un-gated while Loomweave rejects it at `entity_id` construction and degrades +the whole file — no canonical colon-free form exists yet (see +`docs/integration/2026-06-10-wardline-loomweave-rust-qualname-amendment-requests.md`). +Freezing such a qualname here would unilaterally pre-empt that decision. +`test_fixture_has_no_path_typed_generic_args` guards the constraint; lift it +only after the ADR-049 amendment lands (which will be a versioned rekey). + +## Determinism (verified before freezing) + +The capture was run twice in separate processes (fresh interpreter each) and +byte-compared before the corpus was committed — mirroring the parent oracle's +discipline. All sorts are content-derived (no engine-emission-order artifacts). + +## Regenerating (intentional rekey ONLY) + +```bash +cd tests && PYTHONPATH=. python -m golden.identity.rust.regen --reason "" +``` diff --git a/tests/golden/identity/rust/__init__.py b/tests/golden/identity/rust/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/identity/rust/_capture.py b/tests/golden/identity/rust/_capture.py new file mode 100644 index 00000000..72172a61 --- /dev/null +++ b/tests/golden/identity/rust/_capture.py @@ -0,0 +1,147 @@ +"""Deterministic Rust identity-capture harness (the SP2 completion gate). + +The Rust sibling of ``golden.identity._capture`` — same canonical-JSON discipline +(stable named-array sorts, ``sort_keys``, relative paths only, no timestamps/host +data), reusing its ``to_json`` serializer and finding sort key. + +**A PARTIAL mirror by necessity:** ``RustAnalyzer.last_context`` is ``None`` — +``RustAnalysisContext`` is not the Python ``AnalysisContext`` — so the Python +oracle's SARIF code-flows, taint facts, and explain surfaces are NOT capturable +here. The Rust identity surface is: + +- **findings** — the real wire format (``Finding.to_jsonl()``) for the + identity-bearing population (``RS-WL-* ∧ Kind.DEFECT``), produced by the REAL + analyzer path (``run_scan(root, lang="rust")`` — discovery, crate roots, module + routes, per-file pipeline, suppression — exactly what a scan emits). +- **entities** — qualname, ADR-049 id-kind (via the ``entity_id`` mapping, so the + semantic ``method`` freezes as ``function``), parent, and full span of EVERY + emitted entity across the fixture crate. +- **edges** — every anchored ``imports``/``implements`` edge + (``discover_rust_edges`` over the same whole-tree parse products). + +Engine diagnostics (``WLN-ENGINE-*`` / ``WLN-RUST-COVERAGE`` / ``Kind.METRIC`` / +``Kind.FACT``) are deliberately excluded, mirroring the Python oracle's rationale. + +Imported by both ``regen.py`` (freeze) and ``test_rust_identity_parity.py`` (gate) +via ``from golden.identity.rust import _capture`` (``tests/`` is on ``sys.path``). +""" + +from __future__ import annotations + +import dataclasses +import json +from typing import TYPE_CHECKING, Any + +from golden.identity._capture import _finding_sort_key, to_json # type: ignore[import-not-found] +from wardline.core import config as config_mod +from wardline.core.discovery import discover +from wardline.core.finding import Finding, Kind +from wardline.core.paths import weft_config_path +from wardline.core.run import run_scan +from wardline.rust import qualname as q +from wardline.rust.analyzer import _build_overlays, _module_for +from wardline.rust.crate_roots import discover_crate_roots +from wardline.rust.edges import RustParsedFile, discover_rust_edges, index_rust_file + +if TYPE_CHECKING: + from pathlib import Path + +__all__ = ["capture", "is_identity_bearing", "to_json"] + + +def is_identity_bearing(f: Finding) -> bool: + """The Rust analogue of the Python oracle's predicate: ``RS-WL-* ∧ Kind.DEFECT``. + + A positive allowlist so engine diagnostics (``WLN-ENGINE-*`` FACTs, the + ``WLN-RUST-COVERAGE`` METRIC) can never silently enter the frozen corpus. + """ + return f.rule_id.startswith("RS-WL-") and f.kind is Kind.DEFECT + + +def _capture_findings(result: Any) -> list[dict[str, Any]]: + # The REAL wire format (Finding.to_jsonl), re-parsed for canonical + # re-serialization — identical discipline to the Python oracle. + recs = [json.loads(f.to_jsonl()) for f in result.findings if is_identity_bearing(f)] + return sorted(recs, key=_finding_sort_key) + + +def _parsed_files(root: Path) -> list[RustParsedFile]: + """Parse + index every discovered ``.rs`` file exactly the way the analyzer + does: same ``discover`` sweep (suffix ``.rs``, ``target/`` skipped), same + crate-root pass, same ``_module_for`` route, relative ``path`` labels only.""" + resolved_root = root.resolve() + cfg = config_mod.load(weft_config_path(resolved_root), explicit=False) + files = discover(resolved_root, cfg, confine_to_root=True, suffixes=frozenset({".rs"})) + crate_roots = discover_crate_roots(resolved_root) + sources = {file: file.read_text(encoding="utf-8") for file in files} + # Same Amendment-8 pre-pass as the analyzer: per-crate #[path] mount overlays. + overlays = _build_overlays(sources, resolved_root, crate_roots) + parsed: list[RustParsedFile] = [] + for file in files: + module = _module_for(file, resolved_root, crate_roots, overlays) + relpath = file.resolve().relative_to(resolved_root).as_posix() + parsed.append(index_rust_file(source=sources[file], module=module, path=relpath)) + return parsed + + +def _capture_entities(parsed: list[RustParsedFile]) -> list[dict[str, Any]]: + # EVERY emitted entity: qualname, id-kind (entity_id maps method -> function), + # parent, full span. (path, qualname, kind) is a total key — within one file a + # (kind, qualname) pair is unique (the per-kind twin counter guarantees it) — + # but keep the canonical-JSON tiebreaker anyway, mirroring the Python oracle. + rows: list[dict[str, Any]] = [] + for f in parsed: + for e in f.entities: + id_kind = q.entity_id(e.kind, e.qualname).split(":", 2)[1] + loc = e.location + rows.append( + { + "qualname": e.qualname, + "kind": id_kind, + "parent": e.parent, + "location": { + "path": loc.path, + "line_start": loc.line_start, + "line_end": loc.line_end, + "col_start": loc.col_start, + "col_end": loc.col_end, + }, + } + ) + return sorted( + rows, + key=lambda r: ( + r["location"]["path"], + r["qualname"], + r["kind"], + json.dumps(r, sort_keys=True, ensure_ascii=False), + ), + ) + + +def _capture_edges(parsed: list[RustParsedFile]) -> list[dict[str, Any]]: + # The full RustEdge field set, totally ordered by its own content (the dataclass + # fields are the whole record, so the tuple key is total). + rows = [dataclasses.asdict(e) for e in discover_rust_edges(parsed)] + return sorted( + rows, + key=lambda r: ( + r["kind"], + r["from_id"], + r["to_id"], + r["source_byte_start"], + r["source_byte_end"], + r["confidence"], + ), + ) + + +def capture(root: Path) -> dict[str, Any]: + """Capture the full Rust identity surface for one fixture crate root.""" + result = run_scan(root, lang="rust") + parsed = _parsed_files(root) + return { + "findings": _capture_findings(result), + "entities": _capture_entities(parsed), + "edges": _capture_edges(parsed), + } diff --git a/tests/golden/identity/rust/conftest.py b/tests/golden/identity/rust/conftest.py new file mode 100644 index 00000000..43e7c9e8 --- /dev/null +++ b/tests/golden/identity/rust/conftest.py @@ -0,0 +1,41 @@ +"""On a Rust parity failure, dump the freshly-captured ``actual`` to /tmp and emit +a unified-diff head (the Rust sibling of ``golden/identity/conftest.py`` — the +parent hook keys on the Python oracle's stash key and ignores these tests).""" + +from __future__ import annotations + +import difflib +from pathlib import Path + +import pytest + +_HERE = Path(__file__).parent + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): # type: ignore[no-untyped-def] + outcome = yield + report = outcome.get_result() + if report.when != "call" or report.passed: + return + from golden.identity.rust.test_rust_identity_parity import _ACTUAL_KEY # type: ignore[import-not-found] + + stashed = item.stash.get(_ACTUAL_KEY, None) + if stashed is None: + return + name, actual = stashed + dump = Path("/tmp") / f"corpus_actual_rust_{name}.json" + dump.write_text(actual, encoding="utf-8") + golden_path = _HERE / "corpus" / f"{name}.json" + golden = golden_path.read_text(encoding="utf-8") if golden_path.exists() else "" + diff = "".join( + difflib.unified_diff( + golden.splitlines(keepends=True), + actual.splitlines(keepends=True), + fromfile=f"corpus/{name}.json (committed)", + tofile=f"/tmp/corpus_actual_rust_{name}.json (now)", + n=2, + ) + ) + head = "\n".join(diff.splitlines()[:60]) + report.sections.append((f"rust identity corpus diff [{name}]", head or "(no line diff; check byte/encoding)")) diff --git a/tests/golden/identity/rust/corpus/META.json b/tests/golden/identity/rust/corpus/META.json new file mode 100644 index 00000000..37ed99b0 --- /dev/null +++ b/tests/golden/identity/rust/corpus/META.json @@ -0,0 +1,5 @@ +{ + "corpus_version": 3, + "fingerprint_scheme": "wlfp2", + "reason": "rekey (rust-sp2-2026-06-10 keystone): entity-relative fingerprint discriminators (wlfp2 move-stability) + #out non-conformance route branding + relpath-pure class-3 crate segment + class-2 fixture pinned" +} diff --git a/tests/golden/identity/rust/corpus/rustapp.json b/tests/golden/identity/rust/corpus/rustapp.json new file mode 100644 index 00000000..774a94ff --- /dev/null +++ b/tests/golden/identity/rust/corpus/rustapp.json @@ -0,0 +1,302 @@ +{ + "edges": [ + { + "confidence": "resolved", + "from_id": "rust:impl:rust_app.cmd.runner.Runner.impl[Describe]", + "kind": "implements", + "source_byte_end": 804, + "source_byte_start": 796, + "to_id": "rust:trait:rust_app.Describe" + }, + { + "confidence": "resolved", + "from_id": "rust:module:rust_app", + "kind": "imports", + "source_byte_end": 203, + "source_byte_start": 172, + "to_id": "rust:struct:rust_app.cmd.runner.Runner" + }, + { + "confidence": "resolved", + "from_id": "rust:module:rust_app.cmd.runner", + "kind": "imports", + "source_byte_end": 393, + "source_byte_start": 373, + "to_id": "rust:trait:rust_app.Describe" + } + ], + "entities": [ + { + "kind": "module", + "location": { + "col_end": 0, + "col_start": 0, + "line_end": 4, + "line_start": 1, + "path": "src/cmd/mod.rs" + }, + "parent": null, + "qualname": "rust_app.cmd" + }, + { + "kind": "module", + "location": { + "col_end": 0, + "col_start": 0, + "line_end": 46, + "line_start": 1, + "path": "src/cmd/runner.rs" + }, + "parent": null, + "qualname": "rust_app.cmd.runner" + }, + { + "kind": "const", + "location": { + "col_end": 41, + "col_start": 0, + "line_end": 12, + "line_start": 12, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.DEFAULT_TIMEOUT_SECS" + }, + { + "kind": "enum", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 17, + "line_start": 14, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.Mode" + }, + { + "kind": "struct", + "location": { + "col_end": 18, + "col_start": 0, + "line_end": 19, + "line_start": 19, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.Runner" + }, + { + "kind": "impl", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 29, + "line_start": 21, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.Runner.impl#<>" + }, + { + "kind": "function", + "location": { + "col_end": 5, + "col_start": 4, + "line_end": 28, + "line_start": 23, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner.Runner.impl#<>", + "qualname": "rust_app.cmd.runner.Runner.impl#<>.launch" + }, + { + "kind": "impl", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 35, + "line_start": 31, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.Runner.impl[Describe]" + }, + { + "kind": "function", + "location": { + "col_end": 5, + "col_start": 4, + "line_end": 34, + "line_start": 32, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner.Runner.impl[Describe]", + "qualname": "rust_app.cmd.runner.Runner.impl[Describe].describe" + }, + { + "kind": "function", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 40, + "line_start": 38, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.shell_name@cfg(unix)" + }, + { + "kind": "function", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 45, + "line_start": 43, + "path": "src/cmd/runner.rs" + }, + "parent": "rust_app.cmd.runner", + "qualname": "rust_app.cmd.runner.shell_name@cfg(windows)" + }, + { + "kind": "module", + "location": { + "col_end": 0, + "col_start": 0, + "line_end": 22, + "line_start": 1, + "path": "src/main.rs" + }, + "parent": null, + "qualname": "rust_app" + }, + { + "kind": "trait", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 12, + "line_start": 10, + "path": "src/main.rs" + }, + "parent": "rust_app", + "qualname": "rust_app.Describe" + }, + { + "kind": "function", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 21, + "line_start": 15, + "path": "src/main.rs" + }, + "parent": "rust_app", + "qualname": "rust_app.main" + }, + { + "kind": "module", + "location": { + "col_end": 0, + "col_start": 0, + "line_end": 14, + "line_start": 1, + "path": "tests/integration.rs" + }, + "parent": null, + "qualname": "rust_app.#out.tests.integration" + }, + { + "kind": "function", + "location": { + "col_end": 1, + "col_start": 0, + "line_end": 13, + "line_start": 9, + "path": "tests/integration.rs" + }, + "parent": "rust_app.#out.tests.integration", + "qualname": "rust_app.#out.tests.integration.shell_smoke" + } + ], + "findings": [ + { + "confidence": null, + "fingerprint": "3b602642e9cdac085dfcaa3fccc454e7a668f58fb1d16254d33e73ce10429f4c", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": 27, + "line_start": 27, + "path": "src/cmd/runner.rs" + }, + "maturity": "stable", + "message": "Untrusted data selects the program executed by Command::new (constructed at line 26, run at line 27): an attacker controls which executable runs (CWE-78).", + "properties": { + "constructor_line": 26, + "taint_path": "EXTERNAL_RAW->Command::new(program)@L26->exec@L27", + "trigger_node_id": 146 + }, + "qualname": "rust_app.cmd.runner.Runner.impl#<>.launch", + "related_entities": [], + "rule_id": "RS-WL-108", + "severity": "ERROR", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "d27ddae0f7699a677570dde97dd36ff2b581730e91acf19faa3f3fe0af3e99ef", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": 18, + "line_start": 18, + "path": "src/main.rs" + }, + "maturity": "stable", + "message": "Untrusted data reaches a shell command line ('sh -c ...', run at line 18): an attacker can inject shell syntax (CWE-78).", + "properties": { + "constructor_line": 18, + "taint_path": "EXTERNAL_RAW->arg->'sh -c'->exec@L18", + "trigger_node_id": 106 + }, + "qualname": "rust_app.main", + "related_entities": [], + "rule_id": "RS-WL-112", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + }, + { + "confidence": null, + "fingerprint": "c0f302edd3c4cb61f0acf2f5e7f447268270e4d2ea04742f6634432f103a44cf", + "kind": "defect", + "location": { + "col_end": null, + "col_start": null, + "line_end": 12, + "line_start": 12, + "path": "tests/integration.rs" + }, + "maturity": "stable", + "message": "Untrusted data reaches a shell command line ('sh -c ...', run at line 12): an attacker can inject shell syntax (CWE-78).", + "properties": { + "constructor_line": 12, + "taint_path": "EXTERNAL_RAW->arg->'sh -c'->exec@L12", + "trigger_node_id": 74 + }, + "qualname": "rust_app.#out.tests.integration.shell_smoke", + "related_entities": [], + "rule_id": "RS-WL-112", + "severity": "WARN", + "suggestion": null, + "suppression_reason": null, + "suppression_state": "active" + } + ] +} diff --git a/tests/golden/identity/rust/fixtures/rustapp/Cargo.toml b/tests/golden/identity/rust/fixtures/rustapp/Cargo.toml new file mode 100644 index 00000000..d7d670b0 --- /dev/null +++ b/tests/golden/identity/rust/fixtures/rustapp/Cargo.toml @@ -0,0 +1,6 @@ +[package] +# The hyphenated package name is deliberate: the SP2 crate-root pass must +# normalise it to the underscored crate `rust_app` (the identity the corpus pins). +name = "rust-app" +version = "0.1.0" +edition = "2021" diff --git a/tests/golden/identity/rust/fixtures/rustapp/src/cmd/mod.rs b/tests/golden/identity/rust/fixtures/rustapp/src/cmd/mod.rs new file mode 100644 index 00000000..21adf791 --- /dev/null +++ b/tests/golden/identity/rust/fixtures/rustapp/src/cmd/mod.rs @@ -0,0 +1,3 @@ +//! `mod.rs` contributes no segment: this file is the `rust_app.cmd` module. + +pub mod runner; diff --git a/tests/golden/identity/rust/fixtures/rustapp/src/cmd/runner.rs b/tests/golden/identity/rust/fixtures/rustapp/src/cmd/runner.rs new file mode 100644 index 00000000..2f7e3fd8 --- /dev/null +++ b/tests/golden/identity/rust/fixtures/rustapp/src/cmd/runner.rs @@ -0,0 +1,45 @@ +//! Tool runner — the cross-file module route (`rust_app.cmd.runner`), the impl +//! surface, a cfg twin, and the leaf kinds the corpus freezes. +//! +//! Deliberately NO path-typed generic args anywhere (e.g. `impl From`): +//! the reserved-colon rendering is an un-decided cross-tool ADR-049 case — see the +//! corpus README. + +use std::process::Command; + +use crate::Describe; + +pub const DEFAULT_TIMEOUT_SECS: u64 = 30; + +pub enum Mode { + Direct, + Shell, +} + +pub struct Runner; + +impl Runner { + /// @trusted(level=ASSURED) + pub fn launch(&self) { + let tool = std::env::var("RUSTAPP_TOOL").unwrap(); + // RS-WL-108: untrusted data selects the program run by Command::new. + let mut command = Command::new(tool); + command.output(); + } +} + +impl Describe for Runner { + fn describe(&self) -> &'static str { + "runner" + } +} + +#[cfg(unix)] +pub fn shell_name() -> &'static str { + "sh" +} + +#[cfg(windows)] +pub fn shell_name() -> &'static str { + "cmd.exe" +} diff --git a/tests/golden/identity/rust/fixtures/rustapp/src/main.rs b/tests/golden/identity/rust/fixtures/rustapp/src/main.rs new file mode 100644 index 00000000..5872f736 --- /dev/null +++ b/tests/golden/identity/rust/fixtures/rustapp/src/main.rs @@ -0,0 +1,21 @@ +//! Vendored identity fixture — the `rust-app` crate root (`main.rs` contributes +//! no module segment: this file IS the `rust_app` module). + +use std::process::Command; + +use crate::cmd::runner::Runner; + +mod cmd; + +pub trait Describe { + fn describe(&self) -> &'static str; +} + +/// @trusted(level=ASSURED) +fn main() { + let tool = std::env::var("RUSTAPP_TOOL").unwrap(); + // RS-WL-112: untrusted data reaches a `sh -c` shell command line. + Command::new("sh").arg("-c").arg(tool).output(); + let runner = Runner; + runner.launch(); +} diff --git a/tests/golden/identity/rust/fixtures/rustapp/tests/integration.rs b/tests/golden/identity/rust/fixtures/rustapp/tests/integration.rs new file mode 100644 index 00000000..e834d793 --- /dev/null +++ b/tests/golden/identity/rust/fixtures/rustapp/tests/integration.rs @@ -0,0 +1,13 @@ +//! Class-2 fixture (under the crate root, OUTSIDE src/): frozen under the +//! reserved `#out` route segment — `rust_app.#out.tests.integration` — so a +//! class-2 FINDING with the non-conformance branding is pinned in the corpus. +//! No path-typed generic args (reserved-colon constraint, see the README). + +use std::process::Command; + +/// @trusted(level=ASSURED) +fn shell_smoke() { + let tool = std::env::var("RUSTAPP_TOOL").unwrap(); + // RS-WL-112: untrusted data reaches a `sh -c` shell command line. + Command::new("sh").arg("-c").arg(tool).output(); +} diff --git a/tests/golden/identity/rust/regen.py b/tests/golden/identity/rust/regen.py new file mode 100644 index 00000000..0a3e445d --- /dev/null +++ b/tests/golden/identity/rust/regen.py @@ -0,0 +1,53 @@ +"""Regenerate the Rust identity golden corpus. + +Run ONLY for an intentional, versioned rekey — NEVER to paper over an accidental +drift (mirrors the Python oracle's discipline; see +``docs/decisions/2026-06-05-wardline-finding-identity-frozen-contract.md``). +Requires ``--reason`` and stamps it into ``corpus/META.json``. + +Run with ``tests/`` on the path so the ``golden.identity.rust`` package resolves: + + cd tests && PYTHONPATH=. python -m golden.identity.rust.regen --reason "" +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from golden.identity.rust import _capture as c # type: ignore[import-not-found] +from wardline.core.finding import FINGERPRINT_SCHEME + +_HERE = Path(__file__).parent +_INPUTS = { + "rustapp": _HERE / "fixtures" / "rustapp", +} +# 1: initial freeze (SP2 completion gate) — crate-prefixed RS-WL-* identity. +# 2: graduation — drop the provisional_identity property (rules.py no longer emits it; +# RS-WL-* baseline-eligible). Fingerprints unchanged (the property was never folded in). +# 3: rekey (rust-sp2-2026-06-10 keystone) — entity-relative fingerprint discriminators +# (wlfp2 move-stability: lines/NodeIds fold as deltas against the containing fn; +# resolved taint tiers dropped from the join key), `#out` non-conformance route +# branding (class 2 = {crate}.#out.{...}), relpath-pure constant-"crate" class-3 +# segment, and the class-2 fixture (tests/integration.rs) pinned. +CORPUS_VERSION = 3 + + +def main() -> None: + parser = argparse.ArgumentParser(description="Regenerate the Rust identity golden corpus.") + parser.add_argument("--reason", required=True, help="Why the corpus is being rekeyed (accountability record).") + args = parser.parse_args() + + out = _HERE / "corpus" + out.mkdir(exist_ok=True) + for name, root in sorted(_INPUTS.items()): + (out / f"{name}.json").write_text(c.to_json(c.capture(root)), encoding="utf-8") + (out / "META.json").write_text( + c.to_json({"corpus_version": CORPUS_VERSION, "fingerprint_scheme": FINGERPRINT_SCHEME, "reason": args.reason}), + encoding="utf-8", + ) + print(f"wrote Rust identity corpus ({len(_INPUTS)} inputs + META) to {out}") + + +if __name__ == "__main__": + main() diff --git a/tests/golden/identity/rust/test_rust_identity_parity.py b/tests/golden/identity/rust/test_rust_identity_parity.py new file mode 100644 index 00000000..a5c29b1b --- /dev/null +++ b/tests/golden/identity/rust/test_rust_identity_parity.py @@ -0,0 +1,168 @@ +"""Parity gate: re-running the Rust frontend over the frozen fixture crate must +reproduce the committed corpus BYTE-for-BYTE (the SP2 completion gate — RS-WL-* +finding identity graduated from provisional to baseline-eligible on this freeze). + +The Rust sibling of ``golden.identity.test_identity_parity`` — same byte-equality +discipline, same regen accountability (``--reason`` stamped into META.json), same +dump-on-failure conftest. The captured surface is the PARTIAL mirror documented in +``_capture.py``/``README.md``: findings + entities + edges (no SARIF/facts/explain — +``RustAnalysisContext`` is not the Python ``AnalysisContext``). + +Import is ``from golden.identity.rust import _capture`` — the repo puts ``tests/`` +on ``sys.path`` (pytest prepend mode). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter", reason="Rust identity capture needs wardline[rust] (tree-sitter)") + +from golden.identity.rust import _capture as c # type: ignore[import-not-found] # noqa: E402 (after importorskip) + +_HERE = Path(__file__).parent +_INPUTS = { + "rustapp": _HERE / "fixtures" / "rustapp", +} +_REGEN_HINT = ( + "Rust identity corpus drift. If this is an INTENTIONAL, versioned rekey, regenerate with:\n" + " cd tests && PYTHONPATH=. python -m golden.identity.rust.regen --reason ''\n" + "Otherwise this is a real regression — see /tmp/corpus_actual_rust_.json for the diff." +) + + +def test_corpus_meta_has_engine_scheme() -> None: + from wardline.core.finding import FINGERPRINT_SCHEME + + meta = json.loads((_HERE / "corpus" / "META.json").read_text(encoding="utf-8")) + assert meta["fingerprint_scheme"] == FINGERPRINT_SCHEME + + +def test_corpus_meta_version_matches_regen() -> None: + # The keystone panel's discipline gap: bumping regen.CORPUS_VERSION without + # actually regenerating (or vice versa) must fail loudly, not drift silently. + from golden.identity.rust import regen # type: ignore[import-not-found] + + meta = json.loads((_HERE / "corpus" / "META.json").read_text(encoding="utf-8")) + assert meta["corpus_version"] == regen.CORPUS_VERSION + + +@pytest.mark.parametrize("name", sorted(_INPUTS)) +def test_rust_identity_corpus_is_byte_identical(name: str, request: pytest.FixtureRequest) -> None: + root = _INPUTS[name] + golden = (_HERE / "corpus" / f"{name}.json").read_text(encoding="utf-8") + actual = c.to_json(c.capture(root)) + request.node.stash[_ACTUAL_KEY] = (name, actual) # for the conftest dump-on-failure + assert actual == golden, f"{name!r}: {_REGEN_HINT}" + + +# --- Non-vacuity (Step 6.1b): a silently-empty/shallow corpus must NOT pass --- +# Permanent structural tests over the FROZEN JSON (not the live capture), so a +# fixture or harness change that hollows out a surface fails loudly instead of +# freezing a vacuous oracle. + + +def _corpus(name: str) -> dict: + return json.loads((_HERE / "corpus" / f"{name}.json").read_text(encoding="utf-8")) + + +def test_corpus_freezes_a_finding_per_rule_with_real_fingerprints() -> None: + findings = _corpus("rustapp")["findings"] + by_rule: dict[str, list[dict]] = {} + for f in findings: + by_rule.setdefault(f["rule_id"], []).append(f) + for rule in ("RS-WL-108", "RS-WL-112"): + assert by_rule.get(rule), f"corpus freezes no {rule} finding (rules={sorted(by_rule)})" + for f in by_rule[rule]: + assert f["fingerprint"], f"{rule}: empty fingerprint frozen" + # The whole point of SP2: identity is CRATE-prefixed (Cargo.toml name = "rust-app" + # -> crate rust_app), not directory-named. + assert all(f["qualname"].startswith("rust_app.") for f in findings), ( + f"finding qualnames are not crate-prefixed: {[f['qualname'] for f in findings]}" + ) + + +def test_corpus_freezes_a_class2_out_route_finding() -> None: + # The class-2 fixture (tests/integration.rs, outside src/) must freeze a real + # FINDING whose qualname carries the reserved `.#out.` non-conformance branding — + # the route shape the rekey introduced cannot silently fall out of the corpus. + findings = _corpus("rustapp")["findings"] + out_rows = [f for f in findings if ".#out." in f["qualname"]] + assert out_rows, f"no `.#out.` qualname frozen (qualnames={[f['qualname'] for f in findings]})" + assert any(f["rule_id"] == "RS-WL-112" for f in out_rows), "class-2 RS-WL-112 finding not frozen" + assert any(f["qualname"].startswith("rust_app.#out.tests.integration") for f in out_rows) + + +def test_corpus_freezes_the_impl_entity_surface() -> None: + entities = _corpus("rustapp")["entities"] + assert any(e["kind"] == "impl" for e in entities), "no impl entity row frozen" + # The semantic `method` kind freezes as the id-kind `function`, re-parented onto + # the impl entity (module -> impl -> method containment). + methods = [e for e in entities if e["kind"] == "function" and e["parent"] and ".impl" in e["parent"]] + assert methods, "no impl-method entity row (parent = impl entity) frozen" + + +def test_corpus_freezes_a_cfg_twin() -> None: + qns = {e["qualname"] for e in _corpus("rustapp")["entities"]} + assert any("@cfg(" in qn for qn in qns), f"no @cfg(...) twin qualname frozen (qualnames={sorted(qns)})" + + +def test_corpus_freezes_the_cross_file_crate_route() -> None: + # A real crate prefix + cross-file module route (src/cmd/runner.rs -> + # rust_app.cmd.runner) — NOT a directory-name stub. + qns = {e["qualname"] for e in _corpus("rustapp")["entities"]} + assert any(qn.startswith("rust_app.cmd.runner") for qn in qns), ( + f"no rust_app.cmd.runner-prefixed entity frozen (qualnames={sorted(qns)})" + ) + + +def test_corpus_freezes_edges() -> None: + edges = _corpus("rustapp")["edges"] + assert edges, "no edges frozen" + kinds = {e["kind"] for e in edges} + assert kinds == {"imports", "implements"}, f"expected both edge kinds frozen, got {sorted(kinds)}" + for e in edges: + assert e["confidence"] in ("resolved", "ambiguous"), f"illegal confidence frozen: {e}" + + +def test_corpus_entity_spans_carry_real_coordinates() -> None: + for e in _corpus("rustapp")["entities"]: + loc = e["location"] + assert loc["path"] and not loc["path"].startswith("/"), f"non-relative path frozen: {e['qualname']!r}" + assert loc["line_start"] is not None and loc["col_start"] is not None, f"null span {e['qualname']!r}" + + +def test_corpus_fingerprints_are_collision_free() -> None: + # The join-key soundness gate, mirrored from the Python oracle: two distinct + # active findings sharing a fingerprint would silently drop one on the join. + fps = [f["fingerprint"] for f in _corpus("rustapp")["findings"]] + assert len(fps) == len(set(fps)), f"fingerprint collision in the frozen corpus: {fps}" + + +# --- Fixture hygiene: nothing date/env dependent may travel with the fixture --- + + +@pytest.mark.parametrize("name", sorted(_INPUTS)) +def test_fixture_has_no_local_config(name: str) -> None: + root = _INPUTS[name] + assert not (root / ".weft").exists(), f"{name}: fixture must not carry a .weft/ dir" + assert not (root / "weft.toml").exists(), f"{name}: fixture must not carry a weft.toml" + + +def test_fixture_has_no_path_typed_generic_args() -> None: + # Reserved-colon constraint (see README.md): `impl From`-style + # path-typed generic args are an UN-DECIDED cross-tool ADR-049 case — freezing + # one would pre-empt the pending decision. Guard the fixture against a future + # edit re-introducing one: no `impl`/`<...>` qualname may carry a `::` path. + src_files = sorted((_INPUTS["rustapp"] / "src").rglob("*.rs")) + for f in src_files: + for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.strip() + if stripped.startswith("impl") and "<" in stripped and "::" in stripped.split("for")[0]: + pytest.fail(f"{f.name}:{lineno}: path-typed generic arg in an impl header: {stripped!r}") + + +_ACTUAL_KEY = pytest.StashKey[tuple]() diff --git a/tests/golden/identity/test_identity_parity.py b/tests/golden/identity/test_identity_parity.py index e7ae66fe..14010b74 100644 --- a/tests/golden/identity/test_identity_parity.py +++ b/tests/golden/identity/test_identity_parity.py @@ -25,6 +25,7 @@ _INPUTS = { "sampleapp": _HERE / "fixtures" / "sampleapp", "stress": _HERE / "fixtures" / "stress", + "sinks": _HERE / "fixtures" / "sinks", } _REGEN_HINT = ( "identity corpus drift. If this is an INTENTIONAL, versioned rekey, regenerate with:\n" @@ -33,6 +34,17 @@ ) +def test_corpus_meta_has_engine_scheme() -> None: + # P1 scheme-infra: META records the scheme the corpus was captured under so a + # future scheme bump is a visible, accountable corpus delta. + import json + + from wardline.core.finding import FINGERPRINT_SCHEME + + meta = json.loads((_HERE / "corpus" / "META.json").read_text(encoding="utf-8")) + assert meta["fingerprint_scheme"] == FINGERPRINT_SCHEME + + @pytest.mark.parametrize("name", sorted(_INPUTS)) def test_identity_corpus_is_byte_identical(name: str, request: pytest.FixtureRequest) -> None: root = _INPUTS[name] @@ -91,6 +103,35 @@ def test_stress_covers_multiple_rules() -> None: assert len(rules) >= 2, f"stress fixture should exercise >=2 identity rules, got {sorted(rules)}" +@pytest.mark.parametrize("name", sorted(_INPUTS)) +def test_corpus_fingerprints_are_collision_free(name: str) -> None: + # The join-key soundness gate (weft-4a9d0f863c). The fingerprint is the + # cross-tool JOIN KEY into the baseline store and Filigree; if two DISTINCT + # active findings share one fingerprint, one is silently dropped on the join — + # a soundness regression worse than the instability this fix closes. Stripping + # resolved-tier components from taint_path must NEVER collapse two findings, so + # distinct-fingerprint-count MUST equal active-finding-count over the corpus. + # The ``sinks`` fixture deliberately plants same-(rule,line,qualname) pairs that + # only stay distinct via the per-call source discriminator, so this gate is + # non-vacuous for every call-site-anchored rule: + # - PY-WL-118 ``chained_queries``: a CHAINED ``execute(a).execute(b)`` whose two + # calls share a start column — distinct ONLY via the full span end-column, so + # this line guards specifically against a span -> start-column-only regression. + # - PY-WL-106 ``double_deserialize`` and PY-WL-105/PY-WL-120 ``fan_out_stored``: + # sibling calls on one line — guard against a discriminator -> None regression. + # All four call-site rules share the identical ``@{col_offset}:{end_col_offset}`` + # pattern, so the chained PY-WL-118 line is representative of the span requirement + # for the family. + findings = _capture(name)["findings"] + fps = [f["fingerprint"] for f in findings] + dupes = {fp for fp in fps if fps.count(fp) > 1} + assert not dupes, ( + f"{name}: {len(fps) - len(set(fps))} fingerprint collision(s) — two distinct active findings " + f"share a join key and one would be silently dropped. Colliding: " + f"{[(f['rule_id'], f['qualname'], f['location']['line_start']) for f in findings if f['fingerprint'] in dupes]}" + ) + + def test_assure_corpus_has_no_waiver_debt() -> None: # Fixtures ship with no .weft/ waivers, so waiver_debt must be empty — # otherwise build_posture's date.today() would date-poison the corpus. diff --git a/tests/golden/identity/test_rekey_collision_pairs.py b/tests/golden/identity/test_rekey_collision_pairs.py new file mode 100644 index 00000000..d72bc189 --- /dev/null +++ b/tests/golden/identity/test_rekey_collision_pairs.py @@ -0,0 +1,94 @@ +"""P3 rekey guard — multi-emit COLLISION-FREEDOM after the line_start drop. + +When two findings share (rule_id, qualname) they are distinguished TODAY only by +absolute line_start. After P3 drops line_start from the hash, each multi-emit rule +must carry a source-derived entity-relative discriminator that keeps them distinct. +This is the `wardline-6102d4c833` regression net: GREEN today (line_start), and it +MUST STAY GREEN after S4 — if a multi-emit rule loses its discriminator it goes RED +here instead of silently collapsing in baseline.setdefault. + +Runs in a tmp dir (non-corpus). The planted pairs are the worst cases: +- two same-COLUMN, different-LINE, SAME-LENGTH sink calls (so rel_line is the SOLE + distinguisher — equal-length text keeps col/end_col identical, else the gate is + vacuous); +- two broad `except` handlers (PY-WL-103); +- two silent handlers (PY-WL-104). +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path + +import pytest + +pytest.importorskip("blake3", reason="run_scan identity path needs wardline[loomweave]") + +from wardline.core.finding import Kind # noqa: E402 +from wardline.core.run import run_scan # noqa: E402 + +_SRC = """\ +from collections.abc import Sequence + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def source(argv: Sequence[str]) -> str: + return argv[0] if argv else "" + + +@trusted(level="ASSURED") +def two_sinks(argv: Sequence[str]) -> None: + import subprocess + + aa = source(argv) + bb = source(argv) + subprocess.run(aa, shell=True) + subprocess.run(bb, shell=True) + + +@trusted(level="ASSURED") +def two_broad(argv: Sequence[str]) -> None: + try: + _alpha(source(argv)) + except Exception: + _log(1) + try: + _beta(source(argv)) + except Exception: + _log(2) + + +@trusted(level="ASSURED") +def two_silent(argv: Sequence[str]) -> None: + try: + _gamma(source(argv)) + except Exception: + pass + try: + _delta(source(argv)) + except Exception: + pass +""" + + +def test_multiemit_pairs_stay_distinct(tmp_path: Path) -> None: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "m.py").write_text(_SRC, encoding="utf-8") + + groups: dict[tuple[str, str], list[str]] = defaultdict(list) + for f in run_scan(proj).findings: + if f.kind is not Kind.DEFECT or f.qualname is None: + continue + groups[(f.rule_id, f.qualname)].append(f.fingerprint) + + multi = {k: v for k, v in groups.items() if len(v) > 1} + # Non-vacuity: the three planted multi-emit groups must actually be present. + assert ("PY-WL-112", "m.two_sinks") in multi, f"expected 2 sink findings in two_sinks; groups={dict(groups)}" + assert ("PY-WL-103", "m.two_broad") in multi, f"expected 2 broad findings in two_broad; groups={dict(groups)}" + assert ("PY-WL-104", "m.two_silent") in multi, f"expected 2 silent findings in two_silent; groups={dict(groups)}" + + collided = {k: v for k, v in multi.items() if len(set(v)) != len(v)} + assert not collided, f"distinct findings in one (rule, qualname) share a fingerprint: {collided}" diff --git a/tests/golden/identity/test_rekey_mutation_pairs.py b/tests/golden/identity/test_rekey_mutation_pairs.py new file mode 100644 index 00000000..ae0287c3 --- /dev/null +++ b/tests/golden/identity/test_rekey_mutation_pairs.py @@ -0,0 +1,105 @@ +"""P3 rekey driver — ENTITY-RELATIVE fingerprint stability. + +A benign comment inserted ABOVE a finding-bearing entity shifts every line number +but must NOT change the finding's fingerprint, because the discriminator is +entity-relative (offset from the enclosing def), not absolute. RED before P3 (the +hash still folds absolute line_start); GREEN after. + +NOTE: this is "entity-relative", NOT "move-stable" in the strong sense — a comment +inserted INSIDE the function above the sink still shifts the relative offset (an +accepted limitation, wardline-8654423823). This test only exercises comment-above- +entity, the reported churn case. + +Scans the SAME relative path twice (write -> scan -> overwrite -> scan) so the +`path` component of the fingerprint is held constant; runs in a tmp dir so the +frozen identity corpus is untouched. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("blake3", reason="run_scan identity path needs wardline[loomweave]") + +from wardline.core.finding import Kind # noqa: E402 +from wardline.core.run import run_scan # noqa: E402 + +# One finding per (rule_id, qualname) so the non-fingerprint match key is unambiguous: +# a command-injection sink (PY-WL-108, call-site family) and a broad handler (PY-WL-103, +# handler-anchored) in distinct trusted functions. +_SRC = """\ +from collections.abc import Sequence + +from wardline.decorators import external_boundary, trusted + + +@external_boundary +def source(argv: Sequence[str]) -> str: + return argv[0] if argv else "" + + +@trusted(level="ASSURED") +def runner(argv: Sequence[str]) -> object: + import subprocess + + cmd = source(argv) + return subprocess.run(cmd, shell=True) + + +@trusted(level="ASSURED") +def swallower(argv: Sequence[str]) -> None: + try: + _work(source(argv)) + except Exception: + return None +""" + +_COMMENT_ABOVE = "# audit: reviewed upstream — a benign comment that shifts every line below\n" + + +def _scan_fps(proj: Path) -> dict[tuple[str, str], str]: + """Map (rule_id, qualname) -> fingerprint for the DEFECTs in one scan.""" + out: dict[tuple[str, str], str] = {} + for f in run_scan(proj).findings: + if f.kind is not Kind.DEFECT or f.qualname is None: + continue + key = (f.rule_id, f.qualname) + assert key not in out, f"fixture must emit ONE finding per (rule, qualname); got 2 for {key}" + out[key] = f.fingerprint + return out + + +def test_comment_above_entity_keeps_fingerprint(tmp_path: Path) -> None: + proj = tmp_path / "proj" + proj.mkdir() + mod = proj / "m.py" + + mod.write_text(_SRC, encoding="utf-8") + before = _scan_fps(proj) + + mod.write_text(_COMMENT_ABOVE + _SRC, encoding="utf-8") + after = _scan_fps(proj) + + # Non-vacuity: the fixture really produces both move-prone families — a call-site + # sink (PY-WL-112 here: subprocess shell=True) and a handler-anchored broad except. + rules = {rule for rule, _ in before} + _CALL_SITE = { + "PY-WL-105", + "PY-WL-106", + "PY-WL-107", + "PY-WL-108", + "PY-WL-112", + "PY-WL-115", + "PY-WL-116", + "PY-WL-117", + "PY-WL-118", + "PY-WL-120", + } + assert rules & _CALL_SITE, f"expected a call-site sink finding; got {sorted(rules)}" + assert "PY-WL-103" in rules, f"expected a broad-handler finding; got {sorted(rules)}" + + assert before.keys() == after.keys(), "the same findings must surface before and after the benign comment" + drifted = {k: (before[k], after[k]) for k in before if before[k] != after[k]} + assert not drifted, f"benign comment above the entity churned the fingerprint for: {drifted}" diff --git a/tests/grammar/fixtures/custom_grammar.py b/tests/grammar/fixtures/custom_grammar.py index 9a713ade..d08d5bfe 100644 --- a/tests/grammar/fixtures/custom_grammar.py +++ b/tests/grammar/fixtures/custom_grammar.py @@ -89,7 +89,6 @@ def check(self, context: AnalysisContext) -> list[Finding]: fingerprint=_fp( rule_id=self.rule_id, path=entity.location.path, - line_start=entity.location.line_start, qualname=qualname, taint_path=f"{actual.value}->{declared.value}", ), diff --git a/tests/grammar/golden/builtin_findings.jsonl b/tests/grammar/golden/builtin_findings.jsonl index ccc9dfe2..3167e5fd 100644 --- a/tests/grammar/golden/builtin_findings.jsonl +++ b/tests/grammar/golden/builtin_findings.jsonl @@ -1,34 +1,36 @@ -{"confidence": null, "fingerprint": "97fcbb3547c624ddeb39c3c37c11fc5e5b0d2d1eda1b6465bc32ad81952aca07", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "L3 resolver run metrics", "properties": {"cache_hit_rate": 0.0, "convergence_iterations_histogram": [[1, 5]], "convergence_iterations_max": 1, "scc_size_distribution": [[1, 5]], "taint_source_counts": {"anchored": 39, "fallback": 5, "module_default": 0}}, "qualname": null, "related_entities": [], "rule_id": "WLN-ENGINE-METRICS", "severity": "NONE", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "5a0d307a403219d45d1701776b71a05840a09f9395d007b4245075ecf51dede3", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.aliased_stdlib.aliased_sink has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "f02a6f8ac9963cc361ba887bad5c5ef18c9a178bddebb44bc74a6bebfdc0ac55", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.aliased_stdlib.indirect_return has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "3afa077d8f0b2ed588e2452b98c7afd6d6634c9d1f95cba527cf4ba3d5bca7ee", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.cf_joins.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "3eeafba571222e05ca13a57f17c5e7b7d797794c26100a924c7369bb5b8d544f", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.match_arms.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "d1c0002b30f0e4f8a0506e830c6f05364e691b946120b92cacf80fff80c28b94", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.more_shapes.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "1f79bbef4441f05d6c0795f8bb1c29c67611cc3cdc495a6fc34a95cd982230ef", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.validators.has_rejection has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "582fbe157c5771c206383f81299f572d7fb29d11551f5d4c06e824768a0d4a02", "kind": "defect", "location": {"col_end": 28, "col_start": 0, "line_end": 13, "line_start": 12, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.aliased_sink declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.aliased_sink", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "c61ddc0aead2da70944cfd52a0c27e6ccb94a194513f2a5c641a843b2a906e79", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 19, "line_start": 17, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.indirect_return declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.indirect_return", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "7867deb10c7761beab4baa764ea8c46780e328860174a9579d3599513818fb40", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 23, "line_start": 20, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.if_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.if_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "a7552cfd1721d7df78d2f882d7f0087bddae41c5c7cb71d21fcde50f91151009", "kind": "defect", "location": {"col_end": 26, "col_start": 0, "line_end": 31, "line_start": 27, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.try_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.try_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "b5254770c2ad34076f7f44a20a42031156f1d6e04190023126ed80334d7f668f", "kind": "defect", "location": {"col_end": 19, "col_start": 0, "line_end": 17, "line_start": 13, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "58d893ebfaee68fe96b8a5930072c58fa2f6fb32e5e2fe799e10d270502fd8e7", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 26, "line_start": 21, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "65882a616c5d38981743aab237001a7877b18442738c979e185838462ed74cc4", "kind": "defect", "location": {"col_end": 44, "col_start": 0, "line_end": 34, "line_start": 30, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.narrow_logged declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.narrow_logged", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "b451b97cc9a21f0e0a8652de37232a2de5bb722e68e7c8353806e8a11bc872c4", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 26, "line_start": 20, "path": "tests/corpus/fixtures/match_arms.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.match_arms.match_arm_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.match_arms.match_arm_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "7bd22330169739f26817f110cfc7a944d09bb92ddec1604fd31cf947b97b2876", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 22, "line_start": 21, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.direct_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.direct_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "3ecd4fbbe6886c8a482989834496b303dad1f3302ba9e37db74521ecb20ce140", "kind": "defect", "location": {"col_end": 53, "col_start": 0, "line_end": 27, "line_start": 26, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.dict_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.dict_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "ab1266baf6c0487e4c091a6a604df2ec93caaad65fe9a19cfcd8efbf9ab82c84", "kind": "defect", "location": {"col_end": 27, "col_start": 0, "line_end": 32, "line_start": 31, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.str_wrapped_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.str_wrapped_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "ef809807c4383a2f04bb7c6445a151dfaae92d6b06ce730a9b682723b8bcdcef", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 39, "line_start": 36, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.chained_indirection declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.chained_indirection", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "2eb8b17bec9fd041446d1353de9917d36c2e20cd90883722c3ee9d6e5cca115a", "kind": "defect", "location": {"col_end": 25, "col_start": 0, "line_end": 45, "line_start": 43, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.fstring_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.fstring_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "7d50fefe0225e23c8650ce8064e8abc6e54c64d69cfcb181f3a4463b5f63dac3", "kind": "defect", "location": {"col_end": 37, "col_start": 0, "line_end": 50, "line_start": 49, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.list_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.list_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "787ebaa087db047519c7460540b867b00b1f0f556a029ea58de4ac13d7bca0bb", "kind": "defect", "location": {"col_end": 14, "col_start": 0, "line_end": 57, "line_start": 54, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.augassign_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.augassign_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "490fd3d5f75b21a869f73297d8ee0b6a0f6116e87fc2916be50aa7b6b81d03d2", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 65, "line_start": 61, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "9e97be58ccce035dba9d6a5bf276c5dffe4b2516368648b1fb05da9e8197592a", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 71, "line_start": 69, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.passthrough_no_check declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.passthrough_no_check", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "3042b0dda717f3cc0b6e0e5e798da1a4f220ab7a741c55b9655094f580e552af", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 9, "line_start": 8, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "e27672ff7bc798accf1edd645163de014744e59a0eeb495a59dfeb2afc0b8826", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 14, "line_start": 13, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection_guarded declares a trust boundary (EXTERNAL_RAW -> GUARDED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "GUARDED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection_guarded", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "da2a549c6e8d7be9511bc565ae169493d5b820d6721f11ee0951bcbf5a4824d5", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler: broad exception handler at line 16", "properties": {"tier": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-103", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "f743355684ee7af2e9bf83dd94951678ffdafcb00b74efedfb31da8489831a94", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 24, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler: exception silently swallowed at line 24", "properties": {"tier": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-104", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "a691d4bd0b118f5d73f2cc65429b7d77c688b2b740e2ce4a383362c14c08b959", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 8, "line_start": 7, "path": "tests/corpus/fixtures/contradictory.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.contradictory.conflicting carries contradictory trust markers (external_boundary+trusted); the engine resolves the clash to the least-trusted seed, silently ignoring the rest", "properties": {"markers": "external_boundary+trusted"}, "qualname": "tests.corpus.fixtures.contradictory.conflicting", "related_entities": [], "rule_id": "PY-WL-110", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "385a3b2dd4dbc349dcb2656cf79ea83b6d0820faa9fe2530b9d11f357ca363e0", "kind": "defect", "location": {"col_end": 10, "col_start": 0, "line_end": 9, "line_start": 6, "path": "tests/corpus/fixtures/none_leak.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.none_leak.maybe_none declares trusted return ASSURED but a path returns None (bare return / return None) — None leaks from a trusted producer", "properties": {"declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.none_leak.maybe_none", "related_entities": [], "rule_id": "PY-WL-109", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "7818cf51b8302c0dafc1317e9867645768ad59e6f16a55f4534e7f014ecd2b49", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/trusted_callee.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.trusted_callee.handler: EXTERNAL_RAW (untrusted) data passed to trusted producer tests.corpus.fixtures.trusted_callee.store() at line 16", "properties": {"arg_taint": "EXTERNAL_RAW", "callee": "tests.corpus.fixtures.trusted_callee.store", "callee_body": "ASSURED"}, "qualname": "tests.corpus.fixtures.trusted_callee.handler", "related_entities": [], "rule_id": "PY-WL-105", "severity": "ERROR", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "ea3883a7f6cd5bef0a3c95adfa9f3c9c1a68405448255d804eb5e253cb685de4", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 15, "path": "tests/corpus/fixtures/deser_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.deser_sink.loads_untrusted: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "pickle.loads", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.deser_sink.loads_untrusted", "related_entities": [], "rule_id": "PY-WL-106", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "313e1050416b8b245d991c7d6ae06428e9a897c804e71b9f34bccb7e853caf89", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 13, "path": "tests/corpus/fixtures/exec_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exec_sink.evals_untrusted: EXTERNAL_RAW (untrusted) data reaches the dynamic-code-execution sink eval() at line 13", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "eval", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.exec_sink.evals_untrusted", "related_entities": [], "rule_id": "PY-WL-107", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} -{"confidence": null, "fingerprint": "d2314f3580695093b23e89b87ffebc59a67ed757f3b26ea1b9a29970cd6e02b1", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 15, "path": "tests/corpus/fixtures/command_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.command_sink.runs_untrusted: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.command_sink.runs_untrusted", "related_entities": [], "rule_id": "PY-WL-108", "severity": "WARN", "suggestion": null, "suppressed": "active", "suppression_reason": null} \ No newline at end of file +{"confidence": null, "fingerprint": "97fcbb3547c624ddeb39c3c37c11fc5e5b0d2d1eda1b6465bc32ad81952aca07", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "L3 resolver run metrics", "properties": {"cache_hit_rate": 0.0, "convergence_iterations_histogram": [[1, 5]], "convergence_iterations_max": 1, "scc_size_distribution": [[1, 5]], "taint_source_counts": {"anchored": 43, "fallback": 5, "module_default": 0}}, "qualname": null, "related_entities": [], "rule_id": "WLN-ENGINE-METRICS", "severity": "NONE", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "5a0d307a403219d45d1701776b71a05840a09f9395d007b4245075ecf51dede3", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.aliased_stdlib.aliased_sink has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "f02a6f8ac9963cc361ba887bad5c5ef18c9a178bddebb44bc74a6bebfdc0ac55", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.aliased_stdlib.indirect_return has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "3afa077d8f0b2ed588e2452b98c7afd6d6634c9d1f95cba527cf4ba3d5bca7ee", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.cf_joins.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "3eeafba571222e05ca13a57f17c5e7b7d797794c26100a924c7369bb5b8d544f", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.match_arms.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "d1c0002b30f0e4f8a0506e830c6f05364e691b946120b92cacf80fff80c28b94", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.more_shapes.validate has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "1f79bbef4441f05d6c0795f8bb1c29c67611cc3cdc495a6fc34a95cd982230ef", "kind": "metric", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": null, "path": ""}, "maturity": "stable", "message": "Function tests.corpus.fixtures.validators.has_rejection has 100% unresolved calls (1/1)", "properties": {}, "qualname": null, "related_entities": [], "rule_id": "WLN-L3-LOW-RESOLUTION", "severity": "INFO", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "6538bf6c3e76ab83c38754d032c8ace87fac06c5cd157c39496e8c4e746115a0", "kind": "defect", "location": {"col_end": 28, "col_start": 0, "line_end": 13, "line_start": 12, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.aliased_sink declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.aliased_sink", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "235f99c070c0362f7a5c80494d7059843a12086a27035cd9213a150e542d4758", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 19, "line_start": 17, "path": "tests/corpus/fixtures/aliased_stdlib.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.aliased_stdlib.indirect_return declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.aliased_stdlib.indirect_return", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "7bdc8a2f82080dd7ea07e0e3159b6a4b4327d80b37f6b04bed94f9d1c63bb668", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 23, "line_start": 20, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.if_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.if_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "b5ebcee770eefd21f57f87e070112a8174275c2d8c0522c8f75fff63e8f71c2c", "kind": "defect", "location": {"col_end": 26, "col_start": 0, "line_end": 31, "line_start": 27, "path": "tests/corpus/fixtures/cf_joins.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.cf_joins.try_branch_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.cf_joins.try_branch_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "4113a2676ccaea6f25d097281c4b9010d693b90400e3fa484c088de944f96575", "kind": "defect", "location": {"col_end": 19, "col_start": 0, "line_end": 17, "line_start": 13, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "4284ec8847342158d3e3f0ad5ce16834176b2abee8d8e4111d50304e599b0c69", "kind": "defect", "location": {"col_end": 15, "col_start": 0, "line_end": 26, "line_start": 21, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "1b15d5f9fa1213cad2b9414f837db7cc4783fd73c060353ab0ebb2c080fbdfcb", "kind": "defect", "location": {"col_end": 44, "col_start": 0, "line_end": 34, "line_start": 30, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.narrow_logged declares return trust INTEGRAL but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.narrow_logged", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "df9ffd51b8b982a203654d2a3589dd989203d564987550612e8356fea9ddf080", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 26, "line_start": 20, "path": "tests/corpus/fixtures/match_arms.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.match_arms.match_arm_leaks declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.match_arms.match_arm_leaks", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "dcd5ada1c54e1b0a068f736a784a01d206f4fc6bf8f2ddf6487f0ee4dde2b122", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 22, "line_start": 21, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.direct_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.direct_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "2fa762ccb85504025885b0d9bc9849cdcde22583896b76541ac6eb73874bf461", "kind": "defect", "location": {"col_end": 53, "col_start": 0, "line_end": 27, "line_start": 26, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.dict_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.dict_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "04e42f1735123d50541ec5930b836a0656728518c3ca323d9a58f56e37eda36e", "kind": "defect", "location": {"col_end": 27, "col_start": 0, "line_end": 32, "line_start": 31, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.str_wrapped_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.str_wrapped_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "d152a65a239f55609188ad4e4f7ef6b2051cf7727d3c44f040f8f50c64643f97", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 39, "line_start": 36, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.chained_indirection declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.chained_indirection", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "9b4c4306347a4cf91818b996287659b4b646d3ec4fe95b3edcf42e61e657d4a2", "kind": "defect", "location": {"col_end": 25, "col_start": 0, "line_end": 45, "line_start": 43, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.fstring_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.fstring_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "b2469a2f40d36d013829539b3ed90854a3949c87c4c850c47d3488a74fa87462", "kind": "defect", "location": {"col_end": 37, "col_start": 0, "line_end": 50, "line_start": 49, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.list_of_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.list_of_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "feb63a22babf3e084187866a7d54170c61d22baa44470a347fa93a990a7ff778", "kind": "defect", "location": {"col_end": 14, "col_start": 0, "line_end": 57, "line_start": 54, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.augassign_raw declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.augassign_raw", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "48db6ff8bfe3be3a2ffe31918ae202123b7f25f7ed1554b3c5897a9a3bf777ac", "kind": "defect", "location": {"col_end": 22, "col_start": 0, "line_end": 65, "line_start": 61, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary declares return trust ASSURED but actually returns UNKNOWN_RAW (less trusted) — untrusted data reaches a trusted producer", "properties": {"actual_return": "UNKNOWN_RAW", "declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.launders_through_broken_boundary", "related_entities": [], "rule_id": "PY-WL-101", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "988c22d25a99f03ed8e75f487e0743fdc5a9faf2d32d785cb2992ad9977dc28c", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 71, "line_start": 69, "path": "tests/corpus/fixtures/more_shapes.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.more_shapes.passthrough_no_check declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.more_shapes.passthrough_no_check", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "b780ea20891c98e4a01af2d3e4dd22c9977d2a1f266efeca818d3c9e820d46ba", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 10, "line_start": 8, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection declares a trust boundary (EXTERNAL_RAW -> ASSURED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "ASSURED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "c0c5caa3685eef1f73f8e2f97605e9806a0ad444170317d5c5137c3f8469e8ee", "kind": "defect", "location": {"col_end": 18, "col_start": 0, "line_end": 16, "line_start": 14, "path": "tests/corpus/fixtures/validators.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.validators.no_rejection_guarded declares a trust boundary (EXTERNAL_RAW -> GUARDED) but has no rejection path (no raise / no falsy return) — it cannot validate", "properties": {"body_taint": "EXTERNAL_RAW", "return_taint": "GUARDED"}, "qualname": "tests.corpus.fixtures.validators.no_rejection_guarded", "related_entities": [], "rule_id": "PY-WL-102", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "c965eff4deb1719dd123d7a179cba25ae6590e324b0cb31637cf04f540cd8816", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.broad_handler: broad exception handler at line 16", "properties": {"tier": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.broad_handler", "related_entities": [], "rule_id": "PY-WL-103", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "c50617814cd7cac9b17ce5e07647a1bf3429d2ffd9d50e98481a128cb0037fad", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 24, "path": "tests/corpus/fixtures/exceptions.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exceptions.silent_handler: exception silently swallowed at line 24", "properties": {"tier": "INTEGRAL"}, "qualname": "tests.corpus.fixtures.exceptions.silent_handler", "related_entities": [], "rule_id": "PY-WL-104", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "6c2e8485d5076b983edf80ad5081109f3f58e204e5bd5e8fbd50a109e54da101", "kind": "defect", "location": {"col_end": 12, "col_start": 0, "line_end": 8, "line_start": 7, "path": "tests/corpus/fixtures/contradictory.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.contradictory.conflicting carries contradictory trust markers (external_boundary+trusted); the engine resolves the clash to the least-trusted seed, silently ignoring the rest", "properties": {"markers": "external_boundary+trusted"}, "qualname": "tests.corpus.fixtures.contradictory.conflicting", "related_entities": [], "rule_id": "PY-WL-110", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "72439c3d8c31d8d4ccece6f134368a8927a75d138b5adc18acbd293fcc9a9f98", "kind": "defect", "location": {"col_end": 10, "col_start": 0, "line_end": 9, "line_start": 6, "path": "tests/corpus/fixtures/none_leak.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.none_leak.maybe_none declares trusted return ASSURED but a path returns None (bare return / return None) — None leaks from a trusted producer", "properties": {"declared_return": "ASSURED"}, "qualname": "tests.corpus.fixtures.none_leak.maybe_none", "related_entities": [], "rule_id": "PY-WL-109", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "b480326767381cab9c1995241e94f6cb9dc6308281f2842f10a9f036d1e0760e", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 16, "path": "tests/corpus/fixtures/trusted_callee.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.trusted_callee.handler: EXTERNAL_RAW (untrusted) data passed to trusted producer tests.corpus.fixtures.trusted_callee.store() at line 16", "properties": {"arg_taint": "EXTERNAL_RAW", "callee": "tests.corpus.fixtures.trusted_callee.store", "callee_body": "ASSURED"}, "qualname": "tests.corpus.fixtures.trusted_callee.handler", "related_entities": [], "rule_id": "PY-WL-105", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "d666c4d2616986f1f3aeb1d743ed5badb0d67848b94b51b028322f714d684148", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 15, "path": "tests/corpus/fixtures/deser_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.deser_sink.loads_untrusted: EXTERNAL_RAW (untrusted) data reaches the deserialization sink pickle.loads() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "pickle.loads", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.deser_sink.loads_untrusted", "related_entities": [], "rule_id": "PY-WL-106", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "090abfc382d13eda6255b2948426fca197c6d5fbe177845860a9368a50a6178e", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 13, "path": "tests/corpus/fixtures/exec_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.exec_sink.evals_untrusted: EXTERNAL_RAW (untrusted) data reaches the dynamic-code-execution sink eval() at line 13", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "eval", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.exec_sink.evals_untrusted", "related_entities": [], "rule_id": "PY-WL-107", "severity": "WARN", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "89495235b260639eb40e0aef10c4c40dee8d0fd3ab3767fde1f65ff7cc0f00ac", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 15, "path": "tests/corpus/fixtures/command_sink.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.command_sink.runs_untrusted: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 15", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.command_sink.runs_untrusted", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "d0431eaf7203c0e40e57e855c8e9820062f75146a50e371f04021b34be50489d", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 24, "path": "tests/corpus/fixtures/shadow_launder.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.shadow_launder.shadowed_sink: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 24", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.shadow_launder.shadowed_sink", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} +{"confidence": null, "fingerprint": "db0b0dc75b64818b2d48960f83a2c457f2d433d40277fc0e724bc806ad7c84a6", "kind": "defect", "location": {"col_end": null, "col_start": null, "line_end": null, "line_start": 21, "path": "tests/corpus/fixtures/shadow_launder_bare.py"}, "maturity": "stable", "message": "tests.corpus.fixtures.shadow_launder_bare.bare_shadow_sink: EXTERNAL_RAW (untrusted) data reaches the OS-command sink os.system() at line 21", "properties": {"arg_taint": "EXTERNAL_RAW", "sink": "os.system", "tier": "ASSURED"}, "qualname": "tests.corpus.fixtures.shadow_launder_bare.bare_shadow_sink", "related_entities": [], "rule_id": "PY-WL-108", "severity": "ERROR", "suggestion": null, "suppression_reason": null, "suppression_state": "active"} \ No newline at end of file diff --git a/tests/grammar/test_analyzer_wiring.py b/tests/grammar/test_analyzer_wiring.py index cac3fbc5..967c8719 100644 --- a/tests/grammar/test_analyzer_wiring.py +++ b/tests/grammar/test_analyzer_wiring.py @@ -33,6 +33,12 @@ "PY-WL-118", "PY-WL-119", "PY-WL-120", + "PY-WL-121", + "PY-WL-122", + "PY-WL-123", + "PY-WL-124", + "PY-WL-125", + "PY-WL-126", ] diff --git a/tests/grammar/test_grammar_model.py b/tests/grammar/test_grammar_model.py index c5e513d9..904d129c 100644 --- a/tests/grammar/test_grammar_model.py +++ b/tests/grammar/test_grammar_model.py @@ -56,6 +56,12 @@ def test_default_grammar_has_builtin_marker_namespaces_and_all_rules() -> None: "PY-WL-118", "PY-WL-119", "PY-WL-120", + "PY-WL-121", + "PY-WL-122", + "PY-WL-123", + "PY-WL-124", + "PY-WL-125", + "PY-WL-126", ] diff --git a/tests/grammar/test_output_determinism.py b/tests/grammar/test_output_determinism.py new file mode 100644 index 00000000..54c55ee7 --- /dev/null +++ b/tests/grammar/test_output_determinism.py @@ -0,0 +1,56 @@ +"""Run-to-run output determinism guard (wardline-e159060db7). + +The product promises a stable finding stream (non-flaky gate, byte-stable +baselines/attestations). That property is currently held by convention scattered +across ~10 engine sites (sorted() discovery, Tarjan node/neighbor sorting, +least_trusted commutativity over unsorted callee sets, ...). The golden oracle +(``test_golden_oracle.py``) pins ONE run against a frozen golden, and only for +STABLE-maturity findings — it would catch drift, but a nondeterministic PREVIEW +rule or per-run engine state would slip past it. + +This is the single guard at the output boundary: two INDEPENDENT analyzer runs +over the fixed corpus must produce byte-identical full streams (every maturity, +every kind), in identical order. +""" + +from __future__ import annotations + +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.taints import TRUST_RANK, TaintState +from wardline.scanner.analyzer import WardlineAnalyzer + +REPO_ROOT = Path(__file__).resolve().parents[2] +_CORPUS = REPO_ROOT / "tests" / "corpus" / "fixtures" + + +def _full_stream() -> str: + """One complete, ordered, serialized analyzer run over the labeled corpus. + + Unlike ``golden_harness.produce_stream`` this does NOT filter to STABLE + maturity: PREVIEW rules (PY-WL-116..126) must be order-deterministic too — + they feed baselines and the delta gate even before graduation. + """ + files = sorted(_CORPUS.rglob("*.py")) + analyzer = WardlineAnalyzer() + findings = analyzer.analyze(files, WardlineConfig(), root=REPO_ROOT) + return "\n".join(f.to_jsonl() for f in findings) + + +def test_analyzer_output_is_byte_identical_across_runs() -> None: + first = _full_stream() + second = _full_stream() + assert first, "corpus produced an empty stream — fixture path broken" + assert first == second, "analyzer output differs between identical runs" + + +def test_trust_rank_is_injective() -> None: + # Load-bearing for order stability: callee sets are aggregated with the + # rank-meet ``least_trusted`` via ``reduce`` over an UNSORTED set + # (propagation.py); that is only order-independent (commutative + + # associative) while TRUST_RANK assigns every TaintState a DISTINCT rank. + # Two states sharing a rank would make the reduce pick whichever the set + # yields first — silent per-run nondeterminism. + ranks = [TRUST_RANK[t] for t in TaintState] + assert len(set(ranks)) == len(list(TaintState)) diff --git a/tests/integration/test_rekey_loud_miss.py b/tests/integration/test_rekey_loud_miss.py new file mode 100644 index 00000000..133726c3 --- /dev/null +++ b/tests/integration/test_rekey_loud_miss.py @@ -0,0 +1,56 @@ +"""P4 S12 — the safety contract: an un-rekeyed (old-scheme) store makes the next scan +fail LOUD with SCHEME_MISMATCH naming the file + `wardline rekey`, so a missed leg is +impossible to ignore. After the migration, the scan is clean. + +No new production code — this consumes P1's load-time scheme assertion end to end. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") +pytest.importorskip("blake3", reason="run_scan needs wardline[loomweave]") + +from click.testing import CliRunner # noqa: E402 + +from wardline.cli.main import cli # noqa: E402 +from wardline.core import paths # noqa: E402 + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return raw(p)\n" +) + + +def test_unrekeyed_store_fails_scheme_mismatch_then_clean_after_rekey(tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text(_LEAKY, encoding="utf-8") + + # An old-scheme (wlfp1) waivers store left in place by a missed migration leg. + wp = paths.waivers_path(project) + wp.parent.mkdir(parents=True, exist_ok=True) + wp.write_text( + yaml.safe_dump( + {"fingerprint_scheme": "wlfp1", "version": 1, "waivers": [{"fingerprint": "a" * 64, "reason": "stale"}]} + ), + encoding="utf-8", + ) + + # The next scan must fail LOUD — naming the file and steering to `wardline rekey`. + before = CliRunner().invoke(cli, ["scan", str(project), "--output", str(tmp_path / "o.jsonl")]) + assert before.exit_code == 2, before.output + assert "waivers.yaml" in before.output + assert "wardline rekey" in before.output + + # Migrate, then the same scan is clean (no SCHEME_MISMATCH). + rk = CliRunner().invoke(cli, ["rekey", str(project)]) + assert rk.exit_code == 0, rk.output + + after = CliRunner().invoke(cli, ["scan", str(project), "--output", str(tmp_path / "o2.jsonl")]) + assert after.exit_code != 2, after.output # no scheme error (exit 0/1 by gate, never the 2-error) + assert "does not match this build" not in after.output diff --git a/tests/test_self_hosting_violation.py b/tests/test_self_hosting_violation.py new file mode 100644 index 00000000..becf8629 --- /dev/null +++ b/tests/test_self_hosting_violation.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +from wardline.core.finding import Kind, Severity +from wardline.core.run import gate_decision, run_scan + + +def test_self_hosting_pipeline_catches_real_violation() -> None: + # NON-VACUOUS counterpart to test_wardline_scans_itself_clean: the self-hosting + # scan pipeline (run_scan + gate_decision — the exact path the `wardline scan + # src/ --fail-on ERROR` CI step exercises) MUST catch a real trust-boundary + # violation. The fixture lives under tests/fixtures/ (NOT src/) so the src/ + # self-scan stays clean, but a tier-gated rule (PY-WL-101) fires here and the + # ERROR gate trips — proving the pipeline CAN go red on a genuine defect. + fixtures = Path(__file__).resolve().parent / "fixtures" / "self_hosting_violation" + result = run_scan(fixtures) + defects = [f for f in result.findings if f.kind == Kind.DEFECT] + assert defects, "the violation fixture must produce a DEFECT" + assert any(d.rule_id == "PY-WL-101" for d in defects), [d.rule_id for d in defects] + assert any(d.severity is Severity.ERROR for d in defects if d.rule_id == "PY-WL-101") + + decision = gate_decision(result, Severity.ERROR) + assert decision.tripped is True + assert decision.verdict == "FAILED" diff --git a/tests/unit/cli/test_agent_summary_cmd.py b/tests/unit/cli/test_agent_summary_cmd.py index bf024fdd..55eef7b1 100644 --- a/tests/unit/cli/test_agent_summary_cmd.py +++ b/tests/unit/cli/test_agent_summary_cmd.py @@ -29,3 +29,16 @@ def test_scan_agent_summary_format_writes_stable_json(tmp_path: Path) -> None: assert data["schema"] == "wardline-agent-summary-1" assert data["active_defects"][0]["rule_id"] == "PY-WL-101" assert "summary.json" in result.output + + +def test_scan_agent_summary_default_output_refuses_repo_symlink(tmp_path: Path) -> None: + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + outside = tmp_path.parent / f"{tmp_path.name}-outside.json" + outside.write_text("keep-me\n", encoding="utf-8") + (tmp_path / "findings.agent-summary.json").symlink_to(outside) + + result = CliRunner().invoke(cli, ["scan", str(tmp_path), "--format", "agent-summary"]) + + assert result.exit_code == 2 + assert "findings.agent-summary.json: refusing to write through a symlink" in result.stderr + assert outside.read_text(encoding="utf-8") == "keep-me\n" diff --git a/tests/unit/cli/test_assure_cmd.py b/tests/unit/cli/test_assure_cmd.py index ce15d13e..39ba7855 100644 --- a/tests/unit/cli/test_assure_cmd.py +++ b/tests/unit/cli/test_assure_cmd.py @@ -15,8 +15,9 @@ from click.testing import CliRunner +from wardline.cli.assure import _render_human from wardline.cli.main import cli -from wardline.core.assure import build_posture +from wardline.core.assure import AssurancePosture, UnknownBoundary, build_posture # Identical decorated module used by test_assure.py so the engine produces # boundaries_total >= 1 (two @trusted producers + one @external_boundary = 3). @@ -40,6 +41,9 @@ ) _PLAIN = "def f():\n return 1\n" +_BAD_DECORATED = ( + "from wardline.decorators.trust import trusted\n\n@trusted(level='INTEGRAL')\ndef broken(:\n return 1\n" +) def test_json_output_equals_core(tmp_path: Path) -> None: @@ -74,6 +78,51 @@ def test_human_format_empty_surface(tmp_path: Path) -> None: assert "100% coverage" not in result.output +def test_human_format_unanalyzed_files_are_not_full_coverage(tmp_path: Path) -> None: + (tmp_path / "good.py").write_text(_MODULE, encoding="utf-8") + (tmp_path / "bad.py").write_text(_BAD_DECORATED, encoding="utf-8") + + result = CliRunner().invoke(cli, ["assure", str(tmp_path), "--format", "human"]) + + assert result.exit_code == 0, result.output + assert "100.0%" not in result.output + assert "75.0%" in result.output + assert "unanalyzed files: 1" in result.output + + +def test_human_format_escapes_repository_control_chars(capsys) -> None: + posture = AssurancePosture( + boundaries_total=1, + proven=0, + defect_total=0, + unknown=[ + UnknownBoundary( + qualname="svc.\x1b]52;clipboard", + tier="ASSURED", + path="evil\nname.py", + line=7, + reason="\x1b[31mrecursion", + ) + ], + engine_limited=1, + coverage_pct=0.0, + unanalyzed_total=0, + unanalyzed_rule_ids=[], + waiver_debt=[], + baselined_total=0, + judged_total=0, + ) + + _render_human(posture) + out = capsys.readouterr().out + + assert "\x1b" not in out + assert "evil\nname.py" not in out + assert r"svc.\x1b]52;clipboard" in out + assert r"evil\nname.py" in out + assert r"\x1b[31mrecursion" in out + + def test_human_lapsed_waiver_wording(tmp_path: Path) -> None: """A lapsed waiver must say "expired N day(s) ago", not "-N day(s) until earliest expiry".""" # Write a decorated module so the posture is non-empty, then invoke via JSON diff --git a/tests/unit/cli/test_attest_cmd.py b/tests/unit/cli/test_attest_cmd.py index bf667313..6fbc608e 100644 --- a/tests/unit/cli/test_attest_cmd.py +++ b/tests/unit/cli/test_attest_cmd.py @@ -187,6 +187,29 @@ def test_verify_mode_valid_and_tampered(tmp_path: Path, monkeypatch: pytest.Monk assert json.loads(bad.output)["signature_valid"] is False +def test_verify_reproduce_stale_bundle_exits_1(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("WARDLINE_ATTEST_KEY", raising=False) + _make_clean_repo(tmp_path) + + runner = CliRunner() + bundle_path = tmp_path / "attest.json" + built = runner.invoke(cli, ["attest", str(tmp_path), "--out", str(bundle_path)]) + assert built.exit_code == 0, built.output + + (tmp_path / "m.py").write_text( + _MODULE + "\n@trusted(level='INTEGRAL')\ndef extra():\n return 2\n", + encoding="utf-8", + ) + + stale = runner.invoke(cli, ["attest", str(tmp_path), "--verify", str(bundle_path), "--reproduce"]) + + assert stale.exit_code == 1 + result = json.loads(stale.output) + assert result["signature_valid"] is True + assert result["reproduced"] is False + assert "boundaries" in result["mismatches"] + + def test_verify_reproduce_refuses_escaping_source_roots_by_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 38907eed..78cbcb76 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -10,6 +10,7 @@ from wardline.cli.main import cli from wardline.cli.main import cli as _cli from wardline.cli.scan import scan +from wardline.core.finding import FINGERPRINT_SCHEME from wardline.core.paths import baseline_path, judged_path FIXTURE = Path(__file__).parents[2] / "fixtures" / "sample_project" @@ -31,6 +32,29 @@ def test_scan_writes_findings_and_exits_zero(tmp_path: Path) -> None: assert any(_json.loads(ln)["rule_id"] == "WLN-ENGINE-METRICS" for ln in lines) +def test_scan_warns_when_weft_toml_is_missing(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + out = tmp_path / "findings.jsonl" + + result = CliRunner().invoke(cli, ["scan", str(tmp_path), "--output", str(out)]) + + assert result.exit_code == 0, result.output + assert "warning: no weft.toml found" in result.output + assert "wardline doctor --repair" in result.output + assert "wardline scan-job start" in result.output + + +def test_scan_does_not_warn_when_weft_toml_exists(tmp_path: Path) -> None: + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + (tmp_path / "app.py").write_text("def ok():\n return 1\n", encoding="utf-8") + out = tmp_path / "findings.jsonl" + + result = CliRunner().invoke(cli, ["scan", str(tmp_path), "--output", str(out)]) + + assert result.exit_code == 0, result.output + assert "warning: no weft.toml found" not in result.output + + def test_scan_format_sarif_writes_sarif_file(tmp_path: Path) -> None: out = tmp_path / "out.sarif" result = CliRunner().invoke(cli, ["scan", str(FIXTURE), "--format", "sarif", "--output", str(out)]) @@ -50,6 +74,22 @@ def test_scan_format_sarif_default_output_path(tmp_path: Path) -> None: assert (project / "findings.sarif").exists() +def test_scan_format_sarif_default_refuses_symlinked_output(tmp_path: Path) -> None: + import shutil + + project = tmp_path / "proj" + shutil.copytree(FIXTURE, project) + outside = tmp_path / "outside.txt" + outside.write_text("keep\n", encoding="utf-8") + (project / "findings.sarif").symlink_to(outside) + + result = CliRunner().invoke(cli, ["scan", str(project), "--format", "sarif"]) + + assert result.exit_code == 2 + assert "refusing to write through a symlink" in result.output + assert outside.read_text(encoding="utf-8") == "keep\n" + + def test_scan_format_sarif_still_gates(tmp_path: Path) -> None: proj = tmp_path / "proj" proj.mkdir() @@ -93,6 +133,54 @@ def test_scan_default_output_lands_in_scanned_path(tmp_path: Path) -> None: assert (project / "findings.jsonl").exists() +def test_scan_relative_subdir_default_output_not_double_resolved(tmp_path: Path, monkeypatch) -> None: + # Regression: `wardline scan pkg` (relative subdir). The default output is reported as + # `pkg/findings.jsonl`, and the root-confined writer must write THERE — not resolve `pkg` + # into the already-prefixed path twice (`pkg/pkg/findings.jsonl`), which left the reported + # artifact path empty. + import shutil + + project = tmp_path / "pkg" + shutil.copytree(FIXTURE, project) + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(cli, ["scan", "pkg"]) + assert result.exit_code == 0, result.output + assert (project / "findings.jsonl").exists() # at the reported path + assert not (project / "pkg").exists() # no double-resolved pkg/pkg/ + + +def test_baseline_create_relative_subdir_not_double_resolved(tmp_path: Path, monkeypatch) -> None: + # Regression twin for `wardline baseline create pkg`: the baseline must land at the + # reported `pkg/.weft/wardline/baseline.yaml`, not `pkg/pkg/.weft/...`, or a later scan + # of `pkg` silently never loads it. + import shutil + + project = tmp_path / "pkg" + shutil.copytree(FIXTURE, project) + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(cli, ["baseline", "create", "pkg"]) + assert result.exit_code == 0, result.output + assert (project / ".weft" / "wardline" / "baseline.yaml").exists() + assert not (project / "pkg").exists() + + +def test_scan_jsonl_default_refuses_symlinked_output(tmp_path: Path) -> None: + import shutil + + project = tmp_path / "proj" + shutil.copytree(FIXTURE, project) + outside = tmp_path / "outside.jsonl" + outside.write_text("keep\n", encoding="utf-8") + (project / "findings.jsonl").unlink(missing_ok=True) + (project / "findings.jsonl").symlink_to(outside) + + result = CliRunner().invoke(cli, ["scan", str(project)]) + + assert result.exit_code == 2 + assert "findings.jsonl: refusing to write through a symlink" in result.output + assert outside.read_text(encoding="utf-8") == "keep\n" + + def _git(repo: Path, *args: str) -> None: import subprocess @@ -384,10 +472,11 @@ def test_scan_cache_dir_persists_warm_taints_equal_cold(tmp_path) -> None: out1 = tmp_path / "f1.jsonl" out2 = tmp_path / "f2.jsonl" runner = CliRunner() - r1 = runner.invoke(scan, [str(proj), "--cache-dir", str(cache), "--output", str(out1)]) + env = {"WARDLINE_SUMMARY_CACHE_KEY": "unit-test-summary-cache-key"} + r1 = runner.invoke(scan, [str(proj), "--cache-dir", str(cache), "--output", str(out1)], env=env) assert r1.exit_code == 0, r1.output assert cache.exists() and any(cache.iterdir()) # cache written to disk - r2 = runner.invoke(scan, [str(proj), "--cache-dir", str(cache), "--output", str(out2)]) + r2 = runner.invoke(scan, [str(proj), "--cache-dir", str(cache), "--output", str(out2)], env=env) assert r2.exit_code == 0, r2.output def _parse(p: Path) -> list[dict]: @@ -408,6 +497,70 @@ def _hit_rate(fs: list[dict]) -> float: assert _hit_rate(f2) > 0.0 +def test_scan_cache_dir_ignores_unsigned_forged_cache(tmp_path: Path) -> None: + import json + + from wardline.core.config import WardlineConfig + from wardline.core.ruleset import ruleset_hash + from wardline.core.taints import TaintState as T + from wardline.scanner.taint.decorator_provider import DecoratorTaintSourceProvider + from wardline.scanner.taint.project_resolver import _RESOLVER_VERSION + from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION, FunctionSummary, compute_cache_key + from wardline.scanner.taint.summary_cache import _serialise_summary + + proj = tmp_path / "proj" + proj.mkdir() + source = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" + "@trusted(level='ASSURED')\n" + "def sink(x):\n" + " return x\n" + "def f(p):\n" + " return sink(read_raw(p))\n" + ) + (proj / "m.py").write_text(source, encoding="utf-8") + + cache = tmp_path / "repo-controlled-cache" + cache.mkdir() + key = compute_cache_key( + module_path="m", + source_bytes=source.encode("utf-8"), + schema_version=SUMMARY_SCHEMA_VERSION, + resolver_version=_RESOLVER_VERSION, + provider_fingerprint=DecoratorTaintSourceProvider().fingerprint(), + scan_policy_hash=ruleset_hash(WardlineConfig()), + ) + forged = tuple( + FunctionSummary( + fqn=fqn, + body_taint=T.INTEGRAL, + return_taint=T.INTEGRAL, + taint_source="anchored", + unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, + cache_key=key, + ) + for fqn in ("m.read_raw", "m.sink", "m.f") + ) + (cache / f"{key}.json").write_text(json.dumps([_serialise_summary(s) for s in forged]), encoding="utf-8") + + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke( + scan, + [str(proj), "--cache-dir", str(cache), "--output", str(out)], + env={"WARDLINE_SUMMARY_CACHE_KEY": "unit-test-summary-cache-key"}, + ) + + assert result.exit_code == 0, result.output + findings = [_json.loads(line) for line in out.read_text().splitlines() if line.strip()] + metrics = next(f for f in findings if f["rule_id"] == "WLN-ENGINE-METRICS") + assert metrics["properties"]["cache_hit_rate"] == 0.0 + assert any(f["rule_id"] == "PY-WL-101" for f in findings) + + def _write(project, name, src): p = project / name p.write_text(src, encoding="utf-8") @@ -453,7 +606,9 @@ def test_scan_baseline_annotates_but_does_not_clear_gate(tmp_path) -> None: bl = baseline_path(proj) bl.parent.mkdir(parents=True, exist_ok=True) bl.write_text( - "version: 1\nentries:\n - fingerprint: " + fp + "\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\nentries:\n - fingerprint: " + + fp + + "\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", encoding="utf-8", ) # SECURITY default: the defect is baselined for REPORTING (annotated), but the @@ -462,7 +617,7 @@ def test_scan_baseline_annotates_but_does_not_clear_gate(tmp_path) -> None: assert res.exit_code == 1, res.output findings2 = [_json.loads(ln) for ln in out.read_text().splitlines() if ln.strip()] leak = next(f for f in findings2 if f["rule_id"] == "PY-WL-101") - assert leak["suppressed"] == "baselined" # annotate-and-keep + assert leak["suppression_state"] == "baselined" # annotate-and-keep assert "1 suppressed" in res.output @@ -478,7 +633,9 @@ def test_scan_baseline_clears_gate_with_trust_suppressions(tmp_path) -> None: bl = baseline_path(proj) bl.parent.mkdir(parents=True, exist_ok=True) bl.write_text( - "version: 1\nentries:\n - fingerprint: " + fp + "\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\nentries:\n - fingerprint: " + + fp + + "\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", encoding="utf-8", ) res = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--fail-on", "ERROR", "--trust-suppressions"]) @@ -529,6 +686,7 @@ def test_scan_benign_no_module_is_quiet(tmp_path) -> None: proj = tmp_path / "proj" proj.mkdir() + (proj / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") _write(proj, "__init__.py", "VERSION = 1\n") _write(proj, "mod.py", "def g(): return 1\n") out = tmp_path / "f.jsonl" @@ -712,6 +870,7 @@ def test_scan_relative_root_emits_relative_path_and_qualname(tmp_path) -> None: def test_scan_filigree_emit_success(tmp_path, monkeypatch) -> None: proj = tmp_path / "proj" proj.mkdir() + (proj / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") _write(proj, "svc.py", _LEAKY) captured: dict[str, object] = {} @@ -735,26 +894,63 @@ def emit(self, findings, *, scanned_paths=()): assert captured["url"] == "http://x/api/weft/scan-results" assert captured["scanned_paths"] == ("svc.py",) assert "emitted" in result.output and "warning" not in result.output # stats surfaced, no warning + assert "unscoped endpoint" in result.output + assert "server-default project" not in result.output -def test_scan_filigree_protocol_error_exits_2(tmp_path, monkeypatch) -> None: +def test_scan_filigree_protocol_error_does_not_preempt_gate(tmp_path, monkeypatch) -> None: proj = tmp_path / "proj" proj.mkdir() _write(proj, "svc.py", _LEAKY) - from wardline.core.errors import FiligreeEmitError + captured: dict[str, object] = {} - class _BadEmitter: + class _RejectedEmitter: def __init__(self, url, **kw): - pass + captured["url"] = url + captured["kwargs"] = kw def emit(self, findings, *, scanned_paths=()): - raise FiligreeEmitError("Filigree rejected (400): bad path") + from wardline.core.filigree_emit import EmitResult, FailedFinding - monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _BadEmitter) + return EmitResult( + reachable=True, + failures=tuple(FailedFinding(reason="partial", detail="payload too large (400)") for _ in findings), + warnings=("Filigree rejected scan-results (400): payload too large",), + ) + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _RejectedEmitter) out = tmp_path / "f.jsonl" - result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--filigree-url", "http://x"]) - assert result.exit_code == 2, result.output - assert "bad path" in result.output + result = CliRunner().invoke( + scan, + [str(proj), "--output", str(out), "--filigree-url", "http://x", "--fail-on", "ERROR"], + ) + assert result.exit_code == 1, result.output + assert captured["url"] == "http://x" + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["protocol_errors_loud"] is False + assert "Filigree rejected scan-results (400)" in result.output + assert "gate: FAILED" in result.output + + +def test_scan_local_only_suppresses_resolved_emit_urls(tmp_path, monkeypatch) -> None: + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://x/api/weft/scan-results") + monkeypatch.setenv("WARDLINE_LOOMWEAVE_URL", "http://loom/api/wardline/taint-facts") + + class _UnexpectedEmitter: + def __init__(self, *args, **kwargs): + raise AssertionError("local-only must not construct FiligreeEmitter") + + monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _UnexpectedEmitter) + out = tmp_path / "f.jsonl" + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--local-only"]) + + assert result.exit_code == 0, result.output + assert "emitted" not in result.output + assert "Filigree" not in result.output def test_scan_filigree_absent_continues(tmp_path, monkeypatch) -> None: @@ -896,6 +1092,28 @@ def test_scan_loomweave_soft_outage_does_not_change_exit(tmp_path, monkeypatch) assert "scan unaffected" in result.output +def test_scan_loomweave_soft_outage_redacts_url_secrets(tmp_path, monkeypatch) -> None: + from wardline.loomweave.client import WriteResult + + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + monkeypatch.setattr( + "wardline.loomweave.write.write_facts_to_loomweave", + lambda *a, **k: WriteResult(reachable=False, disabled_reason="connection refused"), + ) + out = tmp_path / "f.jsonl" + secret_url = "https://user:secret@loomweave.example/api/taint?token=abc#frag" + + result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--loomweave-url", secret_url]) + + assert result.exit_code == 0, result.output + assert "https://@loomweave.example/api/taint" in result.output + assert "user:secret" not in result.output + assert "token=abc" not in result.output + assert "#frag" not in result.output + + def test_scan_reports_filigree_success_and_loomweave_unreachable_independently(tmp_path, monkeypatch) -> None: from wardline.loomweave.client import WriteResult @@ -1409,9 +1627,15 @@ def __init__(self, url, **kw): pass def emit(self, findings, *, scanned_paths=()): - from wardline.core.filigree_emit import EmitResult + from wardline.core.filigree_emit import EmitResult, FailedFinding - return EmitResult(reachable=True, created=0, updated=0, failed=1, warnings=("w1", "w2")) + return EmitResult( + reachable=True, + created=0, + updated=0, + failures=(FailedFinding(reason="rejected", fingerprint="wlfp2:w"),), + warnings=("w1", "w2"), + ) monkeypatch.setattr("wardline.cli.scan.FiligreeEmitter", _WarningFailedEmitter) out = tmp_path / "f.jsonl" @@ -1436,3 +1660,60 @@ def test_scan_loomweave_with_unresolved_qualnames(tmp_path, monkeypatch) -> None assert result.exit_code == 0, result.output assert "wrote 1 taint fact(s)" in result.output assert "unresolved" in result.output + + +# --- N-3 (wardline-8669de3576): subdirectory scans warn loudly --------------- + + +def test_scan_subdirectory_of_weft_project_warns(tmp_path: Path) -> None: + # Scanning a subdirectory of a weft project mints scan-relative qualnames, + # skips the project baseline, and writes output into the subdir. The CLI must + # be LOUD about it (stderr warning sourced from the WLN-ENGINE-NESTED-SCAN-ROOT + # fact) while the scan itself still succeeds. + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + sub = proj / "specimen" + sub.mkdir() + (sub / "m.py").write_text("def f(): return 1\n", encoding="utf-8") + result = CliRunner().invoke(cli, ["scan", str(sub)]) + assert result.exit_code == 0, result.output + assert "warning:" in result.stderr + assert "qualname" in result.stderr + assert str(proj.resolve()) in result.stderr + + +def test_scan_project_root_does_not_warn_nested(tmp_path: Path) -> None: + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + (proj / "m.py").write_text("def f(): return 1\n", encoding="utf-8") + result = CliRunner().invoke(cli, ["scan", str(proj)]) + assert result.exit_code == 0, result.output + assert "WLN-ENGINE-NESTED-SCAN-ROOT" not in result.stderr + assert "subdirectory" not in result.stderr + + +def test_scan_help_documents_scan_root_qualname_coupling() -> None: + result = CliRunner().invoke(cli, ["scan", "--help"]) + assert result.exit_code == 0 + helptext = result.output.lower() + assert "qualname" in helptext + assert "scan root" in helptext + + +def test_dossier_help_documents_scan_root_qualname_coupling() -> None: + result = CliRunner().invoke(cli, ["dossier", "--help"]) + assert result.exit_code == 0 + helptext = result.output.lower() + assert "scan root" in helptext or "project root" in helptext + + +def test_scan_fail_on_accepts_lowercase(tmp_path: Path) -> None: + # N-5 (wardline-dc6f44707d): --fail-on was uppercase-only; an agent carrying + # filigree's lowercase habit got a usage error. Case-insensitive now; the + # canonical (uppercase) form is what the gate output echoes back. + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + result = CliRunner().invoke(cli, ["scan", str(proj), "--fail-on", "error"]) + assert result.exit_code == 1, result.output + assert "--fail-on ERROR" in result.stderr diff --git a/tests/unit/cli/test_doctor.py b/tests/unit/cli/test_doctor.py index 17e03e25..f927ae06 100644 --- a/tests/unit/cli/test_doctor.py +++ b/tests/unit/cli/test_doctor.py @@ -38,13 +38,17 @@ def test_doctor_repair_installs_artifacts_and_discovers_bindings(tmp_path: Path, assert "CLAUDE.md: repaired" in result.output assert ".mcp.json: repaired" in result.output assert "Codex MCP: repaired" in result.output + assert "weft.toml: created" in result.output # Bindings are no longer wired into config — repair only DETECTS siblings and - # ensures the .weft/wardline/ state dir exists. No config file is written. + # ensures the .weft/wardline/ state dir exists. Doctor now creates Wardline's + # bounded local policy when it is missing. assert "bindings: detected" in result.output assert (tmp_path / ".mcp.json").is_file() assert (home / ".codex" / "config.toml").is_file() assert (tmp_path / ".weft" / "wardline").is_dir() - assert not (tmp_path / "weft.toml").exists() + cfg = (tmp_path / "weft.toml").read_text(encoding="utf-8") + assert 'source_roots = ["."]' in cfg + assert '".uv-cache/**"' in cfg def test_doctor_passes_after_repair(tmp_path: Path, monkeypatch) -> None: @@ -97,6 +101,10 @@ def test_doctor_fix_emits_shared_machine_readable_shape(tmp_path: Path, monkeypa assert checks[check_id]["status"] == "ok" assert isinstance(checks[check_id]["fixed"], bool) assert checks["mcp.registration"]["fixed"] is True + assert checks["wardline.config"]["fixed"] is True + cfg = (tmp_path / "weft.toml").read_text(encoding="utf-8") + assert 'source_roots = ["."]' in cfg + assert '"telemetry/**"' in cfg def test_doctor_reports_present_but_broken_weft_toml_as_error(tmp_path: Path, monkeypatch) -> None: @@ -125,7 +133,7 @@ def test_doctor_fix_reports_filigree_url_ok_from_env(tmp_path: Path, monkeypatch # The "upgrade commented binding when a port appears" feature was removed: doctor # no longer writes config and the filigree.url check is now ENV-ONLY (a published # port is a scan-time discovery concern, not a doctor concern). When the env var - # is set to a valid URL, the check is ok; doctor writes no config file. + # is set to a valid URL, the check is ok. home = tmp_path / "home" monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) monkeypatch.delenv("WARDLINE_LOOMWEAVE_TOKEN", raising=False) @@ -141,4 +149,74 @@ def test_doctor_fix_reports_filigree_url_ok_from_env(tmp_path: Path, monkeypatch payload = json.loads(result.output) checks = {check["id"]: check for check in payload["checks"]} assert checks["filigree.url"]["status"] == "ok" - assert not (tmp_path / "weft.toml").exists() + assert (tmp_path / "weft.toml").exists() + + +def test_doctor_repair_creates_src_bounded_weft_toml(tmp_path: Path, monkeypatch) -> None: + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + (tmp_path / "src").mkdir() + + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + + assert result.exit_code == 0, result.output + cfg = (tmp_path / "weft.toml").read_text(encoding="utf-8") + assert 'source_roots = ["src"]' in cfg + assert '"telemetry/**"' in cfg + + +def test_doctor_reports_missing_weft_toml_without_repair(tmp_path: Path, monkeypatch) -> None: + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + repair = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert repair.exit_code == 0, repair.output + (tmp_path / "weft.toml").unlink() + + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path)]) + + assert result.exit_code == 1 + assert "weft.toml: missing weft.toml" in result.output + + +def test_doctor_accepts_filigree_url_flag_and_reports_not_configured(tmp_path: Path, monkeypatch) -> None: + # No filigree wiring (no .mcp.json arg, no env, no port) => filigree.auth is ok/not-configured, + # so doctor does no network and the new flag is accepted. + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + repair = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--repair"]) + assert repair.exit_code == 0, repair.output + + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "filigree.auth" in result.output + + +def test_doctor_fix_json_includes_filigree_auth_check(tmp_path: Path, monkeypatch) -> None: + import json as _json + + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + + result = CliRunner().invoke(cli, ["doctor", "--root", str(tmp_path), "--fix"]) + payload = _json.loads(result.output) + ids = [c["id"] for c in payload["checks"]] + assert "filigree.auth" in ids diff --git a/tests/unit/cli/test_explain_taint_cmd.py b/tests/unit/cli/test_explain_taint_cmd.py new file mode 100644 index 00000000..04ad289f --- /dev/null +++ b/tests/unit/cli/test_explain_taint_cmd.py @@ -0,0 +1,74 @@ +# tests/unit/cli/test_explain_taint_cmd.py +"""N-2 (wardline-0be02bf8e6): `wardline explain-taint` — the CLI twin of the MCP +`explain_taint` tool, so a CLI-only agent does not dead-end at step 2 of the +scan -> explain -> fix -> rescan loop. Thin wrapper over the same core builder +(`core.explain.explain_taint_result`) the MCP handler uses — identical by +construction.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from wardline.cli.main import cli +from wardline.core.run import run_scan + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + + +def _leaky_proj(tmp_path: Path) -> tuple[Path, str]: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_LEAKY, encoding="utf-8") + fp = next(f for f in run_scan(proj).findings if f.rule_id == "PY-WL-101").fingerprint + return proj, fp + + +def test_explain_taint_by_fingerprint(tmp_path: Path) -> None: + proj, fp = _leaky_proj(tmp_path) + res = CliRunner().invoke(cli, ["explain-taint", fp, str(proj)]) + assert res.exit_code == 0, res.output + payload = json.loads(res.output) + assert payload["fingerprint"] == fp + assert payload["rule_id"] == "PY-WL-101" + assert payload["sink_qualname"] == "svc.leaky" + assert payload["immediate_tainted_callee"] == "read_raw" + # the remediation hint rides along, exactly as the MCP result carries it + assert payload["remediation"]["kind"] == "boundary_placement" + + +def test_explain_taint_matches_mcp_result_shape(tmp_path: Path) -> None: + # Identical-by-construction pin: the CLI JSON equals the MCP handler's result + # dict for the same finding (no chain, no loomweave). + from wardline.mcp.server import _explain_taint + + proj, fp = _leaky_proj(tmp_path) + mcp_result = _explain_taint({"fingerprint": fp}, proj) + res = CliRunner().invoke(cli, ["explain-taint", fp, str(proj)]) + assert res.exit_code == 0, res.output + assert json.loads(res.output) == mcp_result + + +def test_explain_taint_stale_fingerprint_exits_2(tmp_path: Path) -> None: + proj, _ = _leaky_proj(tmp_path) + res = CliRunner().invoke(cli, ["explain-taint", "0" * 64, str(proj)]) + assert res.exit_code == 2 + err = res.output + res.stderr + assert "re-scan" in err # same actionable message the MCP tool returns + + +def test_explain_taint_listed_in_cli_help() -> None: + res = CliRunner().invoke(cli, ["--help"]) + assert "explain-taint" in res.output + + +def test_explain_taint_help_names_the_loop() -> None: + res = CliRunner().invoke(cli, ["explain-taint", "--help"]) + assert res.exit_code == 0 + assert "fingerprint" in res.output.lower() diff --git a/tests/unit/cli/test_findings_cmd.py b/tests/unit/cli/test_findings_cmd.py index 47922e97..bcbcad45 100644 --- a/tests/unit/cli/test_findings_cmd.py +++ b/tests/unit/cli/test_findings_cmd.py @@ -41,3 +41,46 @@ def test_findings_non_object_where_exits_2(tmp_path): res = CliRunner().invoke(cli, ["findings", str(tmp_path), "--where", "[1,2]"]) assert res.exit_code == 2 assert "JSON object" in res.output + + +# --- N-5 (wardline-dc6f44707d): case-insensitive severity + flat flags -------- + + +def test_findings_lowercase_severity_matches(tmp_path): + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + res = CliRunner().invoke(cli, ["findings", str(tmp_path), "--where", json.dumps({"severity": "error"})]) + assert res.exit_code == 0 + lines = [json.loads(line) for line in res.output.splitlines() if line.strip()] + assert lines and all(d["severity"] == "ERROR" for d in lines) + + +def test_findings_out_of_domain_severity_exits_2_with_vocab(tmp_path): + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + res = CliRunner().invoke(cli, ["findings", str(tmp_path), "--where", json.dumps({"severity": "medium"})]) + assert res.exit_code == 2 + err = res.output + res.stderr + assert "medium" in err and "ERROR" in err # names the offender and the vocabulary + + +def test_findings_flat_flags_filter(tmp_path): + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + res = CliRunner().invoke(cli, ["findings", str(tmp_path), "--rule-id", "PY-WL-101", "--severity", "error"]) + assert res.exit_code == 0, res.output + lines = [json.loads(line) for line in res.output.splitlines() if line.strip()] + assert lines and all(d["rule_id"] == "PY-WL-101" and d["severity"] == "ERROR" for d in lines) + + +def test_findings_flat_flag_conflicts_with_where_key(tmp_path): + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + res = CliRunner().invoke( + cli, + ["findings", str(tmp_path), "--rule-id", "PY-WL-101", "--where", json.dumps({"rule_id": "PY-WL-106"})], + ) + assert res.exit_code == 2 + assert "rule_id" in (res.output + res.stderr) + + +def test_findings_sink_flat_flag_accepted(tmp_path): + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + res = CliRunner().invoke(cli, ["findings", str(tmp_path), "--sink", "subprocess.run"]) + assert res.exit_code == 0, res.output # no sink-family finding here: empty, not an error diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py index 4c76a999..5480c80c 100644 --- a/tests/unit/cli/test_install.py +++ b/tests/unit/cli/test_install.py @@ -17,7 +17,7 @@ def test_scan_resolves_filigree_url_from_published_port(tmp_path: Path, monkeypa captured: dict[str, object] = {} class _FakeEmitter: - def __init__(self, url: str, *, token: str | None = None) -> None: + def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url def emit(self, findings, *, scanned_paths=()): # noqa: ANN001 @@ -40,9 +40,19 @@ def test_mcp_resolves_loomweave_url_from_env(tmp_path: Path, monkeypatch) -> Non captured: dict[str, object] = {} class _FakeServer: - def __init__(self, *, root: Path, loomweave_url: str | None = None, filigree_url: str | None = None) -> None: + def __init__( + self, + *, + root: Path, + loomweave_url: str | None = None, + filigree_url: str | None = None, + allow_write: bool = True, + allow_network: bool = True, + ) -> None: captured["loomweave_url"] = loomweave_url captured["filigree_url"] = filigree_url + captured["allow_write"] = allow_write + captured["allow_network"] = allow_network self.rpc = self def run_stdio(self) -> None: @@ -130,6 +140,28 @@ def test_install_summary_includes_binding_lines(tmp_path: Path, monkeypatch) -> assert "filigree:" in result.output +def test_install_refuses_symlinked_skill_parent_escape(tmp_path: Path, monkeypatch) -> None: + root = tmp_path / "repo" + root.mkdir() + outside = tmp_path / "outside" + outside_skill = outside / "skills" / "wardline-gate" + outside_skill.mkdir(parents=True) + sentinel = outside_skill / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + (root / ".claude").symlink_to(outside, target_is_directory=True) + + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: tmp_path / "home") + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + + result = CliRunner().invoke(cli, ["install", "--root", str(root)]) + + assert result.exit_code == 2 + assert "escapes project root" in result.output + assert sentinel.read_text(encoding="utf-8") == "keep" + assert not (outside_skill / "SKILL.md").exists() + + def test_install_detects_filigree_from_ephemeral_port(tmp_path: Path, monkeypatch) -> None: # The "wire config" feature was removed: install DETECTS the sibling from its # published port and REPORTS it, writing no config file. @@ -179,7 +211,7 @@ def test_install_rerun_detects_filigree_when_port_appears_after_initial_install( captured: dict[str, object] = {} class _FakeEmitter: - def __init__(self, url: str, *, token: str | None = None) -> None: + def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url def emit(self, findings, *, scanned_paths=()): # noqa: ANN001 @@ -206,7 +238,7 @@ def test_scan_threads_filigree_bearer_token_from_env(tmp_path: Path, monkeypatch captured: dict[str, object] = {} class _FakeEmitter: - def __init__(self, url: str, *, token: str | None = None) -> None: + def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url captured["token"] = token @@ -234,7 +266,7 @@ def test_scan_threads_filigree_bearer_token_from_deprecated_env(tmp_path: Path, captured: dict[str, object] = {} class _FakeEmitter: - def __init__(self, url: str, *, token: str | None = None) -> None: + def __init__(self, url: str, *, token: str | None = None, **_kwargs) -> None: captured["url"] = url captured["token"] = token diff --git a/tests/unit/cli/test_install_attest_key.py b/tests/unit/cli/test_install_attest_key.py index f85537b4..01eef804 100644 --- a/tests/unit/cli/test_install_attest_key.py +++ b/tests/unit/cli/test_install_attest_key.py @@ -3,6 +3,7 @@ from __future__ import annotations +import subprocess from pathlib import Path from click.testing import CliRunner @@ -20,6 +21,10 @@ ] +def _git(args: list[str], cwd: Path) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) + + def test_install_mints_attest_key(tmp_path: Path, monkeypatch) -> None: """First run: key is minted, .env created, .gitignore contains .env.""" monkeypatch.delenv("WARDLINE_ATTEST_KEY", raising=False) @@ -69,3 +74,23 @@ def test_install_attest_key_idempotent(tmp_path: Path, monkeypatch) -> None: second = runner.invoke(cli, args) assert second.exit_code == 0, second.output assert "attest key: present" in second.output + + +def test_install_refuses_to_mint_into_tracked_dotenv(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_ATTEST_KEY", raising=False) + _git(["init"], tmp_path) + _git(["config", "user.email", "test@example.com"], tmp_path) + _git(["config", "user.name", "Test"], tmp_path) + dotenv = tmp_path / ".env" + dotenv.write_text("EXISTING=1\n", encoding="utf-8") + _git(["add", ".env"], tmp_path) + _git(["commit", "-m", "track env"], tmp_path) + + result = CliRunner().invoke( + cli, + ["install", "--root", str(tmp_path), *_SKIP_ALL_OTHER], + ) + + assert result.exit_code == 2 + assert "tracked .env" in result.stderr + assert dotenv.read_text(encoding="utf-8") == "EXISTING=1\n" diff --git a/tests/unit/cli/test_mcp_cli.py b/tests/unit/cli/test_mcp_cli.py index 382449dc..04fbf50e 100644 --- a/tests/unit/cli/test_mcp_cli.py +++ b/tests/unit/cli/test_mcp_cli.py @@ -38,6 +38,67 @@ def test_mcp_command_is_registered() -> None: result = CliRunner().invoke(cli, ["mcp", "--help"]) assert result.exit_code == 0 assert "stdio" in result.output.lower() or "mcp" in result.output.lower() + assert "--read-only" in result.output + assert "--no-network" in result.output + + +def test_mcp_command_passes_policy_flags(tmp_path, monkeypatch) -> None: + from click.testing import CliRunner + + import wardline.cli.mcp as mcp_cli + from wardline.cli.main import cli + + captured: dict[str, object] = {} + + class FakeRpc: + def run_stdio(self) -> None: + captured["ran"] = True + + class FakeServer: + def __init__( + self, + *, + root, + loomweave_url=None, + filigree_url=None, + allow_write=True, + allow_network=True, + ) -> None: + captured.update( + { + "root": root, + "loomweave_url": loomweave_url, + "filigree_url": filigree_url, + "allow_write": allow_write, + "allow_network": allow_network, + } + ) + self.rpc = FakeRpc() + + monkeypatch.setattr(mcp_cli, "WardlineMCPServer", FakeServer) + + result = CliRunner().invoke( + cli, + [ + "mcp", + "--root", + str(tmp_path), + "--loomweave-url", + "http://localhost:9100", + "--filigree-url", + "http://localhost:8628/api/weft/scan-results", + "--read-only", + "--no-network", + ], + ) + + assert result.exit_code == 0, result.output + assert captured["root"] == tmp_path + assert captured["loomweave_url"] == "http://localhost:9100" + assert captured["filigree_url"] == "http://localhost:8628/api/weft/scan-results" + assert captured["allow_write"] is False + assert captured["allow_network"] is False + assert captured["ran"] is True def test_mcp_command_runs_stdio_end_to_end(tmp_path) -> None: diff --git a/tests/unit/cli/test_rekey_cli.py b/tests/unit/cli/test_rekey_cli.py new file mode 100644 index 00000000..045ef57d --- /dev/null +++ b/tests/unit/cli/test_rekey_cli.py @@ -0,0 +1,174 @@ +"""P4 S11 — `wardline rekey` end to end over a copied tree: migrate / probe / resume / rollback.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") +pytest.importorskip("blake3", reason="run_scan needs wardline[loomweave]") + +from click.testing import CliRunner # noqa: E402 + +from wardline.cli.main import cli # noqa: E402 +from wardline.core import paths # noqa: E402 +from wardline.core.baseline import load_baseline # noqa: E402 +from wardline.core.fingerprint_v0 import compute_finding_fingerprint_v0 # noqa: E402 +from wardline.core.rekey import load_journal, snapshot_dir, write_journal # noqa: E402 +from wardline.core.run import run_scan # noqa: E402 + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return raw(p)\n" +) + + +def _project(tmp_path: Path) -> Path: + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text(_LEAKY, encoding="utf-8") + return project + + +def _seed_wlfp1_baseline(project: Path): + """Seed an OLD-scheme baseline whose entry is the real wlfp1 fingerprint of the + leaky PY-WL-101 finding (so the migration genuinely carries it to its new_fp).""" + leak = next(f for f in run_scan(project).findings if f.rule_id == "PY-WL-101") + old_fp = compute_finding_fingerprint_v0( + rule_id=leak.rule_id, + path=leak.location.path, + line_start=leak.location.line_start, + qualname=leak.qualname, + taint_path=leak.taint_path_v0, + ) + bp = paths.baseline_path(project) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "entries": [ + { + "fingerprint": old_fp, + "rule_id": leak.rule_id, + "path": leak.location.path, + "message": leak.message, + } + ], + } + ), + encoding="utf-8", + ) + return leak, old_fp + + +def test_rekey_migrates_baseline_and_is_resumable_without_rescan(tmp_path: Path) -> None: + project = _project(tmp_path) + leak, old_fp = _seed_wlfp1_baseline(project) + assert leak.fingerprint != old_fp + + res = CliRunner().invoke(cli, ["rekey", str(project)]) + assert res.exit_code == 0, res.output + assert load_baseline(paths.baseline_path(project)).fingerprints == frozenset({leak.fingerprint}) + assert paths.migration_journal_path(project).is_file() + assert (snapshot_dir(project) / "baseline.yaml").is_file() + + # RESUME WITHOUT RE-SCAN: revert the baseline leg to pending + corrupt the live store, + # then DELETE the source so any re-scan would fail — resume must still re-derive the + # verdict from the snapshot. + jpath = paths.migration_journal_path(project) + journal = load_journal(jpath) + journal.leg("baseline").done = False + write_journal(jpath, journal, root=project) + paths.baseline_path(project).write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp2", "version": 1, "entries": []}), encoding="utf-8" + ) + (project / "svc.py").unlink() # a re-scan would now find nothing / error + + res2 = CliRunner().invoke(cli, ["rekey", str(project), "--resume"]) + assert res2.exit_code == 0, res2.output + assert load_baseline(paths.baseline_path(project)).fingerprints == frozenset({leak.fingerprint}) + assert "predates" not in res2.output # a schemed wlfp1 store must NOT trip the prescheme caution + + +def test_rekey_probe_writes_nothing(tmp_path: Path) -> None: + project = _project(tmp_path) + _seed_wlfp1_baseline(project) + res = CliRunner().invoke(cli, ["rekey", str(project), "--probe"]) + assert res.exit_code == 0, res.output + assert "will carry" in res.output + assert "predates" not in res.output # a schemed wlfp1 store must NOT trip the prescheme caution + assert not paths.migration_journal_path(project).exists() + assert not snapshot_dir(project).exists() + + +def test_rekey_rollback_restores(tmp_path: Path) -> None: + project = _project(tmp_path) + _seed_wlfp1_baseline(project) + original = paths.baseline_path(project).read_bytes() + assert CliRunner().invoke(cli, ["rekey", str(project)]).exit_code == 0 + assert paths.baseline_path(project).read_bytes() != original # migrated + + res = CliRunner().invoke(cli, ["rekey", str(project), "--rollback"]) + assert res.exit_code == 0, res.output + assert paths.baseline_path(project).read_bytes() == original # byte-identical restore + assert not paths.migration_journal_path(project).exists() + + +def test_probe_cautions_on_a_scheme_less_store(tmp_path: Path) -> None: + # A scheme-less (pre-P1) baseline -> the probe must caution that a high orphan rate may + # be a fingerprint-formula change, not source churn, AND the orphan label must name the + # custom-rule cause (not only "source moved/deleted"). + project = _project(tmp_path) + bp = paths.baseline_path(project) + bp.parent.mkdir(parents=True, exist_ok=True) + # NO fingerprint_scheme header; one entry that no current finding matches -> an orphan. + bp.write_text( + yaml.safe_dump( + { + "version": 1, + "entries": [{"fingerprint": "a" * 64, "rule_id": "PY-WL-101", "path": "svc.py", "message": "x"}], + } + ), + encoding="utf-8", + ) + res = CliRunner().invoke(cli, ["rekey", str(project), "--probe"]) + assert res.exit_code == 1, res.output # an orphan present -> non-clean probe + assert "predates" in res.output # the pre-scheme caution fired + assert "taint_path_v0" in res.output # the orphan label names the custom-rule cause + + +def test_rekey_on_scheme_less_baseline_sets_flag_and_cautions_on_resume(tmp_path: Path) -> None: + # End-to-end snapshot-detection path (distinct from --probe, which reads LIVE stores): + # a scheme-less (pre-P1) baseline whose fingerprints DO reconstruct still carries, but the + # forward run must FLAG it from the SNAPSHOT (journal.snapshot_prescheme) and caution, and + # --resume must re-print that caution from the persisted journal. + project = _project(tmp_path) + leak, _ = _seed_wlfp1_baseline(project) + bp = paths.baseline_path(project) + doc = yaml.safe_load(bp.read_text(encoding="utf-8")) + doc.pop("fingerprint_scheme", None) # strip the header -> scheme-less / pre-P1 + bp.write_text(yaml.safe_dump(doc), encoding="utf-8") + + res = CliRunner().invoke(cli, ["rekey", str(project)]) + assert res.exit_code == 0, res.output + assert "predates" in res.output # forward run cautions (flag read from the snapshot) + jpath = paths.migration_journal_path(project) + assert load_journal(jpath).snapshot_prescheme is True # persisted onto the journal + assert load_baseline(bp).fingerprints == frozenset({leak.fingerprint}) # still carried + + journal = load_journal(jpath) + journal.leg("baseline").done = False + write_journal(jpath, journal, root=project) + res2 = CliRunner().invoke(cli, ["rekey", str(project), "--resume"]) + assert res2.exit_code == 0, res2.output + assert "predates" in res2.output # --resume re-prints the caution from the loaded journal + + +def test_mutually_exclusive_flags(tmp_path: Path) -> None: + res = CliRunner().invoke(cli, ["rekey", str(_project(tmp_path)), "--probe", "--rollback"]) + assert res.exit_code == 2 + assert "mutually exclusive" in res.output diff --git a/tests/unit/cli/test_scan_file_findings_cmd.py b/tests/unit/cli/test_scan_file_findings_cmd.py index 6325f85e..0a6bc89d 100644 --- a/tests/unit/cli/test_scan_file_findings_cmd.py +++ b/tests/unit/cli/test_scan_file_findings_cmd.py @@ -8,6 +8,16 @@ from wardline.cli.main import cli +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" + "@trusted\n" + "def leaky(p):\n" + " return read_raw(p)\n" +) + def test_scan_file_findings_cli_defaults_to_dry_run(tmp_path, monkeypatch): from wardline.cli import scan_file_findings as mod @@ -56,3 +66,14 @@ def __init__(self, url, *, secret, project): assert seen["dry_run"] is False assert seen["filigree_emitter"] == ("emitter", "http://f/api/weft/scan-results") assert isinstance(seen["loomweave_client"], FakeLoomweave) + + +def test_scan_file_findings_cli_fail_on_uses_gate_exit_class(tmp_path): + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + + res = CliRunner().invoke(cli, ["scan-file-findings", str(tmp_path), "--fail-on", "ERROR"]) + + assert res.exit_code == 1 + payload = json.loads(res.output) + assert payload["gate"]["tripped"] is True + assert payload["gate"]["exit_class"] == 1 diff --git a/tests/unit/cli/test_scan_job.py b/tests/unit/cli/test_scan_job.py new file mode 100644 index 00000000..83f4d703 --- /dev/null +++ b/tests/unit/cli/test_scan_job.py @@ -0,0 +1,255 @@ +import json +import signal +import time +from datetime import UTC, datetime +from pathlib import Path + +from click.testing import CliRunner + +from wardline.cli.main import cli +from wardline.core.filigree_emit import EmitResult +from wardline.core.scan_jobs import DEFAULT_SCAN_JOB_TIMEOUT_SECONDS + +_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 _write(project: Path, name: str, src: str) -> Path: + path = project / name + path.write_text(src, encoding="utf-8") + return path + + +def _write_job_status(project: Path, job_id: str, payload: dict[str, object]) -> None: + job_dir = project / ".weft" / "wardline" / "jobs" / job_id + job_dir.mkdir(parents=True) + (job_dir / "status.json").write_text(json.dumps(payload), encoding="utf-8") + + +def _base_job(job_id: str) -> dict[str, object]: + now = datetime.now(UTC).isoformat().replace("+00:00", "Z") + return { + "job_id": job_id, + "status": "running", + "phase": "scanning", + "pid": 12345, + "progress": {"steps_completed": 1, "steps_total": 4}, + "created_at": now, + "updated_at": now, + "heartbeat": now, + "request": {}, + "artifacts": {}, + "failure_kind": None, + "error": None, + } + + +def test_scan_job_start_foreground_completes_and_status_is_pollable(tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + _write(project, "svc.py", "def ok():\n return 1\n") + + result = CliRunner().invoke(cli, ["scan-job", "start", str(project), "--foreground"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "completed" + assert payload["phase"] == "complete" + assert payload["progress"]["steps_completed"] == payload["progress"]["steps_total"] == 4 + assert payload["heartbeat"] + assert Path(payload["artifacts"]["findings"]).exists() + + status = CliRunner().invoke(cli, ["scan-job", "status", payload["job_id"], "--path", str(project)]) + assert status.exit_code == 0, status.output + assert json.loads(status.output)["job_id"] == payload["job_id"] + + +def test_scan_job_gate_failure_is_terminal_gate_status(tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + _write(project, "svc.py", _LEAKY) + + result = CliRunner().invoke(cli, ["scan-job", "start", str(project), "--fail-on", "ERROR", "--foreground"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "failed" + assert payload["failure_kind"] == "gate" + assert payload["gate"]["verdict"] == "FAILED" + assert Path(payload["artifacts"]["findings"]).exists() + + +def test_scan_job_enrichment_failure_keeps_scan_artifact(tmp_path: Path, monkeypatch) -> None: + project = tmp_path / "proj" + project.mkdir() + _write(project, "svc.py", "def ok():\n return 1\n") + + class _UnavailableEmitter: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def emit(self, findings: object, *, scanned_paths: object = ()) -> EmitResult: + return EmitResult(reachable=False, url="http://x/api/weft/scan-results") + + monkeypatch.setattr("wardline.core.scan_jobs.FiligreeEmitter", _UnavailableEmitter) + + result = CliRunner().invoke( + cli, + [ + "scan-job", + "start", + str(project), + "--filigree-url", + "http://x/api/weft/scan-results", + "--foreground", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "completed_with_enrichment_failure" + assert payload["failure_kind"] == "enrichment" + assert payload["filigree_emit"]["disabled_reason"] == "filigree unreachable at http://x/api/weft/scan-results" + assert Path(payload["artifacts"]["findings"]).exists() + + +def test_scan_job_status_marks_dead_worker_failed(tmp_path: Path, monkeypatch) -> None: + job_id = "a" * 32 + project = tmp_path / "proj" + project.mkdir() + _write_job_status(project, job_id, _base_job(job_id)) + monkeypatch.setattr("wardline.core.scan_jobs._pid_alive", lambda pid: False) + + result = CliRunner().invoke(cli, ["scan-job", "status", job_id, "--path", str(project)]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "failed" + assert payload["failure_kind"] == "stale_worker" + assert "12345" in payload["error"] + + persisted = json.loads((project / ".weft" / "wardline" / "jobs" / job_id / "status.json").read_text()) + assert persisted["status"] == "failed" + assert persisted["failure_kind"] == "stale_worker" + + +def test_scan_job_agent_summary_artifact_matches_terminal_status(tmp_path: Path, monkeypatch) -> None: + # Honesty: the agent-summary ARTIFACT must carry the same gate (honoring + # fail_on_unanalyzed) and filigree_emit block as the terminal job status — not an + # unanalyzed-blind, pre-enrichment `configured: false` snapshot. + project = tmp_path / "proj" + project.mkdir() + _write(project, "ok.py", "def ok():\n return 1\n") + _write(project, "broken.py", "def f(:\n") # parse error -> an unanalyzed file + + class _OkEmitter: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def emit(self, findings: object, *, scanned_paths: object = ()) -> EmitResult: + return EmitResult(reachable=True, created=1, url="http://x/api/weft/scan-results") + + monkeypatch.setattr("wardline.core.scan_jobs.FiligreeEmitter", _OkEmitter) + + result = CliRunner().invoke( + cli, + [ + "scan-job", + "start", + str(project), + "--format", + "agent-summary", + "--fail-on-unanalyzed", + "--filigree-url", + "http://x/api/weft/scan-results", + "--foreground", + ], + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + artifact = json.loads(Path(payload["artifacts"]["findings"]).read_text()) + + # the ARTIFACT gate honors fail_on_unanalyzed (tripped on the unanalyzed file), + # matching the terminal status — not the unanalyzed-blind snapshot it used to write. + assert artifact["summary"]["unanalyzed"] == 1 + assert artifact["gate"]["tripped"] is True == payload["gate"]["tripped"] + assert "fail_on_unanalyzed" in artifact["gate"]["reason"] + # integrations.filigree_emit reflects the REAL emit (configured + reachable), not configured:false + emit_block = artifact["integrations"]["filigree_emit"] + assert emit_block["configured"] is True + assert emit_block["reachable"] is True + + +def test_scan_job_cancel_signals_process_group_and_persists_terminal_status(tmp_path: Path, monkeypatch) -> None: + job_id = "b" * 32 + project = tmp_path / "proj" + project.mkdir() + _write_job_status(project, job_id, _base_job(job_id)) + sent: list[tuple[int, int]] = [] + monkeypatch.setattr("wardline.core.scan_jobs._pid_alive", lambda pid: True) + # Treat the recorded pid as a validated worker for this path; the rejection of a + # forged/non-worker pid is covered by + # tests/unit/security/test_symlink_toctou_hardening.py::test_cancel_does_not_signal_forged_pid. + monkeypatch.setattr("wardline.core.scan_jobs._pid_is_scan_job_worker", lambda pid, job_id: True) + monkeypatch.setattr("wardline.core.scan_jobs.os.killpg", lambda pid, sig: sent.append((pid, sig))) + + result = CliRunner().invoke(cli, ["scan-job", "cancel", job_id, "--path", str(project)]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "cancelled" + assert payload["phase"] == "cancelled" + assert payload["failure_kind"] == "cancelled" + assert sent == [(12345, signal.SIGTERM)] + + persisted = json.loads((project / ".weft" / "wardline" / "jobs" / job_id / "status.json").read_text()) + assert persisted["status"] == "cancelled" + + +def test_scan_job_timeout_is_terminal_timeout_status(tmp_path: Path, monkeypatch) -> None: + project = tmp_path / "proj" + project.mkdir() + _write(project, "svc.py", "def ok():\n return 1\n") + + def slow_scan(*args: object, **kwargs: object) -> object: + time.sleep(0.2) + raise AssertionError("timeout should interrupt run_scan before it returns") + + monkeypatch.setattr("wardline.core.scan_jobs.run_scan", slow_scan) + + result = CliRunner().invoke(cli, ["scan-job", "start", str(project), "--timeout", "0.01", "--foreground"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "failed" + assert payload["failure_kind"] == "timeout" + assert "timed out" in payload["error"] + + +def test_scan_job_start_applies_default_timeout(tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + _write(project, "svc.py", "def ok():\n return 1\n") + + result = CliRunner().invoke(cli, ["scan-job", "start", str(project), "--foreground"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "completed" + assert payload["request"]["timeout_seconds"] == DEFAULT_SCAN_JOB_TIMEOUT_SECONDS + + +def test_scan_job_start_allows_timeout_opt_out(tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + _write(project, "svc.py", "def ok():\n return 1\n") + + result = CliRunner().invoke(cli, ["scan-job", "start", str(project), "--timeout", "0", "--foreground"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "completed" + assert payload["request"]["timeout_seconds"] == 0.0 diff --git a/tests/unit/cli/test_scan_rust.py b/tests/unit/cli/test_scan_rust.py new file mode 100644 index 00000000..fe63a9dc --- /dev/null +++ b/tests/unit/cli/test_scan_rust.py @@ -0,0 +1,76 @@ +"""WP6: ``wardline scan --lang rust`` end-to-end through the CLI. + +Drives the real Click command over a tmp ``.rs`` tree: the gate fires (exit 1) on an +``RS-WL-108`` injection, a clean tree exits 0, the posture banner prints to stderr, and a +malformed file is surfaced via ``--fail-on-unanalyzed``. +""" + +from __future__ import annotations + +import json + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from click.testing import CliRunner # noqa: E402 + +from wardline.cli.scan import scan # noqa: E402 + +_TRUSTED = "/// @trusted(level=ASSURED)\n" +_INJECTION = _TRUSTED + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + + +def test_scan_lang_rust_gate_trips_on_injection(tmp_path) -> None: + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke(scan, [str(tmp_path), "--lang", "rust", "--fail-on", "ERROR", "--output", str(out)]) + assert result.exit_code == 1 # gate tripped + rule_ids = [json.loads(line)["rule_id"] for line in out.read_text().splitlines() if line.strip()] + assert "RS-WL-108" in rule_ids + # The posture banner is loud on stderr (slice coverage + no severity-override parity). + assert "command-injection slice" in result.output + assert "severity overrides do not yet apply" in result.output + + +def test_scan_lang_rust_clean_tree_exits_zero(tmp_path) -> None: + (tmp_path / "m.rs").write_text(_TRUSTED + 'fn run() {\n Command::new("ls").output();\n}\n', encoding="utf-8") + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke(scan, [str(tmp_path), "--lang", "rust", "--fail-on", "ERROR", "--output", str(out)]) + assert result.exit_code == 0 + + +def test_scan_lang_rust_malformed_file_fails_unanalyzed_gate(tmp_path) -> None: + (tmp_path / "broken.rs").write_text("fn f( {\n std::env::var(\n", encoding="utf-8") + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke(scan, [str(tmp_path), "--lang", "rust", "--fail-on-unanalyzed", "--output", str(out)]) + assert result.exit_code == 1 + assert "could not be analyzed" in result.output + + +def test_scan_lang_rust_warns_on_empty_trust_surface(tmp_path) -> None: + # A repo with NO @trusted markers is vacuously green (default-clean). The CLI must say + # so loudly — "0 active" without an empty-trust-surface warning is the false green. + (tmp_path / "m.rs").write_text( + 'fn a() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n', encoding="utf-8" + ) + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke(scan, [str(tmp_path), "--lang", "rust", "--output", str(out)]) + assert result.exit_code == 0 + assert "trust surface" in result.output.lower() and "0 of 1" in result.output + + +def test_scan_lang_rust_reports_coverage_when_markers_present(tmp_path) -> None: + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") # one @trusted fn + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke(scan, [str(tmp_path), "--lang", "rust", "--output", str(out)]) + assert "1 of 1" in result.output # trust surface fully covered + + +def test_scan_default_lang_python_ignores_rs(tmp_path) -> None: + # Without --lang rust, a .rs file is not swept and no Rust posture banner prints. + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + out = tmp_path / "findings.jsonl" + result = CliRunner().invoke(scan, [str(tmp_path), "--output", str(out)]) + assert result.exit_code == 0 + assert "command-injection slice" not in result.output diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..bcea95c1 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,18 @@ +"""Unit-test isolation shared across ``tests/unit``.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_filigree_server_config(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None: + """Default every unit test to an ABSENT Filigree server-mode registry. + + ``wardline.core.config._filigree_server_config_path`` resolves to the real + ``~/.config/filigree/server.json`` in production; a unit test must never read the + dev machine's copy (it would make filigree-URL resolution and ``.mcp.json`` repair + machine-dependent). Tests that exercise server mode override this by writing their + own registry and re-pointing the resolver.""" + absent = tmp_path_factory.mktemp("no_filigree_server") / "server.json" + monkeypatch.setattr("wardline.core.config._filigree_server_config_path", lambda: absent) diff --git a/tests/unit/core/fixtures/wlfp1_sinks_fingerprints.json b/tests/unit/core/fixtures/wlfp1_sinks_fingerprints.json new file mode 100644 index 00000000..b93a6aad --- /dev/null +++ b/tests/unit/core/fixtures/wlfp1_sinks_fingerprints.json @@ -0,0 +1,163 @@ +{ + "_comment": "FROZEN wlfp1 (line_start IN) fingerprints from the real pre-P3 engine, corpus_version 3 @ git 966cd9f^ (P3 parent). The NON-CIRCULAR oracle for P4 old_fp reconstruction: compute_old_new_fingerprints over a fresh sinks scan must reproduce these byte-for-byte. DO NOT regenerate by running the current engine.", + "source": "git 966cd9f^:tests/golden/identity/corpus/sinks.json", + "count": 26, + "fingerprints": [ + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks._refine_a", + "line_start": 60, + "fingerprint": "2b49ef9cc66d8a1698e32be64199877a8ab671a16f24698379fa9929d318c746" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks._refine_b", + "line_start": 65, + "fingerprint": "aa3b8af972c4494bfd7903762c0062d6ef41ab6f85b0463bf4b8507e22f428a6" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.chained_queries", + "line_start": 88, + "fingerprint": "5d068cf314842b696b664280a6e76ff1c9769fbc8657f716fbfb8dfd55ef3964" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.double_deserialize", + "line_start": 79, + "fingerprint": "c6601a8abd12cbbe5627550271240bc815d123e32f8949e86c39ee484f8f53a5" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.import_catalog_blob", + "line_start": 31, + "fingerprint": "78c8c570136abce22f3cbf65f754c173839d9d39be653d0ab4d57c8ce015e09b" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.load_report_plugin", + "line_start": 45, + "fingerprint": "91ce0eca56522f2ba3d59a5dda28c5e75edc440faeef1a7661e1a91d3afe394d" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.lookup_member", + "line_start": 111, + "fingerprint": "0508a699d59ad28767a979c4af85e524a025325efa28070bbdbcf6df418e5024" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.open_catalog_file", + "line_start": 52, + "fingerprint": "0dc077bd4c491f6a3fe29df19d797f7f542d57ce2d7bdca11a33ab29a6c715ce" + }, + { + "rule_id": "PY-WL-101", + "qualname": "wardline_sinks.run_export", + "line_start": 38, + "fingerprint": "8e23886db6ba4c2678a9bca0cf3c27726f7bd155fe7543b0faacb07f3344358c" + }, + { + "rule_id": "PY-WL-105", + "qualname": "wardline_sinks.fan_out_stored", + "line_start": 75, + "fingerprint": "3f907f0e7a83be168e0b85bc22f4f0ab00ac78dc204dc1f8b66e9e0d0323fbba" + }, + { + "rule_id": "PY-WL-105", + "qualname": "wardline_sinks.fan_out_stored", + "line_start": 75, + "fingerprint": "981ae1ca44239bd55288edf7496e79bc01a686c2bf9f0f278d31ac5802721bcf" + }, + { + "rule_id": "PY-WL-106", + "qualname": "wardline_sinks.double_deserialize", + "line_start": 84, + "fingerprint": "b8cf67e1bb76a75db7985800f8c17c66aaa0a883cb18425453a7abbf128ea789" + }, + { + "rule_id": "PY-WL-106", + "qualname": "wardline_sinks.double_deserialize", + "line_start": 84, + "fingerprint": "c263717a7e2bb42dc8d39054544500f2dc397c5bfddcfe7a9292d23997217edb" + }, + { + "rule_id": "PY-WL-106", + "qualname": "wardline_sinks.import_catalog_blob", + "line_start": 34, + "fingerprint": "cf48c38ba6782406cb79ab19d19fb700cdaba8073b3db1bfb366e74f93a282c6" + }, + { + "rule_id": "PY-WL-112", + "qualname": "wardline_sinks.run_export", + "line_start": 41, + "fingerprint": "fdf48dba3decae9faa13ee79e21aeefc634a2d10c6f2acda7d1db3fb07fc351f" + }, + { + "rule_id": "PY-WL-114", + "qualname": "wardline_sinks.stacked_invalid_levels", + "line_start": 100, + "fingerprint": "470bbce20fe595fd6bf27a51922d490be30b4284bb1ba5076ba58d328df4ba35" + }, + { + "rule_id": "PY-WL-114", + "qualname": "wardline_sinks.stacked_invalid_levels", + "line_start": 100, + "fingerprint": "86344efb75eb182f898cd934d54e39c0aae9caa6b2c9f20caefa502cd22dbf0d" + }, + { + "rule_id": "PY-WL-115", + "qualname": "wardline_sinks.load_report_plugin", + "line_start": 48, + "fingerprint": "e71fc9ff496b1b53a4ecbd06c2f4486e04d57a7f84628eacd2e6987d15319ce4" + }, + { + "rule_id": "PY-WL-116", + "qualname": "wardline_sinks.open_catalog_file", + "line_start": 55, + "fingerprint": "fe304ec020d8d529106f1bc47492590cf766ff076dbdb7c16a7f52809e69e609" + }, + { + "rule_id": "PY-WL-118", + "qualname": "wardline_sinks.chained_queries", + "line_start": 95, + "fingerprint": "3c64beba56d1118ca347efff026846773d761b8e0a2882108ed5cc0bfd79e69a" + }, + { + "rule_id": "PY-WL-118", + "qualname": "wardline_sinks.chained_queries", + "line_start": 95, + "fingerprint": "6a5d9ec7bb8a6af691bbe339f0470011b7f10182f721cfa733762e9372a6aa06" + }, + { + "rule_id": "PY-WL-118", + "qualname": "wardline_sinks.lookup_member", + "line_start": 118, + "fingerprint": "5ba5cc508e6f372d29620b40e6b5ffa334100acb9a43ba3694d1538afe496ba9" + }, + { + "rule_id": "PY-WL-120", + "qualname": "wardline_sinks.fan_out_stored", + "line_start": 75, + "fingerprint": "8b4332eff3e3142c28153ca17f7b2e67556f3bdf14ffba796c6bf280ecf87f5c" + }, + { + "rule_id": "PY-WL-120", + "qualname": "wardline_sinks.fan_out_stored", + "line_start": 75, + "fingerprint": "ced370ab43dafe2f9f240b17aa4453b0ec3ed6df19a5af9a6714f9939a133f40" + }, + { + "rule_id": "PY-WL-120", + "qualname": "wardline_sinks.lookup_member", + "line_start": 119, + "fingerprint": "dff95e304d3c6f3afaab584a465850fd82fdfb89b8151f1fd7b5cbce0764f20c" + }, + { + "rule_id": "PY-WL-120", + "qualname": "wardline_sinks.open_catalog_file", + "line_start": 56, + "fingerprint": "7739d3dc46b4bb6f8afcad2db38a059b020c740b053e3258ee4795016d2508af" + } + ] +} diff --git a/tests/unit/core/test_agent_summary.py b/tests/unit/core/test_agent_summary.py index c3ada084..ebedd2be 100644 --- a/tests/unit/core/test_agent_summary.py +++ b/tests/unit/core/test_agent_summary.py @@ -1,10 +1,11 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from wardline.core.agent_summary import build_agent_summary from wardline.core.baseline import write_baseline -from wardline.core.finding import Severity +from wardline.core.finding import Finding, Kind, Location, Severity from wardline.core.paths import baseline_path from wardline.core.run import gate_decision, run_scan @@ -34,6 +35,23 @@ def test_agent_summary_rejects_negative_max_findings(tmp_path: Path) -> None: AgentSummary(result=scan, gate=gate, max_findings=-1) +def test_agent_summary_zero_max_findings_does_not_stall_pagination(tmp_path: Path) -> None: + # Regression: max_findings=0 yields an empty window; the truncation cursor must NOT + # report next_offset == offset (a paging agent following it would loop forever). A + # zero-progress window reports findings_truncated=False / next_offset=None instead. + from wardline.core.agent_summary import AgentSummary + + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + scan = run_scan(tmp_path) + gate = gate_decision(scan, Severity.ERROR) + out = AgentSummary(result=scan, gate=gate, max_findings=0, offset=0).to_dict() + trunc = out["truncation"] + assert trunc["findings_total"] >= 1 # there ARE findings + assert trunc["findings_returned"] == 0 + assert trunc["findings_truncated"] is False + assert trunc["next_offset"] is None # never == offset + + def test_agent_summary_active_defects_first_and_stable(tmp_path: Path) -> None: (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") scan = run_scan(tmp_path) @@ -108,7 +126,11 @@ def test_agent_summary_no_active_defects_still_has_next_actions(tmp_path: Path) out = build_agent_summary(scan, gate_decision(scan, None)).to_dict() assert out["active_defects"] == [] - assert out["next_actions"] == [{"tool": "scan", "reason": "no active defects; rescan after edits"}] + # A bare scan is NOT_EVALUATED (weft-b937e53854): next_actions point at enforcing a + # threshold, never the bland "rescan after edits" that reads as a pass. + assert len(out["next_actions"]) == 1 + reason = out["next_actions"][0]["reason"].lower() + assert "not_evaluated" in reason and "--fail-on" in reason def test_agent_summary_next_actions_do_not_say_passed_when_gate_tripped(tmp_path: Path) -> None: @@ -172,3 +194,87 @@ def test_agent_summary_includes_integration_status_blocks(tmp_path: Path) -> Non assert out["integrations"]["filigree_emit"]["reachable"] is False assert out["integrations"]["loomweave_write"]["disabled_reason"] == "403" + + +def test_agent_summary_nondefects_included_in_union_and_pagination(tmp_path: Path) -> None: + """W3 residual: non-defect findings beyond engine facts must appear in the union + and pagination, not just in the summary count. + + Three assertions: + (1) truncation.findings_total == summary.total_findings (union == whole scan). + (2) Each synthetic non-defect finding's fingerprint appears in exactly one + display array (metric → informational; non-engine FACT → informational). + (3) len(active_defects) + len(suppressed_findings) + len(engine_facts) + + len(informational) == truncation.findings_total. + """ + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + real_scan = run_scan(tmp_path) + + # Synthetic non-defect findings that must NOT be silently dropped: + # – a Kind.METRIC with a "WLN-ENGINE-METRICS" rule_id (engine prefix, but METRIC + # kind → _is_engine_fact is False → goes to informational display array) + # – a non-engine Kind.FACT with a "WLN-L3-*" rule_id (non-engine → informational) + metric_fp = "metric-fingerprint-test-01" + l3fact_fp = "l3fact-fingerprint-test-01" + metric = Finding( + rule_id="WLN-ENGINE-METRICS", + message="L3 resolver run metrics", + severity=Severity.NONE, + kind=Kind.METRIC, + location=Location(path=""), + fingerprint=metric_fp, + ) + # A Kind.FACT whose rule_id does NOT start with "WLN-ENGINE-" → _is_engine_fact + # returns False → must appear in the informational display array, not engine_facts. + l3_fact = Finding( + rule_id="WLN-CUSTOM-NON-ENGINE-FACT", + message="a non-engine fact finding for test coverage", + severity=Severity.INFO, + kind=Kind.FACT, + location=Location(path=""), + fingerprint=l3fact_fp, + ) + + augmented_findings = real_scan.findings + [metric, l3_fact] + orig_summary = real_scan.summary + augmented_summary = replace( + orig_summary, + total=orig_summary.total + 2, + informational=orig_summary.informational + 2, + ) + augmented_scan = replace( + real_scan, + findings=augmented_findings, + summary=augmented_summary, + ) + + out = build_agent_summary(augmented_scan, gate_decision(augmented_scan, None)).to_dict() + + # (1) union length == total_findings + assert out["truncation"]["findings_total"] == out["summary"]["total_findings"], ( + "findings_total (union) must equal total_findings (summary)" + ) + + # (2) each synthetic finding appears in exactly one display array + all_fps_by_array: dict[str, list[str]] = { + "active_defects": [e["fingerprint"] for e in out["active_defects"]], + "suppressed_findings": [e["fingerprint"] for e in out["suppressed_findings"]], + "engine_facts": [e["fingerprint"] for e in out["engine_facts"]], + "informational": [e["fingerprint"] for e in out["informational"]], + } + for synthetic_fp in (metric_fp, l3fact_fp): + found_in = [name for name, fps in all_fps_by_array.items() if synthetic_fp in fps] + assert found_in == ["informational"], ( + f"fingerprint {synthetic_fp!r} should be in exactly [informational], got {found_in}" + ) + + # (3) four-array partition == findings_total + total_shown = ( + len(out["active_defects"]) + + len(out["suppressed_findings"]) + + len(out["engine_facts"]) + + len(out["informational"]) + ) + assert total_shown == out["truncation"]["findings_total"], ( + f"display array sum {total_shown} != findings_total {out['truncation']['findings_total']}" + ) diff --git a/tests/unit/core/test_assure.py b/tests/unit/core/test_assure.py index 16762e3b..1c124fd4 100644 --- a/tests/unit/core/test_assure.py +++ b/tests/unit/core/test_assure.py @@ -51,6 +51,10 @@ " return src()\n" ) +_BAD_DECORATED_MODULE = ( + "from wardline.decorators.trust import trusted\n\n@trusted(level='INTEGRAL')\ndef broken(:\n return 1\n" +) + def test_coverage_denominator_end_to_end(tmp_path: Path) -> None: (tmp_path / "m.py").write_text(_MODULE, encoding="utf-8") @@ -76,6 +80,7 @@ def test_coverage_denominator_end_to_end(tmp_path: Path) -> None: assert got["engine_limited"] == 0 # 3 of 3 reached a definite verdict — the defect counts as COVERED. assert got["coverage_pct"] == 100.0 + assert got["unanalyzed_total"] == 0 assert got["unanalyzed_rule_ids"] == [] # 2026-07-01 − 2026-06-03 = 28 days. assert got["waiver_debt"] == [ @@ -153,6 +158,7 @@ def test_unknown_and_engine_limited_branch() -> None: assert got["proven"] == 1 assert len(got["unknown"]) == 2 assert got["engine_limited"] == 1 + assert got["unanalyzed_total"] == 0 assert got["coverage_pct"] == round(100 * (3 - 2) / 3, 1) == 33.3 # `unknown` is sorted by qualname: m.b before m.c. assert [u["qualname"] for u in got["unknown"]] == ["m.b", "m.c"] @@ -180,6 +186,7 @@ def test_empty_surface_coverage_is_null(tmp_path: Path) -> None: got = posture.to_dict() assert got["boundaries_total"] == 0 + assert got["unanalyzed_total"] == 0 assert got["coverage_pct"] is None, ( f"expected None but got {got['coverage_pct']!r}; " "a vacuous 100.0 reads as 'fully assured' to a numeric gate — false-green" @@ -216,3 +223,20 @@ def test_empty_surface_coverage_is_null(tmp_path: Path) -> None: pure_posture = posture_from_scan(empty_result, empty_ctx, waivers=(), today=date(2026, 6, 3)) assert pure_posture.coverage_pct is None assert pure_posture.to_dict()["coverage_pct"] is None + + +def test_unanalyzed_files_count_against_assurance_coverage(tmp_path: Path) -> None: + (tmp_path / "good.py").write_text(_MODULE, encoding="utf-8") + (tmp_path / "bad.py").write_text(_BAD_DECORATED_MODULE, encoding="utf-8") + + posture = build_posture(tmp_path, today=date(2026, 6, 3)) + got = posture.to_dict() + + assert got["boundaries_total"] == 3 + assert got["proven"] == 2 + assert got["defect_total"] == 1 + assert got["unknown"] == [] + assert got["unanalyzed_total"] == 1 + assert got["engine_limited"] == 1 + assert got["coverage_pct"] == round(100 * 3 / 4, 1) == 75.0 + assert got["unanalyzed_rule_ids"] == ["WLN-ENGINE-PARSE-ERROR"] diff --git a/tests/unit/core/test_attest.py b/tests/unit/core/test_attest.py index ba121cd8..0d6bb9b6 100644 --- a/tests/unit/core/test_attest.py +++ b/tests/unit/core/test_attest.py @@ -14,6 +14,7 @@ from __future__ import annotations +import shlex import subprocess from datetime import date from pathlib import Path @@ -27,12 +28,12 @@ _canonical_bytes, build_attestation, git_state, - ruleset_hash, verify_attestation, ) from wardline.core.config import WardlineConfig from wardline.core.errors import AttestError, WardlineError from wardline.core.paths import waivers_path +from wardline.core.ruleset import ruleset_hash from wardline.core.taints import TaintState from wardline.core.waivers import add_waiver from wardline.scanner.grammar import BoundaryType, TrustGrammar @@ -231,6 +232,45 @@ def test_git_state_repo_clean_dirty_and_non_git(tmp_path: Path) -> None: assert dirty2 is True +def test_git_state_disables_repo_configured_fsmonitor(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "f.py").write_text("x = 1\n", encoding="utf-8") + _git(["init"], repo) + _git(["add", "-A"], repo) + _git( + [ + "-c", + "user.email=t@example.com", + "-c", + "user.name=Test", + "commit", + "-m", + "init", + ], + repo, + ) + + log_path = tmp_path / "fsmonitor.log" + hook_path = tmp_path / "fsmonitor.sh" + hook_path.write_text( + f"#!/bin/sh\nprintf 'fsmonitor ran\\n' >> {shlex.quote(str(log_path))}\nprintf 'hook-token\\n'\n", + encoding="utf-8", + ) + hook_path.chmod(0o755) + _git(["config", "core.fsmonitor", str(hook_path)], repo) + + commit, dirty = git_state(repo) + + assert commit is not None + assert dirty is False + assert not log_path.exists() + + (repo / "f.py").write_text("x = 2\n", encoding="utf-8") + assert git_state(repo) == (commit, True) + assert not log_path.exists() + + # --------------------------------------------------------------------------- # # 3. build_attestation + signature round-trip / wrong-key / tamper # --------------------------------------------------------------------------- # @@ -455,7 +495,8 @@ def __init__(self, *, hit: str = "m.leak", sei: str = _SEI) -> None: def capabilities(self) -> dict[str, object]: return {"sei": {"supported": True, "version": 1}} - def resolve(self, qualnames: list[str]) -> ResolveResult: + def resolve(self, qualnames: list[str], *, plugin: str | None = None) -> ResolveResult: + self.plugin_hints = [*getattr(self, "plugin_hints", []), plugin] resolved = {q: f"python:function:{q}" for q in qualnames if q == self._hit} unresolved = [q for q in qualnames if q != self._hit] return ResolveResult(resolved=resolved, unresolved=unresolved) @@ -476,10 +517,14 @@ def capabilities(self) -> dict[str, object]: def test_sei_keyed_bundle_fills_resolved_boundary_only(tmp_path: Path) -> None: tree = _annotated_tree(tmp_path) - bundle = build_attestation(tree, _KEY, loomweave_client=_FakeLoomweave(hit="m.leak"), today=_PINNED) + client = _FakeLoomweave(hit="m.leak") + bundle = build_attestation(tree, _KEY, loomweave_client=client, today=_PINNED) payload = bundle["payload"] assert payload["sei_source"] == "loomweave" + # Boundaries come from the Python AnalysisContext, so every resolve hop carries + # the ADR-036 plugin hint (constraint, never fabricated). + assert set(client.plugin_hints) == {"python"} by_qn = {b["qualname"]: b for b in payload["boundaries"]} assert by_qn["m.leak"]["sei"] == _SEI # the one resolvable qualname is keyed assert by_qn["m.clean"]["sei"] is None # unresolved → honestly None diff --git a/tests/unit/core/test_attest_key.py b/tests/unit/core/test_attest_key.py index b58f07ea..a670f721 100644 --- a/tests/unit/core/test_attest_key.py +++ b/tests/unit/core/test_attest_key.py @@ -4,6 +4,7 @@ from __future__ import annotations import re +import subprocess from pathlib import Path import pytest @@ -16,6 +17,11 @@ ) from wardline.core.errors import WardlineError + +def _git(args: list[str], cwd: Path) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) + + # --------------------------------------------------------------------------- # Test 1: load_attest_key — env wins # --------------------------------------------------------------------------- @@ -91,6 +97,22 @@ def test_mint_rejects_symlinked_dotenv(tmp_path: Path, monkeypatch: pytest.Monke assert outside.read_text(encoding="utf-8") == "" +def test_mint_refuses_tracked_dotenv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(WARDLINE_ATTEST_KEY_ENV, raising=False) + _git(["init"], tmp_path) + _git(["config", "user.email", "test@example.com"], tmp_path) + _git(["config", "user.name", "Test"], tmp_path) + dotenv = tmp_path / ".env" + dotenv.write_text("EXISTING=1\n", encoding="utf-8") + _git(["add", ".env"], tmp_path) + _git(["commit", "-m", "track env"], tmp_path) + + with pytest.raises(WardlineError, match="tracked \\.env"): + mint_attest_key(tmp_path) + + assert dotenv.read_text(encoding="utf-8") == "EXISTING=1\n" + + def test_mint_key_loadable_after_mint(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """load_attest_key returns the minted key immediately after minting.""" monkeypatch.delenv(WARDLINE_ATTEST_KEY_ENV, raising=False) diff --git a/tests/unit/core/test_autofix.py b/tests/unit/core/test_autofix.py index 9952eafc..2639abb8 100644 --- a/tests/unit/core/test_autofix.py +++ b/tests/unit/core/test_autofix.py @@ -44,6 +44,36 @@ def test_autofix_basic_assert(tmp_path: Path) -> None: assert new_content == expected +def test_autofix_relative_root_dry_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Dogfood-4 A1: the MCP server launches with the literal `--root .`; the + # resolved file path relativized against the unresolved root raised + # ValueError ("is not in the subpath of '.'") on ANY invocation, dry-run + # included. The relative root must work end to end. + content = """def my_func(x): + assert x > 0 + return x +""" + (tmp_path / "test_file.py").write_text(content, encoding="utf-8") + findings = [ + Finding( + rule_id="PY-WL-111", + message="assert-only boundary check", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="test_file.py", line_start=1), + fingerprint="test_fp", + ) + ] + config = WardlineConfig(autofix={"boundary_exception": "ValueError"}) + monkeypatch.chdir(tmp_path) + + result = run_autofix(findings, config, Path("."), dry_run=True) + + assert "test_file.py" in result + # dry-run: reported but not written + assert (tmp_path / "test_file.py").read_text(encoding="utf-8") == content + + def test_autofix_assert_with_msg(tmp_path: Path) -> None: content = """def my_func(x): assert x > 0, "x must be positive" diff --git a/tests/unit/core/test_baseline.py b/tests/unit/core/test_baseline.py index b1209c13..6348fcea 100644 --- a/tests/unit/core/test_baseline.py +++ b/tests/unit/core/test_baseline.py @@ -11,8 +11,9 @@ load_baseline, write_baseline, ) -from wardline.core.errors import ConfigError -from wardline.core.finding import Finding, Kind, Location, Severity +from wardline.core.errors import ConfigError, SchemeMismatchError, WardlineError +from wardline.core.finding import FINGERPRINT_SCHEME, Finding, Kind, Location, Severity +from wardline.core.paths import baseline_path _FP_A = "a" * 64 _FP_B = "b" * 64 @@ -32,9 +33,52 @@ def _finding(fp: str, *, rule: str = "PY-WL-101", sev: Severity = Severity.ERROR def test_build_document_shape_and_version() -> None: doc = build_baseline_document([_finding(_FP_A)]) assert doc["version"] == BASELINE_VERSION + assert doc["fingerprint_scheme"] == FINGERPRINT_SCHEME == "wlfp2" assert doc["entries"][0]["fingerprint"] == _FP_A assert doc["entries"][0]["rule_id"] == "PY-WL-101" assert "path" in doc["entries"][0] and "message" in doc["entries"][0] + # entry fingerprint stays BARE 64-hex (no scheme prefix in-store) + assert ":" not in doc["entries"][0]["fingerprint"] + + +def test_missing_scheme_header_raises_scheme_mismatch_not_version(tmp_path: Path) -> None: + # A header-less store must fail with the actionable SchemeMismatchError + # (naming the file + `wardline rekey`), NOT a hintless version error — and + # the scheme check must run BEFORE the version check. + p = tmp_path / "b.yaml" + p.write_text(yaml.safe_dump({"version": BASELINE_VERSION, "entries": []}), encoding="utf-8") + with pytest.raises(SchemeMismatchError) as ei: + load_baseline(p) + assert "wardline rekey" in str(ei.value) + + +def test_wrong_scheme_raises_scheme_mismatch(tmp_path: Path) -> None: + p = tmp_path / "b.yaml" + p.write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp1", "version": BASELINE_VERSION, "entries": []}), + encoding="utf-8", + ) + with pytest.raises(SchemeMismatchError): + load_baseline(p) + + +def test_non_string_scheme_header_treated_as_missing(tmp_path: Path) -> None: + # A non-string fingerprint_scheme (hand-mangled) is treated as missing and + # raises the actionable SchemeMismatchError, not a crash. Locks the isinstance + # guard in require_fingerprint_scheme. + p = tmp_path / "b.yaml" + p.write_text(yaml.safe_dump({"fingerprint_scheme": 1, "version": BASELINE_VERSION, "entries": []}), "utf-8") + with pytest.raises(SchemeMismatchError) as ei: + load_baseline(p) + assert "wardline rekey" in str(ei.value) + + +def test_empty_mapping_is_empty_baseline_no_scheme_error(tmp_path: Path) -> None: + # Fresh checkout: an empty `{}` store returns empty, never SchemeMismatchError + # (empty-guard precedes the scheme check). + p = tmp_path / "b.yaml" + p.write_text("{}\n", encoding="utf-8") + assert load_baseline(p).fingerprints == frozenset() def test_build_document_dedups_and_sorts_severity_first() -> None: @@ -61,6 +105,31 @@ def test_write_then_load_round_trips(tmp_path: Path) -> None: assert bl.contains(_FP_A) and not bl.contains("c" * 64) +def test_write_baseline_refuses_direct_symlink_target(tmp_path: Path) -> None: + outside = tmp_path / "outside.yaml" + outside.write_text("", encoding="utf-8") + link = tmp_path / "baseline.yaml" + link.symlink_to(outside) + + with pytest.raises(WardlineError, match="symlink"): + write_baseline(link, [_finding(_FP_A)]) + + assert outside.read_text(encoding="utf-8") == "" + + +def test_write_baseline_refuses_rooted_symlink_target(tmp_path: Path) -> None: + outside = tmp_path / "outside.yaml" + outside.write_text("", encoding="utf-8") + bp = baseline_path(tmp_path) + bp.parent.mkdir(parents=True) + bp.symlink_to(outside) + + with pytest.raises(WardlineError, match="symlink"): + write_baseline(bp, [_finding(_FP_A)], root=tmp_path) + + assert outside.read_text(encoding="utf-8") == "" + + def test_missing_file_is_empty_baseline(tmp_path: Path) -> None: assert load_baseline(tmp_path / "nope.yaml").fingerprints == frozenset() @@ -80,14 +149,26 @@ def test_malformed_yaml_raises(tmp_path: Path) -> None: def test_version_mismatch_raises(tmp_path: Path) -> None: p = tmp_path / "b.yaml" - p.write_text(yaml.safe_dump({"version": 999, "entries": []}), encoding="utf-8") + p.write_text( + yaml.safe_dump({"fingerprint_scheme": FINGERPRINT_SCHEME, "version": 999, "entries": []}), + encoding="utf-8", + ) with pytest.raises(ConfigError): load_baseline(p) def test_bad_fingerprint_raises(tmp_path: Path) -> None: p = tmp_path / "b.yaml" - p.write_text(yaml.safe_dump({"version": BASELINE_VERSION, "entries": [{"fingerprint": "short"}]}), encoding="utf-8") + p.write_text( + yaml.safe_dump( + { + "fingerprint_scheme": FINGERPRINT_SCHEME, + "version": BASELINE_VERSION, + "entries": [{"fingerprint": "short"}], + } + ), + encoding="utf-8", + ) with pytest.raises(ConfigError): load_baseline(p) @@ -95,7 +176,13 @@ def test_bad_fingerprint_raises(tmp_path: Path) -> None: def test_duplicate_fingerprint_in_file_raises(tmp_path: Path) -> None: p = tmp_path / "b.yaml" p.write_text( - yaml.safe_dump({"version": BASELINE_VERSION, "entries": [{"fingerprint": _FP_A}, {"fingerprint": _FP_A}]}), + yaml.safe_dump( + { + "fingerprint_scheme": FINGERPRINT_SCHEME, + "version": BASELINE_VERSION, + "entries": [{"fingerprint": _FP_A}, {"fingerprint": _FP_A}], + } + ), encoding="utf-8", ) with pytest.raises(ConfigError): diff --git a/tests/unit/core/test_cli_mcp_parity.py b/tests/unit/core/test_cli_mcp_parity.py index 4ec93e83..05e664b6 100644 --- a/tests/unit/core/test_cli_mcp_parity.py +++ b/tests/unit/core/test_cli_mcp_parity.py @@ -16,28 +16,40 @@ from pathlib import Path +from wardline.core.agent_summary import build_agent_summary from wardline.core.finding import Severity from wardline.core.run import baseline_migration_hint, gate_decision, run_scan -from wardline.mcp.server import _finding_to_dict, _scan +from wardline.mcp.server import _scan _CORPUS = Path(__file__).resolve().parents[3] / "tests" / "corpus" / "fixtures" def test_cli_and_mcp_scan_agree_on_findings_and_gate() -> None: - # Shared scan default: confine_to_root=True. + # Shared scan default: confine_to_root=True. The finding BODIES live solely in + # agent_summary now (the bloat-causing top-level `findings` array was removed, W1). + # The CLI `--format agent-summary` is uncapped; MCP defaults bounded, so parity is + # asserted against MCP full=true (engine parity preserved; only the default page size + # differs by surface, by design). cli_result = run_scan(_CORPUS) - cli_findings = [_finding_to_dict(f) for f in cli_result.findings] cli_gate = gate_decision(cli_result, Severity.ERROR) + cli_ag = build_agent_summary(cli_result, cli_gate).to_dict() # MCP parameterization: the real _scan handler (confine_to_root=True, no loomweave). - mcp = _scan({"fail_on": "ERROR"}, root=_CORPUS) + mcp = _scan({"fail_on": "ERROR", "full": True}, root=_CORPUS) + mcp_ag = mcp["agent_summary"] - assert mcp["findings"] == cli_findings + for key in ("active_defects", "suppressed_findings", "engine_facts", "informational"): + assert mcp_ag[key] == cli_ag[key], key cli_hint = baseline_migration_hint(cli_result, cli_gate, root=_CORPUS, new_since=None) assert mcp["gate"] == { "tripped": cli_gate.tripped, "fail_on": cli_gate.fail_on, + "fail_on_unanalyzed": cli_gate.fail_on_unanalyzed, "exit_class": cli_gate.exit_class, + "verdict": cli_gate.verdict, + "severity_tripped": cli_gate.severity_tripped, + "unanalyzed_tripped": cli_gate.unanalyzed_tripped, + "would_trip_at": cli_gate.would_trip_at, "reason": cli_gate.reason, "evaluated": cli_gate.evaluated, "migration_hint": cli_hint, @@ -46,7 +58,49 @@ def test_cli_and_mcp_scan_agree_on_findings_and_gate() -> None: assert mcp["summary"]["active"] == cli_result.summary.active assert mcp["files_scanned"] == cli_result.files_scanned # Sanity: the labeled corpus is a non-trivial substrate (it fires real defects). - assert any(f["kind"] == "defect" for f in cli_findings) + assert any(e["kind"] == "defect" for e in cli_ag["active_defects"]) + + +def test_cli_and_mcp_scan_agree_on_rust_findings(tmp_path: Path) -> None: + """A1 (wardline-2ee1bbda82): the Rust frontend must yield the SAME findings over + both surfaces. The CLI side is the REAL Click command (`scan --lang rust`) writing + jsonl; the MCP side is `_scan({"lang": "rust", ...})`. Every agent_summary bucket + entry carries a fingerprint, so the union must equal the CLI's emitted finding set, + and the gate verdicts must agree.""" + import json + + import pytest + + pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + from click.testing import CliRunner + + from wardline.cli.scan import scan as scan_cmd + + trusted = "/// @trusted(level=ASSURED)\n" + (tmp_path / "hot.rs").write_text( + trusted + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n', + encoding="utf-8", + ) + (tmp_path / "clean.rs").write_text(trusted + 'fn ok() {\n Command::new("ls").output();\n}\n', encoding="utf-8") + + out = tmp_path / "findings.jsonl" + cli = CliRunner().invoke(scan_cmd, [str(tmp_path), "--lang", "rust", "--fail-on", "ERROR", "--output", str(out)]) + assert cli.exit_code == 1 # the RS-WL-108 injection trips the gate + cli_fps = sorted(json.loads(line)["fingerprint"] for line in out.read_text().splitlines() if line.strip()) + + mcp = _scan({"lang": "rust", "fail_on": "ERROR", "full": True}, root=tmp_path) + ag = mcp["agent_summary"] + mcp_fps = sorted( + e["fingerprint"] + for key in ("active_defects", "suppressed_findings", "engine_facts", "informational") + for e in ag[key] + ) + assert mcp_fps == cli_fps + assert mcp["gate"]["tripped"] is True + assert mcp["gate"]["verdict"] == "FAILED" + # Engine-level parity too: the shared run_scan path under lang="rust" is what both drove. + engine = run_scan(tmp_path, lang="rust") + assert sorted(f.fingerprint for f in engine.findings) == cli_fps def test_cli_and_mcp_emit_identical_filigree_body() -> None: @@ -76,3 +130,35 @@ def emit(self, findings, *, scanned_paths=()): # active-only) silently diverging the two surfaces' Filigree emission. assert [f.fingerprint for f in cap.seen] == [f.fingerprint for f in cli_result.findings] assert cap.scanned_paths == cli_result.scanned_paths + + +def test_cli_and_mcp_agree_on_fail_on_unanalyzed_gate(tmp_path: Path) -> None: + """A4 (wardline-7fd0f3a82c): the unanalyzed gate must be controllable over BOTH + surfaces and yield the SAME verdict. The CLI side is the REAL Click command; the MCP + side is `_scan` with the new `fail_on_unanalyzed` arg. Fixture: an unparseable file + (discovered but never analysed) and no severity threshold.""" + from click.testing import CliRunner + + from wardline.cli.scan import scan as scan_cmd + + proj = tmp_path / "proj" + proj.mkdir() + (proj / "bad.py").write_text("def f(:\n", encoding="utf-8") # syntax error -> unanalyzed + + for knob, expect_trip in ((True, True), (False, False)): + cli_args = [str(proj), "--output", str(tmp_path / "f.jsonl")] + cli_args.append("--fail-on-unanalyzed" if knob else "--no-fail-on-unanalyzed") + cli = CliRunner().invoke(scan_cmd, cli_args) + mcp = _scan({"fail_on_unanalyzed": knob}, root=proj) + assert cli.exit_code == (1 if expect_trip else 0), cli.output + assert mcp["gate"]["tripped"] is expect_trip + assert mcp["gate"]["exit_class"] == cli.exit_code + assert mcp["gate"]["fail_on_unanalyzed"] is knob + assert mcp["gate"]["unanalyzed_tripped"] is expect_trip + assert mcp["summary"]["unanalyzed"] >= 1 + if expect_trip: + assert mcp["gate"]["verdict"] == "FAILED" + assert "not analyzed" in (mcp["gate"]["reason"] or "") + else: + # Knob off + no threshold: the gate never ran — released behaviour. + assert mcp["gate"]["verdict"] == "NOT_EVALUATED" diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 5d648d5f..8f478591 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -310,6 +310,108 @@ def test_published_port_unreadable_is_soft(tmp_path: Path, monkeypatch) -> None: assert resolve_loomweave_url(None, tmp_path, None) is None +# --- Filigree server-mode scope resolution (~/.config/filigree/server.json) --- +# +# Server mode runs ONE daemon for many projects on a single shared port and publishes +# NO per-project ephemeral.port; it records each project's store dir -> {prefix} in a +# home-global registry. wardline reads it to build the project-SCOPED write route +# (/api/p/{prefix}/...) that a multi-project daemon fail-closes an unscoped write +# against. Tests redirect the registry path so they never touch the real machine file. + +import json as _json # noqa: E402 + +# Per-test isolation from the real ~/.config/filigree/server.json is provided by the +# autouse fixture in tests/unit/conftest.py; the server-mode tests below override it. + + +def _register_filigree_server(monkeypatch, cfg_home: Path, *, port, projects: dict) -> Path: + """Write a Filigree server-mode ``server.json`` and point wardline's resolver at it.""" + sj = cfg_home / "server.json" + sj.parent.mkdir(parents=True, exist_ok=True) + sj.write_text(_json.dumps({"port": port, "projects": projects}), encoding="utf-8") + monkeypatch.setattr("wardline.core.config._filigree_server_config_path", lambda: sj) + return sj + + +def test_filigree_server_mode_scoped_url(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + assert resolve_filigree_url(None, tmp_path, None) == "http://localhost:8749/api/p/lacuna/weft/scan-results" + + +def test_filigree_server_mode_legacy_store_path_matches(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + legacy = tmp_path / ".filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=9000, projects={str(legacy): {"prefix": "proj"}}) + assert resolve_filigree_url(None, tmp_path, None) == "http://localhost:9000/api/p/proj/weft/scan-results" + + +def test_filigree_server_scope_beats_stale_ephemeral_port(tmp_path: Path, monkeypatch) -> None: + # A multi-project daemon's scope is authoritative and outranks a leftover unscoped + # per-project ephemeral.port from a past ethereal run. + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + d = tmp_path / ".weft" / "filigree" + d.mkdir(parents=True) + (d / "ephemeral.port").write_text("5555", encoding="ascii") + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(d): {"prefix": "lacuna"}}) + assert resolve_filigree_url(None, tmp_path, None) == "http://localhost:8749/api/p/lacuna/weft/scan-results" + + +def test_filigree_ethereal_only_is_unscoped(tmp_path: Path, monkeypatch) -> None: + # No server.json match -> ethereal: the per-project ephemeral.port's unscoped URL. + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + d = tmp_path / ".weft" / "filigree" + d.mkdir(parents=True) + (d / "ephemeral.port").write_text("6006", encoding="ascii") + assert resolve_filigree_url(None, tmp_path, None) == "http://localhost:6006/api/weft/scan-results" + + +def test_filigree_server_unregistered_project_falls_through(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + _register_filigree_server( + monkeypatch, tmp_path / "cfg", port=8749, projects={"/some/other/.weft/filigree": {"prefix": "other"}} + ) + # Not our project, and no local ephemeral.port -> nothing resolves. + assert resolve_filigree_url(None, tmp_path, None) is None + + +def test_filigree_server_scope_loses_to_flag_and_env(tmp_path: Path, monkeypatch) -> None: + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + assert resolve_filigree_url("http://fil-flag", tmp_path, None) == "http://fil-flag" + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://fil-env") + assert resolve_filigree_url(None, tmp_path, None) == "http://fil-env" + + +def test_filigree_server_scope_skipped_under_strict_defaults(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + assert resolve_filigree_url(None, tmp_path, None, strict_defaults=True) is None + + +@pytest.mark.parametrize( + "payload", + [ + "{not json", + "[]", + '{"projects": {}}', # no port + '{"port": "x", "projects": {}}', # non-int port + '{"port": true, "projects": {}}', # bool is not a port + '{"port": 70000, "projects": {}}', # out of range + '{"port": 8749, "projects": []}', # projects not an object + ], +) +def test_filigree_server_json_malformed_is_soft(tmp_path: Path, monkeypatch, payload: str) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + sj = tmp_path / "cfg" / "server.json" + sj.parent.mkdir(parents=True) + sj.write_text(payload, encoding="utf-8") + monkeypatch.setattr("wardline.core.config._filigree_server_config_path", lambda: sj) + assert resolve_filigree_url(None, tmp_path, None) is None + + # --- ADR-044 twin: published filigree ephemeral.port resolution (consumer half) --- @@ -363,6 +465,26 @@ def test_filigree_published_port_malformed_returns_none(tmp_path: Path, monkeypa assert resolve_filigree_url(None, tmp_path, None) is None +@pytest.mark.skipif(not hasattr(Path, "symlink_to"), reason="symlink support unavailable") +def test_filigree_published_port_symlink_is_not_opened(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + port_dir = tmp_path / ".weft" / "filigree" + port_dir.mkdir(parents=True) + port_path = port_dir / "ephemeral.port" + port_path.symlink_to(Path("/dev/zero")) + + real_read_text = Path.read_text + + def _read_text(self: Path, *args: object, **kwargs: object) -> str: + if self == port_path: + raise AssertionError("symlinked ephemeral.port must not be opened") + return real_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _read_text) + + assert resolve_filigree_url(None, tmp_path, None) is None + + def test_filigree_published_port_boundaries_accepted(tmp_path: Path, monkeypatch) -> None: monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) _publish_filigree_port(tmp_path, "1") diff --git a/tests/unit/core/test_decorator_coverage.py b/tests/unit/core/test_decorator_coverage.py index bacee14a..92adf15c 100644 --- a/tests/unit/core/test_decorator_coverage.py +++ b/tests/unit/core/test_decorator_coverage.py @@ -3,7 +3,7 @@ from pathlib import Path from wardline.core.baseline import write_baseline -from wardline.core.decorator_coverage import build_decorator_coverage +from wardline.core.decorator_coverage import build_decorator_coverage, decorator_coverage_from_scan from wardline.core.dossier import TicketRef, WorkSection from wardline.core.identity import ContentStatus, EntityBinding, IdentityStatus from wardline.core.paths import baseline_path @@ -113,3 +113,21 @@ def test_decorator_coverage_surfaces_suppressed_defects(tmp_path: Path) -> None: assert rows["svc.leaky"].finding_state == "suppressed" assert rows["svc.leaky"].active_finding_fingerprints == [] assert rows["svc.leaky"].suppressed_finding_fingerprints == [leak.fingerprint] + + +def test_decorator_coverage_degrades_when_decorator_unparse_recurses( + tmp_path: Path, + monkeypatch, +) -> None: + root = _project(tmp_path) + result = run_scan(root) + assert result.context is not None + + def raise_recursion(_node) -> str: + raise RecursionError("simulated decorator depth") + + monkeypatch.setattr("wardline.core.decorator_coverage.ast.unparse", raise_recursion) + + rows = {row.qualname: row for row in decorator_coverage_from_scan(result, result.context).rows} + + assert rows["svc.clean"].decorators == ["@"] diff --git a/tests/unit/core/test_discovery.py b/tests/unit/core/test_discovery.py index a705c6f8..c47f491d 100644 --- a/tests/unit/core/test_discovery.py +++ b/tests/unit/core/test_discovery.py @@ -19,6 +19,18 @@ def test_respects_exclude_globs() -> None: assert all(p.name != "mod.py" for p in files) +def test_prunes_tool_cache_directories_before_discovery(tmp_path: Path) -> None: + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("x = 1\n", encoding="utf-8") + cache = tmp_path / ".uv-cache" / "archive" / "pkg" + cache.mkdir(parents=True) + (cache / "cached.py").write_text("y = 2\n", encoding="utf-8") + + files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + + assert [p.relative_to(tmp_path).as_posix() for p in files] == ["src/app.py"] + + def test_skip_dirs_are_relative_to_source_root_not_absolute_parents(tmp_path: Path) -> None: root = tmp_path / ".venv" / "proj" root.mkdir(parents=True) @@ -62,6 +74,189 @@ def test_confine_excludes_symlink_escaping_root(tmp_path: Path) -> None: assert all(p.name != "evil.py" for p in files) +def test_discover_rust_suffix(tmp_path: Path) -> None: + # The suffix parameter routes discovery to a different language's files: a + # `.rs` sweep finds `a.rs`, never `a.py`; `target/` (cargo build output) is + # skipped; and the default (no `suffixes`) call is byte-unchanged Python-only. + root = tmp_path / "root" + src = root / "src" + src.mkdir(parents=True) + (src / "a.rs").write_text("fn main() {}\n", encoding="utf-8") + (src / "a.py").write_text("x = 1\n", encoding="utf-8") + built = src / "target" + built.mkdir() + (built / "built.rs").write_text("fn x() {}\n", encoding="utf-8") + + cfg = WardlineConfig(source_roots=("src",)) + + rust_files = discover(root, cfg, suffixes=frozenset({".rs"})) + assert sorted(p.name for p in rust_files) == ["a.rs"] # a.rs only; not a.py, not target/ + + default_files = discover(root, cfg) # default suffixes -> Python only + assert sorted(p.name for p in default_files) == ["a.py"] + + +def test_target_dir_is_not_skipped_for_python(tmp_path: Path) -> None: + # `target` is a cargo build dir, skipped only in `.rs` mode. It is a perfectly + # legitimate Python package name, so a default `.py` scan must NOT silently + # drop code under `target/` — that would be exactly the under-scan wardline + # surfaces loudly everywhere else. (Pins the suffix-gate; the identity oracle + # does not discriminate global-vs-gated since no fixture has a `target/` dir.) + root = tmp_path / "root" + pkg = root / "src" / "target" + pkg.mkdir(parents=True) + (pkg / "m.py").write_text("y = 2\n", encoding="utf-8") + + cfg = WardlineConfig(source_roots=("src",)) + files = discover(root, cfg) + assert [p.name for p in files] == ["m.py"] + + +def test_discover_rust_symlink_confined(tmp_path: Path) -> None: + # The THREAT-001 confinement invariant holds for `.rs` discovery too: a `.rs` + # file-symlink inside a legitimate source_root pointing OUTSIDE the root is + # skipped with WLN-ENGINE-FILE-SKIPPED under confine_to_root, never read. + import pytest + + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "secret.rs" + secret.write_text("const SECRET: u8 = 1;\n") + + root = tmp_path / "root" + src = root / "src" + src.mkdir(parents=True) + real = src / "real.rs" + real.write_text("fn main() {}\n") + (src / "evil.rs").symlink_to(secret) + + cfg = WardlineConfig(source_roots=("src",)) + with pytest.warns(UserWarning, match="WLN-ENGINE-FILE-SKIPPED"): + files = discover(root, cfg, confine_to_root=True, suffixes=frozenset({".rs"})) + + resolved = {p.resolve() for p in files} + assert secret.resolve() not in resolved + assert real.resolve() in resolved + assert all(p.name != "evil.rs" for p in files) + + +def test_gitignored_dir_is_not_scanned(tmp_path: Path) -> None: + # The owner's top blocker: discover() must consult .gitignore and PRUNE matched + # directories during the walk, never descending a multi-GB gitignored third-party + # tree. A dir listed in the project-root .gitignore yields none of its files. + (tmp_path / ".gitignore").write_text("third_party/\n", encoding="utf-8") + (tmp_path / "app.py").write_text("x = 1\n", encoding="utf-8") + vendored = tmp_path / "third_party" / "huge" / "deep" + vendored.mkdir(parents=True) + (vendored / "lib.py").write_text("y = 2\n", encoding="utf-8") + + files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + + rel = [p.relative_to(tmp_path).as_posix() for p in files] + assert rel == ["app.py"] + assert all("third_party" not in p for p in rel) + + +def test_gitignored_walk_does_not_descend_ignored_dir(tmp_path: Path, monkeypatch) -> None: + # Pruning must happen DURING the walk (dirnames[:] in place), so os.walk never + # even enters the ignored subtree — not merely a post-filter. Assert the ignored + # directory is never yielded by the walk. + import wardline.core.discovery as discovery_mod + + (tmp_path / ".gitignore").write_text(".venv-vendor/\n", encoding="utf-8") + (tmp_path / "keep.py").write_text("x = 1\n", encoding="utf-8") + ignored = tmp_path / ".venv-vendor" / "pkg" + ignored.mkdir(parents=True) + (ignored / "dep.py").write_text("y = 2\n", encoding="utf-8") + + walked: list[str] = [] + real_walk = discovery_mod.os.walk + + def spy_walk(top, *args, **kwargs): + for dirpath, dirnames, filenames in real_walk(top, *args, **kwargs): + walked.append(Path(dirpath).name) + yield dirpath, dirnames, filenames + + monkeypatch.setattr(discovery_mod.os, "walk", spy_walk) + discover(tmp_path, WardlineConfig(source_roots=(".",))) + + assert ".venv-vendor" not in walked + assert "pkg" not in walked + + +def test_negated_gitignore_pattern_keeps_dir(tmp_path: Path) -> None: + # Last-match-wins with negation, at a path with no EXCLUDED parent: a dir matched + # by a glob and then re-admitted by a later `!` rule is still scanned. (Git cannot + # re-include a child of an excluded parent; that limitation is pinned in the + # gitignore-matcher unit test, verified against real `git check-ignore`.) + (tmp_path / ".gitignore").write_text("build*/\n!build-keep/\n", encoding="utf-8") + keep = tmp_path / "build-keep" + drop = tmp_path / "build-out" + keep.mkdir() + drop.mkdir() + (keep / "k.py").write_text("x = 1\n", encoding="utf-8") + (drop / "d.py").write_text("y = 2\n", encoding="utf-8") + + files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + + rel = [p.relative_to(tmp_path).as_posix() for p in files] + assert rel == ["build-keep/k.py"] + + +def test_expanded_floor_prunes_build_dist_eggs(tmp_path: Path) -> None: + # The default skip floor now also fences build/, dist/, .eggs/ and *.egg-info/ + # WITHOUT any .gitignore — these bloat a project-root scan. + (tmp_path / "real.py").write_text("x = 1\n", encoding="utf-8") + for noisy in ("build", "dist", ".eggs", "mypkg.egg-info"): + d = tmp_path / noisy + d.mkdir() + (d / "junk.py").write_text("y = 2\n", encoding="utf-8") + + files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + + assert [p.relative_to(tmp_path).as_posix() for p in files] == ["real.py"] + + +def test_nested_gitignore_layers(tmp_path: Path) -> None: + # A .gitignore in a subdirectory is layered in as the walk descends (git's nested + # ignore semantics), pruning only within its own subtree. + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / ".gitignore").write_text("generated/\n", encoding="utf-8") + (tmp_path / "pkg" / "real.py").write_text("x = 1\n", encoding="utf-8") + gen = tmp_path / "pkg" / "generated" + gen.mkdir() + (gen / "g.py").write_text("y = 2\n", encoding="utf-8") + # A sibling top-level "generated" is NOT ignored — the rule is scoped to pkg/. + other = tmp_path / "generated" + other.mkdir() + (other / "o.py").write_text("z = 3\n", encoding="utf-8") + + files = discover(tmp_path, WardlineConfig(source_roots=(".",))) + + rel = sorted(p.relative_to(tmp_path).as_posix() for p in files) + assert rel == ["generated/o.py", "pkg/real.py"] + + +def test_gitignore_does_not_prune_outside_root(tmp_path: Path) -> None: + # The gitignore base is the scan ROOT. A source_root that resolves outside the + # root (no confinement) has no gitignore context applied — discovery stays the + # explicit walk, never silently importing an unrelated tree's ignore rules. + root = tmp_path / "root" + root.mkdir() + (root / ".gitignore").write_text("data/\n", encoding="utf-8") + sibling = tmp_path / "sibling" + data = sibling / "data" + data.mkdir(parents=True) + (data / "keep.py").write_text("x = 1\n", encoding="utf-8") + + cfg = WardlineConfig(source_roots=("../sibling",)) + files = discover(root, cfg) + + # The root's `data/` rule must not reach into the out-of-root sibling tree. + rel = sorted(p.name for p in files) + assert rel == ["keep.py"] + + def test_no_confine_keeps_low_level_symlink_escape_behavior(tmp_path: Path) -> None: # With the low-level discovery opt-out, behavior is unchanged: the escaping # symlink is still discovered. diff --git a/tests/unit/core/test_dossier_assembler.py b/tests/unit/core/test_dossier_assembler.py index bc215772..2006ffdc 100644 --- a/tests/unit/core/test_dossier_assembler.py +++ b/tests/unit/core/test_dossier_assembler.py @@ -364,6 +364,33 @@ def test_assembled_envelope_is_token_bounded(tmp_path: Path) -> None: assert estimate_tokens(text) <= DOSSIER_TOKEN_BUDGET +def test_shape_source_text_cannot_bypass_dossier_budget(tmp_path: Path) -> None: + proj = tmp_path / "shape" + proj.mkdir() + long_default = repr("d" * 10_000) + long_annotation = "tuple[" + ", ".join(["str"] * 2_000) + "]" + long_decorator = repr("x" * 10_000) + (proj / "m.py").write_text( + "def noisy(*args, **kwargs):\n" + " def wrap(fn):\n" + " return fn\n" + " return wrap\n" + f"@noisy({long_decorator})\n" + f"def target(arg: {long_annotation} = {long_default}):\n" + " return arg\n", + encoding="utf-8", + ) + + d = build_dossier("m.target", root=proj) + text = json.dumps(d.to_dict(), sort_keys=True) + + assert estimate_tokens(text) <= DOSSIER_TOKEN_BUDGET + assert d.truncation.truncated is True + assert "shape" in (d.truncation.note or "") + assert d.shape.signature is not None + assert len(d.shape.signature) < 1_000 + + # --- synthesis is best-effort and degrades with its inputs ------------------ @@ -372,3 +399,49 @@ def test_synthesis_is_present_and_degrades_without_optional_sources(tmp_path: Pa # best-effort: mentions the live defect; never asserts a join it could not compute assert d.synthesis is not None assert "PY-WL-101" in d.synthesis + + +# --- N-8 (folded into wardline-8669de3576): resolve-against-scan-root remedy -- + + +def test_unknown_entity_error_names_nested_scan_root_remedy(tmp_path: Path) -> None: + # The CLI dossier run against a subdirectory rejects the package-qualified + # qualname the MCP dossier (rooted at the project) accepts — same root cause as + # N-3: qualnames are minted relative to the scan root. The not-found error must + # carry the resolve-against-scan-root remedy: name the scan-relative form that + # DOES match under this root and the project root to rerun against. + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + sub = proj / "specimen" + sub.mkdir() + (sub / "svc.py").write_text(_LEAKY, encoding="utf-8") + with pytest.raises(DossierError) as exc: + build_dossier("specimen.svc.leaky", root=sub) + msg = str(exc.value) + assert "specimen.svc.leaky" in msg + assert "'svc.leaky'" in msg # the scan-relative form that matches under this root + assert str(proj.resolve()) in msg # the project root to rerun against + + +def test_unknown_entity_error_nested_without_match_still_points_at_root(tmp_path: Path) -> None: + # Nested root but the entity genuinely doesn't exist under either form: the + # error still teaches the scan-root coupling and points at the project root. + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + sub = proj / "specimen" + sub.mkdir() + (sub / "svc.py").write_text(_LEAKY, encoding="utf-8") + with pytest.raises(DossierError) as exc: + build_dossier("specimen.svc.nonexistent", root=sub) + msg = str(exc.value) + assert str(proj.resolve()) in msg + assert "scan root" in msg + + +def test_unknown_entity_error_stays_plain_when_not_nested(tmp_path: Path) -> None: + # No enclosing project: the established terse error is unchanged. + with pytest.raises(DossierError) as exc: + build_dossier("svc.does_not_exist", root=_proj(tmp_path)) + msg = str(exc.value) + assert "entity not found in scanned set: svc.does_not_exist" in msg + assert "scan root" not in msg diff --git a/tests/unit/core/test_dossier_envelope.py b/tests/unit/core/test_dossier_envelope.py index 20720bb8..fa10d998 100644 --- a/tests/unit/core/test_dossier_envelope.py +++ b/tests/unit/core/test_dossier_envelope.py @@ -219,16 +219,16 @@ def test_oversize_from_synthesis_only_drops_synthesis_with_empty_elision() -> No assert estimate_tokens(json.dumps(bounded.to_dict(), sort_keys=True)) <= DOSSIER_TOKEN_BUDGET -def test_untrimmable_core_over_budget_is_marked_honestly() -> None: - # identity/shape are never trimmed, so a pathologically long qualname can leave - # the core over budget. The marker must NOT claim "trimmed to fit" — it must say - # the budget was not achievable (a dishonest marker is itself a false-green). +def test_oversized_core_scalars_are_compacted_and_marked() -> None: + # Source-controlled identity/shape text is load-bearing but not unlimited: a + # pathologically long qualname must be compacted rather than bypassing the budget. huge = _minimal_dossier(identity=_identity(qualname="m." + "x" * 12_000)) bounded = bound_to_budget(huge) assert bounded.truncation.truncated is True - assert estimate_tokens(json.dumps(bounded.to_dict(), sort_keys=True)) > DOSSIER_TOKEN_BUDGET - assert "EXCEEDS token budget" in (bounded.truncation.note or "") - # identity is preserved even when un-fittable + assert estimate_tokens(json.dumps(bounded.to_dict(), sort_keys=True)) <= DOSSIER_TOKEN_BUDGET + assert "core scalar fields compacted" in (bounded.truncation.note or "") + assert "identity.qualname" in (bounded.truncation.note or "") + assert "......" in bounded.identity.qualname assert bounded.identity.qualname.startswith("m.x") diff --git a/tests/unit/core/test_emit.py b/tests/unit/core/test_emit.py index 358f3923..7bbbf43d 100644 --- a/tests/unit/core/test_emit.py +++ b/tests/unit/core/test_emit.py @@ -1,7 +1,10 @@ import json from pathlib import Path +import pytest + from wardline.core.emit import JsonlSink +from wardline.core.errors import WardlineError from wardline.core.finding import Finding, Kind, Location, Severity @@ -29,3 +32,28 @@ def test_jsonl_sink_writes_empty_file_for_no_findings(tmp_path: Path) -> None: JsonlSink(out).write([]) assert out.exists() assert out.read_text(encoding="utf-8") == "" + + +def test_jsonl_sink_refuses_symlink_target(tmp_path: Path) -> None: + outside = tmp_path / "outside.jsonl" + outside.write_text("keep\n", encoding="utf-8") + out = tmp_path / "findings.jsonl" + out.symlink_to(outside) + + with pytest.raises(WardlineError, match="refusing to write through a symlink"): + JsonlSink(out).write([_finding()]) + + assert outside.read_text(encoding="utf-8") == "keep\n" + + +def test_jsonl_sink_with_root_refuses_parent_symlink_escape(tmp_path: Path) -> None: + root = tmp_path / "project" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (root / "reports").symlink_to(outside, target_is_directory=True) + + with pytest.raises(WardlineError, match="escapes project root"): + JsonlSink(root / "reports" / "findings.jsonl", root=root).write([_finding()]) + + assert not (outside / "findings.jsonl").exists() diff --git a/tests/unit/core/test_errors.py b/tests/unit/core/test_errors.py index 1f60b552..c9dad861 100644 --- a/tests/unit/core/test_errors.py +++ b/tests/unit/core/test_errors.py @@ -15,3 +15,27 @@ def test_subclasses_are_wardline_errors() -> None: def test_raises_and_is_catchable_as_base() -> None: with pytest.raises(WardlineError): raise errors.ConfigError("bad config") + + +def test_scheme_mismatch_error_is_actionable_configerror() -> None: + # Subclasses ConfigError so existing `except ConfigError` / CLI exit-2 + # mapping keeps working unchanged. + assert issubclass(errors.SchemeMismatchError, errors.ConfigError) + e = errors.SchemeMismatchError(store_name="baseline.yaml", found="wlfp0", expected="wlfp1") + msg = str(e) + assert "baseline.yaml" in msg # names the offending file + assert "wlfp1" in msg # names the scheme this build expects + assert "wardline rekey" in msg # the actionable next step + assert "--resume" in msg # the recovery path for an interrupted migration + assert e.store_name == "baseline.yaml" + assert e.found == "wlfp0" + assert e.expected == "wlfp1" + + +def test_scheme_mismatch_error_renders_absent_header() -> None: + # A store with NO fingerprint_scheme header passes found=None; the message + # must still be readable and still point at `wardline rekey`. + e = errors.SchemeMismatchError(store_name="waivers.yaml", found=None, expected="wlfp1") + msg = str(e) + assert "waivers.yaml" in msg + assert "wardline rekey" in msg diff --git a/tests/unit/core/test_explain.py b/tests/unit/core/test_explain.py index 947da0c3..273fab8f 100644 --- a/tests/unit/core/test_explain.py +++ b/tests/unit/core/test_explain.py @@ -96,3 +96,136 @@ def test_explain_finding_still_projects_provenance(tmp_path: Path) -> None: assert exp.sink_qualname == "svc.leaky" assert exp.immediate_tainted_callee == "read_raw" assert exp.source_boundary_qualname == "svc.read_raw" + + +# --- B7 (weft-0d24cf9152): sink-rule findings must name their taint source --------- + +# A PY-WL-125-style sink finding: the tainted value reaches the sink ARGUMENT (never +# the return), so the return-callee path cannot explain it — the sink-argument +# derivation must. +_SINK_LEAKY = ( + "import logging\n" + "from wardline.decorators import external_boundary, trusted\n" + "logger = logging.getLogger(__name__)\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef log_it(p):\n msg = read_raw(p)\n logger.info(msg)\n" +) + +# Same sink, the source call INLINE in the sink's argument list. +_SINK_LEAKY_INLINE = ( + "import logging\n" + "from wardline.decorators import external_boundary, trusted\n" + "logger = logging.getLogger(__name__)\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef log_it(p):\n logger.info(read_raw(p))\n" +) + +# The tainted argument comes from an IMPORTED callee wardline cannot resolve — the +# source is genuinely underivable from the single-scan analysis. +_SINK_UNDERIVABLE = ( + "import logging\n" + "from somewhere_else import fetch_text\n" + "from wardline.decorators import trusted\n" + "logger = logging.getLogger(__name__)\n" + "@trusted\ndef log_it(p):\n msg = fetch_text(p)\n logger.info(msg)\n" +) + + +def _sink_project(tmp_path: Path, source: str) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(source, encoding="utf-8") + return proj + + +def _finding_125(root: Path): + result = run_scan(root) + for f in result.findings: + if f.rule_id == "PY-WL-125": + return f + raise AssertionError("sink project has no PY-WL-125 finding") + + +def test_explain_sink_finding_names_the_taint_source(tmp_path: Path) -> None: + root = _sink_project(tmp_path, _SINK_LEAKY) + f = _finding_125(root) + exp = explain_finding(root, fingerprint=f.fingerprint) + assert exp is not None + # The actual source — read_raw, assigned to msg three lines above the sink — + # is named, with its one-hop boundary resolution. + assert exp.immediate_tainted_callee == "read_raw" + assert exp.source_boundary_qualname == "svc.read_raw" + # Sink findings carry tier facts in tier/arg_taint properties, not the + # return-mismatch keys — the explanation must project them, not null out. + assert exp.tier_in == "EXTERNAL_RAW" + assert exp.tier_out == "INTEGRAL" + + +def test_explain_sink_finding_names_an_inline_call_source(tmp_path: Path) -> None: + root = _sink_project(tmp_path, _SINK_LEAKY_INLINE) + f = _finding_125(root) + exp = explain_finding(root, fingerprint=f.fingerprint) + assert exp is not None + assert exp.immediate_tainted_callee == "read_raw" + assert exp.source_boundary_qualname == "svc.read_raw" + + +def test_explain_taint_result_marks_source_resolution_resolved(tmp_path: Path) -> None: + from wardline.core.explain import explain_taint_result + + root = _sink_project(tmp_path, _SINK_LEAKY) + f = _finding_125(root) + result = explain_taint_result(root, fingerprint=f.fingerprint) + assert result is not None + res = result["source_resolution"] + assert res["status"] == "resolved" + assert res["missing_capability"] is None + + +def test_explain_taint_result_degrades_honestly_when_source_underivable(tmp_path: Path) -> None: + # C-10(c): an unresolved source must SAY what is missing and how to enable it — + # never nulls that read as a complete-but-empty answer. + from wardline.core.explain import explain_taint_result + + root = _sink_project(tmp_path, _SINK_UNDERIVABLE) + f = _finding_125(root) + result = explain_taint_result(root, fingerprint=f.fingerprint, loomweave=None) + assert result is not None + assert result["immediate_tainted_callee"] is None + res = result["source_resolution"] + assert res["status"] == "unresolved" + assert res["reason"] # names WHY, not just null + assert res["missing_capability"] == "loomweave_taint_store" + assert "loomweave" in res["enablement"].lower() + + +def test_explain_taint_result_chain_unavailable_is_explicit(tmp_path: Path) -> None: + # chain=true without a Loomweave store used to degrade SILENTLY (no chain block); + # C-10(c) requires an explicit marker naming the capability and enablement path. + from wardline.core.explain import explain_taint_result + + root = _sink_project(tmp_path, _SINK_LEAKY) + f = _finding_125(root) + result = explain_taint_result(root, fingerprint=f.fingerprint, chain=True, loomweave=None) + assert result is not None + chain = result["chain"] + assert chain["status"] == "unavailable" + assert chain["hops"] == [] + assert chain["missing_capability"] == "loomweave_taint_store" + assert "loomweave" in chain["enablement"].lower() + + +def test_remediation_is_rule_specific_for_known_sink_rules(tmp_path: Path) -> None: + # B7: generic "review the finding" text on a SPECIFIC finding is part of the + # defect — PY-WL-125 has a canonical fix (lazy %-parameterization). + from wardline.core.explain import explain_taint_result + + root = _sink_project(tmp_path, _SINK_LEAKY) + f = _finding_125(root) + result = explain_taint_result(root, fingerprint=f.fingerprint) + assert result is not None + rem = result["remediation"] + assert rem["kind"] == "sink_hygiene" + assert rem["source_qualname"] == "svc.read_raw" + assert "parameteriz" in rem["summary"] or "%s" in rem["summary"] + assert "Review the finding and apply the rule-specific fix" not in rem["summary"] diff --git a/tests/unit/core/test_explain_chain.py b/tests/unit/core/test_explain_chain.py index 763035ad..5c92ae8b 100644 --- a/tests/unit/core/test_explain_chain.py +++ b/tests/unit/core/test_explain_chain.py @@ -103,3 +103,14 @@ def batch_get(self, qualnames): chain = explain_chain(proj, sink_qualname="svc.leaky", loomweave=RaisingClient(), max_hops=10) assert chain.hops == [] assert chain.truncated_at == "svc.leaky" + + +def test_chain_treats_malformed_callee_qualname_as_boundary_leaf(tmp_path): + proj = _proj(tmp_path) + client = MapClient({"svc.leaky": _fresh_view(proj, "svc.leaky", {"not": "a qualname"})}) + + chain = explain_chain(proj, sink_qualname="svc.leaky", loomweave=client, max_hops=10) + + assert [hop.qualname for hop in chain.hops] == ["svc.leaky"] + assert chain.hops[0].contributing_callee_qualname is None + assert chain.truncated_at is None diff --git a/tests/unit/core/test_explain_loomweave.py b/tests/unit/core/test_explain_loomweave.py index d824fdd2..6acdb85f 100644 --- a/tests/unit/core/test_explain_loomweave.py +++ b/tests/unit/core/test_explain_loomweave.py @@ -134,6 +134,85 @@ def counting(*a, **k): assert calls["n"] == 1 +def test_spoofed_remote_hash_falls_back_to_reanalysis(tmp_path, monkeypatch): + proj = _proj(tmp_path) + local_finding = next( + f + for f in run_scan(proj).findings + if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.ACTIVE and f.rule_id == "PY-WL-101" + ) + blob, _h = _fresh_blob(proj, "svc.leaky") + spoofed = "f" * 64 + blob["content_hash_at_compute"] = spoofed + blob["findings"] = [{"rule_id": "PY-WL-101", "fingerprint": "fp-forged", "path": "svc.py", "line_start": 6}] + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, current_content_hash=spoofed) + + import wardline.core.explain as explain_mod + + calls = {"n": 0} + real = explain_mod.run_scan + + def counting(*a, **k): + calls["n"] += 1 + return real(*a, **k) + + monkeypatch.setattr(explain_mod, "run_scan", counting) + + exp = explain_finding(proj, path="svc.py", line=6, loomweave=SpyClient([view]), sink_qualname="svc.leaky") + + assert exp is not None + assert exp.fingerprint == local_finding.fingerprint + assert exp.fingerprint != "fp-forged" + assert calls["n"] == 1 + + +def test_blob_qualname_mismatch_falls_back_to_reanalysis(tmp_path, monkeypatch): + proj = _proj(tmp_path) + blob, h = _fresh_blob(proj, "svc.other") + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, current_content_hash=h) + + import wardline.core.explain as explain_mod + + calls = {"n": 0} + real = explain_mod.run_scan + + def counting(*a, **k): + calls["n"] += 1 + return real(*a, **k) + + monkeypatch.setattr(explain_mod, "run_scan", counting) + + exp = explain_finding(proj, path="svc.py", line=6, loomweave=SpyClient([view]), sink_qualname="svc.leaky") + + assert exp is not None + assert exp.sink_qualname == "svc.leaky" + assert calls["n"] == 1 + + +def test_malformed_blob_counts_fall_back_to_reanalysis(tmp_path, monkeypatch): + proj = _proj(tmp_path) + blob, h = _fresh_blob(proj, "svc.leaky") + blob["taint"]["resolved_call_count"] = "not-an-int" + view = TaintFactView(qualname="svc.leaky", exists=True, wardline_json=blob, current_content_hash=h) + + import wardline.core.explain as explain_mod + + calls = {"n": 0} + real = explain_mod.run_scan + + def counting(*a, **k): + calls["n"] += 1 + return real(*a, **k) + + monkeypatch.setattr(explain_mod, "run_scan", counting) + + exp = explain_finding(proj, path="svc.py", line=6, loomweave=SpyClient([view]), sink_qualname="svc.leaky") + + assert exp is not None + assert exp.resolved_call_count == 1 + assert calls["n"] == 1 + + def test_stale_hash_falls_back_to_reanalysis(tmp_path): proj = _proj(tmp_path) blob, h = _fresh_blob(proj, "svc.leaky") diff --git a/tests/unit/core/test_filigree_destination.py b/tests/unit/core/test_filigree_destination.py new file mode 100644 index 00000000..86e02db7 --- /dev/null +++ b/tests/unit/core/test_filigree_destination.py @@ -0,0 +1,39 @@ +"""N1 / C-10(a): the emit destination must be visible at the caller so a wrong-project +write cannot read as silent success.""" + +from __future__ import annotations + +from wardline.core.filigree_emit import filigree_destination, filigree_url_project + + +def test_url_project_from_query() -> None: + assert filigree_url_project("http://127.0.0.1:8749/api/weft/scan-results?project=lacuna") == "lacuna" + + +def test_url_project_from_path() -> None: + assert filigree_url_project("http://127.0.0.1:8749/api/p/lacuna/weft/scan-results") == "lacuna" + + +def test_url_project_none_when_unpinned() -> None: + # The contamination shape: a bare endpoint pins no project, so Filigree resolves it + # server-side and a misroute is invisible unless surfaced. + assert filigree_url_project("http://127.0.0.1:8749/api/weft/scan-results") is None + assert filigree_url_project(None) is None + + +def test_destination_pinned() -> None: + d = filigree_destination("http://127.0.0.1:8749/api/weft/scan-results?project=lacuna") + assert d == { + "url": "http://127.0.0.1:8749/api/weft/scan-results?project=lacuna", + "project": "lacuna", + "project_pinned": True, + } + + +def test_destination_unpinned_is_visible() -> None: + d = filigree_destination("http://127.0.0.1:8749/api/weft/scan-results") + assert d == { + "url": "http://127.0.0.1:8749/api/weft/scan-results", + "project": None, + "project_pinned": False, + } diff --git a/tests/unit/core/test_filigree_emit.py b/tests/unit/core/test_filigree_emit.py index a9c90a24..7744cda0 100644 --- a/tests/unit/core/test_filigree_emit.py +++ b/tests/unit/core/test_filigree_emit.py @@ -7,11 +7,23 @@ from wardline.core.errors import FiligreeEmitError from wardline.core.filigree_emit import ( EmitResult, + FailedFinding, FiligreeEmitter, Response, build_scan_results_body, + filigree_disabled_reason, ) -from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState +from wardline.core.finding import ( + FINGERPRINT_SCHEME, + Finding, + Kind, + Location, + Severity, + SuppressionState, + to_filigree_metadata, +) + +_PREFIXED_A = f"{FINGERPRINT_SCHEME}:" + "a" * 64 def _f(**kw: object) -> Finding: @@ -34,6 +46,7 @@ def test_body_envelope() -> None: body = build_scan_results_body([_f()]) assert body["scan_source"] == "wardline" assert isinstance(body["findings"], list) and len(body["findings"]) == 1 + assert body["fingerprint_scheme"] == FINGERPRINT_SCHEME == "wlfp2" def test_scan_results_body_sets_mark_unseen() -> None: @@ -55,6 +68,22 @@ def test_scan_results_body_can_reconcile_clean_scanned_files() -> None: assert body["findings"] == [] +def test_scan_results_body_disables_mark_unseen_when_scan_is_unanalyzed() -> None: + parse_error = _f( + rule_id="WLN-ENGINE-PARSE-ERROR", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="src/m.py", line_start=1), + fingerprint="b" * 64, + ) + + body = build_scan_results_body([parse_error], scanned_paths=("src/m.py",)) + + assert body["mark_unseen"] is False + assert body["scanned_paths"] == ["src/m.py"] + assert body["findings"][0]["rule_id"] == "WLN-ENGINE-PARSE-ERROR" + + def test_finding_uses_path_not_file_path() -> None: wire = build_scan_results_body([_f()])["findings"][0] assert wire["path"] == "src/m.py" @@ -63,16 +92,23 @@ def test_finding_uses_path_not_file_path() -> None: def test_fingerprint_is_top_level_and_severity_lowercased() -> None: wire = build_scan_results_body([_f()])["findings"][0] - assert wire["fingerprint"] == "a" * 64 + # The WIRE value is scheme-prefixed (the envelope + value together let a + # Filigree consumer detect a scheme change); the in-memory Finding stays bare. + assert wire["fingerprint"] == _PREFIXED_A assert wire["severity"] == "high" # ERROR -> high assert wire["language"] == "python" assert wire["line_start"] == 5 and wire["line_end"] == 6 +def test_metadata_fingerprint_is_prefixed() -> None: + meta = to_filigree_metadata(_f()) + assert meta["wardline"]["fingerprint"] == _PREFIXED_A + + def test_metadata_namespaced_and_carries_suppression() -> None: wire = build_scan_results_body([_f(suppressed=SuppressionState.WAIVED, suppression_reason="fp")])["findings"][0] assert set(wire["metadata"]) == {"wardline"} - assert wire["metadata"]["wardline"]["suppressed"] == "waived" + assert wire["metadata"]["wardline"]["suppression_state"] == "waived" assert wire["metadata"]["wardline"]["suppression_reason"] == "fp" @@ -131,6 +167,27 @@ def test_success_surfaces_stats_and_warnings() -> None: assert json.loads(t.calls[0][1])["scan_source"] == "wardline" +def test_verify_token_acceptance_only_on_post_auth_statuses() -> None: + # Auth runs before body validation: 2xx and the sentinel-body 400 prove the bearer + # passed; 401/403 are reachable-but-rejected; a 5xx outage / wrong-route 404 / redirect + # never exercised auth and must be INCONCLUSIVE (reachable=False) so doctor --repair + # cannot pin a token on an unverified probe. + def probe(status: int): + t = _FakeTransport(response=Response(status=status, body="")) + return FiligreeEmitter("http://x/api/weft/scan-results", transport=t, token="T").verify_token() + + for ok_status in (200, 204, 400): + r = probe(ok_status) + assert (r.reachable, r.accepted) == (True, True), ok_status + for rejected in (401, 403): + r = probe(rejected) + assert (r.reachable, r.accepted) == (True, False), rejected + for inconclusive in (404, 500, 502, 301): + r = probe(inconclusive) + assert (r.reachable, r.accepted) == (False, False), inconclusive + assert r.status == inconclusive # status retained for diagnostics + + def test_http_400_raises_filigree_emit_error() -> None: t = _FakeTransport(response=Response(status=400, body='{"error":"bad path key"}')) with pytest.raises(FiligreeEmitError) as exc: @@ -138,6 +195,121 @@ def test_http_400_raises_filigree_emit_error() -> None: assert '{"error":"bad path key"}' in str(exc.value) # verbatim response body echoed +def test_http_400_can_degrade_to_warning_result() -> None: + t = _FakeTransport(response=Response(status=400, body='{"error":"payload too large"}')) + res = FiligreeEmitter("http://x", transport=t, protocol_errors_loud=False).emit([_f()]) + + assert res.reachable is True + assert res.failed == 1 + assert res.warnings + assert "payload too large" in res.warnings[0] + + +def test_large_emit_chunks_by_finding_cap() -> None: + class _ChunkTransport: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def post(self, url: str, body: bytes, headers: dict[str, str]) -> Response: # noqa: ARG002 + self.calls.append(json.loads(body.decode("utf-8"))) + return Response(status=200, body=_ok_body()) + + t = _ChunkTransport() + findings = [_f(location=Location(path=f"src/{name}.py", line_start=1)) for name in ("a", "b", "c")] + + res = FiligreeEmitter("http://x", transport=t, max_findings_per_request=2).emit( + findings, + scanned_paths=("src/a.py", "src/b.py", "src/c.py", "src/clean.py"), + ) + + assert res.reachable is True + assert len(t.calls) == 2 + assert [len(call["findings"]) for call in t.calls] == [2, 1] + assert all(call["mark_unseen"] is True for call in t.calls) + assert t.calls[-1]["scanned_paths"] == ["src/c.py", "src/clean.py"] + + +def test_schema_advertised_limit_drives_chunking_when_no_override() -> None: + class _SchemaTransport: + def __init__(self) -> None: + self.gets: list[tuple[str, dict[str, str]]] = [] + self.posts: list[dict[str, object]] = [] + + def get(self, url: str, headers: dict[str, str]) -> Response: + self.gets.append((url, dict(headers))) + return Response( + status=200, + body=json.dumps( + { + "endpoints": { + "POST /api/scan-results": { + "limits": {"max_findings_per_request": 2}, + } + } + } + ), + ) + + def post(self, url: str, body: bytes, headers: dict[str, str]) -> Response: # noqa: ARG002 + self.posts.append(json.loads(body.decode("utf-8"))) + return Response(status=200, body=_ok_body()) + + t = _SchemaTransport() + findings = [_f(location=Location(path=f"src/{name}.py", line_start=1)) for name in ("a", "b", "c")] + + FiligreeEmitter("http://x/api/p/demo/weft/scan-results", transport=t).emit(findings) + + assert t.gets == [("http://x/api/p/demo/files/_schema", {"Content-Type": "application/json"})] + assert [len(call["findings"]) for call in t.posts] == [2, 1] + + +def test_explicit_limit_takes_precedence_over_schema_limit() -> None: + class _SchemaTransport: + def __init__(self) -> None: + self.get_called = False + self.posts: list[dict[str, object]] = [] + + def get(self, url: str, headers: dict[str, str]) -> Response: # noqa: ARG002 + self.get_called = True + return Response(status=200, body=json.dumps({"scan_results": {"max_findings_per_request": 1}})) + + def post(self, url: str, body: bytes, headers: dict[str, str]) -> Response: # noqa: ARG002 + self.posts.append(json.loads(body.decode("utf-8"))) + return Response(status=200, body=_ok_body()) + + t = _SchemaTransport() + findings = [_f(location=Location(path=f"src/{name}.py", line_start=1)) for name in ("a", "b", "c")] + + FiligreeEmitter("http://x/api/weft/scan-results", transport=t, max_findings_per_request=3).emit(findings) + + assert t.get_called is False + assert [len(call["findings"]) for call in t.posts] == [3] + + +def test_oversize_single_file_chunk_disables_mark_unseen() -> None: + class _ChunkTransport: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def post(self, url: str, body: bytes, headers: dict[str, str]) -> Response: # noqa: ARG002 + self.calls.append(json.loads(body.decode("utf-8"))) + return Response(status=200, body=_ok_body()) + + t = _ChunkTransport() + findings = [ + _f(location=Location(path="src/a.py", line_start=1), fingerprint="b" * 64), + _f(location=Location(path="src/a.py", line_start=2), fingerprint="c" * 64), + ] + + FiligreeEmitter("http://x", transport=t, max_findings_per_request=1).emit( + findings, + scanned_paths=("src/a.py",), + ) + + assert len(t.calls) == 2 + assert all(call["mark_unseen"] is False for call in t.calls) + + def test_http_5xx_is_sibling_degraded_not_loud() -> None: # A server outage (503) is the sibling's fault, not a Wardline payload bug: # warn + continue (reachable=False), never exit-2. (Charter: non-load-bearing.) @@ -214,6 +386,47 @@ def test_no_authorization_header_when_no_token() -> None: assert "Authorization" not in t.calls[0][2] +# --- C-7: token-absent vs token-rejected (weft-23574069a1) ------------------- + + +def test_emit_stamps_token_sent_and_url() -> None: + url = "http://x/api/weft/scan-results" + t = _FakeTransport(response=Response(status=401, body="no")) + with_token = FiligreeEmitter(url, transport=t, token="wrong").emit([_f()]) + assert with_token.token_sent is True and with_token.url == url + t2 = _FakeTransport(response=Response(status=401, body="no")) + no_token = FiligreeEmitter(url, transport=t2).emit([_f()]) + assert no_token.token_sent is False and no_token.url == url + # success path also stamps token_sent + url + t3 = _FakeTransport(response=Response(status=200, body=_ok_body())) + ok = FiligreeEmitter(url, transport=t3, token="good").emit([_f()]) + assert ok.token_sent is True and ok.url == url + + +def test_disabled_reason_401_distinguishes_no_token_from_rejected() -> None: + url = "http://h/api/weft/scan-results" + # A token WAS sent and rejected — say the value is wrong, not "set a token" (the C-7 + # misdiagnosis). Names the URL it tried. + rejected = filigree_disabled_reason(reachable=False, status=401, token_sent=True, url=url) + assert rejected is not None + assert "401" in rejected and "wrong" in rejected and url in rejected + assert "no token sent" not in rejected + # No token sent — that is the "set WEFT_FEDERATION_TOKEN" case. + absent = filigree_disabled_reason(reachable=False, status=401, token_sent=False, url=url) + assert absent is not None + assert "no token sent" in absent and "WEFT_FEDERATION_TOKEN" in absent and url in absent + + +def test_disabled_reason_403_and_unreachable_unchanged_in_shape() -> None: + url = "http://h/api/weft/scan-results" + forbidden = filigree_disabled_reason(reachable=False, status=403, token_sent=True, url=url) + assert forbidden is not None and "403" in forbidden and "lacks access" in forbidden + unreachable = filigree_disabled_reason(reachable=False, status=None, token_sent=False, url=url) + assert unreachable is not None and "unreachable" in unreachable and url in unreachable + # reached/success -> no disabled_reason + assert filigree_disabled_reason(reachable=True, status=None) is None + + def test_2xx_with_unparseable_body_warns_not_crashes() -> None: # POST accepted (2xx) but the body is not a JSON object -> surface a warning, # reachable=True, zeroed stats; must NOT raise. @@ -236,6 +449,98 @@ def test_failed_list_surfaced_in_result() -> None: assert res.failed == 1 +# --- PDR-0023 honesty invariant: partial ingest carries machine-readable reasons ---------- + + +def test_clean_run_reports_empty_failures_earned() -> None: + # The earned empty list: a fully-successful emit has no failures, and the derived count + # agrees. This is the true-negative half the invariant must stay distinguishable from. + body = json.dumps({"stats": {"findings_created": 2}, "failed": [], "warnings": []}) + res = FiligreeEmitter("http://x", transport=_FakeTransport(Response(200, body))).emit([_f(), _f()]) + assert res.failures == () + assert res.failed == 0 + + +def test_partial_ingest_surfaces_per_finding_reasons() -> None: + # The named golden vector: a 2xx where Filigree rejected SOME findings must not read as a + # clean emit. Each reject lands in failures[] with a machine-readable reason + its + # fingerprint, so an agent can tell "M of N emitted, K failed because R" from success. + body = json.dumps( + { + "stats": {"findings_created": 1, "findings_updated": 0}, + "failed": [ + {"fingerprint": "wlfp2:bad1", "reason": "validation-error", "detail": "line_start required"}, + {"fingerprint": "wlfp2:bad2", "reason": "scheme mismatch", "detail": "expected wlfp3"}, + {"fingerprint": "wlfp2:bad3"}, # no reason → degrades to a loud 'rejected', not dropped + ], + "warnings": [], + } + ) + res = FiligreeEmitter("http://x", transport=_FakeTransport(Response(200, body))).emit([_f()]) + assert res.failed == 3 + assert [(f.reason, f.fingerprint) for f in res.failures] == [ + ("validation_error", "wlfp2:bad1"), + ("scheme_mismatch", "wlfp2:bad2"), + ("rejected", "wlfp2:bad3"), + ] + assert res.failures[0].detail == "line_start required" + # The honesty contract: this is NOT byte-indistinguishable from a clean emit. + clean = json.dumps({"stats": {"findings_created": 1}, "failed": [], "warnings": []}) + clean_res = FiligreeEmitter("http://x", transport=_FakeTransport(Response(200, clean))).emit([_f()]) + assert clean_res.failures == () and clean_res.failed == 0 + assert clean_res != res # a partial and a clean emit are distinguishable values + + +def test_failures_serialize_to_wire_with_reason() -> None: + res = FiligreeEmitter( + "http://x", + transport=_FakeTransport( + Response(200, json.dumps({"stats": {}, "failed": [{"fingerprint": "wlfp2:z", "reason": "rejected"}]})) + ), + ).emit([_f()]) + # weft-reason (G1): the wire carries the shipped domain fields (reason/detail) AND the + # canonical carrier triple {reason_class, cause, fix} additively (rejected -> rejected). + assert [f.to_wire() for f in res.failures] == [ + { + "reason": "rejected", + "detail": "", + "reason_class": "rejected", + "cause": "rejected", + "fix": ( + "inspect the per-finding reject cause in Filigree's report and re-emit once the finding is acceptable" + ), + "fingerprint": "wlfp2:z", + } + ] + + +def test_protocol_reject_fail_soft_records_each_pending_finding_as_partial() -> None: + # Fail-soft (protocol_errors_loud=False): a rejected chunk is no longer flattened to an + # opaque count. EVERY still-pending finding becomes a 'partial' failure carrying the + # status, so the caller reads which findings did not land and why — not "created N" minus + # a silent number. + t = _FakeTransport(response=Response(status=422, body='{"error":"payload too large"}')) + res = FiligreeEmitter("http://x", transport=t, protocol_errors_loud=False).emit([_f(), _f()]) + assert res.reachable is True + assert res.failed == 2 + assert all(f.reason == "partial" for f in res.failures) + assert all("422" in f.detail for f in res.failures) + assert all(f.fingerprint == _PREFIXED_A for f in res.failures) + + +def test_failed_count_is_derived_from_failures_and_cannot_disagree() -> None: + # The count is a property over failures — there is no setter to hardwire a contradictory + # failed=0 while failures is non-empty (the confident-empty defect the invariant forbids). + r = EmitResult(reachable=True, created=1, failures=(FailedFinding(reason="rejected", fingerprint="wlfp2:q"),)) + assert r.failed == 1 + assert EmitResult(reachable=True).failed == 0 + + +def test_failed_finding_rejects_unknown_reason() -> None: + with pytest.raises(ValueError, match="unknown emit-failure reason"): + FailedFinding(reason="totally-made-up") + + def test_connection_error_is_sibling_absent() -> None: t = _FakeTransport(exc=ConnectionRefusedError("no server")) res = FiligreeEmitter("http://x", transport=t).emit([_f()]) @@ -328,5 +633,5 @@ def test_judged_finding_carries_suppression_metadata() -> None: wire = build_scan_results_body([_f(suppressed=SuppressionState.JUDGED, suppression_reason="over-taint floor")])[ "findings" ][0] - assert wire["metadata"]["wardline"]["suppressed"] == "judged" + assert wire["metadata"]["wardline"]["suppression_state"] == "judged" assert wire["metadata"]["wardline"]["suppression_reason"] == "over-taint floor" diff --git a/tests/unit/core/test_filigree_issue.py b/tests/unit/core/test_filigree_issue.py index c1925d51..5e76694f 100644 --- a/tests/unit/core/test_filigree_issue.py +++ b/tests/unit/core/test_filigree_issue.py @@ -6,6 +6,7 @@ import pytest from wardline.core.errors import FiligreeEmitError +from wardline.core.filigree_emit import build_scan_results_body from wardline.core.filigree_issue import ( FileResult, FiligreeIssueFiler, @@ -13,8 +14,27 @@ Response, api_base_url_from_weft, attach_loomweave_identity_for_finding, + build_promote_body, promote_url_from_weft, ) +from wardline.core.finding import Finding, Kind, Location, Severity + + +def test_promote_wire_fingerprint_matches_ingest_wire() -> None: + # The promote join key MUST equal the value scan-results ingested, or Filigree's + # exact-match lookup 404s against the finding it just stored. Lock the two wires + # symmetric: both carry the scheme-prefixed form for the same bare fingerprint. + f = Finding( + rule_id="PY-WL-101", + message="m", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="svc.py", line_start=1), + fingerprint="a" * 64, + ) + ingested = build_scan_results_body([f])["findings"][0]["fingerprint"] + promoted = build_promote_body(fingerprint=f.fingerprint)["fingerprint"] + assert promoted == ingested == "wlfp2:" + "a" * 64 class FakeTransport: @@ -50,9 +70,35 @@ def test_api_base_url_derived_from_scan_results_url(): assert api_base_url_from_weft("http://h:8628/api/weft/scan-results") == "http://h:8628/api" -def test_promote_url_rejects_non_weft_url(): - with pytest.raises(FiligreeEmitError, match="/api/weft/"): - promote_url_from_weft("http://h/api/something/else") +def test_promote_url_preserves_project_scoped_path_dialect(): + # Dogfood-4 A3: the installer writes the /api/p// scoped endpoint; the promote + # route must stay inside that scope or the promote lands in the default project. + assert ( + promote_url_from_weft("http://127.0.0.1:8749/api/p/lacuna/weft/scan-results") + == "http://127.0.0.1:8749/api/p/lacuna/weft/findings/promote" + ) + + +def test_api_base_url_preserves_project_scoped_path_dialect(): + assert ( + api_base_url_from_weft("http://127.0.0.1:8749/api/p/lacuna/weft/scan-results") + == "http://127.0.0.1:8749/api/p/lacuna" + ) + + +def test_query_project_dialect_converts_to_path_scope(): + # ?project= is honored only on weft-scoped routes server-side; derived routes must + # carry the scope as the path dialect, which Filigree dual-mounts for ALL routes. + assert api_base_url_from_weft("http://h:8749/api/weft/scan-results?project=lacuna") == "http://h:8749/api/p/lacuna" + assert ( + promote_url_from_weft("http://h:8749/api/weft/scan-results?project=lacuna") + == "http://h:8749/api/p/lacuna/weft/findings/promote" + ) + + +def test_promote_url_rejects_non_http_scheme(): + with pytest.raises(FiligreeEmitError, match="http or https"): + promote_url_from_weft("file:///etc/passwd") def test_file_returns_issue_id_on_200(): @@ -60,9 +106,11 @@ def test_file_returns_issue_id_on_200(): filer = FiligreeIssueFiler("http://h/api/weft/scan-results", transport=t) res = filer.file("fp123", priority="P2") assert res == FileResult(reachable=True, issue_id="wardline-abc", created=True) - # The request carried the fingerprint + scan_source to the promote route. + # The request carried the fingerprint + scan_source to the promote route. The wire + # value is scheme-PREFIXED (symmetric with the ingest wire) so the promote join + # matches what scan-results stored; the caller passed the bare in-memory value. assert t.last["url"].endswith("/api/weft/findings/promote") - assert t.last["body"] == {"scan_source": "wardline", "fingerprint": "fp123", "priority": "P2"} + assert t.last["body"] == {"scan_source": "wardline", "fingerprint": "wlfp2:fp123", "priority": "P2"} def test_file_already_linked_created_false(): @@ -182,7 +230,8 @@ class DownLoomweave: def capabilities(self): return None - def resolve(self, qualnames): + def resolve(self, qualnames, *, plugin=None): + self.plugin_hints = [*getattr(self, "plugin_hints", []), plugin] return None @@ -190,7 +239,8 @@ class LegacyLoomweave: def capabilities(self): return None - def resolve(self, qualnames): + def resolve(self, qualnames, *, plugin=None): + self.plugin_hints = [*getattr(self, "plugin_hints", []), plugin] return SimpleNamespace(resolved={qualnames[0]: "python:function:pkg.mod.leaky"}, unresolved=[]) def get_taint_fact(self, qualname): @@ -312,3 +362,128 @@ def test_attach_loomweave_identity_reports_association_failure(monkeypatch, tmp_ assert res.attached is False assert res.entity_id == "loomweave:eid:abc" assert res.reason == "filigree association returned HTTP 500" + + +class RustFakeFinding: + def __init__(self, qualname): + self.qualname = qualname + self.rule_id = "RS-WL-101" + + +def test_plugin_for_finding_discriminates_by_rule_family(): + from wardline.core.filigree_issue import plugin_for_finding + + assert plugin_for_finding(RustFakeFinding("demo.m.f")) == "rust" + assert plugin_for_finding(FakeFinding("pkg.mod.leaky")) == "python" # no rule_id attr -> python + assert plugin_for_finding(SimpleNamespace(rule_id="PY-WL-101")) == "python" + + +def test_attach_threads_rust_plugin_through_locator_resolve_and_entity_kind(monkeypatch, tmp_path): + # ADR-036 plugin hint, end to end on the Wardline side: a Rust finding mints a + # rust: locator for the SEI hop, sends plugin="rust" on the legacy resolve hop, + # and stamps the association entity_kind as rust:function. + from wardline.core import filigree_issue as mod + + monkeypatch.setattr(mod, "_finding_for_fingerprint", lambda fp, root, cfg: RustFakeFinding("demo.m.leaky")) + + class RustLegacyLoomweave: + def __init__(self): + self.identity_locators = [] + self.plugin_hints = [] + + def capabilities(self): + return None # pre-SEI -> the legacy locator path + + def resolve(self, qualnames, *, plugin=None): + self.plugin_hints.append(plugin) + return SimpleNamespace(resolved={qualnames[0]: "rust:function:demo.m.leaky"}, unresolved=[]) + + def resolve_identity(self, locator): + self.identity_locators.append(locator) + return None + + def get_taint_fact(self, qualname): + return SimpleNamespace(current_content_hash="rust-hash") + + client = RustLegacyLoomweave() + transport = RecordingTransport() + filer = FiligreeIssueFiler("http://f/api/weft/scan-results", transport=transport) + + res = attach_loomweave_identity_for_finding( + fingerprint="fp1", + issue_id="wardline-1", + root=tmp_path, + filer=filer, + loomweave_client=client, + ) + + assert client.plugin_hints == ["rust"] + assert res.attached is True + assert transport.calls[0]["body"]["entity_id"] == "rust:function:demo.m.leaky" + assert transport.calls[0]["body"]["entity_kind"] == "rust:function" + + +def test_file_2xx_non_string_issue_id_is_normalized_to_none(): + # Filigree's promote response is external input: a non-string issue_id (e.g. an + # integer) must not flow verbatim into tool payloads that publish issue_id as + # string|null in their MCP outputSchema — type-narrow at the wire boundary. + t = FakeTransport(200, '{"issue_id": 123, "created": true}') + res = FiligreeIssueFiler("http://h/api/weft/scan-results", transport=t).file("fp") + assert res.reachable is True and res.issue_id is None and res.created is True + + +# --- resolve_entity_binding_input (doctrine inline SEI-on-entry) ------------------- + + +def test_resolve_entity_binding_input_none_when_no_refs(): + from wardline.core.filigree_issue import resolve_entity_binding_input + + assert resolve_entity_binding_input(entity_id=None, entity_symbol=None, loomweave_client=SeiLoomweave()) is None + + +def test_resolve_entity_binding_input_l1_opaque_sei_carried_verbatim(): + from wardline.core.filigree_issue import resolve_entity_binding_input + + out = resolve_entity_binding_input(entity_id="loomweave:eid:zzz", entity_symbol=None, loomweave_client=None) + assert out is not None and out.resolved is True + assert out.entity_id == "loomweave:eid:zzz" + assert out.binding_kind == "sei" + + +def test_resolve_entity_binding_input_l1_opaque_locator_marked_locator(): + from wardline.core.filigree_issue import resolve_entity_binding_input + + out = resolve_entity_binding_input( + entity_id="python:function:pkg.mod.fn", entity_symbol=None, loomweave_client=None + ) + assert out is not None and out.resolved is True + assert out.binding_kind == "locator" + + +def test_resolve_entity_binding_input_l2_symbol_resolves_to_sei(): + from wardline.core.filigree_issue import resolve_entity_binding_input + + out = resolve_entity_binding_input(entity_id=None, entity_symbol="pkg.mod.leaky", loomweave_client=SeiLoomweave()) + assert out is not None and out.resolved is True + assert out.entity_id == "loomweave:eid:abc" + assert out.content_hash == "hash-v1" + assert out.binding_kind == "sei" + + +def test_resolve_entity_binding_input_l2_unresolved_returns_carrier(): + from wardline.core.filigree_issue import resolve_entity_binding_input + + # A Loomweave with no SEI capability cannot resolve a symbol -> unresolved_input. + out = resolve_entity_binding_input(entity_id=None, entity_symbol="pkg.mod.ghost", loomweave_client=DownLoomweave()) + assert out is not None and out.resolved is False + assert out.reason_class == "unresolved_input" + assert out.cause and out.fix + + +def test_resolve_entity_binding_input_l2_no_client_is_unresolved(): + from wardline.core.filigree_issue import resolve_entity_binding_input + + out = resolve_entity_binding_input(entity_id=None, entity_symbol="pkg.mod.leaky", loomweave_client=None) + assert out is not None and out.resolved is False + assert out.reason_class == "unresolved_input" + assert "no Loomweave URL" in out.cause diff --git a/tests/unit/core/test_filigree_verify_token.py b/tests/unit/core/test_filigree_verify_token.py new file mode 100644 index 00000000..2ee3916e --- /dev/null +++ b/tests/unit/core/test_filigree_verify_token.py @@ -0,0 +1,57 @@ +# tests/unit/core/test_filigree_verify_token.py +from collections.abc import Mapping + +from wardline.core.filigree_emit import FiligreeEmitter, Response + + +class _FakeTransport: + """Records the last POST and returns a canned Response or raises.""" + + def __init__(self, *, status: int | None = None, exc: Exception | None = None) -> None: + self._status = status + self._exc = exc + self.calls: list[tuple[str, bytes, dict[str, str]]] = [] + + def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: + self.calls.append((url, body, dict(headers))) + if self._exc is not None: + raise self._exc + assert self._status is not None + return Response(status=self._status, body="") + + +def test_verify_token_401_is_rejected() -> None: + t = _FakeTransport(status=401) + result = FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="bad").verify_token() + assert result.reachable is True + assert result.accepted is False + assert result.status == 401 + + +def test_verify_token_400_is_accepted() -> None: + # Auth middleware runs before body validation: a good token + sentinel body => 400. + t = _FakeTransport(status=400) + result = FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="good").verify_token() + assert result.accepted is True + assert result.status == 400 + # Sentinel body present; bearer attached. The body MUST be the deliberately-incomplete + # b"{}" sentinel, NOT a real scan-results body — a regression to emit()-style content + # (build_scan_results_body([]) is also truthy) would register an empty scan on the + # daemon every doctor probe. Pin the exact content so that regression fails here. + url, body, headers = t.calls[0] + assert headers["Authorization"] == "Bearer good" + assert body == b"{}" # sentinel only; no findings/scan_source body + assert b"findings" not in body and b"scan_source" not in body + + +def test_verify_token_403_is_rejected() -> None: + result = FiligreeEmitter("http://x/y", transport=_FakeTransport(status=403), token="t").verify_token() + assert result.accepted is False + + +def test_verify_token_transport_error_is_unreachable() -> None: + t = _FakeTransport(exc=OSError("connection refused")) + result = FiligreeEmitter("http://127.0.0.1:8749/api/weft/scan-results", transport=t, token="t").verify_token() + assert result.reachable is False + assert result.accepted is False + assert result.status is None diff --git a/tests/unit/core/test_finding.py b/tests/unit/core/test_finding.py index 7c1c8ee4..1f54e1d3 100644 --- a/tests/unit/core/test_finding.py +++ b/tests/unit/core/test_finding.py @@ -7,6 +7,7 @@ Location, Severity, compute_finding_fingerprint, + to_filigree_metadata, ) @@ -53,26 +54,20 @@ def test_to_jsonl_round_trips_collections() -> None: def test_finding_fingerprint_is_deterministic_and_discriminating() -> None: - a = compute_finding_fingerprint( - rule_id="PY-WL-101", path="a.py", line_start=1, qualname="m.f", taint_path="EXTERNAL_RAW|g" - ) - b = compute_finding_fingerprint( - rule_id="PY-WL-101", path="a.py", line_start=1, qualname="m.f", taint_path="EXTERNAL_RAW|g" - ) + a = compute_finding_fingerprint(rule_id="PY-WL-101", path="a.py", qualname="m.f", taint_path="EXTERNAL_RAW|g") + b = compute_finding_fingerprint(rule_id="PY-WL-101", path="a.py", qualname="m.f", taint_path="EXTERNAL_RAW|g") # same inputs -> stable assert a == b assert len(a) == 64 # path-sensitive assert a != compute_finding_fingerprint( - rule_id="PY-WL-101", path="b.py", line_start=1, qualname="m.f", taint_path="EXTERNAL_RAW|g" - ) - # TWO TAINT PATHS INTO ONE SINK: same (rule, file, line, qualname) but a - # different taint path -> DISTINCT fingerprint (Filigree constraint, §7). - assert a != compute_finding_fingerprint( - rule_id="PY-WL-101", path="a.py", line_start=1, qualname="m.f", taint_path="MIXED_RAW|h" + rule_id="PY-WL-101", path="b.py", qualname="m.f", taint_path="EXTERNAL_RAW|g" ) + # TWO TAINT PATHS INTO ONE SINK: same (rule, file, qualname) but a different + # taint path -> DISTINCT fingerprint (Filigree constraint, §7). + assert a != compute_finding_fingerprint(rule_id="PY-WL-101", path="a.py", qualname="m.f", taint_path="MIXED_RAW|h") # optional fields default cleanly - assert len(compute_finding_fingerprint(rule_id="WLN-ENGINE-X", path="a.py", line_start=None)) == 64 + assert len(compute_finding_fingerprint(rule_id="WLN-ENGINE-X", path="a.py")) == 64 def test_finding_defaults_to_active_suppression() -> None: @@ -87,13 +82,13 @@ def test_suppressed_serializes_in_jsonl() -> None: f = _finding(suppressed=SuppressionState.WAIVED, suppression_reason="reviewed") obj = json.loads(f.to_jsonl()) - assert obj["suppressed"] == "waived" + assert obj["suppression_state"] == "waived" assert obj["suppression_reason"] == "reviewed" def test_active_suppression_serializes_too() -> None: obj = json.loads(_finding().to_jsonl()) - assert obj["suppressed"] == "active" + assert obj["suppression_state"] == "active" assert obj["suppression_reason"] is None @@ -112,7 +107,15 @@ def test_filigree_metadata_includes_suppression_only_when_suppressed() -> None: from wardline.core.finding import SuppressionState, to_filigree_metadata active = to_filigree_metadata(_finding())["wardline"] - assert "suppressed" not in active + assert "suppression_state" not in active waived = to_filigree_metadata(_finding(suppressed=SuppressionState.WAIVED, suppression_reason="ok"))["wardline"] - assert waived["suppressed"] == "waived" + assert waived["suppression_state"] == "waived" assert waived["suppression_reason"] == "ok" + + +def test_filigree_metadata_strips_property_accessor_suffix_from_wire_qualname() -> None: + setter = to_filigree_metadata(_finding(qualname="pkg.mod.C.value:setter"))["wardline"] + deleter = to_filigree_metadata(_finding(qualname="pkg.mod.C.value:deleter"))["wardline"] + + assert setter["qualname"] == "pkg.mod.C.value" + assert deleter["qualname"] == "pkg.mod.C.value" diff --git a/tests/unit/core/test_finding_identity.py b/tests/unit/core/test_finding_identity.py new file mode 100644 index 00000000..4fda200c --- /dev/null +++ b/tests/unit/core/test_finding_identity.py @@ -0,0 +1,91 @@ +"""P1 scheme-infra S8: the single JOIN predicate the suppression layer calls. + +``resolve_identity`` factors the waiver > judged > baseline match (and its +reason) out of ``apply_suppressions`` into one place — so a later phase (P4) can +populate ``drifted_from`` without touching the suppression-layer signature. This +phase: ``drifted_from`` is always None. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + +from wardline.core.baseline import Baseline +from wardline.core.finding_identity import IdentityResolution, resolve_identity +from wardline.core.judged import JudgedFP, JudgedSet +from wardline.core.waivers import WaiverSet, parse_waivers + +_FP = "a" * 64 +_TODAY = date(2026, 5, 30) + + +def _waivers(reason: str = "human waiver", expires: str | None = None) -> WaiverSet: + item: dict[str, object] = {"fingerprint": _FP, "reason": reason} + if expires is not None: + item["expires"] = expires + return WaiverSet(parse_waivers([item])) + + +def _judged() -> JudgedSet: + return JudgedSet( + [ + JudgedFP( + fingerprint=_FP, + rule_id="PY-WL-101", + path="src/m.py", + message="m", + rationale="over-taint floor", + model_id="m", + confidence=0.9, + recorded_at=datetime(2026, 5, 30, tzinfo=UTC), + policy_hash="sha256:x", + ) + ] + ) + + +def test_waiver_wins_over_judged_and_baseline() -> None: + r = resolve_identity(_FP, baseline=Baseline(frozenset({_FP})), waivers=_waivers(), judged=_judged(), today=_TODAY) + assert r == IdentityResolution(matched=True, matched_on="waiver", drifted_from=None, reason="human waiver") + + +def test_judged_when_no_waiver() -> None: + r = resolve_identity( + _FP, baseline=Baseline(frozenset({_FP})), waivers=WaiverSet(()), judged=_judged(), today=_TODAY + ) + assert r.matched is True + assert r.matched_on == "judged" + assert r.reason == "over-taint floor" + assert r.drifted_from is None + + +def test_baseline_when_neither_waiver_nor_judged() -> None: + r = resolve_identity( + _FP, baseline=Baseline(frozenset({_FP})), waivers=WaiverSet(()), judged=JudgedSet([]), today=_TODAY + ) + assert r.matched is True + assert r.matched_on == "baseline" + assert r.reason is None # baseline carries no reason + + +def test_no_match_is_unmatched() -> None: + r = resolve_identity(_FP, baseline=Baseline(frozenset()), waivers=WaiverSet(()), judged=JudgedSet([]), today=_TODAY) + assert r == IdentityResolution(matched=False, matched_on=None, drifted_from=None, reason=None) + + +def test_expired_waiver_does_not_match_falls_to_baseline() -> None: + r = resolve_identity( + _FP, + baseline=Baseline(frozenset({_FP})), + waivers=_waivers(expires="2026-05-29"), # expired before today + judged=JudgedSet([]), + today=_TODAY, + ) + assert r.matched_on == "baseline" # expired waiver is not a match + + +def test_drifted_from_is_none_this_phase() -> None: + r = resolve_identity( + _FP, baseline=Baseline(frozenset({_FP})), waivers=WaiverSet(()), judged=JudgedSet([]), today=_TODAY + ) + assert r.drifted_from is None diff --git a/tests/unit/core/test_finding_query.py b/tests/unit/core/test_finding_query.py index 6f538ac1..ad3d2e44 100644 --- a/tests/unit/core/test_finding_query.py +++ b/tests/unit/core/test_finding_query.py @@ -81,3 +81,42 @@ def test_conjunction_all_must_match(): def test_unknown_key_raises_valueerror(): with pytest.raises(ValueError, match="unknown filter key"): filter_findings([_f()], {"bogus": "x"}) + + +# --- N-5 (wardline-dc6f44707d): closed-vocab values normalize, never silent-empty --- + + +def test_severity_matches_case_insensitively(): + a = _f(severity=Severity.WARN) + assert filter_findings([a], {"severity": "warn"}) == [a] + assert filter_findings([a], {"severity": "WARN"}) == [a] + assert filter_findings([a], {"severity": "Warn"}) == [a] + + +def test_kind_and_suppression_match_case_insensitively(): + a = _f(kind=Kind.FACT, suppressed=SuppressionState.BASELINED, severity=Severity.NONE) + assert filter_findings([a], {"kind": "FACT"}) == [a] + assert filter_findings([a], {"suppression": "Baselined"}) == [a] + + +def test_severity_out_of_domain_raises_with_allowed_values(): + # A value that can NEVER match (e.g. filigree's 'medium' scale) must error + # loudly with the allowed vocabulary — a silent empty result is the N-5 + # bad-error an agent cannot diagnose. + with pytest.raises(ValueError, match="medium"): + filter_findings([_f()], {"severity": "medium"}) + with pytest.raises(ValueError, match="WARN"): + filter_findings([_f()], {"severity": "medium"}) + + +def test_suppression_and_kind_out_of_domain_raise(): + with pytest.raises(ValueError, match="suppression"): + filter_findings([_f()], {"suppression": "suppressed"}) + with pytest.raises(ValueError, match="kind"): + filter_findings([_f()], {"kind": "bug"}) + + +def test_open_keys_stay_exact_and_silent(): + # rule_id/qualname/sink/tier are open vocabularies (packs can extend tiers) — + # no normalization, no domain error; unmatched simply filters to empty. + assert filter_findings([_f()], {"rule_id": "py-wl-101"}) == [] diff --git a/tests/unit/core/test_fingerprint_scheme.py b/tests/unit/core/test_fingerprint_scheme.py new file mode 100644 index 00000000..693fbd4b --- /dev/null +++ b/tests/unit/core/test_fingerprint_scheme.py @@ -0,0 +1,64 @@ +"""Self-describing fingerprint scheme stamp (P1 scheme-infra) + the wlfp2 label. + +The format layer (``scheme:hex``) is applied only at the wire/store boundary; +``compute_finding_fingerprint`` returns bare 64-hex and the prefix is never +stored in-memory. P3 advanced the scheme label to ``wlfp2`` when it dropped +``line_start`` from the hashed parts (wardline-8654423823) — the parse/format +helpers are scheme-agnostic, so their tokens below are arbitrary. +""" + +from __future__ import annotations + +import pytest + +from wardline.core.finding import ( + FINGERPRINT_SCHEME, + compute_finding_fingerprint, + format_fingerprint, + parse_fingerprint, +) + + +def test_scheme_constant_is_wlfp2() -> None: + assert FINGERPRINT_SCHEME == "wlfp2" + + +def test_format_fingerprint_prefixes_with_colon() -> None: + assert format_fingerprint("wlfp1", "ab" * 32) == "wlfp1:" + "ab" * 32 + + +def test_parse_round_trips_format() -> None: + h = "cd" * 32 + assert parse_fingerprint(format_fingerprint(FINGERPRINT_SCHEME, h)) == (FINGERPRINT_SCHEME, h) + + +def test_parse_returns_scheme_verbatim_does_not_validate_scheme_value() -> None: + # parse is a pure FORMAT parser — scheme-mismatch is the store loaders' job + # (SchemeMismatchError), not parse_fingerprint's. It returns whatever scheme + # token is present so a reader can compare it itself. + h = "ab" * 32 + assert parse_fingerprint(f"wlfp2:{h}") == ("wlfp2", h) + + +@pytest.mark.parametrize( + "bad", + [ + "ab" * 32, # no colon (a bare fingerprint is not the prefixed form) + "wlfp1:" + "ab" * 31, # hex too short (62) + "wlfp1:" + "ab" * 33, # hex too long (66) + "wlfp1:" + "AB" * 32, # uppercase hex rejected + "wlfp1:" + "zz" * 32, # non-hex chars + ":" + "ab" * 32, # empty scheme + "wlfp1:" + "ab" * 31 + "g0", # 64 chars but non-hex + ], +) +def test_parse_rejects_malformed(bad: str) -> None: + with pytest.raises(ValueError): + parse_fingerprint(bad) + + +def test_compute_fingerprint_stays_bare_64_hex() -> None: + fp = compute_finding_fingerprint(rule_id="PY-WL-101", path="a.py") + assert ":" not in fp + assert len(fp) == 64 + assert all(c in "0123456789abcdef" for c in fp) diff --git a/tests/unit/core/test_fingerprint_sei_isolation.py b/tests/unit/core/test_fingerprint_sei_isolation.py index 41686451..92700386 100644 --- a/tests/unit/core/test_fingerprint_sei_isolation.py +++ b/tests/unit/core/test_fingerprint_sei_isolation.py @@ -16,10 +16,11 @@ from wardline.core.finding import compute_finding_fingerprint -# Independently computed (NOT via compute_finding_fingerprint): -# parts = ("PY-WL-101", "pkg/mod.py", "42", "pkg.mod.f", "EXTERNAL_RAW") +# Independently computed (NOT via compute_finding_fingerprint). wlfp2 dropped +# line_start from the hashed parts (wardline-8654423823): +# parts = ("PY-WL-101", "pkg/mod.py", "pkg.mod.f", "EXTERNAL_RAW") # hashlib.sha256("\x00".join(parts).encode()).hexdigest() -_GOLDEN = "2f10c79df56839bfce49b31359bd392240cf146ef7280190baa5666d1ff25126" +_GOLDEN = "9a2a957f3862de61faddf9c9fc44433169ef655fd6c35fdb0c275a612de95cba" def test_fingerprint_matches_independent_golden() -> None: @@ -27,7 +28,6 @@ def test_fingerprint_matches_independent_golden() -> None: fp = compute_finding_fingerprint( rule_id="PY-WL-101", path="pkg/mod.py", - line_start=42, qualname="pkg.mod.f", taint_path="EXTERNAL_RAW", ) @@ -40,12 +40,12 @@ def test_fingerprint_has_no_sei_or_identity_parameter() -> None: assert "sei" not in params assert "identity" not in params assert "binding_key" not in params - assert params == {"rule_id", "path", "line_start", "qualname", "taint_path"} + assert params == {"rule_id", "path", "qualname", "taint_path"} def test_fingerprint_rejects_sei_keyword() -> None: # Belt-and-braces: passing an SEI keyword is a TypeError (no such input exists). with pytest.raises(TypeError): compute_finding_fingerprint( # type: ignore[call-arg] - rule_id="PY-WL-101", path="p.py", line_start=1, sei="loomweave:eid:deadbeef" + rule_id="PY-WL-101", path="p.py", sei="loomweave:eid:deadbeef" ) diff --git a/tests/unit/core/test_fingerprint_stability.py b/tests/unit/core/test_fingerprint_stability.py index 3ced1891..8409e38b 100644 --- a/tests/unit/core/test_fingerprint_stability.py +++ b/tests/unit/core/test_fingerprint_stability.py @@ -1,19 +1,22 @@ """Fingerprint stability — pin the *real* contract, both directions. -CLAUDE.md calls a fingerprint change "breaking" (it silently invalidates every -baseline and waiver). The fingerprint inputs are -``(rule_id, path, line_start, qualname, taint_path)`` — and ``line_start`` IS an -input (the function ``def`` line for declaration-anchored rules; the call line -for sink rules). So the contract is NOT "any cosmetic edit keeps the fingerprint": - - * **Anchor-preserving edits** — rename a local, add a trailing comment, add or - edit code *below* the finding's anchor line — keep the fingerprint - byte-identical. This is the property baselines/waivers rely on. - * **An edit that shifts the anchor line** (inserting a blank line *above* the - flagged ``def``/call, moving the function down) DOES change the fingerprint. - This is by design: ``line_start`` is a fingerprint input, and CLAUDE.md - forbids "fixing" that (making the fingerprint line-independent would itself - be the breaking change). We pin it as intended behavior, not a bug. +A fingerprint change is "breaking" (it silently invalidates every baseline and +waiver). The fingerprint inputs are ``(rule_id, path, qualname, taint_path)`` — +``line_start`` is NOT hashed (wlfp2, wardline-8654423823). Multi-emit rules carry +an ENTITY-RELATIVE discriminator in ``taint_path`` (``node.lineno - +entity.location.line_start`` + the lexical span). So the contract is: + + * **Anchor-preserving edits** — rename a local, add a trailing comment, edit + code *below* the finding — keep the fingerprint byte-identical. + * **Whole-entity moves** — inserting a blank line / comment ABOVE the flagged + ``def`` shifts every absolute line but keeps the fingerprint, because the + discriminator is relative to the enclosing entity. This is the churn fix + (wardline-8654423823): a benign edit above a function no longer rekeys it. + * **In-entity offset shifts** — inserting a statement INSIDE the function, + ABOVE a multi-emit node (a sink call), DOES change that finding's + fingerprint, because the node's offset relative to its def moved. This is the + accepted limitation: entity-relative, not move-stable in the strong sense. + (A def-anchored singleton, taint_path=None, is immune to in-function edits.) """ from __future__ import annotations @@ -68,12 +71,14 @@ def test_declaration_anchor_preserving_edits_keep_fingerprint(tmp_path: Path) -> assert base and base == preserved -def test_declaration_line_shift_changes_fingerprint(tmp_path: Path) -> None: - # Inserting a blank line above the def shifts line_start -> different fingerprint. - # Documented as intended: line_start is a fingerprint input (CLAUDE.md). +def test_declaration_whole_entity_move_keeps_fingerprint(tmp_path: Path) -> None: + # A blank line ABOVE the def moves the whole entity down. Under wlfp2 the + # def-anchored singleton (taint_path=None, qualname-keyed) is invariant to that + # — the churn fix (wardline-8654423823): a benign edit above a function no + # longer rekeys its baseline/waiver/Filigree join. base = _fingerprints(tmp_path, _DECL_BASE, "PY-WL-102") shifted = _fingerprints(tmp_path, _DECL_LINE_SHIFTING, "PY-WL-102") - assert base and shifted and base != shifted + assert base and shifted and base == shifted # --- call-anchored: PY-WL-107 (anchor = the sink call line) ------------------ @@ -113,7 +118,12 @@ def test_sink_anchor_preserving_edits_keep_fingerprint(tmp_path: Path) -> None: assert base and base == preserved -def test_sink_call_line_shift_changes_fingerprint(tmp_path: Path) -> None: +def test_sink_in_entity_offset_shift_changes_fingerprint(tmp_path: Path) -> None: + # A statement inserted INSIDE the function, ABOVE the sink call, moves the + # call's offset relative to its def -> different fingerprint. The accepted + # entity-relative limitation (wardline-8654423823): not move-stable in the + # strong sense. (A whole-entity move ABOVE the def keeps it — see the + # entity-relative driver in test_rekey_mutation_pairs.) base = _fingerprints(tmp_path, _SINK_BASE, "PY-WL-107") shifted = _fingerprints(tmp_path, _SINK_LINE_SHIFTING, "PY-WL-107") assert base and shifted and base != shifted diff --git a/tests/unit/core/test_fingerprint_v0.py b/tests/unit/core/test_fingerprint_v0.py new file mode 100644 index 00000000..437c6377 --- /dev/null +++ b/tests/unit/core/test_fingerprint_v0.py @@ -0,0 +1,47 @@ +"""The frozen wlfp1 formula must reproduce the pre-P3 hash, byte-for-byte. + +The golden is hand-rolled here (an independent ``hashlib`` line), NOT produced by +running the frozen copy — a self-sourced golden would pass even if the copy drifted. +The strong end-to-end non-circular check (reconstruction vs the real pre-P3 corpus) +lives in ``test_rekey_dual_fp.py``; this just pins the primitive + proves line_start +is load-bearing in v0 (the whole reason a rekey was needed). +""" + +from __future__ import annotations + +import hashlib + +from wardline.core.fingerprint_v0 import FINGERPRINT_SCHEME_V0, compute_finding_fingerprint_v0 + + +def _hand_rolled(rule_id: str, path: str, line_start: int | None, qualname: str, taint_path: str) -> str: + parts = (rule_id, path, str(line_start), qualname, taint_path) + return hashlib.sha256("\x00".join(parts).encode()).hexdigest() + + +def test_v0_scheme_label_is_wlfp1() -> None: + assert FINGERPRINT_SCHEME_V0 == "wlfp1" + + +def test_v0_matches_pre_change_hash() -> None: + # Independent golden (hand-rolled, NOT via the frozen copy). + golden = _hand_rolled("PY-WL-101", "pkg/mod.py", 42, "pkg.mod.f", "sink@4:9") + assert golden == "0d0967ccef033475163c69bd56b1d754d48fb49590f9c8ca56e3e79f9f4f3b95" + assert ( + compute_finding_fingerprint_v0( + rule_id="PY-WL-101", path="pkg/mod.py", line_start=42, qualname="pkg.mod.f", taint_path="sink@4:9" + ) + == golden + ) + + +def test_v0_line_start_is_load_bearing() -> None: + # The entire reason for the rekey: in v0, shifting line_start changes the digest. + a = compute_finding_fingerprint_v0(rule_id="PY-WL-101", path="m.py", line_start=42, qualname="m.f") + b = compute_finding_fingerprint_v0(rule_id="PY-WL-101", path="m.py", line_start=43, qualname="m.f") + assert a != b + assert b == _hand_rolled("PY-WL-101", "m.py", 43, "m.f", "") + + +def test_v0_optional_fields_default_cleanly() -> None: + assert len(compute_finding_fingerprint_v0(rule_id="WLN-ENGINE-X", path="a.py", line_start=None)) == 64 diff --git a/tests/unit/core/test_gitignore.py b/tests/unit/core/test_gitignore.py new file mode 100644 index 00000000..274c42d6 --- /dev/null +++ b/tests/unit/core/test_gitignore.py @@ -0,0 +1,97 @@ +from pathlib import Path + +from wardline.core.gitignore import GitignoreMatcher + + +def test_comments_and_blanks_ignored() -> None: + m = GitignoreMatcher.from_text("# comment\n\n \nnode_modules/\n") + assert m.match("node_modules", is_dir=True) + assert not m.match("node_modules", is_dir=False) # trailing-slash = dir-only + + +def test_bare_name_matches_at_any_depth() -> None: + m = GitignoreMatcher.from_text("node_modules\n") + assert m.match("node_modules", is_dir=True) + assert m.match("a/b/node_modules", is_dir=True) + + +def test_leading_slash_anchors_to_base() -> None: + m = GitignoreMatcher.from_text("/build\n") + assert m.match("build", is_dir=True) + assert not m.match("pkg/build", is_dir=True) + + +def test_internal_slash_anchors() -> None: + m = GitignoreMatcher.from_text("foo/bar\n") + assert m.match("foo/bar", is_dir=True) + assert not m.match("x/foo/bar", is_dir=True) + + +def test_glob_star_within_segment() -> None: + m = GitignoreMatcher.from_text("*.egg-info/\n") + assert m.match("pkg.egg-info", is_dir=True) + assert m.match("a/b/thing.egg-info", is_dir=True) + assert not m.match("egg-info", is_dir=True) + + +def test_double_star_prefix() -> None: + m = GitignoreMatcher.from_text("**/gen\n") + assert m.match("gen", is_dir=True) + assert m.match("a/b/gen", is_dir=True) + + +def test_negation_last_match_wins() -> None: + # Last-match-wins at the SAME path. (Re-including a child of an excluded directory + # is impossible — matching git; covered by test_negation_under_excluded_parent.) + m = GitignoreMatcher.from_text("logs\n!logs\n") + assert not m.match("logs", is_dir=True) + m2 = GitignoreMatcher.from_text("*.tmp\n!keep.tmp\n") + assert m2.match("x.tmp", is_dir=True) + assert not m2.match("keep.tmp", is_dir=True) + + +def test_negation_under_excluded_parent_matches_git() -> None: + # Git: "It is not possible to re-include a file if a parent directory of that file + # is excluded." The matcher is faithful — `!vendor/keep` does NOT re-admit a child + # of an excluded `vendor/`. (Verified against real `git check-ignore`.) + m = GitignoreMatcher.from_text("vendor/\n!vendor/keep/\n") + assert m.match("vendor", is_dir=True) + + +def test_question_mark_single_char() -> None: + m = GitignoreMatcher.from_text("cache?\n") + assert m.match("cacheX", is_dir=True) + assert not m.match("cacheXY", is_dir=True) + + +def test_unmatched_path_is_not_ignored() -> None: + m = GitignoreMatcher.from_text("node_modules/\n") + assert not m.match("src", is_dir=True) + + +def test_extend_layers_later_patterns() -> None: + # A later layer's negation overrides an earlier layer AT THE SAME PATH. + base = GitignoreMatcher.from_text("*.log\n") + local = GitignoreMatcher.from_text("!keep.log\n") + layered = base.extend(local) + assert layered.match("x.log", is_dir=True) + assert not layered.match("keep.log", is_dir=True) + # extend is non-mutating: the base alone still ignores keep.log. + assert base.match("keep.log", is_dir=True) + + +def test_from_file_missing_is_empty(tmp_path: Path) -> None: + m = GitignoreMatcher.from_file(tmp_path / "nope.gitignore") + assert not m + assert not m.match("anything", is_dir=True) + + +def test_empty_matcher_falsey() -> None: + assert not GitignoreMatcher.empty() + assert not GitignoreMatcher.from_text("# only a comment\n") + + +def test_character_class() -> None: + m = GitignoreMatcher.from_text("build[0-9]/\n") + assert m.match("build3", is_dir=True) + assert not m.match("buildX", is_dir=True) diff --git a/tests/unit/core/test_judged.py b/tests/unit/core/test_judged.py index 23ce62be..cc2fd2c0 100644 --- a/tests/unit/core/test_judged.py +++ b/tests/unit/core/test_judged.py @@ -6,8 +6,11 @@ import pytest import yaml -from wardline.core.errors import ConfigError -from wardline.core.judged import JudgedFP, load_judged, write_judged +from wardline.core.errors import ConfigError, SchemeMismatchError +from wardline.core.finding import FINGERPRINT_SCHEME +from wardline.core.judged import JudgedFP, build_judged_document, load_judged, write_judged + +_SCHEME = f"fingerprint_scheme: {FINGERPRINT_SCHEME}\n" def _fp(**kw: object) -> JudgedFP: @@ -49,18 +52,57 @@ def test_write_is_rule_then_fingerprint_sorted(tmp_path: Path) -> None: def test_malformed_version_raises(tmp_path: Path) -> None: path = tmp_path / "judged.yaml" - path.write_text("version: 999\nfindings: []\n") + path.write_text(_SCHEME + "version: 999\nfindings: []\n") with pytest.raises(ConfigError): load_judged(path) def test_bad_fingerprint_raises(tmp_path: Path) -> None: path = tmp_path / "judged.yaml" - path.write_text("version: 1\nfindings:\n - fingerprint: short\n rationale: x\n") + path.write_text(_SCHEME + "version: 1\nfindings:\n - fingerprint: short\n rationale: x\n") with pytest.raises(ConfigError): load_judged(path) +def test_build_document_carries_scheme_and_bare_fp(tmp_path: Path) -> None: + doc = build_judged_document([_fp()]) + assert doc["fingerprint_scheme"] == FINGERPRINT_SCHEME == "wlfp2" + assert ":" not in doc["findings"][0]["fingerprint"] # entry stays bare + + +def test_missing_scheme_raises_scheme_mismatch_not_version(tmp_path: Path) -> None: + path = tmp_path / "judged.yaml" + path.write_text("version: 999\nfindings: []\n") # header-less, like an old store + with pytest.raises(SchemeMismatchError) as ei: + load_judged(path) + assert "wardline rekey" in str(ei.value) + + +def test_wrong_scheme_raises_scheme_mismatch(tmp_path: Path) -> None: + path = tmp_path / "judged.yaml" + path.write_text("fingerprint_scheme: wlfp1\nversion: 1\nfindings: []\n") + with pytest.raises(SchemeMismatchError): + load_judged(path) + + +def test_empty_mapping_is_empty_no_scheme_error(tmp_path: Path) -> None: + path = tmp_path / "judged.yaml" + path.write_text("{}\n") + assert load_judged(path).match("a" * 64) is None + + +def test_roundtrip_preserves_all_provenance(tmp_path: Path) -> None: + path = tmp_path / "judged.yaml" + write_judged(path, [_fp()]) + m = load_judged(path).match("a" * 64) + assert m is not None + assert m.rationale == "constructor over-taint floor" + assert m.model_id == "anthropic/claude-opus-4-8" + assert m.policy_hash == "sha256:abc" + assert m.confidence == 0.9 + assert m.recorded_at == datetime(2026, 5, 30, tzinfo=UTC) + + def test_rejudge_updates_existing_record(tmp_path: Path) -> None: path = tmp_path / "judged.yaml" write_judged(path, [_fp(rationale="first")]) @@ -74,7 +116,8 @@ def test_missing_provenance_raises(tmp_path: Path) -> None: # verdict is present so this exercises the PROVENANCE guard, not the verdict guard. path = tmp_path / "judged.yaml" path.write_text( - f"version: 1\nfindings:\n - fingerprint: {'a' * 64}\n verdict: FALSE_POSITIVE\n rationale: x\n", + _SCHEME + "version: 1\nfindings:\n" + f" - fingerprint: {'a' * 64}\n verdict: FALSE_POSITIVE\n rationale: x\n", encoding="utf-8", ) with pytest.raises(ConfigError): @@ -85,7 +128,7 @@ def test_out_of_range_confidence_raises(tmp_path: Path) -> None: # verdict is present so this reaches the confidence range check, not the verdict guard. path = tmp_path / "judged.yaml" path.write_text( - "version: 1\nfindings:\n" + _SCHEME + "version: 1\nfindings:\n" f" - fingerprint: {'a' * 64}\n verdict: FALSE_POSITIVE\n rationale: x\n model_id: m\n" " policy_hash: sha256:x\n confidence: 1.5\n", encoding="utf-8", @@ -98,7 +141,7 @@ def test_missing_verdict_raises(tmp_path: Path) -> None: # A judged record with no verdict cannot be trusted as a FALSE_POSITIVE suppression. path = tmp_path / "judged.yaml" path.write_text( - "version: 1\nfindings:\n" + _SCHEME + "version: 1\nfindings:\n" f" - fingerprint: {'a' * 64}\n rationale: x\n model_id: m\n" " policy_hash: sha256:x\n confidence: 0.9\n", encoding="utf-8", @@ -112,7 +155,7 @@ def test_non_false_positive_verdict_rejected(tmp_path: Path) -> None: # silent suppression — judged.yaml only ever records FALSE_POSITIVE. path = tmp_path / "judged.yaml" path.write_text( - "version: 1\nfindings:\n" + _SCHEME + "version: 1\nfindings:\n" f" - fingerprint: {'a' * 64}\n verdict: TRUE_POSITIVE\n rationale: x\n model_id: m\n" " policy_hash: sha256:x\n confidence: 0.9\n", encoding="utf-8", diff --git a/tests/unit/core/test_legis_artifact.py b/tests/unit/core/test_legis_artifact.py index e064ad4b..d9ce115a 100644 --- a/tests/unit/core/test_legis_artifact.py +++ b/tests/unit/core/test_legis_artifact.py @@ -28,11 +28,16 @@ from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState # --------------------------------------------------------------------------- -# Golden vector — the literal hex was produced by the REAL legis signer -# (`from legis.enforcement.signing import sign`) over the fields below with the -# key below. Vendoring the canonical_json+HMAC *formula* cannot catch a shared -# misreading of the contract; a hardcoded hex captured from legis can. The live -# `legis_e2e` oracle re-confirms the verify path against a running legis. +# Golden vector — the literal hex pins Wardline's signer over the fields below +# with the key below. Vendoring the canonical_json+HMAC *formula* cannot catch a +# shared misreading of the contract; a hardcoded hex can. The live `legis_e2e` +# oracle re-confirms the verify path against a running legis. +# +# REKEYED for W3 (weft-f506e5f845): the per-finding key was renamed +# ``suppressed`` -> ``suppression_state``, which changes the SIGNED bytes. legis +# (the consumer + co-signer) must adopt the same key and regenerate its matching +# vector before the signed hop verifies again — tracked in the W3 legis weft ticket; +# the opt-in `legis_e2e` oracle stays red against an un-updated legis until then. # --------------------------------------------------------------------------- _GOLDEN_KEY = b"test-shared-secret-key" _GOLDEN_FIELDS = { @@ -49,11 +54,11 @@ "fingerprint": "a" * 64, "qualname": "svc.leaky", "properties": {"declared_return": "INTEGRAL", "actual_return": "EXTERNAL_RAW"}, - "suppressed": "active", + "suppression_state": "active", } ], } -_GOLDEN_SIG = "hmac-sha256:v2:73eb9f0c8b7ba898aa4b5fd62fa56ade0cbd9755d2c42eee209012675537f81d" +_GOLDEN_SIG = "hmac-sha256:v2:2b2cf09548572b58fd01c359d1b6a16c3c1181f1cbfe8e4f5ada6fcd21f35ac4" def test_golden_signature_matches_real_legis() -> None: @@ -121,7 +126,7 @@ def test_projection_minimal_legis_read_surface() -> None: "fingerprint", "qualname", "properties", - "suppressed", + "suppression_state", } @@ -130,7 +135,7 @@ def test_baselined_maps_to_suppressed_with_synthesized_proof() -> None: # non-active defect. A baselined finding with no reason must still carry proof. f = _finding(suppressed=SuppressionState.BASELINED, suppression_reason=None) out = legis.project_finding(f) - assert out["suppressed"] == "suppressed" + assert out["suppression_state"] == "suppressed" assert isinstance(out["properties"].get("suppression_reason"), str) assert out["properties"]["suppression_reason"].strip() @@ -138,20 +143,20 @@ def test_baselined_maps_to_suppressed_with_synthesized_proof() -> None: def test_judged_maps_to_suppressed_and_carries_reason() -> None: f = _finding(suppressed=SuppressionState.JUDGED, suppression_reason="LLM: false positive") out = legis.project_finding(f) - assert out["suppressed"] == "suppressed" + assert out["suppression_state"] == "suppressed" assert out["properties"]["suppression_reason"] == "LLM: false positive" def test_waived_keeps_state_and_injects_proof_into_properties() -> None: f = _finding(suppressed=SuppressionState.WAIVED, suppression_reason="WAIVE-123") out = legis.project_finding(f) - assert out["suppressed"] == "waived" + assert out["suppression_state"] == "waived" assert out["properties"]["suppression_reason"] == "WAIVE-123" def test_active_finding_carries_no_suppression_proof() -> None: out = legis.project_finding(_finding(properties={"tier": "INTEGRAL"})) - assert out["suppressed"] == "active" + assert out["suppression_state"] == "active" assert "suppression_reason" not in out["properties"] @@ -234,6 +239,20 @@ def test_signed_artifact_signature_verifies_over_minus_signature(tmp_path) -> No assert legis.sign_artifact(scan, b"k") == sig # re-sign of the posted body matches +def test_artifact_envelope_carries_scheme_findings_stay_bare(tmp_path) -> None: + # The artifact ENVELOPE carries the scheme signal (like Filigree's envelope / + # SARIF's key-version); legis ignores unknown top-level fields, so this is + # forward-compatible. Per-finding fingerprints stay BARE (legis reads them from + # to_jsonl; SARIF-style bare value). The scheme is part of the signed body, so the + # signature round-trip above still holds with it present. + from wardline.core.finding import FINGERPRINT_SCHEME + + scan = _build(_committed_repo(tmp_path)) # unsigned + assert scan["fingerprint_scheme"] == FINGERPRINT_SCHEME == "wlfp2" + for finding in scan["findings"]: + assert ":" not in finding["fingerprint"] # bare 64-hex, no scheme prefix + + def test_artifact_includes_all_findings_projected(tmp_path) -> None: # legis records finding_count over the WHOLE list (service/wardline.py), so the # artifact carries every finding — including engine FACTs — each projected so its diff --git a/tests/unit/core/test_paths.py b/tests/unit/core/test_paths.py index 88b41cb1..40560562 100644 --- a/tests/unit/core/test_paths.py +++ b/tests/unit/core/test_paths.py @@ -60,3 +60,51 @@ def test_store_dir_absolute_outside_root_falls_back_to_default(tmp_path): def test_store_dir_relative_escape_falls_back_to_default(tmp_path): (tmp_path / "weft.toml").write_text('[wardline]\nstore_dir = "../escape"\n', encoding="utf-8") assert paths.weft_state_dir(tmp_path) == tmp_path / ".weft" / "wardline" + + +# --- enclosing_project_root (N-3: the scan root governs qualnames) ----------- + + +def test_enclosing_project_root_none_when_root_has_weft_toml(tmp_path): + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + assert paths.enclosing_project_root(tmp_path) is None + + +def test_enclosing_project_root_none_when_root_has_state_dir(tmp_path): + (tmp_path / ".weft" / "wardline").mkdir(parents=True) + assert paths.enclosing_project_root(tmp_path) is None + + +def test_enclosing_project_root_finds_weft_toml_ancestor(tmp_path): + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + sub = tmp_path / "specimen" + sub.mkdir() + assert paths.enclosing_project_root(sub) == tmp_path.resolve() + + +def test_enclosing_project_root_finds_state_dir_ancestor_deep(tmp_path): + (tmp_path / ".weft" / "wardline").mkdir(parents=True) + sub = tmp_path / "a" / "b" + sub.mkdir(parents=True) + assert paths.enclosing_project_root(sub) == tmp_path.resolve() + + +def test_enclosing_project_root_nested_project_is_its_own_root(tmp_path): + # A subdirectory that is its OWN weft project root (vendored tree) is not + # "nested" — its markers win and no enclosing root is reported. + (tmp_path / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + sub = tmp_path / "vendored" + sub.mkdir() + (sub / "weft.toml").write_text("[wardline]\n", encoding="utf-8") + assert paths.enclosing_project_root(sub) is None + + +def test_enclosing_project_root_ignores_sibling_only_weft_dir(tmp_path): + # A .weft/ holding only SIBLING members (filigree/loomweave) marks nothing for + # wardline: neither operator config (weft.toml) nor wardline state exists, so + # there is no baseline to miss and no [wardline] config to skip. (This also keeps + # wardline's own repo — .weft/filigree only — from warning on in-repo fixture scans.) + (tmp_path / ".weft" / "filigree").mkdir(parents=True) + sub = tmp_path / "specimen" + sub.mkdir() + assert paths.enclosing_project_root(sub) is None diff --git a/tests/unit/core/test_rekey_adversarial.py b/tests/unit/core/test_rekey_adversarial.py new file mode 100644 index 00000000..5ce6154f --- /dev/null +++ b/tests/unit/core/test_rekey_adversarial.py @@ -0,0 +1,248 @@ +"""P4 — adversarial rekey coverage (wardline-85b418585b). + +Two scenarios beyond the single-store rollback that existing tests cover: + +(a) MIXED-SCHEME PARTIAL migration with a pre-resume SOURCE change. One leg is + already migrated (live store rewritten to wlfp2), another is still pending + (live store still wlfp1) — a genuine two-scheme-at-once live tree — and the + project source mutates before `--resume`. Because the resume path reads ONLY + the immutable snapshot + the frozen journal remap (never re-scans, never reads + the live store or source), the source change MUST be a no-op: the pending leg + carries the snapshot verdict deterministically and the already-done leg is left + byte-untouched. Proves no verdict loss / no silent corruption / deterministic. + +(b) MULTI-STORE rollback restores all-or-nothing. A rollback spanning >1 store + restores every one byte-identical (the stated single-store gap), and — under a + mid-rollback write failure — the snapshot is left fully intact so a re-run + converges (no unrecoverable half-rolled-back state, no data loss). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +from wardline.core import paths # noqa: E402 +from wardline.core.baseline import load_baseline # noqa: E402 +from wardline.core.errors import WardlineError # noqa: E402 +from wardline.core.judged import load_judged # noqa: E402 +from wardline.core.rekey import ( # noqa: E402 + Journal, + Leg, + carry_baseline_forward, + resume_rekey, + rollback, + snapshot_dir, + write_journal, +) + +# Old (wlfp1) / new (wlfp2) fingerprints for two distinct verdicts. +A_OLD, A_NEW = "a" * 64, "1" * 64 +B_OLD, B_NEW = "b" * 64, "2" * 64 + + +def _seed_snapshot(root: Path) -> None: + """Snapshot two old-scheme stores: a baseline (verdict A) + a judged (verdict B).""" + sdir = snapshot_dir(root) + sdir.mkdir(parents=True, exist_ok=True) + (sdir / "baseline.yaml").write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "entries": [{"fingerprint": A_OLD, "rule_id": "PY-WL-108", "path": "m.py", "message": "x"}], + } + ), + encoding="utf-8", + ) + (sdir / "judged.yaml").write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "findings": [ + { + "fingerprint": B_OLD, + "rule_id": "PY-WL-108", + "path": "m.py", + "verdict": "FALSE_POSITIVE", + "rationale": "sanitized at the boundary", + "model_id": "test-model", + "policy_hash": "deadbeef", + "confidence": 0.97, + "recorded_at": "2026-06-10T00:00:00Z", + } + ], + } + ), + encoding="utf-8", + ) + + +# --- (a) mixed-scheme partial migration + pre-resume source change ---------------- + + +def test_mixed_scheme_partial_resume_ignores_source_change(tmp_path: Path) -> None: + """Baseline already migrated (wlfp2 live), judged still pending (wlfp1 live), and + the source changes before resume. Resume must carry the judged verdict from the + SNAPSHOT (no re-scan), leave the already-done baseline byte-identical, and orphan + nothing — the source mutation is a no-op by construction.""" + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _seed_snapshot(root) + + remap = {A_OLD: A_NEW, B_OLD: B_NEW} + + # Hand-build the crash-mid-migration state: baseline leg DONE with a wlfp2 live store; + # judged leg PENDING with the ORIGINAL wlfp1 live store still in place. + res = carry_baseline_forward(snapshot_dir(root) / "baseline.yaml", remap) + from wardline.core.rekey import _write_store_doc # noqa: PLC0415 + + _write_store_doc(root, paths.baseline_path(root), res.document) + # judged live store is still the old wlfp1 doc (copy the snapshot back as "live"): + (state / "judged.yaml").write_bytes((snapshot_dir(root) / "judged.yaml").read_bytes()) + + journal = Journal( + remap=remap, + legs=[ + Leg("baseline", done=True, carried=[A_OLD]), + Leg("judged", done=False), + Leg("waivers", done=True), # never existed -> treated done + Leg("filigree", done=True), + ], + ) + write_journal(paths.migration_journal_path(root), journal, root=root) + + # The live tree genuinely holds BOTH schemes at once right now. + assert load_baseline(paths.baseline_path(root)).fingerprints == frozenset({A_NEW}) + with pytest.raises(WardlineError): # judged live store is still wlfp1 -> rejected by loader + load_judged(paths.judged_path(root)) + + # Mutate the project source AFTER the partial migration, BEFORE resume. + (root / "m.py").write_text("# changed source — must not affect the resume\nprint('x')\n", encoding="utf-8") + + baseline_before = paths.baseline_path(root).read_bytes() + + resumed = resume_rekey(root, findings=None, filigree=None) + + # (i) judged pending leg carried the SNAPSHOT verdict, re-keyed to wlfp2. + assert load_judged(paths.judged_path(root)).fingerprints() == frozenset({B_NEW}) + assert resumed.leg("judged").done is True + assert resumed.leg("judged").orphaned == [] # source change did NOT orphan the verdict + assert resumed.complete + + # (ii) the already-done baseline leg was left byte-identical (not re-derived/rewritten). + assert paths.baseline_path(root).read_bytes() == baseline_before + assert load_baseline(paths.baseline_path(root)).fingerprints == frozenset({A_NEW}) + + # (iii) deterministic: a second resume reproduces byte-identical stores. + judged_after = paths.judged_path(root).read_bytes() + resume_rekey(root, findings=None, filigree=None) + assert paths.judged_path(root).read_bytes() == judged_after + assert paths.baseline_path(root).read_bytes() == baseline_before + + +def test_mixed_scheme_resume_is_source_independent_proof(tmp_path: Path) -> None: + """Tightened proof that the carry is snapshot-bound: even if the post-crash LIVE + judged store is corrupted to empty AND the source is deleted, resume still re-derives + the verdict from the immutable snapshot.""" + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _seed_snapshot(root) + remap = {A_OLD: A_NEW, B_OLD: B_NEW} + + # Corrupt the live judged store to an EMPTY wlfp2 doc (simulates a torn write). + (state / "judged.yaml").write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp2", "version": 1, "findings": []}), encoding="utf-8" + ) + journal = Journal( + remap=remap, + legs=[ + Leg("baseline", done=True), + Leg("judged", done=False), + Leg("waivers", done=True), + Leg("filigree", done=True), + ], + ) + write_journal(paths.migration_journal_path(root), journal, root=root) + + # No source file at all (deleted) — must not matter. + resume_rekey(root, findings=None, filigree=None) + + assert load_judged(paths.judged_path(root)).fingerprints() == frozenset({B_NEW}), ( + "resume must re-derive the verdict from the snapshot — an empty store here would mean " + "the judged verdict was silently shredded by trusting the corrupted live store" + ) + + +# --- (b) multi-store rollback: all-or-nothing ------------------------------------- + + +def _seed_for_rollback(root: Path) -> tuple[bytes, bytes]: + """Snapshot baseline + judged (pre-migration), and write DIFFERENT wlfp2 live stores + + a journal, as a completed multi-store migration. Returns the two snapshot blobs.""" + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _seed_snapshot(root) + sdir = snapshot_dir(root) + base_orig = (sdir / "baseline.yaml").read_bytes() + judged_orig = (sdir / "judged.yaml").read_bytes() + # Migrated (wlfp2) live stores — what rollback must overwrite. + (state / "baseline.yaml").write_bytes(b"REKEYED-wlfp2-BASELINE") + (state / "judged.yaml").write_bytes(b"REKEYED-wlfp2-JUDGED") + paths.migration_journal_path(root).write_text("schema_version: 1\nremap: {}\n", encoding="utf-8") + return base_orig, judged_orig + + +def test_multistore_rollback_restores_all_byte_identical(tmp_path: Path) -> None: + """A rollback spanning TWO stores restores BOTH byte-identical and clears journal + + snapshot — no store is left in its migrated (wlfp2) state.""" + root = tmp_path + state = paths.weft_state_dir(root) + base_orig, judged_orig = _seed_for_rollback(root) + + result = rollback(root) + + assert set(result.restored) == {"baseline.yaml", "judged.yaml"} + assert (state / "baseline.yaml").read_bytes() == base_orig + assert (state / "judged.yaml").read_bytes() == judged_orig + assert not paths.migration_journal_path(root).exists() + sdir = snapshot_dir(root) + assert not (sdir / "baseline.yaml").exists() + assert not (sdir / "judged.yaml").exists() + + +def test_multistore_rollback_failure_preserves_snapshot_and_converges(tmp_path: Path) -> None: + """If the SECOND store's restore write fails mid-rollback, the snapshot must remain + fully intact (deletion is after all writes) so a re-run converges — no unrecoverable + half-rolled-back state, no verdict loss. all-or-nothing = recoverable, not torn.""" + root = tmp_path + state = paths.weft_state_dir(root) + base_orig, judged_orig = _seed_for_rollback(root) + sdir = snapshot_dir(root) + + # Force the judged restore write to fail: pre-create the live path AS A DIRECTORY so + # write_bytes raises (IsADirectoryError). The first store (baseline) restores fine. + (state / "judged.yaml").unlink() + (state / "judged.yaml").mkdir() + + with pytest.raises(OSError): + rollback(root) + + # The snapshot is UNTOUCHED — both legs of provenance survive the partial failure. + assert (sdir / "baseline.yaml").read_bytes() == base_orig + assert (sdir / "judged.yaml").read_bytes() == judged_orig + assert paths.migration_journal_path(root).exists() # journal not removed -> resume/rollback still possible + + # Heal the obstruction and re-run: rollback now converges, restoring ALL stores. + (state / "judged.yaml").rmdir() + result = rollback(root) + assert set(result.restored) == {"baseline.yaml", "judged.yaml"} + assert (state / "baseline.yaml").read_bytes() == base_orig + assert (state / "judged.yaml").read_bytes() == judged_orig + assert not paths.migration_journal_path(root).exists() diff --git a/tests/unit/core/test_rekey_carry.py b/tests/unit/core/test_rekey_carry.py new file mode 100644 index 00000000..a3bab7d0 --- /dev/null +++ b/tests/unit/core/test_rekey_carry.py @@ -0,0 +1,134 @@ +"""P4 S5 — carry verdicts from the SNAPSHOT (D-PROVENANCE), byte-preserving every +non-fingerprint field, flagging orphans, and producing a doc that loads clean under +wlfp2 (the S12 contract, proven early).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +from wardline.core import paths # noqa: E402 +from wardline.core.baseline import load_baseline # noqa: E402 +from wardline.core.judged import load_judged # noqa: E402 +from wardline.core.rekey import ( # noqa: E402 + carry_baseline_forward, + carry_judged_forward, + carry_waivers_forward, +) +from wardline.core.waivers import load_project_waivers # noqa: E402 + +A, B, C = "a" * 64, "b" * 64, "c" * 64 +NA, NB = "1" * 64, "2" * 64 +REMAP = {A: NA, B: NB} # C is intentionally absent -> orphan + + +def _seed(path: Path, doc: dict) -> None: + path.write_text(yaml.safe_dump(doc, sort_keys=False), encoding="utf-8") + + +def test_carry_baseline_preserves_fields_and_flags_orphan(tmp_path: Path) -> None: + sp = tmp_path / "baseline.yaml" + _seed( + sp, + { + "fingerprint_scheme": "wlfp1", # OLD scheme — loaders would reject; carry reads raw + "version": 1, + "entries": [ + {"fingerprint": A, "rule_id": "PY-WL-108", "path": "m.py", "message": "shell"}, + {"fingerprint": B, "rule_id": "PY-WL-101", "path": "n.py", "message": "ret"}, + {"fingerprint": C, "rule_id": "PY-WL-102", "path": "o.py", "message": "gone"}, + ], + }, + ) + res = carry_baseline_forward(sp, REMAP) + + assert set(res.carried) == {A, B} + assert res.orphaned == (C,) + assert res.document["fingerprint_scheme"] == "wlfp2" + entry = next(e for e in res.document["entries"] if e["fingerprint"] == NA) + assert entry["rule_id"] == "PY-WL-108" and entry["message"] == "shell" # provenance preserved + + # The carried doc LOADS clean under wlfp2 (no SCHEME_MISMATCH) — the S12 contract. + out = paths.baseline_path(tmp_path) + out.parent.mkdir(parents=True, exist_ok=True) + _seed(out, res.document) + assert load_baseline(out).fingerprints == frozenset({NA, NB}) + + +def test_carry_judged_preserves_full_provenance(tmp_path: Path) -> None: + sp = tmp_path / "judged.yaml" + _seed( + sp, + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "findings": [ + { + "fingerprint": A, + "rule_id": "PY-WL-108", + "path": "m.py", + "message": "shell", + "verdict": "FALSE_POSITIVE", + "rationale": "operator-controlled constant", + "confidence": 0.97, + "model_id": "anthropic/claude", + "recorded_at": "2026-06-01T00:00:00+00:00", + "policy_hash": "deadbeef", + }, + { + "fingerprint": C, + "rule_id": "PY-WL-101", + "path": "o.py", + "message": "gone", + "verdict": "FALSE_POSITIVE", + "rationale": "stale", + "confidence": 0.5, + "model_id": "m", + "recorded_at": "2026-06-01T00:00:00+00:00", + "policy_hash": "f00d", + }, + ], + }, + ) + res = carry_judged_forward(sp, REMAP) + assert set(res.carried) == {A} and res.orphaned == (C,) + + out = paths.judged_path(tmp_path) + out.parent.mkdir(parents=True, exist_ok=True) + _seed(out, res.document) + js = load_judged(out) + carried = js.match(NA) + assert carried is not None + assert carried.rationale == "operator-controlled constant" # full provenance survived + assert carried.model_id == "anthropic/claude" + assert abs(carried.confidence - 0.97) < 1e-9 + assert js.match(C) is None # orphan not carried + + +def test_carry_waivers_preserves_reason_and_expiry(tmp_path: Path) -> None: + sp = tmp_path / "waivers.yaml" + _seed( + sp, + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "waivers": [ + {"fingerprint": A, "reason": "accepted risk", "expires": "2099-01-01"}, + {"fingerprint": C, "reason": "moved away"}, + ], + }, + ) + res = carry_waivers_forward(sp, REMAP) + assert set(res.carried) == {A} and res.orphaned == (C,) + + out = paths.waivers_path(tmp_path) + out.parent.mkdir(parents=True, exist_ok=True) + _seed(out, res.document) + waivers = load_project_waivers(tmp_path) + assert len(waivers) == 1 + assert waivers[0].fingerprint == NA + assert waivers[0].reason == "accepted risk" + assert waivers[0].expires is not None and waivers[0].expires.isoformat() == "2099-01-01" diff --git a/tests/unit/core/test_rekey_dual_fp.py b/tests/unit/core/test_rekey_dual_fp.py new file mode 100644 index 00000000..de4d3f21 --- /dev/null +++ b/tests/unit/core/test_rekey_dual_fp.py @@ -0,0 +1,73 @@ +"""P4 S2 — the NON-CIRCULAR reconstruction gate (the make-or-break test). + +``compute_old_new_fingerprints`` reconstructs each finding's OLD (wlfp1) fingerprint +from ``finding.location.line_start`` + ``finding.taint_path_v0``. If that reconstruction +is even subtly wrong (a different node/attribute than the dead engine used), EVERY +verdict silently orphans at migration time and resurfaces ACTIVE — the worst failure +mode for this phase, and one a self-consistent test would miss. + +So the oracle is the REAL pre-P3 corpus: ``tests/unit/core/fixtures/wlfp1_sinks_ +fingerprints.json`` is the genuine wlfp1 (line_start-IN) output of the dead engine, +frozen from ``git 966cd9f^`` (P3's parent, corpus_version 3). It was NOT produced by +running the current engine. The gate: run the CURRENT engine on the same sinks +fixture, reconstruct every ``old_fp``, and assert the multiset equals the frozen +wlfp1 fingerprints byte-for-byte. The sinks fixture exercises the entire call-site +family (101/105/106/112/114/115/116/118/120 — both PY-WL-120 sites), which is the +only non-trivial reconstruction, so this validates the risky path end-to-end against +the actual dead engine. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("blake3", reason="run_scan identity path needs wardline[loomweave]") + +from wardline.core.rekey import compute_old_new_fingerprints, is_join_population # noqa: E402 +from wardline.core.run import run_scan # noqa: E402 + +_SINKS_FIXTURE = Path(__file__).resolve().parents[2] / "golden" / "identity" / "fixtures" / "sinks" +_WLFP1_GOLDEN = Path(__file__).resolve().parent / "fixtures" / "wlfp1_sinks_fingerprints.json" + + +def _frozen_wlfp1() -> list[dict]: + return json.loads(_WLFP1_GOLDEN.read_text(encoding="utf-8"))["fingerprints"] + + +def test_reconstructed_old_fp_multiset_matches_real_wlfp1_corpus() -> None: + frozen = _frozen_wlfp1() + expected_old = sorted(r["fingerprint"] for r in frozen) + + result = run_scan(_SINKS_FIXTURE) + remaps = compute_old_new_fingerprints(result.findings) + + # Non-vacuity: the migration scope must actually be the 26-finding sinks set. + assert len(remaps) == len(frozen) == 26, f"expected 26 join-population findings, got {len(remaps)}" + + got_old = sorted(r.old_fp for r in remaps) + assert got_old == expected_old, ( + "RECONSTRUCTION DRIFT: a reconstructed old_fp does not match the real pre-P3 (wlfp1) " + "engine output. taint_path_v0 / line_start does not byte-reproduce what the dead engine " + "hashed — every baselined/waived/judged verdict would silently orphan on migration." + ) + + +def test_new_fp_is_the_live_fingerprint() -> None: + result = run_scan(_SINKS_FIXTURE) + by_id = {id(f): f for f in result.findings} + remaps = compute_old_new_fingerprints(result.findings) + live = {f.fingerprint for f in result.findings if is_join_population(f)} + got_new = {r.new_fp for r in remaps} + assert got_new == live, "new_fp must be the live wlfp2 finding.fingerprint, unchanged" + assert by_id # sanity: scan produced findings + + +def test_old_and_new_differ_for_the_whole_set() -> None: + # line_start dropped for ALL rules, so every finding's identity moved — there must + # be no accidental old==new (which would mean a finding wasn't actually rekeyed). + result = run_scan(_SINKS_FIXTURE) + remaps = compute_old_new_fingerprints(result.findings) + assert remaps and all(r.old_fp != r.new_fp for r in remaps) diff --git a/tests/unit/core/test_rekey_filigree.py b/tests/unit/core/test_rekey_filigree.py new file mode 100644 index 00000000..a4f00c2a --- /dev/null +++ b/tests/unit/core/test_rekey_filigree.py @@ -0,0 +1,65 @@ +"""P4 S8 — the Filigree leg: LAST, soft-fail into recorded debt, never aborts the +already-complete YAML migration.""" + +from __future__ import annotations + +from pathlib import Path + +from wardline.core.filigree_emit import EmitResult +from wardline.core.finding import Finding, Kind, Location, Severity +from wardline.core.rekey import Journal, apply_pending_legs + + +class _FakeEmitter: + def __init__(self, result: EmitResult) -> None: + self._result = result + self.calls: list = [] + + def emit(self, findings, *, scanned_paths=()): # type: ignore[no-untyped-def] + self.calls.append(list(findings)) + return self._result + + +def _join_finding() -> Finding: + return Finding( + rule_id="PY-WL-108", + message="m", + severity=Severity.WARN, + kind=Kind.DEFECT, + location=Location(path="m.py", line_start=3), + fingerprint="1" * 64, + ) + + +def _journal_yaml_done() -> Journal: + j = Journal(remap={}) + for name in ("baseline", "judged", "waivers"): + j.leg(name).done = True + return j + + +def test_filigree_leg_soft_fails_and_then_succeeds(tmp_path: Path) -> None: + finding = _join_finding() + + # Unreachable sibling -> leg not done, debt recorded, migration NOT aborted. + j = _journal_yaml_done() + bad = _FakeEmitter(EmitResult(reachable=False, status=None, url="http://x")) + apply_pending_legs(tmp_path, j, findings=[finding], filigree=bad) + assert j.leg("filigree").done is False + filigree_debt = j.leg("filigree").debt + assert filigree_debt and "unreachable" in filigree_debt.lower() + assert bad.calls and bad.calls[0][0].fingerprint == "1" * 64 # re-emitted under new_fp + + # A later 2xx marks it done. + ok = _FakeEmitter(EmitResult(reachable=True, url="http://x")) + apply_pending_legs(tmp_path, j, findings=[finding], filigree=ok) + assert j.leg("filigree").done is True + assert j.leg("filigree").debt is None + assert j.complete + + +def test_no_filigree_configured_is_a_noop_done(tmp_path: Path) -> None: + j = _journal_yaml_done() + apply_pending_legs(tmp_path, j, findings=[_join_finding()], filigree=None) + assert j.leg("filigree").done is True + assert j.complete diff --git a/tests/unit/core/test_rekey_injective.py b/tests/unit/core/test_rekey_injective.py new file mode 100644 index 00000000..330e497a --- /dev/null +++ b/tests/unit/core/test_rekey_injective.py @@ -0,0 +1,40 @@ +"""P4 S3 — new_fp injectivity: per-collision orphan-and-report, NEVER whole-run abort.""" + +from __future__ import annotations + +from wardline.core.rekey import FingerprintRemap, build_remap + + +def _rm(old: str, new: str, rule: str = "PY-WL-108", q: str = "m.f") -> FingerprintRemap: + return FingerprintRemap(old_fp=old, new_fp=new, rule_id=rule, path="m.py", qualname=q) + + +def test_collapsing_remap_reports_and_continues() -> None: + a, b, c = "a" * 64, "b" * 64, "c" * 64 + shared, ok = "1" * 64, "2" * 64 + # a and b are distinct under wlfp1 but collapse to `shared` under wlfp2. + res = build_remap([_rm(a, shared), _rm(b, shared), _rm(c, ok)]) + + assert len(res.collisions) == 1 + assert res.collisions[0].new_fp == shared + assert res.collisions[0].old_fps == (a, b) + assert "WLN-ENGINE-FINGERPRINT-COLLISION" in res.collisions[0].message + # BOTH colliding old_fps are orphaned (neither verdict carried)... + assert a not in res.old_to_new and b not in res.old_to_new + # ...and the rest of the migration proceeds. + assert res.old_to_new == {c: ok} + + +def test_clean_remap_has_no_collisions() -> None: + a, b = "a" * 64, "b" * 64 + res = build_remap([_rm(a, "1" * 64), _rm(b, "2" * 64)]) + assert res.collisions == () + assert res.old_to_new == {a: "1" * 64, b: "2" * 64} + + +def test_identical_finding_seen_twice_is_not_a_collision() -> None: + # Same (old_fp, new_fp) twice is idempotent, not a collision. + a = "a" * 64 + res = build_remap([_rm(a, "1" * 64), _rm(a, "1" * 64)]) + assert res.collisions == () + assert res.old_to_new == {a: "1" * 64} diff --git a/tests/unit/core/test_rekey_journal.py b/tests/unit/core/test_rekey_journal.py new file mode 100644 index 00000000..6f8d8811 --- /dev/null +++ b/tests/unit/core/test_rekey_journal.py @@ -0,0 +1,51 @@ +"""P4 S6 — the migration journal: remap + per-leg done-flags, roundtrip, resume skips done.""" + +from __future__ import annotations + +from pathlib import Path + +from wardline.core.rekey import FingerprintRemap, Journal, RekeyCollision, load_journal, new_journal, write_journal + + +def test_journal_roundtrip_and_resume_skips_done(tmp_path: Path) -> None: + a, na = "a" * 64, "1" * 64 + j = new_journal([FingerprintRemap(old_fp=a, new_fp=na, rule_id="PY-WL-108", path="m.py", qualname="m.f")]) + assert [leg.name for leg in j.legs] == ["baseline", "judged", "waivers", "filigree"] + assert j.next_pending_leg() == "baseline" + assert j.fingerprint_scheme_from == "wlfp1" and j.fingerprint_scheme_to == "wlfp2" + + j.leg("baseline").done = True + j.leg("baseline").carried = [a] + j.snapshot_prescheme = True # a scheme-less (pre-P1) snapshot — must persist for --resume display + assert j.next_pending_leg() == "judged" + + p = tmp_path / "migration_journal.yaml" + write_journal(p, j, root=tmp_path) + loaded = load_journal(p) + assert loaded.remap == {a: na} + assert loaded.leg("baseline").done is True + assert loaded.leg("baseline").carried == [a] + assert loaded.snapshot_prescheme is True # the prescheme caution survives the roundtrip + assert loaded.next_pending_leg() == "judged" + + for leg in loaded.legs: + leg.done = True + assert loaded.complete + + +def test_journal_snapshot_prescheme_defaults_false_when_absent(tmp_path: Path) -> None: + # A journal written before this field existed (no key) loads as False — backward compatible. + j = Journal(remap={}) + assert j.snapshot_prescheme is False + p = tmp_path / "j.yaml" + write_journal(p, j, root=tmp_path) + assert load_journal(p).snapshot_prescheme is False + + +def test_journal_persists_collisions(tmp_path: Path) -> None: + j = Journal(remap={}, collisions=[RekeyCollision(new_fp="1" * 64, old_fps=("a" * 64, "b" * 64))]) + p = tmp_path / "j.yaml" + write_journal(p, j, root=tmp_path) + loaded = load_journal(p) + assert len(loaded.collisions) == 1 + assert loaded.collisions[0].old_fps == ("a" * 64, "b" * 64) diff --git a/tests/unit/core/test_rekey_legs.py b/tests/unit/core/test_rekey_legs.py new file mode 100644 index 00000000..188aa65c --- /dev/null +++ b/tests/unit/core/test_rekey_legs.py @@ -0,0 +1,85 @@ +"""P4 S7 — per-leg idempotent application + the crash-after-write-before-flag proof. + +The crash test is the one that matters: carry NEVER reads the live store, only the +snapshot, so a resume after a partial run (store written, done-flag not persisted, and +even the live store corrupted) re-derives the CORRECT content — never an empty store. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +from wardline.core import paths # noqa: E402 +from wardline.core.baseline import load_baseline # noqa: E402 +from wardline.core.errors import ConfigError # noqa: E402 +from wardline.core.rekey import ( # noqa: E402 + Journal, + _write_store_doc, # noqa: E402 + apply_pending_legs, + carry_baseline_forward, + snapshot_dir, +) + +A, NA = "a" * 64, "1" * 64 + + +def _seed_snapshot_baseline(root: Path) -> None: + sdir = snapshot_dir(root) + sdir.mkdir(parents=True, exist_ok=True) + (sdir / "baseline.yaml").write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", # old scheme — the pre-migration state + "version": 1, + "entries": [{"fingerprint": A, "rule_id": "PY-WL-108", "path": "m.py", "message": "x"}], + } + ), + encoding="utf-8", + ) + + +def test_legs_idempotent_and_gate_green_after_yaml(tmp_path: Path) -> None: + root = tmp_path + _seed_snapshot_baseline(root) + journal = Journal(remap={A: NA}) # findings=[] (forward run) + filigree=None -> filigree leg is a no-op done + + apply_pending_legs(root, journal, findings=[]) + bp = paths.baseline_path(root) + # Gate green under wlfp2: the rekeyed store loads clean (no SCHEME_MISMATCH)... + assert load_baseline(bp).fingerprints == frozenset({NA}) + # ...while the pre-migration snapshot is still old-scheme (would fail to load). + with pytest.raises(ConfigError): + load_baseline(snapshot_dir(root) / "baseline.yaml") + assert journal.complete + + # Idempotent: a second run rewrites nothing (every leg already done). + mtime = bp.stat().st_mtime_ns + apply_pending_legs(root, journal, findings=[]) + assert bp.stat().st_mtime_ns == mtime + + +def test_crash_after_write_before_flag_preserves_content(tmp_path: Path) -> None: + root = tmp_path + _seed_snapshot_baseline(root) + journal = Journal(remap={A: NA}) + + # Simulate a crash: the baseline leg WROTE the store but the done-flag was never + # persisted (leg.done stays False). Then — to prove resume does NOT trust the live + # store — corrupt the live store to an EMPTY one. + res = carry_baseline_forward(snapshot_dir(root) / "baseline.yaml", journal.remap) + _write_store_doc(root, paths.baseline_path(root), res.document) + paths.baseline_path(root).write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp2", "version": 1, "entries": []}), encoding="utf-8" + ) + assert journal.leg("baseline").done is False # crash left it pending + + # Resume: re-carries from the SNAPSHOT, not the corrupted live store. + apply_pending_legs(root, journal) + assert load_baseline(paths.baseline_path(root)).fingerprints == frozenset({NA}), ( + "resume must re-derive verdicts from the snapshot — an empty store here would mean " + "every verdict was silently shredded" + ) diff --git a/tests/unit/core/test_rekey_population.py b/tests/unit/core/test_rekey_population.py new file mode 100644 index 00000000..69f75b7c --- /dev/null +++ b/tests/unit/core/test_rekey_population.py @@ -0,0 +1,131 @@ +"""P4 review fold — the rekey population must cover EVERY stored DEFECT, not just +PY-WL-*, and must reconstruct old_fp by the right mechanism per finding. + +The dead-engine oracle (test_rekey_dual_fp) only sees PY-WL-* (the identity corpus +predicate), so the engine-DEFECT path is proven HERE — and the POLICY-CONFIG old_fp +is pinned to an INDEPENDENT hand-rolled wlfp1 hash (NOT via the production v0), so a +reconstruction drift fails this test, not just a membership bug. +""" + +from __future__ import annotations + +import hashlib + +from wardline.core.finding import ENGINE_PATH, Finding, Kind, Location, Severity +from wardline.core.rekey import ( + _POLICY_CONFIG_RULE_ID, + _is_scheme_independent, + build_remap, + carry_baseline_forward, + compute_old_new_fingerprints, + is_join_population, +) + +_POLICY_TAINT = "rules.enable:empty" + + +def _hand_rolled_wlfp1(rule_id: str, path: str, line_start: str, qualname: str, taint_path: str) -> str: + # Independent of compute_finding_fingerprint_v0 — the non-circular oracle. + return hashlib.sha256("\x00".join((rule_id, path, line_start, qualname, taint_path)).encode()).hexdigest() + + +def _policy_config_finding(new_fp: str) -> Finding: + return Finding( + rule_id=_POLICY_CONFIG_RULE_ID, + message="config weakens rules", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=ENGINE_PATH), # lineless -> line_start is None + fingerprint=new_fp, + taint_path_v0=_POLICY_TAINT, + ) + + +def _engine_diagnostic_finding(fp: str) -> Finding: + return Finding( + rule_id="WLN-L3-MONOTONICITY-VIOLATION", + message="engine diagnostic", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path=ENGINE_PATH), + fingerprint=fp, + taint_path_v0=None, + ) + + +def test_policy_config_is_in_the_join_population() -> None: + # The gate regression: this gating ERROR DEFECT must NOT be excluded from the remap. + assert is_join_population(_policy_config_finding("9" * 64)) + + +def test_policy_config_old_fp_is_v0_reconstructed_noncircular() -> None: + f = _policy_config_finding("9" * 64) + assert f.location.line_start is None # lineless -> "None" in the wlfp1 parts + expected_old = _hand_rolled_wlfp1(_POLICY_CONFIG_RULE_ID, ENGINE_PATH, "None", "", _POLICY_TAINT) + assert expected_old == "c168d13a201d791952cac46ced9f9ab8910c7ee2d39711261601e557f11d7701" + assert not _is_scheme_independent(_POLICY_CONFIG_RULE_ID) # v0 branch, NOT identity + + [remap] = compute_old_new_fingerprints([f]) + assert remap.old_fp == expected_old, "POLICY-CONFIG old_fp must byte-match the wlfp1 dead-engine hash" + assert remap.new_fp == "9" * 64 + assert remap.old_fp != remap.new_fp # it genuinely rekeyed (line_start dropped) + + +def test_engine_diagnostic_old_fp_is_identity() -> None: + # diagnostics._fingerprint is scheme-independent (no line_start), so old_fp == new_fp. + assert _is_scheme_independent("WLN-L3-MONOTONICITY-VIOLATION") + f = _engine_diagnostic_finding("d" * 64) + [remap] = compute_old_new_fingerprints([f]) + assert remap.old_fp == remap.new_fp == "d" * 64 + + +def test_policy_config_baselined_verdict_carries_across_rekey() -> None: + import pytest + + yaml = pytest.importorskip("yaml") + import tempfile + from pathlib import Path + + f = _policy_config_finding("9" * 64) + result = build_remap(compute_old_new_fingerprints([f])) + old_fp = next(iter(result.old_to_new)) + with tempfile.TemporaryDirectory() as td: + sp = Path(td) / "baseline.yaml" + sp.write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "entries": [ + {"fingerprint": old_fp, "rule_id": _POLICY_CONFIG_RULE_ID, "path": ENGINE_PATH, "message": "x"} + ], + } + ), + encoding="utf-8", + ) + carry = carry_baseline_forward(sp, result.old_to_new) + assert carry.carried == (old_fp,) # NOT orphaned (the regression: it used to drop) + assert carry.orphaned == () + assert {e["fingerprint"] for e in carry.document["entries"]} == {"9" * 64} + + +def test_rekey_policy_config_rule_id_matches_scanner() -> None: + # core/ must not import scanner/ (layering), so the rule_id is duplicated — lock it. + from wardline.scanner.rules import _POLICY_CONFIG_RULE_ID as SCANNER_PCID + + assert _POLICY_CONFIG_RULE_ID == SCANNER_PCID + + +def test_rs_wl_included_in_the_migration_population() -> None: + # P5-REVISIT decided 2026-06-10 (identity keystone): Rust identity graduated — + # RS-WL-* DEFECTs are baseline-eligible, so a stored RS-WL verdict must migrate + # like any other DEFECT (the former exclusion became a live orphaning path). + rs = Finding( + rule_id="RS-WL-108", + message="rust cmd injection", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="m.rs", line_start=4), + fingerprint="r" * 64, + ) + assert is_join_population(rs) diff --git a/tests/unit/core/test_rekey_probe.py b/tests/unit/core/test_rekey_probe.py new file mode 100644 index 00000000..90e1502a --- /dev/null +++ b/tests/unit/core/test_rekey_probe.py @@ -0,0 +1,212 @@ +"""P4 S9 — `--probe`: read-only cross-check that writes NOTHING.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +from wardline.core import paths # noqa: E402 +from wardline.core.finding import Finding, Kind, Location, Severity # noqa: E402 +from wardline.core.fingerprint_v0 import compute_finding_fingerprint_v0 # noqa: E402 +from wardline.core.rekey import probe, snapshot_dir # noqa: E402 + +ORPHAN = "f" * 64 + + +def _finding() -> Finding: + return Finding( + rule_id="PY-WL-108", + message="m", + severity=Severity.WARN, + kind=Kind.DEFECT, + location=Location(path="m.py", line_start=10), + fingerprint="9" * 64, + qualname="m.f", + taint_path_v0="os.system@4:20", + ) + + +def test_probe_reports_unmatched_and_writes_nothing(tmp_path: Path) -> None: + root = tmp_path + f = _finding() + old_fp = compute_finding_fingerprint_v0( + rule_id="PY-WL-108", path="m.py", line_start=10, qualname="m.f", taint_path="os.system@4:20" + ) + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + # Live baseline (old-scheme): one matchable old_fp + one orphan (source gone). + (state / "baseline.yaml").write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "entries": [ + {"fingerprint": old_fp, "rule_id": "PY-WL-108", "path": "m.py", "message": "x"}, + {"fingerprint": ORPHAN, "rule_id": "PY-WL-101", "path": "gone.py", "message": "y"}, + ], + } + ), + encoding="utf-8", + ) + + report = probe(root, [f]) + assert report.scanned_findings == 1 + assert report.matched == 1 + assert report.orphaned == (ORPHAN,) + assert report.collisions == () + assert report.per_store == {"baseline.yaml": 1} + assert not report.clean + + # Writes NOTHING: no journal, no snapshot, baseline untouched. + assert not paths.migration_journal_path(root).exists() + assert not snapshot_dir(root).exists() + + +def test_probe_clean_when_all_match(tmp_path: Path) -> None: + root = tmp_path + f = _finding() + old_fp = compute_finding_fingerprint_v0( + rule_id="PY-WL-108", path="m.py", line_start=10, qualname="m.f", taint_path="os.system@4:20" + ) + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + (state / "baseline.yaml").write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "entries": [{"fingerprint": old_fp, "rule_id": "PY-WL-108", "path": "m.py", "message": "x"}], + } + ), + encoding="utf-8", + ) + report = probe(root, [f]) + assert report.matched == 1 and report.orphaned == () and report.clean + assert report.prescheme is False # a wlfp1-stamped store is not pre-scheme + + +def test_probe_does_not_flag_an_empty_project(tmp_path: Path) -> None: + # No stores at all -> nothing to migrate, nothing to caution about. + assert probe(tmp_path, [_finding()]).prescheme is False + + +def _write_store(state: Path, name: str, *, scheme: str | None, fingerprints: list[str]) -> None: + doc: dict = { + "version": 1, + "entries": [{"fingerprint": fp, "rule_id": "PY-WL-108", "path": "m.py", "message": "x"} for fp in fingerprints], + } + if scheme is not None: + doc["fingerprint_scheme"] = scheme + (state / name).write_text(yaml.safe_dump(doc), encoding="utf-8") + + +def test_probe_reports_a_healthy_current_scheme_baseline_as_clean_noop(tmp_path: Path) -> None: + # A7 (weft-dda1a6d8dd): a store ALREADY stamped with the live scheme whose entries + # all match the current scan is a healthy baseline with NO migration pending. The + # probe must report matched=N, orphaned=0, clean — never 100% orphaned (which it + # did when it compared every store against the wlfp1-reconstructed keys only). + root = tmp_path + f = _finding() + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _write_store(state, "baseline.yaml", scheme="wlfp2", fingerprints=[f.fingerprint]) + + report = probe(root, [f]) + assert report.matched == 1 + assert report.orphaned == () + assert report.stale == () + assert report.clean + assert report.no_op + assert report.current_scheme_stores == ("baseline.yaml",) + # Read-only, as ever. + assert not paths.migration_journal_path(root).exists() + assert not snapshot_dir(root).exists() + + +def test_probe_current_scheme_entry_without_a_finding_is_stale_not_orphaned(tmp_path: Path) -> None: + # An entry already at the live scheme that matches no current finding is baseline + # DRIFT (the source changed since baselining) — a rekey would not touch it, so it + # must not be reported as a migration orphan with the source-moved cause. + root = tmp_path + f = _finding() + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _write_store(state, "baseline.yaml", scheme="wlfp2", fingerprints=[f.fingerprint, "e" * 64]) + + report = probe(root, [f]) + assert report.matched == 1 + assert report.orphaned == () + assert report.stale == ("e" * 64,) + assert report.no_op + assert report.clean # no migration pending — stale entries are hygiene, not rekey risk + + +def test_probe_mixed_schemes_judges_each_store_against_its_own_scheme(tmp_path: Path) -> None: + # One store still wlfp1 (migration pending for it), one already wlfp2: the wlfp1 + # store is judged against the reconstructed old keys, the wlfp2 store against the + # live fingerprints — neither contaminates the other's verdict. + root = tmp_path + f = _finding() + old_fp = compute_finding_fingerprint_v0( + rule_id="PY-WL-108", path="m.py", line_start=10, qualname="m.f", taint_path="os.system@4:20" + ) + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _write_store(state, "baseline.yaml", scheme="wlfp1", fingerprints=[old_fp, ORPHAN]) + doc = { + "fingerprint_scheme": "wlfp2", + "version": 1, + "waivers": [{"fingerprint": f.fingerprint, "rule_id": "PY-WL-108", "path": "m.py", "message": "x"}], + } + (state / "waivers.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") + + report = probe(root, [f]) + assert report.matched == 2 # old_fp via the remap keys, f.fingerprint via the live set + assert report.orphaned == (ORPHAN,) + assert report.per_store == {"baseline.yaml": 1} + assert report.current_scheme_stores == ("waivers.yaml",) + assert not report.no_op # a wlfp1 store still pends migration + assert not report.clean + + +def test_run_rekey_refuses_when_no_migration_is_pending(tmp_path: Path) -> None: + # Companion guard to the probe fix: applying a rekey over stores ALREADY at the + # live scheme would re-key wlfp2 entries through the wlfp1 remap and orphan every + # verdict (the destructive twin of the A7 probe misread). Refuse before writing. + from wardline.core.errors import WardlineError + from wardline.core.rekey import run_rekey + + root = tmp_path + f = _finding() + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + _write_store(state, "baseline.yaml", scheme="wlfp2", fingerprints=[f.fingerprint]) + before = (state / "baseline.yaml").read_bytes() + + with pytest.raises(WardlineError, match="no fingerprint migration is pending"): + run_rekey(root, [f]) + assert (state / "baseline.yaml").read_bytes() == before + assert not paths.migration_journal_path(root).exists() + assert not snapshot_dir(root).exists() + + +def test_probe_flags_a_scheme_less_prescheme_store(tmp_path: Path) -> None: + # A POPULATED store with NO fingerprint_scheme header predates P1's stamp. Its + # fingerprints may also predate 705acfe (resolved-taint) -> v0 can't reconstruct them + # and verdicts orphan from a formula change, not source churn. Surface the possibility. + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + (state / "baseline.yaml").write_text( + yaml.safe_dump( + { + "version": 1, + "entries": [{"fingerprint": "a" * 64, "rule_id": "PY-WL-101", "path": "x.py", "message": "y"}], + } + ), + encoding="utf-8", + ) + assert probe(root, [_finding()]).prescheme is True diff --git a/tests/unit/core/test_rekey_review_fold.py b/tests/unit/core/test_rekey_review_fold.py new file mode 100644 index 00000000..67a8bdaf --- /dev/null +++ b/tests/unit/core/test_rekey_review_fold.py @@ -0,0 +1,119 @@ +"""P4 review fold — the secondary findings (all real, all folded): +Filigree 2xx-with-failures, populated-collision surfacing, expired-waiver carry, +and the already-complete forward-rerun guard. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +from wardline.core import paths # noqa: E402 +from wardline.core.errors import WardlineError # noqa: E402 +from wardline.core.filigree_emit import EmitResult, FailedFinding # noqa: E402 +from wardline.core.finding import Finding, Kind, Location, Severity # noqa: E402 +from wardline.core.rekey import ( # noqa: E402 + Journal, + apply_pending_legs, + probe, + run_rekey, + snapshot_dir, + write_journal, +) + + +class _FakeEmitter: + def __init__(self, result: EmitResult) -> None: + self._result = result + + def emit(self, findings, *, scanned_paths=()): # type: ignore[no-untyped-def] + return self._result + + +def _defect(fp: str, line: int, tpv0: str) -> Finding: + return Finding( + rule_id="PY-WL-108", + message="m", + severity=Severity.WARN, + kind=Kind.DEFECT, + location=Location(path="m.py", line_start=line), + fingerprint=fp, + qualname="m.f", + taint_path_v0=tpv0, + ) + + +def test_filigree_2xx_with_failures_records_debt_not_done(tmp_path: Path) -> None: + # A 2xx whose body reports rejected findings is NOT a clean reconciliation. + j = Journal(remap={}) + for n in ("baseline", "judged", "waivers"): + j.leg(n).done = True + partial = _FakeEmitter( + EmitResult( + reachable=True, + created=1, + failures=( + FailedFinding(reason="rejected", fingerprint="wlfp2:p1"), + FailedFinding(reason="rejected", fingerprint="wlfp2:p2"), + ), + url="http://x", + ) + ) + apply_pending_legs(tmp_path, j, findings=[_defect("1" * 64, 3, "a@1:2")], filigree=partial) + assert j.leg("filigree").done is False + assert j.leg("filigree").debt and "rejected" in j.leg("filigree").debt + assert not j.complete + + +def test_probe_surfaces_a_real_collision(tmp_path: Path) -> None: + # Two findings DISTINCT under wlfp1 (different line_start -> different old_fp) but + # sharing one new_fp -> a collision the operator must SEE. + f1 = _defect("c" * 64, 10, "os.system@1:2") + f2 = _defect("c" * 64, 20, "os.system@1:2") + report = probe(tmp_path, [f1, f2]) + assert len(report.collisions) == 1 + assert report.collisions[0].new_fp == "c" * 64 + assert len(report.collisions[0].old_fps) == 2 + assert not report.clean + + +def test_carry_preserves_an_expired_waiver(tmp_path: Path) -> None: + # Preservation, not filtering: an operator's expired waiver carries with its past date. + from wardline.core.rekey import carry_waivers_forward + + a, na = "a" * 64, "1" * 64 + sp = tmp_path / "waivers.yaml" + sp.write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp1", + "version": 1, + "waivers": [{"fingerprint": a, "reason": "old", "expires": "2000-01-01"}], + } + ), + encoding="utf-8", + ) + carry = carry_waivers_forward(sp, {a: na}) + entry = carry.document["waivers"][0] + assert entry["fingerprint"] == na and entry["expires"] == "2000-01-01" + + +def test_run_rekey_refuses_when_already_complete(tmp_path: Path) -> None: + # A forward re-run over a COMPLETE migration would re-carry from the stale wlfp1 + # snapshot and drop verdicts added since — refuse loudly, point at --rollback. + root = tmp_path + snapshot_dir(root).mkdir(parents=True) + (snapshot_dir(root) / "baseline.yaml").write_text( + "fingerprint_scheme: wlfp1\nversion: 1\nentries: []\n", encoding="utf-8" + ) + j = Journal(remap={}) + for leg in j.legs: + leg.done = True + write_journal(paths.migration_journal_path(root), j, root=root) + assert j.complete + + with pytest.raises(WardlineError, match="already complete"): + run_rekey(root, [], filigree=None) diff --git a/tests/unit/core/test_rekey_rollback.py b/tests/unit/core/test_rekey_rollback.py new file mode 100644 index 00000000..99330b5a --- /dev/null +++ b/tests/unit/core/test_rekey_rollback.py @@ -0,0 +1,36 @@ +"""P4 S10 — forward-only rollback: restore YAML byte-identical, remove journal+snapshot.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from wardline.core import paths +from wardline.core.errors import WardlineError +from wardline.core.rekey import rollback, snapshot_dir + + +def test_rollback_restores_yaml_byte_identical(tmp_path: Path) -> None: + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + sdir = snapshot_dir(root) + sdir.mkdir(parents=True) + + original = b"fingerprint_scheme: wlfp1\nversion: 1\nentries: []\n" + (sdir / "baseline.yaml").write_bytes(original) # the pre-migration snapshot + (state / "baseline.yaml").write_bytes(b"REKEYED-wlfp2-CONTENT") # the migrated live store + paths.migration_journal_path(root).write_text("schema_version: 1\nremap: {}\n", encoding="utf-8") + + result = rollback(root) + + assert result.restored == ("baseline.yaml",) + assert (state / "baseline.yaml").read_bytes() == original # byte-identical restore + assert not paths.migration_journal_path(root).exists() # journal removed + assert not (sdir / "baseline.yaml").exists() # snapshot cleaned up + + +def test_rollback_without_snapshot_raises(tmp_path: Path) -> None: + with pytest.raises(WardlineError, match="nothing to roll back"): + rollback(tmp_path) diff --git a/tests/unit/core/test_rekey_snapshot.py b/tests/unit/core/test_rekey_snapshot.py new file mode 100644 index 00000000..2aa31a9f --- /dev/null +++ b/tests/unit/core/test_rekey_snapshot.py @@ -0,0 +1,40 @@ +"""P4 S4 — pre-flight snapshot: copy existing stores only, never clobber.""" + +from __future__ import annotations + +from pathlib import Path + +from wardline.core import paths +from wardline.core.rekey import snapshot_dir, snapshot_stores + + +def test_snapshot_copies_existing_stores_only(tmp_path: Path) -> None: + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + (state / "baseline.yaml").write_text("baseline-bytes", encoding="utf-8") + (state / "waivers.yaml").write_text("waivers-bytes", encoding="utf-8") + # judged.yaml deliberately absent + + present = snapshot_stores(root) + assert set(present) == {"baseline.yaml", "waivers.yaml"} + + sdir = snapshot_dir(root) + assert (sdir / "baseline.yaml").read_text(encoding="utf-8") == "baseline-bytes" + assert (sdir / "waivers.yaml").read_text(encoding="utf-8") == "waivers-bytes" + assert not (sdir / "judged.yaml").exists() + + +def test_snapshot_is_idempotent_and_never_clobbers(tmp_path: Path) -> None: + root = tmp_path + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + (state / "baseline.yaml").write_text("ORIGINAL", encoding="utf-8") + snapshot_stores(root) + + # A second invocation after the live store changed must NOT overwrite the + # snapshot — it is the immutable pre-migration provenance source. + (state / "baseline.yaml").write_text("REWRITTEN-BY-A-PARTIAL-RUN", encoding="utf-8") + present = snapshot_stores(root) + assert "baseline.yaml" in present + assert (snapshot_dir(root) / "baseline.yaml").read_text(encoding="utf-8") == "ORIGINAL" diff --git a/tests/unit/core/test_run.py b/tests/unit/core/test_run.py index 0c917ab5..55d2f107 100644 --- a/tests/unit/core/test_run.py +++ b/tests/unit/core/test_run.py @@ -6,7 +6,7 @@ import pytest from wardline.core.errors import ConfigError -from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState +from wardline.core.finding import FINGERPRINT_SCHEME, Finding, Kind, Location, Severity, SuppressionState from wardline.core.judged import JudgedFP, write_judged from wardline.core.paths import baseline_path, judged_path, waivers_path from wardline.core.run import ( @@ -49,6 +49,19 @@ def test_run_scan_returns_findings_summary_and_context() -> None: assert result.context is not None +def test_run_scan_reports_discovery_and_analysis_progress(tmp_path: Path) -> None: + (tmp_path / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + events: list[dict[str, object]] = [] + + result = run_scan(tmp_path, progress_callback=events.append) + + assert result.files_scanned == 1 + assert [event["phase"] for event in events] == ["discovered", "analyzing", "analyzed"] + assert events[0]["files_discovered"] == 1 + assert events[-1]["files_analyzed"] == 1 + assert "findings" in events[-1] + + def test_gate_decision_trips_on_active_error(tmp_path: Path) -> None: proj = tmp_path / "proj" proj.mkdir() @@ -113,7 +126,7 @@ def test_run_scan_baselined_count_distinguishes_categories(tmp_path: Path) -> No bl = baseline_path(proj) bl.parent.mkdir(parents=True, exist_ok=True) bl.write_text( - "version: 1\nentries:\n" + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\nentries:\n" f" - fingerprint: {leak.fingerprint}\n" " rule_id: PY-WL-101\n path: svc.py\n message: m\n", encoding="utf-8", @@ -146,7 +159,8 @@ def _write_baseline(proj: Path, fp: str) -> None: bl = baseline_path(proj) bl.parent.mkdir(parents=True, exist_ok=True) bl.write_text( - f"version: 1\nentries:\n - fingerprint: {fp}\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\n" + f"entries:\n - fingerprint: {fp}\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", encoding="utf-8", ) @@ -251,17 +265,32 @@ def test_gate_decision_reason_names_both_active_and_suppressed_on_mixed_trip(tmp def test_gate_decision_rejects_contradictory_construction() -> None: # The __post_init__ invariant guard: GateDecision must make "tripped gate that reads - # as passed" (dogfood #2) unconstructible, not merely avoided by the factory. + # as passed" (dogfood #2) unconstructible, not merely avoided by the factory. The guards + # are now verdict-keyed (weft-b937e53854). with pytest.raises(ValueError, match="exit_class"): - GateDecision(tripped=True, fail_on="ERROR", exit_class=0, reason="x", evaluated="y") - with pytest.raises(ValueError, match="reason"): - GateDecision(tripped=True, fail_on="ERROR", exit_class=1, reason=None, evaluated="y") + GateDecision(tripped=True, fail_on="ERROR", exit_class=0, verdict="FAILED", reason="x", evaluated="y") with pytest.raises(ValueError, match="reason"): - # fail_on set but no verdict — the no-gate shape leaking into a gated decision. - GateDecision(tripped=False, fail_on="ERROR", exit_class=0, reason=None, evaluated=None) - # The two legitimate shapes the factory produces still construct cleanly. - GateDecision(tripped=False, fail_on=None, exit_class=0) - GateDecision(tripped=True, fail_on="ERROR", exit_class=1, reason="1 active", evaluated="unsuppressed") + GateDecision(tripped=True, fail_on="ERROR", exit_class=1, verdict="FAILED", reason=None, evaluated="y") + with pytest.raises(ValueError, match="NOT_EVALUATED"): + # NOT_EVALUATED but a threshold IS set — the no-gate shape leaking into a gated decision. + GateDecision(tripped=False, fail_on="ERROR", exit_class=0, verdict="NOT_EVALUATED", reason="x", evaluated="y") + with pytest.raises(ValueError, match="FAILED"): + # tripped but verdict says PASSED — the dogfood #2 regression made unconstructible. + GateDecision(tripped=True, fail_on="ERROR", exit_class=1, verdict="PASSED", reason="x", evaluated="y") + # The three legitimate shapes the factory produces still construct cleanly. + GateDecision(tripped=False, fail_on=None, exit_class=0, verdict="NOT_EVALUATED", reason="no threshold") + GateDecision( + tripped=False, fail_on="ERROR", exit_class=0, verdict="PASSED", reason="clean", evaluated="unsuppressed" + ) + GateDecision( + tripped=True, + fail_on="ERROR", + exit_class=1, + verdict="FAILED", + reason="1 active", + evaluated="unsuppressed", + severity_tripped=True, + ) def test_gate_decision_evaluated_reflects_trust_suppressions(tmp_path: Path) -> None: @@ -272,10 +301,118 @@ def test_gate_decision_evaluated_reflects_trust_suppressions(tmp_path: Path) -> assert decision.evaluated is not None and "honored" in decision.evaluated -def test_gate_decision_no_threshold_has_no_reason() -> None: +def test_gate_decision_no_threshold_is_not_evaluated() -> None: + # weft-b937e53854: a bare scan is NOT a clean pass — it never ran the gate. result = ScanResult(findings=[], summary=ScanSummary(0, 0, 0, 0, 0), files_scanned=0, context=None) decision = gate_decision(result, None) - assert decision.reason is None and decision.evaluated is None + assert decision.verdict == "NOT_EVALUATED" + assert decision.tripped is False and decision.exit_class == 0 + # Empty tree: nothing would trip, but the decision still carries an honest reason + population. + assert decision.would_trip_at is None + assert decision.reason is not None and "did not evaluate" in decision.reason + assert decision.evaluated is not None + + +def _unanalyzed_result(unanalyzed: int) -> ScanResult: + # The unanalyzed overlay counts non-gating engine FACTs (file-skipped / missing source + # root), so the severity gate population can be empty while unanalyzed > 0. + return ScanResult( + findings=[], + summary=ScanSummary(unanalyzed, 0, 0, 0, 0, informational=unanalyzed, unanalyzed=unanalyzed), + files_scanned=1, + context=None, + ) + + +def test_gate_decision_fail_on_unanalyzed_trips_without_threshold() -> None: + # A4 (wardline-7fd0f3a82c): the unanalyzed gate is part of the decision itself, not a + # CLI-only exit-code OR — so the MCP surface (no exit code) can express it too. + decision = gate_decision(_unanalyzed_result(2), None, fail_on_unanalyzed=True) + assert decision.tripped is True and decision.exit_class == 1 + assert decision.verdict == "FAILED" + assert decision.fail_on is None + assert decision.fail_on_unanalyzed is True + assert decision.unanalyzed_tripped is True and decision.severity_tripped is False + assert decision.reason is not None and "2" in decision.reason and "not analyzed" in decision.reason + # The severity gate still never ran — the reason must say so (no vacuous severity green). + assert "no --fail-on threshold set" in decision.reason + + +def test_gate_decision_fail_on_unanalyzed_clean_passes_without_threshold() -> None: + # The knob alone makes the gate EVALUATED (it judged the unanalyzed count), but the + # reason must still flag that no severity threshold ran. + decision = gate_decision(_unanalyzed_result(0), None, fail_on_unanalyzed=True) + assert decision.tripped is False and decision.exit_class == 0 + assert decision.verdict == "PASSED" + assert decision.fail_on is None and decision.fail_on_unanalyzed is True + assert decision.unanalyzed_tripped is False and decision.severity_tripped is False + assert decision.reason is not None and "no --fail-on threshold set" in decision.reason + + +def test_gate_decision_fail_on_unanalyzed_composes_with_severity_gate(tmp_path: Path) -> None: + # Both sub-gates configured: a severity-clean tree with an unanalyzed file still FAILS, + # and the reason names BOTH outcomes. + proj, _fp_ = _leaky_proj(tmp_path) + result = run_scan(proj) + decision = gate_decision(result, Severity.ERROR, fail_on_unanalyzed=True) + # The leaky fixture has no unanalyzed files: severity trips, unanalyzed does not. + assert decision.severity_tripped is True and decision.unanalyzed_tripped is False + assert decision.tripped is True and decision.verdict == "FAILED" + clean = gate_decision(_unanalyzed_result(1), Severity.ERROR, fail_on_unanalyzed=True) + assert clean.severity_tripped is False and clean.unanalyzed_tripped is True + assert clean.tripped is True and clean.verdict == "FAILED" + assert clean.fail_on == "ERROR" + assert clean.reason is not None and "not analyzed" in clean.reason + + +def test_gate_decision_default_ignores_unanalyzed() -> None: + # Default (knob off) preserves released behaviour on BOTH surfaces: unanalyzed is + # reported, never gated. + decision = gate_decision(_unanalyzed_result(3), None) + assert decision.tripped is False and decision.verdict == "NOT_EVALUATED" + assert decision.fail_on_unanalyzed is False and decision.unanalyzed_tripped is False + + +def test_gate_decision_unanalyzed_invariants_unconstructible() -> None: + # The sub-trip decomposition is guarded the same way dogfood #2 is: an overall trip + # that no sub-gate explains (or vice versa) must not construct. + with pytest.raises(ValueError, match="severity_tripped|unanalyzed_tripped|tripped"): + # tripped with NEITHER sub-gate tripping. + GateDecision(tripped=True, fail_on="ERROR", exit_class=1, verdict="FAILED", reason="x", evaluated="y") + with pytest.raises(ValueError, match="unanalyzed_tripped"): + # unanalyzed trip without the knob on. + GateDecision( + tripped=True, + fail_on="ERROR", + exit_class=1, + verdict="FAILED", + reason="x", + evaluated="y", + unanalyzed_tripped=True, + ) + with pytest.raises(ValueError, match="severity_tripped"): + # severity trip without a threshold. + GateDecision( + tripped=True, + fail_on=None, + exit_class=1, + verdict="FAILED", + reason="x", + evaluated="y", + fail_on_unanalyzed=True, + severity_tripped=True, + ) + with pytest.raises(ValueError, match="NOT_EVALUATED"): + # The knob alone makes the gate evaluated — NOT_EVALUATED is no longer its shape. + GateDecision( + tripped=False, + fail_on=None, + exit_class=0, + verdict="NOT_EVALUATED", + reason="x", + evaluated="y", + fail_on_unanalyzed=True, + ) def _hint(proj: Path, *, new_since=None, trust=False): @@ -419,7 +556,8 @@ def h(p): bl = baseline_path(tmp_path) bl.parent.mkdir(parents=True, exist_ok=True) bl.write_text( - f"version: 1\nentries:\n - fingerprint: {new_fp}\n rule_id: PY-WL-101\n path: caller.py\n" + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\n" + f"entries:\n - fingerprint: {new_fp}\n rule_id: PY-WL-101\n path: caller.py\n" " message: m\n", encoding="utf-8", ) @@ -522,14 +660,62 @@ def test_gate_decision_rejects_unknown_fail_on() -> None: # fail_on is always a Severity value; an arbitrary string is an illegal state the # other guards would otherwise let through (it satisfies "reason iff fail_on"). with pytest.raises(ValueError, match="fail_on"): - GateDecision(tripped=True, fail_on="banana", exit_class=1, reason="x", evaluated="y") + GateDecision( + tripped=True, + fail_on="banana", + exit_class=1, + verdict="FAILED", + reason="x", + evaluated="y", + severity_tripped=True, + ) def test_gate_decision_accepts_valid_severity_value() -> None: - dec = GateDecision(tripped=True, fail_on=Severity.ERROR.value, exit_class=1, reason="x", evaluated="y") + dec = GateDecision( + tripped=True, + fail_on=Severity.ERROR.value, + exit_class=1, + verdict="FAILED", + reason="x", + evaluated="y", + severity_tripped=True, + ) assert dec.fail_on == "ERROR" +def test_would_trip_at_names_highest_severity_on_bare_scan(tmp_path: Path) -> None: + # weft-b937e53854: a bare scan reports would_trip_at = the worst active severity, so the + # agent's first call is not a vacuous green. The leaky proj has a PY-WL-101 ERROR defect. + proj, _ = _leaky_proj(tmp_path) + decision = gate_decision(run_scan(proj), None) + assert decision.verdict == "NOT_EVALUATED" + assert decision.would_trip_at == "ERROR" + assert decision.tripped is False and decision.exit_class == 0 + assert decision.reason is not None and "ERROR" in decision.reason + + +def test_verdict_passed_vs_failed_and_would_trip_at_is_threshold_independent(tmp_path: Path) -> None: + proj, _ = _leaky_proj(tmp_path) + result = run_scan(proj) + failed = gate_decision(result, Severity.ERROR) + assert failed.verdict == "FAILED" and failed.tripped is True and failed.would_trip_at == "ERROR" + # A threshold ABOVE the worst active severity passes; would_trip_at still names the worst. + passed = gate_decision(result, Severity.CRITICAL) + assert passed.verdict == "PASSED" and passed.tripped is False + assert passed.would_trip_at == "ERROR" + + +def test_summary_buckets_sum_to_total(tmp_path: Path) -> None: + # weft-f506e5f845: active+baselined+waived+judged+informational == total exactly; + # unanalyzed is an overlay, not a partition member. + proj, fp = _leaky_proj(tmp_path) + _write_baseline(proj, fp) + s = run_scan(proj).summary + assert s.active + s.baselined + s.waived + s.judged + s.informational == s.total + assert s.informational >= 1 # the engine metric/fact is a non-defect finding + + def test_run_scan_explicit_malformed_config_raises(tmp_path: Path) -> None: # (d) An EXPLICIT --config that EXISTS but is malformed must NOT silently fall # back to default policy either — that is the same false-green as a missing path. @@ -573,3 +759,65 @@ def test_run_scan_out_of_root_symlink_yields_finding(tmp_path: Path) -> None: assert len(skipped) == 1 assert skipped[0].location.path == "src/evil.py" assert skipped[0].properties.get("reason") == "out_of_root_symlink" + + +# --- N-3 (wardline-8669de3576): nested scan root is surfaced, never silent --- + + +def test_run_scan_nested_scan_root_yields_fact(tmp_path: Path) -> None: + # A subdirectory scan of a weft project silently mints scan-relative qualnames, + # skips the project baseline, and drops output into the subdir. run_scan must + # surface the nested root as a structured FACT (reaching both the CLI warning + # and the MCP result). + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + sub = proj / "specimen" + sub.mkdir() + (sub / "svc.py").write_text(_LEAKY, encoding="utf-8") + result = run_scan(sub) + facts = [f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT"] + assert len(facts) == 1 + fact = facts[0] + assert fact.kind is Kind.FACT and fact.severity is Severity.NONE + assert fact.properties["project_root"] == str(proj.resolve()) + assert fact.properties["qualname_prefix"] == "specimen" + # the qualname hazard and the remedy root are named in the message (the + # agent-actionable signal — the CLI warning reuses this verbatim) + assert "qualname" in fact.message and str(proj.resolve()) in fact.message + # a scope hazard, not an under-scan — never counted as unanalyzed + assert result.summary.unanalyzed == 0 + # the PY-WL-101 defect still fires, with the scan-relative qualname the fact warns about + leak = next(f for f in result.findings if f.rule_id == "PY-WL-101") + assert leak.qualname == "svc.leaky" + + +def test_run_scan_project_root_scan_has_no_nested_fact(tmp_path: Path) -> None: + proj, _ = _leaky_proj(tmp_path) + (proj / ".weft" / "wardline").mkdir(parents=True, exist_ok=True) + result = run_scan(proj) + assert not [f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT"] + + +def test_run_scan_fresh_tree_subdir_has_no_nested_fact(tmp_path: Path) -> None: + # No weft markers anywhere above: a fresh unfederated tree must not warn — + # warning every first-time user would dilute the signal into habitual noise. + sub = tmp_path / "plain" / "pkg" + sub.mkdir(parents=True) + (sub / "m.py").write_text("def f(): return 1\n", encoding="utf-8") + result = run_scan(sub) + assert not [f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT"] + + +def test_run_scan_nested_src_root_has_empty_qualname_prefix(tmp_path: Path) -> None: + # Scanning P/src of a src-layout project mints the SAME qualnames as scanning P + # (module_dotted_name strips one leading src/ component) — the baseline/output + # hazards remain so the FACT still fires, but the prefix must be empty so the + # message never claims a phantom 'src.' qualname prefix. + proj = tmp_path / "proj" + (proj / ".weft" / "wardline").mkdir(parents=True) + src = proj / "src" + src.mkdir() + (src / "m.py").write_text("def f(): return 1\n", encoding="utf-8") + result = run_scan(src) + fact = next(f for f in result.findings if f.rule_id == "WLN-ENGINE-NESTED-SCAN-ROOT") + assert fact.properties["qualname_prefix"] == "" diff --git a/tests/unit/core/test_run_lang_rust.py b/tests/unit/core/test_run_lang_rust.py new file mode 100644 index 00000000..73fc7f7c --- /dev/null +++ b/tests/unit/core/test_run_lang_rust.py @@ -0,0 +1,64 @@ +"""WP6: ``run_scan(root, lang="rust")`` routes discovery + analysis to the Rust frontend. + +Pins the keystone integration: the ``lang`` discriminator selects ``.rs`` discovery and a +``RustAnalyzer`` while leaving the Python path (``lang`` defaulted) byte-identical, the gate +fires on an ``RS-WL-108`` ERROR, and a malformed ``.rs`` counts toward ``unanalyzed``. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.finding import Severity # noqa: E402 +from wardline.core.run import gate_decision, run_scan # noqa: E402 + +_TRUSTED = "/// @trusted(level=ASSURED)\n" +_INJECTION = _TRUSTED + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + + +def test_run_scan_rust_discovers_rs_and_gates_on_injection(tmp_path) -> None: + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + # A .py file in the same tree must be IGNORED under --lang rust (suffix routing). + (tmp_path / "decoy.py").write_text("x = 1\n", encoding="utf-8") + + result = run_scan(tmp_path, lang="rust") + + rule_ids = [f.rule_id for f in result.findings] + assert "RS-WL-108" in rule_ids + assert result.files_scanned == 1 # only m.rs; decoy.py not swept + assert result.context is None # Rust last_context is None (slice-1) + + decision = gate_decision(result, Severity.ERROR) + assert decision.tripped is True + assert decision.verdict == "FAILED" + + +def test_run_scan_rust_clean_tree_passes(tmp_path) -> None: + (tmp_path / "m.rs").write_text(_TRUSTED + 'fn run() {\n Command::new("ls").output();\n}\n', encoding="utf-8") + result = run_scan(tmp_path, lang="rust") + assert [f for f in result.findings if f.rule_id.startswith("RS-WL-")] == [] + assert gate_decision(result, Severity.ERROR).tripped is False + + +def test_run_scan_rust_malformed_file_counts_unanalyzed(tmp_path) -> None: + (tmp_path / "broken.rs").write_text("fn f( {\n std::env::var(\n", encoding="utf-8") + result = run_scan(tmp_path, lang="rust") + assert result.summary.unanalyzed == 1 + + +def test_run_scan_python_path_unchanged_by_lang_default(tmp_path) -> None: + # The Python default path must be untouched: a .py leak still fires PY-WL-101, and + # .rs files are NOT swept when lang defaults to python. + (tmp_path / "leak.py").write_text( + "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", + encoding="utf-8", + ) + (tmp_path / "ignored.rs").write_text(_INJECTION, encoding="utf-8") + result = run_scan(tmp_path) # lang defaults to python + rule_ids = {f.rule_id for f in result.findings} + assert "PY-WL-101" in rule_ids + assert not any(r.startswith("RS-WL-") for r in rule_ids) diff --git a/tests/unit/core/test_sarif.py b/tests/unit/core/test_sarif.py index 12bbfb0d..2b851216 100644 --- a/tests/unit/core/test_sarif.py +++ b/tests/unit/core/test_sarif.py @@ -4,6 +4,9 @@ import json from pathlib import Path +import pytest + +from wardline.core.errors import WardlineError from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState from wardline.core.sarif import SarifSink, build_sarif from wardline.core.taints import TaintState @@ -61,9 +64,12 @@ def test_severity_maps_to_level() -> None: assert levels == {"CRITICAL": "error", "ERROR": "error", "WARN": "warning", "INFO": "note", "NONE": "none"} -def test_partial_fingerprint_and_location() -> None: +def test_partial_fingerprint_key_is_v2() -> None: res = build_sarif([_f(line_start=42)])["runs"][0]["results"][0] - assert res["partialFingerprints"] == {"wardlineFingerprint/v1": "a" * 64} + # The KEY versions to /v2 to signal the scheme change; the VALUE stays bare + # (SARIF consumers read the value, not a prefixed form). + assert res["partialFingerprints"] == {"wardlineFingerprint/v2": "a" * 64} + assert ":" not in res["partialFingerprints"]["wardlineFingerprint/v2"] region = res["locations"][0]["physicalLocation"]["region"] # _f sets line_start == line_end and no columns -> exactly these two keys, no null cols assert set(region) == {"startLine", "endLine"} @@ -112,6 +118,18 @@ def test_sink_writes_valid_json(tmp_path: Path) -> None: assert loaded["version"] == "2.1.0" +def test_sink_refuses_symlink_target(tmp_path: Path) -> None: + outside = tmp_path / "outside.txt" + outside.write_text("keep\n", encoding="utf-8") + out = tmp_path / "findings.sarif" + out.symlink_to(outside) + + with pytest.raises(WardlineError, match="refusing to write through a symlink"): + SarifSink(out).write([_f()]) + + assert outside.read_text(encoding="utf-8") == "keep\n" + + def test_metric_findings_excluded_from_sarif() -> None: """Kind.METRIC findings (engine telemetry) must not appear in SARIF output.""" metric = _f(rule_id="WLN-L3-LOW-RESOLUTION", sev=Severity.INFO, kind=Kind.METRIC) diff --git a/tests/unit/core/test_scan_file_workflow.py b/tests/unit/core/test_scan_file_workflow.py index cd841189..210134e8 100644 --- a/tests/unit/core/test_scan_file_workflow.py +++ b/tests/unit/core/test_scan_file_workflow.py @@ -116,3 +116,35 @@ def test_scan_file_findings_surfaces_partial_failures(tmp_path): assert finding["promotion"]["disabled_reason"] == "filigree unreachable" assert finding["identity_attach"]["attempted"] is False assert "no issue_id" in finding["identity_attach"]["reason"] + + +def test_scan_file_findings_no_filer_reason_names_missing_url(tmp_path): + # When no Filigree filer is configured, identity_attach must name the actual + # cause (no URL), matching promotion.disabled_reason — not claim a promote + # happened and returned no issue_id. + root = _project(tmp_path) + dry = scan_file_findings(root) + fp = dry["active_defects"][0]["fingerprint"] + + out = scan_file_findings(root, fingerprints=(fp,), dry_run=False) + + finding = out["active_defects"][0] + assert finding["promotion"]["disabled_reason"] == "no Filigree URL configured" + assert finding["identity_attach"]["attempted"] is False + assert finding["identity_attach"]["reason"] == "no Filigree URL configured" + + +def test_scan_file_findings_emit_disabled_reason_uses_discriminated_ladder(tmp_path): + # A 401-with-token soft emit failure must surface the discriminated ladder + # (dogfood #5), not the flat "filigree unreachable". + root = _project(tmp_path) + dry = scan_file_findings(root) + fp = dry["active_defects"][0]["fingerprint"] + emitter = FakeEmitter(EmitResult(reachable=False, status=401, token_sent=True, url="http://filigree.local")) + + out = scan_file_findings(root, fingerprints=(fp,), filigree_emitter=emitter, dry_run=False) + + reason = out["filigree_emit"]["disabled_reason"] + assert "401" in reason + assert "token" in reason + assert "http://filigree.local" in reason diff --git a/tests/unit/core/test_suppression.py b/tests/unit/core/test_suppression.py index df497efe..2f1a02d8 100644 --- a/tests/unit/core/test_suppression.py +++ b/tests/unit/core/test_suppression.py @@ -65,9 +65,35 @@ def test_engine_diagnostic_defect_without_line_start_surfaces() -> None: assert out[0].suppressed is SuppressionState.ACTIVE # surfaces, not dropped +def test_collision_diagnostic_survives_suppression_and_trips_gate() -> None: + # End-to-end posture lock for the no-collision guard (wardline-8fb773a7af): + # the real build_collision_findings output is a lineless ENGINE_PATH ERROR + # DEFECT. It must NOT be downgraded to a non-gating FACT (the lineless + # downgrade exempts ) and it MUST trip --fail-on ERROR — otherwise the + # guard would be loud-but-ignorable and the masked finding stays hidden. + from wardline.scanner.diagnostics import build_collision_findings + + fp = "f" * 64 + a = Finding("PY-WL-114", "first", Severity.ERROR, Kind.DEFECT, Location("a.py", 3), fp) + b = Finding("PY-WL-114", "second", Severity.ERROR, Kind.DEFECT, Location("a.py", 3), fp) + diags = build_collision_findings([a, b]) + assert len(diags) == 1 and diags[0].rule_id == "WLN-ENGINE-FINGERPRINT-COLLISION" + + out = apply_suppressions(diags, _empty_baseline(), _no_waivers(), today=_TODAY) + assert out[0].rule_id == "WLN-ENGINE-FINGERPRINT-COLLISION" # NOT downgraded + assert out[0].kind is Kind.DEFECT + assert out[0].suppressed is SuppressionState.ACTIVE + assert gate_trips(out, Severity.ERROR) is True + + def test_baselined_finding_is_annotated() -> None: out = apply_suppressions([_defect(_FP_A)], Baseline(frozenset({_FP_A})), _no_waivers(), today=_TODAY) assert out[0].suppressed is SuppressionState.BASELINED + # Lock the baseline/else branch split in apply_suppressions: BASELINED carries NO + # reason (the resolver returns reason=None for a baseline match), distinct from + # WAIVED/JUDGED which carry the resolver reason. A future edit collapsing the two + # branches would change this. + assert out[0].suppression_reason is None def test_waived_finding_is_annotated_with_reason() -> None: diff --git a/tests/unit/core/test_waiver_add.py b/tests/unit/core/test_waiver_add.py index f71f4681..b11b3474 100644 --- a/tests/unit/core/test_waiver_add.py +++ b/tests/unit/core/test_waiver_add.py @@ -3,7 +3,7 @@ import pytest -from wardline.core.errors import ConfigError +from wardline.core.errors import ConfigError, WardlineError from wardline.core.paths import waivers_path from wardline.core.waivers import add_waiver, load_project_waivers @@ -80,3 +80,68 @@ def test_add_waiver_no_expiry_omits_field(tmp_path: Path) -> None: assert w.expires is None waivers = load_project_waivers(tmp_path) assert waivers[0].expires is None + + +def test_add_waiver_persists_entity_binding_and_roundtrips(tmp_path: Path) -> None: + w = add_waiver( + waivers_path(tmp_path), + fingerprint=FP, + reason="false positive: validated upstream", + expires=None, + root=tmp_path, + entity_sei="loomweave:eid:abc", + entity_locator="python:function:pkg.mod.leaky", + ) + assert w.entity_sei == "loomweave:eid:abc" + assert w.entity_locator == "python:function:pkg.mod.leaky" + (loaded,) = load_project_waivers(tmp_path) + assert loaded.entity_sei == "loomweave:eid:abc" + assert loaded.entity_locator == "python:function:pkg.mod.leaky" + + +def test_add_waiver_without_entity_binding_omits_fields(tmp_path: Path) -> None: + add_waiver(waivers_path(tmp_path), fingerprint=FP, reason="ok", expires=None, root=tmp_path) + raw = waivers_path(tmp_path).read_text(encoding="utf-8") + assert "entity_sei" not in raw and "entity_locator" not in raw + (loaded,) = load_project_waivers(tmp_path) + assert loaded.entity_sei is None and loaded.entity_locator is None + + +def test_load_waivers_rejects_malformed_entity_sei(tmp_path: Path) -> None: + from wardline.core.finding import FINGERPRINT_SCHEME + + wp = waivers_path(tmp_path) + wp.parent.mkdir(parents=True, exist_ok=True) + wp.write_text( + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\n" + f"waivers:\n- fingerprint: {FP}\n reason: ok\n entity_sei: 123\n", + encoding="utf-8", + ) + # A non-string entity_sei is a corrupt binding; it must fail loud, never drop silently. + with pytest.raises(ConfigError, match="entity_sei"): + load_project_waivers(tmp_path) + + +def test_add_waiver_refuses_direct_symlink_target(tmp_path: Path) -> None: + outside = tmp_path / "outside.yaml" + outside.write_text("", encoding="utf-8") + link = tmp_path / "waivers.yaml" + link.symlink_to(outside) + + with pytest.raises(WardlineError, match="symlink"): + add_waiver(link, fingerprint=FP, reason="ok", expires=None) + + assert outside.read_text(encoding="utf-8") == "" + + +def test_add_waiver_refuses_rooted_symlink_target(tmp_path: Path) -> None: + outside = tmp_path / "outside.yaml" + outside.write_text("", encoding="utf-8") + wp = waivers_path(tmp_path) + wp.parent.mkdir(parents=True) + wp.symlink_to(outside) + + with pytest.raises(WardlineError, match="symlink"): + add_waiver(wp, fingerprint=FP, reason="ok", expires=None, root=tmp_path) + + assert outside.read_text(encoding="utf-8") == "" diff --git a/tests/unit/core/test_waivers.py b/tests/unit/core/test_waivers.py index 8dfdcf5a..e5487562 100644 --- a/tests/unit/core/test_waivers.py +++ b/tests/unit/core/test_waivers.py @@ -4,8 +4,15 @@ import pytest -from wardline.core.errors import ConfigError -from wardline.core.waivers import Waiver, WaiverSet, parse_waivers +from wardline.core.errors import ConfigError, SchemeMismatchError +from wardline.core.finding import FINGERPRINT_SCHEME +from wardline.core.waivers import ( + WAIVERS_VERSION, + Waiver, + WaiverSet, + build_waivers_document, + parse_waivers, +) _FP = "a" * 64 @@ -78,3 +85,90 @@ def test_load_project_waivers_absent_is_empty(tmp_path): from wardline.core.waivers import load_project_waivers assert load_project_waivers(tmp_path) == () + + +# --- P1 scheme-infra (S5) --------------------------------------------------- + + +def test_build_waivers_document_carries_scheme_and_version() -> None: + doc = build_waivers_document([Waiver(fingerprint=_FP, reason="r", expires=date(2030, 1, 1))]) + assert doc["fingerprint_scheme"] == FINGERPRINT_SCHEME == "wlfp2" + assert doc["version"] == WAIVERS_VERSION + assert doc["waivers"][0]["fingerprint"] == _FP # bare 64-hex + assert ":" not in doc["waivers"][0]["fingerprint"] + assert doc["waivers"][0]["reason"] == "r" + assert doc["waivers"][0]["expires"] == "2030-01-01" + + +def test_build_waivers_document_omits_absent_expiry() -> None: + doc = build_waivers_document([Waiver(fingerprint=_FP, reason="r")]) + assert "expires" not in doc["waivers"][0] + + +def test_add_waiver_writes_scheme_header(tmp_path) -> None: + import yaml + + from wardline.core import paths + from wardline.core.waivers import add_waiver + + add_waiver(paths.waivers_path(tmp_path), fingerprint=_FP, reason="ok", expires=None, root=tmp_path) + raw = yaml.safe_load(paths.waivers_path(tmp_path).read_text()) + assert raw["fingerprint_scheme"] == FINGERPRINT_SCHEME + assert raw["version"] == WAIVERS_VERSION + + +def test_second_add_preserves_scheme_header(tmp_path) -> None: + import yaml + + from wardline.core import paths + from wardline.core.waivers import add_waiver, load_project_waivers + + p = paths.waivers_path(tmp_path) + add_waiver(p, fingerprint="a" * 64, reason="first", expires=None, root=tmp_path) + add_waiver(p, fingerprint="b" * 64, reason="second", expires=None, root=tmp_path) + raw = yaml.safe_load(p.read_text()) + assert raw["fingerprint_scheme"] == FINGERPRINT_SCHEME + assert {w["fingerprint"] for w in raw["waivers"]} == {"a" * 64, "b" * 64} + assert {w.fingerprint for w in load_project_waivers(tmp_path)} == {"a" * 64, "b" * 64} + + +def test_missing_scheme_on_nonempty_store_raises(tmp_path) -> None: + import yaml + + from wardline.core import paths + from wardline.core.waivers import load_project_waivers + + p = paths.waivers_path(tmp_path) + p.parent.mkdir(parents=True, exist_ok=True) + # An OLD (pre-P1) header-less store with real waivers must loud-fail. + p.write_text(yaml.safe_dump({"waivers": [{"fingerprint": _FP, "reason": "r"}]}), encoding="utf-8") + with pytest.raises(SchemeMismatchError) as ei: + load_project_waivers(tmp_path) + assert "waivers.yaml" in str(ei.value) + assert "wardline rekey" in str(ei.value) + + +def test_wrong_scheme_raises(tmp_path) -> None: + import yaml + + from wardline.core import paths + from wardline.core.waivers import load_project_waivers + + p = paths.waivers_path(tmp_path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp1", "version": WAIVERS_VERSION, "waivers": []}), + encoding="utf-8", + ) + with pytest.raises(SchemeMismatchError): + load_project_waivers(tmp_path) + + +def test_present_but_empty_mapping_is_empty_no_error(tmp_path) -> None: + from wardline.core import paths + from wardline.core.waivers import load_project_waivers + + p = paths.waivers_path(tmp_path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("{}\n", encoding="utf-8") # empty-guard precedes the scheme check + assert load_project_waivers(tmp_path) == () diff --git a/tests/unit/core/test_weft_dossier.py b/tests/unit/core/test_weft_dossier.py index 43c6e648..4fc88d54 100644 --- a/tests/unit/core/test_weft_dossier.py +++ b/tests/unit/core/test_weft_dossier.py @@ -47,7 +47,8 @@ def capabilities(self): "sei": {"supported": self._sei_supported, "version": 1}, } - def resolve(self, qualnames): + def resolve(self, qualnames, *, plugin=None): + self.plugin_hints = [*getattr(self, "plugin_hints", []), plugin] return ResolveResult(resolved={q: f"python:function:{q}" for q in qualnames}, unresolved=[]) def resolve_identity(self, locator): diff --git a/tests/unit/core/test_weft_mapping.py b/tests/unit/core/test_weft_mapping.py index e2e9d890..a47ca369 100644 --- a/tests/unit/core/test_weft_mapping.py +++ b/tests/unit/core/test_weft_mapping.py @@ -1,5 +1,6 @@ # tests/unit/core/test_weft_mapping.py from wardline.core.finding import ( + FINGERPRINT_SCHEME, Finding, Kind, Location, @@ -32,7 +33,7 @@ def test_metadata_namespaces_rich_fields_under_wardline() -> None: md = to_filigree_metadata(f) assert set(md) == {"wardline"} wl = md["wardline"] - assert wl["fingerprint"] == "fp123" + assert wl["fingerprint"] == f"{FINGERPRINT_SCHEME}:fp123" # wire/metadata fp is scheme-prefixed (S6) assert wl["internal_severity"] == "WARN" assert wl["kind"] == "defect" assert wl["qualname"] == "pkg.mod.C.method" diff --git a/tests/unit/filigree/test_config.py b/tests/unit/filigree/test_config.py index ea3efbe5..dd0d5fc8 100644 --- a/tests/unit/filigree/test_config.py +++ b/tests/unit/filigree/test_config.py @@ -68,12 +68,81 @@ def test_new_name_in_dot_env_wins_over_legacy_in_dot_env(tmp_path: Path) -> None assert load_filigree_token(tmp_path) == "new-file" -def test_new_name_in_dot_env_wins_over_legacy_in_environment(monkeypatch, tmp_path: Path) -> None: - # The migration-relevant cross-tier rung: the new name is resolved FULLY (env then - # .env) before the legacy name is consulted at all, so the new name in .env (rung 2) - # beats the legacy name in the actual environment (rung 3) — a file entry outranks an - # env var across the name boundary. This pins "new-name-first" where it could silently - # regress to legacy-first (relevant while lacuna still exports WARDLINE_FILIGREE_TOKEN). +def test_legacy_environment_wins_over_new_name_in_dot_env(monkeypatch, tmp_path: Path) -> None: + # Process environment is operator-controlled; root/.env may come from the scanned + # repository. All environment aliases must outrank all project .env aliases, even + # across the canonical/legacy name boundary. monkeypatch.setenv(WARDLINE_FILIGREE_TOKEN_ENV, "legacy-env") (tmp_path / ".env").write_text(f"{WEFT_FEDERATION_TOKEN_ENV}=new-file\n", encoding="utf-8") - assert load_filigree_token(tmp_path) == "new-file" + assert load_filigree_token(tmp_path) == "legacy-env" + + +# --------------------------------------------------------------------------- +# Rung 3: filigree's auto-minted project-store federation_token (F1 / C-3). +# The zero-ceremony rung — a same-host install with NO env/.env/.mcp.json reads +# the file filigree mints and validates against, so the client token matches the +# daemon with no operator config. +# --------------------------------------------------------------------------- + + +def _mint_filigree_token(root: Path, value: str) -> Path: + """Write a 0600 federation_token under /.weft/filigree/ as filigree would.""" + store = root / ".weft" / "filigree" + store.mkdir(parents=True, exist_ok=True) + path = store / "federation_token" + path.write_text(value + "\n", encoding="utf-8") + path.chmod(0o600) + return path + + +def test_mint_file_read_when_env_and_dot_env_unset(tmp_path: Path) -> None: + _mint_filigree_token(tmp_path, "minted-tok") + assert load_filigree_token(tmp_path) == "minted-tok" # trailing newline stripped + + +def test_env_overrides_mint_file(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(WEFT_FEDERATION_TOKEN_ENV, "from-env") + _mint_filigree_token(tmp_path, "minted-tok") + assert load_filigree_token(tmp_path) == "from-env" + + +def test_dot_env_overrides_mint_file(tmp_path: Path) -> None: + (tmp_path / ".env").write_text(f"{WEFT_FEDERATION_TOKEN_ENV}=from-file\n", encoding="utf-8") + _mint_filigree_token(tmp_path, "minted-tok") + assert load_filigree_token(tmp_path) == "from-file" + + +def test_legacy_env_overrides_mint_file(monkeypatch, tmp_path: Path) -> None: + # Process environment is operator-controlled and outranks every repo-local token + # source, including the same-host mint file. + monkeypatch.setenv(WARDLINE_FILIGREE_TOKEN_ENV, "legacy-env") + _mint_filigree_token(tmp_path, "minted-tok") + assert load_filigree_token(tmp_path) == "legacy-env" + + +def test_mint_file_wins_over_legacy_dot_env(tmp_path: Path) -> None: + # The same-host mint file still outranks a deprecated repo-local .env fallback. + (tmp_path / ".env").write_text(f"{WARDLINE_FILIGREE_TOKEN_ENV}=legacy-file\n", encoding="utf-8") + _mint_filigree_token(tmp_path, "minted-tok") + assert load_filigree_token(tmp_path) == "minted-tok" + + +def test_missing_mint_file_falls_through_to_legacy(monkeypatch, tmp_path: Path) -> None: + # No mint file present → clean fall-through to the legacy rung (no crash). + monkeypatch.setenv(WARDLINE_FILIGREE_TOKEN_ENV, "legacy-env") + assert load_filigree_token(tmp_path) == "legacy-env" + + +def test_empty_mint_file_falls_through(tmp_path: Path) -> None: + # A blank/whitespace mint file is treated as absent (→ off here). + _mint_filigree_token(tmp_path, " ") + assert load_filigree_token(tmp_path) is None + + +def test_unreadable_mint_dir_falls_through_cleanly(tmp_path: Path) -> None: + # A directory where the file should be (or any OSError) must not crash — emit + # soft-fails, never hard-fails the scan. + store = tmp_path / ".weft" / "filigree" + store.mkdir(parents=True, exist_ok=True) + (store / "federation_token").mkdir() # a dir, not a file → read_text raises OSError + assert load_filigree_token(tmp_path) is None diff --git a/tests/unit/filigree/test_dossier_client.py b/tests/unit/filigree/test_dossier_client.py index e015791c..803720d8 100644 --- a/tests/unit/filigree/test_dossier_client.py +++ b/tests/unit/filigree/test_dossier_client.py @@ -67,6 +67,16 @@ def test_scan_results_url_is_normalized_to_api_origin_for_associations() -> None assert t.calls[0][0].startswith("http://filigree.example/api/entity-associations?") +def test_project_scoped_scan_results_url_keeps_scope_on_associations() -> None: + # Dogfood-4 A4: the lacuna config value. The old normalizer appended /api to the + # scoped endpoint, 404ing the work-join on every dossier/decorator_coverage row. + t = FakeTransport(Response(status=200, body=_rows())) + + FiligreeWorkProvider("http://127.0.0.1:8749/api/p/lacuna/weft/scan-results", transport=t).work(_BINDING) + + assert t.calls[0][0].startswith("http://127.0.0.1:8749/api/p/lacuna/entity-associations?") + + @pytest.mark.parametrize( "url", [ diff --git a/tests/unit/filigree/test_token_symlink_escape.py b/tests/unit/filigree/test_token_symlink_escape.py new file mode 100644 index 00000000..3a035ee1 --- /dev/null +++ b/tests/unit/filigree/test_token_symlink_escape.py @@ -0,0 +1,108 @@ +"""Security: .env / federation-token reads must refuse a symlink that escapes the +project root, so an attacker-authored repo cannot exfil an outside file's contents +as a bearer token / judge API key (wardline-db67828599). Mirrors the symlink-refusal +shape of tests/unit/core/test_attest_key.py::test_mint_rejects_symlinked_dotenv.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from wardline.core.errors import WardlineError +from wardline.core.judge import _API_KEY_ENV +from wardline.core.judge_run import load_env_key +from wardline.filigree.config import ( + _FILIGREE_MINT_RELPATH, + WARDLINE_FILIGREE_TOKEN_ENV, + WEFT_FEDERATION_TOKEN_ENV, + load_filigree_token, +) + + +@pytest.fixture(autouse=True) +def _clear(monkeypatch: pytest.MonkeyPatch) -> None: + # All three credential env names may leak in from the real environment — clear + # them so each test controls the full picture (an unset env is the case where + # the file-based read actually runs). + monkeypatch.delenv(WEFT_FEDERATION_TOKEN_ENV, raising=False) + monkeypatch.delenv(WARDLINE_FILIGREE_TOKEN_ENV, raising=False) + monkeypatch.delenv(_API_KEY_ENV, raising=False) + + +def _outside_secret(tmp_path: Path, name: str, content: str) -> Path: + """Write a secret file OUTSIDE the project root and return its path.""" + outside_root = tmp_path / "outside" + outside_root.mkdir(exist_ok=True) + secret = outside_root / name + secret.write_text(content, encoding="utf-8") + return secret + + +# --------------------------------------------------------------------------- +# Symlink-escape must be REFUSED (no token returned; behaves like attest_key) +# --------------------------------------------------------------------------- + + +def test_filigree_dotenv_symlink_escape_refused(tmp_path: Path) -> None: + """_read_token: a .env symlinked to an outside secret is refused, not followed.""" + root = tmp_path / "root" + root.mkdir() + secret = _outside_secret(tmp_path, "stolen.env", f"{WEFT_FEDERATION_TOKEN_ENV}=EXFIL\n") + (root / ".env").symlink_to(secret) + with pytest.raises(WardlineError, match="symlink"): + load_filigree_token(root) + + +def test_filigree_mint_symlink_escape_refused(tmp_path: Path) -> None: + """_read_filigree_mint: a federation_token symlinked outside root is refused.""" + root = tmp_path / "root" + root.mkdir() + secret = _outside_secret(tmp_path, "stolen_token", "EXFIL-MINT") + mint = root.joinpath(*_FILIGREE_MINT_RELPATH) + mint.parent.mkdir(parents=True) + mint.symlink_to(secret) + with pytest.raises(WardlineError, match="symlink"): + load_filigree_token(root) + + +def test_judge_dotenv_symlink_escape_refused(tmp_path: Path) -> None: + """load_env_key: a .env symlinked to an outside secret is refused; the OpenRouter + key is never set into the environment.""" + root = tmp_path / "root" + root.mkdir() + secret = _outside_secret(tmp_path, "stolen.env", f"{_API_KEY_ENV}=EXFIL-KEY\n") + (root / ".env").symlink_to(secret) + with pytest.raises(WardlineError, match="symlink"): + load_env_key(root) + assert _API_KEY_ENV not in os.environ + + +# --------------------------------------------------------------------------- +# Happy path preserved: a regular in-root file still yields the value +# --------------------------------------------------------------------------- + + +def test_filigree_normal_dotenv(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + (root / ".env").write_text(f'{WEFT_FEDERATION_TOKEN_ENV}="ok"\n', encoding="utf-8") + assert load_filigree_token(root) == "ok" + + +def test_filigree_normal_mint(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + mint = root.joinpath(*_FILIGREE_MINT_RELPATH) + mint.parent.mkdir(parents=True) + mint.write_text("mint-ok\n", encoding="utf-8") + assert load_filigree_token(root) == "mint-ok" + + +def test_judge_normal_dotenv(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + (root / ".env").write_text(f"{_API_KEY_ENV}=key-ok\n", encoding="utf-8") + load_env_key(root) + assert os.environ.get(_API_KEY_ENV) == "key-ok" diff --git a/tests/unit/install/test_block.py b/tests/unit/install/test_block.py index 8a0ac5e1..a4b8706d 100644 --- a/tests/unit/install/test_block.py +++ b/tests/unit/install/test_block.py @@ -1,15 +1,24 @@ +import logging from pathlib import Path import pytest from wardline.core.errors import WardlineError -from wardline.install.block import inject_block, render_block +from wardline.install.block import _atomic_write_text, inject_block, render_block + +# A co-resident sibling tool's managed block (filigree), used to assert +# wardline's writer never deletes, truncates, or spans across a foreign block +# (weft C-4 multi-owner managed-block contract). +_FOREIGN = "\nfiligree body — DO NOT TOUCH\n" +_OPEN = "" +_CLOSE = "" def test_render_block_is_fenced_and_mentions_the_gate() -> None: block = render_block() assert block.startswith("") + assert "" in block.splitlines()[1] assert "wardline scan" in block assert "wardline-gate" in block @@ -88,3 +97,255 @@ def test_inject_is_idempotent_after_collapse(tmp_path: Path) -> None: f.write_text(f"{block}\n\n{block}\n", encoding="utf-8") inject_block(f) # collapses to one assert inject_block(f) == "unchanged" # now converged + + +# --------------------------------------------------------------------------- +# C-4 multi-owner managed-block contract +# --------------------------------------------------------------------------- + + +def test_foreign_block_between_own_head_and_own_trailing_is_preserved( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """(c)+(e): a foreign block sandwiched between an own head and an own trailing + duplicate survives, AND the trailing own duplicate (beyond the foreign block) + is preserved + surfaced, not deleted.""" + block = render_block() + # Stale head block (old hash) so a real rewrite is exercised; the trailing + # block is current and sits BEYOND the foreign fence. + stale = f"{_OPEN}\nOLD BODY\n{_CLOSE}" + f = tmp_path / "CLAUDE.md" + f.write_text( + f"head\n\n{stale}\n\n{_FOREIGN}\n\n{block}\n\ntail\n", + encoding="utf-8", + ) + with caplog.at_level(logging.WARNING): + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + # Stale head body is gone (replaced in place). + assert "OLD BODY" not in text + # Foreign block survives intact. + assert _FOREIGN in text + assert "filigree body — DO NOT TOUCH" in text + # Surrounding user text survives. + assert "head" in text and "tail" in text + # The own duplicate beyond the foreign block is preserved (foreign-safety > + # own-dedup), so there are still two own blocks — NOT collapsed to one. + assert text.count("wardline:instructions:v") == 2 + # ...and it is surfaced. + assert any("duplicate" in r.message for r in caplog.records) + + +def test_unclosed_own_then_foreign_then_complete_own_preserves_foreign( + tmp_path: Path, +) -> None: + """(c): an unclosed own open before a foreign block, followed by a complete + own block, must not let the rewrite span across the foreign block.""" + block = render_block() + f = tmp_path / "CLAUDE.md" + # Orphan own open (no close), then foreign, then a complete own block. + f.write_text( + f"intro\n\n{_OPEN}\nORPHAN BODY\n\n{_FOREIGN}\n\n{block}\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + # Foreign block survives. + assert _FOREIGN in text + assert "filigree body — DO NOT TOUCH" in text + # A valid fresh wardline block is present at the front (orphan recovered). + assert text.lstrip().startswith("intro") + assert _CLOSE in text + # Two own blocks remain: the recovered front block + the complete one beyond + # the foreign fence. We do NOT assert count == 1 — that would contradict + # foreign-safety (the trailing block sits beyond the foreign block). + assert text.count("wardline:instructions:v") == 2 + + +def test_two_call_orphan_open_plus_foreign_keeps_foreign(tmp_path: Path) -> None: + """(c): the reachable 2-call sequence — orphan own open + foreign, then inject + twice — must keep the foreign block across both calls.""" + f = tmp_path / "CLAUDE.md" + f.write_text(f"{_OPEN}\nORPHAN BODY\n\n{_FOREIGN}\n", encoding="utf-8") + # Call 1: append path manufactures a later own close. + inject_block(f) + assert _FOREIGN in f.read_text(encoding="utf-8") + # Call 2: must not swallow the foreign block now that a later close exists. + inject_block(f) + text = f.read_text(encoding="utf-8") + assert _FOREIGN in text + assert "filigree body — DO NOT TOUCH" in text + + +def test_uppercase_foreign_namespace_registers_as_boundary(tmp_path: Path) -> None: + """(h): an uppercase-namespaced sibling fence must register as a foreign + boundary (case-insensitive namespace match).""" + block = render_block() + upper_foreign = "\nFILIGREE BODY\n" + f = tmp_path / "CLAUDE.md" + f.write_text( + f"{_OPEN}\nORPHAN BODY\n\n{upper_foreign}\n\n{block}\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert upper_foreign in text + assert "FILIGREE BODY" in text + + +def test_append_on_unclosed_own_with_trailing_text_preserves_all( + tmp_path: Path, +) -> None: + """(d): an own open with no close marker, followed by user text, must append a + fresh block and preserve all existing text verbatim (no recovery-to-EOF that + would delete the trailing user text).""" + f = tmp_path / "CLAUDE.md" + original = f"intro\n\n{_OPEN}\nORPHAN BODY\n\nIMPORTANT USER TEXT\n" + f.write_text(original, encoding="utf-8") + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + # Every byte of the original is preserved (append-on-missing-end). + assert original in text + assert "IMPORTANT USER TEXT" in text + assert "ORPHAN BODY" in text + # A fresh, well-formed block was appended. + assert _CLOSE in text + + +def test_append_on_unclosed_own_no_trailing_text(tmp_path: Path) -> None: + """(d): an own open with no close marker and nothing after it still appends a + fresh block, preserving the orphan.""" + f = tmp_path / "CLAUDE.md" + original = f"intro\n\n{_OPEN}\nORPHAN BODY\n" + f.write_text(original, encoding="utf-8") + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert original in text + assert "ORPHAN BODY" in text + assert _CLOSE in text + + +def test_atomic_write_refuses_empty_content(tmp_path: Path) -> None: + """(g): the atomic writer refuses empty / whitespace-only content rather than + truncating an existing populated file.""" + f = tmp_path / "CLAUDE.md" + f.write_text("PRECIOUS CONTENT\n", encoding="utf-8") + with pytest.raises(WardlineError, match="empty"): + _atomic_write_text(f, "") + with pytest.raises(WardlineError, match="empty"): + _atomic_write_text(f, " \n\t ") + # File untouched. + assert f.read_text(encoding="utf-8") == "PRECIOUS CONTENT\n" + + +def test_atomic_write_preserves_existing_file_mode(tmp_path: Path) -> None: + """(g): the atomic writer preserves the destination's permission bits.""" + f = tmp_path / "CLAUDE.md" + f.write_text("old\n", encoding="utf-8") + f.chmod(0o640) + _atomic_write_text(f, "new content\n") + assert f.read_text(encoding="utf-8") == "new content\n" + assert (f.stat().st_mode & 0o777) == 0o640 + + +def test_foreign_block_before_own_is_preserved(tmp_path: Path) -> None: + """(f): a foreign block before the own block is never reordered/relocated and + a stale own block is still replaced in place.""" + f = tmp_path / "CLAUDE.md" + f.write_text( + f"{_FOREIGN}\n\n{_OPEN}\nOLD BODY\n{_CLOSE}\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + assert _FOREIGN in text + assert "OLD BODY" not in text + # Foreign block stays first; own block replaced in place. + assert text.index("filigree body") < text.index("wardline:instructions:v") + assert text.count("wardline:instructions:v") == 1 + + +def test_quoted_foreign_marker_in_own_body_is_replaced_in_place( + tmp_path: Path, +) -> None: + """(b)+(c): a foreign marker merely quoted inside wardline's own well-formed + block body is OUR content, not a real foreign boundary — the block is replaced + in place, not truncated at the quoted marker (which would leave a dangling own + close + a stray foreign open that sticks as corruption).""" + f = tmp_path / "CLAUDE.md" + body = "Docs: the filigree marker is " + f.write_text( + f"intro\n{_OPEN}\n{body}\nOLD BODY\n{_CLOSE}\ntail\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + # Exactly one own block, one own close — no dangling/orphaned markers. + assert text.count(_CLOSE) == 1 + assert text.count("wardline:instructions:v") == 1 + # The quoted foreign open (which was our body content) is gone with the old + # body; no stray foreign block left behind. + assert "" not in text + assert "OLD BODY" not in text + assert "intro" in text and "tail" in text + # And it converges. + assert inject_block(f) == "unchanged" + + +def test_genuinely_nested_foreign_block_is_preserved(tmp_path: Path) -> None: + """(c): a genuinely balanced foreign block nested inside the own span (own + open .. foreign open .. foreign close .. own close) is real foreign content — + bounded recovery must cut at it and preserve it, NOT replace across it.""" + f = tmp_path / "CLAUDE.md" + f.write_text( + f"intro\n{_OPEN}\nOLD BODY\n{_FOREIGN}\nMORE OLD\n{_CLOSE}\ntail\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + # The nested foreign block survives intact. + assert _FOREIGN in text + assert "filigree body — DO NOT TOUCH" in text + assert "intro" in text and "tail" in text + + +def test_uppercase_own_duplicate_is_canonicalised(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """(e)+(h): an uppercase-namespaced own duplicate (no foreign block between) + is collapsed, not silently left in place — own-block matching is + case-insensitive, consistent with the boundary comparison.""" + block = render_block() + upper_dup = "\nBODY2\n" + f = tmp_path / "CLAUDE.md" + f.write_text(f"{block}\n\nmid\n\n{upper_dup}\n", encoding="utf-8") + with caplog.at_level(logging.WARNING): + assert inject_block(f) == "updated" + text = f.read_text(encoding="utf-8") + import re + + # No own block remains uncollapsed (case-insensitive count). + assert len(re.findall(r"\nexample: {_OPEN}\nsome text\n", + encoding="utf-8", + ) + assert inject_block(f) == "updated" # appends a fresh block + after_first = f.read_text(encoding="utf-8") + # The (malformed) foreign marker and quoted text are preserved verbatim. + assert "" in after_first + assert "some text" in after_first + # Second and third runs converge — no unbounded growth. + assert inject_block(f) == "unchanged" + assert f.read_text(encoding="utf-8") == after_first + assert inject_block(f) == "unchanged" + assert f.read_text(encoding="utf-8") == after_first diff --git a/tests/unit/install/test_detect.py b/tests/unit/install/test_detect.py index 24a7ed21..929ba98f 100644 --- a/tests/unit/install/test_detect.py +++ b/tests/unit/install/test_detect.py @@ -2,7 +2,7 @@ import pytest -from wardline.install.detect import detect_siblings +from wardline.install.detect import _filigree_url_from_project, detect_siblings def _assert_no_config_written(root: Path) -> None: @@ -62,6 +62,25 @@ def test_filigree_hostile_port_payload_is_soft(tmp_path: Path, monkeypatch, payl _assert_no_config_written(tmp_path) +@pytest.mark.skipif(not hasattr(Path, "symlink_to"), reason="symlink support unavailable") +def test_filigree_published_port_symlink_is_not_opened(tmp_path: Path, monkeypatch) -> None: + port_dir = tmp_path / ".weft" / "filigree" + port_dir.mkdir(parents=True) + port_path = port_dir / "ephemeral.port" + port_path.symlink_to(Path("/dev/zero")) + + real_read_text = Path.read_text + + def _read_text(self: Path, *args: object, **kwargs: object) -> str: + if self == port_path: + raise AssertionError("symlinked ephemeral.port must not be opened") + return real_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _read_text) + + assert _filigree_url_from_project(tmp_path) is None + + def test_filigree_legacy_dot_dir_port_is_detected(tmp_path: Path, monkeypatch) -> None: # The legacy .filigree/ dot-dir is tolerated during the federation transition. monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) diff --git a/tests/unit/install/test_doctor_filigree_auth.py b/tests/unit/install/test_doctor_filigree_auth.py new file mode 100644 index 00000000..ed7938ed --- /dev/null +++ b/tests/unit/install/test_doctor_filigree_auth.py @@ -0,0 +1,457 @@ +# tests/unit/install/test_doctor_filigree_auth.py +import json +import stat +from collections.abc import Mapping +from pathlib import Path + +from wardline.core.filigree_emit import Response +from wardline.install.doctor import ( + _check_filigree_auth, + _check_project_mcp, + _is_loopback, + _mcp_filigree_url, + _resolve_probe_url, + _rewrite_env_token, + machine_readable_doctor, +) + + +def test_rewrite_env_sets_new_name_and_drops_legacy(tmp_path: Path) -> None: + env = tmp_path / ".env" + env.write_text( + "WARDLINE_ATTEST_KEY=keep-me\nWARDLINE_FILIGREE_TOKEN=stale\nOTHER=x\n", + encoding="utf-8", + ) + _rewrite_env_token(env, "GOODTOKEN") + text = env.read_text(encoding="utf-8") + assert "WEFT_FEDERATION_TOKEN=GOODTOKEN" in text + assert "WARDLINE_FILIGREE_TOKEN" not in text # stale legacy line removed + assert "WARDLINE_ATTEST_KEY=keep-me" in text # unrelated line preserved + assert "OTHER=x" in text + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + + +def test_rewrite_env_updates_existing_new_name(tmp_path: Path) -> None: + env = tmp_path / ".env" + env.write_text("WEFT_FEDERATION_TOKEN=old\nKEEP=1\n", encoding="utf-8") + _rewrite_env_token(env, "NEW") + text = env.read_text(encoding="utf-8") + assert text.count("WEFT_FEDERATION_TOKEN=") == 1 + assert "WEFT_FEDERATION_TOKEN=NEW" in text + assert "KEEP=1" in text + + +def test_rewrite_env_creates_file_when_absent(tmp_path: Path) -> None: + env = tmp_path / ".env" + _rewrite_env_token(env, "NEW") + assert env.read_text(encoding="utf-8").strip() == "WEFT_FEDERATION_TOKEN=NEW" + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + + +def test_rewrite_env_preserves_non_utf8_unrelated_line(tmp_path: Path) -> None: + # An unrelated line carrying a raw non-UTF8 byte (0xE9, Latin-1 'é' in another + # secret) must survive byte-for-byte. A decode round-trip with errors="replace" + # would corrupt it to U+FFFD; the bytes-based rewrite preserves it. + env = tmp_path / ".env" + env.write_bytes(b"WARDLINE_ATTEST_KEY=caf\xe9-secret\nWARDLINE_FILIGREE_TOKEN=stale\n") + _rewrite_env_token(env, "GOODTOKEN") + data = env.read_bytes() + assert b"WARDLINE_ATTEST_KEY=caf\xe9-secret" in data # non-UTF8 byte intact + assert b"\xef\xbf\xbd" not in data # no U+FFFD replacement char introduced + assert b"WEFT_FEDERATION_TOKEN=GOODTOKEN" in data + assert b"WARDLINE_FILIGREE_TOKEN" not in data + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + + +# --- Task 3: probe-URL resolution + loopback --------------------------------- + + +def _write_mcp_with_filigree_url(root: Path, url: str) -> None: + root.joinpath(".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "wardline", + "args": ["mcp", "--root", ".", "--filigree-url", url], + } + } + } + ), + encoding="utf-8", + ) + + +def test_mcp_filigree_url_extracts_arg(tmp_path: Path) -> None: + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + assert _mcp_filigree_url(tmp_path) == "http://127.0.0.1:8749/api/weft/scan-results" + + +def test_mcp_filigree_url_none_when_absent(tmp_path: Path) -> None: + tmp_path.joinpath(".mcp.json").write_text( + json.dumps({"mcpServers": {"wardline": {"args": ["mcp", "--root", "."]}}}), encoding="utf-8" + ) + assert _mcp_filigree_url(tmp_path) is None + assert _mcp_filigree_url(tmp_path / "nope") is None # missing file + + +def test_mcp_filigree_url_none_when_args_not_list(tmp_path: Path) -> None: + # A hand-corrupted config where args is a string: str.index("--filigree-url") + # is a substring search that would otherwise return a char index, and + # args[idx + 1] a single bogus character. The list-type guard returns None. + tmp_path.joinpath(".mcp.json").write_text( + json.dumps({"mcpServers": {"wardline": {"args": "mcp --filigree-url http://x"}}}), + encoding="utf-8", + ) + assert _mcp_filigree_url(tmp_path) is None + + +def test_check_project_mcp_accepts_pinned_sibling_args_in_operator_order(tmp_path: Path, monkeypatch) -> None: + # Plain `wardline doctor` (no --repair): an entry pinning --loomweave-url AND + # --filigree-url in the real lacuna order (loomweave-first) must read as configured, + # not "missing wardline server". The order-preserving preserve fix guards this. + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + tmp_path.joinpath(".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "/bin/wardline", + "args": [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:9730", + "--filigree-url", + "http://127.0.0.1:8749/api/p/lacuna/weft/scan-results", + ], + } + } + } + ), + encoding="utf-8", + ) + check = _check_project_mcp(tmp_path) + assert check.ok, check.message + + +def test_resolve_probe_url_precedence(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + # flag wins + assert _resolve_probe_url(tmp_path, "http://flag/x") == "http://flag/x" + # env beats .mcp.json + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://env/y") + assert _resolve_probe_url(tmp_path, None) == "http://env/y" + # .mcp.json arg is the fallback that makes the real setup work + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + assert _resolve_probe_url(tmp_path, None) == "http://127.0.0.1:8749/api/weft/scan-results" + + +def test_resolve_probe_url_none_when_unconfigured(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + # No flag, no env, no .mcp.json arg, and no live filigree daemon (no published + # ephemeral.port, project not server-registered) -> nothing to verify. + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + assert _resolve_probe_url(tmp_path, None) is None + + +def test_resolve_probe_url_falls_back_to_published_port(tmp_path: Path, monkeypatch) -> None: + # The real esper-lite shape: filigree runs an ephemeral per-project daemon (it + # publishes .weft/filigree/ephemeral.port) but the wardline .mcp.json entry pins + # no --filigree-url. The emit path (resolve_filigree_url) auto-discovers that port, + # so the doctor probe MUST too -- otherwise doctor is blind to the very daemon + # wardline will emit to and report "nothing to verify". + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" + port_file.parent.mkdir(parents=True) + port_file.write_text("9189", encoding="ascii") + assert _resolve_probe_url(tmp_path, None) == "http://localhost:9189/api/weft/scan-results" + + +def test_resolve_probe_url_mcp_arg_beats_published_port(tmp_path: Path, monkeypatch) -> None: + # A pinned --filigree-url (e.g. a fixed-port/remote target the published-port rung + # cannot reconstruct) still outranks the auto-discovered ephemeral port. + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" + port_file.parent.mkdir(parents=True) + port_file.write_text("9189", encoding="ascii") + assert _resolve_probe_url(tmp_path, None) == "http://127.0.0.1:8749/api/weft/scan-results" + + +def test_is_loopback() -> None: + assert _is_loopback("http://127.0.0.1:8749/x") is True + assert _is_loopback("http://127.255.255.255:8749/x") is True + assert _is_loopback("http://localhost:8749/x") is True + assert _is_loopback("http://[::1]:8749/x") is True + assert _is_loopback("https://filigree.example.com/x") is False + + +def test_is_loopback_rejects_127_prefixed_registrable_hosts() -> None: + # The host literally STARTS with "127." but is a registrable name that resolves + # off-box via DNS. A startswith("127.") check would wrongly send the federation + # bearer to the attacker; strict IP parsing rejects it (no probe, no leak). + assert _is_loopback("http://127.attacker.com/x") is False + assert _is_loopback("http://127.evil/x") is False + assert _is_loopback("http://127.0.0.1.evil.com/x") is False + + +# --- Task 4: detection (no repair) ------------------------------------------- + + +class _ScriptedTransport: + """Returns a Response per token: maps Authorization bearer -> status.""" + + def __init__(self, status_by_token: dict[str, int], *, unreachable: bool = False) -> None: + self._status_by_token = status_by_token + self._unreachable = unreachable + + def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: + if self._unreachable: + raise OSError("connection refused") + token = headers.get("Authorization", "").removeprefix("Bearer ") + return Response(status=self._status_by_token.get(token, 401), body="") + + +def _setup_lacuna(root: Path, monkeypatch, env_token: str) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + _write_mcp_with_filigree_url(root, "http://127.0.0.1:8749/api/weft/scan-results") + root.joinpath(".env").write_text(f"WARDLINE_FILIGREE_TOKEN={env_token}\n", encoding="utf-8") + + +def test_check_detects_rejected_token(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + t = _ScriptedTransport({"GOOD": 400}) # daemon accepts GOOD; STALE -> 401 + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "error" + assert "rejected" in (check.message or "") + assert check.fixed is False + + +def test_check_ok_when_token_accepted(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="GOOD") + t = _ScriptedTransport({"GOOD": 400}) + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "ok" + + +def test_check_error_when_token_absent(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({})) + assert check.status == "error" + assert "no federation token" in (check.message or "") + + +def test_check_ok_when_auth_off_and_no_token(tmp_path: Path, monkeypatch) -> None: + # Daemon has auth OFF: it accepts an unauthenticated emit ("" bearer -> 400). No token + # configured is fine — not an error. + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:8749/api/weft/scan-results") + t = _ScriptedTransport({"": 400}) # empty (no) bearer is accepted + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "ok" + + +def test_check_ok_when_unreachable(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({}, unreachable=True)) + assert check.status == "ok" + assert "not reachable" in (check.message or "") + + +def test_check_ok_when_non_loopback(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setenv("WEFT_FEDERATION_TOKEN", "T") + check = _check_filigree_auth( + tmp_path, + repair=False, + filigree_url="https://remote.example.com/api/weft/scan-results", + transport=_ScriptedTransport({}), + ) + assert check.status == "ok" + assert "non-loopback" in (check.message or "") + + +def test_check_ok_when_url_unresolved(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({})) + assert check.status == "ok" + assert "not configured" in (check.message or "") + + +# --- Stale --filigree-url pin shadowing a LIVE published daemon (rotated port) ------- + + +class _PortRoutedTransport: + """Reachable only for *live_port*; any other host:port raises (unreachable).""" + + def __init__(self, live_port: int, status: int = 400) -> None: + self._live_port = live_port + self._status = status + + def post(self, url: str, body: bytes, headers: Mapping[str, str]) -> Response: + from urllib.parse import urlsplit + + if urlsplit(url).port != self._live_port: + raise OSError("connection refused") + return Response(status=self._status, body="") + + +def test_check_flags_stale_pin_shadowing_live_published_daemon(tmp_path: Path, monkeypatch) -> None: + # .mcp.json pins a rotated-away port (9229, dead) while Filigree is live on the + # published per-project port (9397). Plain `doctor` must NOT mask this as a soft + # "not reachable" — it surfaces an error pointing at `--repair` (drop the stale pin). + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setenv("WEFT_FEDERATION_TOKEN", "T") + (tmp_path / ".weft" / "filigree").mkdir(parents=True) + (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("9397", encoding="utf-8") + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:9229/api/weft/scan-results") + check = _check_filigree_auth(tmp_path, repair=False, transport=_PortRoutedTransport(9397)) + assert check.status == "error" + assert "9397" in (check.message or "") + assert "--repair" in (check.message or "") + + +def test_check_stays_soft_when_pin_dead_and_no_live_published(tmp_path: Path, monkeypatch) -> None: + # Pinned dead AND nothing live published: a genuinely-absent daemon stays soft "ok". + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setenv("WEFT_FEDERATION_TOKEN", "T") + _write_mcp_with_filigree_url(tmp_path, "http://127.0.0.1:9229/api/weft/scan-results") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({}, unreachable=True)) + assert check.status == "ok" + assert "not reachable" in (check.message or "") + + +def test_check_detects_rejected_token_via_published_port(tmp_path: Path, monkeypatch) -> None: + # esper-lite shape end-to-end: no pinned --filigree-url, but a live ephemeral + # daemon is discoverable via the published port. The doctor must probe it and + # catch a stale token -- the case the old "nothing to verify" was blind to. + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.delenv("WEFT_FEDERATION_TOKEN", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_TOKEN", raising=False) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + port_file = tmp_path / ".weft" / "filigree" / "ephemeral.port" + port_file.parent.mkdir(parents=True) + port_file.write_text("9189", encoding="ascii") + tmp_path.joinpath(".env").write_text("WARDLINE_FILIGREE_TOKEN=STALE\n", encoding="utf-8") + t = _ScriptedTransport({"GOOD": 400}) # daemon accepts GOOD; STALE -> 401 + check = _check_filigree_auth(tmp_path, repair=False, transport=t) + assert check.status == "error" + assert "rejected" in (check.message or "") + + +# --- Task 5: repair ----------------------------------------------------------- + + +def test_repair_writes_accepted_candidate(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + # server-mode store holds the accepted token + cfg = tmp_path / "home" / ".config" / "filigree" + cfg.mkdir(parents=True) + (cfg / "federation_token").write_text("GOOD\n", encoding="utf-8") + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "home") + t = _ScriptedTransport({"GOOD": 400}) # GOOD accepted, STALE -> 401 + + check = _check_filigree_auth(tmp_path, repair=True, transport=t) + + assert check.status == "ok" + assert check.fixed is True + env_text = (tmp_path / ".env").read_text(encoding="utf-8") + assert "WEFT_FEDERATION_TOKEN=GOOD" in env_text + assert "WARDLINE_FILIGREE_TOKEN" not in env_text + + +def test_fix_probes_and_repairs_with_mcp_filigree_url(tmp_path: Path, monkeypatch) -> None: + # Regression (primary lacuna path): a .mcp.json pinning --filigree-url + a stale .env + # token. machine_readable_doctor(fix=True) runs repair_install (which rewrites the + # wardline entry) and must still probe/repair the token. Two guarantees: + # 1. repair_install PRESERVES the operator-pinned --filigree-url arg (it is the + # runtime emit target; the published-port rung cannot reconstruct a fixed-port + # server-mode URL, so stripping it would silently disable emit). + # 2. filigree.auth detects the stale token and pins the accepted mint in .env. + home = tmp_path / "home" + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + # local mint holds the accepted token + cfg = home / ".config" / "filigree" + cfg.mkdir(parents=True) + (cfg / "federation_token").write_text("GOOD\n", encoding="utf-8") + t = _ScriptedTransport({"GOOD": 400}) # GOOD accepted, STALE -> 401 + + payload = machine_readable_doctor(tmp_path, fix=True, transport=t) + + checks = {c["id"]: c for c in payload["checks"]} + auth = checks["filigree.auth"] + assert auth["status"] == "ok" + assert auth["fixed"] is True + # repair preserved the pinned emit target rather than normalizing it away. + args = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["wardline"]["args"] + assert "--filigree-url" in args + assert args[args.index("--filigree-url") + 1] == "http://127.0.0.1:8749/api/weft/scan-results" + # the .mcp.json check accepts the pinned arg (not "missing wardline server"). + mcp_check = checks["mcp.registration"] + assert mcp_check["status"] == "ok" + assert "WEFT_FEDERATION_TOKEN=GOOD" in (tmp_path / ".env").read_text(encoding="utf-8") + + +def test_repair_writes_project_store_candidate_when_config_store_rejected(tmp_path: Path, monkeypatch) -> None: + # Exercises the SECOND candidate rung and the "first rejected -> second accepted" + # iteration: ~/.config/filigree holds a rejected mint, the project store + # /.weft/filigree/federation_token holds the accepted one. The project-store + # value must be the one pinned in .env. + # + # Post-F1 (rung 3 reads /.weft/filigree/federation_token directly) the + # resolver would otherwise pick up the valid project mint with no repair needed. + # To still drive the repair ITERATION we set a stale token via the CANONICAL name, + # which outranks the mint (rung 1/2 > rung 3): the probe of that stale token fails, + # and the doctor recovers by iterating the local mints. + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + (tmp_path / ".env").write_text("WEFT_FEDERATION_TOKEN=STALE\n", encoding="utf-8") + cfg = tmp_path / "home" / ".config" / "filigree" + cfg.mkdir(parents=True) + (cfg / "federation_token").write_text("WRONG\n", encoding="utf-8") # first rung: rejected + proj = tmp_path / ".weft" / "filigree" + proj.mkdir(parents=True) + (proj / "federation_token").write_text("GOOD\n", encoding="utf-8") # project rung: accepted + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "home") + t = _ScriptedTransport({"GOOD": 400}) # only GOOD accepted; STALE/WRONG -> 401 + + check = _check_filigree_auth(tmp_path, repair=True, transport=t) + + assert check.status == "ok" + assert check.fixed is True + env_text = (tmp_path / ".env").read_text(encoding="utf-8") + assert "WEFT_FEDERATION_TOKEN=GOOD" in env_text + assert "WARDLINE_FILIGREE_TOKEN" not in env_text + + +def test_repair_no_candidate_matches_does_not_write(tmp_path: Path, monkeypatch) -> None: + _setup_lacuna(tmp_path, monkeypatch, env_token="STALE") + cfg = tmp_path / "home" / ".config" / "filigree" + cfg.mkdir(parents=True) + (cfg / "federation_token").write_text("ALSO-WRONG\n", encoding="utf-8") + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "home") + t = _ScriptedTransport({"GOOD": 400}) # neither STALE nor ALSO-WRONG is accepted + + check = _check_filigree_auth(tmp_path, repair=True, transport=t) + + assert check.status == "error" + assert "no local federation_token matched" in (check.message or "") + assert "WARDLINE_FILIGREE_TOKEN=STALE" in (tmp_path / ".env").read_text(encoding="utf-8") # untouched diff --git a/tests/unit/install/test_mcp_json.py b/tests/unit/install/test_mcp_json.py index c1dbd385..24c30c48 100644 --- a/tests/unit/install/test_mcp_json.py +++ b/tests/unit/install/test_mcp_json.py @@ -68,6 +68,81 @@ def test_mcpservers_null_is_treated_as_absent(tmp_path: Path, monkeypatch: pytes assert data["other"] == 1 # unrelated top-level keys preserved +def test_preserves_operator_pinned_sibling_url_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # A fixed-port / server-mode emit target (e.g. lacuna) pins --filigree-url / + # --loomweave-url in the wardline entry's args. The published-port rung cannot + # reconstruct such a URL, so these args ARE the runtime emit/discovery target. + # merge_mcp_entry must keep them rather than normalizing to the bare canonical entry. + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "OLD", + "args": [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:9730", + "--filigree-url", + "http://127.0.0.1:8749/api/p/lacuna/weft/scan-results", + ], + } + } + } + ), + encoding="utf-8", + ) + # command is refreshed to canonical (OLD -> /bin/wardline), but the pinned + # sibling-URL args survive in the operator's ORIGINAL order (loomweave-first here). + assert merge_mcp_entry(tmp_path) == "updated" + entry = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["wardline"] + assert entry["command"] == "/bin/wardline" + assert entry["args"] == [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:9730", + "--filigree-url", + "http://127.0.0.1:8749/api/p/lacuna/weft/scan-results", + ] + # idempotent: re-running over the already-preserved entry is a no-op (no reorder churn). + assert merge_mcp_entry(tmp_path) == "unchanged" + + +def test_already_canonical_lacuna_entry_is_unchanged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # The real lacuna ordering (loomweave-first) with the canonical command must be a + # no-op for merge_mcp_entry — no spurious reorder churn on every repair. + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "/bin/wardline", + "args": [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:9730", + "--filigree-url", + "http://127.0.0.1:8749/api/p/lacuna/weft/scan-results", + ], + } + } + } + ), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "unchanged" + + def test_replaces_stale_wardline_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") (tmp_path / ".mcp.json").write_text( @@ -133,3 +208,233 @@ def test_install_codex_mcp_replaces_stale_wardline_entry(tmp_path: Path, monkeyp assert 'command = "/bin/wardline"' in content assert 'args = ["mcp"]' in content assert "--root" not in content + + +# --- Filigree server-mode scoped --filigree-url persist/repair --------------------- +# +# When Filigree runs in server mode for the project, `merge_mcp_entry` injects (fresh) +# or repairs (loopback/unscoped) the wardline entry's --filigree-url to the live +# /api/p/{prefix}/ scope, so a fresh install lands a working, fail-close-safe emit +# target out of the box. An operator's remote endpoint is never rewritten. + + +# Isolation from the real ~/.config/filigree/server.json is provided by the autouse +# fixture in tests/unit/conftest.py; the server-mode tests below override it. + + +def _register_filigree_server(monkeypatch: pytest.MonkeyPatch, cfg_home: Path, *, port, projects: dict) -> None: + sj = cfg_home / "server.json" + sj.parent.mkdir(parents=True, exist_ok=True) + sj.write_text(json.dumps({"port": port, "projects": projects}), encoding="utf-8") + monkeypatch.setattr("wardline.core.config._filigree_server_config_path", lambda: sj) + + +def _wardline_args(tmp_path: Path) -> list: + return json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["wardline"]["args"] + + +def test_install_injects_server_mode_scoped_filigree_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + assert merge_mcp_entry(tmp_path) == "created" + assert _wardline_args(tmp_path) == [ + "mcp", + "--root", + ".", + "--filigree-url", + "http://localhost:8749/api/p/lacuna/weft/scan-results", + ] + # Idempotent: the discovered scope matches the persisted flag on re-run. + assert merge_mcp_entry(tmp_path) == "unchanged" + + +def test_install_repairs_unscoped_loopback_filigree_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "/bin/wardline", + "args": ["mcp", "--root", ".", "--filigree-url", "http://127.0.0.1:8749/api/weft/scan-results"], + } + } + } + ), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path)[-1] == "http://localhost:8749/api/p/lacuna/weft/scan-results" + + +def test_install_repairs_filigree_url_in_place_preserving_loomweave_order( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "/bin/wardline", + "args": [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:9730", + "--filigree-url", + "http://127.0.0.1:8749/api/weft/scan-results", + ], + } + } + } + ), + encoding="utf-8", + ) + assert merge_mcp_entry(tmp_path) == "updated" + assert _wardline_args(tmp_path) == [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:9730", + "--filigree-url", + "http://localhost:8749/api/p/lacuna/weft/scan-results", + ] + + +def test_install_never_rewrites_operator_remote_filigree_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + remote = "https://filigree.example.com/api/weft/scan-results" + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "wardline": { + "type": "stdio", + "command": "/bin/wardline", + "args": ["mcp", "--root", ".", "--filigree-url", remote], + } + } + } + ), + encoding="utf-8", + ) + # A deliberate non-loopback endpoint is preserved verbatim (no-op). + assert merge_mcp_entry(tmp_path) == "unchanged" + assert _wardline_args(tmp_path)[-1] == remote + + +def test_install_preserves_already_scoped_loopback_host_spelling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # An entry that already names the correct port+scope but spells the host 127.0.0.1 + # (vs our localhost) is the canary case: same target, must NOT be churned. + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + store = tmp_path / ".weft" / "filigree" + _register_filigree_server(monkeypatch, tmp_path / "cfg", port=8749, projects={str(store): {"prefix": "lacuna"}}) + canary = "http://127.0.0.1:8749/api/p/lacuna/weft/scan-results" + entry = {"type": "stdio", "command": "/bin/wardline", "args": ["mcp", "--root", ".", "--filigree-url", canary]} + (tmp_path / ".mcp.json").write_text(json.dumps({"mcpServers": {"wardline": entry}}), encoding="utf-8") + assert merge_mcp_entry(tmp_path) == "unchanged" + assert _wardline_args(tmp_path)[-1] == canary + + +# --- Drop stale loopback sibling pins when a live per-project published port exists -- +# +# A frozen --filigree-url / --loomweave-url pinned to a port Filigree/Loomweave has +# since rotated away (the legacy .filigree/ephemeral.port rung outliving a rotation) +# becomes an explicit flag that SHADOWS published-port discovery. In per-project mode +# repair DROPS such a loopback pin so runtime discovery owns the always-current port. + + +def _write_wardline_args(root: Path, args: list[str]) -> None: + entry = {"type": "stdio", "command": "/bin/wardline", "args": args} + (root / ".mcp.json").write_text(json.dumps({"mcpServers": {"wardline": entry}}), encoding="utf-8") + + +def test_repair_drops_stale_loopback_pins_when_per_project_ports_live( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + # Live per-project published rungs (new .weft//ephemeral.port). + (tmp_path / ".weft" / "filigree").mkdir(parents=True) + (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("9397", encoding="utf-8") + (tmp_path / ".weft" / "loomweave").mkdir(parents=True) + (tmp_path / ".weft" / "loomweave" / "ephemeral.port").write_text("39759", encoding="utf-8") + # ...but the entry pins the rotated-away ports. + _write_wardline_args( + tmp_path, + [ + "mcp", + "--root", + ".", + "--loomweave-url", + "http://127.0.0.1:10251", + "--filigree-url", + "http://127.0.0.1:9229/api/weft/scan-results", + ], + ) + assert merge_mcp_entry(tmp_path) == "updated" + args = _wardline_args(tmp_path) + assert "--filigree-url" not in args # stale loopback pin dropped + assert "--loomweave-url" not in args # stale loopback pin dropped + assert args == ["mcp", "--root", "."] # discovery owns both ports + + +def test_repair_preserves_loopback_pin_when_no_live_daemon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # No published rung and not server mode: we cannot improve on the pin, so a loopback + # value is left verbatim (it may be a daemon that is merely down right now). + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + _write_wardline_args( + tmp_path, ["mcp", "--root", ".", "--filigree-url", "http://127.0.0.1:9229/api/weft/scan-results"] + ) + assert merge_mcp_entry(tmp_path) == "unchanged" + assert _wardline_args(tmp_path)[-1] == "http://127.0.0.1:9229/api/weft/scan-results" + + +def test_repair_drops_only_filigree_loopback_pin_preserving_remote_loomweave( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A remote (non-loopback) pin is the operator's deliberate endpoint — never dropped, + # even while a sibling's stale loopback pin is. + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + (tmp_path / ".weft" / "filigree").mkdir(parents=True) + (tmp_path / ".weft" / "filigree" / "ephemeral.port").write_text("9397", encoding="utf-8") + remote_loom = "https://loomweave.example.com" + _write_wardline_args( + tmp_path, + [ + "mcp", + "--root", + ".", + "--loomweave-url", + remote_loom, + "--filigree-url", + "http://127.0.0.1:9229/api/weft/scan-results", + ], + ) + assert merge_mcp_entry(tmp_path) == "updated" + args = _wardline_args(tmp_path) + assert "--filigree-url" not in args # stale loopback dropped + assert args[args.index("--loomweave-url") + 1] == remote_loom # remote preserved + + +def test_same_scope_target_handles_malformed_port_without_crashing() -> None: + # A preserved .mcp.json --filigree-url with a malformed loopback port + # (http://localhost:notaport/...) must read as non-matching (so repair replaces it), + # not raise ValueError out of urlsplit().port and crash `doctor --repair`. + from wardline.install.mcp_json import _same_scope_target + + assert _same_scope_target("http://localhost:notaport/x", "http://localhost:8749/x") is False + assert _same_scope_target("http://localhost:8749/x", "http://localhost:8749/x") is True diff --git a/tests/unit/install/test_skill.py b/tests/unit/install/test_skill.py index 77d7b269..569f5eb5 100644 --- a/tests/unit/install/test_skill.py +++ b/tests/unit/install/test_skill.py @@ -1,5 +1,8 @@ from pathlib import Path +import pytest + +from wardline.core.errors import WardlineError from wardline.install.skill import install_skill @@ -20,3 +23,35 @@ def test_reinstall_overwrites(tmp_path: Path) -> None: assert results[".claude"] == "overwritten" assert results[".agents"] == "overwritten" assert "name: wardline-gate" in stale.read_text(encoding="utf-8") + + +def test_install_skill_rejects_symlinked_parent_escape(tmp_path: Path) -> None: + root = tmp_path / "repo" + root.mkdir() + outside = tmp_path / "outside" + outside_skill = outside / "skills" / "wardline-gate" + outside_skill.mkdir(parents=True) + sentinel = outside_skill / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + (root / ".claude").symlink_to(outside, target_is_directory=True) + + with pytest.raises(WardlineError, match="escapes project root"): + install_skill(root) + + assert sentinel.read_text(encoding="utf-8") == "keep" + assert not (outside_skill / "SKILL.md").exists() + + +def test_install_skill_rejects_symlinked_skill_target(tmp_path: Path) -> None: + outside = tmp_path / "outside-skill" + outside.mkdir() + sentinel = outside / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + skills = tmp_path / ".claude" / "skills" + skills.mkdir(parents=True) + (skills / "wardline-gate").symlink_to(outside, target_is_directory=True) + + with pytest.raises(WardlineError, match="symlink"): + install_skill(tmp_path) + + assert sentinel.read_text(encoding="utf-8") == "keep" diff --git a/tests/unit/loomweave/test_client.py b/tests/unit/loomweave/test_client.py index 073814dd..cfc2a91a 100644 --- a/tests/unit/loomweave/test_client.py +++ b/tests/unit/loomweave/test_client.py @@ -68,6 +68,27 @@ def test_write_chunks_against_batch_max(): assert result.written == 6 +def test_write_chunks_against_serialized_body_size(): + t = FakeTransport([Response(status=200, body='{"written":1,"unresolved_qualnames":[]}')] * 3) + facts = [{"qualname": f"m.f{i}", "wardline_json": {"payload": "x" * 30}} for i in range(3)] + + result = _client(t, batch_max=100, max_body_bytes=180).write_taint_facts(facts) + + assert result.reachable is True + assert len(t.calls) > 1 + assert all(len(body) <= 180 for _method, _url, body, _headers in t.calls) + + +def test_write_oversized_single_fact_is_fail_soft_without_sending(): + t = FakeTransport() + fact = {"qualname": "m.big", "wardline_json": {"payload": "x" * 300}} + + result = _client(t, max_body_bytes=120).write_taint_facts([fact]) + + assert result.reachable is False + assert t.calls == [] + + def test_batch_get_chunks_and_preserves_input_order(): r1 = json.dumps([{"qualname": "a", "exists": False}, {"qualname": "b", "exists": False}]) r2 = json.dumps([{"qualname": "c", "exists": True, "wardline_json": {"x": 1}, "current_content_hash": "deadbeef"}]) @@ -119,9 +140,74 @@ def _raise(req, timeout=None): # noqa: ARG001 assert resp.body.endswith("[truncated]") +def test_urllib_transport_bounds_success_body(monkeypatch) -> None: + import io + + from wardline.core.http import MAX_RESPONSE_BODY_BYTES + from wardline.loomweave.client import UrllibTransport + + class HugeResponse(io.BytesIO): + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda req, timeout=None: HugeResponse(b"x" * (MAX_RESPONSE_BODY_BYTES + 9)), # noqa: ARG005 + ) + + resp = UrllibTransport().request("POST", "http://loomweave.example/api/wardline/resolve", b"{}", {}) + + assert len(resp.body) < MAX_RESPONSE_BODY_BYTES + 128 + assert resp.body.endswith("[truncated]") + + def test_connection_error_is_soft(): class Boom: def request(self, *a, **k): raise OSError("connection refused") assert _client(Boom()).batch_get(["a"]) is None + + +def test_resolve_sends_batch_scoped_plugin_hint(): + # ADR-036 plugin-aware resolution: the OPTIONAL batch-scoped hint rides the + # request verbatim (docs/integration/2026-06-11-wardline-resolve-plugin-hint- + # proposal.md). One hint per request — never per qualname. + t = FakeTransport([Response(status=200, body='{"resolved":{},"unresolved":["m.f"]}')]) + _client(t).resolve(["m.f"], plugin="rust") + assert json.loads(t.calls[0][2])["plugin"] == "rust" + + +def test_resolve_omits_plugin_field_when_unhinted(): + # Omission is today's behavior FOREVER (the contract never fabricates a hint) — + # and an absent field is what keeps unhinted requests valid against any server + # version under deny_unknown_fields. + t = FakeTransport([Response(status=200, body='{"resolved":{},"unresolved":["m.f"]}')]) + _client(t).resolve(["m.f"]) + assert "plugin" not in json.loads(t.calls[0][2]) + + +def test_resolve_hinted_4xx_downgrades_chunk_to_unresolved(): + # Fail-soft: an older Loomweave whose ResolveRequest is deny_unknown_fields 400s + # on the hint field — identity enrichment must degrade to unresolved, not crash + # the dossier/attach path. + t = FakeTransport([Response(status=400, body='{"error":"unknown field `plugin`"}')]) + result = _client(t).resolve(["m.f", "m.g"], plugin="rust") + assert result is not None + assert result.resolved == {} + assert result.unresolved == ["m.f", "m.g"] + + +def test_resolve_unhinted_4xx_stays_loud(): + # An unhinted 4xx cannot be hint-field version skew — it is a real request bug + # and must stay diagnosable (the pre-existing INVALID_PATH pin, re-asserted + # against the hint-conditional soft band). + t = FakeTransport([Response(status=400, body='{"code":"INVALID_PATH"}')]) + with pytest.raises(LoomweaveError, match="INVALID_PATH"): + _client(t).resolve(["m.f"]) diff --git a/tests/unit/loomweave/test_client_linkages.py b/tests/unit/loomweave/test_client_linkages.py index b7bdeb80..311d645e 100644 --- a/tests/unit/loomweave/test_client_linkages.py +++ b/tests/unit/loomweave/test_client_linkages.py @@ -72,6 +72,40 @@ def test_get_callees_reads_the_callees_field(): assert res.truncated is True +def test_linkage_non_finite_large_float_total_falls_back_to_neighbour_count(): + body = ( + '{"entity_id":"x","callers":[' + '{"entity_id":"python:function:svc.caller","confidence":"resolved","call_site_count":1}' + '],"total":1e999999,"truncated":false}' + ) + res = _client(FakeTransport([Response(status=200, body=body)])).get_callers("x") + assert res is not None + assert res.neighbours == ("python:function:svc.caller",) + assert res.total == 1 + + +def test_linkage_nan_total_falls_back_to_neighbour_count(): + body = '{"entity_id":"x","callees":[],"total":NaN,"truncated":false}' + res = _client(FakeTransport([Response(status=200, body=body)])).get_callees("x") + assert res is not None + assert res.neighbours == () + assert res.total == 0 + + +def test_linkage_negative_total_falls_back_to_neighbour_count(): + body = json.dumps( + { + "entity_id": "x", + "callers": [{"entity_id": "python:function:svc.caller"}], + "total": -4, + "truncated": False, + } + ) + res = _client(FakeTransport([Response(status=200, body=body)])).get_callers("x") + assert res is not None + assert res.total == 1 + + def test_linkage_404_entity_unknown_is_soft_none(): # entity not known to Loomweave → 404 → honest None (caller degrades), never a crash t = FakeTransport([Response(status=404, body='{"code":"NOT_FOUND","error":"unknown"}')]) diff --git a/tests/unit/loomweave/test_dossier_sources.py b/tests/unit/loomweave/test_dossier_sources.py index 23f51866..658ec294 100644 --- a/tests/unit/loomweave/test_dossier_sources.py +++ b/tests/unit/loomweave/test_dossier_sources.py @@ -27,7 +27,8 @@ def get_callers(self, entity_id, *, limit=50): def get_callees(self, entity_id, *, limit=50): return self._callees - def resolve(self, qualnames): + def resolve(self, qualnames, *, plugin=None): + self.plugin_hints = [*getattr(self, "plugin_hints", []), plugin] from wardline.loomweave.client import ResolveResult resolved = {q: self._resolved[q] for q in qualnames if q in self._resolved} diff --git a/tests/unit/loomweave/test_facts.py b/tests/unit/loomweave/test_facts.py index 99d27b6a..ffb55f97 100644 --- a/tests/unit/loomweave/test_facts.py +++ b/tests/unit/loomweave/test_facts.py @@ -53,6 +53,15 @@ def test_content_hash_is_blake3_whole_file_and_top_level_and_in_blob(tmp_path): assert len(expected) == 64 +def test_fact_emission_refuses_files_changed_after_scan(tmp_path): + proj, result = _scan_leaky(tmp_path) + (proj / "svc.py").write_text("def replacement():\n return 1\n", encoding="utf-8") + + facts = build_taint_facts(result, proj) + + assert facts == [] + + def test_per_file_hash_is_memoized(tmp_path, monkeypatch): proj, result = _scan_leaky(tmp_path) import wardline.loomweave.facts as facts_mod diff --git a/tests/unit/mcp/test_protocol.py b/tests/unit/mcp/test_protocol.py index 3955d8c1..1878a6cb 100644 --- a/tests/unit/mcp/test_protocol.py +++ b/tests/unit/mcp/test_protocol.py @@ -1,7 +1,7 @@ import io import json -from wardline.mcp.protocol import PROTOCOL_VERSION, JsonRpcServer, McpError +from wardline.mcp.protocol import PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, JsonRpcServer, McpError def _server() -> JsonRpcServer: @@ -27,6 +27,37 @@ def test_initialize_returns_capabilities_and_protocol_version() -> None: assert resp["result"]["serverInfo"]["name"] == "wardline" +def test_initialize_negotiates_each_supported_protocol_version() -> None: + # Spec negotiation: a supported requested revision is echoed VERBATIM, so an + # older client (e.g. pinned to 2024-11-05) keeps its own revision. + srv = _server() + for requested in SUPPORTED_PROTOCOL_VERSIONS: + resp = srv.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": requested, "capabilities": {}}, + } + ) + assert resp["result"]["protocolVersion"] == requested + + +def test_initialize_unknown_protocol_version_answers_latest() -> None: + # An unsupported revision gets the newest revision this server speaks. + srv = _server() + resp = srv.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "1999-01-01", "capabilities": {}}, + } + ) + assert resp["result"]["protocolVersion"] == PROTOCOL_VERSION + assert SUPPORTED_PROTOCOL_VERSIONS[0] == PROTOCOL_VERSION # newest first + + def test_notification_initialized_returns_none() -> None: srv = _server() # notifications (no id) must not produce a response diff --git a/tests/unit/mcp/test_server_doctor.py b/tests/unit/mcp/test_server_doctor.py new file mode 100644 index 00000000..11d9bf07 --- /dev/null +++ b/tests/unit/mcp/test_server_doctor.py @@ -0,0 +1,167 @@ +"""A2 (wardline-4c5165e896): the `doctor` MCP twin + server-freshness self-identification. + +The CLI `doctor --fix` envelope (machine_readable_doctor) is served read-only over MCP, +plus a `server` self-identification block (package version, pid, start time, source +freshness) so an agent can detect the 2026-06-06 stale-server class — a long-lived +`wardline mcp` process serving code older than the tree it scans — without shelling out. +Repair is behind an explicit `repair: true` (WRITE-gated); the default call writes nothing. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +from wardline._version import __version__ +from wardline.install.doctor import machine_readable_doctor +from wardline.mcp.server import WardlineMCPServer, _doctor + + +def _isolate(tmp_path: Path, monkeypatch) -> Path: + home = tmp_path / "home" + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.mcp_json.Path.home", lambda: home) + monkeypatch.setattr("wardline.install.mcp_json._find_wardline_command", lambda: "/bin/wardline") + monkeypatch.setattr("wardline.install.detect.shutil.which", lambda _: None) + return home + + +def test_doctor_matches_cli_machine_readable_envelope(tmp_path: Path, monkeypatch) -> None: + """CLI==MCP by construction: the MCP doctor's check set IS machine_readable_doctor's + (fix=False), with exactly one extra MCP-only check appended (server.freshness).""" + _isolate(tmp_path, monkeypatch) + cli = machine_readable_doctor(tmp_path, fix=False) + mcp = _doctor({}, tmp_path, started_at=time.time()) + assert mcp["checks"][:-1] == cli["checks"] + assert mcp["checks"][-1]["id"] == "server.freshness" + # A fresh server adds no failure: ok parity holds. + assert mcp["ok"] == cli["ok"] + + +def test_doctor_url_checks_report_launch_flags_with_provenance(tmp_path: Path, monkeypatch) -> None: + """Dogfood-4 B8: doctor said loomweave.url/filigree.url 'not configured' while + the answering server was launched with both flags and using them. The url + checks must describe THIS process's effective config and name the source.""" + _isolate(tmp_path, monkeypatch) + payload = _doctor( + {}, + tmp_path, + started_at=time.time(), + filigree_url="http://127.0.0.1:8749/api/p/lacuna/weft/scan-results", + loomweave_url="http://127.0.0.1:9730", + ) + by_id = {c["id"]: c for c in payload["checks"]} + assert by_id["loomweave.url"]["message"] == "from --loomweave-url launch flag" + assert by_id["filigree.url"]["message"] == "from --filigree-url launch flag" + # And honest absence names what was checked, not a bare "not configured". + bare = _doctor({}, tmp_path, started_at=time.time()) + by_id = {c["id"]: c for c in bare["checks"]} + assert by_id["loomweave.url"]["message"] == "not configured (no launch flag, no env)" + + +def test_doctor_reports_server_identity(tmp_path: Path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + now = time.time() + payload = _doctor({}, tmp_path, started_at=now) + server = payload["server"] + assert server["package_version"] == __version__ + assert server["pid"] == os.getpid() + assert server["project_root"] == str(tmp_path) + assert server["started_at"].startswith("20") # ISO timestamp + assert server["fresh"] is True + assert payload["checks"][-1] == {"id": "server.freshness", "status": "ok", "fixed": False} + + +def test_doctor_detects_stale_server(tmp_path: Path, monkeypatch) -> None: + """A server started before the on-disk wardline source last changed is STALE — + the exact dogfood-2026-06-06 failure class. The verdict must flip ok and land in + next_actions with the restart instruction.""" + _isolate(tmp_path, monkeypatch) + payload = _doctor({}, tmp_path, started_at=1.0) # 1970 — everything on disk is newer + server = payload["server"] + assert server["fresh"] is False + freshness = payload["checks"][-1] + assert freshness["id"] == "server.freshness" + assert freshness["status"] == "error" + assert "restart" in freshness["message"].lower() + assert payload["ok"] is False + assert any("server.freshness" in action and "restart" in action.lower() for action in payload["next_actions"]) + + +def test_doctor_default_is_read_only(tmp_path: Path, monkeypatch) -> None: + home = _isolate(tmp_path, monkeypatch) + _doctor({}, tmp_path, started_at=time.time()) + assert not (tmp_path / "CLAUDE.md").exists() + assert not (tmp_path / ".mcp.json").exists() + assert not (home / ".codex" / "config.toml").exists() + + +def test_doctor_repair_true_repairs_install_artifacts(tmp_path: Path, monkeypatch) -> None: + home = _isolate(tmp_path, monkeypatch) + payload = _doctor({"repair": True}, tmp_path, started_at=time.time()) + assert "wardline:instructions:" in (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert (tmp_path / ".mcp.json").is_file() + assert (home / ".codex" / "config.toml").is_file() + assert (tmp_path / ".weft" / "wardline").is_dir() + by_id = {c["id"]: c for c in payload["checks"]} + assert by_id["mcp.registration"]["status"] == "ok" + + +def _tool_call(server: WardlineMCPServer, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + } + ) + assert "error" not in resp, resp + return resp["result"] + + +def test_doctor_repair_is_denied_by_no_write_policy(tmp_path: Path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + server = WardlineMCPServer(root=tmp_path, allow_write=False) + result = _tool_call(server, "doctor", {"repair": True}) + assert result["isError"] is True + assert "write" in result["content"][0]["text"].lower() + # The read-only probe stays allowed under the same policy. + ok = _tool_call(server, "doctor") + assert "isError" not in ok + + +def test_doctor_with_probe_url_is_denied_by_no_network_policy(tmp_path: Path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + server = WardlineMCPServer(root=tmp_path, filigree_url="http://127.0.0.1:9/weft", allow_network=False) + result = _tool_call(server, "doctor") + assert result["isError"] is True + assert "network" in result["content"][0]["text"].lower() + + +def test_doctor_registered_with_served_schema(tmp_path: Path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + server = WardlineMCPServer(root=tmp_path) + resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + doctor = next(t for t in resp["result"]["tools"] if t["name"] == "doctor") + props = doctor["inputSchema"]["properties"] + assert props["repair"]["type"] == "boolean" + assert props["filigree_url"]["type"] == "string" + # The anti-stale-server contract is advertised where agents read it. + assert "stale" in doctor["description"].lower() or "fresh" in doctor["description"].lower() + + +def test_doctor_over_rpc_serves_identity(tmp_path: Path, monkeypatch) -> None: + """End-to-end through the dispatch loop: the server's own started_at feeds the + freshness verdict, and a just-started server is fresh.""" + import json + + _isolate(tmp_path, monkeypatch) + server = WardlineMCPServer(root=tmp_path) + result = _tool_call(server, "doctor") + payload = json.loads(result["content"][0]["text"]) + assert payload["server"]["fresh"] is True + assert payload["server"]["package_version"] == __version__ diff --git a/tests/unit/mcp/test_server_dossier.py b/tests/unit/mcp/test_server_dossier.py index c4085406..511d5bcf 100644 --- a/tests/unit/mcp/test_server_dossier.py +++ b/tests/unit/mcp/test_server_dossier.py @@ -94,7 +94,7 @@ def __init__(self, *a, **k): def capabilities(self): return None - def resolve(self, qualnames): + def resolve(self, qualnames, *, plugin=None): return None monkeypatch.setattr("wardline.loomweave.client.LoomweaveClient", _FakeClient) diff --git a/tests/unit/mcp/test_server_filigree_emit.py b/tests/unit/mcp/test_server_filigree_emit.py index 51678300..b2c7027c 100644 --- a/tests/unit/mcp/test_server_filigree_emit.py +++ b/tests/unit/mcp/test_server_filigree_emit.py @@ -9,7 +9,7 @@ import pytest from wardline.core.errors import FiligreeEmitError -from wardline.core.filigree_emit import EmitResult +from wardline.core.filigree_emit import EmitResult, FailedFinding from wardline.mcp.server import WardlineMCPServer, _scan _LEAKY = ( @@ -60,10 +60,14 @@ def test_scan_emits_to_filigree_when_emitter_present(tmp_path): "created": 2, "updated": 1, "failed": 0, + "failures": [], "warnings": [], "status": None, "auth_rejected": False, + "token_sent": False, + "url": None, "disabled_reason": None, + "destination": {"url": None, "project": None, "project_pinned": False}, } assert emitter.scanned_paths == ("svc.py",) @@ -80,10 +84,14 @@ def test_scan_reports_both_integrations_successful(tmp_path): "created": 2, "updated": 1, "failed": 0, + "failures": [], "warnings": [], "status": None, "auth_rejected": False, + "token_sent": False, + "url": None, "disabled_reason": None, + "destination": {"url": None, "project": None, "project_pinned": False}, } @@ -97,8 +105,10 @@ def test_scan_filigree_block_null_when_no_emitter(tmp_path): "created": 0, "updated": 0, "failed": 0, + "failures": [], "warnings": [], "disabled_reason": "not configured", + "destination": {"url": None, "project": None, "project_pinned": False}, } @@ -156,6 +166,35 @@ def test_scan_filigree_403_says_forbidden_not_set_a_token(tmp_path): assert "unreachable" not in reason +def test_scan_partial_ingest_surfaces_failures_to_agent(tmp_path): + # PDR-0023: a partial ingest (some findings rejected) must NOT read as a clean emit on + # the agent-facing MCP surface. The `failures` array names which findings failed and why, + # so an agent can distinguish "all emitted" from "M of N emitted, K failed because R". + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + result = EmitResult( + reachable=True, + created=1, + failures=(FailedFinding(reason="scheme_mismatch", detail="expected wlfp3", fingerprint="wlfp2:bad"),), + ) + out = _scan({}, tmp_path, None, FakeEmitter(result)) + # weft-reason (G1): each failure wire carries the shipped domain fields AND the canonical + # carrier triple {reason_class, cause, fix} additively (scheme_mismatch -> scheme_mismatch). + expected_failure = { + "reason": "scheme_mismatch", + "detail": "expected wlfp3", + "reason_class": "scheme_mismatch", + "cause": "expected wlfp3", + "fix": ( + "align the wardline fingerprint scheme to the scheme Filigree expects, then re-emit (a drift join-misses)" + ), + "fingerprint": "wlfp2:bad", + } + assert out["filigree"]["failed"] == 1 + assert out["filigree"]["failures"] == [expected_failure] + assert out["filigree_emit"]["failed"] == 1 + assert out["filigree_emit"]["failures"] == [expected_failure] + + def test_scan_filigree_5xx_says_server_error_not_unreachable(tmp_path): # A 5xx outage reached us (the sibling is degraded, not absent). The disabled_reason # must say "server error (503)", distinct from both the 401 auth case and the genuine diff --git a/tests/unit/mcp/test_server_legis_artifact.py b/tests/unit/mcp/test_server_legis_artifact.py index 22da75fa..238db12a 100644 --- a/tests/unit/mcp/test_server_legis_artifact.py +++ b/tests/unit/mcp/test_server_legis_artifact.py @@ -60,6 +60,27 @@ def test_legis_artifact_unsigned_when_no_key(tmp_path, monkeypatch) -> None: assert "artifact_signature" not in out["legis_artifact"] +def test_legis_suppressed_under_summary_only_even_with_key(tmp_path, monkeypatch) -> None: + # Dogfood-4 B6: summary_only promises the smallest gate payload, but a + # provisioned key auto-attached a ~56KB artifact into it (blew the MCP token + # cap). With a key and summary_only:true the artifact must stay off. + monkeypatch.setenv(LEGIS_ARTIFACT_KEY_ENV, "testsecret") + repo = _committed_repo(tmp_path) + out = _scan({"summary_only": True}, repo, None, None) + assert "legis_artifact" not in out + assert "legis_artifact_status" not in out + + +def test_legis_explicit_opt_in_wins_over_summary_only(tmp_path, monkeypatch) -> None: + # The caller who asks for both gets both: explicit legis_artifact:true still + # attaches under summary_only. + monkeypatch.setenv(LEGIS_ARTIFACT_KEY_ENV, "testsecret") + repo = _committed_repo(tmp_path) + out = _scan({"summary_only": True, "legis_artifact": True}, repo, None, None) + assert "legis_artifact" in out + assert out["legis_artifact_status"]["signed"] is True + + def test_legis_clean_tree_with_key_is_signed(tmp_path, monkeypatch) -> None: # The positive arm of `signed = key and not dirty`: a key present on a CLEAN tree signs. monkeypatch.setenv(LEGIS_ARTIFACT_KEY_ENV, "testsecret") diff --git a/tests/unit/mcp/test_server_loomweave_write.py b/tests/unit/mcp/test_server_loomweave_write.py index 82289812..4aa6c762 100644 --- a/tests/unit/mcp/test_server_loomweave_write.py +++ b/tests/unit/mcp/test_server_loomweave_write.py @@ -56,7 +56,7 @@ def test_scan_tool_survives_loomweave_write_error(tmp_path): # The scan payload itself is intact, NOT discarded — assert on real scan keys # that _scan always returns. assert "summary" in out - assert "findings" in out + assert "agent_summary" in out assert "gate" in out # PY-WL-101 fires on _LEAKY, so the scan found real findings. assert out["summary"]["total"] >= 1 @@ -76,7 +76,14 @@ def _fresh_view(proj, qualname, callee_qualname): "resolved_call_count": 1, "unresolved_call_count": 0, }, - "findings": [], + "findings": [ + { + "rule_id": "PY-WL-101", + "fingerprint": "f" * 64, + "path": "svc.py", + "line_start": 5, + } + ], } return TaintFactView(qualname=qualname, exists=True, wardline_json=blob, current_content_hash=h) @@ -103,3 +110,50 @@ def test_explain_taint_chain_block_with_store(tmp_path): assert "chain" in out assert [h["qualname"] for h in out["chain"]["hops"]] == ["svc.leaky", "svc.read_raw"] assert out["chain"]["truncated_at"] is None + + +def _type_skewed_view(proj, qualname): + # A hand-edited / version-skewed blob: tiers and callee carry non-string types. + h = blake3.blake3((proj / "svc.py").read_bytes()).hexdigest() + blob = { + "schema_version": "wardline-taint-1", + "qualname": qualname, + "content_hash_at_compute": h, + "taint": { + "declared_return": 7, + "actual_return": ["EXTERNAL_RAW"], + "source": "anchored", + "contributing_callee_qualname": 99, + "resolved_call_count": 1, + "unresolved_call_count": 0, + }, + "findings": [ + { + "rule_id": "PY-WL-101", + "fingerprint": "f" * 64, + "path": "svc.py", + "line_start": 5, + } + ], + } + return TaintFactView(qualname=qualname, exists=True, wardline_json=blob, current_content_hash=h) + + +def test_explain_taint_type_skewed_blob_fields_coerce_to_none(tmp_path): + # The store blob is external input: non-string tiers/callee must coerce to None + # (the fields are string|null in the published outputSchema), matching the + # adjacent fingerprint/rule_id/path guards — never flow through verbatim. + (tmp_path / "svc.py").write_text(_LEAKY, encoding="utf-8") + client = MapClient({"svc.leaky": _type_skewed_view(tmp_path, "svc.leaky")}) + + out = _explain_taint({"sink_qualname": "svc.leaky", "chain": True}, tmp_path, client) + + assert out["tier_in"] is None + assert out["tier_out"] is None + assert out["immediate_tainted_callee"] is None + assert out["source_boundary_qualname"] is None + hop = out["chain"]["hops"][0] + assert hop["tier_in"] is None + assert hop["tier_out"] is None + assert hop["contributing_callee_qualname"] is None + assert out["chain"]["truncated_at"] is None diff --git a/tests/unit/mcp/test_server_query_explain.py b/tests/unit/mcp/test_server_query_explain.py index 7a8a3fed..d27abc0f 100644 --- a/tests/unit/mcp/test_server_query_explain.py +++ b/tests/unit/mcp/test_server_query_explain.py @@ -36,13 +36,13 @@ def _baseline_all(tmp_path) -> None: def test_where_filters_findings_by_qualname(tmp_path): (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") - full = _scan({}, tmp_path) - qualnames = {f["qualname"] for f in full["findings"] if f["rule_id"] == "PY-WL-101"} + full = _scan({"full": True}, tmp_path) + qualnames = {e["qualname"] for e in full["agent_summary"]["active_defects"] if e["rule_id"] == "PY-WL-101"} assert "svc.leak_a" in qualnames and "svc.leak_b" in qualnames - filtered = _scan({"where": {"qualname": "svc.leak_a"}}, tmp_path) - got = [f for f in filtered["findings"] if f["rule_id"] == "PY-WL-101"] - assert {f["qualname"] for f in got} == {"svc.leak_a"} + filtered = _scan({"where": {"qualname": "svc.leak_a"}, "full": True}, tmp_path) + got = [e for e in filtered["agent_summary"]["active_defects"] if e["rule_id"] == "PY-WL-101"] + assert {e["qualname"] for e in got} == {"svc.leak_a"} def test_where_summary_and_gate_describe_whole_project(tmp_path): @@ -60,10 +60,29 @@ def test_where_unknown_key_is_toolerror(tmp_path): _scan({"where": {"bogus": "x"}}, tmp_path) +def test_where_sei_filter_honors_strict_defaults(tmp_path, monkeypatch): + from wardline.core import config as config_mod + + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + seen: dict[str, bool] = {} + + def fake_resolve_loomweave_url(flag, root, config_path, *, strict_defaults=False): + seen["strict_defaults"] = strict_defaults + if not strict_defaults: + raise AssertionError("strict_defaults was not propagated to SEI filter resolution") + return None + + monkeypatch.setattr(config_mod, "resolve_loomweave_url", fake_resolve_loomweave_url) + + with pytest.raises(ToolError, match="no Loomweave URL configured"): + _scan({"where": {"qualname": "sei:python:function:svc.leak_a"}}, tmp_path, strict_defaults=True) + assert seen["strict_defaults"] is True + + def test_explain_inlines_provenance_on_active_defects(tmp_path): (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") out = _scan({"explain": True}, tmp_path) - by_q = {f["qualname"]: f for f in out["findings"] if f["rule_id"] == "PY-WL-101"} + by_q = {e["qualname"]: e for e in out["agent_summary"]["active_defects"] if e["rule_id"] == "PY-WL-101"} exp = by_q["svc.leak_a"]["explanation"] assert exp["immediate_tainted_callee"] == "read_a" assert exp["source_boundary_qualname"] == "svc.read_a" @@ -73,7 +92,7 @@ def test_explain_inlines_provenance_on_active_defects(tmp_path): def test_explain_absent_by_default(tmp_path): (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") out = _scan({}, tmp_path) - assert all("explanation" not in f for f in out["findings"]) + assert all("explanation" not in e for e in out["agent_summary"]["active_defects"]) def test_explain_matches_single_finding_explain(tmp_path): @@ -82,7 +101,11 @@ def test_explain_matches_single_finding_explain(tmp_path): (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") out = _scan({"explain": True}, tmp_path) - f = next(f for f in out["findings"] if f["qualname"] == "svc.leak_a" and f["rule_id"] == "PY-WL-101") + f = next( + e + for e in out["agent_summary"]["active_defects"] + if e["qualname"] == "svc.leak_a" and e["rule_id"] == "PY-WL-101" + ) single = _explain_taint({"fingerprint": f["fingerprint"]}, tmp_path) # All six explanation keys must match the single-finding explain projection. assert f["explanation"] == {k: single[k] for k in f["explanation"]} @@ -97,9 +120,8 @@ def test_where_filters_agent_summary_arrays(tmp_path): (tmp_path / "svc.py").write_text(_many_leaks(5), encoding="utf-8") _baseline_all(tmp_path) out = _scan({"where": {"suppression": "active", "severity": "CRITICAL"}}, tmp_path) - assert out["findings"] == [] # 0 active CRITICAL summ = out["agent_summary"] - assert summ["suppressed_findings"] == [] + assert summ["suppressed_findings"] == [] # 0 active CRITICAL assert summ["active_defects"] == [] # but the whole-project counts are preserved assert summ["summary"]["suppressed_findings"] == 5 @@ -112,9 +134,9 @@ def test_explain_true_has_default_cap(tmp_path): # truncation is announced — never silent. (tmp_path / "svc.py").write_text(_many_leaks(40), encoding="utf-8") out = _scan({"explain": True}, tmp_path) - explained = [f for f in out["findings"] if "explanation" in f] - assert 0 < len(explained) <= 10 # default cap - assert out["truncation"]["explanations_truncated"] is True + explained = [e for e in out["agent_summary"]["active_defects"] if "explanation" in e] + assert 0 < len(explained) <= 10 # default explanation cap (independent of the body page size) + assert out["agent_summary"]["truncation"]["explanations_truncated"] is True # the true total is still reported, so nothing is silently hidden assert out["summary"]["active"] == 40 @@ -124,22 +146,26 @@ def test_max_findings_can_raise_explain_cap_above_default(tmp_path): # the conservative default (10) when the agent accepts the larger payload. (tmp_path / "svc.py").write_text(_many_leaks(20), encoding="utf-8") out = _scan({"explain": True, "max_findings": 20}, tmp_path) - explained = [f for f in out["findings"] if "explanation" in f] + explained = [e for e in out["agent_summary"]["active_defects"] if "explanation" in e] assert len(explained) > 10 # exceeded the default cap - assert out["truncation"]["explanations_truncated"] is False + assert out["agent_summary"]["truncation"]["explanations_truncated"] is False def test_summary_only_omits_finding_arrays(tmp_path): # (d): the "did the gate pass?" payload — counts + gate, no finding bodies. (tmp_path / "svc.py").write_text(_many_leaks(5), encoding="utf-8") out = _scan({"summary_only": True, "fail_on": "ERROR"}, tmp_path) - assert out["findings"] == [] summ = out["agent_summary"] - assert summ["active_defects"] == [] and summ["suppressed_findings"] == [] and summ["engine_facts"] == [] + assert ( + summ["active_defects"] == [] + and summ["suppressed_findings"] == [] + and summ["engine_facts"] == [] + and summ["informational"] == [] + ) # counts + gate intact assert out["summary"]["active"] == 5 assert out["gate"]["tripped"] is True - assert out["truncation"]["summary_only"] is True + assert summ["truncation"]["summary_only"] is True def test_include_suppressed_false_drops_suppressed(tmp_path): @@ -147,7 +173,8 @@ def test_include_suppressed_false_drops_suppressed(tmp_path): (tmp_path / "svc.py").write_text(_many_leaks(5), encoding="utf-8") _baseline_all(tmp_path) out = _scan({"include_suppressed": False}, tmp_path) - assert all(f["suppressed"] == "active" for f in out["findings"]) + # suppressed bodies are dropped from the page; any shown defect body is active. + assert all(e["suppression_state"] == "active" for e in out["agent_summary"]["active_defects"]) assert out["agent_summary"]["suppressed_findings"] == [] # whole-project count still visible assert out["summary"]["baselined"] == 5 @@ -157,10 +184,12 @@ def test_max_findings_caps_and_marks(tmp_path): # (b): bound the returned list and announce the cut. (tmp_path / "svc.py").write_text(_many_leaks(10), encoding="utf-8") out = _scan({"max_findings": 3}, tmp_path) - assert len(out["findings"]) == 3 - assert out["truncation"]["findings_truncated"] is True - assert out["truncation"]["findings_returned"] == 3 - assert out["truncation"]["findings_total"] >= 10 + ag = out["agent_summary"] + shown = len(ag["active_defects"]) + len(ag["suppressed_findings"]) + len(ag["engine_facts"]) + assert shown == 3 + assert ag["truncation"]["findings_truncated"] is True + assert ag["truncation"]["findings_returned"] == 3 + assert ag["truncation"]["findings_total"] >= 10 @pytest.mark.parametrize("bad", [-1, 1.5, "3", True]) @@ -179,3 +208,48 @@ def test_boolean_payload_controls_reject_non_bool(tmp_path, name): (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") with pytest.raises(ToolError, match=name): _scan({name: "false"}, tmp_path) + + +# --- W1: bounded default + offset pagination (weft-439d09fc8d) --------------- + + +def _shown(ag) -> int: + return ( + len(ag["active_defects"]) + len(ag["suppressed_findings"]) + len(ag["engine_facts"]) + len(ag["informational"]) + ) + + +def test_default_scan_is_bounded_to_25(tmp_path): + # A bare scan over 30 active defects returns at most the bounded page (25), so the + # agent's first natural call cannot overflow its context — the ~123KB dump is gone. + (tmp_path / "svc.py").write_text(_many_leaks(30), encoding="utf-8") + out = _scan({}, tmp_path) + ag = out["agent_summary"] + assert _shown(ag) == 25 + t = ag["truncation"] + assert t["findings_returned"] == 25 and t["findings_truncated"] is True and t["next_offset"] == 25 + # whole-project count stays honest regardless of the page bound + assert out["summary"]["active"] == 30 + + +def test_offset_pages_are_disjoint_and_full_is_uncapped(tmp_path): + (tmp_path / "svc.py").write_text(_many_leaks(30), encoding="utf-8") + page1 = _scan({}, tmp_path)["agent_summary"] + nxt = page1["truncation"]["next_offset"] + assert nxt == 25 + page2 = _scan({"offset": nxt}, tmp_path)["agent_summary"] + fp1 = {e["fingerprint"] for e in page1["active_defects"]} + fp2 = {e["fingerprint"] for e in page2["active_defects"]} + assert fp1 and fp2 and fp1.isdisjoint(fp2) # no finding appears on both pages + assert page2["truncation"]["offset"] == 25 + assert page2["truncation"]["next_offset"] is None # 30 actives → page 2 is the last + # full=true returns every body in one call, untruncated. + full = _scan({"full": True}, tmp_path)["agent_summary"] + assert len(full["active_defects"]) == 30 + assert full["truncation"]["findings_truncated"] is False and full["truncation"]["next_offset"] is None + + +def test_offset_rejects_non_negative_integer(tmp_path): + (tmp_path / "svc.py").write_text(_SRC, encoding="utf-8") + with pytest.raises(ToolError, match="offset"): + _scan({"offset": -1}, tmp_path) diff --git a/tests/unit/mcp/test_server_rekey.py b/tests/unit/mcp/test_server_rekey.py new file mode 100644 index 00000000..c5fd9b95 --- /dev/null +++ b/tests/unit/mcp/test_server_rekey.py @@ -0,0 +1,207 @@ +"""A3 (wardline-d8cc650ab9): the `rekey` MCP twin. + +Probe-by-default (read-only: report match/orphans/collisions, write NOTHING); +`apply` / `resume` / `rollback` are explicit, mutually exclusive, WRITE-gated args. +Shares the CLI's core implementation (core.rekey) — no second migration path. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +yaml = pytest.importorskip("yaml") +pytest.importorskip("blake3", reason="run_scan needs wardline[loomweave]") + +from wardline.core import paths # noqa: E402 +from wardline.core.baseline import load_baseline # noqa: E402 +from wardline.core.fingerprint_v0 import compute_finding_fingerprint_v0 # noqa: E402 +from wardline.core.rekey import load_journal, snapshot_dir, write_journal # noqa: E402 +from wardline.core.run import run_scan # noqa: E402 +from wardline.mcp.server import WardlineMCPServer, _rekey # noqa: E402 +from wardline.mcp.tooling import ToolError # noqa: E402 + +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return raw(p)\n" +) + + +def _project(tmp_path: Path) -> Path: + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text(_LEAKY, encoding="utf-8") + return project + + +def _seed_wlfp1_baseline(project: Path, *, extra_fps: tuple[str, ...] = ()): + leak = next(f for f in run_scan(project).findings if f.rule_id == "PY-WL-101") + old_fp = compute_finding_fingerprint_v0( + rule_id=leak.rule_id, + path=leak.location.path, + line_start=leak.location.line_start, + qualname=leak.qualname, + taint_path=leak.taint_path_v0, + ) + entries = [{"fingerprint": old_fp, "rule_id": leak.rule_id, "path": leak.location.path, "message": leak.message}] + entries += [{"fingerprint": fp, "rule_id": "PY-WL-101", "path": "gone.py", "message": "x"} for fp in extra_fps] + bp = paths.baseline_path(project) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp1", "version": 1, "entries": entries}), + encoding="utf-8", + ) + return leak, old_fp + + +def test_rekey_defaults_to_read_only_probe(tmp_path: Path) -> None: + project = _project(tmp_path) + _seed_wlfp1_baseline(project) + result = _rekey({}, project) + assert result["mode"] == "probe" + assert result["matched"] == 1 + assert result["orphaned_count"] == 0 + assert result["orphaned_sample"] == [] + assert result["no_op"] is False # a wlfp1 store pends migration + assert result["clean"] is True + # Writes NOTHING: no snapshot, no journal, store untouched (still wlfp1). + assert not paths.migration_journal_path(project).exists() + assert not snapshot_dir(project).exists() + assert "wlfp1" in paths.baseline_path(project).read_text(encoding="utf-8") + + +def test_rekey_probe_reports_orphans_with_cause(tmp_path: Path) -> None: + project = _project(tmp_path) + _seed_wlfp1_baseline(project, extra_fps=("deadbeef" * 8,)) + result = _rekey({}, project) + assert result["clean"] is False + assert result["orphaned_count"] == 1 + assert result["orphaned_sample"] == ["deadbeef" * 8] + assert result["per_store"] == {"baseline.yaml": 1} + assert "moved" in result["orphan_cause"] + + +def test_rekey_probe_reports_a_healthy_current_scheme_baseline_as_clean_noop(tmp_path: Path) -> None: + # A7 (weft-dda1a6d8dd): the live-lacuna shape — a wlfp2 baseline whose entries all + # match the current scan must read matched=N / orphaned=0 / clean, never 100% orphaned. + project = _project(tmp_path) + leak = next(f for f in run_scan(project).findings if f.rule_id == "PY-WL-101") + bp = paths.baseline_path(project) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text( + yaml.safe_dump( + { + "fingerprint_scheme": "wlfp2", + "version": 1, + "entries": [ + { + "fingerprint": leak.fingerprint, + "rule_id": leak.rule_id, + "path": leak.location.path, + "message": leak.message, + } + ], + } + ), + encoding="utf-8", + ) + result = _rekey({}, project) + assert result["mode"] == "probe" + assert result["matched"] == 1 + assert result["orphaned_count"] == 0 + assert result["stale_count"] == 0 + assert result["no_op"] is True + assert result["current_scheme_stores"] == ["baseline.yaml"] + assert result["clean"] is True + + +def test_rekey_apply_migrates_and_reports_journal(tmp_path: Path) -> None: + project = _project(tmp_path) + leak, old_fp = _seed_wlfp1_baseline(project) + assert leak.fingerprint != old_fp + result = _rekey({"apply": True}, project) + assert result["mode"] == "apply" + assert result["complete"] is True + legs = {leg["name"]: leg for leg in result["legs"]} + assert legs["baseline"]["done"] is True + assert legs["baseline"]["carried_count"] == 1 + assert load_baseline(paths.baseline_path(project)).fingerprints == frozenset({leak.fingerprint}) + assert (snapshot_dir(project) / "baseline.yaml").is_file() + + +def test_rekey_resume_finishes_without_rescan(tmp_path: Path) -> None: + project = _project(tmp_path) + leak, _old = _seed_wlfp1_baseline(project) + _rekey({"apply": True}, project) + # Revert the baseline leg to pending, corrupt the live store, delete the source — + # resume must re-carry from the snapshot and NEVER re-scan. + jpath = paths.migration_journal_path(project) + journal = load_journal(jpath) + journal.leg("baseline").done = False + write_journal(jpath, journal, root=project) + paths.baseline_path(project).write_text( + yaml.safe_dump({"fingerprint_scheme": "wlfp2", "version": 1, "entries": []}), encoding="utf-8" + ) + (project / "svc.py").unlink() + result = _rekey({"resume": True}, project) + assert result["mode"] == "resume" + assert result["complete"] is True + assert load_baseline(paths.baseline_path(project)).fingerprints == frozenset({leak.fingerprint}) + + +def test_rekey_rollback_restores_stores(tmp_path: Path) -> None: + project = _project(tmp_path) + _leak, old_fp = _seed_wlfp1_baseline(project) + before = paths.baseline_path(project).read_bytes() + _rekey({"apply": True}, project) + result = _rekey({"rollback": True}, project) + assert result["mode"] == "rollback" + assert "baseline.yaml" in result["restored"] + assert "not reversed" in result["note"].lower() + assert paths.baseline_path(project).read_bytes() == before + assert not paths.migration_journal_path(project).exists() + + +def test_rekey_modes_are_mutually_exclusive(tmp_path: Path) -> None: + project = _project(tmp_path) + with pytest.raises(ToolError, match="mutually exclusive"): + _rekey({"apply": True, "rollback": True}, project) + + +def _tool_call(server: WardlineMCPServer, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + } + ) + assert "error" not in resp, resp + return resp["result"] + + +def test_rekey_apply_denied_by_no_write_policy(tmp_path: Path) -> None: + project = _project(tmp_path) + _seed_wlfp1_baseline(project) + server = WardlineMCPServer(root=project, allow_write=False) + for arg in ("apply", "resume", "rollback"): + result = _tool_call(server, "rekey", {arg: True}) + assert result["isError"] is True, arg + assert "write" in result["content"][0]["text"].lower() + # The default read-only probe stays allowed under the same policy. + ok = _tool_call(server, "rekey") + assert "isError" not in ok + + +def test_rekey_apply_with_filigree_url_denied_by_no_network_policy(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + project = _project(tmp_path) + _seed_wlfp1_baseline(project) + server = WardlineMCPServer(root=project, filigree_url="http://127.0.0.1:9/weft", allow_network=False) + result = _tool_call(server, "rekey", {"apply": True}) + assert result["isError"] is True + assert "network" in result["content"][0]["text"].lower() diff --git a/tests/unit/mcp/test_server_scan_jobs.py b/tests/unit/mcp/test_server_scan_jobs.py new file mode 100644 index 00000000..3427cd11 --- /dev/null +++ b/tests/unit/mcp/test_server_scan_jobs.py @@ -0,0 +1,147 @@ +import json +from pathlib import Path +from typing import Any + +import wardline.mcp.server as server_mod +from wardline.mcp.server import WardlineMCPServer + + +def _tool_call(server: WardlineMCPServer, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + } + ) + assert "error" not in resp, resp + result = resp["result"] + if result.get("isError"): + return result + return json.loads(result["content"][0]["text"]) + + +def _status(job_id: str = "a" * 32, status: str = "running") -> dict[str, Any]: + return { + "job_id": job_id, + "status": status, + "phase": "scanning", + "progress": {"steps_completed": 1, "steps_total": 4}, + "heartbeat": "2026-06-13T00:00:00Z", + "request": {}, + "artifacts": {}, + "failure_kind": None, + "error": None, + } + + +def test_scan_job_tools_are_advertised_with_capabilities(tmp_path: Path) -> None: + server = WardlineMCPServer(root=tmp_path) + resp = server.rpc.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + tools = {tool["name"]: tool for tool in resp["result"]["tools"]} + + assert {"scan_job_start", "scan_job_status", "scan_job_cancel"} <= set(tools) + assert {"read", "write"} <= set(tools["scan_job_start"]["capabilities"]) + assert tools["scan_job_status"]["capabilities"] == ["read"] + assert {"read", "write"} <= set(tools["scan_job_cancel"]["capabilities"]) + assert tools["scan_job_start"]["outputSchema"]["type"] == "object" + + +def test_scan_job_start_threads_request_to_core(tmp_path: Path, monkeypatch) -> None: + calls: list[tuple[Path, dict[str, Any], bool]] = [] + + def fake_start(root: Path, request: dict[str, Any], *, foreground: bool = False) -> dict[str, Any]: + calls.append((root, request, foreground)) + return _status() + + monkeypatch.setattr(server_mod, "start_scan_job", fake_start) + server = WardlineMCPServer(root=tmp_path, filigree_url="http://filigree.local/api/weft/scan-results") + + out = _tool_call( + server, + "scan_job_start", + { + "format": "agent-summary", + "fail_on": "ERROR", + "fail_on_unanalyzed": True, + "timeout_seconds": 12.5, + "lang": "python", + "trust_packs": ["org.pack"], + }, + ) + + assert out["job_id"] == "a" * 32 + assert calls == [ + ( + tmp_path, + { + "config": None, + "format": "agent-summary", + "output": None, + "fail_on": "ERROR", + "fail_on_unanalyzed": True, + "cache_dir": None, + "filigree_url": "http://filigree.local/api/weft/scan-results", + "local_only": False, + "filigree_max_findings_per_request": None, + "timeout_seconds": 12.5, + "lang": "python", + "new_since": None, + "trusted_packs": ["org.pack"], + "trust_local_packs": False, + "strict_defaults": False, + "trust_suppressions": False, + }, + False, + ) + ] + + +def test_scan_job_status_and_cancel_call_core(tmp_path: Path, monkeypatch) -> None: + seen: list[tuple[str, Path, str]] = [] + + def fake_status(root: Path, job_id: str) -> dict[str, Any]: + seen.append(("status", root, job_id)) + return _status(job_id=job_id) + + def fake_cancel(root: Path, job_id: str) -> dict[str, Any]: + seen.append(("cancel", root, job_id)) + return _status(job_id=job_id, status="cancelled") + + monkeypatch.setattr(server_mod, "read_scan_job_status", fake_status) + monkeypatch.setattr(server_mod, "cancel_scan_job", fake_cancel) + server = WardlineMCPServer(root=tmp_path) + + status = _tool_call(server, "scan_job_status", {"job_id": "b" * 32}) + cancel = _tool_call(server, "scan_job_cancel", {"job_id": "b" * 32}) + + assert status["status"] == "running" + assert cancel["status"] == "cancelled" + assert seen == [("status", tmp_path, "b" * 32), ("cancel", tmp_path, "b" * 32)] + + +def test_scan_job_start_respects_write_and_network_policy(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WARDLINE_FILIGREE_URL", "http://filigree.local/api/weft/scan-results") + called = False + + def fake_start(root: Path, request: dict[str, Any], *, foreground: bool = False) -> dict[str, Any]: + nonlocal called + called = True + return _status() + + monkeypatch.setattr(server_mod, "start_scan_job", fake_start) + + no_write = WardlineMCPServer(root=tmp_path, allow_write=False) + write_denied = _tool_call(no_write, "scan_job_start") + assert write_denied["isError"] is True + assert "write" in write_denied["content"][0]["text"].lower() + + no_network = WardlineMCPServer(root=tmp_path, allow_network=False) + network_denied = _tool_call(no_network, "scan_job_start") + assert network_denied["isError"] is True + assert "network" in network_denied["content"][0]["text"].lower() + + local_only = _tool_call(no_network, "scan_job_start", {"local_only": True}) + assert local_only["status"] == "running" + assert called is True diff --git a/tests/unit/mcp/test_server_scan_rust.py b/tests/unit/mcp/test_server_scan_rust.py new file mode 100644 index 00000000..713802c1 --- /dev/null +++ b/tests/unit/mcp/test_server_scan_rust.py @@ -0,0 +1,57 @@ +"""A1 (wardline-2ee1bbda82): the MCP ``scan`` tool's ``lang`` arg. + +CLI ``wardline scan --lang rust`` selects the Rust frontend; the MCP scan tool +must expose the same selector or the Rust line is unreachable over the primary +surface. The schema declares the enum, the handler plumbs it to ``run_scan``, +and a bad value surfaces as the agent-actionable ``ConfigError`` (isError +result), never a silent python-default scan. +""" + +from __future__ import annotations + +import pytest + +from wardline.core.errors import ConfigError +from wardline.mcp.server import WardlineMCPServer, _scan + +_TRUSTED = "/// @trusted(level=ASSURED)\n" +_INJECTION = _TRUSTED + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + + +def test_scan_lang_rust_finds_injection_and_trips_gate(tmp_path) -> None: + pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + response = _scan({"lang": "rust", "fail_on": "ERROR", "full": True}, root=tmp_path) + rule_ids = {e["rule_id"] for e in response["agent_summary"]["active_defects"]} + assert "RS-WL-108" in rule_ids + assert response["gate"]["tripped"] is True + assert response["gate"]["verdict"] == "FAILED" + + +def test_scan_default_lang_is_python_and_ignores_rs(tmp_path) -> None: + # Absent lang must stay byte-identical to the released behaviour: .rs files + # are not swept, the gate stays green. + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + response = _scan({"fail_on": "ERROR"}, root=tmp_path) + assert response["files_scanned"] == 0 + assert response["gate"]["tripped"] is False + + +def test_scan_unknown_lang_is_agent_actionable_config_error(tmp_path) -> None: + # ConfigError is a WardlineError -> the MCP loop maps it to an isError result + # the agent can read; the message names the valid set. + with pytest.raises(ConfigError, match="unknown language 'go'.*'python'.*'rust'"): + _scan({"lang": "go"}, root=tmp_path) + + +def test_scan_tool_schema_declares_lang_enum(tmp_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") + lang_schema = scan_tool["inputSchema"]["properties"]["lang"] + assert lang_schema["enum"] == ["python", "rust"] + assert lang_schema["type"] == "string" + # The description must carry the preview posture the CLI banner carries + # (slice coverage; no severity-override parity) — MCP agents read tool docs, + # not stderr. + assert "RS-WL-108" in lang_schema["description"] diff --git a/tests/unit/mcp/test_server_structure.py b/tests/unit/mcp/test_server_structure.py index fed60425..a5a0cc7e 100644 --- a/tests/unit/mcp/test_server_structure.py +++ b/tests/unit/mcp/test_server_structure.py @@ -25,6 +25,9 @@ def test_mcp_advertisement_snapshot() -> None: assert [tool["name"] for tool in tools["result"]["tools"]] == [ "scan", + "scan_job_start", + "scan_job_status", + "scan_job_cancel", "explain_taint", "dossier", "assure", @@ -37,6 +40,8 @@ def test_mcp_advertisement_snapshot() -> None: "baseline", "waiver_add", "fix", + "doctor", + "rekey", ] assert [resource["uri"] for resource in resources["result"]["resources"]] == [ "wardline://vocab", @@ -45,3 +50,20 @@ def test_mcp_advertisement_snapshot() -> None: "wardline://config-schema", ] assert [prompt["name"] for prompt in prompts["result"]["prompts"]] == ["wardline:loop"] + + # B1/B2: every advertised tool carries the standard MCP metadata surface — + # title (2025-03-26), a complete annotations object whose title mirrors the + # tool title, an object-typed outputSchema (2025-06-18) — alongside the + # homegrown capabilities key (mapped, not replaced). + for tool in tools["result"]["tools"]: + assert isinstance(tool["title"], str) and tool["title"], tool["name"] + assert set(tool["annotations"]) == { + "title", + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + }, tool["name"] + assert tool["annotations"]["title"] == tool["title"], tool["name"] + assert tool["outputSchema"]["type"] == "object", tool["name"] + assert isinstance(tool["capabilities"], list) and tool["capabilities"], tool["name"] diff --git a/tests/unit/mcp/test_server_suppression.py b/tests/unit/mcp/test_server_suppression.py index 8e91a675..4872c4ae 100644 --- a/tests/unit/mcp/test_server_suppression.py +++ b/tests/unit/mcp/test_server_suppression.py @@ -13,6 +13,7 @@ import pytest import yaml +from wardline.core.finding import FINGERPRINT_SCHEME from wardline.core.judge import JudgeResponse, JudgeVerdict from wardline.core.paths import baseline_path from wardline.mcp.server import WardlineMCPServer @@ -48,23 +49,42 @@ def test_mcp_scan_gate_trips_on_baselined_defect_by_default(tmp_path: Path) -> N proj = _leaky_project(tmp_path) server = WardlineMCPServer(root=proj) first = _call(server, "scan", {}) - fp = next(f["fingerprint"] for f in first["findings"] if f["rule_id"] == "PY-WL-101") + fp = next(e["fingerprint"] for e in first["agent_summary"]["active_defects"] if e["rule_id"] == "PY-WL-101") bl = baseline_path(proj) bl.parent.mkdir(parents=True, exist_ok=True) bl.write_text( - f"version: 1\nentries:\n - fingerprint: {fp}\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\n" + f"entries:\n - fingerprint: {fp}\n rule_id: PY-WL-101\n path: svc.py\n message: m\n", encoding="utf-8", ) # Default: annotated baselined, but the gate trips. default = _call(server, "scan", {"fail_on": "ERROR"}) - leak = next(f for f in default["findings"] if f["rule_id"] == "PY-WL-101") - assert leak["suppressed"] == "baselined" + leak = next(e for e in default["agent_summary"]["suppressed_findings"] if e["rule_id"] == "PY-WL-101") + assert leak["suppression_state"] == "baselined" assert default["gate"]["tripped"] is True # trust_suppressions restores the trusted-local behaviour: the gate clears. trusted = _call(server, "scan", {"fail_on": "ERROR", "trust_suppressions": True}) assert trusted["gate"]["tripped"] is False +def test_mcp_scan_rejects_string_trust_suppressions(tmp_path: Path) -> None: + # Without jsonschema the handler runs unvalidated; bool("false") is True, so a client + # sending the STRING "false" must NOT silently enable trusted-local suppression. The + # tool rejects a non-boolean (the secure default is preserved) instead of coercing. + proj = _leaky_project(tmp_path) + server = WardlineMCPServer(root=proj) + resp = server.rpc.dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "scan", "arguments": {"fail_on": "ERROR", "trust_suppressions": "false"}}, + } + ) + assert resp["result"]["isError"] is True + assert "boolean" in resp["result"]["content"][0]["text"] + + def test_baseline_optional_reason(tmp_path: Path) -> None: proj = _leaky_project(tmp_path) server = WardlineMCPServer(root=proj) @@ -104,7 +124,9 @@ def test_baseline_create_trusted_pack_matches_scan_mcp(tmp_path: Path, monkeypat server = WardlineMCPServer(root=proj) scan = _call(server, "scan", {"trust_packs": ["baseline_mcp_pack"], "trust_local_packs": True}) - assert any(f["rule_id"] == "PY-WL-901" for f in scan["findings"]) + ag = scan["agent_summary"] + shown = ag["active_defects"] + ag["suppressed_findings"] + ag["engine_facts"] + ag["informational"] + assert any(e["rule_id"] == "PY-WL-901" for e in shown) baseline = _call( server, diff --git a/tests/unit/mcp/test_server_tools.py b/tests/unit/mcp/test_server_tools.py index fc7ea68d..f7d06dc7 100644 --- a/tests/unit/mcp/test_server_tools.py +++ b/tests/unit/mcp/test_server_tools.py @@ -75,8 +75,11 @@ def test_scan_tool_returns_summary_and_gate(tmp_path: Path) -> None: root = _leaky_project(tmp_path) server = WardlineMCPServer(root=root) out = _call(server, "scan", {"fail_on": "ERROR"}) - assert "findings" in out and "summary" in out and "gate" in out - assert out["summary"]["total"] == len(out["findings"]) + assert "agent_summary" in out and "summary" in out and "gate" in out + # Finding bodies live in agent_summary now (no top-level findings array, W1). The + # whole-project buckets partition the total exactly (weft-f506e5f845). + s = out["summary"] + assert s["active"] + s["baselined"] + s["waived"] + s["judged"] + s["informational"] == s["total"] assert out["summary"]["active"] >= 1 assert out["gate"]["tripped"] is True # The agent-facing dogfood gate fields are assembled in server.py separately from @@ -88,7 +91,7 @@ def test_scan_tool_returns_summary_and_gate(tmp_path: Path) -> None: # No committed baseline here, so the migration hint must be present AND None (a # spurious fire would be a regression in the secure-default rollout signal). assert "migration_hint" in out["gate"] and out["gate"]["migration_hint"] is None - assert any(f["rule_id"] == "PY-WL-101" for f in out["findings"]) + assert any(e["rule_id"] == "PY-WL-101" for e in out["agent_summary"]["active_defects"]) def test_scan_tool_summary_includes_unanalyzed(tmp_path: Path) -> None: @@ -127,7 +130,7 @@ def test_explain_taint_success_through_mcp(tmp_path: Path) -> None: root = _leaky_project(tmp_path) server = WardlineMCPServer(root=root) scan_out = _call(server, "scan", {}) - leak = next(f for f in scan_out["findings"] if f["rule_id"] == "PY-WL-101") + leak = next(e for e in scan_out["agent_summary"]["active_defects"] if e["rule_id"] == "PY-WL-101") fp = leak["fingerprint"] resp = server.rpc.dispatch( diff --git a/tests/unit/mcp/test_server_waiver_entity.py b/tests/unit/mcp/test_server_waiver_entity.py new file mode 100644 index 00000000..610619b6 --- /dev/null +++ b/tests/unit/mcp/test_server_waiver_entity.py @@ -0,0 +1,134 @@ +"""waiver_add inline SEI-on-entry (the doctrine spine): a hand-filed waiver may bind +the code entity it suppresses, additively. entity_id (L1, opaque) is carried verbatim; +entity_symbol (L2) resolves through Loomweave; a non-resolving symbol returns +unresolved_input and writes NOTHING.""" + +from wardline.core.paths import waivers_path +from wardline.core.waivers import load_project_waivers +from wardline.mcp.server import _waiver_add + +FP = "a" * 64 + + +class SeiLoomweave: + def capabilities(self): + return {"sei": {"supported": True, "version": 1}} + + def resolve_identity(self, locator): + return { + "alive": True, + "sei": "loomweave:eid:abc", + "current_locator": locator, + "content_hash": "hash-v1", + } + + def resolve_sei(self, sei): + return {"alive": True} + + +class DownLoomweave: + def capabilities(self): + return None + + +def test_waiver_add_no_entity_is_unchanged(tmp_path): + out = _waiver_add( + {"fingerprint": FP, "reason": "validated upstream", "expires": "2026-12-31"}, + tmp_path, + ) + assert out["already_exists"] is False + assert out["entity_sei"] is None + assert out["entity_locator"] is None + assert out["binding_kind"] is None + (loaded,) = load_project_waivers(tmp_path) + assert loaded.entity_sei is None + + +def test_waiver_add_l1_entity_id_carried_verbatim(tmp_path): + out = _waiver_add( + { + "fingerprint": FP, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_id": "loomweave:eid:held", + }, + tmp_path, + loomweave=None, # L1 needs no resolve transport + ) + assert out["entity_sei"] == "loomweave:eid:held" + assert out["binding_kind"] == "sei" + (loaded,) = load_project_waivers(tmp_path) + assert loaded.entity_sei == "loomweave:eid:held" + + +def test_waiver_add_l2_symbol_resolves_to_sei(tmp_path): + out = _waiver_add( + { + "fingerprint": FP, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_symbol": "pkg.mod.leaky", + }, + tmp_path, + loomweave=SeiLoomweave(), + ) + assert out["entity_sei"] == "loomweave:eid:abc" + assert out["entity_locator"] == "python:function:pkg.mod.leaky" + assert out["binding_kind"] == "sei" + assert out["already_exists"] is False + (loaded,) = load_project_waivers(tmp_path) + assert loaded.entity_sei == "loomweave:eid:abc" + + +def test_waiver_add_l2_unresolved_writes_nothing(tmp_path): + out = _waiver_add( + { + "fingerprint": FP, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_symbol": "pkg.mod.ghost", + }, + tmp_path, + loomweave=DownLoomweave(), + ) + assert out["created"] is False + assert out["unresolved_input"]["reason_class"] == "unresolved_input" + assert out["unresolved_input"]["cause"] + assert out["unresolved_input"]["fix"] + # The honesty contract: NOTHING was written. + assert not waivers_path(tmp_path).exists() + assert load_project_waivers(tmp_path) == () + + +def test_waiver_add_l2_no_client_is_unresolved(tmp_path): + out = _waiver_add( + { + "fingerprint": FP, + "reason": "validated upstream", + "expires": "2026-12-31", + "entity_symbol": "pkg.mod.leaky", + }, + tmp_path, + loomweave=None, + ) + assert out["unresolved_input"]["reason_class"] == "unresolved_input" + assert not waivers_path(tmp_path).exists() + + +def test_waiver_add_existing_reports_stored_binding(tmp_path): + _waiver_add( + { + "fingerprint": FP, + "reason": "first", + "expires": "2026-12-31", + "entity_id": "loomweave:eid:held", + }, + tmp_path, + ) + out = _waiver_add( + {"fingerprint": FP, "reason": "second", "expires": "2027-01-01"}, + tmp_path, + ) + assert out["already_exists"] is True + assert out["entity_sei"] == "loomweave:eid:held" + assert out["binding_kind"] == "sei" diff --git a/tests/unit/mcp/test_tooling_explanation.py b/tests/unit/mcp/test_tooling_explanation.py index 20b662b6..053b532c 100644 --- a/tests/unit/mcp/test_tooling_explanation.py +++ b/tests/unit/mcp/test_tooling_explanation.py @@ -1,5 +1,4 @@ -from wardline.core.explain import TaintExplanation -from wardline.mcp.tooling import explanation_to_dict +from wardline.core.explain import TaintExplanation, explanation_to_dict def test_explanation_remediation_degrades_when_source_is_unknown() -> None: diff --git a/tests/unit/rust/__init__.py b/tests/unit/rust/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/rust/test_analyzer_protocol.py b/tests/unit/rust/test_analyzer_protocol.py new file mode 100644 index 00000000..5faaf09e --- /dev/null +++ b/tests/unit/rust/test_analyzer_protocol.py @@ -0,0 +1,197 @@ +"""WP6: ``RustAnalyzer`` satisfies the engine ``Analyzer`` protocol. + +The WP5 ``analyze_source`` is the single-string entry the rule tests drive. WP6 adds +the ``analyze(files, config, *, root)`` protocol method ``run_scan`` calls, plus the +two protocol invariants the integration depends on: + +* ``last_context`` returns the *Python-shaped* ``AnalysisContext | None`` (None in + slice-1 — the Rust-native context is incompatible and would crash the delta/SARIF + consumers; it lives on the separate ``last_rust_context`` accessor); and +* a file tree-sitter cannot fully parse yields a gate-eligible + ``WLN-ENGINE-PARSE-ERROR`` defect and contributes NO ``RS-WL-*`` findings (never + half-analyze a file), mirroring the Python pipeline's parse-error policy so it + counts toward ``ScanSummary.unanalyzed``. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.config import WardlineConfig # noqa: E402 +from wardline.rust.analyzer import RustAnalyzer # noqa: E402 + +_TRUSTED = "/// @trusted(level=ASSURED)\n" +_INJECTION = _TRUSTED + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + + +def _cfg() -> WardlineConfig: + return WardlineConfig() + + +def test_analyze_over_files_finds_injection_with_repo_relative_path(tmp_path) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "m.rs").write_text(_INJECTION, encoding="utf-8") + + analyzer = RustAnalyzer() + findings = list(analyzer.analyze([src / "m.rs"], _cfg(), root=tmp_path)) + + rs = [f for f in findings if f.rule_id.startswith("RS-WL-")] + assert [f.rule_id for f in rs] == ["RS-WL-108"] + # repo-relative POSIX path (the Filigree/Location anchor), not the absolute fs path. + assert rs[0].location.path == "src/m.rs" + # No Cargo.toml and no src/lib|main.rs anywhere -> no crate root registers, so the + # SP2 router uses the class-3 route: constant "crate" segment + #out branding + + # literal relpath stems (relpath-pure — scan-root-name-independent). Real + # crate-prefixed routes are pinned in tests/unit/rust/test_crate_roots.py. + assert rs[0].qualname == "crate.#out.src.m.run" + + +def test_last_context_is_none_but_rust_context_is_retained(tmp_path) -> None: + (tmp_path / "m.rs").write_text(_INJECTION, encoding="utf-8") + analyzer = RustAnalyzer() + list(analyzer.analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + + # Protocol conformance: the engine ``Analyzer.last_context`` is AnalysisContext|None. + # Returning the RustAnalysisContext here would fail mypy and crash the delta/SARIF + # consumers (no .project_edges, wrong field shape). Slice-1 returns None. + assert analyzer.last_context is None + # The Rust-native context stays reachable on its own accessor for introspection. + assert analyzer.last_rust_context is not None + assert analyzer.last_rust_context.triggers + + +def test_unparseable_file_emits_parse_error_defect_and_no_rs_findings(tmp_path) -> None: + # A truncated fn: tree-sitter recovers a partial tree (root_node.has_error). We must + # surface the diagnostic and NOT report findings over a half-parsed file. + (tmp_path / "broken.rs").write_text("fn f( {\n let t = std::env::var(\n", encoding="utf-8") + findings = list(RustAnalyzer().analyze([tmp_path / "broken.rs"], _cfg(), root=tmp_path)) + + parse_errors = [f for f in findings if f.rule_id == "WLN-ENGINE-PARSE-ERROR"] + assert len(parse_errors) == 1 + assert parse_errors[0].location.path == "broken.rs" + assert parse_errors[0].location.line_start == 1 + assert parse_errors[0].severity.value == "ERROR" + assert parse_errors[0].kind.value == "defect" + assert all(not f.rule_id.startswith("RS-WL-") for f in findings) + + +def test_coverage_posture_fact_reports_trust_surface(tmp_path) -> None: + # One @trusted fn + one unmarked fn: the coverage METRIC must report 1 of 2 declared, + # so a default-clean scan cannot read as a clean PASS when nothing was in the trust surface. + (tmp_path / "m.rs").write_text( + _TRUSTED + 'fn declared() {\n Command::new("ls").output();\n}\n' + 'fn undeclared() {\n Command::new("ls").output();\n}\n', + encoding="utf-8", + ) + findings = list(RustAnalyzer().analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + cov = [f for f in findings if f.rule_id == "WLN-RUST-COVERAGE"] + assert len(cov) == 1 + assert cov[0].properties["functions_total"] == 2 + assert cov[0].properties["functions_declared"] == 1 + assert cov[0].severity.value == "NONE" + + +def test_coverage_posture_flags_empty_trust_surface(tmp_path) -> None: + # A repo with ZERO @trusted markers: every finding is modulated to NONE (default-clean), + # so the scan is vacuously green. The coverage FACT must expose functions_declared == 0 + # over a non-zero function count — the anti-false-green signal. + (tmp_path / "m.rs").write_text( + 'fn a() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n', + encoding="utf-8", + ) + findings = list(RustAnalyzer().analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + assert [f for f in findings if f.rule_id.startswith("RS-WL-")] == [] # vacuously clean + (cov,) = [f for f in findings if f.rule_id == "WLN-RUST-COVERAGE"] + assert cov.properties["functions_declared"] == 0 + assert cov.properties["functions_total"] == 1 + + +def test_non_callable_entities_do_not_enter_taint_analysis(tmp_path) -> None: + # Phase 1b: the index now emits struct/enum/const/trait (etc.) rows. The taint + # path must judge CALLABLES ONLY — leaf entities have no body/trust marker, so + # feeding them to taint_for/dataflow would crash or fabricate findings, and the + # coverage METRIC's functions_total must keep counting callables only. + src = 'struct Cfg;\npub enum Mode { A }\npub const NAME: &str = "x";\npub trait Run {}\n' + _INJECTION + (tmp_path / "m.rs").write_text(src, encoding="utf-8") + findings = list(RustAnalyzer().analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + rs = [f for f in findings if f.rule_id.startswith("RS-WL-")] + assert [f.rule_id for f in rs] == ["RS-WL-108"] # exactly the one fn's finding + assert all(f.qualname is not None and f.qualname.endswith(".run") for f in rs) + (cov,) = [f for f in findings if f.rule_id == "WLN-RUST-COVERAGE"] + assert cov.properties["functions_total"] == 1 # callables only, not 5 + assert cov.properties["functions_declared"] == 1 + + +def test_fn_struct_same_name_keeps_both_entities_and_counts_one_callable(tmp_path) -> None: + # Keystone panel: `fn S` and `struct S` share a qualname (the per-kind twin counter + # deliberately adds no @cfg suffix across kinds), so a context map keyed on qualname + # alone silently drops one of them at dict-ification. The context must keep BOTH + # (keys are kind-disambiguated entity ids) and the coverage denominator counts the + # ONE callable — computed from the entity list, never the (collapsible) mapping. + src = ( + "struct S { x: i32 }\n" + + _TRUSTED + + 'fn S() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + ) + (tmp_path / "m.rs").write_text(src, encoding="utf-8") + analyzer = RustAnalyzer() + findings = list(analyzer.analyze([tmp_path / "m.rs"], _cfg(), root=tmp_path)) + + rs = [f for f in findings if f.rule_id.startswith("RS-WL-")] + assert [f.rule_id for f in rs] == ["RS-WL-108"] # the fn IS exercised + ctx = analyzer.last_rust_context + assert ctx is not None + qual = "crate.#out.m.S" # class-3 route: constant crate segment + #out branding + # BOTH entities survive, addressable by their kind-disambiguated entity-id keys. + assert f"rust:struct:{qual}" in ctx.entities + assert f"rust:function:{qual}" in ctx.entities + assert {(e.kind, e.qualname) for e in ctx.entities.values()} >= {("struct", qual), ("function", qual)} + (cov,) = [f for f in findings if f.rule_id == "WLN-RUST-COVERAGE"] + assert cov.properties["functions_total"] == 1 # one callable, not two, not zero + assert cov.properties["functions_declared"] == 1 + + +def test_clean_file_yields_no_findings(tmp_path) -> None: + (tmp_path / "clean.rs").write_text( + _TRUSTED + 'fn run() {\n Command::new("ls").arg("-la").output();\n}\n', encoding="utf-8" + ) + findings = list(RustAnalyzer().analyze([tmp_path / "clean.rs"], _cfg(), root=tmp_path)) + assert [f for f in findings if f.rule_id.startswith("RS-WL-")] == [] + + +def test_multiple_files_accumulate_findings(tmp_path) -> None: + (tmp_path / "a.rs").write_text(_INJECTION, encoding="utf-8") + (tmp_path / "b.rs").write_text(_INJECTION.replace("fn run()", "fn other()"), encoding="utf-8") + findings = list(RustAnalyzer().analyze([tmp_path / "a.rs", tmp_path / "b.rs"], _cfg(), root=tmp_path)) + rs = [f for f in findings if f.rule_id.startswith("RS-WL-")] + assert sorted(f.location.path for f in rs) == ["a.rs", "b.rs"] + + +def test_one_crashing_file_is_isolated_and_does_not_lose_other_findings(tmp_path) -> None: + # A clean-parsing but pathologically deep expression overflows the recursive dataflow + # walk (RecursionError). Per-file isolation must degrade THAT file to a counted + # WLN-ENGINE-FILE-FAILED defect and still emit the OTHER file's real RS-WL-108 — + # never abort the whole scan (the engine's per-function isolation, mirrored per-file). + deep_expr = "+".join(["x"] * 6000) # nested binary_expression depth >> default recursionlimit + deep = tmp_path / "deep.rs" + deep.write_text( + f"/// @trusted(level=ASSURED)\nfn boom() {{\n let t = {deep_expr};\n Command::new(t).output();\n}}\n", + encoding="utf-8", + ) + inject = tmp_path / "inject.rs" + inject.write_text(_INJECTION, encoding="utf-8") + + # deep.rs FIRST so the crash precedes the clean file — isolation must let inject.rs through. + findings = list(RustAnalyzer().analyze([deep, inject], _cfg(), root=tmp_path)) + + file_failed = [f for f in findings if f.rule_id == "WLN-ENGINE-FILE-FAILED"] + assert len(file_failed) == 1 and file_failed[0].location.path == "deep.rs" + assert file_failed[0].location.line_start == 1 + assert file_failed[0].severity.value == "ERROR" + assert file_failed[0].kind.value == "defect" + # The other file's real finding survived the neighbour's crash. + survivors = [f for f in findings if f.rule_id == "RS-WL-108"] + assert len(survivors) == 1 and survivors[0].location.path == "inject.rs" diff --git a/tests/unit/rust/test_corpus.py b/tests/unit/rust/test_corpus.py new file mode 100644 index 00000000..31979d9f --- /dev/null +++ b/tests/unit/rust/test_corpus.py @@ -0,0 +1,68 @@ +"""WP6 corpus gate: the dense positive file fires exactly its intended sinks (≤5% FP, +target 0) and the clean file is hard-zero. Both must parse without ``has_error`` — a +silent PARSE-ERROR would masquerade as "no findings" and pass a naive count check. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.rust.analyzer import RustAnalyzer # noqa: E402 +from wardline.rust.parse import has_errors, parse_rust # noqa: E402 + +_CORPUS = Path(__file__).resolve().parents[2] / "corpus" / "rust" + + +def _rs_findings(text: str, module: str): + findings = RustAnalyzer().analyze_source(text, module=module, path=f"{module}.rs") + return [f for f in findings if f.rule_id.startswith("RS-WL-")] + + +def test_corpus_files_parse_without_error() -> None: + # Guard: if a corpus construct trips tree-sitter-rust 0.24.2, the item walk drops it + # and "0 findings" would be a false all-clear, not a real result. + for name in ("command_sink.rs", "clean_commands.rs"): + text = (_CORPUS / name).read_text(encoding="utf-8") + assert not has_errors(parse_rust(text)), f"{name} must parse cleanly" + + +# The 9 intended sink functions (by qualname leaf), split by rule. The 3 benign +# neighbours (benign_all_literal / benign_nonshell_tainted_arg / benign_rebound_to_clean) +# must NOT appear — they are the false-positive probes. +_EXPECTED_108 = frozenset( + { + "sink_env_var_output", + "sink_env_var_os_status", + "sink_fs_read_to_string_try", + "sink_fs_read_await", + "sink_return_position", + "sink_stepwise", + } +) +_EXPECTED_112 = frozenset({"sink_sh_dash_c", "sink_bin_bash_dash_c", "sink_powershell_command"}) + + +def test_dense_positive_corpus_fires_exactly_the_intended_sinks() -> None: + text = (_CORPUS / "command_sink.rs").read_text(encoding="utf-8") + findings = _rs_findings(text, "corpus.command_sink") + + # Per-FUNCTION attribution, not just aggregate counts: assert the exact SET of functions + # that fired each rule. This catches a compensating double-regression (a benign fn wrongly + # firing while a real sink stops) that an aggregate {108:6, 112:3} count would mask — and + # subsumes the "0 false positives over 12 @trusted fns" property (any benign_* fire fails). + fired_108 = {f.qualname.rsplit(".", 1)[-1] for f in findings if f.rule_id == "RS-WL-108"} + fired_112 = {f.qualname.rsplit(".", 1)[-1] for f in findings if f.rule_id == "RS-WL-112"} + assert fired_108 == _EXPECTED_108 + assert fired_112 == _EXPECTED_112 + # No finding attributes to any benign probe (the explicit ≤5% → 0% FP floor). + benign = {"benign_all_literal", "benign_nonshell_tainted_arg", "benign_rebound_to_clean"} + assert {f.qualname.rsplit(".", 1)[-1] for f in findings} & benign == set() + + +def test_clean_corpus_is_hard_zero() -> None: + text = (_CORPUS / "clean_commands.rs").read_text(encoding="utf-8") + assert _rs_findings(text, "corpus.clean_commands") == [] diff --git a/tests/unit/rust/test_crate_roots.py b/tests/unit/rust/test_crate_roots.py new file mode 100644 index 00000000..bdbe09a3 --- /dev/null +++ b/tests/unit/rust/test_crate_roots.py @@ -0,0 +1,240 @@ +"""Task 4 (SP2): Cargo.toml crate-root discovery + crate-prefixed module routes. + +``wardline.rust.crate_roots`` mirrors the loomweave oracle +(``crates/loomweave-plugin-rust/src/crate_roots.rs``) exactly: + +* **Two-branch registration:** a dir is a crate root iff (a) its ``Cargo.toml`` + parses as TOML AND ``[package].name`` is a string -> that name ``-``->``_`` + normalised; ELSE (b) ``src/lib.rs`` or ``src/main.rs`` exists -> the directory + name normalised. A virtual workspace root (no package name, no src/lib|main.rs) + registers NOTHING — member crates own their files outright. +* **Walk:** symlinked directories are never followed (out-of-tree escape / cycle). +* **Lookup:** file -> crate by longest path-prefix match. + +The COVERAGE tests pin the panel's must-fix: wardline does NOT mirror loomweave's +``emittable_scope`` (scope.rs:21-32) for scan coverage — out-of-src files +(tests/, build.rs) and no-Cargo trees keep producing RS-WL findings via the +documented wardline-local fallback route; only the *route shape* differs. +""" + +from __future__ import annotations + +import os +from functools import partial +from pathlib import Path + +import pytest + +from wardline.rust.crate_roots import discover_crate_roots + +_TRUSTED = "/// @trusted(level=ASSURED)\n" +_INJECTION = _TRUSTED + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + + +def _write_crate(root: Path, rel: str, manifest: str, *, lib: bool = True) -> Path: + crate = root / rel + (crate / "src").mkdir(parents=True) + (crate / "Cargo.toml").write_text(manifest, encoding="utf-8") + (crate / "src" / ("lib.rs" if lib else "main.rs")).write_text("pub fn f() {}\n", encoding="utf-8") + return crate + + +# --------------------------------------------------------------------------- # +# Registration + lookup (pure discovery — mirrors the oracle's branches) +# --------------------------------------------------------------------------- # + + +def test_single_crate_registers_normalised_package_name(tmp_path: Path) -> None: + crate = _write_crate(tmp_path, "app", '[package]\nname = "my-app"\nversion = "0.1.0"\n') + roots = discover_crate_roots(tmp_path) + assert roots.crate_name_for(crate / "src" / "lib.rs") == "my_app" # - -> _ normalised + assert roots.crate_dir_for(crate / "src" / "lib.rs") == crate + + +def test_virtual_workspace_root_registers_nothing(tmp_path: Path) -> None: + # A [workspace]-only manifest with no src/lib|main.rs is NOT a crate root — + # member crates own their files outright (oracle: no package.name -> fall + # through; no src roots -> nothing registered). + (tmp_path / "Cargo.toml").write_text('[workspace]\nmembers = ["m1", "m2"]\n', encoding="utf-8") + m1 = _write_crate(tmp_path, "m1", '[package]\nname = "m-one"\n') + m2 = _write_crate(tmp_path, "m2", '[package]\nname = "m_two"\n', lib=False) + roots = discover_crate_roots(tmp_path) + assert roots.crate_dir_for(tmp_path / "stray.rs") is None # the workspace root owns nothing + assert roots.crate_name_for(m1 / "src" / "lib.rs") == "m_one" + assert roots.crate_name_for(m2 / "src" / "main.rs") == "m_two" + + +def test_nested_crates_resolve_by_longest_prefix(tmp_path: Path) -> None: + outer = _write_crate(tmp_path, "outer", '[package]\nname = "outer"\n') + inner = _write_crate(tmp_path, "outer/inner", '[package]\nname = "inner"\n', lib=False) + roots = discover_crate_roots(tmp_path) + assert roots.crate_name_for(outer / "src" / "lib.rs") == "outer" + assert roots.crate_name_for(inner / "src" / "main.rs") == "inner" # longest prefix wins + assert roots.crate_dir_for(inner / "src" / "main.rs") == inner + + +def test_name_workspace_true_with_src_falls_back_to_dir_name(tmp_path: Path) -> None: + # `name.workspace = true` parses as a TABLE, not a string -> branch (a) falls + # through; src/lib.rs exists -> branch (b) dir-name fallback (normalised). + crate = _write_crate(tmp_path, "member-a", "[package]\nname.workspace = true\n") + roots = discover_crate_roots(tmp_path) + assert roots.crate_name_for(crate / "src" / "lib.rs") == "member_a" + + +def test_package_less_manifest_without_src_is_not_a_root(tmp_path: Path) -> None: + nodir = tmp_path / "meta" + nodir.mkdir() + (nodir / "Cargo.toml").write_text('[package]\nversion = "0.1.0"\n', encoding="utf-8") # no name + roots = discover_crate_roots(tmp_path) + assert roots.crate_name_for(nodir / "x.rs") is None + assert roots.crate_dir_for(nodir / "x.rs") is None + + +def test_unparseable_manifest_falls_back_to_dir_name(tmp_path: Path) -> None: + crate = _write_crate(tmp_path, "broken", "this = = is not toml [\n") + roots = discover_crate_roots(tmp_path) + assert roots.crate_name_for(crate / "src" / "lib.rs") == "broken" + + +@pytest.mark.skipif(os.name != "posix", reason="symlinks: posix-only fixture") +def test_symlinked_external_crate_dir_is_not_registered(tmp_path: Path) -> None: + # Mirrors loomweave's does_not_register_crate_roots_reached_through_symlinked_dirs: + # a symlinked dir is an out-of-tree ESCAPE (an outside Cargo.toml would mint an + # outside crate root) or a CYCLE (re-registration under an aliased path). + proj = tmp_path / "proj" + real = _write_crate(proj, "c", '[package]\nname = "c_crate"\n') + outside = _write_crate(tmp_path, "outside", '[package]\nname = "evil_crate"\n') + (proj / "evil").symlink_to(outside) + (proj / "loop").symlink_to(proj) # the walk must RETURN, not recurse forever + + roots = discover_crate_roots(proj) + assert roots.crate_name_for(real / "src" / "lib.rs") == "c_crate" + assert roots.crate_name_for(proj / "evil" / "src" / "lib.rs") is None, ( + "out-of-tree crate reached via symlink must not be a registered root" + ) + + +# --------------------------------------------------------------------------- # +# Module routing through the analyzer (the three file classes) + coverage +# preservation. These need the tree-sitter extra (they drive RustAnalyzer). +# --------------------------------------------------------------------------- # + + +def _analyzer_module(): # noqa: ANN202 - dynamic import behind importorskip + pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + from wardline.rust import analyzer + + return analyzer + + +def test_in_src_files_get_the_oracle_crate_prefixed_route(tmp_path: Path) -> None: + analyzer = _analyzer_module() + crate = _write_crate(tmp_path, "app", '[package]\nname = "my-app"\n') + (crate / "src" / "a").mkdir() + (crate / "src" / "a" / "b.rs").write_text("", encoding="utf-8") + (crate / "src" / "a" / "mod.rs").write_text("", encoding="utf-8") + roots = discover_crate_roots(tmp_path) + + route = partial(analyzer._module_for, overlays={}) # noqa: SLF001 - the unit under test (overlay-free: the filesystem default) + assert route(crate / "src" / "lib.rs", tmp_path, roots) == "my_app" + assert route(crate / "src" / "a" / "b.rs", tmp_path, roots) == "my_app.a.b" + assert route(crate / "src" / "a" / "mod.rs", tmp_path, roots) == "my_app.a" + + +def test_workspace_member_files_route_to_their_own_crates(tmp_path: Path) -> None: + analyzer = _analyzer_module() + (tmp_path / "Cargo.toml").write_text('[workspace]\nmembers = ["m1", "m2"]\n', encoding="utf-8") + m1 = _write_crate(tmp_path, "m1", '[package]\nname = "m-one"\n') + m2 = _write_crate(tmp_path, "m2", '[package]\nname = "m_two"\n', lib=False) + roots = discover_crate_roots(tmp_path) + + route = partial(analyzer._module_for, overlays={}) # noqa: SLF001 + assert route(m1 / "src" / "lib.rs", tmp_path, roots) == "m_one" + assert route(m2 / "src" / "main.rs", tmp_path, roots) == "m_two" + + +def test_out_of_src_files_get_the_out_branded_crate_route(tmp_path: Path) -> None: + # Class 2: under a crate root but OUTSIDE its src/ — loomweave's emittable_scope + # emits NOTHING here; wardline routes mechanically from the crate dir under the + # real crate name + the reserved `#out` segment (no cross-tool conformance claim; + # `#` appears only inside loomweave's `impl#<...>` discriminators, so the route + # can never collide with a class-1/loomweave locator). ALL stems are literal — + # no main/lib/mod collapsing. + analyzer = _analyzer_module() + crate = _write_crate(tmp_path, "c", '[package]\nname = "c-app"\n') + (crate / "tests").mkdir() + (crate / "tests" / "integration.rs").write_text("", encoding="utf-8") + (crate / "build.rs").write_text("", encoding="utf-8") + roots = discover_crate_roots(tmp_path) + + route = partial(analyzer._module_for, overlays={}) # noqa: SLF001 + assert route(crate / "build.rs", tmp_path, roots) == "c_app.#out.build" + assert route(crate / "tests" / "integration.rs", tmp_path, roots) == "c_app.#out.tests.integration" + + +def test_class2_route_cannot_collide_with_an_in_src_twin(tmp_path: Path) -> None: + # The keystone panel's collision repro, inverted: /tests/integration.rs + # (class 2) and /src/tests/integration.rs (class 1) used to mint the SAME + # qualname prefix (`c_app.tests.integration`). The `#out` branding separates them. + analyzer = _analyzer_module() + crate = _write_crate(tmp_path, "c", '[package]\nname = "c-app"\n') + (crate / "src" / "tests").mkdir() + (crate / "src" / "tests" / "integration.rs").write_text("", encoding="utf-8") + (crate / "tests").mkdir() + (crate / "tests" / "integration.rs").write_text("", encoding="utf-8") + roots = discover_crate_roots(tmp_path) + + route = partial(analyzer._module_for, overlays={}) # noqa: SLF001 + in_src = route(crate / "src" / "tests" / "integration.rs", tmp_path, roots) + out_of_src = route(crate / "tests" / "integration.rs", tmp_path, roots) + assert in_src == "c_app.tests.integration" # class 1: the conformance-bearing route + assert out_of_src == "c_app.#out.tests.integration" # class 2: #out-branded + assert in_src != out_of_src + + +def test_no_crate_files_get_the_relpath_pure_constant_crate_route(tmp_path: Path) -> None: + # Class 3: no owning crate root anywhere. The crate segment is the CONSTANT + # "crate" (cargo forbids the keyword as a package name, so it cannot collide + # with class 1) + the `#out` branding + literal relpath stems — relpath-pure, + # scan-root-name-INDEPENDENT (renaming the root directory does not rekey). + analyzer = _analyzer_module() + (tmp_path / "src").mkdir() + (tmp_path / "src" / "m.rs").write_text("", encoding="utf-8") + roots = discover_crate_roots(tmp_path) + + assert roots.crate_dir_for(tmp_path / "src" / "m.rs") is None # no lib.rs/main.rs -> no root + got = analyzer._module_for(tmp_path / "src" / "m.rs", tmp_path, roots, {}) # noqa: SLF001 + assert got == "crate.#out.src.m" # NOT f"{tmp_path.name}..." — root-name-independent + + +def test_scan_coverage_is_not_narrowed_to_emittable_scope(tmp_path: Path) -> None: + # The panel's must-fix, end-to-end: build.rs, tests/, and a bare no-Cargo tree + # all still produce RS-WL findings (loomweave EXCLUDES them from its federation + # entity surface; wardline keeps scanning them via the fallback route). + analyzer = _analyzer_module() + from wardline.core.config import WardlineConfig + + crate = _write_crate(tmp_path, "c", '[package]\nname = "c-app"\n') + (crate / "src" / "cmd.rs").write_text(_INJECTION, encoding="utf-8") + (crate / "build.rs").write_text(_INJECTION, encoding="utf-8") + (crate / "tests").mkdir() + (crate / "tests" / "integration.rs").write_text(_INJECTION, encoding="utf-8") + bare = tmp_path / "bare" + bare.mkdir() + (bare / "m.rs").write_text(_INJECTION, encoding="utf-8") + + files = [crate / "src" / "cmd.rs", crate / "build.rs", crate / "tests" / "integration.rs", bare / "m.rs"] + findings = list(analyzer.RustAnalyzer().analyze(files, WardlineConfig(), root=tmp_path)) + + rs = [f for f in findings if f.rule_id == "RS-WL-108"] + assert sorted(f.location.path for f in rs) == [ + "bare/m.rs", + "c/build.rs", + "c/src/cmd.rs", + "c/tests/integration.rs", + ] + by_path = {f.location.path: f.qualname for f in rs} + assert by_path["c/src/cmd.rs"] == "c_app.cmd.run" # class 1: the oracle route + assert by_path["c/build.rs"] == "c_app.#out.build.run" # class 2: #out-branded crate route + assert by_path["c/tests/integration.rs"] == "c_app.#out.tests.integration.run" + assert by_path["bare/m.rs"] == "crate.#out.bare.m.run" # class 3: relpath-pure constant crate diff --git a/tests/unit/rust/test_dataflow.py b/tests/unit/rust/test_dataflow.py new file mode 100644 index 00000000..3c796a54 --- /dev/null +++ b/tests/unit/rust/test_dataflow.py @@ -0,0 +1,198 @@ +"""WP4: builder-dataflow L2 — Command receiver tracking + local string taint. + +Drives ``analyze_command_dataflow`` over hand-built specimens and asserts the per-trigger +``CommandTrigger`` state (program literal/taint, shell-flag, arg taints keyed by NodeId). +This is the genuinely-new core: taint flows ONLY from known sources / tainted locals +(default-clean, a finding-producer flags provable taint, not fail-closed unknowns), the +``format!`` heuristic matches direct interpolation-arg tokens only, and ``.args`` is an +opaque vec. Specimens seed taint with ``std::env::var(...).unwrap()`` (a vocab source). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.taints import RAW_ZONE # noqa: E402 +from wardline.rust.dataflow import CommandTrigger, analyze_command_dataflow # noqa: E402 +from wardline.rust.nodeid import mint_node_ids # noqa: E402 +from wardline.rust.parse import parse_rust # noqa: E402 + +if TYPE_CHECKING: + from collections.abc import Sequence + +_SEED = ' let t = std::env::var("X").unwrap();\n' + + +def _triggers(body_src: str) -> Sequence[CommandTrigger]: + src = "fn f() {\n" + body_src + "}\n" + tree = parse_rust(src) + nmap = mint_node_ids(tree) + fn = next(c for c in tree.root_node.children if c.type == "function_item") + body = fn.child_by_field_name("body") + assert body is not None + return analyze_command_dataflow(body, nmap) + + +def _has_raw_arg(trig: CommandTrigger) -> bool: + return any(taint in RAW_ZONE for _node_id, taint in trig.arg_taints) + + +# --------------------------------------------------------------------------- # +# Positives +# --------------------------------------------------------------------------- # + + +def test_stepwise_shell_builder_tracks_program_flag_and_arg_taint() -> None: + (trig,) = _triggers( + _SEED + ' let mut c = Command::new("sh");\n c.arg("-c");\n c.arg(t);\n c.output();\n' + ) + assert trig.program_literal == "sh" + assert trig.shell_flag_seen is True + assert _has_raw_arg(trig) + + +def test_program_injection_marks_program_taint_raw() -> None: + (trig,) = _triggers(_SEED + " Command::new(t).output();\n") + assert trig.program_taint in RAW_ZONE + assert trig.program_literal is None # not a string literal + + +def test_format_string_carries_taint_to_the_shell_arg() -> None: + (trig,) = _triggers(_SEED + ' let s = format!("rm {}", t);\n Command::new("sh").arg("-c").arg(s).output();\n') + assert trig.program_literal == "sh" + assert trig.shell_flag_seen is True + assert _has_raw_arg(trig) + + +def test_two_hop_format_propagation() -> None: + (trig,) = _triggers( + _SEED + + ' let s = format!("{}", t);\n let s2 = format!("{}", s);\n' + + ' Command::new("sh").arg("-c").arg(s2).output();\n' + ) + assert _has_raw_arg(trig) + + +# --------------------------------------------------------------------------- # +# Negatives — the FP guards (the rule decides; the dataflow state must be right) +# --------------------------------------------------------------------------- # + + +def test_non_shell_program_has_no_shell_flag() -> None: + (trig,) = _triggers(_SEED + ' Command::new("ls").arg(t).output();\n') + assert trig.program_literal == "ls" + assert trig.shell_flag_seen is False + assert _has_raw_arg(trig) # the arg IS tainted; the rule won't fire (non-shell, no -c) + + +def test_shell_without_dash_c_sees_no_shell_flag() -> None: + (trig,) = _triggers(_SEED + ' Command::new("sh").arg(t).output();\n') + assert trig.program_literal == "sh" + assert trig.shell_flag_seen is False # RS-WL-112 must not fire without the -c flag + + +def test_dot_args_is_an_opaque_vec_no_arg_taint() -> None: + (trig,) = _triggers(_SEED + ' Command::new("ls").args(t).output();\n') + assert trig.shell_flag_seen is False + assert not _has_raw_arg(trig) # .args is opaque (a vec) — not introspected in slice 1 + + +def test_sanitizer_is_an_accepted_bounded_fp() -> None: + # The sanitizer is invisible to the dataflow, so the arg stays tainted (a bounded + # FP the corpus measures against the <=5% gate). Pins current behavior. + (trig,) = _triggers(_SEED + ' Command::new("sh").arg("-c").arg(format!("echo {}", sanitize(t))).output();\n') + assert _has_raw_arg(trig) + + +def test_clean_literal_format_propagates_no_taint() -> None: + (trig,) = _triggers( + _SEED + ' let s = format!("rm {}", "ls");\n Command::new("sh").arg("-c").arg(s).output();\n' + ) + assert not _has_raw_arg(trig) + + +def test_captured_identifier_format_is_a_documented_fn() -> None: + # format!("rm {t}") has no explicit interpolation arg token — the captured `{t}` is + # invisible to the direct-arg heuristic, so `s` stays clean (documented FN). + (trig,) = _triggers(_SEED + ' let s = format!("rm {t}");\n Command::new("sh").arg("-c").arg(s).output();\n') + assert not _has_raw_arg(trig) + + +def test_variable_bound_shell_name_is_a_documented_fn() -> None: + # `let s = "sh"; Command::new(s)` — the program is an identifier, not a string + # literal, so program_literal is None and RS-WL-112 cannot see it is a shell (FN). + (trig,) = _triggers(_SEED + ' let s = "sh";\n Command::new(s).arg("-c").arg(t).output();\n') + assert trig.program_literal is None + assert _has_raw_arg(trig) # the arg is still tainted; only the shell-ness is lost + + +def test_let_rebind_clears_a_stale_command_builder() -> None: + # A shadowing `let` re-binds the name to a NON-command; the old `_CmdAccum` must not + # survive and have a later `.output()` falsely attributed to it. `_let` mirrors the + # taint clear (`_local_taints.pop`) and must mirror the builder clear too — otherwise + # `c.output()` emits a phantom trigger carrying the FIRST binding's program taint, + # surfacing as a false RS-WL-108 that fires at ERROR and trips the gate. + trigs = _triggers( + _SEED + + " let c = Command::new(t);\n" # c is a tainted-program builder... + + " let c = make_thing();\n" # ...then shadowed by a non-command + + " c.output();\n" # this terminal must NOT reconstruct the stale builder + ) + assert list(trigs) == [] # the rebound `c` is not a tracked command -> zero triggers + + +def test_plain_command_builder_still_fires_after_the_rebind_fix() -> None: + # No-regression guard: a builder bound once and never shadowed still reconstructs. + (trig,) = _triggers(_SEED + " let c = Command::new(t);\n c.output();\n") + assert trig.program_taint in RAW_ZONE + + +# --------------------------------------------------------------------------- # +# Format-family macros — write!/writeln!/format_args! value-taint (issue 8a34187941) +# --------------------------------------------------------------------------- # + + +def test_write_macro_value_taint_propagates() -> None: + # write!(dst, "fmt", t) — value-taint = worst over the format args (the writer `dst` is + # the destination, not a contributor). Modelled like format!'s result. + (trig,) = _triggers( + _SEED + ' let s = write!(dst, "rm {}", t);\n Command::new("sh").arg("-c").arg(s).output();\n' + ) + assert _has_raw_arg(trig) + + +def test_writeln_macro_value_taint_propagates() -> None: + (trig,) = _triggers( + _SEED + ' let s = writeln!(dst, "rm {}", t);\n Command::new("sh").arg("-c").arg(s).output();\n' + ) + assert _has_raw_arg(trig) + + +def test_format_args_macro_value_taint_propagates() -> None: + # format_args! behaves like format! (no writer) and is the genuinely-realistic case: + # it returns `Arguments` consumed format-like. + (trig,) = _triggers( + _SEED + ' let s = format_args!("rm {}", t);\n Command::new("sh").arg("-c").arg(s).output();\n' + ) + assert _has_raw_arg(trig) + + +def test_write_writer_arg_is_not_a_taint_contributor() -> None: + # The leading writer of write!/writeln! is the destination, not a value contributor: + # a tainted writer with clean format args must NOT taint the formatted value. + (trig,) = _triggers( + _SEED + ' let s = write!(t, "rm {}", "ls");\n Command::new("sh").arg("-c").arg(s).output();\n' + ) + assert not _has_raw_arg(trig) + + +def test_clean_format_args_propagates_no_taint() -> None: + # No-FP guard: format_args! over only literals stays clean. + (trig,) = _triggers( + _SEED + ' let s = format_args!("rm {}", "ls");\n Command::new("sh").arg("-c").arg(s).output();\n' + ) + assert not _has_raw_arg(trig) diff --git a/tests/unit/rust/test_edges.py b/tests/unit/rust/test_edges.py new file mode 100644 index 00000000..1e2195f9 --- /dev/null +++ b/tests/unit/rust/test_edges.py @@ -0,0 +1,400 @@ +"""Task 5: anchored ``imports``/``implements`` edges (changeset §6). + +The shared corpus is entity-only, so the contract for edges is changeset +``docs/integration/2026-06-09-loomweave-rust-qualname-phase1b-changeset.md`` §6 plus +the loomweave oracle source for what §6 leaves open (citations pinned per test): + +* ``resolve.rs`` — ``Resolution`` (:10-20: Ambiguous always carries a REAL id, never + null), ``resolve_use_path`` (:39-50: glob ``a::*`` → Ambiguous(in-project module id) + else External), ``resolve_non_glob`` (:106-125: attempt-1 as-is, attempt-2 crate-root + fallback ONLY for a bare single segment — a multi-segment miss stays External, the H5 + guard), ``resolve_ids`` (:127-139: 0 → External, 1 → Resolved, >1 → Ambiguous(first + by sorted order)), ``normalize_path`` (:141-168: ``crate``/``self`` map to the crate; + ``super::`` is a DELIBERATE 1b deferral upstream — see the super tests below for + wardline's plan-locked extension). +* ``extract.rs`` — ``emit_use_edges``/``collect_use_leaves`` (:600-667: groups fan out, + ``as`` aliases resolve the REAL path, a ``self`` group leaf → the prefix module, span + = the whole ``use`` statement), the negative-impl bang guard + one-edge-per-impl- + entity ``seen_impl_ids`` gate (:707-748), ``trait_path_for_lookup`` (:780-794: trait + generic args STRIPPED for lookup). + +Edges are anchored (byte spans) and resolved-or-dropped; ``confidence`` is only ever +``resolved`` or ``ambiguous`` — never ``inferred``. + +Fixtures are real ``tmp_path`` crates with a ``Cargo.toml`` so the module routes come +from the SP2 whole-tree pass (``discover_crate_roots`` + ``rust_module_route``), not a +hand-typed module string. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.rust import qualname as q # noqa: E402 +from wardline.rust.crate_roots import discover_crate_roots # noqa: E402 +from wardline.rust.edges import ( # noqa: E402 + RustEdge, + RustParsedFile, + discover_rust_edges, + index_rust_file, +) + +_MANIFEST = '[package]\nname = "demo"\nversion = "0.1.0"\n' + + +def _parse_crate(tmp_path: Path, files: dict[str, str]) -> list[RustParsedFile]: + """Write a real crate under ``tmp_path`` and produce the per-file parse products. + + Routes are REAL: ``discover_crate_roots`` reads the ``Cargo.toml`` package name and + ``rust_module_route`` derives each file's module — the same SP2 pass the analyzer + runs, so the edge tests exercise crate-prefixed cross-file resolution end to end. + """ + crate = tmp_path / "app" + (crate / "src").mkdir(parents=True, exist_ok=True) + (crate / "Cargo.toml").write_text(_MANIFEST, encoding="utf-8") + for rel, source in files.items(): + target = crate / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + roots = discover_crate_roots(tmp_path) + parsed: list[RustParsedFile] = [] + for rel in files: + file = (crate / rel).resolve() + crate_dir = roots.crate_dir_for(file) + crate_name = roots.crate_name_for(file) + assert crate_dir is not None and crate_name is not None + module = q.rust_module_route(crate=crate_name, src_root=str(crate_dir / "src"), file=str(file)) + parsed.append(index_rust_file(files[rel], module=module, path=rel)) + return parsed + + +def _edges(tmp_path: Path, files: dict[str, str]) -> list[RustEdge]: + return discover_rust_edges(_parse_crate(tmp_path, files)) + + +def _span(source: str, fragment: str) -> tuple[int, int]: + start = source.encode("utf-8").find(fragment.encode("utf-8")) + assert start >= 0, f"fixture fragment {fragment!r} not found" + return start, start + len(fragment.encode("utf-8")) + + +# --------------------------------------------------------------------------- # +# Base case: cross-file resolved imports + implements, spans pinned +# --------------------------------------------------------------------------- # + + +def test_base_two_file_resolved_imports_and_implements(tmp_path: Path) -> None: + """One ``use crate::Greet`` + one ``impl Greet for Foo`` across two files. + + §6: ``imports`` from the enclosing MODULE entity, span = the use statement; + ``implements`` from the trait-IMPL entity, span = the implemented-trait path node + only (extract.rs:746 ``source_range_of(trait_path)``). The bare ``Greet`` trait + name resolves via the crate-root-relative bare-segment fallback + (resolve.rs:106-125 attempt 2). + """ + foo_rs = "use crate::Greet;\nstruct Foo;\nimpl Greet for Foo {\n fn hi(&self) {}\n}\n" + edges = _edges( + tmp_path, + { + "src/lib.rs": "pub trait Greet {}\npub mod foo;\n", + "src/foo.rs": foo_rs, + }, + ) + imports = [e for e in edges if e.kind == "imports"] + implements = [e for e in edges if e.kind == "implements"] + assert len(imports) == 1 + assert len(implements) == 1 + + imp = imports[0] + assert imp.from_id == "rust:module:demo.foo" + assert imp.to_id == "rust:trait:demo.Greet" + assert imp.confidence == "resolved" + assert (imp.source_byte_start, imp.source_byte_end) == _span(foo_rs, "use crate::Greet;") + + impl = implements[0] + assert impl.from_id == "rust:impl:demo.foo.Foo.impl[Greet]" + assert impl.to_id == "rust:trait:demo.Greet" + assert impl.confidence == "resolved" + # span anchors the implemented-trait path node ONLY — the `Greet` of the impl + # header, not the whole impl block and not the use statement. + start, end = impl.source_byte_start, impl.source_byte_end + assert foo_rs.encode("utf-8")[start:end] == b"Greet" + assert start > foo_rs.encode("utf-8").find(b"impl ") + + +def test_inherent_impl_emits_no_implements_edge(tmp_path: Path) -> None: + edges = _edges(tmp_path, {"src/lib.rs": "struct Foo;\nimpl Foo {\n fn go(&self) {}\n}\n"}) + assert [e for e in edges if e.kind == "implements"] == [] + + +# --------------------------------------------------------------------------- # +# super:: resolution (wardline extension — plan-locked; upstream 1b defers) +# --------------------------------------------------------------------------- # + + +def test_super_and_nested_super_resolve_against_module_routes(tmp_path: Path) -> None: + """``super::X`` from ``demo.a.b`` → ``demo.a.X``; ``super::super::Y`` → ``demo.Y``. + + Upstream 1b deliberately defers ``super::`` to External (resolve.rs:141-168: the + defining-module path is not threaded through). Wardline DOES thread the enclosing + module (it is the imports ``from_id``), so it implements the semantics that same + oracle comment names as correct ("``super::a::S`` from module ``c.m.n`` means + ``c.m.a.S``") — the plan-locked Design for what §6 leaves open. + """ + edges = _edges( + tmp_path, + { + "src/lib.rs": "pub struct Y;\npub mod a;\n", + "src/a.rs": "pub struct X;\npub mod b;\n", + "src/a/b.rs": "use super::X;\nuse super::super::Y;\n", + }, + ) + imports = {e.to_id: e for e in edges if e.from_id == "rust:module:demo.a.b"} + assert set(imports) == {"rust:struct:demo.a.X", "rust:struct:demo.Y"} + assert all(e.confidence == "resolved" for e in imports.values()) + + +def test_super_past_crate_root_drops(tmp_path: Path) -> None: + # `super::` at the crate root walks above the crate — resolved-or-dropped (D1). + edges = _edges(tmp_path, {"src/lib.rs": "pub struct X;\nuse super::X;\n"}) + assert [e for e in edges if e.kind == "imports"] == [] + + +def test_self_prefix_resolves_module_relative(tmp_path: Path) -> None: + """``use self::B;`` in module ``demo.a`` → ``demo.a.B`` (module-relative). + + Interpreted §6 gap, pinned here: upstream 1b maps a leading ``self`` to the CRATE + root (resolve.rs normalize_path — it lacks the defining module), which is wrong + Rust semantics; wardline threads the enclosing module, so ``self::`` resolves + module-relative (at the crate root the two agree byte-for-byte). + """ + edges = _edges( + tmp_path, + { + "src/lib.rs": "pub mod a;\n", + "src/a.rs": "pub struct B;\nuse self::B;\n", + }, + ) + imports = [e for e in edges if e.kind == "imports"] + assert [(e.from_id, e.to_id, e.confidence) for e in imports] == [ + ("rust:module:demo.a", "rust:struct:demo.a.B", "resolved") + ] + + +# --------------------------------------------------------------------------- # +# Use-tree expansion: group fan-out, `as` alias, `self` group leaf +# --------------------------------------------------------------------------- # + + +def test_group_fanout_alias_real_path_and_self_leaf(tmp_path: Path) -> None: + """``use crate::a::{self, B, C as Zed};`` → three edges from ONE statement. + + extract.rs:621-667 ``collect_use_leaves``: a Group fans out per branch; a Rename + resolves the REAL path (``crate::a::C`` — the alias ``Zed`` is dropped); a ``self`` + group leaf terminates the PREFIX path unchanged (``crate::a`` → the module itself). + All three share the one use-statement span. + """ + lib_rs = "pub mod a;\nuse crate::a::{self, B, C as Zed};\n" + edges = _edges( + tmp_path, + { + "src/lib.rs": lib_rs, + "src/a.rs": "pub struct B;\npub struct C;\n", + }, + ) + imports = [e for e in edges if e.kind == "imports"] + assert {(e.to_id, e.confidence) for e in imports} == { + ("rust:module:demo.a", "resolved"), # the `self` leaf → the prefix module + ("rust:struct:demo.a.B", "resolved"), + ("rust:struct:demo.a.C", "resolved"), # real path, alias dropped + } + span = _span(lib_rs, "use crate::a::{self, B, C as Zed};") + assert all((e.source_byte_start, e.source_byte_end) == span for e in imports) + assert all(e.from_id == "rust:module:demo" for e in imports) + assert not any("Zed" in e.to_id for e in imports) + + +# --------------------------------------------------------------------------- # +# Globs: in-project → ambiguous(module); external → dropped +# --------------------------------------------------------------------------- # + + +def test_glob_in_project_is_ambiguous_to_the_module(tmp_path: Path) -> None: + """``use crate::a::*;`` → ONE ``ambiguous`` edge to the in-project module entity + (resolve.rs:39-50: a glob can never be promoted to Resolved from syntax alone, but + carries the REAL module id — never null).""" + edges = _edges( + tmp_path, + { + "src/lib.rs": "pub mod a;\nuse crate::a::*;\n", + "src/a.rs": "pub struct B;\npub struct C;\n", + }, + ) + imports = [e for e in edges if e.kind == "imports"] + assert [(e.to_id, e.confidence) for e in imports] == [("rust:module:demo.a", "ambiguous")] + + +def test_glob_external_is_dropped(tmp_path: Path) -> None: + edges = _edges(tmp_path, {"src/lib.rs": "use std::collections::*;\n"}) + assert [e for e in edges if e.kind == "imports"] == [] + + +# --------------------------------------------------------------------------- # +# Multi-kind ambiguity: deterministic first-by-sorted-order target +# --------------------------------------------------------------------------- # + + +def test_multi_kind_ambiguity_targets_first_id_by_sorted_order(tmp_path: Path) -> None: + """``fn S`` + ``struct S`` legally share a qualname (value vs type namespace); + ``use crate::S`` → ``ambiguous`` with ``to_id`` = FIRST entity id by sorted order + (resolve.rs:127-139 ``resolve_ids`` — ``rust:function:…`` < ``rust:struct:…``).""" + edges = _edges( + tmp_path, + { + "src/lib.rs": "pub fn S() {}\npub struct S;\npub mod b;\n", + "src/b.rs": "use crate::S;\n", + }, + ) + imports = [e for e in edges if e.kind == "imports"] + assert [(e.to_id, e.confidence) for e in imports] == [("rust:function:demo.S", "ambiguous")] + + +# --------------------------------------------------------------------------- # +# External / unresolvable → dropped (resolved-or-dropped, D1) +# --------------------------------------------------------------------------- # + + +def test_external_use_is_dropped(tmp_path: Path) -> None: + edges = _edges(tmp_path, {"src/lib.rs": "use std::fmt;\n"}) + assert [e for e in edges if e.kind == "imports"] == [] + + +def test_multi_segment_miss_never_falls_back_to_crate_root(tmp_path: Path) -> None: + """The bare-segment gate (resolve.rs:106-125): a MULTI-segment path that misses + attempt 1 stays dropped — never re-prefixed with the crate (the H5 guard: an + in-project ``mod serde`` must not capture an external ``use serde::Serialize``).""" + edges = _edges( + tmp_path, + { + "src/lib.rs": "pub mod serde;\nuse serde::Serialize;\n", + "src/serde.rs": "pub struct Serialize;\n", + }, + ) + # `serde::Serialize` attempt 1 looks up `serde.Serialize` (no crate prefix) — miss; + # multi-segment, so NO crate-root fallback: dropped, NOT `demo.serde.Serialize`. + assert [e for e in edges if e.to_id == "rust:struct:demo.serde.Serialize"] == [] + + +def test_external_trait_impl_is_dropped(tmp_path: Path) -> None: + edges = _edges( + tmp_path, + {"src/lib.rs": "use std::fmt;\nstruct Foo;\nimpl fmt::Display for Foo {}\n"}, + ) + assert [e for e in edges if e.kind == "implements"] == [] + + +# --------------------------------------------------------------------------- # +# Trait lookup: generic args stripped; negative impls emit nothing +# --------------------------------------------------------------------------- # + + +def test_generic_trait_impl_resolves_with_args_stripped(tmp_path: Path) -> None: + """``impl MyTrait for Foo`` resolves ``MyTrait`` — generic args STRIPPED for + the lookup (extract.rs:780-794 ``trait_path_for_lookup``: the in-project trait + entity is keyed on its bare ident). The span still anchors the full implemented- + trait path node (``MyTrait``, syn Path spans include the args).""" + lib_rs = "pub trait MyTrait {}\nstruct Foo;\nimpl MyTrait for Foo {}\n" + edges = _edges(tmp_path, {"src/lib.rs": lib_rs}) + implements = [e for e in edges if e.kind == "implements"] + assert [(e.from_id, e.to_id, e.confidence) for e in implements] == [ + ("rust:impl:demo.Foo.impl[MyTrait]", "rust:trait:demo.MyTrait", "resolved") + ] + assert (implements[0].source_byte_start, implements[0].source_byte_end) == _span(lib_rs, "MyTrait") + + +def test_negative_impl_emits_no_implements_edge(tmp_path: Path) -> None: + """``impl !Marker for Foo`` asserts NON-implementation → no (positive) edge + (extract.rs:729-738: the bang guard; the impl ENTITY itself is still emitted).""" + edges = _edges( + tmp_path, + {"src/lib.rs": "pub trait Marker {}\nstruct Foo;\nimpl !Marker for Foo {}\n"}, + ) + assert [e for e in edges if e.kind == "implements"] == [] + + +# --------------------------------------------------------------------------- # +# Merged twin impl blocks → exactly ONE implements edge per impl entity +# --------------------------------------------------------------------------- # + + +def test_merged_twin_impl_blocks_emit_exactly_one_edge(tmp_path: Path) -> None: + """Two same-key ``impl Greet for Foo`` blocks merge to ONE impl entity, and the + ``implements`` edge is emitted once per impl ENTITY (extract.rs:707-727 + ``seen_impl_ids`` — the second block only appends methods).""" + edges = _edges( + tmp_path, + { + "src/lib.rs": ( + "pub trait Greet {}\n" + "struct Foo;\n" + "impl Greet for Foo {\n fn a(&self) {}\n}\n" + "impl Greet for Foo {\n fn b(&self) {}\n}\n" + ) + }, + ) + implements = [e for e in edges if e.kind == "implements"] + assert [(e.from_id, e.to_id) for e in implements] == [("rust:impl:demo.Foo.impl[Greet]", "rust:trait:demo.Greet")] + + +# --------------------------------------------------------------------------- # +# Enclosing-module from_id: inline mods; non-module scopes emit nothing +# --------------------------------------------------------------------------- # + + +def test_use_inside_inline_mod_is_from_that_mod_entity(tmp_path: Path) -> None: + edges = _edges( + tmp_path, + {"src/lib.rs": "pub trait Greet {}\nmod inner {\n use crate::Greet;\n}\n"}, + ) + imports = [e for e in edges if e.kind == "imports"] + assert [(e.from_id, e.to_id) for e in imports] == [("rust:module:demo.inner", "rust:trait:demo.Greet")] + + +def test_use_inside_a_function_body_emits_nothing(tmp_path: Path) -> None: + # §6: "one per FILE-SCOPE use leaf" — the oracle walks module item lists only + # (a fn-body use is not a module property; extract.rs never visits it). + edges = _edges( + tmp_path, + {"src/lib.rs": "pub trait Greet {}\nfn f() {\n use crate::Greet;\n}\n"}, + ) + assert [e for e in edges if e.kind == "imports"] == [] + + +# --------------------------------------------------------------------------- # +# Confidence is never `inferred` (anchored edges, ADR-026 decision 3) +# --------------------------------------------------------------------------- # + + +def test_confidence_is_never_inferred(tmp_path: Path) -> None: + edges = _edges( + tmp_path, + { + "src/lib.rs": ( + "pub trait Greet {}\n" + "pub mod a;\n" + "use crate::a::*;\n" # ambiguous + "use crate::Greet;\n" # resolved + "use std::fmt;\n" # dropped + "struct Foo;\n" + "impl Greet for Foo {}\n" # resolved + ), + "src/a.rs": "pub struct B;\n", + }, + ) + assert edges, "fixture must produce edges" + assert {e.confidence for e in edges} <= {"resolved", "ambiguous"} + assert {e.kind for e in edges} == {"imports", "implements"} diff --git a/tests/unit/rust/test_fingerprint_stability.py b/tests/unit/rust/test_fingerprint_stability.py new file mode 100644 index 00000000..ee841a56 --- /dev/null +++ b/tests/unit/rust/test_fingerprint_stability.py @@ -0,0 +1,140 @@ +"""Rust fingerprint stability — the wlfp2 move-stability contract for RS-WL-*. + +The Rust analogue of ``tests/unit/core/test_fingerprint_stability.py`` (identity +keystone panel, rust-sp2-2026-06-10). The fingerprint inputs are +``(rule_id, path, qualname, taint_path)`` — ``line_start`` is NOT hashed (wlfp2, +wardline-8654423823), so the RS-WL-* discriminator must be ENTITY-RELATIVE: + + * **Whole-entity moves** — a comment at the top of the file, a comment directly + above the function, an unrelated sibling ``fn`` added ABOVE — shift every + absolute line AND every pre-order ``NodeId`` below them, but keep the + fingerprint, because both the line and the NodeId fold as deltas against the + containing entity's own anchor (``line - entity_line_start``, + ``trigger_node_id - entity_node_id``). + * **In-entity edits** — a statement inserted INSIDE the function ABOVE the + trigger — DO change the fingerprint (the trigger's offset relative to its own + fn moved). Entity-relative, not move-stable in the strong sense: the contract + is identical-source -> identical-fingerprint, and that edit is not identical + source. Asserted ``!=`` here to document the accepted boundary. + * **Same-line twins** — two distinct triggers on one physical line keep DISTINCT + fingerprints (the relative-NodeId fold is the sole same-line discriminant; see + also test_rules.test_two_commands_on_one_line_get_distinct_fingerprints). + +Plus the FIX-4 class-3 route repro (the panel's exp_e4e): a manifest-less tree's +identity is relpath-pure — renaming the scan-root DIRECTORY must not rekey. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.config import WardlineConfig # noqa: E402 +from wardline.rust.analyzer import RustAnalyzer # noqa: E402 + +_TRUSTED = "/// @trusted(level=ASSURED)\n" + +# One RS-WL-108 (tainted program) + one RS-WL-112 (tainted arg on a `sh -c` line), +# in separate fns so each rule's fingerprint is tracked independently. +_BASE = ( + _TRUSTED + + "fn prog() {\n" + + ' let t = std::env::var("X").unwrap();\n' + + " Command::new(t).output();\n" + + "}\n" + + _TRUSTED + + "fn shell() {\n" + + ' let t = std::env::var("X").unwrap();\n' + + ' Command::new("sh").arg("-c").arg(t).output();\n' + + "}\n" +) + + +def _fingerprints(source: str) -> dict[str, str]: + findings = RustAnalyzer().analyze_source(source, module="demo.m", path="src/m.rs") + out = {f.rule_id: f.fingerprint for f in findings if f.rule_id.startswith("RS-WL-")} + assert set(out) == {"RS-WL-108", "RS-WL-112"}, f"fixture must fire both rules, got {sorted(out)}" + return out + + +def test_comment_at_top_of_file_keeps_both_fingerprints() -> None: + # Shifts every absolute line AND every pre-order NodeId in the file; both fold + # entity-relative, so the join key must not churn (the panel's critical repro). + base = _fingerprints(_BASE) + shifted = _fingerprints("// release notes header\n\n" + _BASE) + assert base == shifted + + +def test_comment_directly_above_the_function_keeps_both_fingerprints() -> None: + base = _fingerprints(_BASE) + shifted = _fingerprints(_BASE.replace(_TRUSTED + "fn prog", "// reviewed 2026-06-10\n" + _TRUSTED + "fn prog")) + assert base == shifted + + +def test_unrelated_sibling_fn_added_above_keeps_both_fingerprints() -> None: + # A whole sibling item above shifts NodeIds by MANY (not one) — the delta, not + # the absolute index, is what must be stable. + base = _fingerprints(_BASE) + shifted = _fingerprints("fn helper() -> i32 {\n 1 + 2\n}\n" + _BASE) + assert base == shifted + + +def test_statement_inserted_inside_the_fn_above_the_trigger_changes_fingerprint() -> None: + # The accepted entity-relative limitation (mirrors the Python contract): an edit + # INSIDE the entity above the trigger moves its relative offset -> rekey. + base = _fingerprints(_BASE) + mutated = _fingerprints(_BASE.replace("fn prog() {\n", "fn prog() {\n let _pad = 0;\n")) + assert base["RS-WL-108"] != mutated["RS-WL-108"] + + +def test_two_same_line_triggers_keep_distinct_fingerprints() -> None: + # The discriminator's original purpose survives the rekey: identical + # (rule, path, qualname, relative line) twins split on the relative NodeId. + src = ( + _TRUSTED + + "fn f() {\n" + + ' let t = std::env::var("X").unwrap();\n' + + " Command::new(t).output(); Command::new(t).spawn();\n" + + "}\n" + ) + findings = RustAnalyzer().analyze_source(src, module="demo.m", path="src/m.rs") + fps = [f.fingerprint for f in findings if f.rule_id == "RS-WL-108"] + assert len(fps) == 2 and len(set(fps)) == 2 + + +def test_display_taint_path_keeps_absolute_lines() -> None: + # properties["taint_path"] is the DISPLAY surface — human-readable, absolute lines + # exactly as before the rekey. Only the fingerprint discriminator went relative. + findings = RustAnalyzer().analyze_source(_BASE, module="demo.m", path="src/m.rs") + by_rule = {f.rule_id: f for f in findings if f.rule_id.startswith("RS-WL-")} + assert by_rule["RS-WL-108"].properties["taint_path"] == "EXTERNAL_RAW->Command::new(program)@L4->exec@L4" + assert by_rule["RS-WL-112"].properties["taint_path"] == "EXTERNAL_RAW->arg->'sh -c'->exec@L9" + + +# --- FIX 4: class-3 (no crate root) identity is relpath-pure ----------------- + + +def _scan_fingerprints(root: Path) -> dict[str, tuple[str, str]]: + files = sorted(root.rglob("*.rs")) + findings = RustAnalyzer().analyze(files, WardlineConfig(), root=root) + return {f.rule_id: (f.qualname or "", f.fingerprint) for f in findings if f.rule_id.startswith("RS-WL-")} + + +def test_class3_fingerprints_survive_a_scan_root_directory_rename(tmp_path: Path) -> None: + # The panel's exp_e4e repro: same content, two scan-root directory NAMES. A + # manifest-less (class-3) tree's crate segment is the CONSTANT "crate", so the + # qualname/fingerprint must be byte-identical across the rename. + src = _TRUSTED + 'fn run() {\n let t = std::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + for name in ("alpha", "beta"): + d = tmp_path / name / "bin" + d.mkdir(parents=True) + (d / "app.rs").write_text(src, encoding="utf-8") + + alpha = _scan_fingerprints(tmp_path / "alpha") + beta = _scan_fingerprints(tmp_path / "beta") + assert alpha["RS-WL-108"] == beta["RS-WL-108"] + # And the route is the decided class-3 shape: constant crate segment + #out branding. + assert alpha["RS-WL-108"][0] == "crate.#out.bin.app.run" diff --git a/tests/unit/rust/test_index.py b/tests/unit/rust/test_index.py new file mode 100644 index 00000000..3eed6cb8 --- /dev/null +++ b/tests/unit/rust/test_index.py @@ -0,0 +1,310 @@ +"""The Rust index — the full ADR-049 entity surface + NodeId stamping. + +Pins the entity-shape contract the corpus parity gate does not: that every emitted +entity carries a ``Location``, a ``NodeId``, and a ``parent`` containment link; that +closures and nested ``fn``s are NOT emitted (ADR-049 — the walk never descends a +``function_item`` body); that Wardline keeps its semantic ``function``/``method`` +split in entity metadata while the qualname id-kind stays ``function`` for both; and +(Phase 1b) the leaf kinds, the merged ``impl`` entity, the corpus-pinned emit +ordering, the per-(kind, name) twin counter, and emission determinism. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.node_id import NodeId # noqa: E402 +from wardline.rust.index import RustEntity, discover_rust_entities # noqa: E402 + +_SPECIMEN = ( + "pub fn top() {}\n" + "struct Foo;\n" + "impl Foo { fn bar(&self) {} }\n" + "fn outer() {\n" + " fn inner() {}\n" # nested fn -> NOT an entity + " let _g = || 1;\n" # closure -> NOT an entity + "}\n" +) + + +def test_emits_one_entity_per_callable_excluding_bodies() -> None: + entities = discover_rust_entities(_SPECIMEN, module="demo.m") + quals = {e.qualname for e in entities if e.kind in ("function", "method")} + assert quals == {"demo.m.top", "demo.m.Foo.impl#<>.bar", "demo.m.outer"} + # the nested fn and the closure produced no entity of their own + assert not any("inner" in q for q in quals) + + +def test_entities_carry_location_and_nodeid() -> None: + entities = discover_rust_entities(_SPECIMEN, module="demo.m") + for e in entities: + assert isinstance(e, RustEntity) + assert isinstance(e.node_id, int) # NodeId is a NewType over int + assert e.location.line_start is not None and e.location.line_start >= 1 + top = next(e for e in entities if e.qualname == "demo.m.top") + assert top.location.line_start == 1 + + +def test_semantic_kind_split_rides_metadata_not_the_qualname() -> None: + # The id-kind in the qualname is `function` for both; the function/method + # distinction is semantic metadata only (ADR-049 kind boundary). + entities = {e.qualname: e for e in discover_rust_entities(_SPECIMEN, module="demo.m")} + assert entities["demo.m.top"].kind == "function" + assert entities["demo.m.Foo.impl#<>.bar"].kind == "method" + + +def test_stacked_cfg_twins_get_distinct_folded_suffixes() -> None: + # ALL stacked #[cfg] attributes fold into the discriminant (loomweave extract.rs + # cfg_predicates collects every cfg; folding only the FIRST/LAST would hand both + # blocks the same suffix and silently merge them). Pinned upstream by the + # stacked_cfg_twin corpus row. + src = ( + "struct Foo;\n" + '#[cfg(feature = "a")]\n#[cfg(unix)]\nimpl Foo { pub fn go(&self) {} }\n' + '#[cfg(feature = "b")]\n#[cfg(unix)]\nimpl Foo { pub fn go(&self) {} }\n' + ) + names = {e.qualname for e in discover_rust_entities(src, module="demo.m")} + assert 'demo.m.Foo.impl#<>@cfg(feature="a"&unix).go' in names + assert 'demo.m.Foo.impl#<>@cfg(feature="b"&unix).go' in names + + +def test_single_stacked_cfg_impl_without_twin_gets_no_suffix() -> None: + # dialect: @cfg is a COLLISION discriminator — a lone stacked-cfg impl stays bare + # (the suffix applies only when extract.rs's twin counter sees a path collision). + src = '#[cfg(feature = "a")]\n#[cfg(unix)]\nstruct Foo;\nimpl Foo { pub fn go(&self) {} }\n' + names = {e.qualname for e in discover_rust_entities(src, module="demo.m")} + assert "demo.m.Foo.impl#<>.go" in names + + +def test_node_id_zero_is_the_root() -> None: + # Sanity that the stamped NodeId comes from the shared mint authority (pre-order + # from the root): the first function's id is well past 0 (root + items precede). + entities = discover_rust_entities("fn only() {}\n", module="demo.m") + only = next(e for e in entities if e.kind == "function") + assert NodeId(only.node_id) > NodeId(0) + + +# --------------------------------------------------------------------------- # +# Phase 1b producer surface: the full ten-kind ADR-049 emission (leaf kinds, +# the `impl` entity, module -> impl -> method containment, per-kind cfg twins, +# corpus-pinned emit ordering). Oracle: loomweave extract.rs (twin_counts +# :326-358; struct arm :397-400; inline-mod arm :436-439; trait arm :476-492 — +# trait BODIES are never walked) + the vendored qualnames_rust.json rows. +# --------------------------------------------------------------------------- # + + +def test_leaf_item_kinds_emitted_with_module_parent() -> None: + # The five Phase-1b leaf kinds + macro_rules!, each at its source position with + # qualname `.` and parent = the file module; the bare macro + # INVOCATION emits nothing (corpus macro_invocation_generates_no_entity). + src = ( + "pub enum Color { Red }\n" + "pub trait Greet {}\n" + "pub type Alias = u8;\n" + "pub const LIMIT: u32 = 10;\n" + 'pub static NAME: &str = "x";\n' + "macro_rules! make { () => { fn generated() {} } }\n" + "make!();\n" + ) + rows = [(e.qualname, e.kind, e.parent) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module", None), + ("demo.m.Color", "enum", "demo.m"), + ("demo.m.Greet", "trait", "demo.m"), + ("demo.m.Alias", "type_alias", "demo.m"), + ("demo.m.LIMIT", "const", "demo.m"), + ("demo.m.NAME", "static", "demo.m"), + ("demo.m.make", "macro", "demo.m"), + ] + + +def test_impl_entity_rows_inherent_trait_and_cfg_forms() -> None: + # The merged `impl` entity is a real row now: inherent `…impl#<>`, trait + # `…impl[Display]`, and the @cfg-split twin forms (corpus inherent_method / + # trait_method / trait_impl_cfg_twin). + src = ( + "struct Foo;\n" + "impl Foo { fn a(&self) {} }\n" + "impl std::fmt::Display for Foo {" + " fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n" + "#[cfg(unix)]\nimpl Clone for Foo { fn clone(&self) -> Foo { Foo } }\n" + "#[cfg(windows)]\nimpl Clone for Foo { fn clone(&self) -> Foo { Foo } }\n" + ) + impls = [e.qualname for e in discover_rust_entities(src, module="demo.m") if e.kind == "impl"] + assert impls == [ + "demo.m.Foo.impl#<>", + "demo.m.Foo.impl[Display]", + "demo.m.Foo.impl[Clone]@cfg(unix)", + "demo.m.Foo.impl[Clone]@cfg(windows)", + ] + + +def test_containment_parents_module_impl_method() -> None: + # module -> impl -> method containment: a method's parent is the impl ENTITY + # qualname; the impl's parent is the module; free items parent on the module. + src = "fn free() {}\nstruct Foo;\nimpl Foo { fn bar(&self) {} }\n" + by_q = {e.qualname: e for e in discover_rust_entities(src, module="demo.m")} + assert by_q["demo.m"].parent is None + assert by_q["demo.m.free"].parent == "demo.m" + assert by_q["demo.m.Foo"].parent == "demo.m" + assert by_q["demo.m.Foo.impl#<>"].parent == "demo.m" + assert by_q["demo.m.Foo.impl#<>.bar"].parent == "demo.m.Foo.impl#<>" + + +def test_merged_impl_emitted_at_first_block_in_source_order() -> None: + # Two same-key inherent blocks merge to ONE impl entity, emitted at the FIRST + # contributing block (its location.line_start = block 1's line); both blocks' + # methods follow in source order (corpus multiple_inherent_merge + oracle + # emit_impl's seen_impl_ids first-block guard). + src = "struct Foo;\nimpl Foo { fn a(&self) {} }\nimpl Foo { fn b(&self) {} }\n" + entities = discover_rust_entities(src, module="demo.m") + rows = [(e.qualname, e.kind) for e in entities] + assert rows == [ + ("demo.m", "module"), + ("demo.m.Foo", "struct"), + ("demo.m.Foo.impl#<>", "impl"), + ("demo.m.Foo.impl#<>.a", "method"), + ("demo.m.Foo.impl#<>.b", "method"), + ] + (impl_entity,) = [e for e in entities if e.kind == "impl"] + assert impl_entity.location.line_start == 2 # the FIRST contributing block + + +def test_file_module_entity_emitted_first_and_inline_mods_at_source_position() -> None: + # Emit ordering (corpus nested_inline_mod / same_type_name_distinct_module_scopes): + # the file-scope module entity comes FIRST, before any item; an inline `mod` entity + # is emitted at its source position, BEFORE its members. + src = "pub fn top() {}\nmod inner {\n pub fn g() {}\n}\npub fn tail() {}\n" + entities = discover_rust_entities(src, module="demo") + rows = [(e.qualname, e.kind) for e in entities] + assert rows == [ + ("demo", "module"), + ("demo.top", "function"), + ("demo.inner", "module"), + ("demo.inner.g", "function"), + ("demo.tail", "function"), + ] + by_q = {e.qualname: e for e in entities} + assert by_q["demo.inner"].parent == "demo" + assert by_q["demo.inner.g"].parent == "demo.inner" + + +def test_per_kind_twin_counting() -> None: + # The twin counter is per-(kind, name) — extract.rs twin_counts. `fn S` and + # `struct S` share a name but NOT a kind, so the cfg-gated fn is no twin and + # gets NO @cfg suffix (the id's kind segment already separates them). + src = "#[cfg(unix)]\nfn S() {}\nstruct S;\n" + rows = {(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")} + assert ("demo.m.S", "function") in rows + assert ("demo.m.S", "struct") in rows + assert not any("@cfg" in qual for qual, _ in rows) + + +def test_per_kind_twin_suffix_applies_within_a_leaf_kind() -> None: + # Within one (kind, name) the @cfg suffix DOES split (corpus leaf_kind_cfg_twin). + src = "#[cfg(unix)]\npub const LIMIT: u32 = 1;\n#[cfg(windows)]\npub const LIMIT: u32 = 2;\n" + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module"), + ("demo.m.LIMIT@cfg(unix)", "const"), + ("demo.m.LIMIT@cfg(windows)", "const"), + ] + + +def test_line_comment_interposition_does_not_detach_cfg() -> None: + # Keystone-panel repro: a `//` comment between #[cfg] and its item. Comments are + # token-stream-invisible to the oracle (syn never sees them — extract.rs + # cfg_predicates operates on syn attributes, which attach across comments), so + # the cfg must stay pending: both twins keep their @cfg discriminant, and there + # is NO bare colliding `demo.m.f`. + src = "#[cfg(unix)]\n// comment\npub fn f() {}\n#[cfg(windows)]\npub fn f() {}\n" + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module"), + ("demo.m.f@cfg(unix)", "function"), + ("demo.m.f@cfg(windows)", "function"), + ] + + +def test_doc_comment_interposition_does_not_detach_cfg() -> None: + # The corpus row (cfg_attr_comment_interposition): a `///` doc comment IS a syn + # attribute (#[doc = "..."], Meta::NameValue) but NOT a cfg, so it contributes + # nothing to the discriminant — and in tree-sitter it is a plain line_comment + # sibling that must not reset the pending cfg either. + src = "#[cfg(unix)]\n// platform note\npub fn f() {}\n#[cfg(windows)]\n/// doc comment\npub fn f() {}\n" + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module"), + ("demo.m.f@cfg(unix)", "function"), + ("demo.m.f@cfg(windows)", "function"), + ] + + +def test_in_predicate_comment_is_token_invisible() -> None: + # The corpus row (cfg_predicate_internal_comment): a /* */ comment INSIDE the + # predicate never reaches the oracle's token stream (proc-macro2 drops comments), + # so the discriminant renders from tokens only — comment stripped, any() args + # sorted, whitespace gone. + src = '#[cfg(any(unix, /* why */ windows))]\npub fn g() {}\n#[cfg(target_os = "macos")]\npub fn g() {}\n' + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module"), + ("demo.m.g@cfg(any(unix,windows))", "function"), + ('demo.m.g@cfg(target_os="macos")', "function"), + ] + + +def test_emission_is_deterministic() -> None: + # Two runs over the same source produce byte-identical ordered emissions + # (qualname, kind, parent, span) — the property the full-set ordered + # conformance comparison and the identity freeze both lean on. + src = ( + "pub enum Color { Red }\n" + "struct Foo;\n" + "impl Foo { fn a(&self) {} }\n" + "impl Foo { fn b(&self) {} }\n" + "mod inner {\n pub fn g() {}\n}\n" + "#[cfg(unix)]\nfn f() {}\n#[cfg(windows)]\nfn f() {}\n" + ) + + def run() -> list[tuple[str, str, str | None, int | None, int | None]]: + return [ + (e.qualname, e.kind, e.parent, e.location.line_start, e.location.line_end) + for e in discover_rust_entities(src, module="demo.m", path="m.rs") + ] + + assert run() == run() + + +def test_identical_witness_twins_do_not_fire_stage_s() -> None: + # ADR-049 Amendment 6, known residual BY DESIGN: coherence-illegal twins with the + # SAME written self-type path carry identical witnesses — no witness can split + # them, stage S stays cold, and the two blocks still MERGE onto one bare key + # (upstream, `duplicate_ids()` is the alarm; un-corpused, pinned here). + src = ( + "pub trait T { fn go(&self); }\n" + "mod a { pub struct X; }\n" + "impl T for a::X { fn go(&self){} }\n" + "impl T for a::X { fn other(&self){} }\n" + ) + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [ + ("demo.m", "module"), + ("demo.m.T", "trait"), + ("demo.m.a", "module"), + ("demo.m.a.X", "struct"), + ("demo.m.X.impl[T]", "impl"), + ("demo.m.X.impl[T].go", "method"), + ("demo.m.X.impl[T].other", "method"), + ] + + +def test_unnamed_const_skip_leaves_named_siblings_untouched() -> None: + # ADR-049 Amendment 9 unit guard (the corpus pins the skip via ordered-equality; + # this pins the SEMANTIC kind view too): `const _` emits nothing — no entity, no + # twin-count participation — while named consts are unaffected. + src = "pub const LIMIT: u32 = 10;\nconst _: () = assert!(true);\nconst _: () = assert!(true);\n" + rows = [(e.qualname, e.kind) for e in discover_rust_entities(src, module="demo.m")] + assert rows == [("demo.m", "module"), ("demo.m.LIMIT", "const")] diff --git a/tests/unit/rust/test_mounts.py b/tests/unit/rust/test_mounts.py new file mode 100644 index 00000000..a8d7d74e --- /dev/null +++ b/tests/unit/rust/test_mounts.py @@ -0,0 +1,92 @@ +# tests/unit/rust/test_mounts.py +"""Unit guards for the #[path] mount overlay (ADR-049 Amendment 8) — the behaviors the +vendored ``module_mounts`` corpus does NOT pin (mounted routes end-to-end live in +tests/conformance/test_loomweave_rust_qualname_parity.py::test_module_mounts): + +* a mount CYCLE drops to the filesystem fallback (normative in the amendment text but + un-corpused — these pin Wardline's deterministic resolution); +* a file tree-sitter cannot fully parse contributes NO mounts (fail-closed, matching + the analyzer's refuse-to-half-analyze posture); +* a ``cfg_attr``-delivered ``path`` and a ``#[path]`` on an INLINE mod are not mounts; +* an un-mounted file routes byte-identically to ``rust_module_route`` (the overlay's + default IS the fallback pin). +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.rust.mounts import build_mount_overlay # noqa: E402 +from wardline.rust.qualname import rust_module_route # noqa: E402 + + +def test_mount_cycle_drops_to_filesystem_fallback() -> None: + # a.rs mounts b.rs; b.rs mounts a.rs — resolving either re-enters itself. The + # amendment says cycles drop to the filesystem fallback; the corpus does not pin + # WHICH link falls back, so this pins Wardline's resolution: the re-entered file + # resolves by filesystem, and the chain stays deterministic from there. + files = { + "src/a.rs": '#[path = "b.rs"]\nmod from_a;\n', + "src/b.rs": '#[path = "a.rs"]\nmod from_b;\n', + } + overlay = build_mount_overlay(files, crate="demo", src_root="src") + # Resolving b walks b -> (declared in a) -> a -> (declared in b) -> b: the + # RE-ENTERED file (the one being resolved) drops to its filesystem route, so each + # file's chain terminates at itself. Order-independent: _resolve never consults + # the memo mid-chain. + assert overlay.logical_module_path("src/b.rs") == "demo.b.from_b.from_a" + assert overlay.logical_module_path("src/a.rs") == "demo.a.from_a.from_b" + + +def test_self_mount_terminates() -> None: + # Degenerate self-mount: a file mounting itself must terminate via the cycle rule. + files = {"src/loop.rs": '#[path = "loop.rs"]\nmod me;\n'} + overlay = build_mount_overlay(files, crate="demo", src_root="src") + assert overlay.logical_module_path("src/loop.rs") == "demo.loop.me" + + +def test_unparseable_file_contributes_no_mounts() -> None: + # Fail-closed: tree-sitter error-recovery in the declaring file drops its mounts — + # no routing is derived from a file the analyzer refuses to analyze. + files = { + "src/lib.rs": '#[path = "impl_a.rs"]\nmod good; fn broken( {\n', + "src/impl_a.rs": "pub fn a() {}\n", + } + overlay = build_mount_overlay(files, crate="demo", src_root="src") + assert overlay.logical_module_path("src/impl_a.rs") == "demo.impl_a" + + +def test_cfg_attr_delivered_path_is_not_a_mount() -> None: + # ADR-049 Amendment 8: only a LITERAL #[path] attribute is a mount — a + # #[cfg_attr(pred, path = "…")] is invisible by dialect rule (no producer + # evaluates cfg predicates); the target routes by filesystem. + files = { + "src/lib.rs": '#[cfg_attr(unix, path = "unix_impl.rs")]\nmod imp;\n', + "src/unix_impl.rs": "pub fn u() {}\n", + } + overlay = build_mount_overlay(files, crate="demo", src_root="src") + assert overlay.logical_module_path("src/unix_impl.rs") == "demo.unix_impl" + + +def test_path_on_inline_mod_is_not_a_mount() -> None: + # The normative rule covers the DECL form (`mod name;`) only: a #[path] on an + # inline `mod name { … }` mounts nothing, and the inline body still nests by name. + files = { + "src/lib.rs": '#[path = "elsewhere"]\nmod host { #[path = "real.rs"] mod imp; }\n', + "src/host/real.rs": "pub fn r() {}\n", + } + overlay = build_mount_overlay(files, crate="demo", src_root="src") + # The inner decl-mod mount still resolves against the BARE would-be directory + # (src/host/), unaffected by the inline mod's ignored #[path]. + assert overlay.logical_module_path("src/host/real.rs") == "demo.host.imp" + + +def test_unmounted_file_matches_pure_filesystem_route() -> None: + # The overlay's no-mount default MUST be byte-identical to rust_module_route + # (the de-gapped `path_attr_known_gap` discipline). + files = {"src/lib.rs": "pub fn f() {}\n"} + overlay = build_mount_overlay(files, crate="demo", src_root="src") + for file in ("src/lib.rs", "src/config.rs", "src/plugin/host.rs", "src/plugin/mod.rs"): + assert overlay.logical_module_path(file) == rust_module_route(crate="demo", src_root="src", file=file) diff --git a/tests/unit/rust/test_nodeid.py b/tests/unit/rust/test_nodeid.py new file mode 100644 index 00000000..22454024 --- /dev/null +++ b/tests/unit/rust/test_nodeid.py @@ -0,0 +1,171 @@ +"""WP0: the NodeId keying authority (spec §5). + +These pin the *single keying authority* contract that WP4 (dataflow) and WP5 +(rules) depend on: one parse tree, ids minted once in deterministic pre-order, +looked up by passing the ``Node`` (never a raw id). The WP2 cross-pass agreement +test builds on this; here we pin minting in isolation. + +Ordering and determinism are checked against an **independent oracle** — +tree-sitter's own ``TreeCursor`` (``_cursor_preorder``), a different traversal +algorithm from ``mint_node_ids``'s explicit stack — so a pre-order regression in +mint cannot hide behind a clone of its own walk. +""" + +from __future__ import annotations + +import gc +from typing import TYPE_CHECKING + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.node_id import NodeId # noqa: E402 +from wardline.rust._tree_sitter import require_rust # noqa: E402 +from wardline.rust.nodeid import mint_node_ids # noqa: E402 + +if TYPE_CHECKING: + from collections.abc import Iterator + + from tree_sitter import Node, Tree + + +def _parse(source: bytes) -> Tree: + _, parser = require_rust() + return parser.parse(source) + + +def _cursor_preorder(tree: Tree) -> Iterator[Node]: + """An INDEPENDENT pre-order walk via tree-sitter's ``TreeCursor`` — a + different algorithm than ``mint_node_ids``'s reversed-stack, so it is a real + oracle for "did mint visit nodes parent-before-child, leftmost-first?".""" + cursor = tree.walk() + while True: + node = cursor.node + assert node is not None # the cursor is always positioned on a real node here + yield node + if cursor.goto_first_child(): + continue + if cursor.goto_next_sibling(): + continue + while True: + if not cursor.goto_parent(): + return + if cursor.goto_next_sibling(): + break + + +def test_mint_is_dense_and_total() -> None: + # Every node in the tree is minted exactly once; the ids are exactly {0..n-1}. + tree = _parse(b"fn main(){ let x = 1; }") + nmap = mint_node_ids(tree) + nodes = list(_cursor_preorder(tree)) + ids = [int(nmap.node_id(node)) for node in nodes] + assert sorted(ids) == list(range(len(nodes))) + assert len(nmap) == len(nodes) + + +def test_mint_order_matches_an_independent_cursor_oracle() -> None: + # The discriminating order check: mint's indices, read out in the cursor's + # independent pre-order, must be the strictly increasing 0,1,2,... — which only + # holds if mint is genuinely pre-order (a post-order mint would NOT be monotone). + tree = _parse(b"fn main(){ let x = foo(1); }") + nmap = mint_node_ids(tree) + ids = [int(nmap.node_id(node)) for node in _cursor_preorder(tree)] + assert ids == list(range(len(ids))) + + +def test_mint_is_deterministic_across_separate_parses() -> None: + # Two independent parses of the same source yield the same (index, node.type) + # sequence — pins the docstring's "deterministic across runs" claim, which + # re-minting one Tree object cannot. + src = b"fn a(){ let x = 1; } fn b(){ bar(); }" + t1, t2 = _parse(src), _parse(src) + m1, m2 = mint_node_ids(t1), mint_node_ids(t2) + seq1 = [(int(m1.node_id(n)), n.type) for n in _cursor_preorder(t1)] + seq2 = [(int(m2.node_id(n)), n.type) for n in _cursor_preorder(t2)] + assert seq1 == seq2 + + +def test_root_is_first_in_preorder() -> None: + tree = _parse(b"fn main(){}") + nmap = mint_node_ids(tree) + assert nmap.node_id(tree.root_node) == NodeId(0) + + +def test_same_node_via_renavigation_resolves_to_same_id() -> None: + tree = _parse(b"fn main(){}") + nmap = mint_node_ids(tree) + fn_a = tree.root_node.named_children[0] + fn_b = tree.root_node.named_children[0] + assert nmap.node_id(fn_a) == nmap.node_id(fn_b) + + +def test_membership_and_get() -> None: + tree = _parse(b"fn main(){}") + nmap = mint_node_ids(tree) + assert tree.root_node in nmap + assert nmap.get(tree.root_node) == NodeId(0) + + +def test_lookup_of_a_foreign_node_raises_keyerror() -> None: + # Looking a node up in a map minted over a *different* (live) tree is a bug, + # not a silent miss — the keying authority must refuse it loudly. + tree = _parse(b"fn main(){}") + foreign = _parse(b"fn other(){}") + nmap = mint_node_ids(tree) + assert foreign.root_node not in nmap + with pytest.raises(KeyError): + nmap.node_id(foreign.root_node) + + +def test_map_outliving_its_source_tree_refuses_foreign_lookups() -> None: + # The §5 fail-quiet hazard: node.id is a pointer tree-sitter reuses once a tree + # is freed. The map PINS its source tree, so the keyspace stays valid for the + # map's whole life and foreign (live) nodes never false-hit a stored key. + # Without the pin this loops to a non-zero hit count (empirically reproduced). + nmap = mint_node_ids(_parse(b"fn outer(){ let c = mk(); c.run(); }")) + # the map is now the ONLY reference keeping the source tree alive + gc.collect() + foreign_hits = 0 + for i in range(500): + foreign = _parse(b"fn f%d(){ let x = 1; }" % i) + for node in _cursor_preorder(foreign): + if node in nmap: + foreign_hits += 1 + assert foreign_hits == 0 + + +def test_nodeidmap_pins_its_source_tree() -> None: + # Deterministic white-box guard for the §5 Tree-pin: the behavioral freed-tree + # test above is allocator-probabilistic (a lucky run could pass with the pin + # removed); this fails the instant someone drops `_tree` from NodeIdMap. + tree = _parse(b"fn main(){}") + nmap = mint_node_ids(tree) + assert nmap._tree is tree # noqa: SLF001 — white-box on the keystone keyspace-lifetime invariant + + +def test_mint_over_empty_source_is_total() -> None: + tree = _parse(b"") + nmap = mint_node_ids(tree) + assert len(nmap) == len(list(_cursor_preorder(tree))) + assert nmap.node_id(tree.root_node) == NodeId(0) + + +def test_mint_over_malformed_source_is_total_including_error_nodes() -> None: + # ERROR / MISSING nodes are ordinary node.children entries — they must be + # minted like any other node, and mint must not raise on a broken parse. + tree = _parse(b"fn (") + nmap = mint_node_ids(tree) + nodes = list(_cursor_preorder(tree)) + assert all(node in nmap for node in nodes) + assert len(nmap) == len(nodes) + + +def test_mint_survives_a_tree_deeper_than_the_recursion_limit() -> None: + # The explicit stack (not recursion) must handle a tree deeper than Python's + # default recursion limit without a RecursionError. + depth = 2000 + src = b"fn f(){ let _x = " + b"(" * depth + b"1" + b")" * depth + b"; }" + nmap = mint_node_ids(_parse(src)) + assert len(nmap) > depth diff --git a/tests/unit/rust/test_nodeid_crosspass.py b/tests/unit/rust/test_nodeid_crosspass.py new file mode 100644 index 00000000..c925e962 --- /dev/null +++ b/tests/unit/rust/test_nodeid_crosspass.py @@ -0,0 +1,108 @@ +"""WP2: the spec §5 cross-pass NodeId agreement test. + +The engine correlates its passes (index, dataflow WP4, rules WP5) SOLELY by node id +and fails *quietly* if two passes disagree. The Rust frontend's guard is the single +``NodeIdMap`` keying authority: every pass consults it with a ``Node`` object, never a +re-derived id. This test proves the guard holds by locating the *same* builder-chain +trigger node TWO independent ways and asserting the shared map gives them one NodeId — +and it pins WHY the map is necessary by showing the raw ``node.id`` is NOT stable across +parses while the minted index is. + +The two locators are *different traversal algorithms* (recursive descent vs. an iterative +``TreeCursor`` pre-order) on purpose: a pass that keyed on anything but the shared map +(a raw id, a re-parse) would diverge here instead of agreeing. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.rust.nodeid import mint_node_ids # noqa: E402 +from wardline.rust.parse import parse_rust # noqa: E402 + +if TYPE_CHECKING: + from collections.abc import Iterator + + from tree_sitter import Node, Tree + +_CHAIN = 'fn f(){ let mut c = Command::new("sh"); c.arg("-c"); c.output(); }' + + +def _is_output_call(node: Node) -> bool: + if node.type != "call_expression": + return False + fn = node.child_by_field_name("function") + if fn is None or fn.type != "field_expression": + return False + field = fn.child_by_field_name("field") + return field is not None and field.text == b"output" + + +def _locate_by_recursion(node: Node) -> Node | None: + """Pass A — recursive descent over ``node.children``.""" + if _is_output_call(node): + return node + for child in node.children: + hit = _locate_by_recursion(child) + if hit is not None: + return hit + return None + + +def _cursor_preorder(tree: Tree) -> Iterator[Node]: + cursor = tree.walk() + while True: + node = cursor.node + assert node is not None + yield node + if cursor.goto_first_child(): + continue + if cursor.goto_next_sibling(): + continue + while True: + if not cursor.goto_parent(): + return + if cursor.goto_next_sibling(): + break + + +def _locate_by_cursor(tree: Tree) -> Node | None: + """Pass B — iterative ``TreeCursor`` pre-order, a different algorithm than Pass A.""" + return next((n for n in _cursor_preorder(tree) if _is_output_call(n)), None) + + +def test_two_independent_passes_agree_on_the_trigger_nodeid() -> None: + tree = parse_rust(_CHAIN) + nmap = mint_node_ids(tree) + + a = _locate_by_recursion(tree.root_node) # "dataflow records the .output() trigger" + b = _locate_by_cursor(tree) # "the rule locator looks it up" + assert a is not None and b is not None + assert a.id == b.id # genuinely the same CST node, found two ways + + # NON-TAUTOLOGICAL: the minted NodeId of the trigger equals its INDEPENDENTLY + # computed pre-order position (the cursor walk counts it). A broken mint — wrong + # child order, off-by-one, named-only, reversed — fails here. Asserting only + # nmap.node_id(a) == nmap.node_id(b) would be dict[k]==dict[k] (always true once + # a.id == b.id) and would never exercise the mint algorithm §5 depends on. + expected_index = next(i for i, n in enumerate(_cursor_preorder(tree)) if n.id == a.id) + assert int(nmap.node_id(a)) == expected_index + assert int(nmap.node_id(b)) == expected_index + + +def test_raw_node_id_is_not_stable_across_parses_but_the_minted_index_is() -> None: + # WHY the map is necessary: re-parsing the same source yields a different raw + # node.id (a process-local pointer) for the logically-identical trigger, but the + # minted pre-order index is deterministic. A pass that keyed on raw node.id across + # a re-parse would silently mis-correlate — exactly the hazard §5 closes. + t1, t2 = parse_rust(_CHAIN), parse_rust(_CHAIN) + n1 = _locate_by_recursion(t1.root_node) + n2 = _locate_by_recursion(t2.root_node) + assert n1 is not None and n2 is not None + assert n1.id != n2.id # the premise: distinct live trees -> distinct raw pointers + m1, m2 = mint_node_ids(t1), mint_node_ids(t2) + assert m1.node_id(n1) == m2.node_id(n2) # minted index: deterministic across parses diff --git a/tests/unit/rust/test_parse.py b/tests/unit/rust/test_parse.py new file mode 100644 index 00000000..9831dcb9 --- /dev/null +++ b/tests/unit/rust/test_parse.py @@ -0,0 +1,38 @@ +"""WP2: parse-error detection — the no-silent-under-scan signal. + +tree-sitter always returns a tree; a malformed ``.rs`` produces ERROR nodes and the +item walk silently skips them, so ``discover_rust_entities`` under-emits without raising. +``has_errors`` is the signal a scan must consult to surface a diagnostic instead of a +false all-clear. (Emitting that diagnostic as a finding is WP6's done-criterion — here +we pin that the signal is available and that the silent under-scan is real.) +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.rust.index import discover_rust_entities # noqa: E402 +from wardline.rust.parse import has_errors, parse_rust # noqa: E402 + +_TRUNCATED = 'fn handler(req: Request) {\n let x = req.param("cmd")\n Command::new("sh").arg(x).output();\n' + + +def test_has_errors_false_on_well_formed_source() -> None: + assert has_errors(parse_rust("fn main() { let x = 1; }\n")) is False + + +def test_has_errors_true_on_truncated_source() -> None: + assert has_errors(parse_rust(_TRUNCATED)) is True + + +def test_malformed_source_under_emits_so_has_errors_is_the_required_guard() -> None: + # The whole point: the truncated fn has a real signature but the broken body makes + # the parse recover into ERROR/loose tokens, so the function entity is dropped — a + # silent under-scan. has_errors is what lets the scan layer refuse the false clean. + tree = parse_rust(_TRUNCATED) + entities = discover_rust_entities(_TRUNCATED, module="demo.m") + assert has_errors(tree) is True + # 'handler' is silently lost to the recovery — proving the under-scan the guard exists for. + assert not any(e.qualname == "demo.m.handler" for e in entities) diff --git a/tests/unit/rust/test_provider.py b/tests/unit/rust/test_provider.py new file mode 100644 index 00000000..9377b3d2 --- /dev/null +++ b/tests/unit/rust/test_provider.py @@ -0,0 +1,121 @@ +"""WP3: the Rust trust provider — `/// @trusted(level=...)` doc-comment markers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.taints import TaintState # noqa: E402 +from wardline.rust import vocabulary # noqa: E402 +from wardline.rust.parse import parse_rust # noqa: E402 +from wardline.rust.provider import RustTrustProvider, rust_provider_fingerprint # noqa: E402 + +if TYPE_CHECKING: + from tree_sitter import Node + + +def _first_fn(source: str) -> Node: + tree = parse_rust(source) + fn = next(c for c in tree.root_node.children if c.type == "function_item") + return fn + + +def test_trusted_marker_seeds_declared_trust() -> None: + fn = _first_fn("/// @trusted(level=ASSURED)\nfn f() {}\n") + seed = RustTrustProvider().taint_for(fn) + assert seed is not None + assert seed.body_taint is TaintState.ASSURED + assert seed.return_taint is TaintState.ASSURED + + +def test_trusted_marker_accepts_guarded() -> None: + fn = _first_fn("/// @trusted(level=GUARDED)\npub fn f() {}\n") + seed = RustTrustProvider().taint_for(fn) + assert seed is not None and seed.body_taint is TaintState.GUARDED + + +def test_marker_survives_an_interleaved_cfg_attribute() -> None: + # The marker may sit between a #[cfg] attribute and the fn (both are preceding + # siblings); the provider must still find it. + fn = _first_fn("#[cfg(unix)]\n/// @trusted(level=ASSURED)\nfn f() {}\n") + seed = RustTrustProvider().taint_for(fn) + assert seed is not None and seed.body_taint is TaintState.ASSURED + + +def test_unmarked_fn_has_no_opinion_fail_closed() -> None: + # No marker -> None (the L1 seeder turns this into the fail-closed UNKNOWN_RAW + # default, source='default'). + fn = _first_fn("fn f() {}\n") + assert RustTrustProvider().taint_for(fn) is None + + +def test_prose_mention_of_trusted_does_not_match() -> None: + fn = _first_fn("/// see the @trusted convention in the docs\nfn f() {}\n") + assert RustTrustProvider().taint_for(fn) is None + + +def test_prose_containing_the_full_directive_does_not_match() -> None: + # A doc comment that MENTIONS the full parenthesized directive in prose (e.g. warning + # against it) must not seed trust — only a directive that LEADS the doc line counts. The + # marker is anchored to the start of the doc text, so `search`-style mid-string matches + # (which would falsely seed ASSURED and spuriously un-suppress findings) are rejected. + for prose in ( + "/// Do not use @trusted(level=ASSURED) here; it is dangerous.\nfn f() {}\n", + "/// Historically this was @trusted(level=GUARDED).\nfn f() {}\n", + ): + assert RustTrustProvider().taint_for(_first_fn(prose)) is None + + +def test_marker_with_no_leading_space_still_matches() -> None: + # No-regression for the anchor: `///@trusted(...)` (no space after the slashes) must still + # be honored — the anchor allows leading whitespace, it does not require it. + fn = _first_fn("///@trusted(level=ASSURED)\nfn f() {}\n") + seed = RustTrustProvider().taint_for(fn) + assert seed is not None and seed.body_taint is TaintState.ASSURED + + +def test_trusted_marker_on_impl_method_seeds_only_that_method() -> None: + # The marker works on impl METHODS, not just free fns: inside the impl body the + # doc comment is the method's preceding sibling exactly as at file scope. The + # marked method seeds its level; its unmarked sibling stays None (fail-closed). + src = ( + "struct Foo;\n" + "impl Foo {\n" + " /// @trusted(level=GUARDED)\n" + " fn marked(&self) {}\n" + " fn sibling(&self) {}\n" + "}\n" + ) + tree = parse_rust(src) + impl_node = next(c for c in tree.root_node.children if c.type == "impl_item") + body = impl_node.child_by_field_name("body") + assert body is not None + fns: dict[str, Node] = {} + for child in body.named_children: + if child.type == "function_item": + name = child.child_by_field_name("name") + assert name is not None and name.text is not None + fns[name.text.decode("utf-8")] = child + provider = RustTrustProvider() + seed = provider.taint_for(fns["marked"]) + assert seed is not None and seed.body_taint is TaintState.GUARDED + assert provider.taint_for(fns["sibling"]) is None + + +def test_malformed_level_is_surfaced_not_silently_ignored() -> None: + fn = _first_fn("/// @trusted(level=BOGUS)\nfn f() {}\n") + with pytest.raises(ValueError, match="level"): + RustTrustProvider().taint_for(fn) + + +def test_fingerprint_embeds_version_and_changes_on_bump() -> None: + prov = RustTrustProvider() + assert prov.fingerprint() == rust_provider_fingerprint(vocabulary.RUST_TAINT_VERSION) + assert prov.fingerprint() == f"rust-vocab:{vocabulary.RUST_TAINT_VERSION}" + # A version bump must change the fingerprint (closes the cache-version gap). + assert rust_provider_fingerprint(vocabulary.RUST_TAINT_VERSION) != rust_provider_fingerprint( + vocabulary.RUST_TAINT_VERSION + 1 + ) diff --git a/tests/unit/rust/test_qualname.py b/tests/unit/rust/test_qualname.py new file mode 100644 index 00000000..3ac19197 --- /dev/null +++ b/tests/unit/rust/test_qualname.py @@ -0,0 +1,214 @@ +"""WP2: the Rust qualname dialect (Loomweave ADR-049). + +The byte-for-byte entity-qualname obligation is pinned by the vendored corpus gate +(``tests/conformance/test_loomweave_rust_qualname_parity.py``). These focused tests +cover the two pieces the corpus exercises only thinly or not at all: ``rust_module_route`` +(path -> dotted module) and the ``@cfg`` predicate normaliser (whitespace strip + +``any()/all()`` arg sort — ADR-049 specifies it but the slice-1 corpus only has bare +``unix``/``windows``), plus the rename-stability invariant as a direct assertion. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.rust.index import discover_rust_entities # noqa: E402 +from wardline.rust.parse import parse_rust # noqa: E402 +from wardline.rust.qualname import ( # noqa: E402 + RUST_ONTOLOGY_VERSION, + RUST_PLUGIN_ID, + cfg_discriminant, + cfg_predicate_of, + entity_id, + normalize_cfg_predicate, + rust_module_route, +) + + +def test_entity_id_maps_method_and_validates_kind() -> None: + # entity_id mirrors loomweave's build_entity_id posture: `{plugin}:{kind}:{qualname}`, + # Wardline's semantic `method` maps to the id-kind `function` HERE (callers never + # pre-map), and a kind outside the ten-kind ADR-049 set raises. + assert entity_id("function", "demo.m.f") == "rust:function:demo.m.f" + assert entity_id("method", "demo.m.Foo.impl#<>.bar") == "rust:function:demo.m.Foo.impl#<>.bar" + assert entity_id("impl", "demo.m.Foo.impl#<>") == "rust:impl:demo.m.Foo.impl#<>" + assert entity_id("module", "demo.m") == "rust:module:demo.m" + assert entity_id("type_alias", "demo.m.Alias") == "rust:type_alias:demo.m.Alias" + with pytest.raises(ValueError, match="union"): + entity_id("union", "demo.m.U") + assert RUST_PLUGIN_ID == "rust" + assert RUST_ONTOLOGY_VERSION == "0.4.0" + + +def _callable_quals(source: str, module: str) -> set[str]: + """The CALLABLE qualnames of a source — these rendering tests pin method/fn + qualname bytes; the full ten-kind emission surface is test_index.py's job.""" + return {e.qualname for e in discover_rust_entities(source, module=module) if e.kind in ("function", "method")} + + +def test_module_route_root_files_contribute_no_segment() -> None: + assert rust_module_route(crate="demo", src_root="/p/src", file="/p/src/lib.rs") == "demo" + assert rust_module_route(crate="demo", src_root="/p/src", file="/p/src/main.rs") == "demo" + + +def test_module_route_flat_and_nested_files() -> None: + assert rust_module_route(crate="demo", src_root="/p/src", file="/p/src/config.rs") == "demo.config" + assert rust_module_route(crate="demo", src_root="/p/src", file="/p/src/plugin/host.rs") == "demo.plugin.host" + + +def test_module_route_mod_rs_collapses_to_directory() -> None: + assert rust_module_route(crate="demo", src_root="/p/src", file="/p/src/plugin/mod.rs") == "demo.plugin" + + +def test_normalize_cfg_strips_whitespace() -> None: + assert normalize_cfg_predicate("( unix )") == "unix" + assert normalize_cfg_predicate('(target_os = "linux")') == 'target_os="linux"' + + +def test_normalize_cfg_sorts_a_single_flat_any_all_like_the_oracle() -> None: + # The single top-level any()/all() case (the in-corpus shape) sorts its args. This + # mirrors qualname.rs normalise_pred's NAIVE split(',') byte-for-byte — we do NOT + # enshrine the deeper-nesting case, which the oracle deliberately mangles (the + # contract is byte-equality with the oracle, not a "nicer" canonical form). + assert normalize_cfg_predicate("(any(windows, unix))") == "any(unix,windows)" + assert normalize_cfg_predicate("(all(unix, windows))") == "all(unix,windows)" + + +def test_normalize_cfg_predicate_escapes_reserved_chars() -> None: + # % before : (order matters — injective, mirrors loomweave escape_reserved: + # the introducer is encoded first so a literal source `%3A` cannot alias a + # real escaped `:`; qualname.rs escape_reserved). + assert normalize_cfg_predicate('feature = "a:b"') == 'feature="a%3Ab"' + assert normalize_cfg_predicate('feature = "a%3Ab"') == 'feature="a%253Ab"' + + +def test_escape_happens_before_any_all_split() -> None: + # escape applies to the whole stripped pred BEFORE arg sorting (oracle order: + # qualname.rs normalise_pred — strip ws -> escape_reserved -> any()/all() sort). + assert normalize_cfg_predicate('any(feature = "a:b", unix)') == 'any(feature="a%3Ab",unix)' + + +def test_cfg_discriminant_folds_all_predicates_sorted() -> None: + # ALL predicates fold (each normalised, the set sorted, joined `&`) — mirrors + # loomweave cfg_discriminant (qualname.rs); pinned upstream by stacked_cfg_twin. + assert cfg_discriminant(["unix", 'feature = "a"']) == '@cfg(feature="a"&unix)' + assert cfg_discriminant(['feature = "a"', "unix"]) == '@cfg(feature="a"&unix)' # order-independent + + +def test_cfg_discriminant_normalizes_exactly_once() -> None: + # raw input with a reserved char escapes ONCE (no double-escape through the + # pipeline): index.py collects RAW predicates, cfg_discriminant is the single + # normalisation point — the layering that prevents `a:b` -> `a%253Ab`. + assert cfg_discriminant(['feature = "a:b"']) == '@cfg(feature="a%3Ab")' + + +def test_cfg_discriminant_rejects_empty_input() -> None: + # An empty predicate list would render the meaningless `@cfg()` and silently + # collide every "discriminated" twin onto it — a caller bug, surfaced loudly. + with pytest.raises(ValueError, match="predicate"): + cfg_discriminant([]) + + +def test_cfg_predicate_of_returns_raw_unnormalized_text() -> None: + # The collection-is-raw invariant (mirrors extract.rs cfg_predicates): the + # returned predicate keeps its outer argument parens, source spacing, and the + # UNESCAPED reserved `:` — cfg_discriminant is the single normalisation point + # (normalising here too would double-escape `a:b` -> `a%253Ab`). + tree = parse_rust('#[cfg(feature = "a:b")]\nfn f() {}\n') + attr = next(c for c in tree.root_node.children if c.type == "attribute_item") + assert cfg_predicate_of(attr) == '(feature = "a:b")' + + +def test_cfg_predicate_of_excludes_comment_tokens() -> None: + # A /* */ comment inside the predicate is NOT part of the oracle's token stream + # (proc-macro2 drops comments before cfg_predicates ever sees them), so the raw + # collected text excludes the comment bytes — everything else stays raw + # (the comment's surrounding source whitespace survives; normalize strips it). + tree = parse_rust("#[cfg(any(unix, /* why */ windows))]\npub fn g() {}\n") + attr = next(c for c in tree.root_node.children if c.type == "attribute_item") + raw = cfg_predicate_of(attr) + assert raw == "(any(unix, windows))" + assert normalize_cfg_predicate(raw) == "any(unix,windows)" + + +def test_positional_generics_are_rename_stable() -> None: + # and render to the identical positional ($0) locator — a param rename + # must not churn the qualname (ADR-049 De Bruijn rendering) — in BOTH the self-type + # prefix (Foo<$0>) and the inherent #<$0> signature (self-type-args amendment, ADR-049 + # §2). No source-order ordinal (ADR-049 amend, Option b). + with_t = "struct Foo(T);\nimpl Foo { fn get(&self) {} }\n" + with_u = "struct Foo(U);\nimpl Foo { fn get(&self) {} }\n" + t = _callable_quals(with_t, "demo.m") + u = _callable_quals(with_u, "demo.m") + assert t == u == {"demo.m.Foo<$0>.impl#<$0>.get"} + + +def test_positional_generics_count_type_params_only() -> None: + # syn generics.type_params() excludes lifetimes AND const params (ADR-049 amend); + # only `T` counts. impl<'a, const N: usize, T> -> one positional $0 in both prefix + # (Foo<$0>) and signature (impl#<$0>). + src = "struct Foo(T);\nimpl<'a, const N: usize, T> Foo { fn get(&self) {} }\n" + quals = _callable_quals(src, "demo.m") + assert quals == {"demo.m.Foo<$0>.impl#<$0>.get"} + + +def test_self_type_concrete_args_split_distinct_impls() -> None: + # ADR-049 §2 self-type-args amendment: distinct CONCRETE instantiations get distinct + # keys, so two like-named `get` methods do NOT collide/merge (the silent-data-loss + # family the amendment closes). Mirrors corpus generic_self_inherent_concrete_args. + src = "struct Foo(T);\nimpl Foo { fn get(&self) {} }\nimpl Foo { fn get(&self) {} }\n" + quals = _callable_quals(src, "demo.m") + assert quals == {"demo.m.Foo.impl#<>.get", "demo.m.Foo.impl#<>.get"} + + +def test_nested_self_type_param_renders_literal_not_positional() -> None: + # F2 nested-param rule: positional substitution is TOP-LEVEL only. A param nested + # inside another self-type arg keeps its LITERAL text (Foo>, NOT Foo>; + # Foo<&T>, NOT Foo<&$0>). Loomweave owes a nested-param corpus row, so THIS is the + # only guard against accidentally implementing recursive positional substitution. + vec = "struct Foo(T);\nimpl Foo> { fn get(&self) {} }\n" + ref = "struct Foo(T);\nimpl Foo<&T> { fn get(&self) {} }\n" + v = _callable_quals(vec, "demo.m") + r = _callable_quals(ref, "demo.m") + assert v == {"demo.m.Foo>.impl#<$0>.get"} + assert r == {"demo.m.Foo<&T>.impl#<$0>.get"} + + +def test_non_generic_self_type_renders_bare() -> None: + # A non-generic self type renders the bare name (no empty brackets) — unchanged by + # the self-type-args amendment. + src = "struct Foo;\nimpl Foo { fn bar(&self) {} }\n" + quals = _callable_quals(src, "demo.m") + assert quals == {"demo.m.Foo.impl#<>.bar"} + + +def test_empty_turbofish_self_type_does_not_emit_empty_brackets() -> None: + # `impl Foo<>` is malformed: tree-sitter error-recovers a blank generic arg (verified + # has_errors=True, so the scan path gates it before index_entities). The PURE producer + # must still not emit a `Foo<>` segment — Wardline filters the empty arg to bare `Foo`. + # (Not asserted as oracle-convergence: whether syn accepts `impl Foo<>` at all is + # unverified; either way neither producer emits a `Foo<>` entity for this input.) + src = "struct Foo;\nimpl Foo<> { fn bar(&self) {} }\n" + quals = _callable_quals(src, "demo.m") + assert quals == {"demo.m.Foo.impl#<>.bar"} + + +def test_trait_args_drop_lifetimes_and_bindings_and_omit_empty_brackets() -> None: + # qualname.rs trait_generic_args keeps only Type/Const args (lifetimes AND + # associated-type bindings dropped), and trait_impl omits <> when none survive. + binding = "struct F;\nimpl Iterator for F { fn next(&mut self) {} }\n" + lifetime = "struct F;\nimpl<'a> MyTrait<'a> for F { fn m(&self) {} }\n" + b = _callable_quals(binding, "m") + lt = _callable_quals(lifetime, "m") + assert b == {"m.F.impl[Iterator].next"} # binding dropped, no brackets + assert lt == {"m.F.impl[MyTrait].m"} # lifetime dropped, no empty <> + + +def test_cfg_twin_inherent_impls_split_by_at_cfg() -> None: + # Post-amendment the ordinal is gone, so a cfg-gated pair of same-signature inherent + # impls would COLLIDE to one qualname (silent finding-masking) without the @cfg split. + src = "struct Foo;\n#[cfg(unix)]\nimpl Foo { fn run(&self) {} }\n#[cfg(windows)]\nimpl Foo { fn run(&self) {} }\n" + quals = sorted(_callable_quals(src, "demo.m")) + assert quals == ["demo.m.Foo.impl#<>@cfg(unix).run", "demo.m.Foo.impl#<>@cfg(windows).run"] diff --git a/tests/unit/rust/test_rules.py b/tests/unit/rust/test_rules.py new file mode 100644 index 00000000..f03913fb --- /dev/null +++ b/tests/unit/rust/test_rules.py @@ -0,0 +1,246 @@ +"""WP5: the two rules end-to-end through RustAnalyzer (verdict layer). + +Drives the analyzer over @trusted-fn specimens and asserts the emitted Finding shape: +RS-WL-108 (program injection, ERROR, cites both lines), RS-WL-112 (shell injection, WARN), +de-confliction single-fire, the modulate-to-NONE suppressions, and pinned taint_path golden +strings (the Rust analog of golden-identity — taint_path serialization is fingerprint-folded). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.finding import Kind, Severity # noqa: E402 +from wardline.rust.analyzer import RustAnalyzer # noqa: E402 + +if TYPE_CHECKING: + from collections.abc import Sequence + + from wardline.core.finding import Finding + +_TRUSTED = "/// @trusted(level=ASSURED)\n" + + +def _findings(source: str) -> Sequence[Finding]: + return RustAnalyzer().analyze_source(source, module="demo.m", path="src/m.rs") + + +# A stepwise 108 specimen so the constructor (L4) and trigger (L5) are on DISTINCT lines. +_PROGRAM_INJECTION = ( + _TRUSTED + "fn f() {\n" + ' let t = std::env::var("X").unwrap();\n' + " let mut c = Command::new(t);\n" + " c.output();\n" + "}\n" +) +_SHELL_INJECTION = ( + _TRUSTED + "fn f() {\n" + ' let t = std::env::var("X").unwrap();\n' + ' Command::new("sh").arg("-c").arg(t).output();\n' + "}\n" +) + + +def test_program_injection_fires_error_citing_both_lines() -> None: + (f,) = _findings(_PROGRAM_INJECTION) + assert f.rule_id == "RS-WL-108" + assert f.severity is Severity.ERROR + assert f.kind is Kind.DEFECT + assert f.location.line_start == 5 # anchored at the .output() trigger + assert "line 4" in f.message and "line 5" in f.message # cites constructor AND trigger + assert f.qualname == "demo.m.f" + + +def test_shell_injection_fires_warn_anchored_at_trigger() -> None: + (f,) = _findings(_SHELL_INJECTION) + assert f.rule_id == "RS-WL-112" + assert f.severity is Severity.WARN + assert f.kind is Kind.DEFECT + assert f.location.line_start == 4 + + +def test_pinned_taint_path_golden_strings() -> None: + (prog,) = _findings(_PROGRAM_INJECTION) + (shell,) = _findings(_SHELL_INJECTION) + assert prog.properties["taint_path"] == "EXTERNAL_RAW->Command::new(program)@L4->exec@L5" + assert shell.properties["taint_path"] == "EXTERNAL_RAW->arg->'sh -c'->exec@L4" + + +def test_de_confliction_tainted_program_with_shell_flag_fires_once() -> None: + # Tainted program AND a -c flag: exactly ONE finding (RS-WL-108), not two (spec §9.2). + src = ( + _TRUSTED + "fn f() {\n" + ' let t = std::env::var("X").unwrap();\n' + ' Command::new(t).arg("-c").arg(t).output();\n' + "}\n" + ) + findings = _findings(src) + assert [f.rule_id for f in findings] == ["RS-WL-108"] + + +def test_non_shell_arg_does_not_fire() -> None: + src = _TRUSTED + 'fn f() {\n let t = std::env::var("X").unwrap();\n Command::new("ls").arg(t).output();\n}\n' + assert _findings(src) == [] + + +def test_unmarked_fn_is_suppressed_by_modulate() -> None: + # No @trusted marker -> UNKNOWN_RAW tier -> modulate(_, UNKNOWN_RAW) == NONE -> nothing. + src = 'fn f() {\n let t = std::env::var("X").unwrap();\n Command::new("sh").arg("-c").arg(t).output();\n}\n' + assert _findings(src) == [] + + +_SEED = ' let t = std::env::var("X").unwrap();\n' + + +@pytest.mark.parametrize( + "terminal", + [ + "Command::new(t).output();", # baseline + "Command::new(t).output()?;", # ? operator — the dominant Rust spawn idiom + "Command::new(t).output().await;", # async + "Command::new(t).output().unwrap();", # call wrapper + "return Command::new(t).output();", # return position + ], +) +def test_program_injection_fires_through_idiomatic_terminators(terminal: str) -> None: + # Regression: ?/await/return-wrapped Command calls were silently dropped (the widest + # Tier-A hole the WP4+WP5 panel found). Each must still fire RS-WL-108. + src = _TRUSTED + "fn f() {\n" + _SEED + " " + terminal + "\n}\n" + assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] + + +def test_program_injection_fires_in_tail_position() -> None: + # A block's tail expression (no trailing `;`) is a bare call_expression child, not an + # expression_statement — it was dropped. Must fire. + src = _TRUSTED + "fn f() {\n" + _SEED + " Command::new(t).output()\n}\n" + assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] + + +@pytest.mark.parametrize("program", ["/bin/sh", "/usr/bin/bash", "powershell"]) +def test_shell_injection_recognizes_path_qualified_and_powershell_shells(program: str) -> None: + flag = "-Command" if program == "powershell" else "-c" + src = _TRUSTED + "fn f() {\n" + _SEED + f' Command::new("{program}").arg("{flag}").arg(t).output();\n}}\n' + assert [f.rule_id for f in _findings(src)] == ["RS-WL-112"] + + +def test_rebind_to_a_clean_value_clears_stale_taint() -> None: + # Regression: a local re-bound to a provably-clean literal must drop its prior taint + # (a false positive the analyzer has full information to avoid). + src = _TRUSTED + "fn f() {\n" + _SEED + ' let t = "safe";\n Command::new(t).output();\n}\n' + assert _findings(src) == [] + + +def test_assignment_reassign_to_clean_command_clears_stale_tainted_builder() -> None: + # An *assignment* re-bind (`cmd = ...;`, not a `let`) must clear a tracked builder the + # same way `let` does. Here `cmd` is rebuilt with a CLEAN literal program, so `cmd.output()` + # must reconstruct the clean builder — no RS-WL-108. Before the fix the assignment statement + # was dropped (its node is an assignment_expression, not a call), leaving the stale tainted + # L4 builder and firing a phantom RS-WL-108 ERROR (FP at the gating severity) on safe code. + src = ( + _TRUSTED + "fn f() {\n" + _SEED + " let mut cmd = Command::new(t);\n" + ' cmd = Command::new("/usr/bin/ls");\n' + ' cmd.arg("-la");\n' + " cmd.output();\n}\n" + ) + assert _findings(src) == [] + + +def test_assignment_reassign_to_tainted_command_fires() -> None: + # The inverse: a CLEAN builder reassigned to a tainted-program builder must now fire (the + # actually-attacker-controlled exec). Before the fix this was a silent false negative. + src = ( + _TRUSTED + "fn f() {\n" + _SEED + ' let mut cmd = Command::new("/usr/bin/ls");\n' + " cmd = Command::new(t);\n" + " cmd.output();\n}\n" + ) + assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] + + +def test_assignment_reassign_to_non_command_drops_the_builder() -> None: + # Reassigning a Command-bound name to a non-command must drop the tracked builder entirely; + # a later `.output()` on it is a method call on some other value, not a phantom spawn. + src = ( + _TRUSTED + "fn f() {\n" + _SEED + " let mut cmd = Command::new(t);\n" + " cmd = make_safe_command();\n" + " cmd.output();\n}\n" + ) + assert _findings(src) == [] + + +@pytest.mark.parametrize( + "ctor", + [ + "tokio::process::Command::new(t).output().await", # the dominant async command sink + "async_process::Command::new(t).output().await", # smol/async-std ecosystem + ], +) +def test_async_ecosystem_command_sinks_fire(ctor: str) -> None: + # The crate qualifier must ADMIT the declared async-runtime Command sinks, not just std. + # tokio::process::Command genuinely spawns an OS process, so a tainted program there is a + # true RS-WL-108 — losing it (an FN) would blind the tool to the most common real sink. + src = _TRUSTED + "fn f() {\n" + _SEED + f" {ctor};\n}}\n" + assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] + + +def test_foreign_crate_command_new_does_not_fire() -> None: + # The vocab declares the sink for crate `std` (std::process::Command::new). A DIFFERENT + # crate's `Command::new` (e.g. a user CQRS/command-bus type) spawns no OS process and must + # not fire RS-WL-108 at ERROR. Matching is crate-consistent (a trailing segment-suffix of + # the crate-qualified std path), so an explicit foreign root is rejected. + src = _TRUSTED + "fn f() {\n" + _SEED + " mycrate::Command::new(t).output();\n}\n" + assert _findings(src) == [] + + +@pytest.mark.parametrize( + "ctor", + [ + "Command::new(t)", # bare (use std::process::Command) — the documented aliasing + "process::Command::new(t)", # use std::process + "std::process::Command::new(t)", # fully qualified + ], +) +def test_std_command_new_aliases_still_fire(ctor: str) -> None: + # No-regression: every crate-consistent spelling of the std sink must still fire. + src = _TRUSTED + "fn f() {\n" + _SEED + f" {ctor}.output();\n}}\n" + assert [f.rule_id for f in _findings(src)] == ["RS-WL-108"] + + +def test_foreign_crate_env_var_is_not_a_taint_source() -> None: + # The crate qualifier is honored on the SOURCE side too: a non-std `env::var` (some other + # crate's like-named fn) is not the std external-input source, so a program built from it + # is clean and RS-WL-108 must not fire. + src = _TRUSTED + 'fn f() {\n let t = myconfig::env::var("X").unwrap();\n Command::new(t).output();\n}\n' + assert _findings(src) == [] + + +def test_a_typoed_trusted_marker_does_not_abort_the_whole_file_scan() -> None: + # A malformed @trusted level must fail closed for that fn, not crash the scan. + src = "/// @trusted(level=BOGUS)\nfn f() {\n" + _SEED + " Command::new(t).output();\n}\n" + assert _findings(src) == [] # fail-closed, no exception + + +def test_two_commands_on_one_line_get_distinct_fingerprints() -> None: + # The no-collision invariant: two DISTINCT triggers on the same physical line (identical + # taint_path) must not share a fingerprint (the NodeId disambiguates). + src = _TRUSTED + "fn f() {\n" + _SEED + " Command::new(t).output(); Command::new(t).spawn();\n}\n" + fps = [f.fingerprint for f in _findings(src)] + assert len(fps) == 2 and len(set(fps)) == 2 + + +def test_guarded_tier_downgrades_severity() -> None: + # @trusted(level=GUARDED) -> modulate(ERROR, GUARDED) == WARN (the partial-trust downgrade). + src = ( + "/// @trusted(level=GUARDED)\n" + "fn f() {\n" + ' let t = std::env::var("X").unwrap();\n' + " let mut c = Command::new(t);\n" + " c.output();\n" + "}\n" + ) + (f,) = _findings(src) + assert f.rule_id == "RS-WL-108" + assert f.severity is Severity.WARN # downgraded from ERROR diff --git a/tests/unit/rust/test_rust_identity_graduated.py b/tests/unit/rust/test_rust_identity_graduated.py new file mode 100644 index 00000000..0ce9d865 --- /dev/null +++ b/tests/unit/rust/test_rust_identity_graduated.py @@ -0,0 +1,118 @@ +"""SP2 graduation: RS-WL-* findings are full citizens of the suppression machinery. + +The pre-SP2 ``properties["provisional_identity"]`` short-circuits (never baseline-match, +never baseline-capture) are GONE — Rust identity is frozen (crate-prefixed, gated by +``tests/golden/identity/rust/``), so an RS-WL-* finding: +* IS matched against a committed baseline entry like any other defect; and +* IS written into a generated baseline. + +These tests are the inversion of the retired ``test_provisional_identity.py``. A stray +``provisional_identity`` property (e.g. from a stale producer) must be inert — no code +path consults it anymore. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pytest + +from wardline.core.baseline import Baseline, build_baseline_document +from wardline.core.finding import Finding, Kind, Location, Severity, SuppressionState +from wardline.core.judged import JudgedFP, JudgedSet +from wardline.core.suppression import apply_suppressions, gate_trips +from wardline.core.waivers import Waiver, WaiverSet + + +def _rs_finding(*, stray_provisional_prop: bool = False) -> Finding: + props: dict[str, object] = {"taint_path": "x"} + if stray_provisional_prop: + props["provisional_identity"] = True + return Finding( + rule_id="RS-WL-108", + message="program injection", + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="src/m.rs", line_start=3, line_end=3), + fingerprint="a" * 64, + qualname="m.f", + properties=props, + ) + + +def test_rs_finding_is_suppressed_by_a_matching_baseline() -> None: + f = _rs_finding() + baseline = Baseline(frozenset({"a" * 64})) # a committed entry keyed to its exact fingerprint + (out,) = apply_suppressions([f], baseline, WaiverSet([]), today=date(2026, 6, 10)) + assert out.suppressed is SuppressionState.BASELINED # graduated: baseline-eligible + + +def test_rs_finding_is_captured_in_a_generated_baseline() -> None: + doc = build_baseline_document([_rs_finding()]) + assert [e["fingerprint"] for e in doc["entries"]] == ["a" * 64] + + +def test_rs_finding_is_suppressed_by_a_matching_waiver() -> None: + # Graduation covers the WHOLE suppression machinery, not just the baseline leg: + # a hand-authored waiver keyed to the RS-WL fingerprint must resolve WAIVED + # (waiver > judged > baseline precedence lives in resolve_identity). + f = _rs_finding() + waivers = WaiverSet([Waiver(fingerprint="a" * 64, reason="accepted: sandboxed CLI", expires=None)]) + (out,) = apply_suppressions([f], Baseline(frozenset()), waivers, today=date(2026, 6, 10)) + assert out.suppressed is SuppressionState.WAIVED + assert out.suppression_reason # the waiver's reason travels with the verdict + + +def test_rs_finding_is_suppressed_by_a_judged_false_positive() -> None: + f = _rs_finding() + judged = JudgedSet( + [ + JudgedFP( + fingerprint="a" * 64, + rule_id="RS-WL-108", + path="src/m.rs", + message="program injection", + rationale="the program is a vetted constant at runtime", + model_id="test-judge", + confidence=0.97, + recorded_at=datetime(2026, 6, 10, tzinfo=UTC), + policy_hash="p" * 8, + ) + ] + ) + (out,) = apply_suppressions([f], Baseline(frozenset()), WaiverSet([]), today=date(2026, 6, 10), judged=judged) + assert out.suppressed is SuppressionState.JUDGED + + +def test_active_rs_error_trips_the_gate() -> None: + # An ACTIVE (unsuppressed) RS-WL-108 ERROR participates in --fail-on like any + # Python defect — graduated findings are gate citizens, not advisory. + assert gate_trips([_rs_finding()], Severity.ERROR) is True + + +def test_producer_no_longer_emits_the_provisional_property() -> None: + # The real RS-WL-* producer (rules.py) emits no retired identity flag. + pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + from wardline.rust.analyzer import RustAnalyzer + + source = ( + "/// @trusted(level=ASSURED)\n" + "fn f() {\n" + ' let t = std::env::var("X").unwrap();\n' + " Command::new(t).output();\n" + "}\n" + ) + (finding,) = RustAnalyzer().analyze_source(source, module="demo.m", path="src/m.rs") + assert finding.rule_id == "RS-WL-108" + assert "provisional_identity" not in finding.properties + + +def test_stray_provisional_property_is_inert() -> None: + # The plumbing is removed, not bypassed: a finding still carrying the retired + # property baselines and captures exactly like any other defect. + f = _rs_finding(stray_provisional_prop=True) + baseline = Baseline(frozenset({"a" * 64})) + (out,) = apply_suppressions([f], baseline, WaiverSet([]), today=date(2026, 6, 10)) + assert out.suppressed is SuppressionState.BASELINED + doc = build_baseline_document([f]) + assert [e["fingerprint"] for e in doc["entries"]] == ["a" * 64] diff --git a/tests/unit/rust/test_tree_sitter_loader.py b/tests/unit/rust/test_tree_sitter_loader.py new file mode 100644 index 00000000..e5ecb864 --- /dev/null +++ b/tests/unit/rust/test_tree_sitter_loader.py @@ -0,0 +1,44 @@ +"""WP0: the lazy tree-sitter loader (the ``wardline[rust]`` extra). + +``pytest.importorskip`` is the FIRST executable statement so that, under an +interpreter WITHOUT the extra, this module skips cleanly *before* importing +``wardline.rust`` — that is the empirical proof of Verification §6 (run this dir +under the extra-free venv and it skips, never ImportErrors). +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from tree_sitter import Language, Parser # noqa: E402 (after importorskip by design) + +from wardline.core.errors import WardlineError # noqa: E402 +from wardline.rust._tree_sitter import RustToolingError, require_rust # noqa: E402 + + +def test_require_rust_returns_usable_language_and_parser() -> None: + language, parser = require_rust() + assert isinstance(language, Language) + assert isinstance(parser, Parser) + + +def test_require_rust_round_trips_rust_source() -> None: + _, parser = require_rust() + tree = parser.parse(b"fn main(){}") + assert tree.root_node.type == "source_file" + # confirm it is the *Rust* grammar, not some other language + assert any(child.type == "function_item" for child in tree.root_node.named_children) + + +def test_require_rust_returns_a_fresh_parser_each_call() -> None: + # No shared parser state leaks between callers. + _, p1 = require_rust() + _, p2 = require_rust() + assert p1 is not p2 + + +def test_rust_tooling_error_is_a_wardline_error() -> None: + # The lazy guard raises an actionable error inside the Wardline hierarchy. + assert issubclass(RustToolingError, WardlineError) diff --git a/tests/unit/rust/test_vocabulary.py b/tests/unit/rust/test_vocabulary.py new file mode 100644 index 00000000..840a1d31 --- /dev/null +++ b/tests/unit/rust/test_vocabulary.py @@ -0,0 +1,66 @@ +"""WP3: the bundled Rust trust vocabulary (sources + command sinks).""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("tree_sitter", reason="wardline[rust] extra not installed") + +from wardline.core.taints import TaintState # noqa: E402 +from wardline.rust.vocabulary import ( # noqa: E402 + RUST_TAINT_VERSION, + RustSink, + RustSource, + _build_tables, + load_rust_taint, +) + + +def test_version_is_exported() -> None: + assert isinstance(RUST_TAINT_VERSION, int) and RUST_TAINT_VERSION >= 1 + + +def test_bundled_table_has_the_command_source_and_sink() -> None: + tables = load_rust_taint() + env_var = tables.sources[("std", "env::var")] + assert isinstance(env_var, RustSource) + assert env_var.returns_taint is TaintState.EXTERNAL_RAW + + cmd = tables.sinks[("std", "process::Command::new")] + assert isinstance(cmd, RustSink) + assert cmd.sink_kind == "command" + + +def test_returns_taint_constrained_to_legal_tiers() -> None: + # INTEGRAL and the unreachable trio must never enter the pipeline (reachable-set + # invariant); a source returning your-own-fully-trusted data is nonsensical. + raw = { + "version": RUST_TAINT_VERSION, + "sources": [{"crate": "std", "path": "x::y", "returns_taint": "INTEGRAL", "rationale": "r"}], + "sinks": [], + } + with pytest.raises(ValueError, match="legal"): + _build_tables(raw) + + +def test_duplicate_source_key_rejected() -> None: + raw = { + "version": RUST_TAINT_VERSION, + "sources": [ + {"crate": "std", "path": "env::var", "returns_taint": "EXTERNAL_RAW", "rationale": "a"}, + {"crate": "std", "path": "env::var", "returns_taint": "EXTERNAL_RAW", "rationale": "b"}, + ], + "sinks": [], + } + with pytest.raises(ValueError, match="duplicate"): + _build_tables(raw) + + +def test_unknown_sink_kind_rejected() -> None: + raw = { + "version": RUST_TAINT_VERSION, + "sources": [], + "sinks": [{"crate": "std", "path": "p::q", "sink_kind": "bogus", "rationale": "r"}], + } + with pytest.raises(ValueError, match="sink_kind"): + _build_tables(raw) diff --git a/tests/unit/scanner/rules/test_assert_only_boundary.py b/tests/unit/scanner/rules/test_assert_only_boundary.py index 37ad1ec4..4fdd751c 100644 --- a/tests/unit/scanner/rules/test_assert_only_boundary.py +++ b/tests/unit/scanner/rules/test_assert_only_boundary.py @@ -36,7 +36,8 @@ def test_assert_only_boundary_fires_111_not_102(tmp_path) -> None: def test_no_rejection_at_all_is_102_not_111(tmp_path) -> None: - ctx = _analyze(tmp_path, _BOUNDARY + " return p\n") + # Laundered pass-through (NOT the bare degenerate shape, which is PY-WL-119's). + ctx = _analyze(tmp_path, _BOUNDARY + " x = p\n return x\n") assert AssertOnlyBoundary().check(ctx) == [] assert _ids(ctx) == {"PY-WL-102"} @@ -72,3 +73,43 @@ def test_trusted_producer_is_not_a_boundary(tmp_path) -> None: def test_undecorated_assert_is_silent(tmp_path) -> None: ctx = _analyze(tmp_path, "def v(p):\n assert p\n return p\n") assert AssertOnlyBoundary().check(ctx) == [] + + +def test_assert_plus_raising_helper_is_clean(tmp_path) -> None: + # boundary.json FP 2: a same-module helper that raises survives `python -O`, so + # the assert is NOT the sole rejection — 111 (and 102) must stay silent. + ctx = _analyze( + tmp_path, + "from wardline.decorators import trust_boundary\n" + "def _require_nonempty(p):\n if not p:\n raise ValueError('empty')\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n assert isinstance(p, str)\n _require_nonempty(p)\n return p\n", + ) + assert AssertOnlyBoundary().check(ctx) == [] + assert _ids(ctx) == set() + + +def test_assert_plus_non_raising_helper_still_fires_111(tmp_path) -> None: + # SOUNDNESS GUARD: a helper that cannot raise (logs and returns) does not rescue + # the boundary — the assert remains the sole rejection. + ctx = _analyze( + tmp_path, + "from wardline.decorators import trust_boundary\n" + "def _log(p):\n print(p)\n return p\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n assert p\n _log(p)\n return p\n", + ) + assert [(f.rule_id, f.qualname) for f in AssertOnlyBoundary().check(ctx)] == [("PY-WL-111", "m.v")] + + +def test_assert_plus_assert_only_helper_still_fires_111(tmp_path) -> None: + # A helper whose only rejection is itself an assert vanishes under -O too — + # it must not count as a real rejection. + ctx = _analyze( + tmp_path, + "from wardline.decorators import trust_boundary\n" + "def _check(p):\n assert p\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n assert isinstance(p, str)\n _check(p)\n return p\n", + ) + assert [(f.rule_id, f.qualname) for f in AssertOnlyBoundary().check(ctx)] == [("PY-WL-111", "m.v")] diff --git a/tests/unit/scanner/rules/test_ast_helpers.py b/tests/unit/scanner/rules/test_ast_helpers.py index 40fc7524..c7471400 100644 --- a/tests/unit/scanner/rules/test_ast_helpers.py +++ b/tests/unit/scanner/rules/test_ast_helpers.py @@ -3,12 +3,17 @@ import ast import textwrap +from wardline.scanner.index import discover_file_entities from wardline.scanner.rules._ast_helpers import ( asserts_are_sole_rejection, + block_has_real_rejection, + has_real_rejection, has_rejection_path, is_broad_except, + is_degenerate_boundary, is_silent_handler, own_except_handlers, + rejecting_helper_calls, ) @@ -30,6 +35,13 @@ def test_has_rejection_path_detects_raise_and_falsy_returns() -> None: assert not has_rejection_path(_fn("def f(p):\n x = p\n return x\n")) +def test_unreachable_rejections_do_not_count() -> None: + assert not has_rejection_path(_fn("def f(p):\n return p\n raise ValueError\n")) + assert not has_real_rejection(_fn("def f(p):\n return p\n raise ValueError\n")) + assert not has_rejection_path(_fn("def f(p):\n if c:\n return p\n else:\n return p\n raise ValueError\n")) + assert not asserts_are_sole_rejection(_fn("def f(p):\n return p\n assert p\n")) + + def test_asserts_are_sole_rejection() -> None: # only an assert -> True (PY-WL-111's case) assert asserts_are_sole_rejection(_fn("def f(p):\n assert p\n return p\n")) @@ -41,6 +53,176 @@ def test_asserts_are_sole_rejection() -> None: assert not asserts_are_sole_rejection(_fn("def f(p):\n return p\n")) +def test_has_rejection_path_sees_conditional_expression_falsy_branch() -> None: + # `return m.group(0) if m else None` is the ternary form of the recognised + # `if not m: return None` rejection — semantically identical, must count. + assert has_rejection_path(_fn("def f(p):\n m = check(p)\n return m.group(0) if m else None\n")) + assert has_rejection_path(_fn("def f(p):\n return p if ok(p) else False\n")) + # nested ternary: a falsy branch anywhere in the conditional tree counts + assert has_rejection_path(_fn("def f(p):\n return (a if x else None) if c else b\n")) + # a ternary with NO falsy branch is not a rejection + assert not has_rejection_path(_fn("def f(p):\n return a if c else b\n")) + + +def test_has_rejection_path_curated_raising_conversion_returns() -> None: + # Curated validate-by-construction shapes: the conversion/lookup raises on + # every invalid input (ValueError / KeyError), so the boundary CAN reject. + assert has_rejection_path(_fn("def f(p):\n return int(p)\n")) + assert has_rejection_path(_fn("def f(p):\n return float(p)\n")) + assert has_rejection_path(_fn("def f(p):\n return Decimal(p)\n")) + assert has_rejection_path(_fn("def f(p):\n return decimal.Decimal(p)\n")) + assert has_rejection_path(_fn("def f(p):\n return uuid.UUID(p)\n")) + # Enum subscript and mapping/allowlist subscript lookup raise KeyError + assert has_rejection_path(_fn("def f(p):\n return Color[p]\n")) + assert has_rejection_path(_fn("def f(p):\n return ALLOWED[p]\n")) + + +def test_raising_conversion_set_is_curated_not_arbitrary() -> None: + # SOUNDNESS: an arbitrary call is NOT a rejection (that would be an FN hole). + assert not has_rejection_path(_fn("def f(p):\n return frobnicate(p)\n")) + # str()/bool() never reject; not in the curated set + assert not has_rejection_path(_fn("def f(p):\n return str(p)\n")) + # a conversion of nothing / of a constant validates nothing + assert not has_rejection_path(_fn("def f(p):\n return int()\n")) + assert not has_rejection_path(_fn("def f(p):\n return int('5')\n")) + # a constant subscript is positional access, not a validating lookup + assert not has_rejection_path(_fn("def f(p):\n return parts[0]\n")) + + +def test_asserts_are_sole_rejection_sees_extended_rejection_returns() -> None: + # A raising-conversion or ternary-falsy return is a REAL (non-assert) rejection, + # so the assert is not the sole rejection -> PY-WL-111 stays silent. + assert not asserts_are_sole_rejection(_fn("def f(p):\n assert p\n return int(p)\n")) + assert not asserts_are_sole_rejection(_fn("def f(p):\n assert p\n m = c(p)\n return m.g() if m else None\n")) + + +def test_has_real_rejection_excludes_assert() -> None: + # has_real_rejection is the production-surviving predicate: assert alone is NOT real. + assert not has_real_rejection(_fn("def f(p):\n assert p\n return p\n")) + assert has_real_rejection(_fn("def f(p):\n if not p:\n raise ValueError\n return p\n")) + assert has_real_rejection(_fn("def f(p):\n if not p:\n return None\n return p\n")) + assert not has_real_rejection(_fn("def f(p):\n return p\n")) + + +def _entities(src: str): + tree = ast.parse(textwrap.dedent(src)) + ents = discover_file_entities(tree, module="m", path="m.py") + return {e.qualname: e for e in ents} + + +def test_rejecting_helper_calls_one_hop_lexical_fallback() -> None: + ents = _entities( + """ + def _require_nonempty(p): + if not p: + raise ValueError("empty") + def v(p): + _require_nonempty(p) + return p + """ + ) + calls = rejecting_helper_calls(ents["m.v"], ents, {}) + assert len(calls) == 1 + + +def test_rejecting_helper_call_after_return_does_not_count() -> None: + ents = _entities( + """ + def _require_nonempty(p): + if not p: + raise ValueError("empty") + def v(p): + return p + _require_nonempty(p) + """ + ) + assert rejecting_helper_calls(ents["m.v"], ents, {}) == frozenset() + + +def test_rejecting_helper_calls_staticmethod_helper() -> None: + ents = _entities( + """ + class Validators: + @staticmethod + def require(p): + if not p: + raise ValueError("empty") + def v(p): + Validators.require(p) + return p + """ + ) + assert len(rejecting_helper_calls(ents["m.v"], ents, {})) == 1 + + +def test_rejecting_helper_calls_rejects_non_raising_helper() -> None: + # SOUNDNESS GUARD: a helper that cannot raise (logs and returns) is NOT a rejection. + ents = _entities( + """ + def _log(p): + print(p) + return p + def v(p): + _log(p) + return p + """ + ) + assert rejecting_helper_calls(ents["m.v"], ents, {}) == frozenset() + + +def test_rejecting_helper_calls_assert_only_helper_does_not_count() -> None: + # A helper whose only rejection is assert vanishes under -O just like an inline + # assert; it is not a REAL one-hop rejection (keeps the 102/111 partition honest). + ents = _entities( + """ + def _check(p): + assert p + def v(p): + _check(p) + return p + """ + ) + assert rejecting_helper_calls(ents["m.v"], ents, {}) == frozenset() + + +def test_rejecting_helper_calls_is_same_module_only() -> None: + # One-hop SAME-MODULE only: a resolved cross-module callee does not count. + ents_m = _entities("def v(p):\n helper(p)\n return p\n") + other = _entities("def helper(p):\n if not p:\n raise ValueError\n") + # graft the foreign entity (different path) into the lookup table under the + # qualname the resolver would report + tree = ast.parse("def helper(p):\n if not p:\n raise ValueError\n") + foreign = discover_file_entities(tree, module="n", path="n.py")[0] + entities = {**ents_m, foreign.qualname: foreign} + call = next(n for n in ast.walk(ents_m["m.v"].node) if isinstance(n, ast.Call)) + assert rejecting_helper_calls(ents_m["m.v"], entities, {id(call): "n.helper"}) == frozenset() + assert other # silence unused warning + + +def test_block_has_real_rejection_scans_statement_lists() -> None: + fn = _fn( + """ + def f(p): + try: + if not p: + raise ValueError + except ValueError: + return p + """ + ) + try_stmt = fn.body[0] + assert isinstance(try_stmt, ast.Try) + assert block_has_real_rejection(try_stmt.body) + assert not block_has_real_rejection(try_stmt.handlers[0].body) + + +def test_is_degenerate_boundary_shapes() -> None: + assert is_degenerate_boundary(_fn("def f(p):\n return p\n")) + assert is_degenerate_boundary(_fn("def f(p):\n 'doc'\n return p\n")) + assert not is_degenerate_boundary(_fn("def f(p):\n x = p\n return x\n")) + assert not is_degenerate_boundary(_fn("def f(p):\n return g(p)\n")) + + def test_own_except_handlers_skips_nested_functions() -> None: fn = _fn( "def f():\n" diff --git a/tests/unit/scanner/rules/test_boundary_partition.py b/tests/unit/scanner/rules/test_boundary_partition.py new file mode 100644 index 00000000..d61380ac --- /dev/null +++ b/tests/unit/scanner/rules/test_boundary_partition.py @@ -0,0 +1,213 @@ +"""Boundary-integrity family partition oracle (wardline-718048a518). + +The four rules partition the declared-boundary defect space — AT MOST ONE of +{PY-WL-102, PY-WL-111, PY-WL-113, PY-WL-119} fires per boundary: + + - PY-WL-119 — the bare degenerate shape (single ``return ``); + - PY-WL-102 — every other shape with NO rejection path; + - PY-WL-111 — the only rejection is ``assert`` (vanishes under ``python -O``); + - PY-WL-113 — a real rejection exists but a fail-open handler defeats it. + +This file is the executable form of that contract: each canonical shape is run +through the full analyzer and pinned to EXACTLY its owning rule. The partition +regressed silently before because no test asserted exactly-one-of; these do. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from wardline.core.config import WardlineConfig +from wardline.scanner.analyzer import WardlineAnalyzer + +FAMILY = frozenset({"PY-WL-102", "PY-WL-111", "PY-WL-113", "PY-WL-119"}) + +_HEADER = "from wardline.decorators import trust_boundary\n" + + +def _family_ids(tmp_path: Path, src: str) -> set[str]: + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + findings = WardlineAnalyzer().analyze([p], WardlineConfig(), root=tmp_path) + return {f.rule_id for f in findings if f.rule_id in FAMILY} + + +_CASES = [ + pytest.param( + """ + @trust_boundary(to_level='ASSURED') + def v(p): + return p + """, + {"PY-WL-119"}, + id="degenerate-bare-passthrough-is-119-only", + ), + pytest.param( + """ + @trust_boundary(to_level='ASSURED') + def v(p): + x = p + return x + """, + {"PY-WL-102"}, + id="laundered-passthrough-is-102-only", + ), + pytest.param( + """ + @trust_boundary(to_level='ASSURED') + def v(p): + assert p + return p + """, + {"PY-WL-111"}, + id="assert-only-is-111-only", + ), + pytest.param( + # canonical self-catch fail-open + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + if not p: + raise ValueError + return p + except ValueError: + return p + """, + {"PY-WL-113"}, + id="self-catch-failopen-is-113-only", + ), + pytest.param( + # wardline-718048a518 repro A: no rejection anywhere + substituting handler + """ + def compute(p): + return p + @trust_boundary(to_level='ASSURED') + def v(p): + try: + x = compute(p) + except Exception: + return p + return x + """, + {"PY-WL-102"}, + id="repro-a-no-rejection-substituting-handler-is-102-only", + ), + pytest.param( + # wardline-718048a518 repro B: assert-only rejection + substituting handler + """ + @trust_boundary(to_level='ASSURED') + def v(p): + assert p + try: + return p + except Exception: + return "x" + """, + {"PY-WL-111"}, + id="repro-b-assert-only-substituting-handler-is-111-only", + ), + pytest.param( + # docstring precedence: the assert IS inside the try and caught by a + # substituting handler — the rejection is still assert-only, so 111 wins. + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + assert p + return p + except AssertionError: + return "x" + """, + {"PY-WL-111"}, + id="assert-inside-try-swallowed-is-111-not-113", + ), + pytest.param( + # clean validator: real raise, fail-closed + """ + @trust_boundary(to_level='ASSURED') + def v(p): + if not p: + raise ValueError + return p + """, + set(), + id="raising-validator-is-clean", + ), + pytest.param( + # boundary.json FP 5: rejection outside the try; cache-miss fallback handler + """ + _cache = {} + @trust_boundary(to_level='ASSURED') + def v(p): + if not p.isdigit(): + raise ValueError + try: + cached = _cache[p] + return cached + except KeyError: + result = int(p) + _cache[p] = result + return result + """, + set(), + id="fail-closed-cache-fallback-is-clean", + ), + pytest.param( + # one-hop helper rejection: clean for 102/111, and fail-closed (no try) + """ + def _require_nonempty(p): + if not p: + raise ValueError("empty") + @trust_boundary(to_level='ASSURED') + def v(p): + _require_nonempty(p) + return p + """, + set(), + id="helper-rejecting-validator-is-clean", + ), + pytest.param( + # delegation to a raising boundary: single Return of a CALL — not degenerate + """ + @trust_boundary(to_level='ASSURED') + def inner(p): + if not p: + raise ValueError + return p + @trust_boundary(to_level='ASSURED') + def v(p): + return inner(p) + """, + set(), + id="delegating-validator-is-clean", + ), + pytest.param( + # helper rejection inside the try, swallowed by a substituting handler: + # the rejection exists (not 102) and is not assert-only (not 111) — 113 owns it. + """ + def _require_nonempty(p): + if not p: + raise ValueError("empty") + @trust_boundary(to_level='ASSURED') + def v(p): + try: + _require_nonempty(p) + return p + except ValueError: + return p + """, + {"PY-WL-113"}, + id="helper-rejection-swallowed-is-113-only", + ), +] + + +@pytest.mark.parametrize(("src", "expected"), _CASES) +def test_family_partition_exactly_one_owner(tmp_path: Path, src: str, expected: set[str]) -> None: + ids = _family_ids(tmp_path, src) + assert ids == expected + assert len(ids) <= 1, f"family partition violated — co-fired: {sorted(ids)}" diff --git a/tests/unit/scanner/rules/test_boundary_without_rejection.py b/tests/unit/scanner/rules/test_boundary_without_rejection.py index 9308a07f..9e2aa7c6 100644 --- a/tests/unit/scanner/rules/test_boundary_without_rejection.py +++ b/tests/unit/scanner/rules/test_boundary_without_rejection.py @@ -27,12 +27,14 @@ def _run(ctx): def test_boundary_without_rejection_fires(tmp_path) -> None: - # @trust_boundary that just returns its input — cannot reject -> DEFECT. + # @trust_boundary that launders its input through a local — cannot reject -> DEFECT. + # (The bare single-statement `return p` shape is PY-WL-119's domain now; 102 owns + # every OTHER no-rejection shape.) ctx, _ = _analyze( tmp_path, { "m.py": "from wardline.decorators import trust_boundary\n" - "@trust_boundary(to_level='ASSURED')\ndef v(p):\n return p\n", + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n x = p\n return x\n", }, ) findings = _run(ctx) @@ -40,6 +42,20 @@ def test_boundary_without_rejection_fires(tmp_path) -> None: assert findings[0].kind == Kind.DEFECT +def test_degenerate_shape_is_119s_domain_102_suppressed(tmp_path) -> None: + # Suppress-and-delegate: the bare degenerate boundary (`return p`) is PY-WL-119's + # finding; 102 must NOT double-fire on it (wardline-718048a518 four-way partition). + ctx, findings = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n return p\n", + }, + ) + assert _run(ctx) == [] + assert [(f.rule_id, f.qualname) for f in findings if f.rule_id == "PY-WL-119"] == [("PY-WL-119", "m.v")] + + def test_boundary_with_raise_is_clean(tmp_path) -> None: ctx, _ = _analyze( tmp_path, @@ -52,6 +68,18 @@ def test_boundary_with_raise_is_clean(tmp_path) -> None: assert _run(ctx) == [] +def test_unreachable_raise_does_not_rescue_boundary(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n x = p\n return x\n raise ValueError\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + def test_boundary_with_falsy_return_is_clean(tmp_path) -> None: ctx, _ = _analyze( tmp_path, @@ -81,3 +109,141 @@ def test_non_boundary_decorators_are_ignored(tmp_path) -> None: def test_undecorated_is_silent(tmp_path) -> None: ctx, _ = _analyze(tmp_path, {"m.py": "def v(p):\n return p\n"}) assert _run(ctx) == [] + + +# ── One-hop same-module helper rejection (boundary.json FP 1) ──────────────── + + +def test_boundary_rejecting_via_same_module_raising_helper_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "def _require_nonempty(p):\n if not p:\n raise ValueError('empty')\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n _require_nonempty(p)\n return p\n", + }, + ) + assert _run(ctx) == [] + + +def test_unreachable_rejecting_helper_does_not_rescue_boundary(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "def _require_nonempty(p):\n if not p:\n raise ValueError('empty')\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n x = p\n return x\n _require_nonempty(p)\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + +def test_boundary_rejecting_via_staticmethod_helper_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "class Validators:\n" + " @staticmethod\n" + " def require(p):\n if not p:\n raise ValueError('empty')\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n Validators.require(p)\n return p\n", + }, + ) + assert _run(ctx) == [] + + +def test_boundary_delegating_to_raising_boundary_is_clean(tmp_path) -> None: + # Wholesale delegation to another declared boundary that itself raises. + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def inner(p):\n if not p:\n raise ValueError\n return p\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n return inner(p)\n", + }, + ) + assert _run(ctx) == [] + + +def test_non_raising_helper_does_not_count_as_rejection(tmp_path) -> None: + # SOUNDNESS GUARD: a helper that logs and returns cannot reject — 102 still fires. + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "def _log(p):\n print(p)\n return p\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n _log(p)\n return p\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + +def test_cross_module_raising_helper_does_not_count(tmp_path) -> None: + # One-hop is SAME-MODULE only (cheap + conservative): a cross-module raising + # helper does not silence the rule. + ctx, _ = _analyze( + tmp_path, + { + "helpers.py": "def require(p):\n if not p:\n raise ValueError\n", + "m.py": "from wardline.decorators import trust_boundary\n" + "from helpers import require\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n require(p)\n return p\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + +def test_unresolvable_call_does_not_count_as_rejection(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n frobnicate(p)\n return p\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + +# ── Conditional-expression and raising-conversion returns (FPs 3 + 4) ─────── + + +def test_ternary_falsy_return_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "import re\nfrom wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n" + " m = re.fullmatch(r'[a-z]+', p)\n return m.group(0) if m else None\n", + }, + ) + assert _run(ctx) == [] + + +def test_raising_conversion_returns_are_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "m.py": "import enum\nfrom wardline.decorators import trust_boundary\n" + "class Color(enum.Enum):\n RED = 'red'\n" + "ALLOWED = {'a': 1}\n" + "@trust_boundary(to_level='ASSURED')\ndef to_port(p):\n return int(p)\n" + "@trust_boundary(to_level='ASSURED')\ndef to_color(p):\n return Color[p]\n" + "@trust_boundary(to_level='ASSURED')\ndef to_allowed(p):\n return ALLOWED[p]\n", + }, + ) + assert _run(ctx) == [] + + +def test_arbitrary_call_return_still_fires(tmp_path) -> None: + # SOUNDNESS GUARD: the raising-conversion set is curated — `return helper_obj(p)` + # for an unknown callee is NOT a rejection. + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n x = str(p)\n return str(x)\n", + }, + ) + assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] diff --git a/tests/unit/scanner/rules/test_broad_exception.py b/tests/unit/scanner/rules/test_broad_exception.py index a509e25d..2d1e13cc 100644 --- a/tests/unit/scanner/rules/test_broad_exception.py +++ b/tests/unit/scanner/rules/test_broad_exception.py @@ -107,3 +107,104 @@ def test_specific_tuple_is_clean(tmp_path) -> None: }, ) assert _run(ctx) == [] + + +def test_multiple_handlers_one_function_are_distinguished_only_by_line_start(tmp_path) -> None: + # Cardinality + latent-collision precondition (wardline-6102d4c833). PY-WL-103 is + # MULTI-EMIT per (rule, path, qualname): one finding per broad handler, with + # taint_path=None, so two handlers in ONE function are kept distinct SOLELY by + # line_start (each handler is on its own line). This is correct under the CURRENT + # contract (line_start is in the join key). It is a documented PRECONDITION for the + # move-stability redesign: when line_start is removed from the key these two distinct + # findings collide unless the rule is first given a source-derived within-scope + # discriminator (the fix is sequenced into that redesign to avoid a double rekey). + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from wardline.decorators import trusted\n" + "@trusted\ndef f():\n" + " try:\n a()\n except:\n pass\n" + " try:\n b()\n except:\n pass\n", + }, + ) + findings = _run(ctx) + assert [f.qualname for f in findings] == ["m.f", "m.f"], "multi-emit: one finding per handler" + fps = {f.fingerprint for f in findings} + assert len(fps) == 2, "distinct today (via line_start); collides iff line_start leaves the key" + + +def test_nested_trusted_def_uses_its_own_tier(tmp_path) -> None: + # A nested def carrying its OWN trust declaration is governed by that tier, not + # the undecorated outer's UNKNOWN_RAW (which would wrongly suppress the rule). + # Aligns PY-WL-103 with the sink family's enclosing_declared_tier semantics + # (wardline-bb8396f96e). + ctx, _ = _analyze( + tmp_path, + { + "m.py": """\ + from wardline.decorators import trusted + + def outer(p): + @trusted(level="ASSURED") + def inner(): + try: + g() + except Exception: + pass + return inner + """, + }, + ) + findings = _run(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-103", "m.outer..inner")] + assert findings[0].severity == Severity.WARN + assert findings[0].properties["tier"] == "ASSURED" + + +def test_nested_external_boundary_def_inside_trusted_is_suppressed(tmp_path) -> None: + # A nested @external_boundary def is explicitly in the raw zone; the @trusted + # OUTER's tier must not leak onto it (the FP direction of the .. strip). + ctx, _ = _analyze( + tmp_path, + { + "m.py": """\ + from wardline.decorators import external_boundary, trusted + + @trusted(level="ASSURED") + def outer(p): + @external_boundary + def inner(): + try: + g() + except Exception: + pass + return inner + """, + }, + ) + assert _run(ctx) == [] + + +def test_undeclared_nested_def_inherits_enclosing_declared_tier(tmp_path) -> None: + # A genuinely undeclared nested def inherits the nearest DECLARED enclosing + # scope's tier (wardline-9b88ec5419) — pins the inheritance half of the walk. + ctx, _ = _analyze( + tmp_path, + { + "m.py": """\ + from wardline.decorators import trusted + + @trusted(level="ASSURED") + def outer(p): + def inner(): + try: + g() + except Exception: + pass + return inner + """, + }, + ) + findings = _run(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-103", "m.outer..inner")] + assert findings[0].properties["tier"] == "ASSURED" diff --git a/tests/unit/scanner/rules/test_command_expansion.py b/tests/unit/scanner/rules/test_command_expansion.py new file mode 100644 index 00000000..5d334169 --- /dev/null +++ b/tests/unit/scanner/rules/test_command_expansion.py @@ -0,0 +1,366 @@ +"""PY-WL-108/112 command-family expansion + calibration (wardline-13cfdd7b31). + +Covers the four decided behavior changes plus the eval-flagged test gaps: + +* 108's charter is now **command/program-execution** — the always-shell string + APIs PLUS the argv-style program-execution family (``os.exec*`` / ``os.spawn*`` + / ``os.posix_spawn*`` / ``pty.spawn``), all CWE-78. +* ``shlex.quote`` neutralizes shell-string taint for 108 **in concatenation + context only**: a quoted fragment inside a larger constant command is GUARDED + (clean); a bare whole-command quote still fires (a fully-quoted single token + IS the attacker-chosen program name); the guard never applies to the argv + program-execution sinks (no shell — quoting protects nothing). +* Variable-binding aliases resolve: ``runner = subprocess.run; runner(raw, + shell=True)`` fires PY-WL-112 (and the same for 108's sinks). +* Severity calibration: 108/112 are base ERROR — same exploit class as SQLi + (PY-WL-118). +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_command import UntrustedToCommand +from wardline.scanner.rules.untrusted_to_shell_subprocess import UntrustedToShellSubprocess + +_HEADER = ( + "import os, pty, shlex, subprocess\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context + + +# --------------------------------------------------------------------------- +# (1) Program-execution charter expansion: os.exec* / os.spawn* / posix_spawn / +# pty.spawn are PY-WL-108 sinks (attacker-controlled program path/argv). +# --------------------------------------------------------------------------- + +_PROGRAM_EXEC_SINKS = [ + "os.execl", + "os.execle", + "os.execlp", + "os.execlpe", + "os.execv", + "os.execve", + "os.execvp", + "os.execvpe", + "os.spawnl", + "os.spawnle", + "os.spawnlp", + "os.spawnlpe", + "os.spawnv", + "os.spawnve", + "os.spawnvp", + "os.spawnvpe", + "os.posix_spawn", + "os.posix_spawnp", + "pty.spawn", +] + + +@pytest.mark.parametrize("sink", _PROGRAM_EXEC_SINKS) +def test_108_raw_reaches_program_execution_sink(tmp_path, sink: str) -> None: + ctx = _analyze( + tmp_path, + f""" + @trusted(level='ASSURED') + def f(p): + {sink}(read_raw(p)) + """, + ) + findings = UntrustedToCommand().check(ctx) + assert [(x.rule_id, x.qualname, x.properties["sink"]) for x in findings] == [("PY-WL-108", "m.f", sink)] + assert findings[0].kind is Kind.DEFECT + assert findings[0].severity is Severity.ERROR + + +def test_108_spawn_with_constant_mode_and_raw_path_fires(tmp_path) -> None: + # The realistic spawn shape: a clean mode slot (os.P_WAIT) must not mask the + # raw program path in slot 1. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + os.spawnl(os.P_WAIT, read_raw(p), 'x') + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToCommand().check(ctx)] == [("PY-WL-108", "m.f")] + + +def test_108_spawn_all_constant_args_does_not_fire(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + os.spawnl(os.P_WAIT, '/bin/ls', 'ls') + """, + ) + assert UntrustedToCommand().check(ctx) == [] + + +# --------------------------------------------------------------------------- +# (2) shlex.quote semantics: GUARDED as a fragment of a constant concatenation; +# NOT guarded as the whole command; NEVER guarding the argv exec sinks. +# --------------------------------------------------------------------------- + + +def test_108_shlex_quoted_fragment_concat_is_clean(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system("echo " + shlex.quote(raw)) + """, + ) + assert UntrustedToCommand().check(ctx) == [] + + +def test_108_shlex_quoted_fragment_fstring_is_clean(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system(f"echo {shlex.quote(raw)}") + """, + ) + assert UntrustedToCommand().check(ctx) == [] + + +def test_108_from_import_quote_alias_is_clean(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + from shlex import quote + @trusted(level='ASSURED') + def f(p): + os.system("echo " + quote(read_raw(p))) + """, + ) + assert UntrustedToCommand().check(ctx) == [] + + +def test_108_unquoted_concat_of_raw_still_fires(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system("echo " + raw) + """, + ) + findings = UntrustedToCommand().check(ctx) + assert [(x.rule_id, x.qualname, x.severity) for x in findings] == [("PY-WL-108", "m.f", Severity.ERROR)] + + +def test_108_whole_command_quote_still_fires(tmp_path) -> None: + # A fully-quoted single token passed to a shell EXECUTES that token as the + # program name — quoting the whole command is not a remediation. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system(shlex.quote(raw)) + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToCommand().check(ctx)] == [("PY-WL-108", "m.f")] + + +def test_108_quoted_command_with_constant_arg_still_fires(tmp_path) -> None: + # The command WORD is quoted-and-attacker-chosen, with only a CONSTANT arg + # suffix: `shlex.quote(raw) + " --version"`. The constant fragment + all-leaves- + # quoted-or-constant shape used to look "guarded", but shlex.quote sanitizes an + # argument, not the identity of the executable — the attacker still picks the + # program. Must fire (regression for the quoted-command-vs-quoted-arg gap). + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system(shlex.quote(raw) + " --version") + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToCommand().check(ctx)] == [("PY-WL-108", "m.f")] + + +def test_108_quoted_command_fstring_with_constant_arg_still_fires(tmp_path) -> None: + # Same gap in f-string form: the quoted leaf leads, a constant trails. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system(f"{shlex.quote(raw)} --version") + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToCommand().check(ctx)] == [("PY-WL-108", "m.f")] + + +def test_108_mixed_concat_with_unquoted_raw_leaf_fires(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.system("echo " + shlex.quote(raw) + raw) + """, + ) + assert [x.rule_id for x in UntrustedToCommand().check(ctx)] == ["PY-WL-108"] + + +def test_108_variable_mediated_quote_still_fires(tmp_path) -> None: + # The guard is INLINE-syntactic only (see the rule docstring): resolving a + # NAME leaf through the last-binding-wins binding collector would let a + # later ``x = shlex.quote(x)`` launder an earlier raw use — so the + # variable-mediated form deliberately stays a (conservative) positive. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + safe = shlex.quote(read_raw(p)) + os.system("echo " + safe) + """, + ) + assert [x.rule_id for x in UntrustedToCommand().check(ctx)] == ["PY-WL-108"] + + +def test_108_quote_guard_does_not_apply_to_program_execution_sinks(tmp_path) -> None: + # No shell mediates os.execv — shlex.quote protects nothing; the program + # path is still attacker-controlled. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + os.execv("/bin/" + shlex.quote(raw), ["x"]) + """, + ) + assert [(x.rule_id, x.properties["sink"]) for x in UntrustedToCommand().check(ctx)] == [("PY-WL-108", "os.execv")] + + +# --------------------------------------------------------------------------- +# (3) Variable-binding aliases (function-local) resolve to the sink FQN. +# --------------------------------------------------------------------------- + + +def test_112_local_binding_alias_fires(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + runner = subprocess.run + runner(read_raw(p), shell=True) + """, + ) + findings = UntrustedToShellSubprocess().check(ctx) + assert [(x.rule_id, x.qualname, x.properties["sink"]) for x in findings] == [("PY-WL-112", "m.f", "subprocess.run")] + assert findings[0].severity is Severity.ERROR + + +def test_112_local_binding_alias_without_shell_true_does_not_fire(tmp_path) -> None: + # The literal shell=True gate applies to binding-resolved calls too. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + runner = subprocess.run + runner(read_raw(p)) + """, + ) + assert UntrustedToShellSubprocess().check(ctx) == [] + + +def test_108_local_binding_alias_fires(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + run_cmd = os.system + run_cmd(read_raw(p)) + """, + ) + assert [(x.rule_id, x.properties["sink"]) for x in UntrustedToCommand().check(ctx)] == [("PY-WL-108", "os.system")] + + +# --------------------------------------------------------------------------- +# (4) Severity calibration: 108/112 base ERROR (same exploit class as PY-WL-118). +# --------------------------------------------------------------------------- + + +def test_108_and_112_base_severity_is_error() -> None: + # Tier MODULATION is unchanged machinery (severity_model tests); the + # calibration decision is the BASE severity, pinned here and exercised at + # ASSURED tier (base kept) by every positive test in this module. + assert UntrustedToCommand.metadata.base_severity is Severity.ERROR + assert UntrustedToShellSubprocess.metadata.base_severity is Severity.ERROR + + +# --------------------------------------------------------------------------- +# (5) Eval-flagged test gaps: per-sink positives for the pre-existing tables. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sink", ["os.popen", "subprocess.getoutput", "subprocess.getstatusoutput"]) +def test_108_raw_reaches_shell_string_sink(tmp_path, sink: str) -> None: + ctx = _analyze( + tmp_path, + f""" + @trusted(level='ASSURED') + def f(p): + {sink}(read_raw(p)) + """, + ) + findings = UntrustedToCommand().check(ctx) + assert [(x.rule_id, x.qualname, x.properties["sink"]) for x in findings] == [("PY-WL-108", "m.f", sink)] + assert findings[0].severity is Severity.ERROR + + +@pytest.mark.parametrize( + "sink", + ["subprocess.run", "subprocess.call", "subprocess.check_call", "subprocess.check_output", "subprocess.Popen"], +) +def test_112_raw_reaches_family_member_shell_true(tmp_path, sink: str) -> None: + ctx = _analyze( + tmp_path, + f""" + @trusted(level='ASSURED') + def f(p): + {sink}(read_raw(p), shell=True) + """, + ) + findings = UntrustedToShellSubprocess().check(ctx) + assert [(x.rule_id, x.qualname, x.properties["sink"]) for x in findings] == [("PY-WL-112", "m.f", sink)] + assert findings[0].severity is Severity.ERROR diff --git a/tests/unit/scanner/rules/test_contradictory_trust.py b/tests/unit/scanner/rules/test_contradictory_trust.py index 872dc26f..b2ed92e9 100644 --- a/tests/unit/scanner/rules/test_contradictory_trust.py +++ b/tests/unit/scanner/rules/test_contradictory_trust.py @@ -198,3 +198,41 @@ def conflicting(p): ) findings = _run(ctx) assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-110", "m.conflicting")] + + +def test_nested_path_marker_engine_rejects_does_not_fire(tmp_path) -> None: + # wardline-09c09f14df: PY-WL-110 must not count a marker the engine's seeding + # (`_is_builtin_decorator_fqn`) rejects. `wardline.decorators.sub.external_boundary` + # is an arbitrarily-nested path the engine does NOT recognise as a builtin export, + # so it is never seeded; only `wardline.decorators.trust_boundary` anchors the + # entity. With exactly one recognised marker there is no clash — the rule must stay + # silent. (Before the fix the loose `startswith(prefix + ".")` test counted both, + # firing a false PY-WL-110.) + ctx = _analyze( + tmp_path, + """ + import wardline.decorators + import wardline.decorators.sub + @wardline.decorators.trust_boundary(to_level='ASSURED') + @wardline.decorators.sub.external_boundary + def f(p): + if not p: + raise ValueError + return p + """, + ) + assert _run(ctx) == [] + + # Control (false-negative guard): a GENUINE two-valid-marker clash — two EXACT + # builtin exports the engine DOES both seed — must STILL fire PY-WL-110. + ctx2 = _analyze( + tmp_path, + """ + from wardline.decorators import trusted, external_boundary + @trusted + @external_boundary + def g(p): + return p + """, + ) + assert [(x.rule_id, x.qualname) for x in _run(ctx2)] == [("PY-WL-110", "m.g")] diff --git a/tests/unit/scanner/rules/test_default_registry.py b/tests/unit/scanner/rules/test_default_registry.py index d39ca1b4..7331eda3 100644 --- a/tests/unit/scanner/rules/test_default_registry.py +++ b/tests/unit/scanner/rules/test_default_registry.py @@ -59,6 +59,12 @@ def test_default_registry_has_all_builtin_rules() -> None: "PY-WL-118", "PY-WL-119", "PY-WL-120", + "PY-WL-121", + "PY-WL-122", + "PY-WL-123", + "PY-WL-124", + "PY-WL-125", + "PY-WL-126", } @@ -140,7 +146,7 @@ def check(self, context: AnalysisContext) -> list[Finding]: severity=Severity.ERROR, kind=Kind.DEFECT, location=Location(path="test.py", line_start=1), - fingerprint=_fp(rule_id=self.rule_id, path="test.py", line_start=1), + fingerprint=_fp(rule_id=self.rule_id, path="test.py"), ) ] diff --git a/tests/unit/scanner/rules/test_deserialization_expansion.py b/tests/unit/scanner/rules/test_deserialization_expansion.py new file mode 100644 index 00000000..dfbc0cc2 --- /dev/null +++ b/tests/unit/scanner/rules/test_deserialization_expansion.py @@ -0,0 +1,366 @@ +"""PY-WL-106 deserialization sink-family expansion (ticket wardline-4299f07bb4). + +Covers the three expansion axes plus the declared-sink test gap: + * OO streaming-unpickle API: ``pickle.Unpickler(raw).load()`` — chained AND + stored-instance form, resolved through the shared sink-binding machinery; + the dangerous data is the stream handed to the CONSTRUCTOR, so taint is + read from the constructor call's arguments. + * ``shelve.open`` — pickle-backed; the taint is on the PATH argument + (ArgSpec ``positions=(0,)`` / ``keywords=("filename",)``), so a tainted + non-path slot does not fire. + * Curated third-party CWE-502 table (name-matched at AST level — the modules + are never imported by the analyzer): dill.load/loads, jsonpickle.decode, + joblib.load, torch.load, numpy.load. numpy.load fires ONLY with a literal + ``allow_pickle=True`` (safe-by-default since numpy 1.16.3); torch.load is + suppressed by a literal ``weights_only=True`` (the modern safe spelling). + * Every entry in the rule's ``_SINKS`` gets at least one positive test + (closes the yaml/marshal/pickle.load mutation-survival gap), with a + completeness pin so a future sink addition forces a test. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_deserialization import _SINKS, UntrustedToDeserialization + +# The analyzed module is parsed, never executed — third-party imports need not be installed. +_HEADER = ( + "import pickle, marshal, shelve, yaml\n" + "import dill, jsonpickle, joblib, torch, numpy\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context + + +def _findings(tmp_path: Path, src: str): + return UntrustedToDeserialization().check(_analyze(tmp_path, src)) + + +# ── every declared sink has a positive (mutation-survival gap closure) ────────────── + +# (canonical sink fqn, the tainted sink expression using raw var ``b``) +_POSITIVE_SINK_CALLS = [ + ("pickle.loads", "pickle.loads(b)"), + ("pickle.load", "pickle.load(b)"), + ("marshal.loads", "marshal.loads(b)"), + ("marshal.load", "marshal.load(b)"), + ("yaml.load", "yaml.load(b)"), + ("yaml.load_all", "yaml.load_all(b)"), + ("yaml.unsafe_load", "yaml.unsafe_load(b)"), + ("yaml.full_load", "yaml.full_load(b)"), + ("pickle.Unpickler.load", "pickle.Unpickler(b).load()"), + ("shelve.open", "shelve.open(b)"), + ("dill.load", "dill.load(b)"), + ("dill.loads", "dill.loads(b)"), + ("jsonpickle.decode", "jsonpickle.decode(b)"), + ("joblib.load", "joblib.load(b)"), + ("torch.load", "torch.load(b)"), + ("numpy.load", "numpy.load(b, allow_pickle=True)"), +] + + +@pytest.mark.parametrize(("sink", "call"), _POSITIVE_SINK_CALLS, ids=[s for s, _ in _POSITIVE_SINK_CALLS]) +def test_every_declared_sink_fires_on_raw(tmp_path: Path, sink: str, call: str) -> None: + findings = _findings( + tmp_path, + f""" + @trusted(level='ASSURED') + def f(p): + b = read_raw(p) + {call} + """, + ) + assert [(x.rule_id, x.qualname, x.properties["sink"]) for x in findings] == [("PY-WL-106", "m.f", sink)] + # RCE-equivalent sinks carry the rule family's base severity (tier-modulated). + assert findings[0].severity == Severity.WARN + assert findings[0].kind == Kind.DEFECT + + +def test_positive_table_covers_every_declared_sink() -> None: + # Completeness pin: adding a sink to _SINKS without a positive test fails here. + assert {sink for sink, _ in _POSITIVE_SINK_CALLS} == set(_SINKS) + + +# ── OO streaming-unpickle API (pickle.Unpickler) ───────────────────────────────────── + + +def test_unpickler_stored_instance_fires(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + b = read_raw(p) + u = pickle.Unpickler(b) + return u.load() + """, + ) + assert [(x.rule_id, x.qualname, x.properties["sink"]) for x in findings] == [ + ("PY-WL-106", "m.f", "pickle.Unpickler.load") + ] + assert findings[0].properties["arg_taint"] == "EXTERNAL_RAW" + + +def test_unpickler_finding_anchors_on_the_load_call(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + b = read_raw(p) + u = pickle.Unpickler(b) + return u.load() + """, + ) + # _HEADER is 6 lines + the snippet's leading blank line → u.load() is line 12, + # NOT the constructor's line 11: the finding anchors on the sink METHOD call. + assert [x.location.line_start for x in findings] == [12] + + +def test_unpickler_clean_literal_stream_is_silent(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return pickle.Unpickler('model.bin').load() + """, + ) + assert findings == [] + + +def test_unpickler_import_alias_resolves(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + import pickle as pk + @trusted(level='ASSURED') + def f(p): + return pk.Unpickler(read_raw(p)).load() + """, + ) + assert [x.properties["sink"] for x in findings] == ["pickle.Unpickler.load"] + + +def test_unpickler_annotation_only_binding_is_a_bounded_fn(tmp_path: Path) -> None: + # An annotation binds the class but carries no constructor call, so there is no + # stream argument to read taint from — documented bounded false negative. + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(u: pickle.Unpickler): + x: pickle.Unpickler = u + return x.load() + """, + ) + assert findings == [] + + +# ── shelve.open (taint on the path argument) ───────────────────────────────────────── + + +def test_shelve_open_tainted_path_fires(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + raw = read_raw(x) + shelve.open(raw) + """, + ) + assert [(x.rule_id, x.properties["sink"]) for x in findings] == [("PY-WL-106", "shelve.open")] + + +def test_shelve_open_tainted_filename_keyword_fires(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + shelve.open(filename=read_raw(x)) + """, + ) + assert [x.properties["sink"] for x in findings] == ["shelve.open"] + + +def test_shelve_open_tainted_non_path_slot_is_silent(tmp_path: Path) -> None: + # ArgSpec precision: only the path slot is dangerous — a tainted flag is not. + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + shelve.open('app.db', read_raw(x)) + """, + ) + assert findings == [] + + +def test_shelve_open_as_context_manager_fires(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + with shelve.open(read_raw(x)) as db: + return db['k'] + """, + ) + assert [x.properties["sink"] for x in findings] == ["shelve.open"] + + +# ── curated third-party table ──────────────────────────────────────────────────────── + + +def test_numpy_load_without_allow_pickle_is_silent(tmp_path: Path) -> None: + # allow_pickle defaults to False (safe) in modern numpy — absent means safe. + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + numpy.load(read_raw(x)) + """, + ) + assert findings == [] + + +def test_numpy_load_allow_pickle_false_is_silent(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + numpy.load(read_raw(x), allow_pickle=False) + """, + ) + assert findings == [] + + +def test_numpy_load_dynamic_allow_pickle_is_silent(tmp_path: Path) -> None: + # Only the statically-visible literal True fires — a dynamic flag is a bounded FN. + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x, flag): + numpy.load(read_raw(x), allow_pickle=flag) + """, + ) + assert findings == [] + + +def test_numpy_load_alias_with_allow_pickle_fires(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + import numpy as np + @trusted(level='ASSURED') + def f(x): + np.load(read_raw(x), allow_pickle=True) + """, + ) + assert [x.properties["sink"] for x in findings] == ["numpy.load"] + + +def test_torch_load_literal_weights_only_true_is_silent(tmp_path: Path) -> None: + # The modern safe spelling: weights_only=True restricts the unpickler. + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + torch.load(read_raw(x), weights_only=True) + """, + ) + assert findings == [] + + +def test_torch_load_weights_only_false_fires(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + torch.load(read_raw(x), weights_only=False) + """, + ) + assert [x.properties["sink"] for x in findings] == ["torch.load"] + + +def test_third_party_from_import_alias_resolves(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + from dill import loads as dloads + @trusted(level='ASSURED') + def f(x): + dloads(read_raw(x)) + """, + ) + assert [x.properties["sink"] for x in findings] == ["dill.loads"] + + +def test_third_party_tainted_non_dangerous_slot_is_silent(tmp_path: Path) -> None: + # ArgSpec precision: torch.load's map_location is not the dangerous slot. + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + torch.load('model.pt', map_location=read_raw(x)) + """, + ) + assert findings == [] + + +# ── tier discipline + multi-emit identity ──────────────────────────────────────────── + + +def test_new_sinks_stay_silent_in_freedom_zone(tmp_path: Path) -> None: + # Undecorated → UNKNOWN_RAW tier → modulate → NONE (opt-in preserved). + findings = _findings( + tmp_path, + """ + def f(x): + b = read_raw(x) + torch.load(b) + shelve.open(b) + pickle.Unpickler(b).load() + """, + ) + assert findings == [] + + +def test_co_located_sinks_get_distinct_fingerprints(tmp_path: Path) -> None: + findings = _findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(x): + b = read_raw(x) + shelve.open(b); dill.loads(b) + """, + ) + assert sorted(x.properties["sink"] for x in findings) == ["dill.loads", "shelve.open"] + assert len({x.fingerprint for x in findings}) == 2 diff --git a/tests/unit/scanner/rules/test_discriminator_shape.py b/tests/unit/scanner/rules/test_discriminator_shape.py new file mode 100644 index 00000000..8ec3d755 --- /dev/null +++ b/tests/unit/scanner/rules/test_discriminator_shape.py @@ -0,0 +1,76 @@ +"""P3 S3 — construction-shape lint: multi_emit <-> taint_path discriminator. + +Source-AST guardrail (wardline-8654423823). Since wlfp2 dropped ``line_start`` from +the hash, a rule that can emit >1 finding per (rule_id, qualname) MUST carry a +source-derived entity-relative discriminator in ``taint_path`` (a col span or an +ordinal); a singleton passes ``taint_path=None``. ``RuleMetadata.multi_emit`` is the +declared source of truth. This lint enforces the correspondence at AUTHORING time — +the gap the runtime collision guard (P2) and the frozen corpus only close once a +colliding pair is actually planted in a fixture. ``taint_path`` is a hash input that +is never persisted, so the check MUST be over source, not runtime. +""" + +from __future__ import annotations + +import ast +import inspect +from pathlib import Path + +from wardline.scanner.rules import BUILTIN_RULE_CLASSES +from wardline.scanner.rules._sink_helpers import TaintedSinkRule + +_FP_NAMES = {"_fp", "compute_finding_fingerprint"} + + +def _taint_path_none_flags(source_file: str) -> list[bool]: + """For every ``_fp``/``compute_finding_fingerprint`` call in ``source_file``, whether + its ``taint_path`` kwarg is the literal ``None`` (True) or a real discriminator + (False). Asserts the kwarg is present at all (a missing taint_path is itself a bug).""" + tree = ast.parse(Path(source_file).read_text(encoding="utf-8")) + flags: list[bool] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in _FP_NAMES: + tp = next((kw for kw in node.keywords if kw.arg == "taint_path"), None) + assert tp is not None, f"{source_file}: a fingerprint call omits the taint_path kwarg" + flags.append(isinstance(tp.value, ast.Constant) and tp.value.value is None) + return flags + + +def test_taintedsinkrule_base_carries_a_discriminator() -> None: + # The attribute-only sink subclasses (106/107/108/112/115/117/121-126) have NO + # per-module _fp call — the single call lives in the shared base's + # build_sink_finding (the 2026-06-10 consolidation restored this single-call + # property; the former mixins satisfied it only transitively). It must carry a + # (non-None) span so every subclass is covered. + flags = _taint_path_none_flags(inspect.getfile(TaintedSinkRule)) + assert flags, "expected the TaintedSinkRule base to build a fingerprint" + assert not any(flags), "the shared sink-rule fingerprint must carry a discriminator (never taint_path=None)" + + +def test_every_rule_multi_emit_matches_its_taint_path_shape() -> None: + seen_multi = seen_singleton = False + for cls in BUILTIN_RULE_CLASSES: + multi_emit = cls.metadata.multi_emit + flags = _taint_path_none_flags(inspect.getfile(cls)) + if not flags: + # No local fingerprint call => a TaintedSinkRule subclass, covered by the base + # (asserted above). Such a rule is inherently multi-emit; the flag must say so. + assert issubclass(cls, TaintedSinkRule), f"{cls.__name__}: no local _fp call but not a sink subclass" + assert multi_emit, f"{cls.__name__}: sink subclass must be flagged multi_emit" + seen_multi = True + continue + if multi_emit: + seen_multi = True + assert not any(flags), ( + f"{cls.__name__} is multi_emit but a fingerprint call passes taint_path=None — co-located " + f"findings would COLLIDE now that line_start left the hash (wlfp2). Give it an entity-relative " + f"span/ordinal discriminator, or set multi_emit=False if it truly emits <=1 per qualname." + ) + else: + seen_singleton = True + assert all(flags), ( + f"{cls.__name__} is a singleton (multi_emit=False) but a fingerprint call passes a non-None " + f"taint_path. Either drop the discriminator (taint_path=None) or set multi_emit=True." + ) + # Non-vacuity: the corpus of rules actually exercises both arms. + assert seen_multi and seen_singleton, "expected both multi_emit and singleton rules in the builtin set" diff --git a/tests/unit/scanner/rules/test_exec_expansion.py b/tests/unit/scanner/rules/test_exec_expansion.py new file mode 100644 index 00000000..51d99b38 --- /dev/null +++ b/tests/unit/scanner/rules/test_exec_expansion.py @@ -0,0 +1,158 @@ +# tests/unit/scanner/rules/test_exec_expansion.py +"""PY-WL-107 coverage expansion: exec()/compile() positives + the __builtins__ spelling. + +The pre-existing suite only had an eval() positive for PY-WL-107; exec() and +compile() were sinks with no positive regression cover, and the +``__builtins__.eval`` / ``__builtins__.exec`` spelling was unmatched entirely +(ticket wardline-c83b40c73a). +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_exec import UntrustedToExec + +_HEADER = ( + "from wardline.decorators import external_boundary, trusted\n@external_boundary\ndef read_raw(p):\n return p\n" +) + + +def _analyze(tmp_path: Path, src: str) -> tuple[WardlineAnalyzer, object]: + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer, analyzer.last_context + + +def test_107_raw_reaches_exec(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + src = read_raw(p) + exec(src) + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert [(f.rule_id, f.qualname, f.kind, f.severity) for f in findings] == [ + ("PY-WL-107", "m.f", Kind.DEFECT, Severity.WARN) + ] + assert findings[0].properties["sink"] == "exec" + + +def test_107_raw_reaches_compile(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + compile(read_raw(p), '', 'exec') + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert [(f.rule_id, f.qualname, f.kind, f.severity) for f in findings] == [ + ("PY-WL-107", "m.f", Kind.DEFECT, Severity.WARN) + ] + assert findings[0].properties["sink"] == "compile" + + +def test_107_raw_reaches_dunder_builtins_eval(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + src = read_raw(p) + __builtins__.eval(src) + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert [(f.rule_id, f.qualname, f.kind, f.severity) for f in findings] == [ + ("PY-WL-107", "m.f", Kind.DEFECT, Severity.WARN) + ] + assert findings[0].properties["sink"] == "__builtins__.eval" + + +def test_107_raw_reaches_dunder_builtins_exec(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + __builtins__.exec(read_raw(p)) + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-107", "m.f")] + assert findings[0].properties["sink"] == "__builtins__.exec" + + +def test_107_raw_reaches_dunder_builtins_compile(tmp_path) -> None: + # Same spelling class as __builtins__.eval/.exec — covered for parity with + # the builtins.compile form the sink table already carries. + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + __builtins__.compile(read_raw(p), '', 'exec') + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-107", "m.f")] + assert findings[0].properties["sink"] == "__builtins__.compile" + + +def test_107_exec_clean_constant(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + exec('x = 1') + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert findings == [] + + +def test_107_compile_clean_constant(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + compile('x = 1', '', 'exec') + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert findings == [] + + +def test_107_dunder_builtins_eval_undecorated_suppressed(tmp_path) -> None: + # New spelling obeys the same tier gate as every other sink form. + _, ctx = _analyze( + tmp_path, + """ + def f(p): + __builtins__.eval(read_raw(p)) + return 1 + """, + ) + findings = UntrustedToExec().check(ctx) + assert findings == [] diff --git a/tests/unit/scanner/rules/test_failopen_boundary.py b/tests/unit/scanner/rules/test_failopen_boundary.py index a7473b6b..f57842c9 100644 --- a/tests/unit/scanner/rules/test_failopen_boundary.py +++ b/tests/unit/scanner/rules/test_failopen_boundary.py @@ -113,3 +113,282 @@ def f(p): """, ) assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_boundary_fires_on_assignment_then_fallthrough(tmp_path) -> None: + # PY-WL-113 FN (wardline-c314a7140b): the handler swallows the rejection and + # substitutes via an ASSIGNMENT to a name that the function then returns by + # fall-through. Structurally identical to the `return p` self-catch, which fires. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + if not p: + raise ValueError + result = read_raw(p) + except ValueError: + result = p + return result + """, + ) + findings = FailOpenBoundary().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-113", "m.v")] + assert findings[0].kind == Kind.DEFECT + assert findings[0].severity == Severity.ERROR + + +def test_failopen_boundary_silent_on_assignment_of_falsy_rejection(tmp_path) -> None: + # CONTROL: handler assigns a falsy/rejection value (None) to the returned name. + # That is a rejection signal, not a value-bearing substitution -> must stay silent, + # mirroring `return None`. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + if not p: + raise ValueError + result = read_raw(p) + except ValueError: + result = None + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_boundary_silent_on_assignment_to_unreturned_name(tmp_path) -> None: + # CONTROL (FP guard): handler assigns the untrusted value to a scratch name that + # the function does NOT return; the boundary still returns the validated `result`. + # The substitution does not escape, so it must stay silent. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + if not p: + raise ValueError + result = read_raw(p) + except ValueError: + tmp = p + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_boundary_silent_on_assignment_then_reraise(tmp_path) -> None: + # CONTROL: handler assigns then re-raises (fail-closed) -> must stay silent. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + if not p: + raise ValueError + result = read_raw(p) + except ValueError: + result = p + raise + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_boundary_silent_on_assign_then_reject_return(tmp_path) -> None: + # CONTROL (panel FP, wardline-c314a7140b): handler assigns `result = p` then rejects + # via `return None`. The assignment is a dead store; the boundary fails CLOSED. The + # assign-substitution must be gated on the handler falling through — must stay silent. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + try: + result = read_raw(p) + except ValueError: + result = p + return None + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_boundary_silent_on_idempotent_self_assignment(tmp_path) -> None: + # CONTROL (panel FP): an idempotent `result = result` in an unrelated handler + # substitutes nothing new — must stay silent. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + if not p: + raise ValueError + result = read_raw(p) + try: + pass + except Exception: + result = result + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_silent_when_no_rejection_exists_102s_domain(tmp_path) -> None: + # wardline-718048a518 repro A: no rejection of any shape exists, so the boundary + # is PY-WL-102's ("cannot reject at all") — 113's premise (a rejection exists and + # is defeated) does not hold; 113 must stay silent. + ctx = _analyze( + tmp_path, + """ + def compute(p): + return p + @trust_boundary(to_level='ASSURED') + def v(p): + try: + x = compute(p) + except Exception: + return p + return x + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_silent_when_assert_is_sole_rejection_111s_domain(tmp_path) -> None: + # wardline-718048a518 repro B: the only rejection is an assert — PY-WL-111's + # exclusive domain; 113 must stay silent. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p): + assert p + try: + return p + except Exception: + return "x" + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_silent_on_unrelated_cache_miss_fallback(tmp_path) -> None: + # boundary.json FP 5: the boundary's rejection (the raise) is lexically OUTSIDE + # the try, so the handler cannot swallow it — fail-CLOSED, must stay silent. + ctx = _analyze( + tmp_path, + """ + _cache = {} + @trust_boundary(to_level='ASSURED') + def v(p): + if not p.isdigit(): + raise ValueError + try: + cached = _cache[p] + return cached + except KeyError: + result = int(p) + _cache[p] = result + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_fires_when_rejection_inside_try_with_unrelated_handler_type(tmp_path) -> None: + # TP preserved: an unrelated exception TYPE whose raise IS inside the same try + # can preempt the validation — still fires (type-blindness is documented). + ctx = _analyze( + tmp_path, + """ + _cache = {} + @trust_boundary(to_level='ASSURED') + def v(p): + try: + if not p.isdigit(): + raise ValueError + cached = _cache[p] + return cached + except KeyError: + return p + """, + ) + assert [(f.rule_id, f.qualname) for f in FailOpenBoundary().check(ctx)] == [("PY-WL-113", "m.v")] + + +def test_failopen_fires_when_helper_rejection_inside_try_is_swallowed(tmp_path) -> None: + # One-hop coherence: the rejection delegated to a same-module raising helper + # (which silences 102) is INSIDE the try and swallowed by a substituting + # handler — a real fail-open; 113 must own it. + ctx = _analyze( + tmp_path, + """ + def _require_nonempty(p): + if not p: + raise ValueError("empty") + @trust_boundary(to_level='ASSURED') + def v(p): + try: + _require_nonempty(p) + return p + except ValueError: + return p + """, + ) + assert [(f.rule_id, f.qualname) for f in FailOpenBoundary().check(ctx)] == [("PY-WL-113", "m.v")] + + +def test_failopen_silent_when_helper_rejection_outside_try(tmp_path) -> None: + # The helper-delegated rejection runs BEFORE the try — the cache-miss handler + # cannot swallow it; fail-closed, silent. + ctx = _analyze( + tmp_path, + """ + _cache = {} + def _require_digit(p): + if not p.isdigit(): + raise ValueError + @trust_boundary(to_level='ASSURED') + def v(p): + _require_digit(p) + try: + cached = _cache[p] + return cached + except KeyError: + result = int(p) + _cache[p] = result + return result + """, + ) + assert FailOpenBoundary().check(ctx) == [] + + +def test_failopen_boundary_fires_on_conditional_return_then_fallthrough_assign(tmp_path) -> None: + # panel-2 FN (wardline-c314a7140b): the handler returns only CONDITIONALLY (nested if) + # and otherwise FALLS THROUGH with a value-substituting assignment. The fall-through + # gate must key on TOP-LEVEL returns only — a conditional nested return does not stop + # the assignment escaping on the other path, so this is a real fail-open and must fire. + ctx = _analyze( + tmp_path, + """ + @trust_boundary(to_level='ASSURED') + def v(p, check): + try: + result = read_raw(p) + except ValueError: + if check: + return None + result = p + return result + """, + ) + assert [(f.rule_id, f.qualname) for f in FailOpenBoundary().check(ctx)] == [("PY-WL-113", "m.v")] diff --git a/tests/unit/scanner/rules/test_invalid_decorator_level.py b/tests/unit/scanner/rules/test_invalid_decorator_level.py index dd436166..8a21f776 100644 --- a/tests/unit/scanner/rules/test_invalid_decorator_level.py +++ b/tests/unit/scanner/rules/test_invalid_decorator_level.py @@ -97,3 +97,97 @@ def clean_dynamic(p): ) findings = InvalidDecoratorLevel().check(ctx) assert len(findings) == 0 + + +def test_invalid_decorator_level_aliased_builtin_fires(tmp_path) -> None: + # The FN: an aliased builtin decorator with a typo'd level silently disables the gate + # AND escaped the rule meant to catch it. Resolving through the alias map fixes it + # (wardline-0267c31cd8). + _, ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted as t + + @t(level='ASURED') + def f(p): + return p + """, + ) + findings = InvalidDecoratorLevel().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-114", "m.f")] + + +def test_invalid_decorator_level_aliased_valid_does_not_fire(tmp_path) -> None: + # Guard: the alias resolution must not over-fire on a VALID aliased level. + _, ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted as t + + @t(level='ASSURED') + def f(p): + return p + """, + ) + assert InvalidDecoratorLevel().check(ctx) == [] + + +def test_invalid_decorator_level_foreign_same_name_does_not_fire(tmp_path) -> None: + # The FP: a non-wardline decorator that merely happens to be spelled ``trusted`` is not + # the builtin marker — an invalid level on it is out of scope (wardline-0267c31cd8). + _, ctx = _analyze( + tmp_path, + """ + import other_pkg + + @other_pkg.trusted(level='BOGUS') + def f(p): + return p + """, + ) + assert InvalidDecoratorLevel().check(ctx) == [] + + +def test_invalid_decorator_level_local_same_name_does_not_fire(tmp_path) -> None: + # The FP: a locally-defined ``trusted`` decorator is not the builtin marker. + _, ctx = _analyze( + tmp_path, + """ + def trusted(**kw): + def deco(fn): + return fn + return deco + + @trusted(level='BOGUS') + def f(p): + return p + """, + ) + assert InvalidDecoratorLevel().check(ctx) == [] + + +def test_stacked_identical_decorators_have_distinct_fingerprints(tmp_path) -> None: + # Soundness / fingerprint collision (wardline-377b896a87): two stacked identical invalid + # decorators on ONE def are two distinct findings, but the fingerprint anchored at the + # ENTITY line with taint_path=f"{name}:{token}" (no within-def discriminator) collapsed them + # to one key — one silently masking the other on the baseline/waiver/judge/Filigree joins. + # The decorators share name, token, AND entity line; the only thing that tells them apart is + # their POSITION in the decorator_list, so the discriminator carries the decorator ordinal + # (move-stable: invariant to the def moving and to column shifts; collision-complete since at + # most one finding is emitted per decorator). + _, ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trust_boundary + + @trust_boundary(to_level='bogus') + @trust_boundary(to_level='bogus') + def handler(p): + if not p: raise ValueError + return p + """, + ) + findings = InvalidDecoratorLevel().check(ctx) + assert len(findings) == 2, "both invalid decorators must be reported" + fps = {f.fingerprint for f in findings} + assert len(fps) == 2, "two distinct findings must not share a fingerprint (collision)" diff --git a/tests/unit/scanner/rules/test_invalid_decorator_level_recognizer.py b/tests/unit/scanner/rules/test_invalid_decorator_level_recognizer.py new file mode 100644 index 00000000..c76dec28 --- /dev/null +++ b/tests/unit/scanner/rules/test_invalid_decorator_level_recognizer.py @@ -0,0 +1,148 @@ +# tests/unit/scanner/rules/test_invalid_decorator_level_recognizer.py +"""PY-WL-114 marker recognition must match the engine's seeding predicate. + +The rule may only recognise a builtin level-bearing marker the engine's seeding +would honour (``_is_builtin_decorator_fqn`` exact exports + shadowed-root +fail-closed rejection). Recognising a marker the seeding rejects produces a +false "a typo disables all taint gates" finding for a gate that never existed +(the same drift PY-WL-110 closed under wardline-09c09f14df). +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Finding +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.invalid_decorator_level import InvalidDecoratorLevel + + +def _findings_114(tmp_path: Path, files: dict[str, str]) -> list[Finding]: + paths = [] + for rel, src in files.items(): + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(textwrap.dedent(src), encoding="utf-8") + paths.append(p) + analyzer = WardlineAnalyzer() + analyzer.analyze(sorted(paths), WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return InvalidDecoratorLevel().check(analyzer.last_context) + + +def test_nested_path_under_builtin_prefix_does_not_fire(tmp_path) -> None: + # The engine seeds ONLY the exact exports P. / P.trust.; + # an arbitrarily-nested path like wardline.decorators.evil.trusted is + # rejected by seeding, so no gate exists for a bad level to disable. + findings = _findings_114( + tmp_path, + { + "m.py": """\ + import wardline.decorators.evil + + @wardline.decorators.evil.trusted(level="BOGUS") + def f(p): + return p + """, + }, + ) + assert findings == [] + + +def test_shadowed_marker_root_does_not_fire(tmp_path) -> None: + # A project shipping its OWN top-level weft_markers module shadows the + # builtin marker root: seeding fails closed for every marker under it, so + # the decorator is a project-local foreign decorator, not the builtin. + findings = _findings_114( + tmp_path, + { + "weft_markers.py": """\ + def trusted(level=None): + def deco(fn): + return fn + return deco + """, + "m.py": """\ + from weft_markers import trusted + + @trusted(level="BOGUS") + def f(p): + return p + """, + }, + ) + assert findings == [] + + +def test_shadowed_wardline_root_does_not_fire(tmp_path) -> None: + # Same fail-closed rejection for a project-local top-level ``wardline`` module. + findings = _findings_114( + tmp_path, + { + "wardline.py": "X = 1\n", + "m.py": """\ + from wardline.decorators import trusted + + @trusted(level="BOGUS") + def f(p): + return p + """, + }, + ) + assert findings == [] + + +def test_exact_export_still_fires(tmp_path) -> None: + # Control: the real builtin export keeps firing after the recognizer tightening. + findings = _findings_114( + tmp_path, + { + "m.py": """\ + from wardline.decorators import trusted + + @trusted(level="BOGUS") + def f(p): + return p + """, + }, + ) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-114", "m.f")] + + +def test_trust_submodule_export_still_fires(tmp_path) -> None: + # The implementation-module export P.trust. is also a seeded spelling. + findings = _findings_114( + tmp_path, + { + "m.py": """\ + from wardline.decorators.trust import trust_boundary + + @trust_boundary(to_level="INTEGRAL") + def g(p): + if not p: + raise ValueError + return p + """, + }, + ) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-114", "m.g")] + + +def test_unshadowed_weft_markers_still_fires(tmp_path) -> None: + # Control: the weft_markers shim is a builtin root; with no project-local + # shadow it is seeded, so an invalid level on it is a real PY-WL-114. + findings = _findings_114( + tmp_path, + { + "m.py": """\ + from weft_markers import trusted + + @trusted(level="BOGUS") + def f(p): + return p + """, + }, + ) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-114", "m.f")] diff --git a/tests/unit/scanner/rules/test_none_leak.py b/tests/unit/scanner/rules/test_none_leak.py index e4caf6b2..43828f68 100644 --- a/tests/unit/scanner/rules/test_none_leak.py +++ b/tests/unit/scanner/rules/test_none_leak.py @@ -133,6 +133,22 @@ def maybe(flag): assert _ids(ctx) == [] +def test_undecorated_does_not_fire(tmp_path) -> None: + # Tier-suppression matrix slot (wardline-e159060db7): PY-WL-109 is gated on + # an ANCHORED trusted producer. The identical annotated mixed-return shape + # without a trust marker makes no trusted-output claim -> silent. + ctx = _analyze( + tmp_path, + """ + def maybe(flag) -> int: + if flag: + return 1 + return + """, + ) + assert _ids(ctx) == [] + + def test_all_value_returns_do_not_fire(tmp_path) -> None: ctx = _analyze( tmp_path, @@ -166,6 +182,149 @@ def always(flag) -> int: assert _ids(ctx) == [] +def test_with_wrapped_all_value_returns_do_not_fire(tmp_path) -> None: + # A with body where every path returns a value cannot leak None — the with + # block is transparent to control flow (terminal iff its body is terminal). + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def always(flag) -> int: + with open("x") as fh: + if flag: + return 1 + else: + return 2 + """, + ) + assert _ids(ctx) == [] + + +def test_async_with_wrapped_all_value_returns_do_not_fire(tmp_path) -> None: + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + async def always(flag) -> int: + async with ctxmgr() as fh: + if flag: + return 1 + else: + return 2 + """, + ) + assert _ids(ctx) == [] + + +def test_with_body_falls_through_still_fires(tmp_path) -> None: + # SOUNDNESS GUARD: a with body that does NOT always return still leaks None. + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def maybe(flag) -> int: + with open("x") as fh: + if flag: + return 1 + """, + ) + assert _ids(ctx) == [("PY-WL-109", "m.maybe")] + + +def test_while_true_no_break_single_value_return_does_not_fire(tmp_path) -> None: + # A constant-true loop with no break never exits to fall through — the only + # way out is the value-bearing return, so None cannot leak. + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def f(flag) -> int: + while True: + if flag: + return 1 + """, + ) + assert _ids(ctx) == [] + + +def test_while_true_with_break_can_fall_through_fires(tmp_path) -> None: + # SOUNDNESS GUARD: a break exits the loop, so control can fall through to the + # implicit None return — this is a real leak. + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def f(flag) -> int: + while True: + if flag: + return 1 + break + """, + ) + assert _ids(ctx) == [("PY-WL-109", "m.f")] + + +def test_while_non_constant_test_can_fall_through_fires(tmp_path) -> None: + # SOUNDNESS GUARD: a non-constant test can be false from the start (or become + # false), so the loop can be skipped and fall through to implicit None. + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def f(flag) -> int: + while flag: + return 1 + """, + ) + assert _ids(ctx) == [("PY-WL-109", "m.f")] + + +def test_while_true_break_in_nested_loop_does_not_fall_through(tmp_path) -> None: + # SOUNDNESS GUARD (no over-firing): a break that binds to a NESTED loop does + # not let the outer constant-true loop fall through. + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def f(flag) -> int: + while True: + for _ in range(3): + break + if flag: + return 1 + """, + ) + assert _ids(ctx) == [] + + +def test_while_true_break_in_nested_loop_else_can_fall_through_fires(tmp_path) -> None: + # SOUNDNESS GUARD: a break in a nested loop's else-clause binds to the OUTER + # while, so that loop can exit and fall through to implicit None — real leak. + ctx = _analyze( + tmp_path, + """ + from wardline.decorators import trusted + @trusted(level='ASSURED') + def f(flag) -> int: + while True: + for _ in range(3): + pass + else: + break + if flag: + return 1 + """, + ) + assert _ids(ctx) == [("PY-WL-109", "m.f")] + + def test_guarded_wildcard_match_can_fall_through(tmp_path) -> None: ctx = _analyze( tmp_path, diff --git a/tests/unit/scanner/rules/test_path_traversal_expansion.py b/tests/unit/scanner/rules/test_path_traversal_expansion.py new file mode 100644 index 00000000..4864c0ca --- /dev/null +++ b/tests/unit/scanner/rules/test_path_traversal_expansion.py @@ -0,0 +1,387 @@ +"""PY-WL-116 expansion (wardline-04b65cf0be + Zip Slip eval item) — empirical coverage. + +Three sink families joining the path-traversal rule: + +1. Filesystem-MUTATION sinks (``os.remove``/``shutil.rmtree``/...) — direct dotted + calls with a tainted path argument (destructive traversal: delete/move/copy + outside the intended directory). +2. Path-METHOD sinks — ``read_text``/``write_bytes``/... on a ``pathlib.Path`` + CONSTRUCTED from tainted input, both bound (``p = Path(raw); p.read_text()``) + and chained (``pathlib.Path(raw).read_text()``), via the shared sink-binding + machinery. The taint is read from the CONSTRUCTOR call's arguments. +3. Archive extraction (Zip Slip / tarbomb, CWE-22) — ``extractall``/``extract`` on + a ``tarfile.open``/``tarfile.TarFile``/``zipfile.ZipFile`` instance whose + ARCHIVE SOURCE is tainted; exempt when the call passes the tarfile safe filter + ``filter="data"``. + +Precision pins: constant paths (``os.remove('/var/log/x')``, +``Path('static.txt').read_text()``) stay silent; the freedom zone stays silent. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Severity +from wardline.scanner.analyzer import WardlineAnalyzer + +_HEADER = ( + "import os, os.path, shutil, pathlib, tarfile, zipfile\n" + "from pathlib import Path\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _pt_findings(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + return [f for f in findings if f.rule_id == "PY-WL-116"] + + +def _sinks(findings) -> set[str]: + return {f.properties["sink"] for f in findings if f.properties} + + +# ── 1. Filesystem-mutation sinks (direct dotted calls) ───────────────────── + + +@pytest.mark.parametrize( + "call", + [ + "os.remove(read_raw(p))", + "os.unlink(read_raw(p))", + "os.rmdir(read_raw(p))", + "os.makedirs(read_raw(p))", + "os.mkdir(read_raw(p))", + "os.rename(read_raw(p), '/dst')", + "os.renames(read_raw(p), '/dst')", + "os.replace(read_raw(p), '/dst')", + "shutil.rmtree(read_raw(p))", + "shutil.copy(read_raw(p), '/dst')", + "shutil.copy2(read_raw(p), '/dst')", + "shutil.copyfile(read_raw(p), '/dst')", + "shutil.copytree(read_raw(p), '/dst')", + "shutil.move(read_raw(p), '/dst')", + ], +) +def test_fs_mutation_sink_fires_on_tainted_path(tmp_path: Path, call: str) -> None: + findings = _pt_findings( + tmp_path, + f""" + @trusted(level='ASSURED') + def f(p): + {call} + """, + ) + assert len(findings) == 1 + assert findings[0].qualname == "m.f" + assert findings[0].severity == Severity.WARN + + +def test_fs_mutation_tainted_destination_also_fires(tmp_path: Path) -> None: + # Worst-of-all-args: a tainted DESTINATION is traversal too (write outside the dir). + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + shutil.copy('/etc/app.conf', read_raw(p)) + """, + ) + assert _sinks(findings) == {"shutil.copy"} + + +def test_fs_mutation_constant_path_is_silent(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + os.remove('/var/log/app.log') + shutil.rmtree('/tmp/scratch') + """, + ) + assert findings == [] + + +def test_fs_mutation_freedom_zone_is_silent(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + def f(p): + os.remove(read_raw(p)) + """, + ) + assert findings == [] + + +def test_os_open_tainted_path_fires(tmp_path: Path) -> None: + # os.open is a declared _SINKS member with no dedicated positive + # (wardline-e159060db7) — a silent drop from the table would go undetected. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + os.open(read_raw(p), os.O_RDONLY) + """, + ) + assert [(x.rule_id, x.qualname, x.severity) for x in findings] == [("PY-WL-116", "m.f", Severity.WARN)] + + +def test_pathlib_path_constructor_tainted_fires(tmp_path: Path) -> None: + # The bare pathlib.Path(...) CONSTRUCTOR is itself a declared sink (distinct + # from the construct-then-method shapes below) — pin it individually. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + pathlib.Path(read_raw(p)) + """, + ) + assert [(x.rule_id, x.qualname, x.severity) for x in findings] == [("PY-WL-116", "m.f", Severity.WARN)] + + +# ── 2. Path-method sinks (construct-then-method via the binding machinery) ── + + +def test_bound_path_read_text_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + q = pathlib.Path(read_raw(p)) + return q.read_text() + """, + ) + # The tainted CONSTRUCTOR still fires (existing sink) and the method adds its own. + assert _sinks(findings) == {"pathlib.Path", "pathlib.Path.read_text"} + method = next(f for f in findings if f.properties["sink"] == "pathlib.Path.read_text") + assert method.severity == Severity.WARN + assert method.qualname == "m.f" + + +def test_chained_path_read_bytes_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return pathlib.Path(read_raw(p)).read_bytes() + """, + ) + assert "pathlib.Path.read_bytes" in _sinks(findings) + + +def test_from_import_alias_path_write_text_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + q = Path(read_raw(p)) + q.write_text('payload') + """, + ) + assert "pathlib.Path.write_text" in _sinks(findings) + + +@pytest.mark.parametrize("method", ["write_bytes", "open", "unlink", "rmdir", "mkdir"]) +def test_bound_path_method_family_fires(tmp_path: Path, method: str) -> None: + findings = _pt_findings( + tmp_path, + f""" + @trusted(level='ASSURED') + def f(p): + q = Path(read_raw(p)) + q.{method}() + """, + ) + assert f"pathlib.Path.{method}" in _sinks(findings) + + +def test_static_path_methods_are_silent(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + q = Path('static.txt') + q.read_text() + Path('static.txt').read_bytes() + """, + ) + assert findings == [] + + +def test_method_on_non_path_instance_is_silent(tmp_path: Path) -> None: + # A var bound to some other constructor must not match the Path method sinks. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + c = compute(read_raw(p)) + return c.read_text() + """, + ) + assert findings == [] + + +def test_rebound_path_resolves_at_final_binding(tmp_path: Path) -> None: + # Last-binding-wins (machinery contract): tainted Path rebound to a constant + # Path before the method call — the method must not fire on the stale ctor. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + q = Path(read_raw(p)) + q = Path('static.txt') + return q.read_text() + """, + ) + assert _sinks(findings) == {"pathlib.Path"} # only the tainted ctor itself + + +# ── 3. Archive extraction (Zip Slip / tarbomb) ────────────────────────────── + + +def test_tarfile_bound_extractall_fires_and_names_archive_extraction(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + tf = tarfile.open(read_raw(p)) + tf.extractall('/dst') + """, + ) + assert _sinks(findings) == {"tarfile.open.extractall"} + assert "archive extraction" in findings[0].message + assert findings[0].severity == Severity.WARN + + +def test_tarfile_with_binding_extract_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, member): + with tarfile.open(read_raw(p)) as tf: + tf.extract(member, '/dst') + """, + ) + assert "tarfile.open.extract" in _sinks(findings) + + +def test_zipfile_chained_extractall_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + zipfile.ZipFile(read_raw(p)).extractall() + """, + ) + assert _sinks(findings) == {"zipfile.ZipFile.extractall"} + + +def test_zipfile_bound_extract_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + zf = zipfile.ZipFile(read_raw(p)) + zf.extract('member.txt') + """, + ) + assert "zipfile.ZipFile.extract" in _sinks(findings) + + +def test_tarfile_class_constructor_extractall_fires(tmp_path: Path) -> None: + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + tf = tarfile.TarFile(read_raw(p)) + tf.extractall('/dst') + """, + ) + assert "tarfile.TarFile.extractall" in _sinks(findings) + + +def test_data_filter_exempts_extraction(tmp_path: Path) -> None: + # filter="data" is tarfile's safe extraction filter (blocks absolute paths, + # ../ traversal, devices) — the documented mitigation, so no finding. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + tf = tarfile.open(read_raw(p)) + tf.extractall('/dst', filter='data') + """, + ) + assert findings == [] + + +def test_non_data_filter_still_fires(tmp_path: Path) -> None: + # Only the "data" filter is the documented exemption; "fully_trusted" is unsafe. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + tf = tarfile.open(read_raw(p)) + tf.extractall('/dst', filter='fully_trusted') + """, + ) + assert _sinks(findings) == {"tarfile.open.extractall"} + + +def test_constant_archive_source_is_silent(tmp_path: Path) -> None: + # v1 scope decision: the rule keys on the ARCHIVE SOURCE; a tainted + # destination with a trusted archive does not fire this family. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + tf = tarfile.open('bundled.tar') + tf.extractall('/dst') + """, + ) + assert findings == [] + + +# ── Fingerprint discipline (multi_emit discriminator) ─────────────────────── + + +def test_co_located_method_and_ctor_findings_have_distinct_fingerprints(tmp_path: Path) -> None: + # The chained form emits both the ctor and the method finding on ONE line — + # the taint_path discriminator (span + sink name) must separate them. + findings = _pt_findings( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return pathlib.Path(read_raw(p)).read_text() + """, + ) + assert _sinks(findings) == {"pathlib.Path", "pathlib.Path.read_text"} + fps = [f.fingerprint for f in findings] + assert len(fps) == len(set(fps)) diff --git a/tests/unit/scanner/rules/test_review_fixups_rules.py b/tests/unit/scanner/rules/test_review_fixups_rules.py new file mode 100644 index 00000000..26d4f7af --- /dev/null +++ b/tests/unit/scanner/rules/test_review_fixups_rules.py @@ -0,0 +1,287 @@ +# tests/unit/scanner/rules/test_review_fixups_rules.py +"""Regression tests for the 2026-06-10 review-panel RULE-side fixups. + +Covers the confirmed rule-design / quality defects: + +1. **PY-WL-120 suppress-and-delegate must honor rule enablement** — with + ``rules.enable = ("PY-WL-120",)`` the delegate (PY-WL-101) never runs, so + the suppression dropped the raw-storage-return defect entirely. + +2. **PY-WL-124 slot precision** — the only 121–126 family member built on + worst-of-all-args fired ERROR on a tainted ``mode=``/``use_errno=`` flag + with a CONSTANT library name. + +3. **PY-WL-125/126 parameter-annotation receivers** — the docstrings claimed + the ``log: logging.Logger`` / ``s: smtplib.SMTP`` parameter forms bind, but + only body AnnAssign seeded ``instance_classes``. + +4. **Operator severity override equal to the metadata default** — the per-sink + severity table detected an override by value identity, so an explicit + ``rules.severity = {PY-WL-121: ERROR}`` (equal to the default) was ignored. + +5. **WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK** is an engine FACT finding now, not + a per-qualname ``UserWarning`` from inside rule ``check()`` calls. + +6. **Sink-loop consolidation** — the base :class:`TaintedSinkRule` is + binding-aware, so the historical base rules (106/107/115/124) resolve + callable aliases / construct-then-method forms (new findings only). +""" + +from __future__ import annotations + +import textwrap +import warnings +from typing import TYPE_CHECKING + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from wardline.core.finding import Finding + +_PREAMBLE = ( + "import os\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _scan( + tmp_path: Path, + body: str, + config: WardlineConfig | None = None, + preamble: str = _PREAMBLE, +) -> Sequence[Finding]: + p = tmp_path / "m.py" + p.write_text(preamble + textwrap.dedent(body), encoding="utf-8") + with warnings.catch_warnings(): + warnings.simplefilter("error") # the engine must not warn from rule checks + return WardlineAnalyzer().analyze([p], config or WardlineConfig(), root=tmp_path) + + +def _rule_hits(findings: Sequence[Finding], rule_id: str) -> list[Finding]: + return [f for f in findings if f.rule_id == rule_id] + + +_STORAGE_RETURN = """ +@trusted(level='ASSURED') +def f(fd): + data = os.read(fd, 10) + return data +""" + + +# ── 1. PY-WL-120 suppression gated on PY-WL-101 enablement ─────────────────── + + +def test_120_fires_when_101_is_not_enabled(tmp_path: Path) -> None: + findings = _scan(tmp_path, _STORAGE_RETURN, WardlineConfig(rules_enable=("PY-WL-120",))) + assert _rule_hits(findings, "PY-WL-101") == [] # the delegate never ran + hits = _rule_hits(findings, "PY-WL-120") + assert len(hits) == 1 # ...so 120 must NOT suppress its return finding + assert hits[0].qualname == "m.f" + + +def test_120_still_delegates_to_101_under_the_default_config(tmp_path: Path) -> None: + findings = _scan(tmp_path, _STORAGE_RETURN) + assert len(_rule_hits(findings, "PY-WL-101")) == 1 + assert _rule_hits(findings, "PY-WL-120") == [] # delegate fired — suppression stands + + +# ── 2. PY-WL-124 ArgSpec slot precision ────────────────────────────────────── + + +def test_124_tainted_mode_flag_with_constant_name_is_clean(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + import ctypes + @trusted(level='ASSURED') + def f(p): + a = ctypes.CDLL('libm.so.6', mode=read_raw(p)) + b = ctypes.CDLL('libm.so.6', use_errno=read_raw(p)) + return 1 + """, + ) + assert _rule_hits(findings, "PY-WL-124") == [] + + +def test_124_tainted_library_name_still_fires(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + import ctypes + @trusted(level='ASSURED') + def f(p): + a = ctypes.CDLL(read_raw(p)) + b = ctypes.CDLL(name=read_raw(p)) + c = ctypes.cdll.LoadLibrary(read_raw(p)) + return 1 + """, + ) + assert len(_rule_hits(findings, "PY-WL-124")) == 3 + + +# ── 3. PY-WL-125/126 parameter-annotation receivers ────────────────────────── + + +def test_125_param_annotated_logger_fires(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + import logging + @trusted(level='ASSURED') + def f(p, log: logging.Logger): + log.info(read_raw(p)) + return 1 + """, + ) + hits = _rule_hits(findings, "PY-WL-125") + assert len(hits) == 1 + assert hits[0].properties["sink"] == "logging.Logger.info" + + +def test_126_param_annotated_smtp_client_fires(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + import smtplib + @trusted(level='ASSURED') + def f(p, s: smtplib.SMTP): + s.sendmail('f@x.com', 't@x.com', read_raw(p)) + return 1 + """, + ) + assert len(_rule_hits(findings, "PY-WL-126")) == 1 + + +def test_param_annotation_is_shadowed_by_a_body_rebind(tmp_path: Path) -> None: + # A body rebind to an unresolvable RHS invalidates the parameter seed — + # the stale Logger class must not keep matching. + findings = _scan( + tmp_path, + """ + import logging + @trusted(level='ASSURED') + def f(p, log: logging.Logger): + log = make_thing() + log.info(read_raw(p)) + return 1 + """, + ) + assert _rule_hits(findings, "PY-WL-125") == [] + + +# ── 4. Operator severity override equal to the metadata default ────────────── + +_STDLIB_XML = """ +import xml.etree.ElementTree as ET +@trusted(level='ASSURED') +def f(p): + return ET.fromstring(read_raw(p)) +""" + + +def test_121_stdlib_sink_keeps_per_sink_warn_without_override(tmp_path: Path) -> None: + hits = _rule_hits(_scan(tmp_path, _STDLIB_XML), "PY-WL-121") + assert [f.severity for f in hits] == [Severity.WARN] + + +def test_121_explicit_override_equal_to_default_rebases_per_sink_severity(tmp_path: Path) -> None: + findings = _scan(tmp_path, _STDLIB_XML, WardlineConfig(rules_severity={"PY-WL-121": "ERROR"})) + hits = _rule_hits(findings, "PY-WL-121") + assert [f.severity for f in hits] == [Severity.ERROR] + + +def test_121_explicit_lower_override_still_rebases(tmp_path: Path) -> None: + findings = _scan(tmp_path, _STDLIB_XML, WardlineConfig(rules_severity={"PY-WL-121": "INFO"})) + hits = _rule_hits(findings, "PY-WL-121") + assert [f.severity for f in hits] == [Severity.INFO] + + +# ── 5. Flow-insensitive fallback is a FACT finding, not a UserWarning ──────── + + +def test_flow_insensitive_fallback_emits_one_fact_finding(tmp_path: Path, monkeypatch) -> None: + import ast + + import wardline.scanner.analyzer as analyzer_mod + + real = analyzer_mod.run_l2_function_stage + + def _boom(stage_input): # noqa: ANN001, ANN202 + if any(isinstance(n, ast.Name) and n.id == "boom" for n in ast.walk(stage_input.node)): + raise RecursionError("simulated deep L2") + return real(stage_input) + + monkeypatch.setattr(analyzer_mod, "run_l2_function_stage", _boom) + + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + boom = read_raw(p) + return eval(boom) + """, + ) + # The L2-skipped function degrades the sink rules to the pessimistic + # fallback. That degradation is one NONE/FACT finding per scan — never a + # warning (the _scan harness runs with warnings-as-error to prove it). + facts = _rule_hits(findings, "WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK") + assert len(facts) == 1 + assert facts[0].kind == Kind.FACT + assert facts[0].severity == Severity.NONE + assert facts[0].properties["qualnames"] == ["m.f"] + # The pessimistic fallback itself still fails closed: the sink fires. + assert len(_rule_hits(findings, "PY-WL-107")) == 1 + + +def test_no_fallback_fact_on_a_clean_scan(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return eval(read_raw(p)) + """, + ) + assert _rule_hits(findings, "WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK") == [] + + +# ── 6. Consolidated base: historical sink rules gain binding-awareness ─────── + + +def test_106_callable_alias_resolves_after_consolidation(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + import pickle + @trusted(level='ASSURED') + def f(p): + loader = pickle.loads + return loader(read_raw(p)) + """, + ) + hits = _rule_hits(findings, "PY-WL-106") + assert len(hits) == 1 + assert hits[0].properties["sink"] == "pickle.loads" + + +def test_107_callable_alias_resolves_after_consolidation(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + e = eval + return e(read_raw(p)) + """, + ) + assert len(_rule_hits(findings, "PY-WL-107")) == 1 diff --git a/tests/unit/scanner/rules/test_silent_exception.py b/tests/unit/scanner/rules/test_silent_exception.py index 5eaa246a..5f064d63 100644 --- a/tests/unit/scanner/rules/test_silent_exception.py +++ b/tests/unit/scanner/rules/test_silent_exception.py @@ -80,3 +80,80 @@ def test_reraise_is_clean(tmp_path) -> None: }, ) assert _run(ctx) == [] + + +def test_nested_trusted_def_uses_its_own_tier(tmp_path) -> None: + # A nested def carrying its OWN trust declaration is governed by that tier, not + # the undecorated outer's UNKNOWN_RAW (which would wrongly suppress the rule). + # Aligns PY-WL-104 with the sink family's enclosing_declared_tier semantics + # (wardline-bb8396f96e). + ctx, _ = _analyze( + tmp_path, + { + "m.py": """\ + from wardline.decorators import trusted + + def outer(p): + @trusted(level="ASSURED") + def inner(): + try: + g() + except ValueError: + pass + return inner + """, + }, + ) + findings = _run(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-104", "m.outer..inner")] + assert findings[0].severity == Severity.WARN + assert findings[0].properties["tier"] == "ASSURED" + + +def test_nested_external_boundary_def_inside_trusted_is_suppressed(tmp_path) -> None: + # A nested @external_boundary def is explicitly in the raw zone; the @trusted + # OUTER's tier must not leak onto it (the FP direction of the .. strip). + ctx, _ = _analyze( + tmp_path, + { + "m.py": """\ + from wardline.decorators import external_boundary, trusted + + @trusted(level="ASSURED") + def outer(p): + @external_boundary + def inner(): + try: + g() + except ValueError: + pass + return inner + """, + }, + ) + assert _run(ctx) == [] + + +def test_undeclared_nested_def_inherits_enclosing_declared_tier(tmp_path) -> None: + # A genuinely undeclared nested def inherits the nearest DECLARED enclosing + # scope's tier (wardline-9b88ec5419) — pins the inheritance half of the walk. + ctx, _ = _analyze( + tmp_path, + { + "m.py": """\ + from wardline.decorators import trusted + + @trusted(level="ASSURED") + def outer(p): + def inner(): + try: + g() + except ValueError: + pass + return inner + """, + }, + ) + findings = _run(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-104", "m.outer..inner")] + assert findings[0].properties["tier"] == "ASSURED" diff --git a/tests/unit/scanner/rules/test_sink_machinery.py b/tests/unit/scanner/rules/test_sink_machinery.py new file mode 100644 index 00000000..b9afb352 --- /dev/null +++ b/tests/unit/scanner/rules/test_sink_machinery.py @@ -0,0 +1,484 @@ +# tests/unit/scanner/rules/test_sink_machinery.py +"""Unit tests for the shared sink-resolution machinery in ``_sink_helpers``. + +Covers the three additive capabilities (consumers wire in separately): + 1. CONSTRUCT-THEN-METHOD: ``obj.method(...)`` resolves to ``.method`` + when the receiver's class is statically known in the function (direct + construction, chained construction, with/async-with targets, ann-assign). + 2. VARIABLE-BINDING ALIAS: ``runner = subprocess.run; runner(...)`` participates + in sink matching under the resolved FQN (module- or function-level). + 3. ARG-POSITION-AWARE matching: an :class:`ArgSpec` restricts taint resolution + to the declared dangerous argument slots; no spec keeps "worst of all args". +""" + +from __future__ import annotations + +import ast +import textwrap + +from wardline.core.taints import TaintState +from wardline.scanner.context import AnalysisContext +from wardline.scanner.rules._sink_helpers import ( + ArgSpec, + SinkBindings, + collect_sink_bindings, + resolve_bound_call_fqn, + resolved_sink_calls, + worst_dangerous_arg_taint, +) + + +def _last_def(src: str) -> ast.FunctionDef | ast.AsyncFunctionDef: + node = ast.parse(textwrap.dedent(src)).body[-1] + assert isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + return node + + +def _module(src: str) -> ast.Module: + return ast.parse(textwrap.dedent(src)) + + +def _sinks( + func: ast.AST, + sink_names: set[str], + alias_map: dict[str, str] | None = None, + module_bindings: SinkBindings | None = None, +) -> list[str]: + return [ + fqn + for _call, fqn in resolved_sink_calls( + func, frozenset(sink_names), alias_map or {}, "m", module_bindings=module_bindings + ) + ] + + +# --------------------------------------------------------------------------- +# Capability 1: construct-then-method +# --------------------------------------------------------------------------- + + +def test_direct_construction_resolves_method_to_class_fqn() -> None: + func = _last_def( + """ + def f(u): + c = httpx.Client() + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == ["httpx.Client.get"] + + +def test_chained_construction_resolves() -> None: + func = _last_def( + """ + def f(u): + a = httpx.Client().get(u) + b = requests.Session().get(u) + return a, b + """ + ) + assert _sinks( + func, + {"httpx.Client.get", "requests.Session.get"}, + {"httpx": "httpx", "requests": "requests"}, + ) == ["httpx.Client.get", "requests.Session.get"] + + +def test_with_target_resolves() -> None: + func = _last_def( + """ + def f(u): + with httpx.Client() as c: + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == ["httpx.Client.get"] + + +def test_async_with_target_resolves() -> None: + func = _last_def( + """ + async def f(u): + async with httpx.AsyncClient() as client: + return await client.get(u) + """ + ) + assert _sinks(func, {"httpx.AsyncClient.get"}, {"httpx": "httpx"}) == ["httpx.AsyncClient.get"] + + +def test_ann_assign_annotation_resolves() -> None: + # A bare annotation (no value) is a declaration of the var's class. + func = _last_def( + """ + def f(u, factory): + c: httpx.Client + c = factory() + return c.get(u) + """ + ) + # The later factory() rebind is unresolvable and invalidates — annotation + # alone (without an intervening unresolvable rebind) must resolve: + func2 = _last_def( + """ + def f(u): + c: httpx.Client = make_client() + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + assert _sinks(func2, {"httpx.Client.get"}, {"httpx": "httpx"}) == ["httpx.Client.get"] + + +def test_construction_honors_import_alias() -> None: + func = _last_def( + """ + def f(u): + c = hx.Client() + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"hx": "httpx"}) == ["httpx.Client.get"] + + +def test_rebind_to_different_class_uses_new_class_never_stale() -> None: + func = _last_def( + """ + def f(u): + c = httpx.Client() + c = requests.Session() + return c.get(u) + """ + ) + alias = {"httpx": "httpx", "requests": "requests"} + # last-binding-wins: the stale httpx class must NOT match + assert _sinks(func, {"httpx.Client.get"}, alias) == [] + assert _sinks(func, {"requests.Session.get"}, alias) == ["requests.Session.get"] + + +def test_rebind_to_unresolvable_invalidates() -> None: + # rebind via a non-dotted callable (subscript) — class no longer known + func = _last_def( + """ + def f(u, factories): + c = httpx.Client() + c = factories[0]() + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + + +def test_rebind_to_constant_invalidates() -> None: + func = _last_def( + """ + def f(u): + c = httpx.Client() + c = None + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + + +def test_tuple_unpack_rebind_invalidates() -> None: + func = _last_def( + """ + def f(u, pair): + c = httpx.Client() + c, d = pair + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + + +def test_unknown_class_method_never_matches() -> None: + func = _last_def( + """ + def f(u): + c = mystery.Thing() + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + + +def test_method_on_unbound_name_never_matches() -> None: + func = _last_def( + """ + def f(u, c): + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + + +def test_nested_def_bindings_do_not_leak_into_outer_scope() -> None: + func = _last_def( + """ + def f(u): + def g(): + c = httpx.Client() + return c + c = g() + return c.get(u) + """ + ) + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}) == [] + + +# --------------------------------------------------------------------------- +# Capability 2: variable-binding alias of a sink +# --------------------------------------------------------------------------- + + +def test_function_level_callable_alias_matches() -> None: + func = _last_def( + """ + def f(raw): + runner = subprocess.run + runner(raw, shell=True) + """ + ) + assert _sinks(func, {"subprocess.run"}, {"subprocess": "subprocess"}) == ["subprocess.run"] + + +def test_from_import_alias_callable_binding() -> None: + func = _last_def( + """ + def f(raw): + runner = run + runner(raw) + """ + ) + # ``from subprocess import run`` puts {"run": "subprocess.run"} in the alias map + assert _sinks(func, {"subprocess.run"}, {"run": "subprocess.run"}) == ["subprocess.run"] + + +def test_builtin_callable_alias() -> None: + func = _last_def( + """ + def f(raw): + do = eval + do(raw) + """ + ) + assert _sinks(func, {"eval"}) == ["eval"] + + +def test_module_level_callable_alias_via_module_bindings() -> None: + mod = _module( + """ + runner = subprocess.run + + def f(raw): + runner(raw, shell=True) + """ + ) + module_bindings = collect_sink_bindings(mod, {"subprocess": "subprocess"}, "m") + func = mod.body[-1] + assert _sinks(func, {"subprocess.run"}, {"subprocess": "subprocess"}, module_bindings) == ["subprocess.run"] + + +def test_module_level_instance_binding_via_module_bindings() -> None: + mod = _module( + """ + client = httpx.Client() + + def f(u): + return client.get(u) + """ + ) + module_bindings = collect_sink_bindings(mod, {"httpx": "httpx"}, "m") + func = mod.body[-1] + assert _sinks(func, {"httpx.Client.get"}, {"httpx": "httpx"}, module_bindings) == ["httpx.Client.get"] + + +def test_function_rebind_shadows_module_alias() -> None: + mod = _module( + """ + runner = subprocess.run + + def f(raw, other): + runner = other + runner(raw) + """ + ) + module_bindings = collect_sink_bindings(mod, {"subprocess": "subprocess"}, "m") + func = mod.body[-1] + assert _sinks(func, {"subprocess.run"}, {"subprocess": "subprocess"}, module_bindings) == [] + + +def test_alias_of_non_sink_never_matches() -> None: + func = _last_def( + """ + def f(raw): + runner = os.path.join + runner(raw) + """ + ) + assert _sinks(func, {"subprocess.run"}, {"os": "os", "subprocess": "subprocess"}) == [] + + +def test_alias_rebind_last_binding_wins() -> None: + func = _last_def( + """ + def f(raw): + runner = subprocess.run + runner = print + runner(raw) + """ + ) + assert _sinks(func, {"subprocess.run"}, {"subprocess": "subprocess"}) == [] + + +def test_direct_sink_calls_still_match_alongside_bindings() -> None: + # the extended iterator is a superset of sink_calls: direct dotted spellings keep working + func = _last_def( + """ + def f(raw): + runner = subprocess.run + runner(raw) + subprocess.run(raw, shell=True) + """ + ) + assert _sinks(func, {"subprocess.run"}, {"subprocess": "subprocess"}) == [ + "subprocess.run", + "subprocess.run", + ] + + +def test_collect_sink_bindings_separates_kinds() -> None: + func = _last_def( + """ + def f(): + c = httpx.Client() + runner = subprocess.run + """ + ) + bindings = collect_sink_bindings(func, {"httpx": "httpx", "subprocess": "subprocess"}, "m") + assert bindings.instance_classes == {"c": "httpx.Client"} + assert bindings.callable_aliases == {"runner": "subprocess.run"} + + +def test_resolve_bound_call_fqn_negative_paths() -> None: + bindings = SinkBindings(instance_classes={"c": "httpx.Client"}, callable_aliases={}) + # dynamic receiver (subscript) resolves to nothing + call = ast.parse("x[0].get(u)").body[0].value + assert isinstance(call, ast.Call) + assert resolve_bound_call_fqn(call, bindings, {}, "m") is None + # plain name call with no alias binding resolves to nothing + call2 = ast.parse("runner(u)").body[0].value + assert isinstance(call2, ast.Call) + assert resolve_bound_call_fqn(call2, bindings, {}, "m") is None + + +# --------------------------------------------------------------------------- +# Capability 3: arg-position-aware matching +# --------------------------------------------------------------------------- + + +def _call(src: str) -> ast.Call: + call = ast.parse(src).body[0].value # type: ignore[attr-defined] + assert isinstance(call, ast.Call) + return call + + +def _ctx(call: ast.Call, taints: dict[int | str | None, TaintState]) -> AnalysisContext: + return AnalysisContext( + project_taints={}, + project_return_taints={}, + function_var_taints={}, + function_return_taints={}, + function_return_callee={}, + entities={}, + taint_provenance={}, + function_call_site_arg_taints={"m.f": {id(call): taints}}, + ) + + +def test_no_spec_keeps_worst_of_all_args() -> None: + call = _call("requests.get(a, b)") + ctx = _ctx(call, {0: TaintState.ASSURED, 1: TaintState.EXTERNAL_RAW}) + assert worst_dangerous_arg_taint(call, "m.f", ctx, None) == TaintState.EXTERNAL_RAW + + +def test_spec_restricts_to_dangerous_positions() -> None: + # position 0 (the URL) is trusted; the raw arg sits in a non-dangerous slot + call = _call("requests.get(a, b)") + ctx = _ctx(call, {0: TaintState.ASSURED, 1: TaintState.EXTERNAL_RAW}) + spec = ArgSpec(positions=(0,)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.ASSURED + + +def test_spec_matches_dangerous_keyword() -> None: + call = _call("requests.get(timeout=t, url=u)") + ctx = _ctx(call, {"timeout": TaintState.ASSURED, "url": TaintState.EXTERNAL_RAW}) + spec = ArgSpec(positions=(0,), keywords=("url",)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.EXTERNAL_RAW + + +def test_spec_with_no_dangerous_args_present_returns_none() -> None: + call = _call("requests.get(timeout=t)") + ctx = _ctx(call, {"timeout": TaintState.EXTERNAL_RAW}) + spec = ArgSpec(positions=(0,), keywords=("url",)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) is None + + +def test_starred_positional_is_included_fail_closed() -> None: + # *parts may land in ANY positional slot — fail closed when positions are dangerous + call = _call("requests.get(*parts, t)") + ctx = _ctx( + call, + {0: TaintState.EXTERNAL_RAW, "*0": TaintState.EXTERNAL_RAW, 1: TaintState.ASSURED}, + ) + spec = ArgSpec(positions=(0,)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.EXTERNAL_RAW + + +def test_starred_not_included_when_only_keywords_dangerous() -> None: + call = _call("requests.get(*parts, url=u)") + ctx = _ctx( + call, + {0: TaintState.EXTERNAL_RAW, "*0": TaintState.EXTERNAL_RAW, "url": TaintState.ASSURED}, + ) + spec = ArgSpec(keywords=("url",)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.ASSURED + + +def test_double_star_kwargs_included_for_dangerous_keywords_fail_closed() -> None: + # **kw may supply any keyword — fail closed when keywords are dangerous + call = _call("requests.get(**kw)") + ctx = _ctx(call, {None: TaintState.EXTERNAL_RAW}) + spec = ArgSpec(keywords=("url",)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.EXTERNAL_RAW + + +def test_double_star_kwargs_not_included_when_only_positions_dangerous() -> None: + call = _call("requests.get(a, **kw)") + ctx = _ctx(call, {0: TaintState.ASSURED, None: TaintState.EXTERNAL_RAW}) + spec = ArgSpec(positions=(0,)) + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.ASSURED + + +def test_missing_snapshot_falls_back_pessimistic_for_dangerous_slot() -> None: + import warnings + + call = _call("requests.get(u)") + ctx = AnalysisContext( + project_taints={}, + project_return_taints={}, + function_var_taints={}, + function_return_taints={}, + function_return_callee={}, + entities={}, + taint_provenance={}, + ) + spec = ArgSpec(positions=(0,)) + # The degradation is RECORDED on the context (surfaced by the analyzer as one + # WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK FACT per scan), never warned — a + # warnings-as-error embedder must not lose a whole rule to the diagnostic. + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert worst_dangerous_arg_taint(call, "m.f", ctx, spec) == TaintState.UNKNOWN_RAW + assert ctx.flow_insensitive_fallbacks == {"m.f"} diff --git a/tests/unit/scanner/rules/test_sink_rules.py b/tests/unit/scanner/rules/test_sink_rules.py index b26dc0a9..a61f0dce 100644 --- a/tests/unit/scanner/rules/test_sink_rules.py +++ b/tests/unit/scanner/rules/test_sink_rules.py @@ -430,6 +430,67 @@ def f(p, kind): assert UntrustedToExec().check(ctx) == [] +def test_107_sink_lambda_in_non_last_if_arm_fires_after_branch(tmp_path) -> None: + # wardline-383f83fafe (single-slot ceiling): the sink-lambda is bound in the + # NON-last (if) arm; a BENIGN lambda rebinds ``cb`` in the last (else) arm. With the + # old single-slot binding map the benign last arm overwrote the sink binding, so the + # post-branch ``cb(raw)`` resolved only the benign body and the eval(x) sink was a + # silent false-negative. The candidate-set merge keeps both → PY-WL-107 fires. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, cond): + raw = read_raw(p) + if cond: + cb = lambda x: eval(x) + else: + cb = lambda x: x + cb(raw) + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToExec().check(ctx)] == [("PY-WL-107", "m.f")] + + +def test_107_sink_lambda_in_non_last_try_arm_fires_after_branch(tmp_path) -> None: + # Same single-slot ceiling for try/except: sink-lambda in the try-success arm, benign + # rebind in the handler arm, tainted ``cb(raw)`` after the whole try/except. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + try: + cb = lambda x: eval(x) + except Exception: + cb = lambda x: x + cb(raw) + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToExec().check(ctx)] == [("PY-WL-107", "m.f")] + + +def test_107_sink_lambda_in_non_last_match_arm_fires_after_branch(tmp_path) -> None: + # Same single-slot ceiling for match/case: sink-lambda in a non-last case-arm, benign + # rebind in the catch-all arm, tainted ``cb(raw)`` after the match. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, kind): + raw = read_raw(p) + match kind: + case "a": + cb = lambda x: eval(x) + case _: + cb = lambda x: x + cb(raw) + """, + ) + assert [(x.rule_id, x.qualname) for x in UntrustedToExec().check(ctx)] == [("PY-WL-107", "m.f")] + + def test_108_raw_reaches_os_system_in_lambda_body(tmp_path) -> None: # The engine fix is sink-agnostic (shared _resolve_expr / worst_arg_taint): a # command sink in a lambda body fires flow-sensitively on real taint too. @@ -477,6 +538,21 @@ def f(): assert UntrustedToCommand().check(ctx) == [] +def test_108_undecorated_is_suppressed(tmp_path) -> None: + # Matrix slot (wardline-e159060db7): the suite pinned 106/112 undecorated + # suppression but not 108's — an undecorated (UNKNOWN_RAW freedom-zone) + # function must stay silent even with raw data at the always-shell sink. + ctx = _analyze( + tmp_path, + """ + def f(p): + os.system(read_raw(p)) + return 1 + """, + ) + assert UntrustedToCommand().check(ctx) == [] + + def test_107_self_method_call_arg_fires(tmp_path) -> None: # Sibling method call (self.get_raw) should resolve to EXTERNAL_RAW and trigger PY-WL-107. ctx = _analyze( @@ -495,6 +571,53 @@ def run(self, p): assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-107", "m.Service.run")] +def test_107_stale_var_type_does_not_launder_raw_receiver(tmp_path) -> None: + # A name re-bound to an untyped RHS (a subscript here) holds raw, but its STALE recorded + # type (Box) let ``x.val()`` resolve Box.val's clean @trusted summary, laundering raw past + # the RAW_ZONE receiver guard (wardline-5ba7ce0f98). The reassignment must invalidate the + # type so the receiver stays raw and eval(out) fires. + ctx = _analyze( + tmp_path, + """ + class Box: + @trusted(level='ASSURED') + def val(self): + return "literal" + + @trusted(level='ASSURED') + def f(p): + x = Box() + lst = [read_raw(p)] + x = lst[0] + out = x.val() + eval(out) + """, + ) + findings = UntrustedToExec().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-107", "m.f")] + + +def test_107_genuinely_typed_clean_receiver_still_resolves_clean(tmp_path) -> None: + # The no-FP guard for the stale-type fix: a receiver whose type is current (never + # re-bound) must still resolve its clean @trusted method summary — no PY-WL-107. + ctx = _analyze( + tmp_path, + """ + class Box: + @trusted(level='ASSURED') + def val(self): + return "literal" + + @trusted(level='ASSURED') + def f(): + x = Box() + out = x.val() + eval(out) + """, + ) + assert UntrustedToExec().check(ctx) == [] + + def test_112_raw_reaches_subprocess_shell_true(tmp_path) -> None: ctx = _analyze( tmp_path, @@ -659,8 +782,11 @@ def via_from_import_alias(p): ] -def test_fallback_flow_insensitive_warnings() -> None: - # If flow-sensitive map is missing, we warn and pessimistically assume UNKNOWN_RAW. +def test_fallback_flow_insensitive_records_on_the_context() -> None: + # If the flow-sensitive map is missing, the resolver records the degradation on + # the context (one WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK FACT per scan, emitted + # by the analyzer) and pessimistically assumes UNKNOWN_RAW. Never a warning — + # a warnings-as-error embedder must not lose a whole rule to the diagnostic. import ast import warnings @@ -680,9 +806,8 @@ def test_fallback_flow_insensitive_warnings() -> None: taint_provenance={}, ) # Empty context has no flow-sensitive mappings - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") + with warnings.catch_warnings(): + warnings.simplefilter("error") res = worst_arg_taint(call, "m.f", context) - assert len(w) == 1 - assert "WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK" in str(w[0].message) - assert res == TaintState.UNKNOWN_RAW + assert res == TaintState.UNKNOWN_RAW + assert context.flow_insensitive_fallbacks == {"m.f"} diff --git a/tests/unit/scanner/rules/test_sql_injection_expansion.py b/tests/unit/scanner/rules/test_sql_injection_expansion.py new file mode 100644 index 00000000..4a6f9f21 --- /dev/null +++ b/tests/unit/scanner/rules/test_sql_injection_expansion.py @@ -0,0 +1,394 @@ +"""PY-WL-118 precision + coverage expansion (wardline-1751b0fac6 + 2026-06-10 eval FPs). + +Three behaviour groups: + +1. Text-clause constant exemption — ``conn.execute(text("... :id"), {"id": uid})`` + is THE canonical safe SQLAlchemy parameterized pattern: the operation string is a + compile-time constant wrapped in a recognized text-clause constructor, so it cannot + carry attacker bytes. ``text(tainted)`` must still fire (the exemption keys on the + constructor FQN AND all-constant arguments, never on the wrapper alone). + +2. Receiver heuristic — ``.execute``/``.executemany`` matching was receiver-blind, so + ``pool.execute(task)`` on a thread-pool object fired a CWE-89 ERROR. Clearly-non-DB + receivers (pool/executor/worker names, or instances constructed from executor + modules) stop firing; DB-ish and UNKNOWN receivers keep firing (fail-closed — an FN + here is worse than an FP). + +3. Sink coverage — ``executescript`` (sqlite3 cursor/connection; non-parameterizable, + strictly more dangerous than ``execute``) joins the sink set, plus the previously + untested ``executemany`` positive direction. +""" + +from __future__ import annotations + +import textwrap +from collections.abc import Sequence +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Finding, Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer + + +def _analyze_files(tmp_path: Path, files: dict[str, str]) -> Sequence[Finding]: + for name, content in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + header = ( + "from wardline.decorators import external_boundary, trust_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + ) + p.write_text(header + textwrap.dedent(content), encoding="utf-8") + + analyzer = WardlineAnalyzer() + file_paths = [tmp_path / name for name in files] + return analyzer.analyze(file_paths, WardlineConfig(), root=tmp_path) + + +def _sqli(findings: Sequence[Finding]) -> list[Finding]: + return [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + + +# ── 1. text-clause constant exemption (canonical SQLAlchemy parameterized query) ── + + +def test_sqlalchemy_text_constant_operation_does_not_fire(tmp_path: Path) -> None: + # The canonical safe pattern: constant SQL wrapped in text(), untrusted value bound. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + from sqlalchemy import text + + @trusted(level='ASSURED') + def f(p, conn): + uid = read_raw(p) + conn.execute(text("SELECT * FROM t WHERE id = :id"), {"id": uid}) + """ + }, + ) + assert _sqli(findings) == [] + + +def test_sqlalchemy_module_alias_text_constant_does_not_fire(tmp_path: Path) -> None: + # Import-alias awareness: ``import sqlalchemy as sa; sa.text(...)`` and the + # ``sqlalchemy.sql.text`` spelling both resolve to a recognized text-clause FQN. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + import sqlalchemy as sa + from sqlalchemy.sql import text as mktext + + @trusted(level='ASSURED') + def f(p, conn): + uid = read_raw(p) + conn.execute(sa.text("SELECT * FROM t WHERE id = :id"), {"id": uid}) + conn.execute(mktext("SELECT * FROM u WHERE id = :id"), {"id": uid}) + """ + }, + ) + assert _sqli(findings) == [] + + +def test_sqlalchemy_text_constant_via_operation_keyword_does_not_fire(tmp_path: Path) -> None: + findings = _analyze_files( + tmp_path, + { + "m.py": """ + from sqlalchemy import text + + @trusted(level='ASSURED') + def f(p, conn): + conn.execute(statement=text("SELECT 1"), parameters={"id": read_raw(p)}) + """ + }, + ) + assert _sqli(findings) == [] + + +def test_sqlalchemy_text_with_tainted_argument_still_fires(tmp_path: Path) -> None: + # text() is NOT a sanitiser: a tainted string inside the wrapper is still SQLi. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + from sqlalchemy import text + + @trusted(level='ASSURED') + def f(p, conn): + conn.execute(text(read_raw(p))) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_sqlalchemy_text_with_fstring_argument_still_fires(tmp_path: Path) -> None: + # A JoinedStr is not a Constant — interpolated taint inside text() keeps firing. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + from sqlalchemy import text + + @trusted(level='ASSURED') + def f(p, conn): + conn.execute(text(f"SELECT * FROM {read_raw(p)}")) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_unrecognized_constant_arg_wrapper_still_fires(tmp_path: Path) -> None: + # The exemption is scoped to RECOGNIZED text-clause constructors — an arbitrary + # third-party wrapper around a constant stays fail-closed (UNKNOWN_RAW fires). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + import mylib + + @trusted(level='ASSURED') + def f(p, conn): + conn.execute(mylib.textish("SELECT 1")) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_sqlalchemy_text_zero_args_stays_fail_closed(tmp_path: Path) -> None: + # text() with no argument proves nothing constant; the slot keeps its engine taint. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + from sqlalchemy import text + + @trusted(level='ASSURED') + def f(p, conn): + conn.execute(text()) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +# ── 2. receiver heuristic ────────────────────────────────────────────────── + + +def test_non_db_receiver_pool_does_not_fire(tmp_path: Path) -> None: + # The eval FP repro: a thread-pool/command object is not a SQL sink. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, pool): + task = read_raw(p) + pool.execute(task) + """ + }, + ) + assert _sqli(findings) == [] + + +def test_non_db_receiver_thread_pool_attribute_does_not_fire(tmp_path: Path) -> None: + # Non-DB token match on the LAST attribute segment (self.thread_pool → "thread_pool"). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(self, p): + self.thread_pool.execute(read_raw(p)) + """ + }, + ) + assert _sqli(findings) == [] + + +def test_non_db_constructed_executor_does_not_fire(tmp_path: Path) -> None: + # Construct-then-method resolution: an instance provably built from an executor + # module is non-DB even when its NAME looks DB-ish. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + import concurrent.futures + + @trusted(level='ASSURED') + def f(p): + runner = concurrent.futures.ThreadPoolExecutor() + runner.execute(read_raw(p)) + """ + }, + ) + assert _sqli(findings) == [] + + +def test_db_constructed_instance_overrides_non_db_name(tmp_path: Path) -> None: + # Binding evidence beats the name heuristic: a var NAMED "pool" that is provably a + # sqlite3 connection is a real SQL sink and must fire. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + import sqlite3 + + @trusted(level='ASSURED') + def f(p): + pool = sqlite3.connect(":memory:") + pool.execute(read_raw(p)) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_unknown_receiver_fails_closed_and_fires(tmp_path: Path) -> None: + # An opaque single-letter receiver gives no evidence either way — fail closed, fire + # (FN is worse than FP; covers the SQLAlchemy ``s.execute`` / DB-API ``c.execute`` style). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, c): + c.execute(read_raw(p)) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_db_token_receiver_keeps_firing(tmp_path: Path) -> None: + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, db_session): + db_session.execute(read_raw(p)) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_db_token_wins_over_non_db_token(tmp_path: Path) -> None: + # Mixed evidence ("db_pool") resolves toward firing — conservative by design. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, db_pool): + db_pool.execute(read_raw(p)) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +# ── 3. sink coverage: executescript + executemany ────────────────────────── + + +def test_executescript_tainted_fires_at_base_severity(tmp_path: Path) -> None: + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, cursor): + cursor.executescript(read_raw(p)) + """ + }, + ) + sqli = _sqli(findings) + assert len(sqli) == 1 + assert sqli[0].severity is Severity.ERROR + assert sqli[0].properties["sink"] == "executescript" + + +def test_executescript_on_connection_fires(tmp_path: Path) -> None: + # sqlite3 exposes executescript on the CONNECTION as well as the cursor. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + import sqlite3 + + @trusted(level='ASSURED') + def f(p): + conn = sqlite3.connect(":memory:") + conn.executescript(read_raw(p)) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_executescript_sql_script_keyword_fires(tmp_path: Path) -> None: + # The sqlite3 parameter name spelling of the script slot. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, cursor): + cursor.executescript(sql_script=read_raw(p)) + """ + }, + ) + assert len(_sqli(findings)) == 1 + + +def test_executescript_constant_script_does_not_fire(tmp_path: Path) -> None: + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(cursor): + cursor.executescript("CREATE TABLE t (id INTEGER); CREATE INDEX i ON t (id);") + """ + }, + ) + assert _sqli(findings) == [] + + +def test_executemany_tainted_operation_fires(tmp_path: Path) -> None: + # executemany is in _SINKS but had no positive test (wardline-1751b0fac6). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, cursor): + cursor.executemany(read_raw(p), []) + """ + }, + ) + sqli = _sqli(findings) + assert len(sqli) == 1 + assert sqli[0].properties["sink"] == "executemany" + assert sqli[0].severity is Severity.ERROR + + +def test_118_undecorated_is_suppressed(tmp_path: Path) -> None: + # Matrix slot (wardline-e159060db7): SQLInjection overrides check(), so its + # tier-gate branch needs its own undecorated (UNKNOWN_RAW freedom-zone) + # negative — raw data at the execute sink must stay silent without a trust claim. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + def f(p, cursor): + cursor.execute(read_raw(p)) + """ + }, + ) + assert _sqli(findings) == [] diff --git a/tests/unit/scanner/rules/test_ssrf_expansion.py b/tests/unit/scanner/rules/test_ssrf_expansion.py new file mode 100644 index 00000000..9300acd9 --- /dev/null +++ b/tests/unit/scanner/rules/test_ssrf_expansion.py @@ -0,0 +1,296 @@ +# tests/unit/scanner/rules/test_ssrf_expansion.py +"""PY-WL-117 SSRF expansion — wardline-3002f63969 / wardline-66b2c91470. + +Pins three directions: + 1. Construct-then-method sinks (the dominant real-world shape): methods on a + constructed ``httpx.Client``/``AsyncClient`` (incl. ``async with``), + ``requests.Session()``, and ``aiohttp.ClientSession``. + 2. Module-level gaps: ``requests.head``/``requests.options`` and + ``urllib.request.Request`` (the constructor carries the tainted URL). + 3. Arg-position precision: only the URL slot (and ``base_url=`` on client + constructors) drives the verdict — a tainted ``timeout=``/``headers=``/ + ``verify=`` with a clean literal URL is NOT an SSRF vector. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Finding, Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer + +_HEADER = ( + "import requests\n" + "import httpx\n" + "import aiohttp\n" + "import urllib.request\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _ssrf(tmp_path: Path, src: str) -> list[Finding]: + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + findings = WardlineAnalyzer().analyze([p], WardlineConfig(), root=tmp_path) + return [f for f in findings if f.rule_id == "PY-WL-117" and f.kind is Kind.DEFECT] + + +# ── 1. construct-then-method ─────────────────────────────────────────────── + + +def test_117_httpx_client_construct_then_get_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + client = httpx.Client() + client.get(read_raw(p)) + """, + ) + assert [(f.qualname, f.properties["sink"]) for f in findings] == [("m.f", "httpx.Client.get")] + assert findings[0].severity is Severity.WARN + + +def test_117_httpx_async_with_client_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + async def fetch(p): + async with httpx.AsyncClient() as client: + await client.get(read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["httpx.AsyncClient.get"] + + +def test_117_requests_session_chained_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + requests.Session().get(read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["requests.Session.get"] + + +def test_117_requests_session_var_post_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + s = requests.Session() + s.post(read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["requests.Session.post"] + + +def test_117_aiohttp_session_method_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + async def fetch(p): + async with aiohttp.ClientSession() as session: + await session.get(read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["aiohttp.ClientSession.get"] + + +def test_117_client_request_tainted_url_position_fires(tmp_path: Path) -> None: + # client.request(method, url): the URL is the SECOND slot. + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + client = httpx.Client() + client.request("GET", read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["httpx.Client.request"] + + +def test_117_client_request_tainted_method_verb_does_not_fire(tmp_path: Path) -> None: + # A tainted method VERB with a clean literal URL cannot redirect the request target. + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + client = httpx.Client() + client.request(read_raw(p), "https://safe.example") + """, + ) + assert findings == [] + + +def test_117_client_stream_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + with httpx.Client() as client: + client.stream("GET", read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["httpx.Client.stream"] + + +# ── 2. module-level gaps ─────────────────────────────────────────────────── + + +def test_117_requests_head_and_options_fire(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + requests.head(read_raw(p)) + requests.options(read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["requests.head", "requests.options"] + + +def test_117_urllib_request_request_constructor_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + req = urllib.request.Request(read_raw(p)) + return req + """, + ) + assert [f.properties["sink"] for f in findings] == ["urllib.request.Request"] + + +def test_117_module_level_requests_get_stays_green(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + requests.get(read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["requests.get"] + + +# ── 3. arg-position precision ────────────────────────────────────────────── + + +def test_117_tainted_non_url_kwargs_clean_url_do_not_fire(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + requests.get('https://safe.example', timeout=read_raw(p)) + requests.get('https://safe.example', verify=read_raw(p)) + requests.get('https://safe.example', headers=read_raw(p)) + """, + ) + assert findings == [] + + +def test_117_tainted_url_keyword_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + requests.get(url=read_raw(p), timeout=5) + """, + ) + assert [f.properties["sink"] for f in findings] == ["requests.get"] + + +def test_117_httpx_client_ctor_tainted_timeout_does_not_fire(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + httpx.Client(timeout=read_raw(p)) + """, + ) + assert findings == [] + + +def test_117_httpx_client_ctor_tainted_first_positional_does_not_fire(tmp_path: Path) -> None: + # httpx.Client()'s first positional is not a URL; base_url is keyword-only. + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + httpx.Client(read_raw(p)) + """, + ) + assert findings == [] + + +def test_117_httpx_client_ctor_tainted_base_url_fires(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + httpx.Client(base_url=read_raw(p)) + """, + ) + assert [f.properties["sink"] for f in findings] == ["httpx.Client"] + + +def test_117_instance_method_tainted_headers_clean_url_does_not_fire(tmp_path: Path) -> None: + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + client = httpx.Client() + client.get('https://safe.example', headers=read_raw(p)) + """, + ) + assert findings == [] + + +def test_117_star_args_widening_fires(tmp_path: Path) -> None: + # *args makes positional slots unmappable: fail-closed widening keeps the verdict. + findings = _ssrf( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + parts = read_raw(p) + requests.get(*parts) + """, + ) + assert [f.properties["sink"] for f in findings] == ["requests.get"] + + +def test_117_undecorated_construct_then_method_is_suppressed(tmp_path: Path) -> None: + # Freedom zone: the tier gate applies to the new shapes exactly as to the old ones. + findings = _ssrf( + tmp_path, + """ + def f(p): + client = httpx.Client() + client.get(read_raw(p)) + """, + ) + assert findings == [] diff --git a/tests/unit/scanner/rules/test_stored_taint_expansion.py b/tests/unit/scanner/rules/test_stored_taint_expansion.py new file mode 100644 index 00000000..41eec37c --- /dev/null +++ b/tests/unit/scanner/rules/test_stored_taint_expansion.py @@ -0,0 +1,274 @@ +"""PY-WL-120 precision + stored-source coverage (wardline-66b2c91470 / stored.json FP). + +Three behaviors under test: + +1. **Receiver-aware storage matching.** ``io.StringIO`` / ``io.BytesIO`` are + in-memory buffers — a ``.read()`` on one returns data the process itself put + there, never *persistent storage* — so they are exempt from PY-WL-120's + storage-read matcher (the old matcher accepted ANY ``.read()`` receiver-blind + and mislabeled an in-memory constant as "stored/persisted data"). + +2. **101 delegation on unsubstantiated provenance.** When a matched return's + taint is ``UNKNOWN_RAW``/``MIXED_RAW`` the engine could not resolve where the + value came from, so the "stored/persisted" label rests solely on the AST name + match — unsubstantiated. PY-WL-120 suppresses its return finding and delegates + to PY-WL-101 (which polices the same trust-claim violation, gate-eligibly). + A substantiated ``EXTERNAL_RAW`` storage return keeps the documented + complementary 120+101 pair (both pinned by the frozen identity corpus). + +3. **DB-cursor seeding is real.** ``cursor.fetchone/fetchall/fetchmany()`` seed + ``EXTERNAL_RAW`` (wardline-e7c7cda31a), so PY-WL-120's advertised DB-cursor + coverage fires on both the trusted-callee arm and the return arm. +""" + +from __future__ import annotations + +import textwrap +from collections.abc import Sequence +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Finding, Kind +from wardline.core.taints import TaintState +from wardline.scanner.analyzer import WardlineAnalyzer + + +def _analyze(tmp_path: Path, source: str) -> Sequence[Finding]: + header = ( + "from wardline.decorators import external_boundary, trust_boundary, trusted\n" + "import io\n" + "@trust_boundary(to_level='ASSURED')\n" + "def validate(x):\n" + " if not x:\n raise ValueError\n return x\n" + ) + p = tmp_path / "m.py" + p.write_text(header + textwrap.dedent(source), encoding="utf-8") + analyzer = WardlineAnalyzer() + return analyzer.analyze([p], WardlineConfig(), root=tmp_path) + + +def _rule(findings: Sequence[Finding], rule_id: str) -> list[Finding]: + return [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == rule_id] + + +# ── 1. In-memory buffer receivers are exempt (the verified FP) ─────────────── + + +def test_stringio_constant_read_is_not_stored_data(tmp_path: Path) -> None: + # stored.json FP: an io.StringIO of an internal constant involves zero + # persistent storage — the receiver-blind ``.read()`` match mislabeled it. + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + buf = io.StringIO("internal constant") + data = buf.read() + return data + """, + ) + assert _rule(findings, "PY-WL-120") == [] + + +def test_chained_stringio_read_is_not_stored_data(tmp_path: Path) -> None: + # Chained constructor→method form: io.StringIO("x").read(). + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + return io.StringIO("x").read() + """, + ) + assert _rule(findings, "PY-WL-120") == [] + + +def test_from_import_stringio_read_is_not_stored_data(tmp_path: Path) -> None: + # The exemption resolves through the module import alias map. + findings = _analyze( + tmp_path, + """ + from io import StringIO + + @trusted(level='ASSURED') + def f(): + buf = StringIO("x") + data = buf.read() + return data + """, + ) + assert _rule(findings, "PY-WL-120") == [] + + +def test_bytesio_with_block_read_is_not_stored_data(tmp_path: Path) -> None: + # ``with io.BytesIO(...) as buf`` binds via the context-manager target form. + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + with io.BytesIO(b"x") as buf: + data = buf.read() + return data + """, + ) + assert _rule(findings, "PY-WL-120") == [] + + +def test_open_file_read_still_fires(tmp_path: Path) -> None: + # CONTROL: a genuine file read (open() receiver, EXTERNAL_RAW provenance) + # keeps firing — the exemption is buffer-class-specific, not a .read() FN. + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def get_data(): + f = open('data.txt') + content = f.read() + return content + """, + ) + st = _rule(findings, "PY-WL-120") + assert [f.qualname for f in st] == ["m.get_data"] + + +def test_stringio_rebound_to_open_file_still_fires(tmp_path: Path) -> None: + # Last-binding-wins: a name rebound from a buffer to a real file is NOT exempt. + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + h = io.StringIO("x") + h = open('data.txt') + data = h.read() + return data + """, + ) + st = _rule(findings, "PY-WL-120") + assert [f.qualname for f in st] == ["m.f"] + + +# ── 2. Return-arm delegation to PY-WL-101 on unsubstantiated provenance ────── + + +def test_unresolved_receiver_read_return_delegates_to_101(tmp_path: Path) -> None: + # ``legacy.store.read()``: an imported-but-unmodeled dotted call resolves to + # UNKNOWN_RAW — provenance unresolved, so the "stored/persisted" label rests + # solely on the AST ``.read`` name match. 120 suppresses and delegates to 101, + # which polices the same trust-claim violation (ONE finding on the return, + # not two). (A bare PARAM receiver like ``handle.read()`` propagates the + # trusted seed instead — neither rule fires there; documented + # over-approximation, see variable_level.py.) + findings = _analyze( + tmp_path, + """ + import legacy + + @trusted(level='ASSURED') + def f(): + data = legacy.store.read() + return data + """, + ) + assert _rule(findings, "PY-WL-120") == [] + p101 = _rule(findings, "PY-WL-101") + assert [f.qualname for f in p101] == ["m.f"] + assert p101[0].properties["actual_return"] == TaintState.UNKNOWN_RAW.value + + +def test_external_raw_storage_return_keeps_the_documented_pair(tmp_path: Path) -> None: + # A SUBSTANTIATED storage return (EXTERNAL_RAW from the open() seed) keeps the + # deliberate complementary pair: 101 = trust-claim violation (gate-eligible), + # 120 = storage-provenance annotation (PREVIEW). Pinned by the frozen identity + # corpus (sinks fixture: open_catalog_file / lookup_member). + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def get_data(): + content = open('data.txt').read() + return content + """, + ) + assert len(_rule(findings, "PY-WL-120")) == 1 + p101 = _rule(findings, "PY-WL-101") + assert len(p101) == 1 + assert p101[0].properties["actual_return"] == TaintState.EXTERNAL_RAW.value + + +# ── 3. DB-cursor fetch coverage (advertised → real; wardline-e7c7cda31a) ───── + + +def test_cursor_fetchone_return_fires_with_external_raw_seed(tmp_path: Path) -> None: + # Acceptance: cursor.fetchone() flowing to a return in a @trusted fn produces + # the documented finding — the fetch* EXTERNAL_RAW seed substantiates the + # storage provenance, so the 120 return arm fires (alongside 101's claim check). + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def load_record(cursor): + row = cursor.fetchone() + return row + """, + ) + st = _rule(findings, "PY-WL-120") + assert [f.qualname for f in st] == ["m.load_record"] + assert st[0].properties["return_taint"] == TaintState.EXTERNAL_RAW.value + p101 = _rule(findings, "PY-WL-101") + assert len(p101) == 1 + assert p101[0].properties["actual_return"] == TaintState.EXTERNAL_RAW.value + + +def test_cursor_fetchone_to_trusted_callee_fires(tmp_path: Path) -> None: + # Acceptance: cursor.fetchone() flowing to a trusted-callee sink fires the + # 120 call arm (the arm PY-WL-101 does not cover). + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def store(val): + return 1 + + @trusted(level='ASSURED') + def load_record(cursor): + row = cursor.fetchone() + store(row) + """, + ) + st = _rule(findings, "PY-WL-120") + assert [f.qualname for f in st] == ["m.load_record"] + assert st[0].properties["callee"] == "m.store" + assert st[0].properties["arg_taint"] == TaintState.EXTERNAL_RAW.value + + +def test_cursor_fetchmany_return_fires(tmp_path: Path) -> None: + # fetchmany completes the advertised fetch{one,all,many} trio (fetchall is + # pinned in test_wave3_deferred_rules.py). + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def load_records(cursor): + rows = cursor.fetchmany(5) + return rows + """, + ) + st = _rule(findings, "PY-WL-120") + assert [f.qualname for f in st] == ["m.load_records"] + assert st[0].properties["return_taint"] == TaintState.EXTERNAL_RAW.value + + +def test_cursor_fetch_validated_stays_silent(tmp_path: Path) -> None: + # CONTROL: laundering through a @trust_boundary clears both arms. + findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def load_record(cursor): + row = cursor.fetchone() + return validate(row) + """, + ) + assert _rule(findings, "PY-WL-120") == [] diff --git a/tests/unit/scanner/rules/test_taint_closures_locked.py b/tests/unit/scanner/rules/test_taint_closures_locked.py index 65d596c5..601e0679 100644 --- a/tests/unit/scanner/rules/test_taint_closures_locked.py +++ b/tests/unit/scanner/rules/test_taint_closures_locked.py @@ -28,6 +28,9 @@ def _defects(tmp_path: Path, body: str) -> set[str]: + # Assertions below pin the EXACT defect set (wardline-e159060db7): a + # membership-only check would let a spurious co-firing (e.g. a boundary + # heuristic regression adding PY-WL-102) ship invisibly. p = tmp_path / "m.py" p.write_text(_HEADER + textwrap.dedent(body), encoding="utf-8") analyzer = WardlineAnalyzer() @@ -39,35 +42,47 @@ def _defects(tmp_path: Path, body: str) -> set[str]: def test_comprehension_propagates_raw_to_trusted_return(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "@trusted(level='ASSURED')\ndef f(p):\n return [x for x in read_raw(p)]", ) def test_walrus_propagates_raw_to_trusted_return(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "@trusted(level='ASSURED')\ndef f(p):\n (y := read_raw(p))\n return y", ) +def test_match_subject_nested_walrus_propagates_raw_to_trusted_return(tmp_path: Path) -> None: + assert {"PY-WL-101"} == _defects( + tmp_path, + "@trusted(level='ASSURED')\n" + "def f(p):\n" + " match (s := read_raw(p))[0]:\n" + " case _:\n" + " pass\n" + " return s", + ) + + def test_starred_unpack_raw_slice_propagates_to_trusted_return(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "@trusted(level='ASSURED')\ndef f(p):\n (a, *rest, c) = (1, read_raw(p), 2)\n return rest", ) def test_existing_comprehension_walrus_target_propagates_to_trusted_return(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "@trusted(level='ASSURED')\ndef f(p):\n x = 1\n [(x := read_raw(p)) for _ in items]\n return x", ) def test_async_for_body_propagates_to_trusted_return(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "@trusted(level='ASSURED')\n" "async def f(p):\n" @@ -78,7 +93,7 @@ def test_async_for_body_propagates_to_trusted_return(tmp_path: Path) -> None: def test_trystar_handler_propagates_to_trusted_return(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "@trusted(level='ASSURED')\ndef f(p):\n" " x = 1\n" @@ -96,7 +111,7 @@ def test_trystar_handler_propagates_to_trusted_return(tmp_path: Path) -> None: @pytest.mark.parametrize("sink_arg", ["*args", "**kwargs"]) def test_starred_raw_into_sink_fires(tmp_path: Path, sink_arg: str) -> None: binder = "args = read_raw(p)" if sink_arg == "*args" else "kwargs = read_raw(p)" - assert "PY-WL-107" in _defects( + assert {"PY-WL-107"} == _defects( tmp_path, f"@trusted(level='ASSURED')\ndef f(p):\n {binder}\n eval({sink_arg})", ) @@ -105,7 +120,7 @@ def test_starred_raw_into_sink_fires(tmp_path: Path, sink_arg: str) -> None: def test_trusted_function_own_varargs_do_not_fire(tmp_path: Path) -> None: # *args/**kwargs of a @trusted function ARE that function's own params at its # declared tier — nothing untrusted entered, so returning them is clean. - assert "PY-WL-101" not in _defects( + assert set() == _defects( tmp_path, "@trusted(level='ASSURED')\ndef f(*args, **kwargs):\n return kwargs", ) @@ -115,7 +130,7 @@ def test_trusted_function_own_varargs_do_not_fire(tmp_path: Path) -> None: def test_call_through_wrapped_callee_propagates(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "def deco(fn):\n" " @functools.wraps(fn)\n def w(*a, **k):\n return fn(*a, **k)\n return w\n" @@ -125,7 +140,7 @@ def test_call_through_wrapped_callee_propagates(tmp_path: Path) -> None: def test_wraps_decorator_stacked_on_producer_propagates(tmp_path: Path) -> None: - assert "PY-WL-101" in _defects( + assert {"PY-WL-101"} == _defects( tmp_path, "def deco(fn):\n" " @functools.wraps(fn)\n def w(*a, **k):\n return fn(*a, **k)\n return w\n" diff --git a/tests/unit/scanner/rules/test_tier_gate_negative.py b/tests/unit/scanner/rules/test_tier_gate_negative.py new file mode 100644 index 00000000..52b2bdcc --- /dev/null +++ b/tests/unit/scanner/rules/test_tier_gate_negative.py @@ -0,0 +1,285 @@ +"""Negative tier-gate tests: rules must stay SILENT below their tier gate. + +Issue wardline-f2cbf07013: the rule suite tests positive (fires) cases and a +couple of ``undecorated`` (UNKNOWN_RAW) suppressions, but it does NOT pin the +gate BOUNDARY for the tier-gated / tier-modulated rules in the *declared* +freedom zone (``@external_boundary`` -> EXTERNAL_RAW) nor assert the +declaration-gated rules (101/102/105) stay silent below their gate. A silently +loosened gate (a rule that begins to fire too eagerly, or a gate that stops +suppressing) would ship undetected. + +Each test pairs a NEGATIVE assertion (silent below the gate) with a POSITIVE +control (the SAME code shape fires at the trusted tier), so the test genuinely +pins the boundary rather than asserting a rule that never fires. + +Gate model (src/wardline/scanner/rules/severity_model.py): + - _TRUSTED = {INTEGRAL, ASSURED} -> base severity + - _PARTIAL = {GUARDED, UNKNOWN_GUARDED, UNKNOWN_ASSURED} -> downgrade one step + - freedom = {EXTERNAL_RAW, UNKNOWN_RAW, MIXED_RAW} -> modulate -> NONE (silent) + +A ``@external_boundary``-bodied function resolves to EXTERNAL_RAW: a *declared* +freedom-zone tier (distinct from the undecorated UNKNOWN_RAW case the suite +already covers). The body-taint-keyed rules (103/104/120) and the +enclosing-declared-tier sink rules (106/107/108/110/111/112/118) must all +suppress to NONE on it. Rules 101/102/105 are declaration-gated (not modulated); +they have their own below-gate predicates (declared-return in RAW_ZONE for 101; +anchored trust-raising shape for 102; raw-body callee / non-provably-untrusted +arg for 105). +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Severity +from wardline.core.taints import TaintState +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.boundary_without_rejection import BoundaryWithoutRejection +from wardline.scanner.rules.broad_exception import BroadException +from wardline.scanner.rules.path_traversal import PathTraversal +from wardline.scanner.rules.severity_model import modulate +from wardline.scanner.rules.silent_exception import SilentException +from wardline.scanner.rules.sql_injection import SQLInjection +from wardline.scanner.rules.ssrf import SSRF +from wardline.scanner.rules.stored_taint import StoredTaint +from wardline.scanner.rules.untrusted_reaches_trusted import UntrustedReachesTrusted +from wardline.scanner.rules.untrusted_to_command import UntrustedToCommand +from wardline.scanner.rules.untrusted_to_deserialization import UntrustedToDeserialization +from wardline.scanner.rules.untrusted_to_exec import UntrustedToExec +from wardline.scanner.rules.untrusted_to_shell_subprocess import UntrustedToShellSubprocess +from wardline.scanner.rules.untrusted_to_trusted_callee import UntrustedReachesTrustedCallee + +_HEADER = ( + "import os, pickle, subprocess, sqlite3\n" + "import requests\n" + "from wardline.decorators import external_boundary, trust_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context + + +# --------------------------------------------------------------------------- +# severity_model.modulate — the single gate function. Pin its exact boundary. +# --------------------------------------------------------------------------- + +_FREEDOM = (TaintState.EXTERNAL_RAW, TaintState.UNKNOWN_RAW, TaintState.MIXED_RAW) +_PARTIAL = (TaintState.GUARDED, TaintState.UNKNOWN_GUARDED, TaintState.UNKNOWN_ASSURED) +_TRUSTED = (TaintState.INTEGRAL, TaintState.ASSURED) + + +@pytest.mark.parametrize("tier", _FREEDOM) +@pytest.mark.parametrize("base", [Severity.CRITICAL, Severity.ERROR, Severity.WARN, Severity.INFO]) +def test_modulate_suppresses_every_base_in_freedom_zone(base: Severity, tier: TaintState) -> None: + # The load-bearing gate invariant: ANY base severity is suppressed to NONE in + # the freedom zone. If a future edit ever let a freedom-zone tier return the + # base (or a non-NONE downgrade), every tier-modulated rule would loosen at once. + assert modulate(base, tier) is Severity.NONE + + +@pytest.mark.parametrize("tier", _TRUSTED) +def test_modulate_preserves_base_in_trusted_zone(tier: TaintState) -> None: + assert modulate(Severity.ERROR, tier) is Severity.ERROR + + +@pytest.mark.parametrize("tier", _PARTIAL) +def test_modulate_downgrades_one_step_in_partial_zone(tier: TaintState) -> None: + # Partial tiers DOWNGRADE (not suppress) — the boundary between PARTIAL and + # freedom. A regression that folded PARTIAL into freedom would silence the + # partial-tier findings; folding it into TRUSTED would over-report. + assert modulate(Severity.ERROR, tier) is Severity.WARN + assert modulate(Severity.WARN, tier) is Severity.INFO + + +# --------------------------------------------------------------------------- +# Tier-MODULATED sink rules (106/107/108/112/116/117/118): a declared +# freedom-zone function (@external_boundary -> EXTERNAL_RAW) must stay SILENT +# even when raw data reaches the sink. Each test carries a trusted-tier control. +# --------------------------------------------------------------------------- + + +def test_106_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n b = read_raw(p)\n pickle.loads(b)\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert UntrustedToDeserialization().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in UntrustedToDeserialization().check(fires)] == ["PY-WL-106"] + + +def test_107_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n src = read_raw(p)\n eval(src)\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert UntrustedToExec().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in UntrustedToExec().check(fires)] == ["PY-WL-107"] + + +def test_108_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n cmd = read_raw(p)\n os.system(cmd)\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert UntrustedToCommand().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in UntrustedToCommand().check(fires)] == ["PY-WL-108"] + + +def test_112_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n subprocess.run(read_raw(p), shell=True)\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert UntrustedToShellSubprocess().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in UntrustedToShellSubprocess().check(fires)] == ["PY-WL-112"] + + +def test_116_path_traversal_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n open(read_raw(p))\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert PathTraversal().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in PathTraversal().check(fires)] == ["PY-WL-116"] + + +def test_117_ssrf_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n requests.get(read_raw(p))\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert SSRF().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in SSRF().check(fires)] == ["PY-WL-117"] + + +def test_118_sql_injection_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n cursor = sqlite3.connect(':memory:').cursor()\n cursor.execute(read_raw(p))\n return 1\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert SQLInjection().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in SQLInjection().check(fires)] == ["PY-WL-118"] + + +# --------------------------------------------------------------------------- +# Tier-MODULATED exception rules (103/104) keyed on the function's OWN body +# taint. @external_boundary body (EXTERNAL_RAW) must stay silent. +# --------------------------------------------------------------------------- + + +def test_103_broad_except_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n try:\n g()\n except Exception:\n h()\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert BroadException().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in BroadException().check(fires)] == ["PY-WL-103"] + + +def test_104_silent_except_external_boundary_tier_is_silent_but_trusted_fires(tmp_path) -> None: + body = "\n try:\n g()\n except ValueError:\n pass\n" + silent = _analyze(tmp_path, f"@external_boundary\ndef f(p):{body}") + assert SilentException().check(silent) == [] + fires = _analyze(tmp_path, f"@trusted(level='ASSURED')\ndef f(p):{body}") + assert [x.rule_id for x in SilentException().check(fires)] == ["PY-WL-104"] + + +# --------------------------------------------------------------------------- +# Stored-taint (120) is tier-modulated on the function body too. +# --------------------------------------------------------------------------- + + +def test_120_stored_taint_external_boundary_tier_is_silent(tmp_path) -> None: + # Stored-taint is modulate-gated on the enclosing function body taint + # (project_taints[qualname] -> modulate -> NONE on EXTERNAL_RAW). Whatever it + # would flag inside a trusted producer, it must stay silent in the freedom zone. + silent = _analyze( + tmp_path, + """ + class Repo: + store = None + @external_boundary + def f(p): + Repo.store = read_raw(p) + return 1 + """, + ) + assert StoredTaint().check(silent) == [] + + +# --------------------------------------------------------------------------- +# Declaration-gated rules — NOT modulated, but have their own below-gate +# predicates that must keep them silent. +# --------------------------------------------------------------------------- + + +def test_102_below_anchor_and_non_raising_gate_is_silent_but_trust_boundary_fires(tmp_path) -> None: + # PY-WL-102 is declaration-gated (NOT modulated): it fires only on an ANCHORED, + # trust-RAISING transition (body strictly less trusted than the declared return — + # the @trust_boundary shape) that lacks a rejection path. Two below-gate cases must + # stay silent: (a) an undecorated boundary-shaped function (not anchored), and + # (b) a @trusted producer (anchored but body == declared, so not trust-raising). + # The positive control is a @trust_boundary with no raise -> fires at base ERROR. + # (Laundered shape: the bare `return p` single-statement body is PY-WL-119's in the + # four-way partition, so 102's control routes through a local.) + undecorated = _analyze(tmp_path, "def v(p):\n return p\n") + assert BoundaryWithoutRejection().check(undecorated) == [] + trusted_not_raising = _analyze(tmp_path, "@trusted(level='ASSURED')\ndef v(p):\n return p\n") + assert BoundaryWithoutRejection().check(trusted_not_raising) == [] + fires = _analyze(tmp_path, "@trust_boundary(to_level='ASSURED')\ndef v(p):\n cleaned = p\n return cleaned\n") + findings = BoundaryWithoutRejection().check(fires) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-102", "m.v")] + assert findings[0].severity is Severity.ERROR + # And a @trust_boundary that DOES reject (has a raise) is above the gate -> silent. + rejects = _analyze( + tmp_path, + "@trust_boundary(to_level='ASSURED')\ndef v(p):\n if not p:\n raise ValueError\n return p\n", + ) + assert BoundaryWithoutRejection().check(rejects) == [] + + +def test_101_declared_raw_return_is_silent_but_trusted_return_fires(tmp_path) -> None: + # PY-WL-101 is gated by a trust CLAIM: the declared return must NOT be in the + # raw/freedom zone. An @external_boundary producer DECLARES EXTERNAL_RAW (in + # RAW_ZONE), so returning raw is its job -> must stay silent. The same body in + # a @trusted(ASSURED) producer (declared ASSURED, NOT raw) fires. + silent = _analyze(tmp_path, "@external_boundary\ndef f(p):\n return read_raw(p)\n") + assert UntrustedReachesTrusted().check(silent) == [] + fires = _analyze(tmp_path, "@trusted(level='ASSURED')\ndef f(p):\n return read_raw(p)\n") + findings = UntrustedReachesTrusted().check(fires) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-101", "m.f")] + # 101 is declaration-gated, so it emits at BASE severity (ERROR), never modulated. + assert findings[0].severity is Severity.ERROR + + +def test_105_raw_body_callee_is_silent_but_trusted_callee_fires(tmp_path) -> None: + # PY-WL-105's below-gate predicate: the callee must be a trust-declared producer + # whose BODY is NOT in the raw zone. An @external_boundary callee (raw body) + # expects raw input -> silent. A @trusted(ASSURED) callee fires. + silent = _analyze(tmp_path, "def h(p):\n read_raw(read_raw(p))\n") + assert UntrustedReachesTrustedCallee().check(silent) == [] + fires = _analyze( + tmp_path, + "@trusted(level='ASSURED')\ndef store(x):\n return 1\ndef h(p):\n store(read_raw(p))\n", + ) + assert [(x.rule_id, x.qualname) for x in UntrustedReachesTrustedCallee().check(fires)] == [("PY-WL-105", "m.h")] + + +def test_105_unknown_raw_arg_below_provable_gate_is_silent_but_external_raw_fires(tmp_path) -> None: + # PY-WL-105's ARG gate fires only on PROVABLY-untrusted args (EXTERNAL_RAW / + # MIXED_RAW), NOT the merely-unprovable UNKNOWN_RAW freedom-zone arg. An + # undecorated param (UNKNOWN_RAW) must stay silent; a value from an + # @external_boundary source (EXTERNAL_RAW) crosses the gate and fires. + silent = _analyze( + tmp_path, + "@trusted(level='ASSURED')\ndef store(x):\n return 1\ndef h(p):\n store(p)\n", + ) + assert UntrustedReachesTrustedCallee().check(silent) == [] + fires = _analyze( + tmp_path, + "@trusted(level='ASSURED')\ndef store(x):\n return 1\ndef h(p):\n store(read_raw(p))\n", + ) + assert [x.rule_id for x in UntrustedReachesTrustedCallee().check(fires)] == ["PY-WL-105"] diff --git a/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py b/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py index dd0dcba5..b2e0fd85 100644 --- a/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py +++ b/tests/unit/scanner/rules/test_untrusted_reaches_trusted.py @@ -44,6 +44,44 @@ def test_trusted_returning_raw_fires(tmp_path) -> None: assert all(f.kind == Kind.DEFECT for f in findings) +def test_trusted_returning_project_submodule_imported_raw_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "pkg/sources.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read(p):\n return p\n", + "svc.py": "import pkg.sources\n" + "from wardline.decorators import trusted\n" + "@trusted(level='ASSURED')\n" + "def leaky(p):\n return pkg.sources.read(p)\n", + }, + ) + assert ctx.function_return_taints["svc.leaky"] == TaintState.EXTERNAL_RAW + assert ("PY-WL-101", "svc.leaky") in {(f.rule_id, f.qualname) for f in _run(ctx)} + + +def test_trusted_early_returning_raw_before_later_clean_reassignment_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\n" + "from io import read_raw\n" + "@trusted(level='ASSURED', to_level='ASSURED')\n" + "def leaky(p, cond):\n" + " x = read_raw(p)\n" + " if cond:\n" + " return x\n" + " x = 1\n" + " return x\n", + }, + ) + assert ctx.function_return_taints["svc.leaky"] == TaintState.EXTERNAL_RAW + findings = _run(ctx) + assert ("PY-WL-101", "svc.leaky") in {(f.rule_id, f.qualname) for f in findings} + + def test_duplicate_trusted_function_uses_second_definition_for_py_wl_101(tmp_path) -> None: ctx, _ = _analyze( tmp_path, @@ -362,3 +400,42 @@ def test_correct_trust_boundary_does_not_fire_101(tmp_path) -> None: # Exemption holds: no PY-WL-101, and 102 is satisfied by the raise guard. assert _run(ctx) == [] assert BoundaryWithoutRejection().check(ctx) == [] + + +def test_py_wl_101_fingerprint_invariant_to_resolved_return_tier(tmp_path) -> None: + # weft-4a9d0f863c regression: the fingerprint is the cross-tool JOIN KEY, and it + # MUST NOT move when only the resolved actual-return TIER changes for the same + # source. The reported bug moved it across three builds (UNKNOWN_RAW<->EXTERNAL_RAW) + # for byte-identical source because the tier was folded into taint_path. Reproduce + # by swapping the resolved tier under an unchanged source position and asserting the + # join key holds while the diagnostic property genuinely differs. + import dataclasses + + ctx, _ = _analyze( + tmp_path, + { + "io.py": "from wardline.decorators import external_boundary\n" + "@external_boundary\ndef raw(p):\n return p\n", + "svc.py": "from wardline.decorators import trusted\nfrom io import raw\n" + "@trusted(level='ASSURED')\ndef f(p):\n return raw(p)\n", + }, + ) + base = [f for f in _run(ctx) if f.rule_id == "PY-WL-101"] + assert len(base) == 1 + f0 = base[0] + assert f0.properties["actual_return"] == TaintState.EXTERNAL_RAW.value + + # Swap ONLY the resolved actual-return tier to a different raw tier; UNKNOWN_RAW is + # still in the raw zone and ranks above the ASSURED declaration, so the rule still + # fires — the finding is "the same" defect, just classified one tier differently. + swapped_ctx = dataclasses.replace( + ctx, + function_return_taints={**ctx.function_return_taints, "svc.f": TaintState.UNKNOWN_RAW}, + ) + swapped = [f for f in UntrustedReachesTrusted().check(swapped_ctx) if f.rule_id == "PY-WL-101"] + assert len(swapped) == 1 + f1 = swapped[0] + + assert f1.properties["actual_return"] == TaintState.UNKNOWN_RAW.value # the tier really changed + assert f1.properties["actual_return"] != f0.properties["actual_return"] + assert f1.fingerprint == f0.fingerprint # ...but the JOIN KEY did not move diff --git a/tests/unit/scanner/rules/test_untrusted_to_import.py b/tests/unit/scanner/rules/test_untrusted_to_import.py index 01ff164d..0b725932 100644 --- a/tests/unit/scanner/rules/test_untrusted_to_import.py +++ b/tests/unit/scanner/rules/test_untrusted_to_import.py @@ -1,5 +1,5 @@ # tests/unit/scanner/rules/test_untrusted_to_import.py -"""Tests for PY-WL-115: untrusted module loaded via dynamic import sinks.""" +"""Tests for PY-WL-115: untrusted module loaded via dynamic code/module-load sinks.""" from __future__ import annotations @@ -18,6 +18,7 @@ "def read_raw(p):\n" " return p\n" ) +_HEADER_LINES = _HEADER.count("\n") def _analyze(tmp_path: Path, src: str) -> tuple[WardlineAnalyzer, object]: @@ -57,7 +58,45 @@ def g(p): """, ) findings = UntrustedToImport().check(ctx) - assert [f.rule_id for f in findings] == ["PY-WL-115"] + assert [(f.rule_id, f.qualname, f.kind, f.severity) for f in findings] == [ + ("PY-WL-115", "m.g", Kind.DEFECT, Severity.WARN) + ] + # snippet: blank line, @trusted, def g, sink — the sink sits 4 lines into the snippet + assert findings[0].location.line_start == _HEADER_LINES + 4 + + +def test_115_raw_reaches_aliased_importlib(tmp_path) -> None: + # `import importlib as il` exercises the shared alias-resolution path + # (canonical_call_name), which the direct-spelling tests above do not. + _, ctx = _analyze( + tmp_path, + """ + import importlib as il + + @trusted(level='ASSURED') + def f(p): + il.import_module(read_raw(p)) + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-115", "m.f")] + assert findings[0].severity == Severity.WARN + + +def test_115_undecorated_function_suppressed(tmp_path) -> None: + # No trust declaration → developer-freedom zone → the rule stays silent + # even on a provably raw flow. + _, ctx = _analyze( + tmp_path, + """ + def g(p): + importlib.import_module(read_raw(p)) + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert findings == [] def test_115_clean_import(tmp_path) -> None: @@ -72,3 +111,88 @@ def h(p): ) findings = UntrustedToImport().check(ctx) assert len(findings) == 0 + + +def test_115_dunder_import_clean(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def h(p): + __import__('os') + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert findings == [] + + +def test_115_raw_reaches_runpy_run_path(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + import runpy + + @trusted(level='ASSURED') + def f(p): + path = read_raw(p) + runpy.run_path(path) + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert [(f.rule_id, f.qualname, f.kind, f.severity) for f in findings] == [ + ("PY-WL-115", "m.f", Kind.DEFECT, Severity.WARN) + ] + assert findings[0].properties["sink"] == "runpy.run_path" + + +def test_115_raw_reaches_runpy_run_module(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + import runpy + + @trusted(level='ASSURED') + def f(p): + runpy.run_module(read_raw(p)) + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-115", "m.f")] + assert findings[0].properties["sink"] == "runpy.run_module" + + +def test_115_raw_reaches_spec_from_file_location(tmp_path) -> None: + # The tainted FILE PATH arg is what makes the loader dangerous. + _, ctx = _analyze( + tmp_path, + """ + import importlib.util + + @trusted(level='ASSURED') + def f(p): + importlib.util.spec_from_file_location('m', read_raw(p)) + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-115", "m.f")] + assert findings[0].properties["sink"] == "importlib.util.spec_from_file_location" + + +def test_115_runpy_clean_constant_path(tmp_path) -> None: + _, ctx = _analyze( + tmp_path, + """ + import runpy + + @trusted(level='ASSURED') + def f(p): + runpy.run_path('scripts/fixed.py') + return 1 + """, + ) + findings = UntrustedToImport().check(ctx) + assert findings == [] diff --git a/tests/unit/scanner/rules/test_untrusted_to_log.py b/tests/unit/scanner/rules/test_untrusted_to_log.py new file mode 100644 index 00000000..16c64387 --- /dev/null +++ b/tests/unit/scanner/rules/test_untrusted_to_log.py @@ -0,0 +1,130 @@ +"""PY-WL-125 — untrusted data as a log MESSAGE (log injection, CWE-117).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_log import UntrustedToLog + +_HEADER = ( + "import logging\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context, list(findings) + + +def test_125_module_level_logging_tainted_message_fires_info(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + logging.info(read_raw(p)) + return 1 + """, + ) + findings = UntrustedToLog().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-125", "m.f")] + assert findings[0].kind == Kind.DEFECT + # Calibrated INFO: log forging is real but noisy by nature — advisory, never gate-tripping. + assert findings[0].severity == Severity.INFO + + +def test_125_all_module_level_methods_fire(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + logging.debug(read_raw(p)) + logging.warning(read_raw(p)) + logging.error(read_raw(p)) + logging.critical(read_raw(p)) + logging.exception(read_raw(p)) + return 1 + """, + ) + assert len(UntrustedToLog().check(ctx)) == 5 + + +def test_125_logger_instance_method_fires(tmp_path) -> None: + # construct-then-method: the getLogger(...) binding resolves logger.warning. + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + logger = logging.getLogger('app') + logger.warning(read_raw(p)) + return 1 + """, + ) + assert [x.properties["sink"] for x in UntrustedToLog().check(ctx)] == ["logging.getLogger.warning"] + + +def test_125_parameterized_logging_is_the_clean_idiom(tmp_path) -> None: + # Tainted data in the lazy %-args position is logging's SAFE parameterization — + # firing on it would be an FP factory. Only the message FORMAT string counts. + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + logging.info('user input = %s', read_raw(p)) + return 1 + """, + ) + assert UntrustedToLog().check(ctx) == [] + + +def test_125_literal_message_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + logging.error('fixed message') + return 1 + """, + ) + assert UntrustedToLog().check(ctx) == [] + + +def test_125_undecorated_is_suppressed(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + def f(p): + logging.info(read_raw(p)) + return 1 + """, + ) + assert UntrustedToLog().check(ctx) == [] + + +def test_125_registered_end_to_end(tmp_path) -> None: + _, findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + logging.info(read_raw(p)) + return 1 + """, + ) + assert "PY-WL-125" in {f.rule_id for f in findings} diff --git a/tests/unit/scanner/rules/test_untrusted_to_mail.py b/tests/unit/scanner/rules/test_untrusted_to_mail.py new file mode 100644 index 00000000..c68f3d97 --- /dev/null +++ b/tests/unit/scanner/rules/test_untrusted_to_mail.py @@ -0,0 +1,145 @@ +"""PY-WL-126 — untrusted recipient/message reaches SMTP.sendmail (mail injection, CWE-93).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_mail import UntrustedToMail + +_HEADER = ( + "import smtplib\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context, list(findings) + + +def test_126_tainted_message_construct_then_method_fires_warn(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + s = smtplib.SMTP('localhost') + s.sendmail('from@example.com', 'to@example.com', read_raw(p)) + return 1 + """, + ) + findings = UntrustedToMail().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-126", "m.f")] + assert findings[0].kind == Kind.DEFECT + assert findings[0].severity == Severity.WARN + assert findings[0].properties["sink"] == "smtplib.SMTP.sendmail" + + +def test_126_tainted_recipient_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + s = smtplib.SMTP('localhost') + s.sendmail('from@example.com', read_raw(p), 'body') + return 1 + """, + ) + assert [x.rule_id for x in UntrustedToMail().check(ctx)] == ["PY-WL-126"] + + +def test_126_with_statement_binding_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + with smtplib.SMTP('localhost') as s: + s.sendmail('from@example.com', 'to@example.com', read_raw(p)) + return 1 + """, + ) + assert [x.rule_id for x in UntrustedToMail().check(ctx)] == ["PY-WL-126"] + + +def test_126_smtp_ssl_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + s = smtplib.SMTP_SSL('localhost') + s.sendmail('from@example.com', 'to@example.com', read_raw(p)) + return 1 + """, + ) + assert [x.properties["sink"] for x in UntrustedToMail().check(ctx)] == ["smtplib.SMTP_SSL.sendmail"] + + +def test_126_literal_recipient_and_message_are_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + s = smtplib.SMTP('localhost') + s.sendmail('from@example.com', 'to@example.com', 'body') + return 1 + """, + ) + assert UntrustedToMail().check(ctx) == [] + + +def test_126_tainted_from_addr_only_is_clean(tmp_path) -> None: + # Charter: recipient (to_addrs) + message are the CWE-93 injection slots; + # from_addr alone is out of scope (documented calibration). + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + s = smtplib.SMTP('localhost') + s.sendmail(read_raw(p), 'to@example.com', 'body') + return 1 + """, + ) + assert UntrustedToMail().check(ctx) == [] + + +def test_126_undecorated_is_suppressed(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + def f(p): + s = smtplib.SMTP('localhost') + s.sendmail('from@example.com', 'to@example.com', read_raw(p)) + return 1 + """, + ) + assert UntrustedToMail().check(ctx) == [] + + +def test_126_registered_end_to_end(tmp_path) -> None: + _, findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + s = smtplib.SMTP('localhost') + s.sendmail('from@example.com', 'to@example.com', read_raw(p)) + return 1 + """, + ) + assert "PY-WL-126" in {f.rule_id for f in findings} diff --git a/tests/unit/scanner/rules/test_untrusted_to_native.py b/tests/unit/scanner/rules/test_untrusted_to_native.py new file mode 100644 index 00000000..0fbc32a5 --- /dev/null +++ b/tests/unit/scanner/rules/test_untrusted_to_native.py @@ -0,0 +1,100 @@ +"""PY-WL-124 — untrusted path reaches a native-library load sink (ctypes, CWE-114).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_native import UntrustedToNative + +_HEADER = ( + "import ctypes\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context, list(findings) + + +def test_124_ctypes_cdll_fires_error(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return ctypes.CDLL(read_raw(p)) + """, + ) + findings = UntrustedToNative().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-124", "m.f")] + assert findings[0].kind == Kind.DEFECT + assert findings[0].severity == Severity.ERROR # arbitrary native-code execution + + +def test_124_dll_variants_and_loadlibrary_fire(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + ctypes.WinDLL(read_raw(p)) + ctypes.OleDLL(read_raw(p)) + ctypes.PyDLL(read_raw(p)) + ctypes.cdll.LoadLibrary(read_raw(p)) + return 1 + """, + ) + sinks = sorted(x.properties["sink"] for x in UntrustedToNative().check(ctx)) + assert sinks == [ + "ctypes.OleDLL", + "ctypes.PyDLL", + "ctypes.WinDLL", + "ctypes.cdll.LoadLibrary", + ] + + +def test_124_literal_library_path_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(): + return ctypes.CDLL('libm.so.6') + """, + ) + assert UntrustedToNative().check(ctx) == [] + + +def test_124_undecorated_is_suppressed(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + def f(p): + return ctypes.CDLL(read_raw(p)) + """, + ) + assert UntrustedToNative().check(ctx) == [] + + +def test_124_registered_end_to_end(tmp_path) -> None: + _, findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return ctypes.cdll.LoadLibrary(read_raw(p)) + """, + ) + assert "PY-WL-124" in {f.rule_id for f in findings} diff --git a/tests/unit/scanner/rules/test_untrusted_to_reflection.py b/tests/unit/scanner/rules/test_untrusted_to_reflection.py new file mode 100644 index 00000000..401fa45e --- /dev/null +++ b/tests/unit/scanner/rules/test_untrusted_to_reflection.py @@ -0,0 +1,117 @@ +"""PY-WL-123 — tainted attribute NAME reaches setattr/getattr (CWE-915 mass assignment).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_reflection import UntrustedToReflection + +_HEADER = ( + "from wardline.decorators import external_boundary, trusted\n@external_boundary\ndef read_raw(p):\n return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context, list(findings) + + +def test_123_setattr_tainted_name_fires_warn(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, obj): + setattr(obj, read_raw(p), 1) + return 1 + """, + ) + findings = UntrustedToReflection().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-123", "m.f")] + assert findings[0].kind == Kind.DEFECT + assert findings[0].severity == Severity.WARN + + +def test_123_getattr_tainted_name_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, obj): + return getattr(obj, read_raw(p)) + """, + ) + assert [x.properties["sink"] for x in UntrustedToReflection().check(ctx)] == ["getattr"] + + +def test_123_tainted_value_with_literal_name_is_clean(tmp_path) -> None: + # Only the attribute NAME slot (position 1) is the mass-assignment vector; + # an untrusted VALUE assigned to a fixed attribute is ordinary data flow. + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, obj): + setattr(obj, 'name', read_raw(p)) + return 1 + """, + ) + assert UntrustedToReflection().check(ctx) == [] + + +def test_123_tainted_getattr_default_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, obj): + return getattr(obj, 'name', read_raw(p)) + """, + ) + assert UntrustedToReflection().check(ctx) == [] + + +def test_123_tainted_receiver_is_clean(tmp_path) -> None: + # A tainted RECEIVER with a literal name is not attribute injection. + ctx, _ = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + return getattr(read_raw(p), 'name') + """, + ) + assert UntrustedToReflection().check(ctx) == [] + + +def test_123_undecorated_is_suppressed(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + def f(p, obj): + setattr(obj, read_raw(p), 1) + return 1 + """, + ) + assert UntrustedToReflection().check(ctx) == [] + + +def test_123_registered_end_to_end(tmp_path) -> None: + _, findings = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, obj): + setattr(obj, read_raw(p), 1) + return 1 + """, + ) + assert "PY-WL-123" in {f.rule_id for f in findings} diff --git a/tests/unit/scanner/rules/test_untrusted_to_template.py b/tests/unit/scanner/rules/test_untrusted_to_template.py new file mode 100644 index 00000000..ae3239fe --- /dev/null +++ b/tests/unit/scanner/rules/test_untrusted_to_template.py @@ -0,0 +1,135 @@ +"""PY-WL-122 — untrusted data compiled into a server-side template (SSTI, CWE-1336).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_template import UntrustedToTemplate + +_HEADER = ( + "from wardline.decorators import external_boundary, trusted\n@external_boundary\ndef read_raw(p):\n return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context, list(findings) + + +def test_122_jinja2_template_fires_error(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import jinja2 + @trusted(level='ASSURED') + def f(p): + return jinja2.Template(read_raw(p)).render() + """, + ) + findings = UntrustedToTemplate().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-122", "m.f")] + assert findings[0].kind == Kind.DEFECT + assert findings[0].severity == Severity.ERROR # SSTI is RCE-adjacent + + +def test_122_environment_from_string_construct_then_method(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import jinja2 + @trusted(level='ASSURED') + def f(p): + env = jinja2.Environment() + return env.from_string(read_raw(p)) + """, + ) + findings = UntrustedToTemplate().check(ctx) + assert [x.properties["sink"] for x in findings] == ["jinja2.Environment.from_string"] + + +def test_122_chained_environment_from_string(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import jinja2 + @trusted(level='ASSURED') + def f(p): + return jinja2.Environment().from_string(read_raw(p)) + """, + ) + assert [x.rule_id for x in UntrustedToTemplate().check(ctx)] == ["PY-WL-122"] + + +def test_122_mako_template_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import mako.template + @trusted(level='ASSURED') + def f(p): + return mako.template.Template(read_raw(p)) + """, + ) + assert [x.properties["sink"] for x in UntrustedToTemplate().check(ctx)] == ["mako.template.Template"] + + +def test_122_literal_template_with_tainted_render_context_is_clean(tmp_path) -> None: + # Tainted data as a RENDER variable is the safe idiom — only the template + # SOURCE being tainted is SSTI. + ctx, _ = _analyze( + tmp_path, + """ + import jinja2 + @trusted(level='ASSURED') + def f(p): + return jinja2.Template('Hello {{ name }}').render(name=read_raw(p)) + """, + ) + assert UntrustedToTemplate().check(ctx) == [] + + +def test_122_get_template_by_name_is_not_a_sink(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import jinja2 + @trusted(level='ASSURED') + def f(p): + env = jinja2.Environment() + return env.get_template(read_raw(p)) + """, + ) + assert UntrustedToTemplate().check(ctx) == [] + + +def test_122_undecorated_is_suppressed(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import jinja2 + def f(p): + return jinja2.Template(read_raw(p)) + """, + ) + assert UntrustedToTemplate().check(ctx) == [] + + +def test_122_registered_end_to_end(tmp_path) -> None: + _, findings = _analyze( + tmp_path, + """ + import jinja2 + @trusted(level='ASSURED') + def f(p): + return jinja2.Template(read_raw(p)) + """, + ) + assert "PY-WL-122" in {f.rule_id for f in findings} diff --git a/tests/unit/scanner/rules/test_untrusted_to_trusted_callee.py b/tests/unit/scanner/rules/test_untrusted_to_trusted_callee.py index d4cff3bb..e3944157 100644 --- a/tests/unit/scanner/rules/test_untrusted_to_trusted_callee.py +++ b/tests/unit/scanner/rules/test_untrusted_to_trusted_callee.py @@ -137,6 +137,23 @@ def h(p): assert _ids(ctx) == [("PY-WL-105", "m.h")] +def test_provably_untrusted_arg_not_masked_by_unknown_co_arg(tmp_path) -> None: + # A provably-untrusted arg (EXTERNAL_RAW, rank 5) must fire even when an + # UNKNOWN_RAW co-arg (rank 6) bumps worst_arg_taint into the predicate's hole. + # _PROVABLY_UNTRUSTED = {EXTERNAL_RAW=5, MIXED_RAW=7} is NOT upward-closed. + ctx = _analyze( + tmp_path, + """ + @trusted(level='ASSURED') + def store2(meta, payload): + return 1 + def h(meta, p): + store2(meta, read_raw(p)) + """, + ) + assert _ids(ctx) == [("PY-WL-105", "m.h")] + + def test_multiple_kwargs_unpacking_combines_raw_before_clean(tmp_path) -> None: ctx = _analyze( tmp_path, @@ -148,3 +165,246 @@ def h(p): """, ) assert _ids(ctx) == [("PY-WL-105", "m.h")] + + +def _branch_dispatch(first: str, second: str, arg: str = "raw") -> str: + classes = ( + "class Plain:\n def take(self, x):\n return 1\n" + "class TrustedSink:\n @trusted(level='ASSURED')\n def take(self, x):\n return 1\n" + ) + return classes + textwrap.dedent( + f""" + def dispatch(flag, p): + raw = read_raw(p) + if flag: + o = {first} + else: + o = {second} + o.take({arg}) + """ + ) + + +def test_branch_conditional_trusted_receiver_fires_regardless_of_ast_order(tmp_path) -> None: + # wardline-499c22bbdd: o is assigned a trusted-sink class in one branch and a plain + # class in the other; PY-WL-105 must fire on the trusted-sink candidate regardless of + # which branch is textually LAST (root cause: local_var_types last-write-wins dropped + # the non-last candidate). + ctx1 = _analyze(tmp_path, _branch_dispatch("TrustedSink()", "Plain()")) # trusted FIRST (regressed FN) + assert _ids(ctx1) == [("PY-WL-105", "m.dispatch")] + ctx2 = _analyze(tmp_path, _branch_dispatch("Plain()", "TrustedSink()")) # trusted LAST (already fired) + assert _ids(ctx2) == [("PY-WL-105", "m.dispatch")] + + +def test_branch_conditional_neither_trusted_stays_silent(tmp_path) -> None: + # CONTROL: two candidate receivers, neither a trusted sink -> must stay silent. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class Plain2: + def take(self, x): + return 1 + def dispatch(flag, p): + raw = read_raw(p) + if flag: + o = Plain() + else: + o = Plain2() + o.take(raw) + """, + ) + assert _ids(ctx) == [] + + +def test_branch_conditional_trusted_receiver_non_raw_arg_stays_silent(tmp_path) -> None: + # CONTROL (arg gate): a real trusted candidate, but the arg is a non-raw literal -> + # the candidate-set widening must still respect the _PROVABLY_UNTRUSTED arg gate. + ctx = _analyze(tmp_path, _branch_dispatch("TrustedSink()", "Plain()", arg="'literal'")) + assert _ids(ctx) == [] + + +def test_branch_conditional_linear_reassignment_stays_silent(tmp_path) -> None: + # CONTROL (panel FP, wardline-499c22bbdd): a straight-line reassignment o=TS(); o=Plain() + # is NOT a branch fork — o is provably Plain at the call. Flow-sensitive resolution must + # NOT widen to the killed TrustedSink binding, so PY-WL-105 stays silent. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def f(p): + raw = read_raw(p) + o = TrustedSink() + o = Plain() + o.take(raw) + """, + ) + assert _ids(ctx) == [] + + +def test_branch_conditional_in_arm_call_uses_arm_local_type(tmp_path) -> None: + # CONTROL (panel FP): a call INSIDE the arm where o is assigned Plain sees only Plain — + # the other arm's TrustedSink does not reach this call site. Must stay silent. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def f(flag, p): + raw = read_raw(p) + if flag: + o = Plain() + o.take(raw) + else: + o = TrustedSink() + o.take(0) + """, + ) + assert _ids(ctx) == [] + + +def test_branch_conditional_two_trusted_candidates_emits_one_finding(tmp_path) -> None: + # panel (double-emit): when BOTH arms are trusted sinks, one taint flow at one call + # site is ONE defect — emit a single finding (deterministic representative), not one + # per candidate. + ctx = _analyze( + tmp_path, + """ + class A: + @trusted(level='ASSURED') + def take(self, x): + return 1 + class B: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def f(flag, p): + raw = read_raw(p) + if flag: + o = A() + else: + o = B() + o.take(raw) + """, + ) + findings = UntrustedReachesTrustedCallee().check(ctx) + assert [(f.rule_id, f.qualname) for f in findings] == [("PY-WL-105", "m.f")] + assert "also reaches" in findings[0].message + + +def test_branch_conditional_loop_carried_receiver_rebind_fires(tmp_path) -> None: + # panel-2 (wardline-499c22bbdd): a receiver rebound at the END of a loop body is the + # runtime dispatch target for the call at the TOP of the body from iteration 2 on. The + # candidate pass's loop fixpoint must surface the rebound TrustedSink so the call fires. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def f(items, p): + raw = read_raw(p) + o = Plain() + for it in items: + o.take(raw) + o = TrustedSink() + """, + ) + assert _ids(ctx) == [("PY-WL-105", "m.f")] + + +def test_branch_conditional_walrus_rebind_kills_stale_candidate(tmp_path) -> None: + # panel-2 (wardline-499c22bbdd): a walrus rebind (o := Plain()) must REPLACE the prior + # TrustedSink binding — o is provably Plain at the call, so PY-WL-105 must stay silent. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def f(p): + raw = read_raw(p) + o = TrustedSink() + if (o := Plain()): + pass + o.take(raw) + """, + ) + assert _ids(ctx) == [] + + +def test_branch_conditional_loop_carried_deep_chain_fires(tmp_path) -> None: + # panel-3 (wardline-499c22bbdd): the candidate-pass loop fixpoint backstop must be keyed + # to COPY-CHAIN DEPTH (names assigned in the body), not the class count. A depth-3 + # rebind chain (d<-c<-b<-a=TrustedSink) carried across loop iterations must still surface + # TrustedSink at the top-of-body d.take(raw) call. A class-count-keyed bound truncated + # here (chain_depth >= class_count + 2) and silently dropped the sink — a fail-open FN. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def f(items, p): + raw = read_raw(p) + a = TrustedSink() + d = Plain() + for x in items: + d.take(raw) + d = c + c = b + b = a + """, + ) + assert _ids(ctx) == [("PY-WL-105", "m.f")] + + +def test_branch_conditional_walrus_same_expression_eval_order(tmp_path) -> None: + # panel-3 (wardline-499c22bbdd): a walrus rebind and a dispatch in the SAME expression — + # `sink((o := Plain()), o.take(raw))`. Python evaluates left-to-right, so o is Plain by + # the time o.take runs; the candidate pass must process the walrus bind before recording + # the dispatch (traversal order matches eval order) — must stay silent. + ctx = _analyze( + tmp_path, + """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + def sink(*a): + return 1 + def f(p): + raw = read_raw(p) + o = TrustedSink() + sink((o := Plain()), o.take(raw)) + """, + ) + assert _ids(ctx) == [] diff --git a/tests/unit/scanner/rules/test_untrusted_to_xml.py b/tests/unit/scanner/rules/test_untrusted_to_xml.py new file mode 100644 index 00000000..bea5ee34 --- /dev/null +++ b/tests/unit/scanner/rules/test_untrusted_to_xml.py @@ -0,0 +1,174 @@ +"""PY-WL-121 — untrusted data reaches an XML parsing sink (XXE / billion-laughs, CWE-611).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Severity +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules.untrusted_to_xml import UntrustedToXML + +_HEADER = ( + "from wardline.decorators import external_boundary, trusted\n@external_boundary\ndef read_raw(p):\n return p\n" +) + + +def _analyze(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return analyzer.last_context, list(findings) + + +def test_121_stdlib_etree_fromstring_fires_warn(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import xml.etree.ElementTree as ET + @trusted(level='ASSURED') + def f(p): + return ET.fromstring(read_raw(p)) + """, + ) + findings = UntrustedToXML().check(ctx) + assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-121", "m.f")] + assert findings[0].kind == Kind.DEFECT + # stdlib etree is entity-safe since 3.7.1 — billion-laughs DoS only -> WARN + assert findings[0].severity == Severity.WARN + + +def test_121_stdlib_etree_parse_and_iterparse_fire(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import xml.etree.ElementTree as ET + @trusted(level='ASSURED') + def f(p): + ET.parse(read_raw(p)) + ET.iterparse(read_raw(p)) + return 1 + """, + ) + sinks = sorted(x.properties["sink"] for x in UntrustedToXML().check(ctx)) + assert sinks == ["xml.etree.ElementTree.iterparse", "xml.etree.ElementTree.parse"] + + +def test_121_minidom_and_sax_fire_warn(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import xml.dom.minidom + import xml.sax + @trusted(level='ASSURED') + def f(p, h): + xml.dom.minidom.parseString(read_raw(p)) + xml.sax.parseString(read_raw(p), h) + return 1 + """, + ) + findings = UntrustedToXML().check(ctx) + assert sorted(x.properties["sink"] for x in findings) == [ + "xml.dom.minidom.parseString", + "xml.sax.parseString", + ] + # All pyexpat-based stdlib parsers share the same default-on risk class (DoS). + assert {x.severity for x in findings} == {Severity.WARN} + + +def test_121_lxml_fires_error(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + from lxml import etree + @trusted(level='ASSURED') + def f(p): + return etree.fromstring(read_raw(p)) + """, + ) + findings = UntrustedToXML().check(ctx) + assert [x.properties["sink"] for x in findings] == ["lxml.etree.fromstring"] + # lxml resolves external entities by default — genuine XXE -> ERROR + assert findings[0].severity == Severity.ERROR + + +def test_121_keyword_spelling_fires(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import xml.etree.ElementTree as ET + @trusted(level='ASSURED') + def f(p): + return ET.fromstring(text=read_raw(p)) + """, + ) + assert [x.rule_id for x in UntrustedToXML().check(ctx)] == ["PY-WL-121"] + + +def test_121_literal_xml_is_clean(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import xml.etree.ElementTree as ET + @trusted(level='ASSURED') + def f(): + return ET.fromstring('') + """, + ) + assert UntrustedToXML().check(ctx) == [] + + +def test_121_taint_in_non_dangerous_slot_is_clean(tmp_path) -> None: + # The parser keyword is not the XML document slot — taint there is not XXE. + ctx, _ = _analyze( + tmp_path, + """ + import xml.etree.ElementTree as ET + @trusted(level='ASSURED') + def f(p): + return ET.fromstring('', parser=read_raw(p)) + """, + ) + assert UntrustedToXML().check(ctx) == [] + + +def test_121_defusedxml_is_not_a_sink(tmp_path) -> None: + ctx, _ = _analyze( + tmp_path, + """ + import defusedxml.ElementTree + @trusted(level='ASSURED') + def f(p): + return defusedxml.ElementTree.fromstring(read_raw(p)) + """, + ) + assert UntrustedToXML().check(ctx) == [] + + +def test_121_undecorated_is_suppressed(tmp_path) -> None: + # Freedom zone -> modulate -> NONE -> no finding (opt-in preserved). + ctx, _ = _analyze( + tmp_path, + """ + import xml.etree.ElementTree as ET + def f(p): + return ET.fromstring(read_raw(p)) + """, + ) + assert UntrustedToXML().check(ctx) == [] + + +def test_121_registered_end_to_end(tmp_path) -> None: + _, findings = _analyze( + tmp_path, + """ + from lxml import etree + @trusted(level='ASSURED') + def f(p): + return etree.parse(read_raw(p)) + """, + ) + assert "PY-WL-121" in {f.rule_id for f in findings} diff --git a/tests/unit/scanner/rules/test_vocabulary_shape_pin.py b/tests/unit/scanner/rules/test_vocabulary_shape_pin.py index c7eee0bc..cf71b329 100644 --- a/tests/unit/scanner/rules/test_vocabulary_shape_pin.py +++ b/tests/unit/scanner/rules/test_vocabulary_shape_pin.py @@ -4,9 +4,11 @@ from pathlib import Path from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind, Maturity, Severity from wardline.core.taints import TRUST_RANK from wardline.core.taints import TaintState as T from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.rules import build_default_registry def _analyze(tmp_path: Path, files: dict[str, str]): @@ -40,3 +42,53 @@ def test_decorator_taint_shapes_are_distinct_and_stable(tmp_path) -> None: assert TRUST_RANK[body["m.tb"]] > TRUST_RANK[ret["m.tb"]] # @trusted: body == return, both trusted (NOT a transition) assert body["m.tr"] == ret["m.tr"] == T.ASSURED + + +# The full rule-metadata shape pin: every builtin PY-WL rule's (severity, kind, +# maturity) triple is part of the product vocabulary (gate behavior, baseline +# eligibility, doc tables). Asserting dict EQUALITY pins all three drift axes at +# once: a renamed/removed/added rule id changes the key set; a recalibrated +# severity (e.g. the 108/112 WARN->ERROR calibration), a kind change, or a +# PREVIEW->STABLE graduation changes a value. Any such drift must be a +# deliberate edit HERE, not an accident. +_EXPECTED_RULE_SHAPE = { + "PY-WL-101": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-102": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-103": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-104": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-105": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-106": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-107": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-108": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-109": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-110": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-111": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-112": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-113": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-114": (Severity.ERROR, Kind.DEFECT, Maturity.STABLE), + "PY-WL-115": (Severity.WARN, Kind.DEFECT, Maturity.STABLE), + "PY-WL-116": (Severity.WARN, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-117": (Severity.WARN, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-118": (Severity.ERROR, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-119": (Severity.ERROR, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-120": (Severity.ERROR, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-121": (Severity.ERROR, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-122": (Severity.ERROR, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-123": (Severity.WARN, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-124": (Severity.ERROR, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-125": (Severity.INFO, Kind.DEFECT, Maturity.PREVIEW), + "PY-WL-126": (Severity.WARN, Kind.DEFECT, Maturity.PREVIEW), +} + + +def test_builtin_rule_metadata_shape_is_pinned() -> None: + reg = build_default_registry(WardlineConfig()) + actual = {r.metadata.rule_id: (r.metadata.base_severity, r.metadata.kind, r.metadata.maturity) for r in reg.rules} + assert actual == _EXPECTED_RULE_SHAPE + + +def test_rule_metadata_id_matches_class_rule_id() -> None: + # The registry keys findings by `rule.rule_id` while docs/vocab read + # `rule.metadata.rule_id`; a divergence would mislabel every finding of that rule. + reg = build_default_registry(WardlineConfig()) + assert [(r.rule_id, r.metadata.rule_id) for r in reg.rules if r.rule_id != r.metadata.rule_id] == [] diff --git a/tests/unit/scanner/rules/test_wave2_engine_precision.py b/tests/unit/scanner/rules/test_wave2_engine_precision.py index 80b40462..ee4000b2 100644 --- a/tests/unit/scanner/rules/test_wave2_engine_precision.py +++ b/tests/unit/scanner/rules/test_wave2_engine_precision.py @@ -20,19 +20,24 @@ from wardline.core.finding import Finding, Kind from wardline.scanner.analyzer import WardlineAnalyzer +# Shared file preamble. Line-number assertions below are expressed relative to +# _HEADER_LINES (wardline-e159060db7): adding a line here used to silently shift +# four absolute line-number assertions with no engine change. +_HEADER = ( + "from wardline.decorators import external_boundary, trust_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trust_boundary(to_level='ASSURED')\n" + "def validate(x):\n" + " if not x:\n raise ValueError\n return x\n" +) +_HEADER_LINES = _HEADER.count("\n") + def _analyze_files(tmp_path: Path, files: dict[str, str]) -> Sequence[Finding]: for name, content in files.items(): p = tmp_path / name p.parent.mkdir(parents=True, exist_ok=True) - header = ( - "from wardline.decorators import external_boundary, trust_boundary, trusted\n" - "@external_boundary\ndef read_raw(p):\n return p\n" - "@trust_boundary(to_level='ASSURED')\n" - "def validate(x):\n" - " if not x:\n raise ValueError\n return x\n" - ) - p.write_text(header + textwrap.dedent(content), encoding="utf-8") + p.write_text(_HEADER + textwrap.dedent(content), encoding="utf-8") analyzer = WardlineAnalyzer() file_paths = [tmp_path / name for name in files] @@ -64,7 +69,8 @@ def test_flow(p): # Check that PY-WL-107 is raised, but only for the first eval. py_wl_107_findings = [f for f in defects if f.rule_id == "PY-WL-107"] assert len(py_wl_107_findings) == 1 - assert py_wl_107_findings[0].location.line_start == 14 + # Snippet line 5 (leading blank, @trusted, def, x=, eval) after the header. + assert py_wl_107_findings[0].location.line_start == _HEADER_LINES + 5 def test_starred_args_and_kwargs_resolution_flow_sensitive(tmp_path: Path) -> None: @@ -93,7 +99,8 @@ def test_starred(p): assert len(py_wl_107_findings) == 1 assert all(f.qualname == "m.target" for f in py_wl_107_findings) lines = sorted((f.location.line_start or 0) for f in py_wl_107_findings) - assert lines == [13] + # eval(a) is snippet line 4 (leading blank, @trusted, def, eval) after the header. + assert lines == [_HEADER_LINES + 4] def test_kwargs_unpack_only_taints_unfilled_keyword_capable_parameters(tmp_path: Path) -> None: @@ -122,7 +129,8 @@ def test_kwargs(p): defects = [f for f in findings if f.kind is Kind.DEFECT] py_wl_107_findings = [f for f in defects if f.rule_id == "PY-WL-107" and f.qualname == "m.target"] lines = sorted((f.location.line_start or 0) for f in py_wl_107_findings) - assert lines == [18, 20] + # eval(c) / eval(extra) are snippet lines 9 and 11 after the header. + assert lines == [_HEADER_LINES + 9, _HEADER_LINES + 11] # ── WP8: Cross-Module Call-Argument Taint ────────────────────────────────── diff --git a/tests/unit/scanner/rules/test_wave3_deferred_rules.py b/tests/unit/scanner/rules/test_wave3_deferred_rules.py index ec963d62..f8c9dc57 100644 --- a/tests/unit/scanner/rules/test_wave3_deferred_rules.py +++ b/tests/unit/scanner/rules/test_wave3_deferred_rules.py @@ -15,7 +15,8 @@ from pathlib import Path from wardline.core.config import WardlineConfig -from wardline.core.finding import Finding, Kind +from wardline.core.finding import Finding, Kind, Severity +from wardline.core.taints import TaintState from wardline.scanner.analyzer import WardlineAnalyzer @@ -150,6 +151,212 @@ def test_sql(p, cursor): assert sqli_findings[0].qualname == "m.test_sql" +def test_sql_injection_parameterized_query_does_not_fire(tmp_path: Path) -> None: + # Bound-parameter query: the SQL string is a constant literal, the untrusted value is + # passed ONLY as a bound parameter (the OWASP-canonical mitigation), so it cannot alter + # SQL structure — there is no CWE-89 finding (wardline-e0e44852e7). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.execute("SELECT * FROM users WHERE id = ?", (read_raw(p),)) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert sqli == [] + + +def test_sql_injection_executemany_parameterized_does_not_fire(tmp_path: Path) -> None: + # executemany's seq_of_params is also a bound-parameter position. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.executemany("INSERT INTO t VALUES (?)", read_raw(p)) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert sqli == [] + + +def test_sql_injection_tainted_sql_string_with_clean_params_still_fires(tmp_path: Path) -> None: + # The no-FN guard for the FP fix: narrowing to the operation position must NOT silence a + # genuinely tainted SQL STRING. Untrusted data interpolated into the query text — with a + # clean bound parameter — is still SQLi. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, uid, cursor): + cursor.execute(f"SELECT * FROM {read_raw(p)} WHERE id = ?", (uid,)) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + assert sqli[0].qualname == "m.q" + + +# ── PY-WL-118 regression: **kwargs dict-unpacking gate (wardline-8c31463f9f) ── + + +def test_sql_injection_fires_on_kwargs_operation(tmp_path: Path) -> None: + # A tainted SQL operation passed via ``**{"operation": ...}`` collapses to the engine's + # ``None`` (``**kwargs``) arg-key. The narrowed _SQL_STRING_KEYS gate ignored ``None``, so + # this FN slipped past the FP fix (wardline-8c31463f9f). The literal-dict key ("operation") + # is in the SQL-string slot, so 118 must fire. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.execute(**{"operation": read_raw(p)}) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + assert sqli[0].qualname == "m.q" + + +def test_sql_injection_fires_on_kwargs_sql(tmp_path: Path) -> None: + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.execute(**{"sql": read_raw(p)}) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + + +def test_sql_injection_kwargs_parameters_only_does_not_fire(tmp_path: Path) -> None: + # Preserves the parameterized-query FP fix (wardline-e0e44852e7) for the ``**kwargs`` shape: + # a literal dict that provably targets only the bound-parameter slot ("parameters") — never + # the SQL string — is not SQLi and must stay silent. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.execute(**{"parameters": read_raw(p)}) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert sqli == [] + + +def test_sql_injection_opaque_kwargs_fails_closed(tmp_path: Path) -> None: + # An opaque ``**`` unpack (not a static literal dict) cannot be split into operation-vs-params, + # so a raw-tier value reaching it is treated as potentially the SQL string — fail closed, fire. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.execute(**read_raw(p)) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + + +def test_sql_injection_fires_on_execute_inside_lambda(tmp_path: Path) -> None: + # PY-WL-118 must inspect sink calls inside LAMBDA bodies, matching its sink-family siblings + # (PY-WL-106/107/108 descend into lambdas via _own_calls). It previously walked own_nodes, + # which treats ast.Lambda as a scope boundary, so a tainted execute() in a lambda silently + # escaped — a real SQLi FN. Attribution is to the enclosing entity (as the siblings do). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, cursor): + run = lambda: cursor.execute(read_raw(p)) + return run + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + assert sqli[0].qualname == "m.f" + assert sqli[0].severity is Severity.ERROR + + +def test_sql_injection_fires_on_kwargs_operation_inside_lambda(tmp_path: Path) -> None: + # The bug-1 **kwargs gate and the lambda-descent fix compose: a tainted ** operation inside + # a lambda body must fire. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, cursor): + run = lambda: cursor.execute(**{"operation": read_raw(p)}) + return run + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + + +def test_sql_injection_lambda_bound_parameter_stays_silent(tmp_path: Path) -> None: + # Descending into lambdas must not break the bound-parameter FP fix (wardline-e0e44852e7): + # a clean SQL string with the untrusted value only in the parameter position stays silent + # even inside a lambda. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def f(p, cursor): + run = lambda: cursor.execute("SELECT * FROM t WHERE id = ?", (read_raw(p),)) + return run + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert sqli == [] + + +def test_sql_injection_kwargs_mixed_literal_overfires_failclosed(tmp_path: Path) -> None: + # KNOWN, INTENDED fail-closed over-approximation: the engine collapses every ``**``-dict + # value into ONE worst-taint under the ``None`` key, so a literal dict that puts a clean + # value in the SQL-string slot ("operation") and the tainted value in the bound-parameter + # slot ("parameters") cannot be attributed per-key. Because an SQL-string key IS present and + # the ``**`` region carries raw taint, the gate fires (over-approximation, never an FN). The + # precise per-key attribution is engine-level work tracked as expansion backlog. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def q(p, cursor): + cursor.execute(**{"operation": "SELECT 1", "parameters": read_raw(p)}) + """ + }, + ) + sqli = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + + # ── PY-WL-119: Degenerate Boundary ───────────────────────────────────────── @@ -188,6 +395,27 @@ def safe(x): assert len(db_findings) == 0 +def test_degenerate_shape_without_boundary_claim_is_suppressed(tmp_path: Path) -> None: + # Tier-suppression matrix slot (wardline-e159060db7): PY-WL-119 is gated on an + # ANCHORED trust-RAISING declaration. The byte-identical bare-passthrough body + # must stay silent both undecorated (not anchored) and under @trusted + # (anchored, but body == return -> not a trust-raising boundary). + findings = _analyze_files( + tmp_path, + { + "m.py": """ + def plain(x): + return x + + @trusted(level='ASSURED') + def producer(x): + return x + """ + }, + ) + assert [f for f in findings if f.rule_id == "PY-WL-119"] == [] + + # ── PY-WL-120: Stored Taint ──────────────────────────────────────────────── @@ -233,7 +461,13 @@ def run(): assert st_findings[0].qualname == "m.run" -def test_sql_injection_nested_scope_isolation(tmp_path: Path) -> None: +def test_sql_injection_nested_def_inherits_trusted_tier(tmp_path: Path) -> None: + # A nested def inside a @trusted parent inherits the parent's trusted tier via the + # family-wide ``..`` strip (commit bdccca1). PY-WL-118 originally lacked the + # strip, so a tainted execute() wrapped in a nested function silently evaded the + # highest-severity sink (wardline-9b88ec5419). This test previously asserted the BUG + # (118 stays silent here); it now asserts the parity it always should have had — + # 118 fires exactly as its siblings 108/115/116/117 do in this shape. findings = _analyze_files( tmp_path, { @@ -251,7 +485,102 @@ def nested_untrusted(): ) defects = [f for f in findings if f.kind is Kind.DEFECT] sqli_findings = [f for f in defects if f.rule_id == "PY-WL-118"] - assert len(sqli_findings) == 0 + assert len(sqli_findings) == 1 + assert sqli_findings[0].qualname == "m.safe_parent..nested_untrusted" + + +def test_sql_injection_nested_own_trusted_decorator_under_undecorated_parent_fires(tmp_path: Path) -> None: + # Regression wardline-bb8396f96e: the unconditional ``..`` strip made a nested def + # inherit its parent's tier even when the nested def carries its OWN trust decorator. Here + # the parent is undecorated (UNKNOWN_RAW); the nested ``inner`` is @trusted in its own right, + # so its tier — not the parent's — governs, and the real SQLi must fire. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + def outer(p, cursor): + @trusted(level='ASSURED') + def inner(q, cursor): + cursor.execute(read_raw(q)) + return inner + """ + }, + ) + defects = [f for f in findings if f.kind is Kind.DEFECT] + sqli = [f for f in defects if f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + assert sqli[0].qualname == "m.outer..inner" + assert sqli[0].severity is Severity.ERROR + + +def test_sql_injection_nested_own_trusted_under_external_boundary_parent_fires(tmp_path: Path) -> None: + # Regression wardline-bb8396f96e, case B: parent is @external_boundary (EXTERNAL_RAW, which + # modulates to NONE). The nested @trusted ``inner`` must use its OWN tier and fire at ERROR + # rather than inheriting the suppressed parent tier. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @external_boundary + def outer(p, cursor): + @trusted(level='ASSURED') + def inner(q, cursor): + cursor.execute(read_raw(q)) + return inner + """ + }, + ) + defects = [f for f in findings if f.kind is Kind.DEFECT] + sqli = [f for f in defects if f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + assert sqli[0].qualname == "m.outer..inner" + assert sqli[0].severity is Severity.ERROR + + +def test_sql_injection_double_nested_undecorated_inherits_trusted_tier(tmp_path: Path) -> None: + # Preserves wardline-9b88ec5419 at depth: two levels of UNDECORATED nesting inside a @trusted + # outer must still inherit the outer's tier (walk-outward to the nearest DECLARED scope), so + # the leaf sink fires. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def deep(p, cursor): + def mid(q): + def leaf(r): + cursor.execute(read_raw(r)) + return leaf + return mid + """ + }, + ) + defects = [f for f in findings if f.kind is Kind.DEFECT] + sqli = [f for f in defects if f.rule_id == "PY-WL-118"] + assert len(sqli) == 1 + assert sqli[0].qualname == "m.deep..mid..leaf" + + +def test_exec_sink_nested_own_trusted_under_undecorated_parent_fires(tmp_path: Path) -> None: + # Family-wide coverage: the same nested-own-decorator fix lives in the shared TaintedSinkRule + # base (_sink_helpers), so a non-118 sink rule (PY-WL-107, eval) must also honor a nested + # def's own @trusted decorator under an undecorated parent. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + def outer(p): + @trusted(level='ASSURED') + def inner(q): + eval(read_raw(q)) + return inner + """ + }, + ) + defects = [f for f in findings if f.kind is Kind.DEFECT] + exec_findings = [f for f in defects if f.rule_id == "PY-WL-107"] + assert len(exec_findings) == 1 + assert exec_findings[0].qualname == "m.outer..inner" def test_stored_taint_nested_scope_isolation(tmp_path: Path) -> None: @@ -271,3 +600,151 @@ def nested_untrusted(): defects = [f for f in findings if f.kind is Kind.DEFECT] st_findings = [f for f in defects if f.rule_id == "PY-WL-120"] assert len(st_findings) == 0 + + +def test_stored_taint_cursor_fetch_reaches_return_fires(tmp_path: Path) -> None: + # wardline-e7c7cda31a: PY-WL-120 was a dead branch for DB-cursor fetches — the matcher + # matched fetchall() but the result was never seeded raw. Seeding fetch{one,all,many} + # EXTERNAL_RAW makes a @trusted fn that returns unvalidated rows fire PY-WL-120. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def get_rows(cursor): + rows = cursor.fetchall() + return rows + """ + }, + ) + st = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-120"] + assert [f.qualname for f in st] == ["m.get_rows"] + # wardline-obs-638a5d9fd1: pin the resolved tier for a DB-cursor read. The PY-WL-101 + # finding on the same producer must classify actual_return as EXTERNAL_RAW (DB read is + # external/stored data, like open()/read_text()), DETERMINISTICALLY — not the old + # fail-closed UNKNOWN_RAW the dead-branch fallback produced. This locks the storage-read + # seed's end-to-end return tier so the "tier drift" observation cannot recur. + p101 = [f for f in findings if f.rule_id == "PY-WL-101" and f.qualname == "m.get_rows"] + assert len(p101) == 1, p101 + assert p101[0].properties["actual_return"] == TaintState.EXTERNAL_RAW.value + + +def test_stored_taint_cursor_fetch_validated_stays_silent(tmp_path: Path) -> None: + # FP guard: fetch then VALIDATE through a @trust_boundary before returning launders the + # return to ASSURED (outside RAW_ZONE) — must not fire PY-WL-120. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def get_rows(cursor): + rows = cursor.fetchall() + return validate(rows) + """ + }, + ) + st = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-120" and f.qualname == "m.get_rows"] + assert st == [] + + +def test_stored_taint_cursor_fetch_constant_return_stays_silent(tmp_path: Path) -> None: + # FP guard: fetching then returning a constant (rows don't flow out) stays silent. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + @trusted(level='ASSURED') + def get_rows(cursor): + rows = cursor.fetchall() + return 42 + """ + }, + ) + st = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-120" and f.qualname == "m.get_rows"] + assert st == [] + + +def test_stored_taint_branch_conditional_trusted_callee_fires_regardless_of_ast_order(tmp_path: Path) -> None: + # wardline-499c22bbdd (PY-WL-120 candidate-set consumer): stored rows passed to a + # branch-conditional receiver whose trusted-sink candidate is the AST-FIRST arm must + # still fire — the candidate set is consulted, not just the AST-last single callee. + src_tmpl = """ + class Plain: + def take(self, x): + return 1 + class TrustedSink: + @trusted(level='ASSURED') + def take(self, x): + return 1 + @trusted(level='ASSURED') + def f(cursor, flag): + rows = cursor.fetchall() + if flag: + o = {first} + else: + o = {second} + o.take(rows) + """ + for first, second in (("TrustedSink()", "Plain()"), ("Plain()", "TrustedSink()")): + findings = _analyze_files(tmp_path, {"m.py": src_tmpl.format(first=first, second=second)}) + st = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-120" and f.qualname == "m.f"] + assert len(st) == 1, (first, second, st) + + +def test_stored_taint_branch_conditional_neither_trusted_stays_silent(tmp_path: Path) -> None: + # CONTROL: two candidate receivers, neither a trusted sink — must stay silent. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + class Plain: + def take(self, x): + return 1 + class Plain2: + def take(self, x): + return 1 + @trusted(level='ASSURED') + def f(cursor, flag): + rows = cursor.fetchall() + if flag: + o = Plain() + else: + o = Plain2() + o.take(rows) + """ + }, + ) + st = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-120" and f.qualname == "m.f"] + assert st == [] + + +def test_stored_taint_two_trusted_candidates_emits_one_finding(tmp_path: Path) -> None: + # panel-2 (wardline-499c22bbdd): PY-WL-120 carries its own one-finding-per-call-site + # collapse. When BOTH branch arms are trusted sinks, stored rows reaching the dispatch + # is ONE defect — assert exactly one finding with the "also reaches" annotation. + findings = _analyze_files( + tmp_path, + { + "m.py": """ + class A: + @trusted(level='ASSURED') + def take(self, x): + return 1 + class B: + @trusted(level='ASSURED') + def take(self, x): + return 1 + @trusted(level='ASSURED') + def f(cursor, flag): + rows = cursor.fetchall() + if flag: + o = A() + else: + o = B() + o.take(rows) + """ + }, + ) + st = [f for f in findings if f.kind is Kind.DEFECT and f.rule_id == "PY-WL-120" and f.qualname == "m.f"] + assert len(st) == 1, st + assert "also reaches" in st[0].message diff --git a/tests/unit/scanner/taint/CHOKEPOINT_NOTES.md b/tests/unit/scanner/taint/CHOKEPOINT_NOTES.md new file mode 100644 index 00000000..8f8a2e58 --- /dev/null +++ b/tests/unit/scanner/taint/CHOKEPOINT_NOTES.md @@ -0,0 +1,161 @@ +# Chokepoint contextvar coupling notes (`scanner/taint/variable_level.py`) + +Companion to `test_property_chokepoint.py` (wardline-369f54b83b). The L2 walk +threads SEVEN pieces of ambient state through `contextvars.ContextVar` slots +instead of parameters. This invisible coupling is where new handlers go wrong: +a handler that forgets to branch-copy / merge / reset one of these maps either +leaks state across mutually-exclusive branch arms (FP/FN) or across functions +(stale-state launder). This file documents, per contextvar, who SETS the slot +(rebinds it, with token/reset discipline), who READS it, and who MUTATES the +dict it points to (a mutation is visible to every reader of the same slot +binding — strictly stronger coupling than a read). + +Line numbers reference the file as of 2026-06-11 (2,178 lines); they drift, +the handler names do not. + +## The seven contextvars + +| ContextVar | Type | Purpose | +|---|---|---| +| `_CURRENT_ALIAS_MAP` | `dict[str, str] \| None` | import alias -> FQN for call/annotation resolution | +| `_CURRENT_CALL_SITE_ARG_TAINTS` | `dict[int, dict[int\|str\|None, TaintState]] \| None` | out-channel: resolved arg taints keyed by `id(call_node)` (sink-rule input) | +| `_CURRENT_VAR_TYPES` | `dict[str, list[str]] \| None` | local name -> CANDIDATE SET of class FQNs it may hold (typed-receiver dispatch) | +| `_CURRENT_ATTR_WRITES` | `dict[str, dict[str, TaintState]] \| None` | out-channel: attribute writes recorded during the walk (class-attribute taint) | +| `_CURRENT_LAMBDA_BINDINGS` | `dict[str, list[ast.Lambda]] \| None` | local name -> CANDIDATE SET of lambda bodies it may hold | +| `_CURRENT_MODULE_PREFIX` | `str \| None` | module dotted prefix for FQN minting | +| `_PROVENANCE_CLASH` | `bool` (lives in `core/taints.py`) | switches `combine()` between `least_trusted` (default) and `taint_join` | + +## Who sets each slot (token + `finally: reset` discipline) + +| Setter | ALIAS_MAP | ARG_TAINTS | VAR_TYPES | ATTR_WRITES | LAMBDA_BINDINGS | MODULE_PREFIX | PROVENANCE_CLASH | +|---|---|---|---|---|---|---|---| +| `analyze_function_variables` (:326) | set | set | — | — | — | set | — | +| `compute_variable_taints` (:547) | set (if arg given) | set (if arg given) | set fresh `{}` | — | set fresh `{}` | — | set (if arg given) | +| `attribute_write_recording` CM (:264) | — | — | — | set | — | — | — | +| `_walk_branch_body` (:1559) | — | — | set arm copy | — | set arm copy | — | — | +| `_handle_match` per-case (:1969) | — | — | set arm copy | — | set arm copy | — | — | + +Key consequences: + +- **`compute_variable_taints` is the lifecycle owner** of `VAR_TYPES` and + `LAMBDA_BINDINGS`: both are ALWAYS fresh per function and ALWAYS reset in its + `finally`. Any code that runs AFTER it returns sees `None` for both. +- **`compute_return_taint` / `compute_return_callee` run with `VAR_TYPES` and + `LAMBDA_BINDINGS` unset** (they are called by `analyze_function_variables` + *after* `compute_variable_taints` has reset them). Re-resolving a `return + h.method()` therefore takes the GENERIC raw-receiver path (`_resolve_call` + :1102 receiver-taint check), never the typed-receiver dispatch — the + declared-raw guard is only exercisable through an in-body assignment. The + property tests bind `x = h.method()` in the body for exactly this reason. +- `ATTR_WRITES` is opt-in: it defaults to `None` and is only live inside the + analyzer's `attribute_write_recording` block. Every recorder is a no-op + when it is `None`. +- Branch arms get arm-local COPIES of `VAR_TYPES` / `LAMBDA_BINDINGS` (deep + enough: the candidate lists are copied too), then the arms are UNION-merged + back into the parent dict in place (`_merge_branch_types` / + `_merge_branch_bindings`). `var_taints` itself is NOT a contextvar — it is a + positional parameter copied/merged by the same handlers; keep the three in + lockstep when adding a branching construct. + +## Who reads / mutates each slot + +### `_CURRENT_ALIAS_MAP` +- `_seed_parameters` (:591) — read; feeds annotation FQN resolution. +- `_resolve_call` (:948) — read; feeds `resolve_call_fqn` (context encoders, + serialisation sinks, imported-FQN `taint_map` hits). +- `_update_var_type` (:1278) — read; types `x = Type()` constructor results. +- Never mutated by the walk; treated as read-only input. + +### `_CURRENT_CALL_SITE_ARG_TAINTS` +- `_resolve_call` (:893) — read slot, **mutates dict**: records/merges + `resolved_args` under `id(call_node)`. Also written through the lambda-body + resolution paths (`_resolve_lambda_bodies`, `_resolve_lambda_body_at_call`), + which re-enter `_resolve_expr` with the same slot live. +- Pure out-channel: nothing in this module reads entries back; sink rules + consume it after the walk. A call node resolved more than once (loop + fixpoint iterations, lambda candidates) MERGES via `combine` rather than + overwriting — re-resolution is monotone, never clean-overwriting. + +### `_CURRENT_VAR_TYPES` +- `_seed_parameters` (:590) — read slot, **mutates dict**: annotation types. +- `_resolve_call` (:991) — read-only: typed-receiver method dispatch + (the declared-raw receiver guard lives here, wardline-03c8805449). +- `_update_var_type` (:1273) — read slot, **mutates dict**: strong update on + straight-line assignment; INVALIDATES (pops) on untypeable RHS + (wardline-5ba7ce0f98 stale-type launder fix). +- `_record_attribute_write` (:308) — read-only: receiver class candidates. +- All branching handlers (`_handle_if` :1633, `_handle_for` :1716/:1752, + `_handle_while` :1777/:1800, `_handle_try` :1840, `_handle_match` :1956) — + read parent for arm copies; `_merge_branch_types` **mutates the parent dict + in place** (clear + union of arms). +- Loops snapshot the pre-loop map as a zero-trip arm and union it back after + the fixpoint (wardline-b369c7d06c / wardline-d6af917bde mirror). +- Invariant: a name is ABSENT or maps to a NON-EMPTY list. + +### `_CURRENT_ATTR_WRITES` +- `_record_attribute_write` (:303) — read slot, **mutates dict**: joins the + RHS taint per (receiver-key, attr) via `combine`. Called from + `_handle_assign`, the AnnAssign branch of `_process_stmt`, and + `_handle_augassign` — i.e. recording happens DURING the statement walk at + the write site, against the current per-statement `var_taints` + (wardline-b369c7d06c: a later reassignment cannot launder the recording). +- Receiver keys: class-FQN candidates from `VAR_TYPES`, or + `SELF_ATTRIBUTE_KEY` for `self`/`cls`; projected onto class qualnames later + by `project_attribute_writes` (pure function, no contextvar). + +### `_CURRENT_LAMBDA_BINDINGS` +- `_handle_assign` (:1309) and the AnnAssign branch of `_process_stmt` + (:1169) — read slot, **mutates dict**: linear rebind stores `[lam]` + (replace, never append); non-lambda RHS pops the name. +- `_resolve_call` (:902) — read-only: resolves a direct `cb(...)` against + EVERY candidate body. +- All branching handlers — arm copies + in-place union merge, exactly like + `VAR_TYPES` (wardline-36016d26f3 arm leak; wardline-383f83fafe single-slot + FN; wardline-d6af917bde zero-trip union). +- Invariant: ABSENT or NON-EMPTY; candidate lists deduped by node IDENTITY + (ast nodes are id-hashed — a set would destabilise golden corpora). + +### `_CURRENT_MODULE_PREFIX` +- `_resolve_call` (:953) — read; feeds `resolve_call_fqn`. +- `_resolve_expr_fqn` (:1251) — read; prefixes un-aliased bare names. +- Set only by `analyze_function_variables`; read-only everywhere else. NOTE: + `compute_variable_taints` does NOT set it — a direct call (as the unit tests + do) runs with whatever the ambient context holds (`None` in tests), so + annotation FQNs resolve to bare class names there but to + `pkg.mod.ClassName` under the analyzer. Test `taint_map` keys must match + the bare spelling; analyzer-built maps use the prefixed one. + +### `_PROVENANCE_CLASH` (in `core/taints.py`) +- `combine` — read on EVERY taint combination anywhere in the walk; flips + between `least_trusted` (default, rank-meet) and `taint_join` + (provenance-clash semantics). +- Set only via `compute_variable_taints(provenance_clash=...)`; default + `False` across the live pipeline. + +## Hazards for new handlers (the recurring bug shapes) + +1. **Forgetting the arm copy**: walking a conditionally-executed body against + the parent `LAMBDA_BINDINGS`/`VAR_TYPES` leaks bindings into sibling arms + (wardline-36016d26f3 class of bug). Use `_branch_copy` / + `_types_branch_copy` + `_walk_branch_body`, then merge. +2. **Forgetting the implicit arm**: no-`else` `if`, zero-trip loops, and + no-match `match` all keep the PRE-state alive; an arm set that omits the + pre-state copy drops it (wardline-d6af917bde). +3. **Overwrite instead of merge** on the out-channels: `ARG_TAINTS` entries + must `combine`-merge on re-resolution (loop fixpoints re-visit call nodes). +4. **Post-reset re-resolution**: anything that re-enters `_resolve_expr` after + `compute_variable_taints` returned (return-taint/callee computation, + sink-rule re-resolution) runs WITHOUT `VAR_TYPES`/`LAMBDA_BINDINGS` — do + not rely on typed dispatch or lambda candidates there. +5. **Empty-list candidates**: storing `[]` in `LAMBDA_BINDINGS`/`VAR_TYPES` + makes membership checks pass while candidate loops do nothing — a silent + FN. Writers must store non-empty or pop. + +## Future direction (from the ticket) + +The reviewers' suggestion stands: a single threaded `_WalkContext` dataclass +(alias_map, module_prefix, var_types, lambda_bindings, arg_taints out-channel, +attr_writes out-channel) passed positionally would make the coupling explicit +and let mypy police it. The property harness in `test_property_chokepoint.py` +is the safety net for that refactor: lattice monotonicity, idempotence, seed +monotonicity, and the receiver guard must all survive it unchanged. diff --git a/tests/unit/scanner/taint/test_attribute_write_flow.py b/tests/unit/scanner/taint/test_attribute_write_flow.py new file mode 100644 index 00000000..bb70a390 --- /dev/null +++ b/tests/unit/scanner/taint/test_attribute_write_flow.py @@ -0,0 +1,434 @@ +"""Attribute-write flow soundness (wardline-b369c7d06c). + +The per-class attribute summary (``class_attr_taints``) used to be built by a +POST-HOC second walk (``collect_attribute_writes``) that resolved each write's +RHS against the function's FINAL ``var_taints`` and tracked receiver classes in +a branch-unaware, single-slot ``var_types`` map. Two laundering mechanisms +followed — both silent false negatives in the exact summaries the +secure-by-default gate's @trusted-method sink rules rely on: + +1. FINAL-STATE LAUNDER: ``v = read_raw(p); self.x = v; v = "safe"`` resolved + ``v`` post-reassignment, recording ``Store.x`` as INTEGRAL. +2. BRANCH-UNAWARE RECEIVER: ``box = Vault(); if flag: box = Ledger(); + box.token = read_raw(p)`` kept only the last class binding, attributing the + raw write solely to ``Ledger`` while on the no-flag path the receiver is + still the ``Vault`` instance. + +Attribute writes are now recorded DURING the main L2 walk (per-statement +``var_taints``, full branch handling) into a side channel +(:func:`attribute_write_recording`), and receiver class tracking is set-valued +and branch-aware: a straight-line class rebind is a strong update, a branch +join unions the arms, and a write through a multi-candidate receiver attributes +to EVERY candidate class. +""" + +from __future__ import annotations + +import ast +import textwrap +from collections.abc import Mapping +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind +from wardline.core.taints import TaintState +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.taint.variable_level import ( + SELF_ATTRIBUTE_KEY, + attribute_write_recording, + compute_variable_taints, + project_attribute_writes, +) + +T = TaintState + +_HEADER = ( + "import pickle\n" + "from wardline.decorators import external_boundary, trusted, trust_boundary\n" + "@external_boundary\ndef read_raw(p):\n return p\n" +) + + +def _analyze(tmp_path: Path, body: str) -> WardlineAnalyzer: + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(body), encoding="utf-8") + analyzer = WardlineAnalyzer() + analyzer.analyze([p], WardlineConfig(), root=tmp_path) + return analyzer + + +def _defect_ids(tmp_path: Path, body: str) -> set[str]: + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(body), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + return {f.rule_id for f in findings if f.kind is Kind.DEFECT} + + +def _attr_taints(analyzer: WardlineAnalyzer) -> Mapping[str, Mapping[str, TaintState]]: + ctx = analyzer.last_context + assert ctx is not None + return ctx.class_attr_taints + + +def _record( + src: str, + taint_map: dict[str, TaintState] | None = None, + function_taint: TaintState = T.UNKNOWN_RAW, +) -> dict[str, dict[str, TaintState]]: + func = ast.parse(textwrap.dedent(src)).body[0] + assert isinstance(func, ast.FunctionDef | ast.AsyncFunctionDef) + out: dict[str, dict[str, TaintState]] = {} + with attribute_write_recording(out): + compute_variable_taints(func, function_taint, taint_map or {}, alias_map={}) + return out + + +# ── s1/s2 — per-statement RHS taint (final-state launder) ───────────────────── + +_STORE_CONTROL = """ +class Store: + def put(self, p): + v = read_raw(p) + self.x = v + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.x) +""" + +_STORE_FINAL_STATE_LAUNDER = """ +class Store: + def put(self, p): + v = read_raw(p) + self.x = v + v = "safe" + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.x) +""" + + +def test_s1_control_raw_attr_fires_sink_in_trusted_method(tmp_path: Path) -> None: + analyzer = _analyze(tmp_path, _STORE_CONTROL) + assert _attr_taints(analyzer)["m.Store"]["x"] == T.EXTERNAL_RAW + assert "PY-WL-106" in _defect_ids(tmp_path, _STORE_CONTROL) + + +def test_s2_reassignment_after_attr_write_does_not_launder(tmp_path: Path) -> None: + # ``v`` is RAW at the ``self.x = v`` statement; the trailing ``v = "safe"`` + # must not retroactively clean the recorded attribute write. + analyzer = _analyze(tmp_path, _STORE_FINAL_STATE_LAUNDER) + assert _attr_taints(analyzer)["m.Store"]["x"] == T.EXTERNAL_RAW + assert "PY-WL-106" in _defect_ids(tmp_path, _STORE_FINAL_STATE_LAUNDER) + + +def test_s4_branch_local_reassignment_after_write_stays_raw(tmp_path: Path) -> None: + # Already-sound pin: ``self.x = v`` then ``if flag: v = "safe"`` — the write + # happened while v was raw; the branch merge must not launder it either. + body = """ + class Store: + def put(self, p, flag): + v = read_raw(p) + self.x = v + if flag: + v = "safe" + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.x) + """ + analyzer = _analyze(tmp_path, body) + assert _attr_taints(analyzer)["m.Store"]["x"] == T.EXTERNAL_RAW + assert "PY-WL-106" in _defect_ids(tmp_path, body) + + +def test_s3_raw_in_one_branch_arm_stays_raw(tmp_path: Path) -> None: + # Already-sound pin: a raw write in one arm joins (least-trusted) with the + # clean arm's write — the summary keeps the raw rank. + body = """ + class Store: + def put(self, p, flag): + if flag: + self.x = read_raw(p) + else: + self.x = "safe" + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.x) + """ + analyzer = _analyze(tmp_path, body) + assert _attr_taints(analyzer)["m.Store"]["x"] == T.EXTERNAL_RAW + assert "PY-WL-106" in _defect_ids(tmp_path, body) + + +def test_augassign_attr_write_uses_per_statement_taint(tmp_path: Path) -> None: + body = """ + class Store: + def put(self, p): + v = read_raw(p) + self.x = "" + self.x += v + v = "safe" + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.x) + """ + analyzer = _analyze(tmp_path, body) + assert _attr_taints(analyzer)["m.Store"]["x"] == T.EXTERNAL_RAW + + +def test_annassign_attr_write_uses_per_statement_taint(tmp_path: Path) -> None: + body = """ + class Store: + def put(self, p): + v = read_raw(p) + self.x: str = v + v = "safe" + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.x) + """ + analyzer = _analyze(tmp_path, body) + assert _attr_taints(analyzer)["m.Store"]["x"] == T.EXTERNAL_RAW + + +# ── s5/s6/s7 — branch-aware, set-valued receiver class tracking ────────────── + +_VAULT_LEDGER = """ +class Vault: + def __init__(self): + self.token = "init" + + @trusted(level='ASSURED') + def use(self): + return pickle.loads(self.token) + +class Ledger: + def __init__(self): + self.token = "init" +""" + + +def test_s5_control_external_write_attributes_to_receiver_class(tmp_path: Path) -> None: + body = ( + _VAULT_LEDGER + + """ +def route(p, flag): + box = Vault() + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + taints = _attr_taints(analyzer) + assert taints["m.Vault"]["token"] == T.EXTERNAL_RAW + assert taints["m.Ledger"]["token"] == T.INTEGRAL + assert "PY-WL-106" in _defect_ids(tmp_path, body) + + +def test_s6_branch_rebound_receiver_attributes_to_all_candidate_classes(tmp_path: Path) -> None: + # On the no-flag arm ``box`` is still the Vault instance, so the raw write + # must attribute to BOTH Vault and Ledger (union of arms), not just the + # last-bound class. + body = ( + _VAULT_LEDGER + + """ +def route(p, flag): + box = Vault() + if flag: + box = Ledger() + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + taints = _attr_taints(analyzer) + assert taints["m.Vault"]["token"] == T.EXTERNAL_RAW + assert taints["m.Ledger"]["token"] == T.EXTERNAL_RAW + assert "PY-WL-106" in _defect_ids(tmp_path, body) + + +def test_s7_receiver_rebound_to_nonclass_in_one_arm_keeps_class_candidate(tmp_path: Path) -> None: + # Already-sound pin: one arm rebinds ``box`` to an untypeable raw value; the + # fall-through arm still holds the Vault instance, so Vault.token records raw. + body = ( + _VAULT_LEDGER + + """ +def route(p, flag): + box = Vault() + if flag: + box = read_raw(p) + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + assert _attr_taints(analyzer)["m.Vault"]["token"] == T.EXTERNAL_RAW + assert "PY-WL-106" in _defect_ids(tmp_path, body) + + +def test_straight_line_class_rebind_is_a_strong_update(tmp_path: Path) -> None: + # Straight-line rebind: after ``box = Ledger()`` the receiver is a Ledger on + # EVERY path, so the raw write attributes only to Ledger — Vault stays clean. + body = ( + _VAULT_LEDGER + + """ +def route(p): + box = Vault() + box = Ledger() + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + taints = _attr_taints(analyzer) + assert taints["m.Vault"]["token"] == T.INTEGRAL + assert taints["m.Ledger"]["token"] == T.EXTERNAL_RAW + assert "PY-WL-106" not in _defect_ids(tmp_path, body) + + +def test_try_except_rebound_receiver_attributes_to_both_arms(tmp_path: Path) -> None: + body = ( + _VAULT_LEDGER + + """ +def route(p): + box = Vault() + try: + box = Ledger() + except ValueError: + pass + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + taints = _attr_taints(analyzer) + assert taints["m.Vault"]["token"] == T.EXTERNAL_RAW + assert taints["m.Ledger"]["token"] == T.EXTERNAL_RAW + + +def test_match_rebound_receiver_attributes_to_both_arms(tmp_path: Path) -> None: + body = ( + _VAULT_LEDGER + + """ +def route(p, flag): + box = Vault() + match flag: + case 1: + box = Ledger() + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + taints = _attr_taints(analyzer) + assert taints["m.Vault"]["token"] == T.EXTERNAL_RAW + assert taints["m.Ledger"]["token"] == T.EXTERNAL_RAW + + +def test_zero_trip_loop_receiver_rebind_keeps_pre_loop_candidate(tmp_path: Path) -> None: + # A loop body is a conditionally-executed arm: on the zero-trip path the + # receiver is still the pre-loop Vault instance. + body = ( + _VAULT_LEDGER + + """ +def route(p, items): + box = Vault() + for _ in items: + box = Ledger() + box.token = read_raw(p) +""" + ) + analyzer = _analyze(tmp_path, body) + taints = _attr_taints(analyzer) + assert taints["m.Vault"]["token"] == T.EXTERNAL_RAW + assert taints["m.Ledger"]["token"] == T.EXTERNAL_RAW + + +# ── recording side channel (unit level) ────────────────────────────────────── + + +def test_recording_self_write_uses_per_statement_var_taints() -> None: + out = _record( + """ + def put(self, p): + v = read_raw(p) + self.x = v + v = "safe" + """, + taint_map={"read_raw": T.EXTERNAL_RAW}, + function_taint=T.INTEGRAL, + ) + assert out[SELF_ATTRIBUTE_KEY]["x"] == T.EXTERNAL_RAW + + +def test_recording_cls_write_maps_to_self_key() -> None: + out = _record( + """ + def put(cls, p): + cls.x = read_raw(p) + """, + taint_map={"read_raw": T.EXTERNAL_RAW}, + function_taint=T.INTEGRAL, + ) + assert out[SELF_ATTRIBUTE_KEY]["x"] == T.EXTERNAL_RAW + + +def test_recording_joins_multiple_writes_least_trusted() -> None: + out = _record( + """ + def put(self, p): + self.x = "clean" + self.x = read_raw(p) + """, + taint_map={"read_raw": T.EXTERNAL_RAW}, + function_taint=T.INTEGRAL, + ) + assert out[SELF_ATTRIBUTE_KEY]["x"] == T.EXTERNAL_RAW + + +def test_recording_typed_receiver_records_under_candidate_fqns() -> None: + out = _record( + """ + def route(p, flag): + box = Vault() + if flag: + box = Ledger() + box.token = read_raw(p) + """, + taint_map={"read_raw": T.EXTERNAL_RAW}, + function_taint=T.INTEGRAL, + ) + assert out["Vault"]["token"] == T.EXTERNAL_RAW + assert out["Ledger"]["token"] == T.EXTERNAL_RAW + + +def test_recording_disabled_by_default() -> None: + func = ast.parse("def put(self, p):\n self.x = p\n").body[0] + assert isinstance(func, ast.FunctionDef) + # No recording context — must not raise, and the walk stays pure. + compute_variable_taints(func, T.UNKNOWN_RAW, {}) + + +def test_project_attribute_writes_filters_and_maps_self() -> None: + recorded = { + SELF_ATTRIBUTE_KEY: {"x": T.EXTERNAL_RAW}, + "m.Vault": {"token": T.EXTERNAL_RAW}, + "m.not_a_class": {"y": T.EXTERNAL_RAW}, + } + projected = project_attribute_writes(recorded, frozenset({"m.Vault", "m.Store"}), "m.Store") + assert projected == { + "m.Store": {"x": T.EXTERNAL_RAW}, + "m.Vault": {"token": T.EXTERNAL_RAW}, + } + # Outside a method the self/cls writes have no class to attribute to. + projected_fn = project_attribute_writes(recorded, frozenset({"m.Vault", "m.Store"}), None) + assert projected_fn == {"m.Vault": {"token": T.EXTERNAL_RAW}} + + +def test_project_attribute_writes_joins_self_and_typed_keys_for_same_class() -> None: + recorded = { + SELF_ATTRIBUTE_KEY: {"x": T.INTEGRAL}, + "m.Store": {"x": T.EXTERNAL_RAW}, + } + projected = project_attribute_writes(recorded, frozenset({"m.Store"}), "m.Store") + assert projected == {"m.Store": {"x": T.EXTERNAL_RAW}} diff --git a/tests/unit/scanner/taint/test_call_taint_map.py b/tests/unit/scanner/taint/test_call_taint_map.py index 60c8d74d..5b401f40 100644 --- a/tests/unit/scanner/taint/test_call_taint_map.py +++ b/tests/unit/scanner/taint/test_call_taint_map.py @@ -43,6 +43,16 @@ def test_dotted_module_project_call_keyed_dotted() -> None: assert tm["other.fn"] == T.MIXED_RAW +def test_multicomponent_project_plain_import_keyed_fully() -> None: + aliases = _aliases("import pkg.sources\n", "m") + tm = build_call_taint_map( + module_path="m", + alias_map=aliases, + project_by_module={"pkg.sources": {"read": T.EXTERNAL_RAW}}, + ) + assert tm["pkg.sources.read"] == T.EXTERNAL_RAW + + def test_stdlib_external_dotted_taint_carries() -> None: # Positive external-dotted channel: a non-sink stdlib entry, aliased. aliases = _aliases("import subprocess as sp\n", "m") @@ -133,6 +143,20 @@ def test_l2_urllib_plain_import_carries_external_raw_end_to_end() -> None: assert out["x"] == T.EXTERNAL_RAW # the curated network-source taint, not dropped +def test_l2_project_plain_submodule_import_carries_return_taint_end_to_end() -> None: + src = "import pkg.sources\ndef f(p):\n x = pkg.sources.read(p)\n" + func = ast.parse(src).body[1] + assert isinstance(func, ast.FunctionDef) + aliases = build_import_alias_map(ast.parse(src), module_path="m") + tm = build_call_taint_map( + module_path="m", + alias_map=aliases, + project_by_module={"pkg.sources": {"read": T.EXTERNAL_RAW}}, + ) + out = compute_variable_taints(func, T.ASSURED, dict(tm)) + assert out["x"] == T.EXTERNAL_RAW + + # ── PART D: aliased serialisation sinks NOT in stdlib_taint resolve to UNKNOWN_RAW ── # # json.dumps/json.dump are in _SERIALISATION_SINKS but ABSENT from stdlib_taint diff --git a/tests/unit/scanner/taint/test_callgraph.py b/tests/unit/scanner/taint/test_callgraph.py index 2db68935..2be24c09 100644 --- a/tests/unit/scanner/taint/test_callgraph.py +++ b/tests/unit/scanner/taint/test_callgraph.py @@ -21,7 +21,7 @@ def test_local_bare_call_edge() -> None: src = "def a():\n return b()\ndef b():\n return 1\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -39,7 +39,7 @@ def test_imported_call_edge() -> None: src = "from other import helper\ndef a():\n return helper()\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset({"m.a", "other.helper"}) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -54,7 +54,7 @@ def test_self_method_edge() -> None: src = "class C:\n def process(self):\n return self.helper()\n def helper(self):\n return 1\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -77,7 +77,7 @@ def test_classmethod_edge_records_implicit_class_receiver() -> None: ) tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -93,7 +93,7 @@ def test_unresolved_external_call_counted() -> None: src = "def a():\n return some_external_thing()\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset({"m.a"}) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -110,7 +110,7 @@ def test_constructor_call_is_unresolved() -> None: src = "class C:\n def __init__(self): pass\ndef make():\n return C()\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -126,7 +126,7 @@ def test_nested_def_calls_not_attributed_to_outer() -> None: src = "def outer():\n def inner():\n return b()\n return inner()\ndef b():\n return 1\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -143,7 +143,7 @@ def test_class_method_resolved_via_instance_tracking() -> None: src = "class C:\n def run(self):\n return 1\ndef make():\n obj = C()\n return obj.run()\n" tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -167,7 +167,7 @@ def test_nested_scope_assignment_does_not_pollute_receiver_tracking() -> None: ) tree, entities, classes, aliases = _module(src, module="m") project_fqns = frozenset(e.qualname for e in entities) - edges, resolved, unresolved, callees, implicit = build_call_edges( + edges, resolved, unresolved, callees, implicit, _candidates = build_call_edges( entities=entities, class_qualnames=classes, alias_map=aliases, @@ -178,3 +178,25 @@ def test_nested_scope_assignment_does_not_pollute_receiver_tracking() -> None: assert resolved["m.outer"] == 0 assert unresolved["m.outer"] == 1 assert implicit == {} + + +def test_branch_conditional_receiver_records_candidate_callees() -> None: + # wardline-499c22bbdd: a receiver assigned a project class in each arm of an if/else + # records the FULL candidate callee set, not just the AST-last class's method. + src = ( + "class Plain:\n def take(self, x):\n return 1\n" + "class TrustedSink:\n def take(self, x):\n return 1\n" + "def dispatch(flag):\n" + " if flag:\n o = TrustedSink()\n else:\n o = Plain()\n" + " o.take(1)\n" + ) + tree, entities, classes, aliases = _module(src, module="m") + project_fqns = frozenset(e.qualname for e in entities) + edges, resolved, unresolved, callees, implicit, candidates = build_call_edges( + entities=entities, + class_qualnames=classes, + alias_map=aliases, + module_prefix="m", + project_fqns=project_fqns, + ) + assert frozenset({"m.Plain.take", "m.TrustedSink.take"}) in set(candidates.values()) diff --git a/tests/unit/scanner/taint/test_decorator_provider.py b/tests/unit/scanner/taint/test_decorator_provider.py index 64f1c720..89cfcc5c 100644 --- a/tests/unit/scanner/taint/test_decorator_provider.py +++ b/tests/unit/scanner/taint/test_decorator_provider.py @@ -2,10 +2,12 @@ from __future__ import annotations import ast +import types from wardline.core.registry import REGISTRY_VERSION from wardline.core.taints import TaintState as T from wardline.scanner.ast_primitives import build_import_alias_map +from wardline.scanner.grammar import BoundaryType from wardline.scanner.index import discover_file_entities from wardline.scanner.taint.decorator_provider import DecoratorTaintSourceProvider from wardline.scanner.taint.provider import FunctionTaint, SeedContext @@ -28,6 +30,38 @@ def _seed( return {e.qualname: provider.taint_for(e, ctx).taint for e in entities} +def _custom_provider_fingerprint(seed: object) -> str: + boundary = BoundaryType("custom_boundary", "custom_pack", 1, (), seed) + return DecoratorTaintSourceProvider(boundary_types=(boundary,)).fingerprint() + + +def test_custom_seed_fingerprint_includes_referenced_global_names() -> None: + seed_a = eval("lambda levels: safe_seed") # noqa: S307 - local test fixture, no user input + seed_b = eval("lambda levels: raw_seed") # noqa: S307 - local test fixture, no user input + + assert seed_a.__code__.co_code == seed_b.__code__.co_code + assert seed_a.__code__.co_consts == seed_b.__code__.co_consts + assert _custom_provider_fingerprint(seed_a) != _custom_provider_fingerprint(seed_b) + + +def test_custom_seed_fingerprint_includes_referenced_global_values() -> None: + safe_seed = FunctionTaint(T.INTEGRAL, T.INTEGRAL) + raw_seed = FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) + + def template(levels): # noqa: ANN001, ANN202 + return SEED # noqa: F821 + + seed_a = types.FunctionType(template.__code__, {"SEED": safe_seed}, name="seed") + seed_b = types.FunctionType(template.__code__, {"SEED": raw_seed}, name="seed") + seed_a.__module__ = seed_b.__module__ = "custom_pack" + seed_a.__qualname__ = seed_b.__qualname__ = "seed" + + assert seed_a.__code__.co_code == seed_b.__code__.co_code + assert seed_a.__code__.co_consts == seed_b.__code__.co_consts + assert seed_a.__code__.co_names == seed_b.__code__.co_names + assert _custom_provider_fingerprint(seed_a) != _custom_provider_fingerprint(seed_b) + + def test_external_boundary_from_import() -> None: out = _seed("from wardline.decorators import external_boundary\n@external_boundary\ndef read(p):\n return p\n") assert out["m.read"] == FunctionTaint(T.EXTERNAL_RAW, T.EXTERNAL_RAW) @@ -127,6 +161,38 @@ def test_trusted_level_assured() -> None: assert out["m.f"] == FunctionTaint(T.ASSURED, T.ASSURED) +def test_trusted_level_tolerates_legacy_to_level_keyword() -> None: + out = _seed( + "from wardline.decorators import trusted\n" + "@trusted(level='ASSURED', to_level='ASSURED')\n" + "def f():\n" + " return 1\n" + ) + assert out["m.f"] == FunctionTaint(T.ASSURED, T.ASSURED) + + +def test_trusted_level_static_kwargs_assured() -> None: + out = _seed("from wardline.decorators import trusted\n@trusted(**{'level': 'ASSURED'})\ndef f():\n return 1\n") + assert out["m.f"] == FunctionTaint(T.ASSURED, T.ASSURED) + + +def test_trusted_dynamic_kwargs_is_no_opinion() -> None: + out = _seed( + "from wardline.decorators import trusted\nKW = {'level': 'ASSURED'}\n@trusted(**KW)\ndef f():\n return 1\n" + ) + assert out["m.f"] is None + + +def test_trusted_positional_arg_is_no_opinion() -> None: + out = _seed("from wardline.decorators import trusted\n@trusted('ASSURED')\ndef f():\n return 1\n") + assert out["m.f"] is None + + +def test_trusted_unexpected_keyword_is_no_opinion() -> None: + out = _seed("from wardline.decorators import trusted\n@trusted(other=1)\ndef f():\n return 1\n") + assert out["m.f"] is None + + def test_trusted_disallowed_level_is_no_opinion() -> None: out = _seed("from wardline.decorators import trusted\n@trusted(level='GUARDED')\ndef f():\n return 1\n") assert out["m.f"] is None @@ -241,7 +307,11 @@ def test_star_imported_trust_boundary_fires_end_to_end(tmp_path) -> None: ) result = run_scan(pkg) active = {f.rule_id for f in result.findings if f.suppressed.value == "active"} - assert "PY-WL-102" in active, "star-imported @trust_boundary was not seeded" + # The boundary-integrity family partitions (wardline-718048a518): the bare + # ``return p`` shape is PY-WL-119's domain, every other no-rejection shape is + # PY-WL-102's. Either member firing proves the star-imported decorator was + # seeded — the FN this test guards is the EMPTY intersection. + assert {"PY-WL-102", "PY-WL-119"} & active, "star-imported @trust_boundary was not seeded" # ── Coverage: fail-closed arms reached only by unusual / malformed decorator @@ -255,14 +325,14 @@ def test_subscript_decorator_is_no_opinion() -> None: assert out["m.f"] is None -def test_non_matching_keyword_is_skipped_before_level_arg() -> None: - # A decorator with an UNRELATED keyword before to_level: the kw loop must skip the - # non-matching keyword (118->117) and still read to_level correctly. +def test_unexpected_keyword_fails_closed_even_with_valid_level_arg() -> None: + # Builtin level-bearing decorators are keyword-only with a fixed signature. A + # malformed call must not be seeded from the valid-looking level beside it. out = _seed( "from wardline.decorators import trust_boundary\n" "@trust_boundary(other=1, to_level='ASSURED')\ndef v(x):\n return x\n" ) - assert out["m.v"] == FunctionTaint(T.EXTERNAL_RAW, T.ASSURED) + assert out["m.v"] is None def test_invalid_taintstate_token_is_no_opinion() -> None: diff --git a/tests/unit/scanner/taint/test_engine_precision.py b/tests/unit/scanner/taint/test_engine_precision.py new file mode 100644 index 00000000..6026718d --- /dev/null +++ b/tests/unit/scanner/taint/test_engine_precision.py @@ -0,0 +1,610 @@ +# tests/unit/scanner/taint/test_engine_precision.py +"""Engine-precision regression suite (2026-06-10 eval batch). + +Covers the confirmed taint-engine FNs plus the engine-precision expansion +ticket (wardline-93d608c997) and the typed-dispatch launder +(wardline-03c8805449): + +1. Container-conversion builtins (list/tuple/set/dict/sorted/frozenset/ + reversed) must propagate the worst argument taint, not launder to the + caller seed. +2. ``str.format_map`` is a sibling of ``str.format`` — receiver+args combine. +3. Non-literal nested-tuple unpack ``a, (b, c) = raw`` must taint b and c. +4. A try handler can observe ANY prefix of the try body (an exception may be + raised mid-body), so its seed is the worst of the pre-try snapshot and the + try-body states — not the pre-try snapshot alone. +5. A TYPED parameter seeded declared-raw (EXTERNAL_RAW/MIXED_RAW) must not be + laundered by its class method's clean @trusted summary. +6. Expansion: ``**raw`` spread into a lambda's ``**kw`` param; Subscript + unpack/for targets contaminate their base container; unresolved bare-name + calls propagate the worst of (caller seed, arg taints). +""" + +from __future__ import annotations + +import ast +import textwrap +from pathlib import Path + +import pytest + +from wardline.core.config import WardlineConfig +from wardline.core.finding import Kind +from wardline.core.taints import TaintState +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.taint.variable_level import ( + SELF_ATTRIBUTE_KEY, + attribute_write_recording, + compute_variable_taints, +) + +T = TaintState + +_RAW_TM = {"read_raw": T.UNKNOWN_RAW} + +_HEADER = ( + "import os, subprocess\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _vt( + src: str, + function_taint: TaintState = T.UNKNOWN_RAW, + taint_map: dict[str, TaintState] | None = None, + alias_map: dict[str, str] | None = None, + param_meets: dict[str, TaintState] | None = None, +) -> dict[str, TaintState]: + func = ast.parse(src).body[0] + assert isinstance(func, ast.FunctionDef | ast.AsyncFunctionDef) + return compute_variable_taints(func, function_taint, taint_map or {}, alias_map=alias_map, param_meets=param_meets) + + +def _defects(tmp_path: Path, src: str) -> list[tuple[str, int | None]]: + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + findings = WardlineAnalyzer().analyze([p], WardlineConfig(), root=tmp_path) + return [(f.rule_id, f.location.line_start) for f in findings if f.kind is Kind.DEFECT] + + +# ── (1) container-conversion builtins propagate argument taint ────────────── + + +@pytest.mark.parametrize("builtin", ["list", "tuple", "set", "frozenset", "dict", "sorted", "reversed"]) +def test_container_builtin_propagates_arg_taint(builtin: str) -> None: + out = _vt( + f"def f(p):\n x = {builtin}(read_raw(p))\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["x"] == T.UNKNOWN_RAW + + +@pytest.mark.parametrize("builtin", ["list", "tuple", "set", "frozenset", "dict", "sorted"]) +def test_container_builtin_no_args_is_integral(builtin: str) -> None: + out = _vt(f"def f():\n x = {builtin}()\n", function_taint=T.UNKNOWN_RAW) + assert out["x"] == T.INTEGRAL + + +def test_sorted_raw_argv_fires_subprocess_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + args = sorted(raw) + subprocess.run(args, shell=True) + """, + ) + assert ("PY-WL-112", 11) in rules + + +# ── (2) str.format_map combines receiver and mapping argument ─────────────── + + +def test_format_map_method_combines_receiver_and_args() -> None: + out = _vt( + "def f(p):\n x = 'echo {x}'.format_map(read_raw(p))\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["x"] == T.UNKNOWN_RAW + + +def test_format_map_validated_arg_stays_clean() -> None: + # FP guard: validated mapping in an ASSURED producer stays clean + # (least_trusted(INTEGRAL receiver, ASSURED) = ASSURED, no MIXED_RAW clash). + out = _vt( + "def f(p):\n x = '{x}'.format_map(validate(p))\n", + function_taint=T.ASSURED, + taint_map={"validate": T.ASSURED}, + ) + assert out["x"] == T.ASSURED + + +def test_format_map_raw_mapping_fires_command_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + raw = read_raw(p) + cmd = "echo {x}".format_map(raw) + os.system(cmd) + """, + ) + assert ("PY-WL-108", 11) in rules + + +# ── (3) non-literal nested-tuple unpack keeps RHS taint on every leaf ─────── + + +def test_nested_tuple_unpack_of_call_rhs_taints_leaves() -> None: + out = _vt( + "def f(p):\n a, (b, c) = read_raw(p)\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["a"] == T.UNKNOWN_RAW + assert out["b"] == T.UNKNOWN_RAW + assert out["c"] == T.UNKNOWN_RAW + + +def test_nested_tuple_unpack_leaf_reaches_exec_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + a, (b, c) = read_raw(p) + eval(b) + return 1 + """, + ) + assert ("PY-WL-107", 10) in rules + + +# ── (4) try handler sees the worst of any try-body prefix ─────────────────── + + +def test_handler_sees_try_body_assignment_post_state() -> None: + # x is reassigned raw in the try body BEFORE a possibly-raising call; the + # handler may observe it. Seeding the handler from dict(pre_try) alone + # dropped the raw value (fail-open under-taint). + out = _vt( + "def f(p):\n" + " x = 'safe'\n" + " try:\n" + " x = read_raw(p)\n" + " risky()\n" + " except ValueError:\n" + " y = x\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["y"] == T.UNKNOWN_RAW + + +def test_handler_sink_on_try_assigned_raw_fires(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + x = "safe" + try: + x = read_raw(p) + risky() + except ValueError: + eval(x) + return 1 + """, + ) + assert any(r == "PY-WL-107" for r, _line in rules) + + +def test_handler_sink_sees_mid_try_prefix_worst(tmp_path: Path) -> None: + # x is raw after the FIRST try statement and cleaned by the second; an + # exception between them still hands the handler the raw value, so the + # handler seed is the worst over every try-body prefix, not the post-state. + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + x = "safe" + try: + x = read_raw(p) + x = "clean" + risky() + except ValueError: + eval(x) + return 1 + """, + ) + assert any(r == "PY-WL-107" for r, _line in rules) + + +def test_post_try_clean_reassign_in_both_arms_stays_clean() -> None: + # FP guard: when the try body ends clean AND the handler reassigns clean, + # the post-merge state stays clean (handler seeding must not leak into the + # merge for a var both arms finished clean). + out = _vt( + "def f(p):\n try:\n x = a()\n except Exception:\n x = b()\n", + function_taint=T.INTEGRAL, + taint_map={"a": T.ASSURED, "b": T.GUARDED}, + ) + assert out["x"] == T.GUARDED + + +# ── (5) declared-raw typed receiver is not laundered by a clean summary ───── + + +def test_declared_raw_typed_param_not_laundered_by_clean_summary() -> None: + out = _vt( + "def f(h: mymod.Schema):\n x = h.validate()\n", + function_taint=T.ASSURED, + taint_map={"mymod.Schema.validate": T.ASSURED}, + alias_map={"mymod": "mymod"}, + param_meets={"h": T.EXTERNAL_RAW}, + ) + assert out["x"] == T.EXTERNAL_RAW + + +def test_unknown_raw_typed_receiver_still_resolves_clean_summary() -> None: + # FP guard (wardline-f6a29ce23a): an unmodeled ``Type()`` constructor seeds + # the receiver UNKNOWN_RAW; the typed dispatch must STILL resolve the clean + # summary — only DECLARED-raw (EXTERNAL_RAW/MIXED_RAW) receivers are gated. + out = _vt( + "def f(obj: mymod.Schema):\n x = obj.validate()\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"mymod.Schema.validate": T.ASSURED}, + alias_map={"mymod": "mymod"}, + ) + assert out["x"] == T.ASSURED + + +def test_typed_param_seeded_raw_interprocedurally_fires_sink(tmp_path: Path) -> None: + # Mirror of the wardline-03c8805449 var_typed repro: only the annotation + # differs from the untyped control, which already fires. + src = """ + class Helper: + @trusted(level='ASSURED') + def get_cmd(self): + return "noop()" + + @trusted(level='ASSURED') + def f(h{ann}): + eval(h.get_cmd()) + + @trusted(level='ASSURED') + def caller(p): + f(read_raw(p)) + """ + untyped = _defects(tmp_path, src.format(ann="")) + assert any(r == "PY-WL-107" for r, _line in untyped) # control + typed = _defects(tmp_path, src.format(ann=": Helper")) + assert any(r == "PY-WL-107" for r, _line in typed) + + +# ── (6a) **spread taint reaches a lambda's **kw param at the call site ─────── + + +def test_double_star_spread_seeds_lambda_kwarg_param(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + raw_kwargs = read_raw(p) + (lambda **kw: os.system(kw.get('cmd', '')))(**raw_kwargs) + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +def test_double_star_spread_seeds_lambda_named_param(tmp_path: Path) -> None: + # A **spread can also bind a NAMED lambda parameter by key. + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + raw_kwargs = read_raw(p) + (lambda cmd='': os.system(cmd))(**raw_kwargs) + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +# ── (6b) Subscript unpack / for targets contaminate the base container ────── + + +def test_unpack_subscript_target_contaminates_base_literal_rhs() -> None: + out = _vt( + "def f(p):\n d = {}\n d['cmd'], y = read_raw(p), 1\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["d"] == T.UNKNOWN_RAW + + +def test_unpack_subscript_target_contaminates_base_call_rhs() -> None: + out = _vt( + "def f(p):\n d = {}\n d['cmd'], y = read_raw(p)\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["d"] == T.UNKNOWN_RAW + assert out["y"] == T.UNKNOWN_RAW + + +def test_for_subscript_target_contaminates_base() -> None: + out = _vt( + "def f(p):\n d = {}\n for d['k'] in [read_raw(p)]:\n pass\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + ) + assert out["d"] == T.UNKNOWN_RAW + + +def test_for_subscript_target_reaches_command_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + d = {} + raw = read_raw(p) + for d['cmd'] in [raw]: + pass + os.system(d['cmd']) + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +# ── (6c) unresolved bare-name calls propagate worst(seed, args) ────────────── + + +def test_unresolved_bare_call_propagates_worst_arg_taint() -> None: + # ``transform`` is absent from the taint_map (a bare parameter / runtime + # callable): an unknown callee cannot be assumed to clean its raw argument, + # matching the imported-unmodeled path's conservatism. + out = _vt( + "def f(p, transform):\n x = transform(read_raw(p))\n", + function_taint=T.GUARDED, + taint_map=_RAW_TM, + ) + assert out["x"] == T.UNKNOWN_RAW + + +def test_unresolved_bare_call_clean_args_keeps_function_taint() -> None: + # FP guard: clean arguments do not demote — the result stays at the worst + # of the caller seed and the args (here the seed). + out = _vt( + "def f(p, transform):\n x = transform('const')\n", + function_taint=T.GUARDED, + taint_map=_RAW_TM, + ) + assert out["x"] == T.GUARDED + + +def test_measuring_builtins_still_fall_back_to_function_taint() -> None: + # len/int validate/measure, they do not carry the data through — the + # curated non-propagating carve-out keeps them at the caller seed. + assert _vt("def f(p):\n x = len(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED + assert _vt("def f(p):\n x = int(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED + + +def test_bare_param_transform_call_fires_command_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p, transform): + raw = read_raw(p) + os.system(transform(raw)) + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +# ── (7) io.StringIO/io.BytesIO constructors carry the WORST ARG taint ──────── + + +@pytest.mark.parametrize("ctor", ["io.StringIO", "io.BytesIO"]) +def test_io_buffer_ctor_const_arg_is_integral(ctor: str) -> None: + # An in-memory buffer over a constant is NOT external data — the ctor + # result carries the worst argument taint, not the unresolved-import + # UNKNOWN_RAW default. + out = _vt( + f"def f():\n x = {ctor}('const')\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + alias_map={"io": "io"}, + ) + assert out["x"] == T.INTEGRAL + + +@pytest.mark.parametrize("ctor", ["io.StringIO", "io.BytesIO"]) +def test_io_buffer_ctor_raw_arg_stays_raw(ctor: str) -> None: + out = _vt( + f"def f(p):\n x = {ctor}(read_raw(p))\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + alias_map={"io": "io"}, + ) + assert out["x"] == T.UNKNOWN_RAW + + +def test_io_buffer_ctor_no_args_is_integral() -> None: + out = _vt( + "def f():\n x = io.StringIO()\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + alias_map={"io": "io"}, + ) + assert out["x"] == T.INTEGRAL + + +def test_io_buffer_from_import_alias_resolves() -> None: + # ``from io import StringIO`` resolves through the alias map to the same + # canonical FQN — the bare-name form must not regress to UNKNOWN_RAW. + out = _vt( + "def f():\n x = StringIO('const')\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + alias_map={"StringIO": "io.StringIO"}, + ) + assert out["x"] == T.INTEGRAL + + +def test_io_buffer_shadowed_by_raw_local_not_laundered() -> None: + # A raw local shadowing the ``io`` module name must not inherit the + # clean worst-arg ctor model (the wardline-f6a29ce23a shadow guard). + out = _vt( + "def f(p):\n io = read_raw(p)\n x = io.StringIO('const')\n", + function_taint=T.INTEGRAL, + taint_map=_RAW_TM, + alias_map={"io": "io"}, + ) + assert out["x"] == T.UNKNOWN_RAW + + +def test_stringio_const_read_no_longer_fires_return_rule(tmp_path: Path) -> None: + # End-to-end: returning the .read() of a CLEAN in-memory buffer is not a + # raw return — the PY-WL-101 FP this residual tracked. + rules = _defects( + tmp_path, + """ + import io + @trusted(level='ASSURED') + def render(): + buf = io.StringIO("constant template") + return buf.read() + """, + ) + assert not any(r == "PY-WL-101" for r, _line in rules) + + +def test_stringio_raw_read_still_fires_return_rule(tmp_path: Path) -> None: + # Soundness control: raw content poured into the buffer still propagates + # through .read() and fires the raw-return rule. + rules = _defects( + tmp_path, + """ + import io + @trusted(level='ASSURED') + def render(p): + buf = io.StringIO(read_raw(p)) + return buf.read() + """, + ) + assert any(r == "PY-WL-101" for r, _line in rules) + + +# ── (8) tuple-unpack attribute targets record into the attr-write channel ──── + + +def test_tuple_unpack_attribute_target_records_self_write() -> None: + func = ast.parse("def put(self, p):\n self.x, y = read_raw(p), 1\n").body[0] + assert isinstance(func, ast.FunctionDef) + out: dict[str, dict[str, TaintState]] = {} + with attribute_write_recording(out): + compute_variable_taints(func, T.INTEGRAL, dict(_RAW_TM), alias_map={}) + assert out[SELF_ATTRIBUTE_KEY]["x"] == T.UNKNOWN_RAW + + +def test_tuple_unpack_attribute_pair_from_call_records_both() -> None: + # ``(self.x, self.y) = pair`` with a non-literal RHS: both elements get + # the RHS taint and BOTH record into the channel. + func = ast.parse("def put(self, p):\n (self.x, self.y) = read_raw(p)\n").body[0] + assert isinstance(func, ast.FunctionDef) + out: dict[str, dict[str, TaintState]] = {} + with attribute_write_recording(out): + compute_variable_taints(func, T.INTEGRAL, dict(_RAW_TM), alias_map={}) + assert out[SELF_ATTRIBUTE_KEY]["x"] == T.UNKNOWN_RAW + assert out[SELF_ATTRIBUTE_KEY]["y"] == T.UNKNOWN_RAW + + +def test_tuple_unpack_attribute_write_fires_cross_method_sink(tmp_path: Path) -> None: + # End-to-end: the unpack-written attribute feeds the cross-method summary + # exactly like the plain ``self.x = raw`` path (which already fires). + rules = _defects( + tmp_path, + """ + class Store: + def put(self, p): + self.x, y = read_raw(p), 1 + @trusted(level='ASSURED') + def use(self): + os.system(self.x) + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +# ── (9) lambda DEFAULT taint seeds the param when omitted at the call site ─── + + +def test_lambda_raw_default_omitted_at_call_fires_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + raw = read_raw(p) + cb = lambda x=raw: os.system(x) + cb() + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +def test_lambda_raw_kwonly_default_omitted_at_call_fires_sink(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + raw = read_raw(p) + cb = lambda *, x=raw: os.system(x) + cb() + """, + ) + assert any(r == "PY-WL-108" for r, _line in rules) + + +def test_lambda_raw_default_overridden_clean_does_not_fire(tmp_path: Path) -> None: + # FP guard: a clean argument supplied at the call site replaces the raw + # default — the default expression never evaluates on this path. + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + raw = read_raw(p) + cb = lambda x=raw: os.system(x) + cb("echo ok") + """, + ) + assert not any(r == "PY-WL-108" for r, _line in rules) + + +def test_lambda_raw_default_overridden_by_keyword_does_not_fire(tmp_path: Path) -> None: + rules = _defects( + tmp_path, + """ + @trusted(level='ASSURED') + def process(p): + raw = read_raw(p) + cb = lambda x=raw: os.system(x) + cb(x="echo ok") + """, + ) + assert not any(r == "PY-WL-108" for r, _line in rules) diff --git a/tests/unit/scanner/taint/test_loop_lambda_zero_trip.py b/tests/unit/scanner/taint/test_loop_lambda_zero_trip.py new file mode 100644 index 00000000..055800fa --- /dev/null +++ b/tests/unit/scanner/taint/test_loop_lambda_zero_trip.py @@ -0,0 +1,96 @@ +"""Zero-trip loop preserves a pre-loop lambda binding (wardline-d6af917bde). + +A loop body is a CONDITIONALLY-executed arm: a reachable 0-iteration path means the +post-loop binding for a name MAY still be its pre-loop value. ``_handle_for`` / +``_handle_while`` walk the body in place (sound for loop-carried in-body resolution) +but, before this fix, dropped a pre-loop sink-lambda when the body rebound the name to a +clean lambda — the zero-trip path's candidate vanished and a post-loop call through the +name missed the sink (FN). The fix unions the pre-loop bindings (the "loop did not run" +arm) back after the fixpoint, exactly like a no-``else`` ``if``. +""" + +from __future__ import annotations + +import ast + +from wardline.core.taints import TaintState +from wardline.scanner.taint.variable_level import compute_variable_taints + +T = TaintState + + +def _lambda_body_sink_arg(src: str) -> TaintState: + """Run the variable-taint pass over *src* and return the taint recorded for the + lambda body's ``sink(c)`` argument (mirrors the helper in test_variable_level.py).""" + func = ast.parse(src).body[0] + assert isinstance(func, ast.FunctionDef) + csat: dict[int, dict[int | str | None, TaintState]] = {} + compute_variable_taints( + func, + T.INTEGRAL, + {}, + call_site_taints={}, + alias_map={}, + call_site_arg_taints=csat, + param_meets={"raw": T.EXTERNAL_RAW}, + ) + sink_call = next( + n for n in ast.walk(func) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "sink" + ) + return csat[id(sink_call)][0] + + +def test_for_zero_trip_preserves_preloop_sink_lambda() -> None: + # ``cb`` is the SINK lambda before the loop; the body rebinds it to a clean lambda. On + # the zero-trip path (``items`` empty) ``cb`` is STILL the sink lambda when ``cb(raw)`` + # runs after the loop, so the body's ``sink(c)`` arg MUST stay EXTERNAL_RAW. The FN: + # the body's clean rebind replaced the candidate set and the loop never unioned the + # pre-loop binding back (wardline-d6af917bde). + src = ( + "def handler(raw, items):\n" + " cb = lambda c: sink(c)\n" + " for it in items:\n" + " cb = lambda c: c\n" + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_while_zero_trip_preserves_preloop_sink_lambda() -> None: + # Same zero-trip FN for ``while`` (the test never executes the body). + src = ( + "def handler(raw, flag):\n cb = lambda c: sink(c)\n while flag:\n cb = lambda c: c\n cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_clean_loop_does_not_overfire() -> None: + # FP guard: the pre-loop-union must NOT fabricate taint for clean loops. ``safe`` is a + # sink-lambda bound to a SEPARATE name that never receives ``raw``; the looped name + # ``cb`` is clean in both the pre-loop and body arms. The union must neither leak + # ``cb``'s raw arg into ``safe`` nor invent a sink candidate for ``cb`` — the only + # recording for ``sink(c)`` is the floor pass's neutral one, so it stays INTEGRAL. + src = ( + "def handler(raw, items):\n" + " safe = lambda c: sink(c)\n" + " cb = lambda c: c\n" + " for it in items:\n" + " cb = lambda c: c\n" + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.INTEGRAL + + +def test_loop_carried_in_body_lambda_still_resolves() -> None: + # Non-vacuity / no-regression guard for in-body resolution: ``cb(raw)`` runs BEFORE the + # in-body rebind to the sink lambda, so on iteration n it MAY hold iteration (n-1)'s + # sink candidate. The in-place body walk inside the fixpoint must keep resolving it — + # the pre-loop-union fix sits AFTER the fixpoint and must not weaken this. EXTERNAL_RAW. + src = ( + "def handler(raw, items):\n" + " cb = lambda c: c\n" + " for it in items:\n" + " cb(raw)\n" + " cb = lambda c: sink(c)\n" + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW diff --git a/tests/unit/scanner/taint/test_module_global_taint.py b/tests/unit/scanner/taint/test_module_global_taint.py new file mode 100644 index 00000000..b6cb0ac3 --- /dev/null +++ b/tests/unit/scanner/taint/test_module_global_taint.py @@ -0,0 +1,170 @@ +"""Module-global taint channel (wardline-66b2c91470). + +Read direction: a module-level variable assigned from a raw source at import +time (``RAW = read_raw(...)`` where ``read_raw`` is an ``@external_boundary`` +function, or a call matching ``config.untrusted_sources``) carries its taint +into every function that reads it — the L2 walk seeds the global like an +implicit parameter, so a local reassignment shadows it flow-sensitively. + +Write direction: a function assigning raw to a declared ``global g`` marks the +module global, and OTHER functions reading ``g`` inherit (one merge hop — +see the analyzer's documented approximation). +""" + +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING + +from wardline.core.config import WardlineConfig +from wardline.core.taints import RAW_ZONE +from wardline.scanner.analyzer import WardlineAnalyzer + +if TYPE_CHECKING: + from pathlib import Path + +_HEADER = ( + "import os\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _scan(tmp_path: Path, src: str, config: WardlineConfig | None = None, header: str = _HEADER): + p = tmp_path / "m.py" + p.write_text(header + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], config or WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return findings, analyzer.last_context + + +def _hits(findings, rule_id: str) -> list[tuple[str, str | None]]: + return [(f.rule_id, f.qualname) for f in findings if f.rule_id == rule_id] + + +def test_module_level_raw_assignment_taints_reader(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + RAW = read_raw('seed') + + @trusted(level='ASSURED') + def f(): + os.system(RAW) + """, + ) + assert _hits(findings, "PY-WL-108") == [("PY-WL-108", "m.f")] + + +def test_module_level_raw_via_config_untrusted_sources(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + RAW = fetchlib.fetch() + + @trusted(level='ASSURED') + def f(): + os.system(RAW) + """, + config=WardlineConfig(untrusted_sources=("fetchlib.fetch",)), + header=("import os\nimport fetchlib\nfrom wardline.decorators import external_boundary, trusted\n"), + ) + assert _hits(findings, "PY-WL-108") == [("PY-WL-108", "m.f")] + + +def test_local_reassignment_shadows_module_global(tmp_path) -> None: + # Flow-sensitive shadowing: the function overwrites the global name with a + # literal BEFORE the sink — no finding. + findings, _ = _scan( + tmp_path, + """ + RAW = read_raw('seed') + + @trusted(level='ASSURED') + def f(): + RAW = 'ls -la' + os.system(RAW) + """, + ) + assert _hits(findings, "PY-WL-108") == [] + + +def test_clean_module_global_stays_clean(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + CMD = 'ls -la' + + @trusted(level='ASSURED') + def f(): + os.system(CMD) + """, + ) + assert _hits(findings, "PY-WL-108") == [] + + +def test_module_level_rebind_to_safe_clears_seed(tmp_path) -> None: + # Last-binding-wins at module scope (same discipline as collect_sink_bindings). + findings, _ = _scan( + tmp_path, + """ + RAW = read_raw('seed') + RAW = 'ls -la' + + @trusted(level='ASSURED') + def f(): + os.system(RAW) + """, + ) + assert _hits(findings, "PY-WL-108") == [] + + +def test_global_write_propagates_to_other_readers(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + G = 'init' + + def poison(p): + global G + G = read_raw(p) + + @trusted(level='ASSURED') + def use(): + os.system(G) + """, + ) + assert _hits(findings, "PY-WL-108") == [("PY-WL-108", "m.use")] + + +def test_global_clean_write_does_not_poison(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + def set_default(): + global G + G = 'ls -la' + + @trusted(level='ASSURED') + def use(): + os.system(G) + """, + ) + assert _hits(findings, "PY-WL-108") == [] + + +def test_raw_module_global_propagates_to_return_taint(tmp_path) -> None: + _, ctx = _scan( + tmp_path, + """ + RAW = read_raw('seed') + + @trusted(level='ASSURED') + def f(): + return RAW + """, + ) + assert ctx.function_return_taints["m.f"] in RAW_ZONE diff --git a/tests/unit/scanner/taint/test_module_summariser.py b/tests/unit/scanner/taint/test_module_summariser.py index a22a0031..b86b5fd8 100644 --- a/tests/unit/scanner/taint/test_module_summariser.py +++ b/tests/unit/scanner/taint/test_module_summariser.py @@ -22,6 +22,7 @@ def test_summaries_map_provider_seed_to_anchored() -> None: source_bytes=b"x\n", resolver_version="sp1d", provider_fingerprint="default-v1", + scan_policy_hash="sha256:policy-a", ) by_fqn = {s.fqn: s for s in summaries} assert by_fqn["m.a"].taint_source == "anchored" @@ -43,6 +44,7 @@ def test_all_summaries_in_module_share_cache_key() -> None: source_bytes=b"x\n", resolver_version="sp1d", provider_fingerprint="default-v1", + scan_policy_hash="sha256:policy-a", ) keys = {s.cache_key for s in summaries} assert len(keys) == 1 # cache_key is module-granular @@ -57,6 +59,7 @@ def test_missing_unresolved_count_defaults_zero() -> None: source_bytes=b"x\n", resolver_version="sp1d", provider_fingerprint="default-v1", + scan_policy_hash="sha256:policy-a", ) assert summaries[0].unresolved_calls == 0 @@ -70,6 +73,7 @@ def test_identical_source_distinct_modules_get_distinct_keys() -> None: source_bytes=b"def f(): pass\n", resolver_version="sp1d", provider_fingerprint="default-v1", + scan_policy_hash="sha256:policy-a", ) key_a = summarise_module(module_path="a", seeds=seeds_a, **common)[0].cache_key key_b = summarise_module(module_path="b", seeds=seeds_b, **common)[0].cache_key diff --git a/tests/unit/scanner/taint/test_project_resolver.py b/tests/unit/scanner/taint/test_project_resolver.py index b06e1de8..fc07ffe2 100644 --- a/tests/unit/scanner/taint/test_project_resolver.py +++ b/tests/unit/scanner/taint/test_project_resolver.py @@ -8,7 +8,7 @@ from wardline.core.taints import TaintState as T from wardline.scanner.ast_primitives import build_import_alias_map from wardline.scanner.index import discover_class_qualnames, discover_file_entities -from wardline.scanner.taint.function_level import seed_function_taints +from wardline.scanner.taint.function_level import FunctionSeed, seed_function_taints from wardline.scanner.taint.project_resolver import _RESOLVER_VERSION, ModuleInput, resolve_project_taints from wardline.scanner.taint.provider import ( DefaultTaintSourceProvider, @@ -59,6 +59,28 @@ def _module_input(module: str, src: str, provider) -> ModuleInput: ) +def _module_input_with_seed(module: str, path: str, src: str, *, body_taint: T, return_taint: T) -> ModuleInput: + tree = ast.parse(src) + entities = tuple(discover_file_entities(tree, module=module, path=path)) + seeds = { + entity.qualname: FunctionSeed( + qualname=entity.qualname, + body_taint=body_taint, + return_taint=return_taint, + source="provider", + ) + for entity in entities + } + return ModuleInput( + module_path=module, + entities=entities, + class_qualnames=discover_class_qualnames(tree, module=module), + alias_map=build_import_alias_map(tree, module_path=module), + seeds=seeds, + source_bytes=src.encode("utf-8"), + ) + + def test_transitive_raw_flows_across_modules_and_self_method() -> None: provider = _RawLeafProvider() inputs = [ @@ -266,6 +288,38 @@ def fingerprint(self) -> str: assert result.return_taint_map["m.plain"] == result.taint_map["m.plain"] +def test_duplicate_project_fqns_emit_diagnostic_and_do_not_overwrite_taints() -> None: + modules = [ + _module_input_with_seed( + "pkg.foo", + "pkg/foo.py", + "def f(p):\n return p\n", + body_taint=T.EXTERNAL_RAW, + return_taint=T.EXTERNAL_RAW, + ), + _module_input_with_seed( + "pkg.foo", + "src/pkg/foo.py", + "def f(p):\n return 'safe'\n", + body_taint=T.INTEGRAL, + return_taint=T.INTEGRAL, + ), + ] + + result = resolve_project_taints(modules=modules, provider_fingerprint="dup-test-v1") + + assert result.diagnostics == ( + ( + "DUPLICATE_FQN", + "Duplicate function qualname 'pkg.foo.f' across 2 entities: " + "pkg.foo (pkg/foo.py:1), pkg.foo (src/pkg/foo.py:1); project taint summaries are keyed by " + "qualname and cannot disambiguate these definitions", + ), + ) + assert result.taint_map["pkg.foo.f"] == T.EXTERNAL_RAW + assert result.return_taint_map["pkg.foo.f"] == T.EXTERNAL_RAW + + def test_cache_miss_on_changed_source_recomputes() -> None: # A module whose source changes gets a different cache_key -> miss -> # fresh summary, even if the caller forgets to mark it dirty. @@ -302,12 +356,17 @@ def test_cache_miss_on_changed_source_recomputes() -> None: def _io_layer_cache_key(provider: _RawLeafProvider) -> str: + from wardline.core.config import WardlineConfig + from wardline.core.ruleset import ruleset_hash + return compute_cache_key( module_path="pkg.io_layer", source_bytes=_IO.encode("utf-8"), schema_version=SUMMARY_SCHEMA_VERSION, resolver_version=_RESOLVER_VERSION, provider_fingerprint=provider.fingerprint(), + # Match the resolver's default (config is None here → default policy). + scan_policy_hash=ruleset_hash(WardlineConfig()), ) diff --git a/tests/unit/scanner/taint/test_propagation.py b/tests/unit/scanner/taint/test_propagation.py index 0da6ae19..ac3681b5 100644 --- a/tests/unit/scanner/taint/test_propagation.py +++ b/tests/unit/scanner/taint/test_propagation.py @@ -92,8 +92,15 @@ def test_two_anchored_callees_aggregate_via_least_trusted() -> None: edges = {"A": {"B", "C"}, "B": set(), "C": set()} tm = {"A": T.UNKNOWN_RAW, "B": T.GUARDED, "C": T.EXTERNAL_RAW} src = {"A": "fallback", "B": "anchored", "C": "anchored"} - refined, _prov, _diags, _it = _run(edges, tm, src) - assert refined["A"] != T.MIXED_RAW + refined, _prov, diags, _it = _run(edges, tm, src) + # Exact value (wardline-e159060db7): the fallback floor must HOLD A at + # UNKNOWN_RAW — `!= MIXED_RAW` also passed under a floor-drop mutation + # (combined = EXTERNAL_RAW satisfies it). And the floor must hold at the + # combination site itself, not via the monotonicity commit backstop: under + # the floor-drop mutation the value survives only because the guard rejects + # the move and emits L3_MONOTONICITY_VIOLATION — so no diagnostics allowed. + assert refined["A"] == T.UNKNOWN_RAW + assert diags == [] def test_clean_different_family_callees_stay_clean() -> None: @@ -165,8 +172,30 @@ def test_fallback_caller_clean_callees_floor_holds_not_mixed() -> None: edges = {"A": {"B", "C"}, "B": set(), "C": set()} tm = {"A": T.UNKNOWN_RAW, "B": T.ASSURED, "C": T.INTEGRAL} src = {"A": "fallback", "B": "anchored", "C": "anchored"} - refined, _prov, _diags, _it = _run(edges, tm, src) + refined, _prov, diags, _it = _run(edges, tm, src) assert refined["A"] == T.UNKNOWN_RAW # floor, NOT MIXED_RAW + # The floor must hold at the combination site, not via the monotonicity + # commit guard (which would emit L3_MONOTONICITY_VIOLATION while preserving + # the value) — see test_two_anchored_callees_aggregate_via_least_trusted. + assert diags == [] + + +def test_scc_round_floor_holds_inside_cycle_without_phase1_seed() -> None: + # Exercises the `_compute_scc_round` floor in ISOLATION from Phase-1 + # initialization (wardline-e159060db7): A and B form a cycle, so B is inside + # A's SCC and Phase 1's external-callee pass never touches A — the Phase-2 + # floor (current[A] = UNKNOWN_RAW) is the ONLY thing pinning A against its + # anchored-INTEGRAL cycle partner. Mutation-probed: with the floor dropped + # (`new_taint = combined`) the round proposes INTEGRAL for A and only the + # monotonicity commit guard saves the VALUE while emitting + # L3_MONOTONICITY_VIOLATION — hence both assertions are load-bearing. + edges = {"A": {"B"}, "B": {"A"}} + tm = {"A": T.UNKNOWN_RAW, "B": T.INTEGRAL} + src = {"A": "fallback", "B": "anchored"} + refined, _prov, diags, _it = _run(edges, tm, src) + assert refined["A"] == T.UNKNOWN_RAW # floor holds — fallback can't prove trust + assert refined["B"] == T.INTEGRAL # anchored partner unaffected + assert diags == [] def test_long_chain_converges_without_bound_diagnostic() -> None: diff --git a/tests/unit/scanner/taint/test_property_chokepoint.py b/tests/unit/scanner/taint/test_property_chokepoint.py new file mode 100644 index 00000000..e20f6e0b --- /dev/null +++ b/tests/unit/scanner/taint/test_property_chokepoint.py @@ -0,0 +1,441 @@ +# tests/unit/scanner/taint/test_property_chokepoint.py +"""Property/differential harness around the L2 taint chokepoint (wardline-369f54b83b). + +Every L2 statement handler routes through the shared recursive core +``_resolve_expr`` / ``_resolve_call`` in ``scanner/taint/variable_level.py`` — +the engine's historical soundness-bug magnet (fail-open launder, stale +``var_types``, lambda single-slot FN, branch-locality FN, raw-receiver bypass). +This module pins four PROPERTIES over generated program corpora, so a future +engine edit that re-opens any of those bug families trips a property here +instead of shipping as a silent FN: + +1. **Lattice monotonicity** (``TestLatticeMonotonicity``): a program whose one + EXTERNAL_RAW input syntactically flows to ``return`` through propagating + constructs never resolves a return taint outside the engine's ``RAW_ZONE`` + (no clean-direction launder). +2. **Idempotence** (``TestIdempotence``): analyzing the same file twice — a + fresh analyzer each time, and the same analyzer re-run — yields + byte-identical findings streams. +3. **Monotonic seeds** (``TestMonotonicSeeds``): weakening an input seed + (trusted literal -> EXTERNAL_RAW boundary read) never REMOVES a + taint-driven finding. +4. **Receiver-guard invariant** (``TestReceiverGuardInvariant``): a typed + receiver whose value is DECLARED raw (EXTERNAL_RAW / MIXED_RAW) never + resolves a trusted method-call result through its clean ``Type.method`` + summary (pins wardline-03c8805449 as a property). + +The corpus generators are deterministic (``random.Random(seed)`` — Mersenne +Twister, stable across CPython versions) and hand-rolled rather than +hypothesis-driven: a frozen, replayable corpus keeps CI runtime flat and makes +a property failure a stable repro (re-run the seed) instead of a shrink hunt. +The grammar deliberately contains NO sanctioning construct (no decorated/ +``taint_map``-mapped callee, no ``_CONTEXT_ENCODERS`` member, no +``_NON_PROPAGATING_BUILTINS`` validator), so every generated flow is one the +lattice must keep raw — templates that legitimately clean are excluded from +the property set by construction, per the ticket. + +Contextvar coupling notes for the handlers under test live in +``CHOKEPOINT_NOTES.md`` (same directory). +""" + +from __future__ import annotations + +import ast +import random +from typing import TYPE_CHECKING + +from wardline.core.config import WardlineConfig +from wardline.core.taints import RAW_ZONE, TRUST_RANK, TaintState +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.taint.variable_level import compute_return_taint, compute_variable_taints + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + + from wardline.core.finding import Finding + +T = TaintState + +# ── Deterministic program generator ────────────────────────────────────────── +# +# A grammar of statement templates, each a taint-PROPAGATING construct: given +# the current tainted variable ``cur`` it emits statements that flow the taint +# into a fresh variable ``nxt``. Chains of 1–4 templates compose into a small +# function whose single EXTERNAL_RAW input (``src``) syntactically reaches the +# final ``return``. Templates cover the ``_resolve_expr`` dispatch surface +# (Name/BinOp/JoinedStr/IfExp/containers/Subscript/BoolOp/NamedExpr), the +# ``_resolve_call`` curated ops (propagating builtins, ``.join``, ``.get``, +# raw-receiver method calls), and the statement-layer merges (if/else, for, +# try/except, match, unpacking, augmented assignment). + +_Template = tuple[str, "Callable[[str, str], list[str]]"] + +_TEMPLATES: tuple[_Template, ...] = ( + ("alias", lambda c, n: [f"{n} = {c}"]), + ("binop", lambda c, n: [f'{n} = {c} + "lit"']), + ("fstring", lambda c, n: [f'{n} = f"v={{{c}}}!"']), + ("ifexp", lambda c, n: [f'{n} = "d" if flag else {c}']), + ("list_subscript", lambda c, n: [f'{n} = [{c}, "a"][0]']), + ("dict_get", lambda c, n: [f'_t_{n} = {{"k": {c}}}', f'{n} = _t_{n}.get("k", "d")']), + ("if_else_merge", lambda c, n: ["if flag:", f" {n} = {c}", "else:", f' {n} = "d"']), + ("augassign", lambda c, n: [f'{n} = ""', f"{n} += {c}"]), + ("tuple_unpack", lambda c, n: [f'{n}, _u_{n} = {c}, "d"']), + ("str_builtin", lambda c, n: [f"{n} = str({c})"]), + ("join_method", lambda c, n: [f'{n} = ",".join([{c}])']), + ("raw_receiver_method", lambda c, n: [f"{n} = {c}.strip()"]), + ("boolop", lambda c, n: [f'{n} = {c} or "d"']), + ("walrus", lambda c, n: [f"_w_{n} = ({n} := {c})"]), + ("for_loop", lambda c, n: [f"for _i_{n} in [{c}]:", f" {n} = _i_{n}"]), + ("try_except", lambda c, n: ["try:", f" {n} = {c}", "except Exception:", f" {n} = {c}"]), + ("match_capture", lambda c, n: [f"match {c}:", f" case _v_{n}:", f" {n} = _v_{n}"]), + ("starred_unpack", lambda c, n: [f'_a_{n}, *{n} = "d", {c}']), +) + +_P1_CASES = 200 + + +def _generate_chain(seed: int) -> tuple[str, list[str]]: + """One generated program: ``def f(src, flag)`` + 1–4 chained templates + + ``return ``. Returns ``(source, template_names_used)``.""" + rng = random.Random(seed) + n_stmts = rng.randint(1, 4) + lines = ["def f(src, flag):"] + cur = "src" + used: list[str] = [] + for i in range(n_stmts): + name, template = rng.choice(_TEMPLATES) + used.append(name) + nxt = f"v{i}" + lines.extend(" " + line for line in template(cur, nxt)) + cur = nxt + lines.append(f" return {cur}") + return "\n".join(lines) + "\n", used + + +def _resolve_program(src: str, *, param_meets: dict[str, TaintState], taint_map: dict[str, TaintState]) -> TaintState: + """Run a generated program through the chokepoint and return its return taint. + + ``function_taint=GUARDED`` is the neutral trusted-zone seed: every fallback + path (unknown name, unmodelled expression) lands at rank 2, so the ONLY way + a result reaches ``RAW_ZONE`` is genuine propagation of the raw seed — and + the only way a raw flow LEAVES ``RAW_ZONE`` is a launder, which is exactly + what the property must catch. + """ + func = ast.parse(src).body[0] + assert isinstance(func, ast.FunctionDef | ast.AsyncFunctionDef) + var_taints = compute_variable_taints(func, T.GUARDED, dict(taint_map), param_meets=param_meets) + result = compute_return_taint(func, T.GUARDED, dict(taint_map), var_taints) + assert result is not None, f"generated program must have a value-bearing return:\n{src}" + return result + + +class TestGeneratorMeta: + """The corpus itself is part of the contract: deterministic and covering.""" + + def test_corpus_is_deterministic(self) -> None: + first = [_generate_chain(seed)[0] for seed in range(_P1_CASES)] + second = [_generate_chain(seed)[0] for seed in range(_P1_CASES)] + assert first == second + + def test_corpus_parses_and_exercises_every_template(self) -> None: + seen: set[str] = set() + for seed in range(_P1_CASES): + src, used = _generate_chain(seed) + ast.parse(src) # every generated program is valid Python + seen.update(used) + missing = {name for name, _ in _TEMPLATES} - seen + assert not missing, f"templates never exercised by the corpus: {sorted(missing)}" + + +class TestLatticeMonotonicity: + """Property 1 — no clean-direction launder through the chokepoint. + + One input (``src``) is seeded EXTERNAL_RAW and flows syntactically to the + return through propagating constructs only; the resolved return taint must + stay in the engine's RAW_ZONE for every program in the corpus. + """ + + def test_raw_seed_never_resolves_to_trusted_zone(self) -> None: + failures: list[str] = [] + for seed in range(_P1_CASES): + src, used = _generate_chain(seed) + result = _resolve_program(src, param_meets={"src": T.EXTERNAL_RAW}, taint_map={}) + if result not in RAW_ZONE: + failures.append(f"seed={seed} return={result} (rank {TRUST_RANK[result]}) templates={used}\n{src}") + assert not failures, "raw input laundered to the trusted zone:\n\n" + "\n".join(failures) + + def test_property_actually_discriminates(self) -> None: + # Negative control: the SAME corpus with a GUARDED (trusted-zone) seed + # must NOT trip the raw-zone check everywhere — i.e. the property's + # signal comes from the seed, not from the templates being raw-by- + # construction (a corpus that returned RAW_ZONE regardless of seed + # would make property 1 vacuous). + trusted_zone_results = 0 + for seed in range(_P1_CASES): + src, _used = _generate_chain(seed) + result = _resolve_program(src, param_meets={"src": T.GUARDED}, taint_map={}) + if result not in RAW_ZONE: + trusted_zone_results += 1 + assert trusted_zone_results == _P1_CASES, ( + "with a trusted seed every chain should resolve in the trusted zone " + f"(got {trusted_zone_results}/{_P1_CASES} — some template injects raw on its own)" + ) + + +# ── Analyzer-level corpus (properties 2 and 3) ─────────────────────────────── +# +# File-level programs: an @external_boundary reader seeds EXTERNAL_RAW, a +# @trusted function carries it through a propagation chain into a sink. The +# @trusted decoration matters — undecorated functions sit in the freedom zone +# and the severity model suppresses their sink findings. + +_MODULE_HEADER = ( + "import os, pickle, subprocess\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + +# Single-line propagation templates only: the raw and trusted module variants +# must be line-for-line aligned apart from the one seed line, so findings can +# be compared by (rule_id, qualname, line_start). +_FLAT_TEMPLATES: tuple[Callable[[str, str], list[str]], ...] = ( + lambda c, n: [f"{n} = {c}"], + lambda c, n: [f'{n} = {c} + "lit"'], + lambda c, n: [f'{n} = f"v={{{c}}}!"'], + lambda c, n: ["if flag:", f" {n} = {c}", "else:", f' {n} = "d"'], + lambda c, n: [f"{n} = str({c})"], + lambda c, n: [f'{n} = [{c}, "a"][0]'], +) + +_SINKS: tuple[Callable[[str], str], ...] = ( + lambda v: f"os.system({v})", + lambda v: f"pickle.loads({v})", + lambda v: f"eval({v})", + lambda v: f"subprocess.run({v}, shell=True)", +) + +# Rules whose firing decision is driven by a resolved taint reaching a sink / +# return (the population property 3 quantifies over). Structural rules +# (PY-WL-102/103/104/110/111/113/114/119) are excluded: they gate on shape, not +# taint, so a seed change can legitimately add or remove them in either +# direction (e.g. a boundary-partition verdict that depends on what the body +# returns). +_TAINT_DRIVEN_RULES = frozenset( + { + "PY-WL-101", + "PY-WL-105", + "PY-WL-106", + "PY-WL-107", + "PY-WL-108", + "PY-WL-109", + "PY-WL-112", + "PY-WL-115", + "PY-WL-116", + "PY-WL-117", + "PY-WL-118", + "PY-WL-120", + "PY-WL-121", + "PY-WL-122", + "PY-WL-123", + "PY-WL-124", + "PY-WL-125", + "PY-WL-126", + } +) + +_P3_CASES = 40 + + +def _generate_module(seed: int, *, raw_seed: bool) -> str: + """A sink-bearing module; ``raw_seed`` picks the one differing line + (boundary read vs trusted literal).""" + rng = random.Random(seed) + n_stmts = rng.randint(0, 2) + seed_expr = "read_raw(p)" if raw_seed else '"safe"' + lines = ["@trusted(level='ASSURED')", f"def fn{seed}(p, flag):", f" data = {seed_expr}"] + cur = "data" + for i in range(n_stmts): + template = rng.choice(_FLAT_TEMPLATES) + nxt = f"v{i}" + lines.extend(" " + line for line in template(cur, nxt)) + cur = nxt + lines.append(" " + rng.choice(_SINKS)(cur)) + lines.append(f" return {cur}") + return _MODULE_HEADER + "\n".join(lines) + "\n" + + +def _analyze_module(tmp_path: Path, name: str, src: str) -> Sequence[Finding]: + p = tmp_path / name + p.write_text(src, encoding="utf-8") + return WardlineAnalyzer().analyze([p], WardlineConfig(), root=tmp_path) + + +def _findings_stream(findings: Sequence[Finding]) -> str: + """The findings stream as bytes-comparable text — order-preserving, full + serialized payload (``to_jsonl`` covers every wire-visible field).""" + return "\n".join(f.to_jsonl() for f in findings) + + +class TestIdempotence: + """Property 2 — same file in, identical findings stream out. + + Both repetition shapes: a FRESH analyzer per run (no shared state at all) + and the SAME analyzer re-run (per-run state must be fully reset between + ``analyze`` calls — a stale cache / leaked contextvar shows up here). + """ + + # A construct-dense module on top of the generated ones: branch-bound + # lambdas, container mutators, typed receivers, try/except, match, loops — + # the id()-keyed and contextvar-coupled paths most likely to destabilise. + _RICH_MODULE = _MODULE_HEADER + ( + "@trusted(level='ASSURED')\n" + "def rich(p, flag):\n" + " data = read_raw(p)\n" + " box = []\n" + " box.append(data)\n" + " if flag:\n" + " cb = lambda v: os.system(v)\n" + " else:\n" + " cb = lambda v: eval(v)\n" + " cb(data)\n" + " try:\n" + " out = box[0]\n" + " except Exception:\n" + " out = 'd'\n" + " match out:\n" + " case v:\n" + " acc = ''\n" + " for item in box:\n" + " acc += item\n" + " subprocess.run(acc, shell=True)\n" + " return acc\n" + ) + + def _corpus(self) -> list[str]: + return [_generate_module(seed, raw_seed=True) for seed in (3, 7, 11)] + [self._RICH_MODULE] + + def test_fresh_analyzer_twice_is_byte_identical(self, tmp_path: Path) -> None: + for idx, src in enumerate(self._corpus()): + name = f"m_fresh_{idx}.py" + first = _findings_stream(_analyze_module(tmp_path, name, src)) + second = _findings_stream(_analyze_module(tmp_path, name, src)) + assert first == second, f"fresh-analyzer re-analysis diverged for module {idx}:\n{src}" + + def test_same_analyzer_rerun_is_byte_identical(self, tmp_path: Path) -> None: + for idx, src in enumerate(self._corpus()): + p = tmp_path / f"m_same_{idx}.py" + p.write_text(src, encoding="utf-8") + analyzer = WardlineAnalyzer() + first = _findings_stream(analyzer.analyze([p], WardlineConfig(), root=tmp_path)) + second = _findings_stream(analyzer.analyze([p], WardlineConfig(), root=tmp_path)) + assert first == second, f"same-analyzer re-run diverged for module {idx}:\n{src}" + + def test_idempotence_corpus_produces_findings(self, tmp_path: Path) -> None: + # Anti-vacuity: the corpus must actually exercise the finding pipeline + # (byte-equality of two empty streams proves nothing). + for idx, src in enumerate(self._corpus()): + findings = _analyze_module(tmp_path, f"m_av_{idx}.py", src) + assert any(f.rule_id in _TAINT_DRIVEN_RULES for f in findings), ( + f"idempotence module {idx} produced no taint-driven findings:\n{src}" + ) + + +class TestMonotonicSeeds: + """Property 3 — weakening a seed (trusted -> raw) never removes a + taint-driven finding: findings(raw) ⊇ findings(trusted) restricted to + ``_TAINT_DRIVEN_RULES``, compared on (rule_id, qualname, line_start). + """ + + @staticmethod + def _taint_keys(findings: Sequence[Finding]) -> set[tuple[str, str | None, int | None]]: + return {(f.rule_id, f.qualname, f.location.line_start) for f in findings if f.rule_id in _TAINT_DRIVEN_RULES} + + def test_variants_differ_in_exactly_the_seed_line(self) -> None: + # Meta: the (rule_id, qualname, line_start) comparison is only valid if + # the two variants are line-aligned apart from the seed expression. + for seed in range(_P3_CASES): + raw_lines = _generate_module(seed, raw_seed=True).splitlines() + trusted_lines = _generate_module(seed, raw_seed=False).splitlines() + assert len(raw_lines) == len(trusted_lines) + diff = [i for i, (a, b) in enumerate(zip(raw_lines, trusted_lines, strict=True)) if a != b] + assert len(diff) == 1, f"seed={seed}: variants must differ on exactly one line, got {diff}" + + def test_raw_seed_findings_superset_of_trusted_seed(self, tmp_path: Path) -> None: + failures: list[str] = [] + nonempty_raw_cases = 0 + for seed in range(_P3_CASES): + raw_keys = self._taint_keys( + _analyze_module(tmp_path, f"m_{seed}_raw.py", _generate_module(seed, raw_seed=True)) + ) + trusted_keys = self._taint_keys( + _analyze_module(tmp_path, f"m_{seed}_tr.py", _generate_module(seed, raw_seed=False)) + ) + if raw_keys: + nonempty_raw_cases += 1 + lost = trusted_keys - raw_keys + if lost: + failures.append( + f"seed={seed}: weakening the seed REMOVED findings {sorted(lost)}\n" + f"{_generate_module(seed, raw_seed=True)}" + ) + assert not failures, "seed monotonicity violated:\n\n" + "\n".join(failures) + # Anti-vacuity: the raw variants must actually fire taint-driven rules. + assert nonempty_raw_cases == _P3_CASES, ( + f"only {nonempty_raw_cases}/{_P3_CASES} raw-seed programs produced taint-driven findings" + ) + + +class TestReceiverGuardInvariant: + """Property 4 — the declared-raw typed-receiver guard (wardline-03c8805449). + + A receiver with a tracked type AND a DECLARED-raw value (EXTERNAL_RAW / + MIXED_RAW — a boundary-seeded parameter, not a constructor default) must + never resolve a trusted result through its clean ``Type.method`` summary: + the summary describes a trustworthy instance, not the attacker-controlled + object actually flowing in. + + UNKNOWN_RAW receivers are DELIBERATELY out of scope: an unmodelled + ``Type()`` constructor defaults to UNKNOWN_RAW, and gating those would + false-positive the ``h = Helper(); h.get_assured()`` pattern — the engine + resolves the clean summary there by design (pinned as the negative control + below, so a future edit that silently widens or narrows the guard trips + one of the two tests). + """ + + _SHAPES = ( + # direct assignment from the typed-receiver method call + "def f(h: {cls}):\n x = h.{meth}()\n return x\n", + # with an argument, then an alias hop + "def f(h: {cls}):\n y = h.{meth}('a')\n x = y\n return x\n", + ) + + def test_declared_raw_receiver_never_resolves_trusted(self) -> None: + failures: list[str] = [] + for cls, meth in (("Helper", "get_assured"), ("Vault", "load")): + for summary in (T.INTEGRAL, T.ASSURED, T.GUARDED): + for receiver in (T.EXTERNAL_RAW, T.MIXED_RAW): + for shape in self._SHAPES: + src = shape.format(cls=cls, meth=meth) + func = ast.parse(src).body[0] + assert isinstance(func, ast.FunctionDef) + taint_map = {f"{cls}.{meth}": summary} + var_taints = compute_variable_taints(func, T.GUARDED, taint_map, param_meets={"h": receiver}) + if var_taints["x"] not in RAW_ZONE: + failures.append( + f"summary={summary} receiver={receiver} resolved x={var_taints['x']}\n{src}" + ) + assert not failures, "declared-raw receiver laundered through a clean summary:\n\n" + "\n".join(failures) + + def test_unknown_raw_receiver_still_resolves_summary(self) -> None: + # Negative control / guard-boundary pin: an UNKNOWN_RAW-valued typed + # receiver (the constructor-default provenance) DOES resolve the clean + # summary — the FP-avoidance side of wardline-03c8805449. + src = "def f(h: Helper):\n x = h.get_assured()\n return x\n" + func = ast.parse(src).body[0] + assert isinstance(func, ast.FunctionDef) + taint_map = {"Helper.get_assured": T.ASSURED} + var_taints = compute_variable_taints(func, T.GUARDED, taint_map, param_meets={"h": T.UNKNOWN_RAW}) + assert var_taints["x"] == T.ASSURED diff --git a/tests/unit/scanner/taint/test_provenance_clash.py b/tests/unit/scanner/taint/test_provenance_clash.py index e388272a..c5539d52 100644 --- a/tests/unit/scanner/taint/test_provenance_clash.py +++ b/tests/unit/scanner/taint/test_provenance_clash.py @@ -9,7 +9,7 @@ from wardline.core.taints import TaintState as T from wardline.scanner.taint.propagation import propagate_callgraph_taints from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION -from wardline.scanner.taint.summary_cache import _deserialise_summary, _serialise_summary +from wardline.scanner.taint.summary_cache import SummaryCache, _deserialise_summary, _serialise_summary from wardline.scanner.taint.variable_level import compute_variable_taints @@ -169,7 +169,7 @@ def test_summary_cache_mixed_raw_clash() -> None: _PROVENANCE_CLASH.reset(token) -def test_run_scan_provenance_clash_with_cache(tmp_path) -> None: +def test_run_scan_provenance_clash_with_cache(tmp_path, monkeypatch) -> None: from wardline.core.run import run_scan proj = tmp_path / "proj" @@ -196,6 +196,7 @@ def clashing(cond, p): (proj / "m.py").write_text(source, encoding="utf-8") cache = tmp_path / "cache" + monkeypatch.setenv("WARDLINE_SUMMARY_CACHE_KEY", "unit-test-summary-cache-key") run_scan(proj, cache_dir=cache) assert cache.exists() @@ -206,15 +207,12 @@ def clashing(cond, p): assert hit_rate > 0.0, "Cache was not hit! MIXED_RAW cache entry might have been dropped." -def test_run_scan_provenance_clash_loads_mixed_raw_cache(tmp_path) -> None: - import json - +def test_run_scan_provenance_clash_loads_signed_mixed_raw_cache(tmp_path, monkeypatch) -> None: from wardline.core.run import run_scan from wardline.core.taints import TaintState as T from wardline.scanner.taint.decorator_provider import DecoratorTaintSourceProvider from wardline.scanner.taint.project_resolver import _RESOLVER_VERSION, compute_cache_key from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION, FunctionSummary - from wardline.scanner.taint.summary_cache import _serialise_summary proj = tmp_path / "proj" proj.mkdir() @@ -225,12 +223,21 @@ def test_run_scan_provenance_clash_loads_mixed_raw_cache(tmp_path) -> None: cache_dir.mkdir() provider_fingerprint = DecoratorTaintSourceProvider().fingerprint() + # The cache key now binds the effective-scan-policy hash (provenance_clash is a signed + # policy field). Reconstruct it from the SAME config run_scan will load, so the pre-seeded + # key matches and the cache hits (wardline-9d6a81b9e7). + from wardline.core import config as config_mod + from wardline.core.paths import weft_config_path + from wardline.core.ruleset import ruleset_hash + + cfg = config_mod.load(weft_config_path(proj), explicit=False) key = compute_cache_key( module_path="m", source_bytes=b"def f(): pass\n", schema_version=SUMMARY_SCHEMA_VERSION, resolver_version=_RESOLVER_VERSION, provider_fingerprint=provider_fingerprint, + scan_policy_hash=ruleset_hash(cfg), ) s = FunctionSummary( @@ -243,7 +250,11 @@ def test_run_scan_provenance_clash_loads_mixed_raw_cache(tmp_path) -> None: cache_key=key, ) - (cache_dir / f"{key}.json").write_text(json.dumps([_serialise_summary(s)]), encoding="utf-8") + secret = b"unit-test-summary-cache-key" + cache = SummaryCache(cache_dir=cache_dir, cache_auth_secret=secret) + cache.put(key, (s,)) + cache.save() + monkeypatch.setenv("WARDLINE_SUMMARY_CACHE_KEY", secret.decode("utf-8")) res = run_scan(proj, cache_dir=cache_dir) diff --git a/tests/unit/scanner/taint/test_review_fixups_engine.py b/tests/unit/scanner/taint/test_review_fixups_engine.py new file mode 100644 index 00000000..b5590eb8 --- /dev/null +++ b/tests/unit/scanner/taint/test_review_fixups_engine.py @@ -0,0 +1,396 @@ +# tests/unit/scanner/taint/test_review_fixups_engine.py +"""Regression tests for the 2026-06-10 review-panel engine fixups. + +Covers four confirmed soundness defects in the L2 walk +(``wardline.scanner.taint.variable_level``): + +1. **Compare/Raise/Assert/Delete expression positions** — a call nested in an + ``ast.Compare`` operand (or a Raise/Assert/Delete statement) was never + resolved by ``_resolve_expr``/``_process_stmt``, so its per-call arg-taint + snapshot was missing: PY-WL-105's provably-untrusted gate stayed silent (FN) + and a CONSTANT-arg sink call degraded to the pessimistic UNKNOWN_RAW + fallback (a gate-tripping PY-WL-108 FP after the WARN→ERROR recalibration). + +2. **Stale receiver-type candidates across non-Assign rebinds** — for/with/ + walrus/tuple-unpack/except-handler rebinds updated ``var_taints`` but not + ``_CURRENT_VAR_TYPES``, so a stale class candidate resolved a clean + ``@trusted`` method summary onto a rebound raw receiver (PY-WL-101 FN). + +3. **Provenance-only re-resolution polluting the flow-sensitive snapshot** — + ``compute_return_callee``'s ``_assignment_callee`` re-resolved direct-call + assignment RHS against the FINAL var_taints with the arg-taint recorder + still active, combining post-call taint into the at-call snapshot + (PY-WL-105/108 FPs on values that were clean AT the call). + +4. **Nested local helper calls** — the bare-name worst-arg conservatism + (wardline-93d608c997) hit nested defs the engine had already analyzed + (``m.f..helper``), marking validated values raw (PY-WL-101 FP). + +5. **Recursive lambda bindings** — call-time lambda body resolution must not + re-enter the same bound body indefinitely and skip the whole function before + later trust-boundary rules can run. +""" + +from __future__ import annotations + +import textwrap +import warnings +from typing import TYPE_CHECKING + +from wardline.core.config import WardlineConfig +from wardline.scanner.analyzer import WardlineAnalyzer + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from wardline.core.finding import Finding + +_PREAMBLE = ( + "import os\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" + "@trusted(level='ASSURED')\n" + "def store(x):\n" + " return 1\n" +) + + +def _scan(tmp_path: Path, body: str, name: str = "m.py", preamble: str = _PREAMBLE) -> Sequence[Finding]: + p = tmp_path / name + p.write_text(preamble + textwrap.dedent(body), encoding="utf-8") + with warnings.catch_warnings(): + warnings.simplefilter("error") # the engine must not warn from rule checks + return WardlineAnalyzer().analyze([p], WardlineConfig(), root=tmp_path) + + +def _rule_hits(findings: Sequence[Finding], rule_id: str) -> list[Finding]: + return [f for f in findings if f.rule_id == rule_id] + + +# ── 1. Compare / Raise / Assert / Delete positions ─────────────────────────── + + +def test_105_fires_on_call_inside_compare(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + def h(p): + if store(read_raw(p)) == 1: + return 1 + return 0 + """, + ) + hits = _rule_hits(findings, "PY-WL-105") + assert len(hits) == 1 + assert hits[0].qualname == "m.h" + + +def test_105_fires_on_call_inside_raise(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + def h(p): + raise ValueError(store(read_raw(p))) + """, + ) + assert len(_rule_hits(findings, "PY-WL-105")) == 1 + + +def test_105_fires_on_call_inside_assert(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + def h(p): + assert store(read_raw(p)) + """, + ) + assert len(_rule_hits(findings, "PY-WL-105")) == 1 + + +def test_105_fires_on_call_inside_assert_msg_and_raise_cause(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + def h_msg(p): + assert p, store(read_raw(p)) + + def h_cause(p): + raise ValueError("x") from store(read_raw(p)) + """, + ) + assert {f.qualname for f in _rule_hits(findings, "PY-WL-105")} == {"m.h_msg", "m.h_cause"} + + +def test_108_constant_command_inside_compare_is_clean(tmp_path: Path) -> None: + """FP-corpus sentinel: ``if os.system(CONST) == 0:`` is THE idiomatic + command-success check — the missing-Compare pessimistic fallback must not + turn the constant into a gate-tripping ERROR.""" + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED') + def const_cmd(): + if os.system("ls") == 0: + return 1 + return 0 + """, + ) + assert _rule_hits(findings, "PY-WL-108") == [] + + +def test_del_subscript_resolves_walrus_and_call(tmp_path: Path) -> None: + """A Delete statement's target expressions are resolved (slice calls record).""" + findings = _scan( + tmp_path, + """ + def h(p, d): + del d[store(read_raw(p))] + """, + ) + assert len(_rule_hits(findings, "PY-WL-105")) == 1 + + +# ── 2. Receiver-type invalidation on non-Assign rebinds ────────────────────── + +_VAULT_PREAMBLE = ( + "import junklib\n" + "from wardline.decorators import trusted\n" + "class Vault:\n" + " @trusted(level='ASSURED')\n" + " def get(self):\n" + " return 'safe'\n" +) + + +def _vault_101(tmp_path: Path, body: str) -> list[Finding]: + findings = _scan(tmp_path, body, name="vault.py", preamble=_VAULT_PREAMBLE) + return _rule_hits(findings, "PY-WL-101") + + +def test_101_fires_after_for_target_rebind(tmp_path: Path) -> None: + hits = _vault_101( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(): + v = Vault() + for v in junklib.items(): + pass + x = v.get() + return x + """, + ) + assert len(hits) == 1 + + +def test_101_fires_after_with_target_rebind(tmp_path: Path) -> None: + hits = _vault_101( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(): + v = Vault() + with junklib.ctx() as v: + pass + x = v.get() + return x + """, + ) + assert len(hits) == 1 + + +def test_101_fires_after_walrus_rebind(tmp_path: Path) -> None: + hits = _vault_101( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(): + v = Vault() + if (v := junklib.one()): + pass + x = v.get() + return x + """, + ) + assert len(hits) == 1 + + +def test_101_fires_after_tuple_unpack_rebind(tmp_path: Path) -> None: + hits = _vault_101( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(): + v = Vault() + v, w = junklib.pair() + x = v.get() + return x + """, + ) + assert len(hits) == 1 + + +def test_except_handler_rebind_invalidates_receiver_type() -> None: + """``except E as v`` rebinds ``v`` to the exception instance — the stale + [Vault] candidate must not resolve ``Vault.fetch -> ASSURED`` on the handler + path (the launder is observable at the L2 unit level: with the stale type, + ``x`` came back ASSURED; with the invalidation it stays UNKNOWN_RAW). + + Note the END-TO-END except shape (rebind in the handler, method call after + the try) stays silent for a DIFFERENT, pre-existing reason: the handler + binds the exception name to the enclosing function's trusted seed (a value- + channel conservatism, not the receiver-type launder this fix closes). + """ + import ast + + from wardline.core.taints import TaintState + from wardline.scanner.taint.variable_level import ( + VariableTaintContext, + analyze_function_variables, + ) + + src = textwrap.dedent( + """ + def f(): + v = Vault() + try: + go() + except Exception as v: + x = v.fetch() + return x + """ + ) + fn = ast.parse(src).body[0] + assert isinstance(fn, ast.FunctionDef) + result = analyze_function_variables( + fn, + TaintState.UNKNOWN_RAW, + {"vault.Vault.fetch": TaintState.ASSURED}, + VariableTaintContext(alias_map={}, module_prefix="vault"), + ) + assert result.variable_taints["x"] is TaintState.UNKNOWN_RAW + + +def test_walrus_constructor_rebind_keeps_type_precision(tmp_path: Path) -> None: + """A walrus that REBINDS to a constructor is a typed strong update, not an + invalidation: ``(v := Vault())`` then ``v.get()`` must stay clean.""" + hits = _vault_101( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(): + if (v := Vault()): + pass + x = v.get() + return x + """, + ) + assert hits == [] + + +# ── 3. Provenance re-resolution must not pollute the at-call snapshot ──────── + + +def test_no_105_when_arg_becomes_raw_only_after_the_call(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + def f(p): + x = "const" + v = store(x) + x = read_raw(p) + return v + """, + ) + assert _rule_hits(findings, "PY-WL-105") == [] + + +def test_no_108_when_command_becomes_raw_only_after_the_call(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED') + def f(p): + cmd = "ls" + v = os.system(cmd) + cmd = read_raw(p) + return v + """, + ) + assert _rule_hits(findings, "PY-WL-108") == [] + + +# ── 4. Nested local helper calls resolve via the engine's own summary ──────── + + +def test_nested_local_helper_validating_raw_is_not_a_101_fp(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(p): + def clean(x): + return 1 + raw = read_raw(p) + v = clean(raw) + return v + """, + ) + assert _rule_hits(findings, "PY-WL-101") == [] + + +def test_nested_local_helper_returning_raw_still_fires_101(tmp_path: Path) -> None: + """The nested-def lookup must use the helper's REAL summary — a helper that + passes raw through keeps firing (no launder through the bare-name hop).""" + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(p): + def passthrough(x): + return x + raw = read_raw(p) + v = passthrough(raw) + return v + """, + ) + assert len(_rule_hits(findings, "PY-WL-101")) == 1 + + +def test_bare_param_callee_still_pessimistic(tmp_path: Path) -> None: + """wardline-93d608c997 stays closed: an UNKNOWN bare-name callee (a param) + cannot be assumed to clean a raw argument.""" + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED') + def launder_check(transform, p): + raw = read_raw(p) + os.system(transform(raw)) + """, + ) + assert len(_rule_hits(findings, "PY-WL-108")) == 1 + + +# ── 5. Recursive lambda call-time resolution must not skip the function ────── + + +def test_recursive_lambda_binding_does_not_skip_later_trust_boundary_check(tmp_path: Path) -> None: + findings = _scan( + tmp_path, + """ + @trusted(level='ASSURED', to_level='ASSURED') + def f(p): + cb = lambda x: cb(x) + if False: + cb(read_raw(p)) + return read_raw(p) + """, + ) + assert _rule_hits(findings, "WLN-ENGINE-FUNCTION-SKIPPED") == [] + hits = _rule_hits(findings, "PY-WL-101") + assert len(hits) == 1 + assert hits[0].qualname == "m.f" diff --git a/tests/unit/scanner/taint/test_summary.py b/tests/unit/scanner/taint/test_summary.py index fd7b6948..ef42e9c2 100644 --- a/tests/unit/scanner/taint/test_summary.py +++ b/tests/unit/scanner/taint/test_summary.py @@ -17,6 +17,7 @@ def _key(**over) -> str: schema_version=SUMMARY_SCHEMA_VERSION, resolver_version="sp1d", provider_fingerprint="default-v1", + scan_policy_hash="sha256:policy-a", ) base.update(over) return compute_cache_key(**base) @@ -32,6 +33,10 @@ def test_cache_key_changes_with_each_input() -> None: assert _key(provider_fingerprint="sp2-vocab-7") != base assert _key(resolver_version="sp1e") != base assert _key(schema_version=SUMMARY_SCHEMA_VERSION + 1) != base + # The effective-scan-policy identity is part of the key: a config that newly names an + # untrusted source (changing ruleset_hash) must not collide with the prior key, else a + # warm cache serves a stale-CLEAN summary (wardline-9d6a81b9e7). + assert _key(scan_policy_hash="sha256:policy-b") != base def test_cache_key_includes_module_identity() -> None: @@ -53,12 +58,14 @@ def test_cache_key_length_prefixed_no_collision() -> None: schema_version=1, resolver_version="c", provider_fingerprint="x", + scan_policy_hash="p", ) != compute_cache_key( module_path="m", source_bytes=b"a", schema_version=1, resolver_version="bc", provider_fingerprint="x", + scan_policy_hash="p", ) diff --git a/tests/unit/scanner/taint/test_summary_cache.py b/tests/unit/scanner/taint/test_summary_cache.py index b68c5b18..27de6ca4 100644 --- a/tests/unit/scanner/taint/test_summary_cache.py +++ b/tests/unit/scanner/taint/test_summary_cache.py @@ -8,12 +8,15 @@ from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION, FunctionSummary from wardline.scanner.taint.summary_cache import ( SummaryCache, + _cache_payload, + _cache_payload_mac, _deserialise_summary, _serialise_summary, ) _KEY = "a" * 64 _KEY2 = "b" * 64 +_SECRET = b"unit-test-summary-cache-key" def _summary(fqn: str, *, schema: int = SUMMARY_SCHEMA_VERSION, key: str = _KEY) -> FunctionSummary: @@ -121,22 +124,147 @@ def test_schema_version_property() -> None: def test_save_and_load_roundtrip(tmp_path) -> None: - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) summaries = (_summary("m.a"), _summary("m.b")) c.put(_KEY, summaries) c.save() - c2 = SummaryCache(cache_dir=tmp_path) + c2 = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c2.load() assert c2.get(_KEY) == summaries +def test_save_without_auth_secret_does_not_persist_entries(tmp_path) -> None: + c = SummaryCache(cache_dir=tmp_path) + c.put(_KEY, (_summary("m.a"),)) + c.save() + assert list(tmp_path.iterdir()) == [] + + +def test_load_drops_unsigned_cache_file_with_matching_key(tmp_path) -> None: + import json + + payload = [_serialise_summary(_summary("m.a"))] + (tmp_path / f"{_KEY}.json").write_text(json.dumps(payload), encoding="utf-8") + + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) + c.load() + + assert len(c) == 0 + assert c.get(_KEY) is None + + +def test_load_drops_bad_mac_cache_file(tmp_path) -> None: + import json + + payload = _cache_payload(_KEY, (_summary("m.a"),)) + payload["mac"] = "0" * 64 + (tmp_path / f"{_KEY}.json").write_text(json.dumps(payload), encoding="utf-8") + + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) + c.load() + + assert len(c) == 0 + assert c.get(_KEY) is None + + +def test_warm_cache_honours_untrusted_sources_policy_change(tmp_path) -> None: + # A warm run whose config newly names a function as an untrusted source must produce the + # SAME defects as a cold run with that config — the cache key binds the effective-scan- + # policy hash, so the prior policy-free CLEAN summary is not served (wardline-9d6a81b9e7). + from wardline.core.config import WardlineConfig + from wardline.core.finding import Kind + from wardline.scanner.analyzer import WardlineAnalyzer + + src = tmp_path / "example.py" + src.write_text( + "from wardline.decorators import trusted\n" + "@trusted(level='ASSURED')\n" + "def read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\n" + "def f(p, cursor):\n cursor.execute(read_raw(p))\n", + encoding="utf-8", + ) + + def defects(az: WardlineAnalyzer, cfg: WardlineConfig) -> list[str]: + fs = list(az.analyze([src], cfg, root=tmp_path)) + return sorted({x.rule_id for x in fs if x.kind is Kind.DEFECT}) + + cfg_clean = WardlineConfig() + cfg_src = WardlineConfig(untrusted_sources=("example.read_raw",)) + + cold = defects(WardlineAnalyzer(summary_cache=SummaryCache()), cfg_src) + assert cold == ["PY-WL-118"] + + # Warm the cache with the policy-FREE run first, then re-run under the source policy. + az = WardlineAnalyzer(summary_cache=SummaryCache()) + assert defects(az, cfg_clean) == [] + assert defects(az, cfg_src) == cold # must NOT serve the stale-clean summary + + +def test_run_scan_ignores_unsigned_forged_summary_cache(tmp_path) -> None: + import json + + from wardline.core.config import WardlineConfig + from wardline.core.ruleset import ruleset_hash + from wardline.core.run import run_scan + from wardline.scanner.taint.decorator_provider import DecoratorTaintSourceProvider + from wardline.scanner.taint.project_resolver import _RESOLVER_VERSION + from wardline.scanner.taint.summary import compute_cache_key + + proj = tmp_path / "proj" + proj.mkdir() + source = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" + "@trusted(level='ASSURED')\n" + "def sink(x):\n" + " return x\n" + "def f(p):\n" + " return sink(read_raw(p))\n" + ) + (proj / "m.py").write_text(source, encoding="utf-8") + + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + key = compute_cache_key( + module_path="m", + source_bytes=source.encode("utf-8"), + schema_version=SUMMARY_SCHEMA_VERSION, + resolver_version=_RESOLVER_VERSION, + provider_fingerprint=DecoratorTaintSourceProvider().fingerprint(), + scan_policy_hash=ruleset_hash(WardlineConfig()), + ) + forged = tuple( + FunctionSummary( + fqn=fqn, + body_taint=T.INTEGRAL, + return_taint=T.INTEGRAL, + taint_source="anchored", + unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, + cache_key=key, + ) + for fqn in ("m.read_raw", "m.sink", "m.f") + ) + (cache_dir / f"{key}.json").write_text(json.dumps([_serialise_summary(s) for s in forged]), encoding="utf-8") + + result = run_scan(proj, cache_dir=cache_dir) + metrics = next(f for f in result.findings if f.rule_id == "WLN-ENGINE-METRICS") + + assert metrics.properties["cache_hit_rate"] == 0.0 + assert any(f.rule_id == "PY-WL-101" for f in result.findings) + + def test_load_drops_file_when_internal_cache_key_mismatches_filename(tmp_path) -> None: import json - payload = [_serialise_summary(_summary("m.a", key=_KEY2))] + payload = _cache_payload(_KEY, (_summary("m.a", key=_KEY2),)) + payload["mac"] = _cache_payload_mac(_SECRET, payload) (tmp_path / f"{_KEY}.json").write_text(json.dumps(payload), encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() assert len(c) == 0 @@ -146,13 +274,17 @@ def test_load_drops_file_when_internal_cache_key_mismatches_filename(tmp_path) - def test_load_drops_file_when_records_mix_cache_keys(tmp_path) -> None: import json - payload = [ - _serialise_summary(_summary("m.a", key=_KEY)), - _serialise_summary(_summary("m.b", key=_KEY2)), - ] + payload = _cache_payload( + _KEY, + ( + _summary("m.a", key=_KEY), + _summary("m.b", key=_KEY2), + ), + ) + payload["mac"] = _cache_payload_mac(_SECRET, payload) (tmp_path / f"{_KEY}.json").write_text(json.dumps(payload), encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() assert len(c) == 0 @@ -161,14 +293,14 @@ def test_load_drops_file_when_records_mix_cache_keys(tmp_path) -> None: def test_load_drops_malformed_json(tmp_path) -> None: (tmp_path / f"{_KEY}.json").write_text("{not json", encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() # must not raise assert len(c) == 0 def test_load_ignores_non_hex_stem_files(tmp_path) -> None: (tmp_path / "notes.json").write_text("[]", encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() assert len(c) == 0 @@ -239,7 +371,7 @@ def test_deserialise_integral_roundtrips() -> None: def test_integral_survives_full_save_load_cycle(tmp_path) -> None: # End-to-end: a poisoned-but-valid trio state would be dropped by load(), # but a legitimate INTEGRAL summary must survive the disk round-trip. - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) s = FunctionSummary( fqn="m.f", body_taint=T.INTEGRAL, @@ -251,7 +383,7 @@ def test_integral_survives_full_save_load_cycle(tmp_path) -> None: ) c.put(_KEY, (s,)) c.save() - c2 = SummaryCache(cache_dir=tmp_path) + c2 = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c2.load() assert c2.get(_KEY) == (s,) @@ -261,8 +393,14 @@ def test_load_drops_poisoned_trio_cache_file(tmp_path, caplog) -> None: # dropped (cold-cache fallback), not injected — load() catches the ValueError. import json - (tmp_path / f"{_KEY}.json").write_text(json.dumps([_summary_dict("MIXED_RAW", "MIXED_RAW")]), encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + payload: dict[str, object] = { + "schema_version": 1, + "cache_key": _KEY, + "summaries": [_summary_dict("MIXED_RAW", "MIXED_RAW")], + } + payload["mac"] = _cache_payload_mac(_SECRET, payload) + (tmp_path / f"{_KEY}.json").write_text(json.dumps(payload), encoding="utf-8") + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() # must not raise assert len(c) == 0 @@ -275,7 +413,7 @@ def test_save_cleans_up_temp_file_when_replace_fails(tmp_path, monkeypatch) -> N # and the error re-raised — the except cleanup arm. import os - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.put(_KEY, (_summary("m.a"),)) def boom(_src, _dst): @@ -291,7 +429,7 @@ def boom(_src, _dst): def test_load_skips_non_json_files(tmp_path) -> None: # A non-.json file in the cache dir must be skipped (the suffix guard), not parsed. (tmp_path / f"{_KEY}.txt").write_text("garbage", encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() # must not raise assert len(c) == 0 @@ -305,9 +443,10 @@ def test_load_drops_stale_schema_entry_from_disk(tmp_path) -> None: stale = _summary("m.a") object.__setattr__(stale, "schema_version", SUMMARY_SCHEMA_VERSION + 1) - payload = [_serialise_summary(stale)] + payload = _cache_payload(_KEY, (stale,)) + payload["mac"] = _cache_payload_mac(_SECRET, payload) (tmp_path / f"{_KEY}.json").write_text(json.dumps(payload), encoding="utf-8") - c = SummaryCache(cache_dir=tmp_path) + c = SummaryCache(cache_dir=tmp_path, cache_auth_secret=_SECRET) c.load() assert len(c) == 0 # stale-schema file dropped (not rehydrated) diff --git a/tests/unit/scanner/taint/test_variable_level.py b/tests/unit/scanner/taint/test_variable_level.py index 4986bcac..2afd0973 100644 --- a/tests/unit/scanner/taint/test_variable_level.py +++ b/tests/unit/scanner/taint/test_variable_level.py @@ -351,6 +351,128 @@ def test_lambda_rebinding_in_try_survives_for_post_block_call() -> None: assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW +def test_sink_lambda_in_non_last_if_arm_survives_for_post_branch_call() -> None: + # wardline-383f83fafe: the single-slot ceiling. A sink-lambda bound in a NON-last + # branch arm and a BENIGN lambda bound to the same name in the last arm — the old + # single-slot ``dict[str, ast.Lambda]`` kept only the last-arm-in-source-order + # binding, so the benign last arm overwrote the sink binding and the post-branch + # ``cb(raw)`` resolved only the benign body → the taint→sink flow was MISSED (FN). + # The candidate-set model keeps BOTH bindings (``cb`` MAY be either lambda after the + # branch), so the body's ``sink(c)`` arg must stay EXTERNAL_RAW. + src = ( + "def handler(raw):\n" + " if flag:\n" + " cb = lambda c: sink(c)\n" # sink-lambda — NON-last arm + " else:\n" + " cb = lambda c: c\n" # benign — last arm in source order + " cb(raw)\n" # raw reaches sink() via the if-arm lambda + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_sink_lambda_in_non_last_try_arm_survives_for_post_branch_call() -> None: + # Same single-slot ceiling for try/except: the sink-lambda is bound in the try-success + # arm (a non-last arm) and a benign lambda rebinds ``cb`` in the handler arm. The + # candidate-set merge keeps both for the post-block ``cb(raw)``. + src = ( + "def handler(raw):\n" + " try:\n" + " cb = lambda c: sink(c)\n" + " except Exception:\n" + " cb = lambda c: c\n" + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_sink_lambda_in_non_last_match_arm_survives_for_post_branch_call() -> None: + # Same single-slot ceiling for match/case: the sink-lambda is bound in a non-last + # case-arm and a benign lambda rebinds ``cb`` in the catch-all arm. + src = ( + "def handler(raw, kind):\n" + " match kind:\n" + " case 'a':\n" + " cb = lambda c: sink(c)\n" + " case _:\n" + " cb = lambda c: c\n" + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_lambda_rebind_after_branch_replaces_candidate_set() -> None: + # wardline-383f83fafe FP guard: the candidate-set model must NOT accumulate stale + # bindings across a LINEAR rebind. After the branch, ``cb`` is rebound to a fresh + # benign lambda; that REPLACES the (sink + benign) post-branch candidate set, so the + # subsequent ``cb(raw)`` resolves ONLY the ``log`` body — the if-arm sink lambda is + # gone and ``sink(c)`` must stay INTEGRAL (the floor pass's neutral recording only). + src = ( + "def handler(raw):\n" + " if flag:\n" + " cb = lambda c: sink(c)\n" + " else:\n" + " cb = lambda c: c\n" + " cb = lambda c: log(c)\n" # linear rebind — REPLACES the candidate set + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.INTEGRAL + + +def test_all_arms_rebind_to_non_lambda_drops_candidate_set() -> None: + # wardline-383f83fafe, claim-#4 lock: when EVERY mutually-exclusive arm rebinds the + # name to a non-lambda, ``cb`` is provably not a lambda on any post-branch path, so + # the union drops it (the candidate set is empty) and ``cb(raw)`` resolves nothing — + # only the floor pass records the orphaned sink lambda neutrally → INTEGRAL. This + # pins the union's DROP semantics against a regression to the old delta-merge + # "left in place" over-approximation (which would have spuriously stayed EXTERNAL_RAW). + src = ( + "def handler(raw, flag):\n" + " cb = lambda c: sink(c)\n" + " if flag:\n" + " cb = 1\n" + " else:\n" + " cb = 2\n" + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.INTEGRAL + + +def test_sink_lambda_in_elif_middle_arm_survives_for_post_branch_call() -> None: + # wardline-383f83fafe: the sink-lambda is in the MIDDLE arm of an if/elif/else (elif + # is a nested ``If`` in the outer ``orelse``), so the union/merge must compose through + # the nesting. ``cb(raw)`` after the branch must see the elif-arm sink body. + src = ( + "def handler(raw, k):\n" + " if k == 1:\n" + " cb = lambda c: c\n" + " elif k == 2:\n" + " cb = lambda c: sink(c)\n" + " else:\n" + " cb = lambda c: c\n" + " cb(raw)\n" + ) + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_sink_lambda_in_loop_body_survives_for_post_loop_call() -> None: + # wardline-383f83fafe coverage: loops take a structurally different path from + # if/try/match — ``_handle_for`` walks the body via ``_walk_body`` directly on the + # shared bindings map (the linear-rebind REPLACE), no _branch_copy/_merge. A + # sink-lambda bound in the loop body persists in the post-loop bindings, so a tainted + # ``cb(raw)`` after the loop must fire. (Pins the loop path the candidate-set's FP + # concern names; verified it neither hangs nor accumulates — rebinds are idempotent.) + src = "def handler(raw):\n for i in items:\n cb = lambda c: sink(c)\n cb(raw)\n" + assert _lambda_body_sink_arg(src) == T.EXTERNAL_RAW + + +def test_benign_rebind_after_loop_replaces_lambda_candidate() -> None: + # wardline-383f83fafe FP guard on the loop path: a benign linear rebind AFTER the loop + # REPLACES the loop body's sink binding, so ``cb(raw)`` resolves only the benign body + # — no stale accumulation across the loop boundary. + src = "def handler(raw):\n for i in items:\n cb = lambda c: sink(c)\n cb = lambda c: c\n cb(raw)\n" + assert _lambda_body_sink_arg(src) == T.INTEGRAL + + def test_compute_return_taint_all_shapes() -> None: import ast import textwrap @@ -432,6 +554,12 @@ def test_match_subject_walrus_is_captured() -> None: assert out["s"] == T.EXTERNAL_RAW +def test_match_subject_nested_walrus_is_captured() -> None: + src = "def f(p):\n match (s := tainted())[0]:\n case _:\n pass\n" + out = _vt(src, function_taint=T.ASSURED, taint_map={"tainted": T.EXTERNAL_RAW}) + assert out["s"] == T.EXTERNAL_RAW + + def test_match_does_not_descend_into_nested_function() -> None: src = ( "def f(p):\n" @@ -944,12 +1072,17 @@ def test_dict_pop_joins_tainted_default_arg() -> None: def test_unknown_builtin_call_still_falls_back_to_function_taint() -> None: - # HARD BOUNDARY: len()/int()/validate() are NOT curated — they must STILL fall - # back to function_taint, or we explode in false positives. + # HARD BOUNDARY: len()/int() are curated NON-propagators (validators/ + # measurers) — they keep the function_taint fallback. An unknown bare call + # like validate() (absent from the taint_map) is the OPPOSITE: it now + # propagates the worst of (caller seed, args) — an unmodeled callee cannot + # be assumed to clean a raw argument (wardline-93d608c997; the unresolved + # bare-name launder closure). See test_engine_precision.py for the family. assert _vt("def f(p):\n x = len(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED assert _vt("def f(p):\n x = int(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED assert ( - _vt("def f(p):\n x = validate(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] == T.GUARDED + _vt("def f(p):\n x = validate(read_raw(p))\n", function_taint=T.GUARDED, taint_map=_RAW_TM)["x"] + == T.UNKNOWN_RAW ) @@ -1401,3 +1534,277 @@ def test_unresolved_nested_imported_call_returns_unknown_raw() -> None: alias_map={"vendor": "vendor"}, ) assert out["x"] == T.UNKNOWN_RAW + + +def test_loop_carried_chain_deeper_than_lattice_height_propagates() -> None: + # wardline-e04db6e656: the loop fixpoint cap was range(8) — lattice HEIGHT, not + # propagation DEPTH. A read-before-write rebind chain propagates one link per + # iteration, so an N-link chain needs N(+1) iterations; at cap=8 a chain > 8 links + # left the head under-tainted (a fail-open soundness FN). Iterate-to-convergence + # (num_vars × lattice_height backstop) fixes it for both for and while. + def chain(n: int, loop: str) -> tuple[str, str]: + names = [f"v{i}" for i in range(n)] + body = [f" {names[i]} = {names[i - 1]}" for i in range(n - 1, 0, -1)] + body.append(f" {names[0]} = read_raw(p)") + head = "for _ in p:" if loop == "for" else "while p:" + src = f"def f(p):\n {names[n - 1]} = ''\n {head}\n" + "\n".join(body) + "\n" + return src, names[n - 1] + + for loop in ("for", "while"): + for n in (9, 25): + src, sink = chain(n, loop) + out = _vt(src, function_taint=T.INTEGRAL, taint_map={"read_raw": T.EXTERNAL_RAW}) + assert out[sink] == T.EXTERNAL_RAW, (loop, n, out[sink]) + + # CONTROL (FP guard): a deep chain whose head is a constant literal — never a raw + # source — must stay clean regardless of iteration count. + names = [f"v{i}" for i in range(12)] + body = [f" {names[i]} = {names[i - 1]}" for i in range(11, 0, -1)] + body.append(f" {names[0]} = ''") + src = f"def f(p):\n {names[11]} = ''\n for _ in p:\n" + "\n".join(body) + "\n" + out = _vt(src, function_taint=T.INTEGRAL, taint_map={}) + assert out[names[11]] == T.INTEGRAL, out[names[11]] + + +def test_storage_read_methods_seed_external_raw() -> None: + # wardline-e7c7cda31a: cursor.fetch{one,all,many}() loads stored/external data and + # must seed EXTERNAL_RAW (the formerly-dead PY-WL-120 branch). Clean function_taint + # so only the seed can introduce taint. + for method in ("fetchone", "fetchall", "fetchmany"): + out = _vt(f"def f(cursor):\n rows = cursor.{method}()\n", function_taint=T.ASSURED) + assert out["rows"] == T.EXTERNAL_RAW, (method, out["rows"]) + + +def test_container_mutator_writes_arg_taint_back_to_receiver() -> None: + # wardline-67c7498931: a container mutator called with a tainted arg contaminates the + # receiver (box.append(raw) matches the literal box=[raw]). Clean ASSURED seed + raw + # via taint_map so only the mutated arg carries taint. + tm = {"read_raw": T.UNKNOWN_RAW} + for stmt, recv in ( + ("box = []\n box.append(raw)", "box"), + ("box = []\n box.extend([raw])", "box"), + ("s = set()\n s.add(raw)", "s"), + ("d = {}\n d.update({'k': raw})", "d"), + ("d = {}\n d.update(k=raw)", "d"), + ("self.cache.append(raw)", "self"), + ): + out = _vt(f"def f(self, p):\n raw = read_raw(p)\n {stmt}\n", function_taint=T.ASSURED, taint_map=tm) + assert out[recv] == T.UNKNOWN_RAW, (stmt, out.get(recv)) + + # CONTROL: a clean arg must NOT taint the receiver (FP guard). + out = _vt( + "def f(p):\n box = []\n box.append('safe')\n box.append(42)\n", function_taint=T.ASSURED, taint_map=tm + ) + assert out["box"] == T.INTEGRAL + # CONTROL: list.insert's INDEX arg is positional metadata, not content — a tainted + # index must not contaminate the container (panel finding wardline-67c7498931). + out = _vt( + "def f(p):\n raw = read_raw(p)\n lst = ['a']\n lst.insert(raw, 'clean')\n", + function_taint=T.ASSURED, + taint_map=tm, + ) + assert out["lst"] == T.INTEGRAL, out["lst"] + # but a tainted VALUE in insert DOES contaminate. + out = _vt( + "def f(p):\n raw = read_raw(p)\n lst = []\n lst.insert(0, raw)\n", + function_taint=T.ASSURED, + taint_map=tm, + ) + assert out["lst"] == T.UNKNOWN_RAW, out["lst"] + + +# ── Receiver shadow laundering: a raw local/param that shadows a module name ── +# +# wardline-f6a29ce23a. A dotted/imported ``taint_map`` entry describes a +# MODULE-LEVEL symbol (``mod.fn`` / from-imported func / config sanitiser). When a +# tracked LOCAL or PARAMETER named the same as the module holds RAW data, the early +# ``taint_map`` short-circuits in ``_resolve_call`` used to return the module's +# clean taint — laundering the raw receiver before the RAW_ZONE receiver guard +# could fire. The discriminator: a real module import is never in ``var_taints``, +# whereas an assigned/param local IS; suppress the short-circuit only when the +# receiver is a tracked raw local, leaving genuine module sanitisers untouched. + +_SHADOW_TM = {"read_raw": T.UNKNOWN_RAW, "cfg.validate": T.ASSURED} + + +def test_raw_local_shadowing_module_does_not_launder_dotted_path() -> None: + # Direct dotted-lookup path (no alias_map): cfg is a raw LOCAL shadowing the + # ``cfg`` module; cfg.validate() must NOT inherit the module entry's ASSURED. + out = _vt( + "def f(p):\n cfg = read_raw(p)\n x = cfg.validate()\n", + function_taint=T.INTEGRAL, + taint_map=_SHADOW_TM, + ) + assert out["cfg"] == T.UNKNOWN_RAW + assert out["x"] == T.UNKNOWN_RAW, out["x"] + + +def test_raw_local_shadowing_module_does_not_launder_imported_fqn_path() -> None: + # imported_fqn path (alias_map present): same shadow, resolved via the alias + # map to the fqn ``cfg.validate`` — must still defer to the raw receiver. + out = _vt( + "def f(p):\n cfg = read_raw(p)\n x = cfg.validate()\n", + function_taint=T.INTEGRAL, + taint_map=_SHADOW_TM, + alias_map={"cfg": "cfg"}, + ) + assert out["x"] == T.UNKNOWN_RAW, out["x"] + + +def test_raw_param_shadowing_module_does_not_launder() -> None: + # A PARAMETER (not just an assigned local) named like the module, seeded raw. + out = _vt( + "def f(cfg):\n x = cfg.validate()\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"cfg.validate": T.ASSURED}, + alias_map={"cfg": "cfg"}, + ) + assert out["x"] == T.UNKNOWN_RAW, out["x"] + + +def test_genuine_module_sanitiser_stays_clean_dotted() -> None: + # FP GUARD: ``cfg`` is NOT a tracked local (a real ``import cfg``), so + # cfg.validate(p) is the genuine module sanitiser and must stay ASSURED even + # when the function/arg is raw — the fix must not over-fire here. + out = _vt( + "def f(p):\n x = cfg.validate(p)\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"cfg.validate": T.ASSURED}, + alias_map={"cfg": "cfg"}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +def test_genuine_module_sanitiser_stays_clean_no_alias() -> None: + # Same guard via the direct dotted-lookup path (no alias_map). + out = _vt( + "def f(p):\n x = cfg.validate(p)\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"cfg.validate": T.ASSURED}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +def test_clean_local_shadow_keeps_module_entry() -> None: + # A tracked local that is NOT raw (INTEGRAL) does not trigger suppression — the + # module entry still resolves (both are clean, so no soundness issue and no + # spurious change). Confirms suppression is gated on RAW_ZONE, not mere tracking. + out = _vt( + "def f():\n cfg = 42\n x = cfg.validate()\n", + function_taint=T.INTEGRAL, + taint_map={"cfg.validate": T.ASSURED}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +# ── Receiver-shadow family: chained receivers, self/cls preservation, bare-name, +# var_types typed-receiver (wardline-f6a29ce23a, consolidated) ────────────── + + +def test_chained_receiver_shadow_does_not_launder() -> None: + # a.b.method(): node.func.value is ast.Attribute. The guard must walk to the + # ROOT name 'a'; a raw 'a' shadowing a multi-component module must not inherit + # the module entry's clean taint. + out = _vt( + "def f(p):\n a = read_raw(p)\n x = a.b.urlopen()\n", + function_taint=T.INTEGRAL, + taint_map={"read_raw": T.UNKNOWN_RAW, "a.b.urlopen": T.ASSURED}, + alias_map={"a": "a"}, + ) + assert out["x"] == T.UNKNOWN_RAW, out["x"] + + +def test_self_method_cross_summary_preserved_when_self_raw() -> None: + # self.sibling() with self seeded RAW must STILL read the analyzer-injected + # cross-method summary (self/cls are never module shadows). The guard must + # exclude self/cls roots, else it demotes a legitimate clean sibling summary. + out = _vt( + "def m(self, p):\n x = self.sanitize()\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"self.sanitize": T.ASSURED}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +def test_cls_method_cross_summary_preserved_when_cls_raw() -> None: + out = _vt( + "def m(cls, p):\n x = cls.sanitize()\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"cls.sanitize": T.ASSURED}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +def test_bare_name_shadow_does_not_launder() -> None: + # foo() where foo is a raw local shadowing a clean from-imported function: + # calling raw data yields raw, never the import's clean taint. + out = _vt( + "def f(p):\n validate = read_raw(p)\n x = validate()\n", + function_taint=T.INTEGRAL, + taint_map={"read_raw": T.UNKNOWN_RAW, "validate": T.ASSURED}, + ) + assert out["x"] == T.UNKNOWN_RAW, out["x"] + + +def test_bare_name_genuine_import_call_stays_clean() -> None: + # FP GUARD: a genuine (un-shadowed) from-imported sanitiser stays clean. + out = _vt( + "def f(p):\n x = validate(p)\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"validate": T.ASSURED}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +def test_var_types_typed_receiver_resolves_via_type_when_value_raw() -> None: + # The var_types path is DELIBERATELY NOT shadow-gated: a legitimately typed + # object routinely carries a RAW-ZONE value taint (an unmodeled ``Type()`` + # constructor defaults to UNKNOWN_RAW), yet must still resolve ``Type.method`` + # via its type. Gating on the value taint would FP the ``h = Helper(); + # h.get_assured()`` pattern (test_helper_method_returning_assured_does_not_ + # false_positive). The var_types path serves typed objects, never module + # shadows (a raw ``cfg = read_raw(p)`` carries no tracked type), so leaving it + # ungated costs no shadow coverage. The narrow raw-typed-PARAMETER + + # config-sanitiser residual is accepted over that FP cost (wardline-f6a29ce23a). + out = _vt( + "def f(obj: mymod.Schema):\n x = obj.validate()\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"mymod.Schema.validate": T.ASSURED}, + alias_map={"mymod": "mymod"}, + ) + assert out["x"] == T.ASSURED, out["x"] + + +def test_encoder_method_shadow_does_not_launder() -> None: + # shlex = raw; shlex.quote(x): the _CONTEXT_ENCODERS branch must not encode a + # raw shadowed receiver into a clean GUARDED result. + out = _vt( + "def f(p):\n shlex = read_raw(p)\n x = shlex.quote(p)\n", + function_taint=T.INTEGRAL, + taint_map={"read_raw": T.UNKNOWN_RAW}, + alias_map={"shlex": "shlex"}, + ) + assert out["x"] in (T.UNKNOWN_RAW, T.EXTERNAL_RAW), out["x"] + + +def test_read_path_raw_local_shadow_does_not_launder() -> None: + # Sibling of the call-path fix on the attribute-READ path (_resolve_expr): a raw + # local shadowing a module name, READ as ``cfg.validate`` (not called), must not + # inherit the module entry's clean taint (wardline-f6a29ce23a / -6b79150e79). + out = _vt( + "def f(p):\n cfg = read_raw(p)\n x = cfg.validate\n", + function_taint=T.INTEGRAL, + taint_map={"read_raw": T.UNKNOWN_RAW, "cfg.validate": T.ASSURED}, + ) + assert out["x"] == T.UNKNOWN_RAW, out["x"] + + +def test_read_path_self_attr_cross_summary_preserved_when_self_raw() -> None: + # self. cross-method summary must STILL be read even when self is raw — + # the read-path guard must exclude self/cls (same as the call path). + out = _vt( + "def m(self, p):\n x = self.cache\n", + function_taint=T.UNKNOWN_RAW, + taint_map={"self.cache": T.ASSURED}, + ) + assert out["x"] == T.ASSURED, out["x"] diff --git a/tests/unit/scanner/test_analyzer.py b/tests/unit/scanner/test_analyzer.py index cfc6295c..7f3cfdc9 100644 --- a/tests/unit/scanner/test_analyzer.py +++ b/tests/unit/scanner/test_analyzer.py @@ -47,6 +47,35 @@ def fingerprint(self) -> str: assert ctx.project_taints["pkg.service.fetch"] == T.MIXED_RAW +def test_analyze_emits_collision_diagnostic_through_the_real_chokepoint(tmp_path) -> None: + # Wiring proof for the no-collision guard (wardline-8fb773a7af). The unit tests + # exercise build_collision_findings in isolation; this proves the guard is + # actually live on the analyze() return path — that two distinct rule findings + # sharing a fingerprint surface a WLN-ENGINE-FINGERPRINT-COLLISION DEFECT in the + # emitted set. Without this, an inert wiring would fail SILENTLY — the exact + # failure the guard exists to prevent. + from wardline.core.finding import Finding, Location, Severity + + _write(tmp_path, "app.py", "def f():\n return 1\n") + shared_fp = "c0ffee" + "0" * 58 + + class _CollidingRegistry: + def run(self, context): # noqa: ANN001, ANN202 + return [ + Finding("PY-WL-114", "first", Severity.ERROR, Kind.DEFECT, Location("app.py", 1), shared_fp), + Finding("PY-WL-114", "second", Severity.ERROR, Kind.DEFECT, Location("app.py", 1), shared_fp), + ] + + analyzer = WardlineAnalyzer(registry=_CollidingRegistry()) + findings = analyzer.analyze([tmp_path / "app.py"], WardlineConfig(), root=tmp_path) + + collisions = [f for f in findings if f.rule_id == "WLN-ENGINE-FINGERPRINT-COLLISION"] + assert len(collisions) == 1 + assert collisions[0].kind == Kind.DEFECT + assert collisions[0].severity == Severity.ERROR + assert collisions[0].properties["colliding_fingerprint"] == shared_fp + + def test_analyzer_emits_unknown_import_fact(tmp_path) -> None: _write(tmp_path, "app.py", "from some_external_lib import thing\ndef f(): return thing()\n") analyzer = WardlineAnalyzer() @@ -103,12 +132,12 @@ def _boom(stage_input): # noqa: ANN001, ANN202 assert ctx is not None assert ctx.function_var_taints["m.a"] == {} # L2 contained assert "b" in ctx.function_var_taints["m.b"] or ctx.function_var_taints["m.b"] == {} - # The contained function is NOT silently dropped — a FACT records the skip - # so its absent return taint is observable, not an invisible under-taint. + # The contained function is NOT silently dropped: the skip is gate-eligible + # and its actual return is pessimistic rather than absent. skips = [f for f in findings if f.rule_id == "WLN-ENGINE-FUNCTION-SKIPPED"] assert [f.qualname for f in skips] == ["m.a"] - assert all(f.kind == Kind.FACT for f in skips) - assert "m.a" not in ctx.function_return_taints + assert all(f.kind == Kind.DEFECT for f in skips) + assert ctx.function_return_taints["m.a"] == T.UNKNOWN_RAW def test_analyzer_default_provider_seeds_from_decorators(tmp_path) -> None: @@ -164,12 +193,23 @@ def test_analyzer_seeded_taints_drive_transitive_propagation(tmp_path) -> None: assert ctx.project_taints["m.mix"] == T.UNKNOWN_RAW # raw, floored — NOT MIXED_RAW -def test_analyzer_skips_unparseable_file_with_fact(tmp_path) -> None: +def test_analyzer_skips_unparseable_file_with_gating_defect(tmp_path) -> None: + # A discovered-but-unparseable file is a GATE-ELIGIBLE ERROR DEFECT (fail-closed: + # its sinks were never analyzed, so the default --fail-on ERROR loop must not read + # GREEN over it) — was a non-gating NONE FACT before the secure-gate change. + from wardline.core.finding import Severity + _write(tmp_path, "bad.py", "def f(:\n") # syntax error _write(tmp_path, "good.py", "def g(): return 1\n") analyzer = WardlineAnalyzer() findings = analyzer.analyze([tmp_path / "bad.py", tmp_path / "good.py"], WardlineConfig(), root=tmp_path) - assert any(f.rule_id == "WLN-ENGINE-PARSE-ERROR" and f.kind == Kind.FACT for f in findings) + parse_errors = [f for f in findings if f.rule_id == "WLN-ENGINE-PARSE-ERROR"] + assert len(parse_errors) == 1 + assert parse_errors[0].kind == Kind.DEFECT + assert parse_errors[0].severity == Severity.ERROR + # line_start is always set, so the lineless-DEFECT downgrade never demotes it. + assert parse_errors[0].location.line_start is not None + # The unparseable file is isolated; the clean sibling is still analysed. assert analyzer.last_context is not None assert "good.g" in analyzer.last_context.project_taints @@ -228,3 +268,69 @@ def test_analyzer_exposes_return_taints_and_resolves_validators(tmp_path) -> Non # actual returned-value taint per function assert ctx.function_return_taints["service.safe"] == T.ASSURED # validated -> clean assert ctx.function_return_taints["service.leaky"] == T.EXTERNAL_RAW # leaks raw + + +def test_config_source_matching_entity_qualname_is_not_reported_unused(tmp_path) -> None: + # An untrusted_sources entry naming a PROJECT ENTITY QUALNAME seeds that entity + # EXTERNAL_RAW (the directive takes effect) — it must therefore be recorded as + # matched, never reported WLN-CONFIG-UNUSED-SOURCE. Before the fix only the + # import/alias path recorded matches, so a WORKING directive was misreported as + # a "configuration error" — pushing an agent to delete a load-bearing entry. + _write( + tmp_path, + "m.py", + "from wardline.decorators import trusted\n" + "def get_input():\n return 'x'\n" + "@trusted(level='ASSURED')\ndef f():\n return get_input()\n", + ) + config = WardlineConfig(untrusted_sources=("m.get_input",)) + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([tmp_path / "m.py"], config, root=tmp_path) + # The seed demonstrably took effect: f leaks the EXTERNAL_RAW source. + ctx = analyzer.last_context + assert ctx is not None + assert ctx.project_return_taints["m.get_input"] == T.EXTERNAL_RAW + # ...so the diagnostic must not contradict it. + assert not any(f.rule_id == "WLN-CONFIG-UNUSED-SOURCE" for f in findings) + + +def test_config_source_matching_nothing_is_still_reported_unused(tmp_path) -> None: + # The diagnostic itself stays live: a source matching neither an import nor a + # project entity is still a (probable) configuration error worth surfacing. + _write(tmp_path, "m.py", "def f():\n return 1\n") + config = WardlineConfig(untrusted_sources=("nowhere.get_input",)) + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([tmp_path / "m.py"], config, root=tmp_path) + unused = [f for f in findings if f.rule_id == "WLN-CONFIG-UNUSED-SOURCE"] + assert [f.properties["source"] for f in unused] == ["nowhere.get_input"] + + +def test_l2_fixed_point_scales_linearly_with_function_count(tmp_path) -> None: + # Regression pin for the O(n^2) blowup: the L2 fixed-point folded the whole + # project's return-taint map into EVERY function's taint map AND memo key, so + # each of N functions paid O(N) per iteration (N=2000 took ~3.3s, N=4000 + # ~13.2s pre-fix — a clean 4x per doubling). With per-function key pruning the + # scan must scale ~linearly: a 4x corpus may not cost anywhere near 16x. + import time + + def _scan_n(n: int, name: str) -> float: + src = "".join(f"def f{i}(x):\n return x\n" for i in range(n)) + root = tmp_path / name + root.mkdir() + (root / "m.py").write_text(src, encoding="utf-8") + analyzer = WardlineAnalyzer() + start = time.perf_counter() + analyzer.analyze([root / "m.py"], WardlineConfig(), root=root) + return time.perf_counter() - start + + small = _scan_n(800, "small") + large = _scan_n(3200, "large") + # Linear scaling gives ~4x; the pre-fix quadratic gave ~16x. 10x is the alarm + # threshold with generous headroom for timer noise on a loaded machine. + assert large < small * 10, f"L2 scan no longer scales linearly: 800→{small:.3f}s, 3200→{large:.3f}s" + # Absolute backstop for a quadratic regression: pre-fix 3200 functions took ~8s+ + # (N=4000 ~13.2s), so a real blowup trips this easily. The bound is deliberately + # loose — a shared CI runner clocks the linear path at ~3-4.5s with timer noise, so + # a tight 4.0s flaked; 8.0s still sits well below any quadratic path. The ratio + # check above is the primary linear-scaling guard. + assert large < 8.0, f"3200-function scan took {large:.2f}s — quadratic regression?" diff --git a/tests/unit/scanner/test_analyzer_isolation.py b/tests/unit/scanner/test_analyzer_isolation.py new file mode 100644 index 00000000..6a328e3d --- /dev/null +++ b/tests/unit/scanner/test_analyzer_isolation.py @@ -0,0 +1,318 @@ +# tests/unit/scanner/test_analyzer_isolation.py +"""Per-file / per-rule exception isolation (analyzer robustness). + +Before this suite existed, any non-RecursionError raised by the L2 walk or by a +single rule aborted the ENTIRE scan (exit 2, zero findings) — one pathological +file lost every other file's findings, asymmetric with the Rust frontend's +WLN-ENGINE-FILE-FAILED per-file fence. The Python contract pinned here: + + * an unexpected exception during one file's analysis becomes a GATE-ELIGIBLE + WLN-ENGINE-FILE-FAILED ERROR DEFECT naming the file (fail-closed — engine + bugs surface, never silently skip), and the scan continues; + * a crashing rule becomes a WLN-ENGINE-RULE-FAILED ERROR DEFECT at ENGINE_PATH + and every other rule still contributes its findings; + * parse failures (syntax/encoding/null bytes) are gate-eligible ERROR DEFECTs + so the default ``--fail-on ERROR`` loop cannot read GREEN over unscanned code. +""" + +from __future__ import annotations + +import ast +import textwrap +from pathlib import Path + +from wardline.core.config import WardlineConfig +from wardline.core.finding import ENGINE_PATH, UNANALYZED_RULE_IDS, Kind, Severity +from wardline.core.run import gate_decision, run_scan +from wardline.scanner.analyzer import WardlineAnalyzer +from wardline.scanner.context import RuleRegistry + + +def _write(root: Path, rel: str, src: str) -> Path: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(textwrap.dedent(src), encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Per-file isolation: an L2 engine exception fails ONE file, not the scan. +# --------------------------------------------------------------------------- + + +def _boom_on_marker(monkeypatch, exc: Exception) -> None: + """Make the L2 stage raise ``exc`` for any function whose body names ``boom``.""" + import wardline.scanner.analyzer as analyzer_mod + + real = analyzer_mod.run_l2_function_stage + + def _boom(stage_input): # noqa: ANN001, ANN202 + if any(isinstance(n, ast.Name) and n.id == "boom" for n in ast.walk(stage_input.node)): + raise exc + return real(stage_input) + + monkeypatch.setattr(analyzer_mod, "run_l2_function_stage", _boom) + + +def test_l2_engine_exception_fails_file_not_scan(tmp_path, monkeypatch) -> None: + _boom_on_marker(monkeypatch, ValueError("synthetic engine defect")) + _write(tmp_path, "broken.py", "def a():\n boom = 1\n return boom\n") + _write(tmp_path, "clean.py", "def ok():\n return 1\n") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([tmp_path / "broken.py", tmp_path / "clean.py"], WardlineConfig(), root=tmp_path) + + failed = [f for f in findings if f.rule_id == "WLN-ENGINE-FILE-FAILED"] + assert len(failed) == 1 + # Fail-closed contract: gate-eligible ERROR DEFECT naming the file, with a + # line anchor so the lineless-DEFECT downgrade cannot demote it out of the gate. + assert failed[0].kind == Kind.DEFECT + assert failed[0].severity == Severity.ERROR + assert failed[0].location.path == "broken.py" + assert failed[0].location.line_start is not None + assert "ValueError" in failed[0].message + # Counted toward ScanSummary.unanalyzed (single source of truth in core.finding). + assert "WLN-ENGINE-FILE-FAILED" in UNANALYZED_RULE_IDS + # The scan CONTINUED: the clean sibling is fully analysed. + ctx = analyzer.last_context + assert ctx is not None + assert "clean.ok" in ctx.project_taints + + +def test_l2_engine_exception_emits_one_finding_per_file(tmp_path, monkeypatch) -> None: + # The fingerprint is keyed on the relpath (the Rust frontend contract), so two + # failing entities in ONE file must collapse to one finding — never two distinct + # findings sharing a fingerprint (that would trip the collision guard). + _boom_on_marker(monkeypatch, ValueError("synthetic engine defect")) + _write( + tmp_path, + "broken.py", + "def a():\n boom = 1\n return boom\ndef b():\n boom = 2\n return boom\n", + ) + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([tmp_path / "broken.py"], WardlineConfig(), root=tmp_path) + failed = [f for f in findings if f.rule_id == "WLN-ENGINE-FILE-FAILED"] + assert len(failed) == 1 + assert not any(f.rule_id == "WLN-ENGINE-FINGERPRINT-COLLISION" for f in findings) + + +def test_recursion_error_still_yields_function_skip_fact(tmp_path, monkeypatch) -> None: + # The broad fence must NOT swallow the dedicated RecursionError boundary — + # too-deep functions keep their dedicated WLN-ENGINE-FUNCTION-SKIPPED finding. + _boom_on_marker(monkeypatch, RecursionError("simulated deep L2")) + _write(tmp_path, "deep.py", "def a():\n boom = 1\n return boom\n") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([tmp_path / "deep.py"], WardlineConfig(), root=tmp_path) + skipped = [f for f in findings if f.rule_id == "WLN-ENGINE-FUNCTION-SKIPPED"] + assert len(skipped) == 1 + assert skipped[0].kind == Kind.DEFECT + assert skipped[0].severity == Severity.ERROR + assert not any(f.rule_id == "WLN-ENGINE-FILE-FAILED" for f in findings) + + +def test_parse_stage_engine_exception_fails_file_not_scan(tmp_path, monkeypatch) -> None: + # An unexpected exception while indexing/seeding ONE file (parse stage) is also + # fenced per-file: WLN-ENGINE-FILE-FAILED for the named file, sibling analysed. + import wardline.scanner.pipeline as pipeline_mod + + real = pipeline_mod.seed_function_taints + + def _boom(entities, *, ctx, provider): # noqa: ANN001, ANN202 + if ctx.module == "broken": + raise KeyError("synthetic seeding defect") + return real(entities, ctx=ctx, provider=provider) + + monkeypatch.setattr(pipeline_mod, "seed_function_taints", _boom) + _write(tmp_path, "broken.py", "def a():\n return 1\n") + _write(tmp_path, "clean.py", "def ok():\n return 1\n") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([tmp_path / "broken.py", tmp_path / "clean.py"], WardlineConfig(), root=tmp_path) + failed = [f for f in findings if f.rule_id == "WLN-ENGINE-FILE-FAILED"] + assert len(failed) == 1 + assert failed[0].location.path == "broken.py" + assert failed[0].kind == Kind.DEFECT + assert failed[0].severity == Severity.ERROR + ctx = analyzer.last_context + assert ctx is not None + assert "clean.ok" in ctx.project_taints + + +# --------------------------------------------------------------------------- +# Per-rule isolation: one crashing rule loses its own findings only. +# --------------------------------------------------------------------------- + + +class _RaisingRule: + rule_id = "PY-WL-TEST-BOOM" + + def check(self, context): # noqa: ANN001, ANN202 + raise RuntimeError("synthetic rule defect") + + +def test_crashing_rule_is_isolated_and_fails_closed(tmp_path) -> None: + # Build the default registry PLUS a raising rule: the default rules' findings + # must survive, and the crash surfaces as a gate-eligible ERROR DEFECT. + from wardline.scanner.rules import build_default_registry + + registry = build_default_registry(WardlineConfig()) + registry.register(_RaisingRule()) + _write( + tmp_path, + "svc.py", + "import subprocess\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\n" + "def run(p):\n subprocess.run(read_raw(p), shell=True)\n return 1\n", + ) + analyzer = WardlineAnalyzer(registry=registry) + findings = analyzer.analyze([tmp_path / "svc.py"], WardlineConfig(), root=tmp_path) + + rule_failed = [f for f in findings if f.rule_id == "WLN-ENGINE-RULE-FAILED"] + assert len(rule_failed) == 1 + assert rule_failed[0].kind == Kind.DEFECT + assert rule_failed[0].severity == Severity.ERROR + # ENGINE_PATH location: exempt from the lineless-DEFECT downgrade, so it gates. + assert rule_failed[0].location.path == ENGINE_PATH + assert rule_failed[0].properties["rule"] == "PY-WL-TEST-BOOM" + # The other rules still ran — the real sink finding is NOT lost. + assert any(f.rule_id.startswith("PY-WL-") and f.kind is Kind.DEFECT for f in findings) + + +def test_duck_typed_registry_seam_still_runs(tmp_path) -> None: + # A test-seam registry exposing only run() (no .rules) keeps the undelegated call. + class _Custom: + def run(self, context): # noqa: ANN001, ANN202 + return [] + + _write(tmp_path, "m.py", "def f():\n return 1\n") + analyzer = WardlineAnalyzer(registry=_Custom()) + findings = analyzer.analyze([tmp_path / "m.py"], WardlineConfig(), root=tmp_path) + assert any(f.rule_id == "WLN-ENGINE-METRICS" for f in findings) + + +def test_registry_rules_maturity_stamp_survives_per_rule_dispatch(tmp_path) -> None: + # The per-rule dispatch reuses RuleRegistry.run, so a PREVIEW rule's findings + # still carry their maturity stamp (the stamping must not be lost to isolation). + from dataclasses import dataclass + + from wardline.core.finding import Finding, Location, Maturity + + @dataclass(frozen=True) + class _Meta: + maturity: Maturity = Maturity.PREVIEW + + class _PreviewRule: + rule_id = "PY-WL-TEST-PREVIEW" + metadata = _Meta() + + def check(self, context): # noqa: ANN001, ANN202 + return [ + Finding( + rule_id="PY-WL-TEST-PREVIEW", + message="preview finding", + severity=Severity.WARN, + kind=Kind.DEFECT, + location=Location(path="m.py", line_start=1), + fingerprint="ab" * 32, + ) + ] + + registry = RuleRegistry() + registry.register(_PreviewRule()) + _write(tmp_path, "m.py", "def f():\n return 1\n") + analyzer = WardlineAnalyzer(registry=registry) + findings = analyzer.analyze([tmp_path / "m.py"], WardlineConfig(), root=tmp_path) + preview = [f for f in findings if f.rule_id == "PY-WL-TEST-PREVIEW"] + assert len(preview) == 1 + assert preview[0].maturity is Maturity.PREVIEW + + +# --------------------------------------------------------------------------- +# Gate eligibility: unscanned code must not pass `--fail-on ERROR` (fail-open fix). +# --------------------------------------------------------------------------- + + +def test_unparseable_file_trips_default_fail_on_error_gate(tmp_path) -> None: + # The exact fail-open from the robustness review: a syntax error wrapping a real + # command sink used to read PASSED/exit 0 under the documented agent loop. + proj = tmp_path / "proj" + proj.mkdir() + (proj / "bad.py").write_text("import subprocess\ndef f(\n subprocess.run(evil)\n", encoding="utf-8") + result = run_scan(proj) + decision = gate_decision(result, Severity.ERROR) + assert decision.tripped is True + assert decision.verdict == "FAILED" + assert result.summary.unanalyzed == 1 + + +def test_runnable_but_undecodable_encoding_trips_gate(tmp_path) -> None: + # The strengthened repro: a latin-1 coding cookie CPython tokenizes and RUNS, + # but the scanner's UTF-8 read rejects — live code invisible to analysis must + # not read GREEN. The encoding error carries no line; the defect must still + # gate (line_start falls back, dodging the lineless-DEFECT downgrade). + proj = tmp_path / "proj" + proj.mkdir() + (proj / "enc.py").write_bytes(b'# -*- coding: latin-1 -*-\nimport os\nos.system("ls \xe9")\n') + result = run_scan(proj) + parse_errors = [f for f in result.findings if f.rule_id == "WLN-ENGINE-PARSE-ERROR"] + assert len(parse_errors) == 1 + assert parse_errors[0].kind is Kind.DEFECT + assert parse_errors[0].location.line_start is not None + decision = gate_decision(result, Severity.ERROR) + assert decision.tripped is True + + +def test_null_byte_file_is_gate_eligible(tmp_path) -> None: + # Null bytes raise SyntaxError on current CPython and ValueError on some others; + # either way the file must surface as a gate-eligible under-scan defect. + proj = tmp_path / "proj" + proj.mkdir() + (proj / "nul.py").write_bytes(b"import os\x00\nos.system('x')\n") + result = run_scan(proj) + under_scan = [f for f in result.findings if f.rule_id in {"WLN-ENGINE-PARSE-ERROR", "WLN-ENGINE-FILE-FAILED"}] + assert len(under_scan) == 1 + assert under_scan[0].kind is Kind.DEFECT + assert under_scan[0].severity is Severity.ERROR + assert gate_decision(result, Severity.ERROR).tripped is True + + +def test_duplicate_project_fqns_trip_default_fail_on_error_gate(tmp_path) -> None: + # A default repository-root scan maps both pkg/foo.py and src/pkg/foo.py to + # pkg.foo. Duplicate function qualnames must fail loud before a later module + # can hide an unsafe summary under the same project-wide key. + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "pkg/foo.py", "def f(p):\n return p\n") + _write(proj, "src/pkg/foo.py", "def f(p):\n return 'safe'\n") + + result = run_scan(proj) + + duplicates = [f for f in result.findings if f.rule_id == "WLN-ENGINE-DUPLICATE-FQN"] + assert len(duplicates) == 1 + assert duplicates[0].kind is Kind.DEFECT + assert duplicates[0].severity is Severity.ERROR + assert "pkg.foo.f" in duplicates[0].message + assert gate_decision(result, Severity.ERROR).tripped is True + + +def test_parse_error_gates_in_secure_population_but_baseline_annotates(tmp_path) -> None: + # Secure-by-default precedent: a committed baseline ANNOTATES the parse-error + # defect (suppressed in the emitted findings) but cannot clear the secure gate; + # --trust-suppressions (trusted checkout) honors it. + from wardline.core.finding import FINGERPRINT_SCHEME + + proj = tmp_path / "proj" + proj.mkdir() + (proj / "bad.py").write_text("def f(:\n", encoding="utf-8") + fp = next(f.fingerprint for f in run_scan(proj).findings if f.rule_id == "WLN-ENGINE-PARSE-ERROR") + bl = proj / ".weft" / "wardline" / "baseline.yaml" + bl.parent.mkdir(parents=True, exist_ok=True) + bl.write_text( + f"fingerprint_scheme: {FINGERPRINT_SCHEME}\nversion: 1\nentries:\n" + f" - fingerprint: {fp}\n rule_id: WLN-ENGINE-PARSE-ERROR\n path: bad.py\n message: m\n", + encoding="utf-8", + ) + secure = run_scan(proj) + assert gate_decision(secure, Severity.ERROR).tripped is True # baseline cannot clear the gate + trusted = run_scan(proj, trust_suppressions=True) + assert gate_decision(trusted, Severity.ERROR).tripped is False # explicit operator trust diff --git a/tests/unit/scanner/test_diagnostics.py b/tests/unit/scanner/test_diagnostics.py index b0ec4645..6007a748 100644 --- a/tests/unit/scanner/test_diagnostics.py +++ b/tests/unit/scanner/test_diagnostics.py @@ -2,8 +2,10 @@ import ast -from wardline.core.finding import Kind, Severity +from wardline.core.finding import ENGINE_PATH, Finding, Kind, Location, Severity +from wardline.scanner.diagnostics import _fingerprint as _diag_fp from wardline.scanner.diagnostics import ( + build_collision_findings, build_diagnostic_findings, build_metric_finding, build_unknown_import_findings, @@ -42,6 +44,7 @@ def test_l3_diagnostic_findings_map_code_to_severity() -> None: ("L3_MONOTONICITY_VIOLATION", "func x moved up"), ("L3_CONVERGENCE_BOUND", "SCC of size 3 hit bound"), ("L3_LOW_RESOLUTION", "Function m.f has 80% unresolved (4/5)"), + ("DUPLICATE_FQN", "duplicate m.f"), ] out = {f.rule_id: f for f in build_diagnostic_findings(diags)} assert out["WLN-L3-MONOTONICITY-VIOLATION"].severity == Severity.ERROR @@ -49,6 +52,8 @@ def test_l3_diagnostic_findings_map_code_to_severity() -> None: assert out["WLN-L3-CONVERGENCE-BOUND"].severity == Severity.WARN assert out["WLN-L3-LOW-RESOLUTION"].severity == Severity.INFO assert out["WLN-L3-LOW-RESOLUTION"].kind == Kind.METRIC + assert out["WLN-ENGINE-DUPLICATE-FQN"].severity == Severity.ERROR + assert out["WLN-ENGINE-DUPLICATE-FQN"].kind == Kind.DEFECT def test_unknown_diagnostic_code_is_error_not_silent() -> None: @@ -57,6 +62,82 @@ def test_unknown_diagnostic_code_is_error_not_silent() -> None: assert "MYSTERY_CODE" in out[0].message +# --- Fingerprint-collision guard (wardline-8fb773a7af) ----------------------- +# Every fingerprint consumer (baseline/judged/waivers/filigree_emit/sarif) treats +# Finding.fingerprint as a UNIQUE join key — baseline (setdefault) and judged +# (last-write-wins) SILENTLY collapse same-fp findings, the three YAML loaders +# REJECT duplicate fps, and SARIF/Filigree dedup downstream. So two *distinct* +# findings sharing a fingerprint is always a soundness defect (one masks the +# other). Two *byte-identical* findings are a benign duplicate (collapsing loses +# nothing) and must NOT fire. The guard converts the silent mask into a loud +# ERROR/DEFECT engine diagnostic at the analyzer chokepoint. + + +def _forge(fp: str, *, rule_id: str = "PY-WL-114", message: str = "m", line: int | None = 3) -> Finding: + return Finding( + rule_id=rule_id, + message=message, + severity=Severity.ERROR, + kind=Kind.DEFECT, + location=Location(path="a.py", line_start=line), + fingerprint=fp, + ) + + +def test_collision_guard_flags_distinct_findings_sharing_a_fingerprint() -> None: + fp = "a" * 64 + out = build_collision_findings([_forge(fp, message="first"), _forge(fp, message="second")]) + assert len(out) == 1 + diag = out[0] + assert diag.rule_id == "WLN-ENGINE-FINGERPRINT-COLLISION" + assert diag.severity == Severity.ERROR # trips --fail-on ERROR (WLN-L3-MONOTONICITY precedent) + assert diag.kind == Kind.DEFECT + assert diag.location.path == ENGINE_PATH # lineless ENGINE_PATH DEFECT still gates (suppression.py:40) + assert fp in diag.message + assert diag.properties["colliding_fingerprint"] == fp + assert diag.properties["finding_count"] == 2 + + +def test_collision_guard_ignores_byte_identical_duplicates() -> None: + # A rule that emits the SAME finding twice is a benign duplicate — collapsing + # on the join key loses nothing, so it is NOT a collision. + fp = "b" * 64 + out = build_collision_findings([_forge(fp, message="same"), _forge(fp, message="same")]) + assert out == [] + + +def test_collision_guard_clean_set_emits_nothing() -> None: + out = build_collision_findings([_forge("c" * 64), _forge("d" * 64, rule_id="PY-WL-101")]) + assert out == [] + + +def test_collision_guard_distinguishes_on_any_consumer_visible_field() -> None: + # Same fp, identical message, but differing properties => still a lossy collapse. + fp = "e" * 64 + a = Finding("PY-WL-114", "m", Severity.ERROR, Kind.DEFECT, Location("a.py", 3), fp, properties={"k": 1}) + b = Finding("PY-WL-114", "m", Severity.ERROR, Kind.DEFECT, Location("a.py", 3), fp, properties={"k": 2}) + out = build_collision_findings([a, b]) + assert len(out) == 1 + # finding_count and members must agree on the SAME distinctness key — a + # properties-only difference must be counted AND listed (one member per distinct). + assert out[0].properties["finding_count"] == 2 + assert len(out[0].properties["members"]) == 2 + assert {m["properties"]["k"] for m in out[0].properties["members"]} == {1, 2} + + +def test_collision_guard_is_deterministic_and_per_group() -> None: + fp1, fp2 = "1" * 64, "2" * 64 + # Pass groups out of fingerprint order to prove the output is sorted by colliding fp. + out = build_collision_findings( + [_forge(fp2, message="x"), _forge(fp1, message="y"), _forge(fp2, message="z"), _forge(fp1, message="w")] + ) + assert [d.properties["colliding_fingerprint"] for d in out] == [fp1, fp2] + # Each diagnostic's OWN fingerprint is keyed on its colliding fp => distinct + stable. + assert out[0].fingerprint == _diag_fp("WLN-ENGINE-FINGERPRINT-COLLISION", fp1) + assert out[1].fingerprint == _diag_fp("WLN-ENGINE-FINGERPRINT-COLLISION", fp2) + assert out[0].fingerprint != out[1].fingerprint + + def test_diagnose_unknown_imports_flags_external_named_import() -> None: tree = ast.parse("from external_pkg import thing\n") out = diagnose_unknown_imports( @@ -70,6 +151,31 @@ def test_diagnose_unknown_imports_flags_external_named_import() -> None: assert "external_pkg" in out[0][2] +def test_diagnose_unknown_imports_flags_external_plain_import() -> None: + tree = ast.parse("import external_pkg\n") + out = diagnose_unknown_imports( + tree=tree, + module_path="m", + project_modules=frozenset({"m"}), + stdlib_keys=frozenset(), + ) + assert len(out) == 1 + assert out[0][1] == "import external_pkg" + assert "external_pkg" in out[0][2] + + +def test_diagnose_unknown_imports_flags_external_aliased_plain_import() -> None: + tree = ast.parse("import external_pkg as e\n") + out = diagnose_unknown_imports( + tree=tree, + module_path="m", + project_modules=frozenset({"m"}), + stdlib_keys=frozenset(), + ) + assert len(out) == 1 + assert out[0][1] == "import external_pkg" + + def test_diagnose_unknown_imports_skips_stdlib_and_project_and_relative() -> None: tree = ast.parse( "import os\n" @@ -87,7 +193,7 @@ def test_diagnose_unknown_imports_skips_stdlib_and_project_and_relative() -> Non def test_unknown_import_findings_are_facts() -> None: - tree = ast.parse("from external_pkg import thing\n") + tree = ast.parse("import external_pkg\n") findings = build_unknown_import_findings( [("pkg/mod.py", "pkg.mod", tree)], project_modules=frozenset({"pkg.mod"}), @@ -95,6 +201,7 @@ def test_unknown_import_findings_are_facts() -> None: assert len(findings) == 1 assert findings[0].kind == Kind.FACT assert findings[0].rule_id == "WLN-ENGINE-UNKNOWN-IMPORT" + assert findings[0].properties["package"] == "external_pkg" # Fingerprint stable from (module, package) — not message text. again = build_unknown_import_findings([("pkg/mod.py", "pkg.mod", tree)], project_modules=frozenset({"pkg.mod"})) assert findings[0].fingerprint == again[0].fingerprint @@ -144,10 +251,10 @@ def test_diagnose_unresolved_star_module_still_emits_fact() -> None: # --- Native / first-party module resolution (Task C) ------------------------- # When wardline.core becomes a compiled (PyO3) module it has NO Python AST in the # scanned tree, so it drops out of project_modules and would fire UNKNOWN-IMPORT. -# The declarative native-prefix allowlist resolves it. These tests SIMULATE the -# native case by passing an empty project_modules (the obvious "scan self" test -# is green today because the .py files are still present, so it would gate -# nothing). +# The declarative native import allowlist resolves exact known exports. These +# tests SIMULATE the native case by passing an empty project_modules (the obvious +# "scan self" test is green today because the .py files are still present, so it +# would gate nothing). def test_native_first_party_core_import_resolves_without_project_module() -> None: @@ -177,14 +284,35 @@ def test_native_allowlist_does_not_suppress_genuine_third_party() -> None: def test_native_allowlist_does_not_suppress_undeclared_wardline_submodule() -> None: - # Precision: only DECLARED native prefixes resolve. A wardline.* module that is - # neither a project module nor a declared native prefix must still report, so + # Precision: only DECLARED native imports resolve. A wardline.* module that is + # neither a project module nor a declared native import must still report, so # the allowlist can't silently swallow a real gap. tree = ast.parse("from wardline.experimental.zzz import q\n") out = diagnose_unknown_imports(tree=tree, module_path="x", project_modules=frozenset(), stdlib_keys=frozenset()) assert len(out) == 1 +def test_native_allowlist_does_not_suppress_unknown_decorator_export() -> None: + tree = ast.parse("from wardline.decorators import nonexistent\n") + out = diagnose_unknown_imports(tree=tree, module_path="x", project_modules=frozenset(), stdlib_keys=frozenset()) + assert len(out) == 1 + assert "nonexistent" in out[0][2] + + +def test_native_allowlist_does_not_suppress_nested_decorator_spoof() -> None: + tree = ast.parse("from wardline.decorators.evil import trusted\n") + out = diagnose_unknown_imports(tree=tree, module_path="x", project_modules=frozenset(), stdlib_keys=frozenset()) + assert len(out) == 1 + assert "wardline.decorators.evil" in out[0][2] + + +def test_native_allowlist_does_not_suppress_unknown_core_submodule() -> None: + tree = ast.parse("from wardline.core.evil import x\n") + out = diagnose_unknown_imports(tree=tree, module_path="x", project_modules=frozenset(), stdlib_keys=frozenset()) + assert len(out) == 1 + assert "wardline.core.evil" in out[0][2] + + def test_native_allowlist_prefix_boundary_is_dotted() -> None: # An adjacent prefix that shares a string-prefix but is NOT under the package # (wardline.core_helpers vs wardline.core) must NOT be suppressed — guards the diff --git a/tests/unit/scanner/test_grammar_sanitiser_collision.py b/tests/unit/scanner/test_grammar_sanitiser_collision.py new file mode 100644 index 00000000..60b2ecc0 --- /dev/null +++ b/tests/unit/scanner/test_grammar_sanitiser_collision.py @@ -0,0 +1,37 @@ +# tests/unit/scanner/test_grammar_sanitiser_collision.py +"""WLN-CONFIG-SANITISER-SINK-COLLISION — a config sanitiser naming a built-in +serialisation sink can never take effect (the conservative sink override wins), +yet it still counts as "matched", suppressing WLN-CONFIG-UNUSED-SANITISER. The +collision must surface as an explicit config-diagnostic FACT, not a silent no-op. +""" + +from __future__ import annotations + +from wardline.core.finding import Kind, Severity +from wardline.scanner.grammar import build_sanitiser_collision_findings + + +def test_colliding_sanitiser_emits_diagnostic() -> None: + findings = build_sanitiser_collision_findings(("json.loads",)) + assert [f.rule_id for f in findings] == ["WLN-CONFIG-SANITISER-SINK-COLLISION"] + f = findings[0] + assert f.kind is Kind.FACT + assert f.severity is Severity.NONE # diagnostic, not a gate-able defect + assert f.location.path == "weft.toml" # config diagnostics point at the config surface + assert "json.loads" in f.message + assert "serialisation sink" in f.message + assert f.properties["sanitiser"] == "json.loads" + + +def test_non_colliding_sanitiser_is_silent() -> None: + assert build_sanitiser_collision_findings(("mylib.clean",)) == [] + + +def test_empty_config_is_silent() -> None: + assert build_sanitiser_collision_findings(()) == [] + + +def test_multiple_collisions_sorted_with_distinct_fingerprints() -> None: + findings = build_sanitiser_collision_findings(("pickle.loads", "mylib.clean", "json.loads")) + assert [f.properties["sanitiser"] for f in findings] == ["json.loads", "pickle.loads"] + assert len({f.fingerprint for f in findings}) == 2 diff --git a/tests/unit/scanner/test_module_bindings.py b/tests/unit/scanner/test_module_bindings.py new file mode 100644 index 00000000..949b60ca --- /dev/null +++ b/tests/unit/scanner/test_module_bindings.py @@ -0,0 +1,149 @@ +"""Module-level binding channel (wardline-13cfdd7b31 / wardline-66b2c91470). + +Module-scope simple bindings — ``runner = subprocess.run`` (callable alias), +``client = httpx.Client()`` (constructed instance) — are collected per module +onto ``AnalysisContext.module_bindings`` and layered UNDER each function's own +bindings by the sink machinery (:func:`resolved_sink_calls`), closing the +documented v1 module-level false negatives: a module-level callable alias used +in a function now fires PY-WL-112/108, a module-level constructed client fires +PY-WL-117. +""" + +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING + +from wardline.core.config import WardlineConfig +from wardline.scanner.analyzer import WardlineAnalyzer + +if TYPE_CHECKING: + from pathlib import Path + +_HEADER = ( + "import os, pickle, subprocess\n" + "import httpx\n" + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" +) + + +def _scan(tmp_path: Path, src: str): + p = tmp_path / "m.py" + p.write_text(_HEADER + textwrap.dedent(src), encoding="utf-8") + analyzer = WardlineAnalyzer() + findings = analyzer.analyze([p], WardlineConfig(), root=tmp_path) + assert analyzer.last_context is not None + return findings, analyzer.last_context + + +def _hits(findings, rule_id: str) -> list[tuple[str, str | None]]: + return [(f.rule_id, f.qualname) for f in findings if f.rule_id == rule_id] + + +def test_context_module_bindings_collects_module_scope_bindings(tmp_path) -> None: + _, ctx = _scan( + tmp_path, + """ + runner = subprocess.run + client = httpx.Client() + + @trusted(level='ASSURED') + def f(p): + return 1 + """, + ) + bindings = ctx.module_bindings["m"] + assert bindings.callable_aliases["runner"] == "subprocess.run" + assert bindings.instance_classes["client"] == "httpx.Client" + + +def test_112_module_level_callable_alias_fires(tmp_path) -> None: + # The exact wardline-13cfdd7b31 repro: module-scope ``runner = subprocess.run`` + # used inside a trusted function with shell=True. + findings, _ = _scan( + tmp_path, + """ + runner = subprocess.run + + @trusted(level='ASSURED') + def f(p): + runner(read_raw(p), shell=True) + """, + ) + assert _hits(findings, "PY-WL-112") == [("PY-WL-112", "m.f")] + + +def test_117_module_level_client_construction_fires(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + client = httpx.Client() + + @trusted(level='ASSURED') + def f(p): + client.get(read_raw(p)) + """, + ) + assert _hits(findings, "PY-WL-117") == [("PY-WL-117", "m.f")] + + +def test_108_module_level_callable_alias_fires(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + sh = os.system + + @trusted(level='ASSURED') + def f(p): + sh(read_raw(p)) + """, + ) + assert _hits(findings, "PY-WL-108") == [("PY-WL-108", "m.f")] + + +def test_106_module_level_callable_alias_fires(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + loader = pickle.loads + + @trusted(level='ASSURED') + def f(p): + return loader(read_raw(p)) + """, + ) + assert _hits(findings, "PY-WL-106") == [("PY-WL-106", "m.f")] + + +def test_function_local_rebind_shadows_module_binding(tmp_path) -> None: + # A function-local rebind to an unresolvable/non-sink value must shadow the + # module-level binding — no false positive on the local ``client``. + findings, _ = _scan( + tmp_path, + """ + client = httpx.Client() + + @trusted(level='ASSURED') + def f(p): + client = object() + client.get(read_raw(p)) + """, + ) + assert _hits(findings, "PY-WL-117") == [] + + +def test_module_binding_clean_argument_does_not_fire(tmp_path) -> None: + findings, _ = _scan( + tmp_path, + """ + client = httpx.Client() + + @trusted(level='ASSURED') + def f(): + client.get('https://example.com') + """, + ) + assert _hits(findings, "PY-WL-117") == [] diff --git a/tests/unit/scanner/test_noop.py b/tests/unit/scanner/test_noop.py index 5c0b8762..1a79acf3 100644 --- a/tests/unit/scanner/test_noop.py +++ b/tests/unit/scanner/test_noop.py @@ -1,9 +1,8 @@ -from pathlib import Path +import wardline.scanner as scanner +from wardline.scanner.analyzer import WardlineAnalyzer -from wardline.core.config import WardlineConfig -from wardline.scanner import NoOpAnalyzer - -def test_noop_analyzer_returns_no_findings() -> None: - result = NoOpAnalyzer().analyze([Path("a.py")], WardlineConfig(), root=Path(".")) - assert list(result) == [] +def test_noop_analyzer_is_not_exported() -> None: + assert scanner.__all__ == ["WardlineAnalyzer"] + assert not hasattr(scanner, "NoOpAnalyzer") + assert scanner.WardlineAnalyzer is WardlineAnalyzer diff --git a/tests/unit/scanner/test_pipeline.py b/tests/unit/scanner/test_pipeline.py index 79c77eb2..65099393 100644 --- a/tests/unit/scanner/test_pipeline.py +++ b/tests/unit/scanner/test_pipeline.py @@ -138,3 +138,77 @@ def test_parse_project_stage_unshadowed_fingerprint_is_bare(tmp_path) -> None: assert result.provider_fingerprint == DecoratorTaintSourceProvider().fingerprint() seed = result.modules[0].seeds["m.f"] assert seed.body_taint == T.INTEGRAL + + +def test_parse_project_stage_records_entity_qualname_config_source_match(tmp_path) -> None: + # An untrusted_sources entry naming a project entity qualname is APPLIED here + # (the seed override below) — the match must be reported back to the analyzer + # so the directive is never misreported as WLN-CONFIG-UNUSED-SOURCE. + path = tmp_path / "m.py" + path.write_text("def get_input():\n return 'x'\n", encoding="utf-8") + result = run_parse_project_stage( + ParseProjectInput( + files=(path,), + root=tmp_path, + provider=DecoratorTaintSourceProvider(), + config=WardlineConfig(untrusted_sources=("m.get_input", "elsewhere.unmatched")), + star_exports=vocabulary_star_exports(), + ) + ) + seed = result.modules[0].seeds["m.get_input"] + assert seed.body_taint == T.EXTERNAL_RAW # the directive took effect... + assert result.matched_config_sources == frozenset({"m.get_input"}) # ...and is recorded + # The unmatched entry is NOT recorded — the unused-source diagnostic stays live. + + +def test_parse_project_stage_parse_failure_is_gating_error_defect(tmp_path) -> None: + # A discovered-but-unparseable file is a gate-eligible ERROR DEFECT (fail-closed: + # unscanned code must not pass the default --fail-on ERROR loop), never a NONE + # FACT. line_start is ALWAYS set (fallback 1) so the lineless-DEFECT downgrade + # in suppression.py cannot demote a no-line encoding failure out of the gate. + from wardline.core.finding import Kind, Severity + + (tmp_path / "syntax.py").write_text("def f(:\n", encoding="utf-8") + (tmp_path / "enc.py").write_bytes(b'# -*- coding: latin-1 -*-\nx = "\xe9"\n') + result = run_parse_project_stage( + ParseProjectInput( + files=(tmp_path / "syntax.py", tmp_path / "enc.py"), + root=tmp_path, + provider=DecoratorTaintSourceProvider(), + config=WardlineConfig(), + star_exports=vocabulary_star_exports(), + ) + ) + by_path = {f.location.path: f for f in result.parse_findings} + assert set(by_path) == {"syntax.py", "enc.py"} + for finding in by_path.values(): + assert finding.rule_id == "WLN-ENGINE-PARSE-ERROR" + assert finding.kind is Kind.DEFECT + assert finding.severity is Severity.ERROR + assert finding.location.line_start is not None + # The syntax error keeps its real line; the encoding error falls back to 1. + assert by_path["syntax.py"].location.line_start == 1 + assert by_path["enc.py"].location.line_start == 1 + + +def test_parse_project_stage_recursion_skip_is_gate_eligible(tmp_path) -> None: + # A recursion-limit file skip means policy rules never ran for the file. It + # must be a gate-eligible under-scan defect, not a green severity result. + from wardline.core.finding import Kind, Severity + + expr = "p" + " + p" * 3000 + (tmp_path / "deep.py").write_text(f"def deep(p):\n x = {expr}\n return x\n", encoding="utf-8") + result = run_parse_project_stage( + ParseProjectInput( + files=(tmp_path / "deep.py",), + root=tmp_path, + provider=DecoratorTaintSourceProvider(), + config=WardlineConfig(), + star_exports=vocabulary_star_exports(), + ) + ) + skips = [f for f in result.parse_findings if f.rule_id == "WLN-ENGINE-FILE-SKIPPED"] + assert len(skips) == 1 + assert skips[0].kind is Kind.DEFECT + assert skips[0].severity is Severity.ERROR + assert skips[0].location.line_start == 1 diff --git a/tests/unit/security/test_symlink_toctou_hardening.py b/tests/unit/security/test_symlink_toctou_hardening.py new file mode 100644 index 00000000..4cd79c38 --- /dev/null +++ b/tests/unit/security/test_symlink_toctou_hardening.py @@ -0,0 +1,200 @@ +"""Security regressions for the 2026-06-15 symlink/TOCTOU review pass (PR #40). + +An untrusted checkout (wardline scans agent-supplied code) must not be able to use a +planted symlink or a forged state file to make wardline follow a link off-box, clobber +an arbitrary user-writable file, disclose an outside file into the project, or signal an +unrelated process group. Each test drives one site found by the Codex review bot. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + +from wardline.core import paths, rekey, scan_jobs +from wardline.core.errors import WardlineError +from wardline.install.doctor import _filigree_token_candidates, _rewrite_env_token + +# --- config.py: server-mode scope spoof via a symlinked store ------------------------ + + +def test_server_scope_refuses_symlinked_store(tmp_path: Path, monkeypatch) -> None: + import wardline.core.config as cfg + + victim = tmp_path / "victim" + (victim / ".weft" / "filigree").mkdir(parents=True) + attacker = tmp_path / "attacker" + (attacker / ".weft").mkdir(parents=True) + # attacker's store is a SYMLINK to victim's registered store + (attacker / ".weft" / "filigree").symlink_to(victim / ".weft" / "filigree") + reg = tmp_path / "server.json" + reg.write_text( + json.dumps({"port": 8749, "projects": {str((victim / ".weft" / "filigree").resolve()): {"prefix": "victim"}}}) + ) + monkeypatch.setattr(cfg, "_filigree_server_config_path", lambda: reg) + + # the spoofing checkout must NOT inherit victim's scoped URL... + assert cfg.filigree_server_scoped_url(attacker) is None + # ...while the legitimate (real-dir) project still resolves its own scope. + assert cfg._filigree_server_scope(victim) == (8749, "victim") + + +# --- doctor.py: auth-repair must not write through a symlinked .env ------------------- + + +def test_rewrite_env_refuses_symlinked_dotenv(tmp_path: Path) -> None: + outside = tmp_path / "victim_secret.txt" + outside.write_text("KEEP\n", encoding="utf-8") + env = tmp_path / "proj" / ".env" + env.parent.mkdir() + env.symlink_to(outside) + with pytest.raises(WardlineError, match="symlink"): + _rewrite_env_token(env, "TOKEN") + assert outside.read_text(encoding="utf-8") == "KEEP\n" # target untouched + assert "WEFT_FEDERATION_TOKEN" not in outside.read_text(encoding="utf-8") + + +# --- rekey.py: snapshot must not copy a symlinked store's target ---------------------- + + +def test_snapshot_skips_symlinked_store(tmp_path: Path) -> None: + root = tmp_path / "proj" + state = paths.weft_state_dir(root) + state.mkdir(parents=True) + outside = tmp_path / "secret.txt" + outside.write_text("TOP SECRET\n", encoding="utf-8") + (state / "baseline.yaml").symlink_to(outside) # planted symlink store + (state / "waivers.yaml").write_text("version: 2\nwaivers: []\n", encoding="utf-8") + + present = rekey.snapshot_stores(root) + snap = rekey.snapshot_dir(root) + assert "baseline.yaml" not in present # symlinked store is not snapshot-eligible + assert not (snap / "baseline.yaml").exists() + assert (snap / "waivers.yaml").exists() # the real store still snapshots + # the outside secret was never disclosed into the project snapshot + assert all(p.read_text(encoding="utf-8") != "TOP SECRET\n" for p in snap.glob("*") if p.is_file()) + + +# --- rekey.py: journal write must not follow a pre-planted .tmp symlink ------ + + +def test_write_journal_refuses_symlinked_tmp(tmp_path: Path) -> None: + root = tmp_path / "proj" + jpath = paths.migration_journal_path(root) + jpath.parent.mkdir(parents=True, exist_ok=True) + victim = tmp_path / "journal_victim.txt" + victim.write_text("KEEP\n", encoding="utf-8") + Path(str(jpath) + ".tmp").symlink_to(victim) + journal = rekey.Journal( + schema_version=1, + fingerprint_scheme_from="wlfp1", + fingerprint_scheme_to="wlfp2", + snapshot_prescheme=True, + remap={}, + collisions=(), + legs=(), + ) + with pytest.raises(WardlineError, match="symlink"): + rekey.write_journal(jpath, journal, root=root) + assert victim.read_text(encoding="utf-8") == "KEEP\n" # target untouched + + +# --- scan_jobs.py: cancel must not killpg a forged/stale PID -------------------------- + + +def test_cancel_does_not_signal_forged_pid(tmp_path: Path) -> None: + # An innocent same-user process group, NOT a wardline worker. + victim = subprocess.Popen(["sleep", "30"], start_new_session=True) # noqa: S603, S607 + try: + root = tmp_path / "proj" + job_id = uuid.uuid4().hex + jd = scan_jobs.job_dir(root, job_id) + jd.mkdir(parents=True) + # forged status.json naming the victim's pid as the "worker" + (jd / "status.json").write_text(json.dumps({"status": "running", "pid": victim.pid}), encoding="utf-8") + + result = scan_jobs.cancel_scan_job(root, job_id) + time.sleep(0.4) + assert result["status"] == "cancelled" # job is marked cancelled... + assert victim.poll() is None # ...but the innocent process group was NOT signaled + finally: + victim.terminate() + victim.wait() + + +def test_repair_token_candidates_skip_symlinked_project_mint(tmp_path: Path, monkeypatch) -> None: + # doctor --repair probes local token candidates by SENDING them to a service. The + # project store is repo-controlled in an untrusted checkout; a symlinked mint would + # exfil its target's bytes as a Bearer. The symlinked project mint must be skipped. + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + root = tmp_path / "proj" + mint = root / ".weft" / "filigree" + mint.mkdir(parents=True) + (tmp_path / "outside_secret").write_text("EXFIL-TOKEN\n", encoding="utf-8") + (mint / "federation_token").symlink_to(tmp_path / "outside_secret") + assert _filigree_token_candidates(root) == [] # symlinked mint contributes nothing + # a real regular mint is still a candidate + (mint / "federation_token").unlink() + (mint / "federation_token").write_text("GOOD\n", encoding="utf-8") + assert _filigree_token_candidates(root) == ["GOOD"] + + +def test_explicit_agent_summary_output_refuses_symlink(tmp_path: Path) -> None: + # `scan --format agent-summary -o ` must not follow a repo-controlled symlink at + # the chosen filename and clobber an arbitrary target (the default + JSONL/SARIF paths + # already use the no-follow writer). + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + victim = tmp_path / "victim.json" + victim.write_text("KEEP\n", encoding="utf-8") + out = tmp_path / "out.json" + out.symlink_to(victim) + result = CliRunner().invoke(cli, ["scan", str(project), "--format", "agent-summary", "--output", str(out)]) + assert result.exit_code == 2 # refused at the boundary + assert "symlink" in result.output + assert victim.read_text(encoding="utf-8") == "KEEP\n" # target untouched + + +def test_scan_job_explicit_agent_summary_output_refuses_symlink(tmp_path: Path) -> None: + # The scan-job WORKER agent-summary artifact write must be no-follow too (regression for + # the fa1ca063 _write_scan_artifact restructure, which lost the guard): a planted + # --output symlink must not truncate an arbitrary target. + from click.testing import CliRunner + + from wardline.cli.main import cli + + project = tmp_path / "proj" + project.mkdir() + (project / "svc.py").write_text("def ok():\n return 1\n", encoding="utf-8") + victim = tmp_path / "victim.json" + victim.write_text("KEEP\n", encoding="utf-8") + out = tmp_path / "out.json" + out.symlink_to(victim) + CliRunner().invoke( + cli, + ["scan-job", "start", str(project), "--format", "agent-summary", "--output", str(out), "--foreground"], + ) + # the job records a failed/errored artifact write rather than clobbering the target + assert victim.read_text(encoding="utf-8") == "KEEP\n" + + +def test_pid_is_scan_job_worker_rejects_non_worker_group_leader() -> None: + # A genuine group-leader that is NOT our worker (cmdline mismatch) is rejected. + victim = subprocess.Popen(["sleep", "30"], start_new_session=True) # noqa: S603, S607 + try: + assert os.getpgid(victim.pid) == victim.pid # it IS a group leader + assert scan_jobs._pid_is_scan_job_worker(victim.pid, "anyjob") is False + finally: + victim.terminate() + victim.wait() diff --git a/tests/unit/test_makefile_clean.py b/tests/unit/test_makefile_clean.py new file mode 100644 index 00000000..7e728483 --- /dev/null +++ b/tests/unit/test_makefile_clean.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +MAKE = shutil.which("make") + + +pytestmark = pytest.mark.skipif(MAKE is None, reason="make is not installed") + + +def run_make_clean(workdir: Path) -> subprocess.CompletedProcess[str]: + assert MAKE is not None + return subprocess.run( + [MAKE, "-f", str(PROJECT_ROOT / "Makefile"), "clean"], + cwd=workdir, + check=False, + text=True, + capture_output=True, + ) + + +def test_make_clean_refuses_symlinked_recursive_targets(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + keep = outside / "keep.txt" + keep.write_text("keep", encoding="utf-8") + + workdir = tmp_path / "workdir" + workdir.mkdir() + (workdir / "dist").symlink_to(outside, target_is_directory=True) + + result = run_make_clean(workdir) + + assert result.returncode != 0 + assert "refusing to remove symlink dist" in result.stderr + assert keep.read_text(encoding="utf-8") == "keep" + assert (workdir / "dist").is_symlink() + + +def test_make_clean_removes_expected_non_symlink_artifacts(tmp_path: Path) -> None: + workdir = tmp_path / "workdir" + workdir.mkdir() + for dirname in ( + "dist", + "build", + "example.egg-info", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + ): + artifact_dir = workdir / dirname + artifact_dir.mkdir() + (artifact_dir / "artifact.txt").write_text("artifact", encoding="utf-8") + for filename in (".coverage", "coverage.json"): + (workdir / filename).write_text("artifact", encoding="utf-8") + pycache = workdir / "pkg" / "__pycache__" + pycache.mkdir(parents=True) + (pycache / "module.pyc").write_text("artifact", encoding="utf-8") + + result = run_make_clean(workdir) + + assert result.returncode == 0, result.stderr + for name in ( + "dist", + "build", + "example.egg-info", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + ".coverage", + "coverage.json", + ): + assert not (workdir / name).exists() + assert not pycache.exists() diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 3bb6369c..44a8aa1c 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -3,5 +3,5 @@ def test_version_is_exported() -> None: assert isinstance(wardline.__version__, str) - # Pin the release line, not the rc suffix, so cutting a new rc doesn't break this. - assert wardline.__version__.startswith("1.0.0") + # Pin the 1.0.x release line, not the exact patch, so a point release doesn't break this. + assert wardline.__version__.startswith("1.0.") diff --git a/uv.lock b/uv.lock index 474aea73..eeb82d9f 100644 --- a/uv.lock +++ b/uv.lock @@ -344,6 +344,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "grimp" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/46/79764cfb61a3ac80dadae5d94fb10acdb7800e31fecf4113cf3d345e4952/grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871", size = 830882, upload-time = "2025-12-10T17:55:01.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/d6/a35ff62f35aa5fd148053506eddd7a8f2f6afaed31870dc608dd0eb38e4f/grimp-3.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ffabc6940301214753bad89ec0bfe275892fa1f64b999e9a101f6cebfc777133", size = 2178573, upload-time = "2025-12-10T17:53:42.836Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/bd2e80273da4d46110969fc62252e5372e0249feb872bc7fe76fdc7f1818/grimp-3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:075d9a1c78d607792d0ed8d4d3d7754a621ef04c8a95eaebf634930dc9232bb2", size = 2110452, upload-time = "2025-12-10T17:53:19.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/c3/7307249c657d34dca9d250d73ba027d6cfe15a98fb3119b6e5210bc388b7/grimp-3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ff52addeb20955a4d6aa097bee910573ffc9ef0d3c8a860844f267ad958156", size = 2283064, upload-time = "2025-12-10T17:52:07.673Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d2/cae4cf32dc8d4188837cc4ab183300d655f898969b0f169e240f3b7c25be/grimp-3.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10e0663e961fcbe8d0f54608854af31f911f164c96a44112d5173050132701f", size = 2235893, upload-time = "2025-12-10T17:52:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/3f58bc3064fc305dac107d08003ba65713a5bc89a6d327f1c06b30cce752/grimp-3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ab874d7ddddc7a1291259cf7c31a4e7b5c612e9da2e24c67c0eb1a44a624e67", size = 2393376, upload-time = "2025-12-10T17:53:02.397Z" }, + { url = "https://files.pythonhosted.org/packages/06/b8/f476f30edf114f04cb58e8ae162cb4daf52bda0ab01919f3b5b7edb98430/grimp-3.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54fec672ec83355636a852177f5a470c964bede0f6730f9ba3c7b5c8419c9eab", size = 2571342, upload-time = "2025-12-10T17:52:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ae/2e44d3c4f591f95f86322a8f4dbb5aac17001d49e079f3a80e07e7caaf09/grimp-3.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9e221b5e8070a916c780e88c877fee2a61c95a76a76a2a076396e459511b0bb", size = 2359022, upload-time = "2025-12-10T17:52:49.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/42b4d6bc0ea119ce2e91e1788feabf32c5433e9617dbb495c2a3d0dc7f12/grimp-3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea6b495f9b4a8d82f5ce544921e76d0d12017f5d1ac3a3bd2f5ac88ab055b1c", size = 2309424, upload-time = "2025-12-10T17:53:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/6a731989625c1790f4da7602dcbf9d6525512264e853cda77b3b3602d5e0/grimp-3.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:655e8d3f79cd99bb859e09c9dd633515150e9d850879ca71417d5ac31809b745", size = 2462754, upload-time = "2025-12-10T17:53:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/3d1571c0a39a59dd68be4835f766da64fe64cbab0d69426210b716a8bdf0/grimp-3.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a14f10b1b71c6c37647a76e6a49c226509648107abc0f48c1e3ecd158ba05531", size = 2501356, upload-time = "2025-12-10T17:54:06.014Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d1/8950b8229095ebda5c54c8784e4d1f0a6e19423f2847289ef9751f878798/grimp-3.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:81685111ee24d3e25f8ed9e77ed00b92b58b2414e1a1c2937236026900972744", size = 2504631, upload-time = "2025-12-10T17:54:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/23bed3da9206138d36d01890b656c7fb7adfb3a37daac8842d84d8777ade/grimp-3.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce8352a8ea0e27b143136ea086582fc6653419aa8a7c15e28ed08c898c42b185", size = 2514751, upload-time = "2025-12-10T17:54:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/6f1f55c97ee982f133ec5ccb22fc99bf5335aee70c208f4fb86cd833b8d5/grimp-3.14-cp312-cp312-win32.whl", hash = "sha256:3fc0f98b3c60d88e9ffa08faff3200f36604930972f8b29155f323b76ea25a06", size = 1875041, upload-time = "2025-12-10T17:55:13.326Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/03ba01288e2a41a948bc8526f32c2eeaddd683ed34be1b895e31658d5a4c/grimp-3.14-cp312-cp312-win_amd64.whl", hash = "sha256:6bca77d1d50c8dc402c96af21f4e28e2f1e9938eeabd7417592a22bd83cde3c3", size = 2013868, upload-time = "2025-12-10T17:55:05.907Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bd/d12a9c821b79ba31fc52243e564712b64140fc6d011c2bdbb483d9092a12/grimp-3.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af8a625554beea84530b98cc471902155b5fc042b42dc47ec846fa3e32b0c615", size = 2178632, upload-time = "2025-12-10T17:53:44.55Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/d6620dbc245149d5a5a7a9342733556ba91a672f358259c0ab31d889b56b/grimp-3.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dd1942ffb419ad342f76b0c3d3d2d7f312b264ddc578179d13ce8d5acec1167", size = 2110288, upload-time = "2025-12-10T17:53:21.662Z" }, + { url = "https://files.pythonhosted.org/packages/60/9d/ea51edc4eb295c99786040051c66466bfa235fd1def9f592057b36e03d0f/grimp-3.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537f784ce9b4acf8657f0b9714ab69a6c72ffa752eccc38a5a85506103b1a194", size = 2282197, upload-time = "2025-12-10T17:52:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/28/6e/7db27818ced6a797f976ca55d981a3af5c12aec6aeda12d63965847cd028/grimp-3.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78ab18c08770aa005bef67b873bc3946d33f65727e9f3e508155093db5fa57d6", size = 2235720, upload-time = "2025-12-10T17:52:21.806Z" }, + { url = "https://files.pythonhosted.org/packages/37/26/0e3bbae4826bd6eaabf404738400414071e73ddb1e65bf487dcce17858c4/grimp-3.14-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28ca58728c27e7292c99f964e6ece9295c2f9cfdefc37c18dea0679c783ffb6f", size = 2393023, upload-time = "2025-12-10T17:53:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/49/f2/7da91db5703da34c7ef4c7cddcbb1a8fc30cd85fe54756eba942c6fb27d8/grimp-3.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b5577de29c6c5ae6e08d4ca0ac361b45dba323aa145796e6b320a6ea35414b7", size = 2571108, upload-time = "2025-12-10T17:52:36.523Z" }, + { url = "https://files.pythonhosted.org/packages/25/5e/4d6278f18032c7208696edf8be24a4b5f7fad80acc20ffca737344bcecb5/grimp-3.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d7d1f9f42306f455abcec34db877e4887ff15f2777a43491f7ccbd6936c449b", size = 2358531, upload-time = "2025-12-10T17:52:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/24/fb/231c32493161ac82f27af6a56965daefa0ec6030fdaf5b948ddd5d68d000/grimp-3.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39bd5c9b7cef59ee30a05535e9cb4cbf45a3c503f22edce34d0aa79362a311a9", size = 2308831, upload-time = "2025-12-10T17:53:12.587Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/f6db325bf5efbbebc9c85cad0af865e821a12a0ba58ee309e938cbd5fedf/grimp-3.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fec3116b4f780a1bc54176b19e6b9f2e36e2ef3164b8fc840660566af35df88", size = 2462138, upload-time = "2025-12-10T17:53:52.403Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/cc3fe29cf07f70364018086840c228a190539ab8105147e34588db590792/grimp-3.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0233a35a5bbb23688d63e1736b54415fa9994ace8dfeb7de8514ed9dee212968", size = 2501393, upload-time = "2025-12-10T17:54:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/e5/eb/54cada9a726455148da23f64577b5cd164164d23a6449e3fa14551157356/grimp-3.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e46b2fef0f1da7e7e2f8129eb93c7e79db716ff7810140a22ce5504e10ed86df", size = 2504514, upload-time = "2025-12-10T17:54:36.34Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/e6afe4f0652df07e8762f61899d1202b73c22c559c804d0a09e5aab2ff17/grimp-3.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e6d9b50623ee1c3d2a1927ec3f5d408995ea1f92f3e91ed996c908bb40e856f", size = 2514018, upload-time = "2025-12-10T17:54:50.76Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/2b8550acc1f010301f02c4fe9664810929fd9277cd032ab608b8534a96fb/grimp-3.14-cp313-cp313-win32.whl", hash = "sha256:fd57c56f5833c99320ec77e8ba5508d56f6fb48ec8032a942f7931cc6ebb80ce", size = 1874922, upload-time = "2025-12-10T17:55:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/bc9db5a54ef22972cd17d15ad80a8fee274a471bd3f02300405702d29ea5/grimp-3.14-cp313-cp313-win_amd64.whl", hash = "sha256:173307cf881a126fe5120b7bbec7d54384002e3c83dcd8c4df6ce7f0fee07c53", size = 2013705, upload-time = "2025-12-10T17:55:07.488Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/02710bf5e50997168c84ac622b10dd41d35515efd0c67549945ad20996a0/grimp-3.14-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebe29f8f13fbd7c314908ed535183a36e6db71839355b04869b27f23c58fa082", size = 2281868, upload-time = "2025-12-10T17:52:10.589Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/2e440c6762cc78bd50582e1b092357d2255f0852ccc6218d8db25170ab31/grimp-3.14-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073d285b00100153fd86064c7726bb1b6d610df1356d33bb42d3fd8809cb6e72", size = 2230917, upload-time = "2025-12-10T17:52:23.212Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bb/2e7dce129b88f07fc525fe5c97f28cfb7ed7b62c59386d39226b4d08969c/grimp-3.14-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6d6efc37e1728bbfcd881b89467be5f7b046292597b3ebe5f8e44e89ea8b6cb", size = 2571371, upload-time = "2025-12-10T17:52:37.84Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2b/8f1be8294af60c953687db7dec25525d87ed9c2aa26b66dcbe5244abaca2/grimp-3.14-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5337d65d81960b712574c41e85b480d4480bbb5c6f547c94e634f6c60d730889", size = 2356980, upload-time = "2025-12-10T17:52:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/ead91e04b3ddd4774ae74601860ea0f0f21bcf6b970b6769ba9571eb2904/grimp-3.14-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:84a7fea63e352b325daa89b0b7297db411b7f0036f8d710c32f8e5090e1fc3ca", size = 2461540, upload-time = "2025-12-10T17:53:53.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/aa/f8a085ff73c37d6e6a37de9f58799a3fea9e16badf267aaef6f11c9a53a3/grimp-3.14-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d0b19a3726377165fe1f7184a8af317734d80d32b371b6c5578747867ab53c0b", size = 2497925, upload-time = "2025-12-10T17:54:23.842Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a3/db3c2d6df07fe74faf5a28fcf3b44fad2831d323ba4a3c2ff66b77a6520c/grimp-3.14-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9caa4991f530750f88474a3f5ecf6ef9f0d064034889d92db00cfb4ecb78aa24", size = 2501794, upload-time = "2025-12-10T17:54:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/095f4e3765e7b60425a41e9fbd2b167f8b0acb957cc88c387f631778a09d/grimp-3.14-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1876efc119b99332a5cc2b08a6bdaada2f0ad94b596f0372a497e2aa8bda4d94", size = 2515203, upload-time = "2025-12-10T17:54:52.555Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5f/ee02a3a1237282d324f596a50923bf9d2cb1b1230ef2fef49fb4d3563c2c/grimp-3.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3ccf03e65864d6bc7bf1c003c319f5330a7627b3677f31143f11691a088464c2", size = 2177150, upload-time = "2025-12-10T17:53:46.145Z" }, + { url = "https://files.pythonhosted.org/packages/f2/64/2a92889e5fc78e8ef5c548e6a5c6fed78b817eeb0253aca586c28108393a/grimp-3.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ecd58fa58a270e7523f8bec9e6452f4fdb9c21e4cd370640829f1e43fa87a69", size = 2109280, upload-time = "2025-12-10T17:53:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/5d0b9ab54821e7fbdeb02f3919fa2cb8b9f0c3869fa6e4b969a5766f0ffa/grimp-3.14-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d75d1f8f7944978b39b08d870315174f1ffcd5123be6ccff8ce90467ace648a", size = 2283367, upload-time = "2025-12-10T17:52:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/c2/96/a77c40c92faf7500f42ac019ab8de108b04ffe3db8ec8d6f90416d2322ce/grimp-3.14-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f70bbb1dd6055d08d29e39a78a11c4118c1778b39d17cd8271e18e213524ca7", size = 2237125, upload-time = "2025-12-10T17:52:24.606Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/3e1483721c83057bff921cf454dd5ff3e661ae1d2e63150a380382d116c2/grimp-3.14-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f21b7c003626c902669dc26ede83a91220cf0a81b51b27128370998c2f247b4", size = 2391735, upload-time = "2025-12-10T17:53:05.619Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/25fad4a174fe672d42f3e5616761a8120a3b03c8e9e2ae3f31159561968a/grimp-3.14-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80d9f056415c936b45561310296374c4319b5df0003da802c84d2830a103792a", size = 2571388, upload-time = "2025-12-10T17:52:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/456df7f6a765ce3f160eb32a0f64ed0c1c3cd39b518555dde02087f9b6e4/grimp-3.14-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0332963cd63a45863775d4237e59dedf95455e0a1ea50c356be23100c5fc1d7c", size = 2359637, upload-time = "2025-12-10T17:52:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/3e5005ef21a4e2243f0da489aba86aaaff0bc11d5240d67113482cba88e0/grimp-3.14-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4144350d074f2058fe7c89230a26b34296b161f085b0471a692cb2fe27036f", size = 2308335, upload-time = "2025-12-10T17:53:13.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/03/4e055f756946d6f71ab7e9d1f8536a9e476777093dd7a050f40412d1a2b1/grimp-3.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e148e67975e92f90a8435b1b4c02180b9a3f3d725b7a188ba63793f1b1e445a0", size = 2463680, upload-time = "2025-12-10T17:53:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/26/b9/3c76b7c2e1587e4303a6eff6587c2117c3a7efe1b100cd13d8a4a5613572/grimp-3.14-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1093f7770cb5f3ca6f99fb152f9c949381cc0b078dfdfe598c8ab99abaccda3b", size = 2502808, upload-time = "2025-12-10T17:54:25.383Z" }, + { url = "https://files.pythonhosted.org/packages/20/80/ada10b85ad3125ebedea10256d9c568b6bf28339d2f79d2d196a7b94f633/grimp-3.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a213f45ec69e9c2b28ffd3ba5ab12cc9859da17083ba4dc39317f2083b618111", size = 2504013, upload-time = "2025-12-10T17:54:39.762Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/7c369f749d50b0ceac23cd6874ca4695cc1359a96091c7010301e5c8b619/grimp-3.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f003ac3f226d2437a49af0b6036f26edba57f8a32d329275dbde1b2b2a00a56", size = 2515043, upload-time = "2025-12-10T17:54:54.437Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/85135fe83826ce11ae56a340d32a1391b91eed94d25ce7bc318019f735de/grimp-3.14-cp314-cp314-win32.whl", hash = "sha256:eec81be65a18f4b2af014b1e97296cc9ee20d1115529bf70dd7e06f457eac30b", size = 1877509, upload-time = "2025-12-10T17:55:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/db/61/e4a2234edecb3bb3cff8963bc4ec5cc482a9e3c54f8df0946d7d90003830/grimp-3.14-cp314-cp314-win_amd64.whl", hash = "sha256:cd3bab6164f1d5e313678f0ab4bf45955afe7f5bdb0f2f481014aa9cca7e81ba", size = 2014364, upload-time = "2025-12-10T17:55:08.896Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/3d304443fbf1df4d60c09668846d0c8a605c6c95646226e41d8f5c3254da/grimp-3.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1df33de479be4d620f69633d1876858a8e64a79c07907d47cf3aaf896af057", size = 2281385, upload-time = "2025-12-10T17:52:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/493e2648dbb83b3fc517ee675e464beb0154551d726053c7982a3138c6a8/grimp-3.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07096d4402e9d5a2c59c402ea3d601f4b7f99025f5e32f077468846fc8d3821b", size = 2231470, upload-time = "2025-12-10T17:52:26.104Z" }, + { url = "https://files.pythonhosted.org/packages/80/84/e772b302385a6b7ec752c88f84ffe35c33d14076245ae27a635aed9c63a2/grimp-3.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:712bc28f46b354316af50c469c77953ba3d6cb4166a62b8fb086436a8b05d301", size = 2571579, upload-time = "2025-12-10T17:52:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/5b23aa7b89c5f4f2cfa636cbeaf33e784378a6b0a823d77a3448670dfacc/grimp-3.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abe2bbef1cf8e27df636c02f60184319f138dee4f3a949405c21a4b491980397", size = 2356545, upload-time = "2025-12-10T17:52:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/bcf2116f4b1c3939ab35f9cdddd9ca59e953e57e9a0ac0c143deaf9f29cc/grimp-3.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2f9ae3fabb7a7a8468ddc96acc84ecabd84f168e7ca508ee94d8f32ea9bd5de2", size = 2461022, upload-time = "2025-12-10T17:53:56.923Z" }, + { url = "https://files.pythonhosted.org/packages/81/ce/1a076dce6bc22bca4b9ad5d1bbcd7e1023dcf7bf20ea9404c6462d78f049/grimp-3.14-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:efaf11ea73f7f12d847c54a5d6edcbe919e0369dce2d1aabae6c50792e16f816", size = 2498256, upload-time = "2025-12-10T17:54:27.214Z" }, + { url = "https://files.pythonhosted.org/packages/45/ea/ac735bed202c1c5c019e611b92d3861779e0cfbe2d20fdb0dec94266d248/grimp-3.14-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e089c9ab8aa755ff5af88c55891727783b4eb6b228e7bdf278e17209d954aa1e", size = 2502056, upload-time = "2025-12-10T17:54:41.537Z" }, + { url = "https://files.pythonhosted.org/packages/80/8f/774ce522de6a7e70fbeceeaeb6fbe502f5dfb8365728fb3bb4cb23463da8/grimp-3.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ad14d5deb56721ac24ab939747f72ab3d378d42e7d1f038317d33b052b77", size = 2515157, upload-time = "2025-12-10T17:54:55.874Z" }, +] + [[package]] name = "hypothesis" version = "6.155.1" @@ -365,6 +434,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, ] +[[package]] +name = "import-linter" +version = "2.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -482,6 +566,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -545,6 +641,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -884,6 +989,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "rpds-py" version = "2026.5.1" @@ -1037,6 +1155,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "tree-sitter" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" }, + { url = "https://files.pythonhosted.org/packages/07/e3/d9526ba71dfbbe4eba5e51d89432b4b333a49a1e70712aa5590cd22fc74f/tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0", size = 146776, upload-time = "2025-09-25T17:37:50.898Z" }, + { url = "https://files.pythonhosted.org/packages/42/97/4bd4ad97f85a23011dd8a535534bb1035c4e0bac1234d58f438e15cff51f/tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87", size = 137732, upload-time = "2025-09-25T17:37:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/1e968aa0b1b567988ed522f836498a6a9529a74aab15f09dd9ac1e41f505/tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab", size = 609456, upload-time = "2025-09-25T17:37:52.925Z" }, + { url = "https://files.pythonhosted.org/packages/48/b6/cf08f4f20f4c9094006ef8828555484e842fc468827ad6e56011ab668dbd/tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358", size = 636772, upload-time = "2025-09-25T17:37:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/57/e2/d42d55bf56360987c32bc7b16adb06744e425670b823fb8a5786a1cea991/tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0", size = 631522, upload-time = "2025-09-25T17:37:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/af9604ebe275a9345d88c3ace0cf2a1341aa3f8ef49dd9fc11662132df8a/tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721", size = 130864, upload-time = "2025-09-25T17:37:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/e64621037357acb83d912276ffd30a859ef117f9c680f2e3cb955f47c680/tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f", size = 117470, upload-time = "2025-09-25T17:37:58.431Z" }, +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/87/75cbd22b927267d310f76cca1ab3c1d9d41035dfa3eb9cc95f96ee199440/tree_sitter_rust-0.24.2.tar.gz", hash = "sha256:54fb02a5911e345308b405174465112479f56dc39e3f1e7744d7568595f00db9", size = 339341, upload-time = "2026-03-27T21:08:55.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/24/2b2d33af5e27c84a4fde4e8cd2594bb4ab1e1cf48756a9f40dadc84956cc/tree_sitter_rust-0.24.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3620cfd12340efa43082d45df76349ff511893a9c361da2f8d6d51e307020a59", size = 129507, upload-time = "2026-03-27T21:08:47.585Z" }, + { url = "https://files.pythonhosted.org/packages/78/2a/cf39f881a545360b5a86bb1accba1f4acc713daab01fb9edd35b6e84f473/tree_sitter_rust-0.24.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:01a46622735498493f29f3e628a90de95c96a07bfbeb88996243eb986b1cee36", size = 136812, upload-time = "2026-03-27T21:08:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/ca/45/a051bbd3045a61182dde25b93ae9a33d2677c935b16952283e12eaf46051/tree_sitter_rust-0.24.2-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e033c5a93b57c88e0a835880de39fc802909ff69f57aaff6000211c196ea5190", size = 164706, upload-time = "2026-03-27T21:08:49.605Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/a5a146df5c0a5daea3ffcd5d7245775fe7f084357770d5a313dd6245ae78/tree_sitter_rust-0.24.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d76d1208c3638b871236090759dfc13d478921320653a6c9da5336e7c58f65a", size = 170310, upload-time = "2026-03-27T21:08:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/95/a8/f85b1ca75e01361ca5f92d226593ca4857cea49551b9f6c8fa6fc08ea917/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87930163a462408c49ab62c667e74029bc26b4cc7123dd1bdc7352215786c64a", size = 168668, upload-time = "2026-03-27T21:08:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/3519f866a4679ca36acd9f5a06a779ecb8a92b18887c5546458d521df557/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da2b86099028fd42c6cd32878b7b16b01f8aac0f7b0e98742b7fa6bc3cf09b89", size = 162403, upload-time = "2026-03-27T21:08:52.588Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/7ef609894dbfe5699eb16f7471f9b8af1d958d8ba3e29c238d7607e8cb47/tree_sitter_rust-0.24.2-cp39-abi3-win_amd64.whl", hash = "sha256:4529c125d928882ddfb879fdc6bc0704913261ecc078b6fa7902559e0daf200d", size = 129422, upload-time = "2026-03-27T21:08:54.031Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245, upload-time = "2026-03-27T21:08:54.803Z" }, +] + [[package]] name = "types-jsonschema" version = "4.26.0.20260518" @@ -1088,6 +1251,13 @@ docs = [ loomweave = [ { name = "blake3" }, ] +rust = [ + { name = "click" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "tree-sitter" }, + { name = "tree-sitter-rust" }, +] scanner = [ { name = "click" }, { name = "jsonschema" }, @@ -1097,6 +1267,7 @@ scanner = [ [package.dev-dependencies] dev = [ { name = "hypothesis" }, + { name = "import-linter" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1109,17 +1280,23 @@ dev = [ [package.metadata] requires-dist = [ { name = "blake3", marker = "extra == 'loomweave'", specifier = ">=1.0" }, + { name = "click", marker = "extra == 'rust'", specifier = ">=8.0" }, { name = "click", marker = "extra == 'scanner'", specifier = ">=8.0" }, + { name = "jsonschema", marker = "extra == 'rust'", specifier = ">=4.0" }, { name = "jsonschema", marker = "extra == 'scanner'", specifier = ">=4.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "pyyaml", marker = "extra == 'rust'", specifier = ">=6.0" }, { name = "pyyaml", marker = "extra == 'scanner'", specifier = ">=6.0" }, + { name = "tree-sitter", marker = "extra == 'rust'", specifier = ">=0.25,<0.26" }, + { name = "tree-sitter-rust", marker = "extra == 'rust'", specifier = "==0.24.2" }, ] -provides-extras = ["docs", "loomweave", "scanner"] +provides-extras = ["docs", "loomweave", "rust", "scanner"] [package.metadata.requires-dev] dev = [ { name = "hypothesis" }, + { name = "import-linter", specifier = ">=2.0" }, { name = "mypy", specifier = ">=1.13.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, diff --git a/wardline-readonly-audit-2026-06-04-subagent-synthesis.md b/wardline-readonly-audit-2026-06-04-subagent-synthesis.md deleted file mode 100644 index ee73a89d..00000000 --- a/wardline-readonly-audit-2026-06-04-subagent-synthesis.md +++ /dev/null @@ -1,335 +0,0 @@ -# Wardline Read-Only Audit Synthesis - -Date: 2026-06-04 -Repository: `/home/john/wardline` -Mode: strictly read-only code review and research. The only workspace write was this requested report artifact. - -## Scope And Method - -Seven specialized read-only subagents reviewed the codebase with `enable_write_tools=false` and `enable_mcp_tools=false` in their task contracts: Architecture Critic, Systems Thinker, Python Engineer, Quality Engineer, Security Architect, Static Tools Analyst, and MCP & CLI Specialist. I then verified and de-duplicated the strongest claims against the live tree using read-only inspection commands. - -No tests were run because even normal pytest/mypy runs can write caches or coverage state. Existing `AUDIT.md` and `wardline-readonly-audit-2026-06-04.md` were left untouched. - -## Executive Summary - -No Critical findings were confirmed. - -The highest-risk issues cluster around four themes: - -- Several public non-scan CLI/core flows do not enforce the source-root confinement that `scan` and MCP now enforce. -- Scanner soundness has false-negative gaps around modern Python constructs and call argument unpacking. -- Trust/evidence artifacts can diverge from the effective scan policy, especially baseline generation and attestation policy identity. -- Live CI oracles can pass as skipped, so external integration drift can be missed. - -## Critical - -No Critical findings confirmed. - -## High - -### H1. Non-scan trust/evidence entrypoints can scan outside the selected project root - -Locations: -- [src/wardline/core/discovery.py:17-48](/home/john/wardline/src/wardline/core/discovery.py:17) -- [src/wardline/cli/scan.py:99-153](/home/john/wardline/src/wardline/cli/scan.py:99) -- [src/wardline/cli/assure.py:35-40](/home/john/wardline/src/wardline/cli/assure.py:35) -- [src/wardline/core/assure.py:233-251](/home/john/wardline/src/wardline/core/assure.py:233) -- [src/wardline/cli/attest.py:90-119](/home/john/wardline/src/wardline/cli/attest.py:90) -- [src/wardline/core/attest.py:173-193](/home/john/wardline/src/wardline/core/attest.py:173), [src/wardline/core/attest.py:226-255](/home/john/wardline/src/wardline/core/attest.py:226) -- [src/wardline/cli/dossier.py:66-72](/home/john/wardline/src/wardline/cli/dossier.py:66) -- [src/wardline/loom_dossier.py:60-69](/home/john/wardline/src/wardline/loom_dossier.py:60) -- [src/wardline/core/dossier.py:628-650](/home/john/wardline/src/wardline/core/dossier.py:628) -- [src/wardline/cli/judge.py:116-129](/home/john/wardline/src/wardline/cli/judge.py:116) -- [src/wardline/core/judge_run.py:128-182](/home/john/wardline/src/wardline/core/judge_run.py:128) -- [src/wardline/core/baseline.py:77-131](/home/john/wardline/src/wardline/core/baseline.py:77) -- MCP contrast: [src/wardline/mcp/server.py:240-323](/home/john/wardline/src/wardline/mcp/server.py:240) - -Evidence: `discover()` only rejects escaping `source_roots` and symlinked Python files when `confine_to_root=True`. The canonical `scan` CLI passes `confine_to_root=not allow_source_root_escape`, and tests pin that default. MCP wrappers also pass `confine_to_root=True` for dossier, assure, attest, verify, and judge. The CLI/core paths for assure, attest, dossier, judge, and baseline use defaults or calls that leave confinement off. - -Impact: A poisoned in-repo `wardline.yaml` can point `source_roots` outside the selected project. That can make assurance posture, attestations, judge excerpts, dossiers, or baselines incorporate out-of-root files while the canonical scan/MCP surfaces would reject the same scope. The judge path is especially sensitive because excerpts may be sent to OpenRouter. - -Remediation: Make public builder defaults `confine_to_root=True`. Pass `confine_to_root=True` from all CLI entrypoints. Add explicit opt-out flags only where intentionally supported, mirroring `scan --allow-source-root-escape`. Add regression tests for escaping `source_roots` across `assure`, `attest --reproduce`, `dossier`, `judge`, and baseline create/update. - -### H2. Persistent taint summary cache can be poisoned into false-green scans - -Locations: -- [src/wardline/scanner/taint/summary_cache.py:217-270](/home/john/wardline/src/wardline/scanner/taint/summary_cache.py:217) -- [src/wardline/scanner/taint/project_resolver.py:113-143](/home/john/wardline/src/wardline/scanner/taint/project_resolver.py:113) - -Evidence: `SummaryCache.load()` accepts any syntactically valid `/.json` payload and deserializes `FunctionSummary.cache_key` from the body, but does not verify that every loaded summary's `cache_key` matches the filename key. The resolver then trusts `summary_cache.get(cache_key)` directly for clean modules. - -Impact: If a CI cache or project cache directory is attacker-controlled or stale in a malicious way, forged summaries can replace fresh analysis and suppress real findings. - -Remediation: Treat persistent cache files as untrusted. On load, require `summary.cache_key == path.stem` for every summary and reject mixed-FQN or mismatched records. Consider authenticated cache records or disabling persistent cache for security gates. Add tests where a valid JSON cache file has a mismatched internal key and must fall back to fresh summarization. - -### H3. Attestation `ruleset_hash` omits effective scan policy inputs - -Locations: -- [src/wardline/core/attest.py:100-114](/home/john/wardline/src/wardline/core/attest.py:100) -- [src/wardline/core/attest.py:188-223](/home/john/wardline/src/wardline/core/attest.py:188) -- [src/wardline/core/config.py:31-49](/home/john/wardline/src/wardline/core/config.py:31) -- [src/wardline/core/config.py:197-214](/home/john/wardline/src/wardline/core/config.py:197) - -Evidence: `ruleset_hash()` hashes only sorted `rules_enable`, sorted `rules_severity`, and Wardline version. It omits fields that materially change scan results, including `source_roots`, `exclude`, `untrusted_sources`, `sanitisers`, `provenance_clash`, custom packs, and pack grammar/config effects. - -Impact: Two attestations can share the same policy identity while scanning different files or using different trust semantics. Downstream governance consumers may treat non-equivalent evidence bundles as comparable. - -Remediation: Replace `ruleset_hash()` with a canonical effective-scan-policy hash. Include source scope, excludes, rules, severity, provenance policy, custom sources/sanitisers, trusted pack names and versions/hashes, and grammar-affecting pack data. Add tests proving each policy-affecting field changes the signed policy identity. - -### H4. Baseline generation bypasses the shared scan pipeline - -Locations: -- [src/wardline/core/run.py:78-152](/home/john/wardline/src/wardline/core/run.py:78) -- [src/wardline/core/baseline.py:77-108](/home/john/wardline/src/wardline/core/baseline.py:77) -- [src/wardline/cli/main.py:59-105](/home/john/wardline/src/wardline/cli/main.py:59) -- [src/wardline/mcp/server.py:348-367](/home/john/wardline/src/wardline/mcp/server.py:348) - -Evidence: `run_scan()` constructs the configured grammar, summary cache, trust-pack behavior, strict defaults, and analyzer. `collect_and_write_baseline()` loads config with default trust flags, calls `discover()` directly, and constructs `WardlineAnalyzer()` directly. - -Impact: A baseline can differ from the scan/gate population. Custom grammar findings can be omitted or baseline generation can fail/differ where scan succeeds with explicit trust options. That weakens baseline suppression as an auditable snapshot of the actual gate. - -Remediation: Generate baselines from `run_scan()` or a shared `ScanOptions` pipeline. Thread `trust_local_packs`, `trusted_packs`, `strict_defaults`, cache options, and confinement through baseline CLI/MCP APIs. Add a regression test where a trusted pack emits a custom finding and baseline creation captures the same finding as `scan`. - -### H5. Multiple `**kwargs` unpackings overwrite earlier taints - -Locations: -- [src/wardline/scanner/taint/variable_level.py:430-447](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:430) -- [src/wardline/scanner/analyzer.py:380-388](/home/john/wardline/src/wardline/scanner/analyzer.py:380) -- [src/wardline/scanner/rules/_sink_helpers.py:172-179](/home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py:172) - -Evidence: Every keyword unpack records `resolved_args[kw.arg] = t`. For `**kwargs`, `kw.arg is None`, so `callee(**raw_kwargs, **clean_kwargs)` records only the last unpack. Interprocedural binding and sink helpers consume the single `None` value. - -Impact: Raw keyword flows can disappear from PY-WL-105, sink rules, and callee parameter propagation. This is a scanner false negative. - -Remediation: Store each `**` unpack separately or combine duplicate `None` entries with `combine()`. Update `_bind_call_site_arguments_to_parameters()` and `worst_arg_taint()` to aggregate all unpack taints. Add regression tests where raw unpack appears before a clean unpack. - -### H6. Starred unpack targets can lose raw taint - -Locations: -- [src/wardline/scanner/taint/variable_level.py:228-237](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:228) -- [src/wardline/scanner/taint/variable_level.py:724-744](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:724) -- [tests/unit/scanner/taint/test_variable_level.py:1089-1097](/home/john/wardline/tests/unit/scanner/taint/test_variable_level.py:1089) - -Evidence: Element-wise unpack handles `ast.Name` and nested tuple/list targets, but skips `ast.Starred`. Later reads of the starred target fall back to function-level taint if no binding exists. The existing test documents that the middle starred target is skipped and does not assert `b`. - -Impact: `(a, *rest, c) = (clean, raw, clean); return rest` inside a trusted producer can suppress PY-WL-101 and downstream sink findings. - -Remediation: Bind starred targets to the captured RHS slice when statically available, or conservatively bind to the whole RHS taint. Add PY-WL-101 and variable-level regression tests for starred unpack targets. - -### H7. `AsyncFor`, `TryStar`, and `except*` handlers are skipped by taint/rule traversal - -Locations: -- [src/wardline/scanner/taint/variable_level.py:602-619](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:602) -- [src/wardline/scanner/taint/variable_level.py:627-629](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:627) -- [src/wardline/scanner/taint/variable_level.py:1226-1251](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:1226) -- [src/wardline/scanner/rules/_ast_helpers.py:32-37](/home/john/wardline/src/wardline/scanner/rules/_ast_helpers.py:32) -- [src/wardline/scanner/rules/broad_exception.py:49-56](/home/john/wardline/src/wardline/scanner/rules/broad_exception.py:49) -- [src/wardline/scanner/rules/silent_exception.py:49-56](/home/john/wardline/src/wardline/scanner/rules/silent_exception.py:49) - -Evidence: L2 statement dispatch handles `For`, `Try`, `With`, `AsyncWith`, and `Match`, but not `AsyncFor` or `TryStar`; unhandled statements only get walrus scanning. `own_except_handlers()` only yields handlers from `ast.Try`, so PY-WL-103/PY-WL-104 miss `except*`. - -Impact: Raw assignments inside `async for` or `except*` paths can be missed, and trusted-tier `except* Exception: pass` can evade broad/silent exception rules. - -Remediation: Route `ast.AsyncFor` through loop handling and `ast.TryStar` through try handling. Update `_ast_helpers.own_except_handlers()` to include `ast.TryStar`. Add fixtures for `async for` taint propagation and `except*` PY-WL-103/PY-WL-104 findings. - -### H8. Comprehension walrus writeback ignores existing outer variables - -Location: -- [src/wardline/scanner/taint/variable_level.py:361-404](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:361) - -Evidence: The PEP 572 writeback loop only writes `var_taints[name] = taint` when `name not in var_taints`. That handles new walrus targets but skips rebinding an existing outer variable. - -Impact: If `x` starts trusted and a comprehension executes `(x := read_raw(p))`, `x` can remain trusted in the outer taint map. Later trusted returns or sinks using `x` can be missed. - -Remediation: For names proven by `_name_bound_by_walrus()`, write back the local taint whether the name is new or existing. Add tests for existing clean variables overwritten inside list/set/dict/gen comprehensions. - -### H9. Live oracle CI can pass without running the live oracle - -Locations: -- [.github/workflows/ci.yml:84-135](/home/john/wardline/.github/workflows/ci.yml:84) -- [tests/e2e/test_judge_live.py:12-38](/home/john/wardline/tests/e2e/test_judge_live.py:12) -- [tests/e2e/test_legis_live.py:55-65](/home/john/wardline/tests/e2e/test_legis_live.py:55) -- [tests/e2e/test_filigree_promote_live.py:44-47](/home/john/wardline/tests/e2e/test_filigree_promote_live.py:44) -- [tests/unit/test_ci_live_oracles.py:6-14](/home/john/wardline/tests/unit/test_ci_live_oracles.py:6) - -Evidence: Scheduled/manual jobs run marker-selected live tests, but tests skip when secrets, services, or routes are absent. The workflow summary explicitly says missing local services or secrets are reported as skipped tests. The default workflow guard only asserts some live markers and summary text, not a required no-skip mode. - -Impact: Weekly/manual CI can be green when OpenRouter, Clarion, Legis, or Filigree coverage did not actually execute, so integration drift is not caught. - -Remediation: Add a required live-oracle mode such as `WARDLINE_LIVE_ORACLE_REQUIRED=1` that turns missing secrets/services/capabilities into failures. Add workflow preflights or pytest no-skip enforcement. Extend `test_ci_live_oracles.py` to assert the network judge job, required secret env, live-oracle matrix, and required-mode behavior. - -## Medium - -### M1. MCP request validation accepts invalid IDs and maps malformed params to internal errors - -Locations: -- [src/wardline/mcp/protocol.py:55-102](/home/john/wardline/src/wardline/mcp/protocol.py:55) -- [src/wardline/mcp/server.py:834-853](/home/john/wardline/src/wardline/mcp/server.py:834) -- [tests/unit/mcp/test_protocol.py:96-105](/home/john/wardline/tests/unit/mcp/test_protocol.py:96) - -Evidence: The server treats presence of the `id` key as a request, so `id: null` is accepted and tested as a valid request. It handles `initialize` before the notification gate, so an `initialize` notification can produce a response. It also assigns `params = message.get("params") or {}` without validating that `params` is an object; `_tools_call()` then assumes `.get()`, so array/string params can become `-32603` internal errors. - -Protocol reference: the current MCP spec says requests must include a string or integer ID, IDs must not be null, and notifications must not include IDs or receive responses. See [MCP messages](https://modelcontextprotocol.io/specification/2025-06-18/basic/index). MCP tool malformed-request errors should be protocol errors rather than tool execution errors; see [MCP tools error handling](https://modelcontextprotocol.io/specification/draft/server/tools). - -Impact: Strict clients can reject the server, and malformed tool envelopes produce opaque internal errors instead of stable `-32602` invalid-params responses. - -Remediation: Validate request IDs before method dispatch. Reject `id is None` and non-string/non-integer IDs with `-32600`. Treat messages without `id`, including `initialize`, as notifications with no response or reject them via documented policy. Validate `params`, `name`, and `arguments` shape before handler calls and return `McpError(..., code=-32602)` for envelope faults. Replace the `id:null` test with conformance rejection tests. - -### M2. Filigree dossier URL scheme and network body sizes are not consistently bounded - -Locations: -- [src/wardline/core/config.py:237-249](/home/john/wardline/src/wardline/core/config.py:237) -- [src/wardline/filigree/dossier_client.py:41-56](/home/john/wardline/src/wardline/filigree/dossier_client.py:41) -- [src/wardline/core/judge.py:276-286](/home/john/wardline/src/wardline/core/judge.py:276) -- [src/wardline/clarion/client.py:48-59](/home/john/wardline/src/wardline/clarion/client.py:48) -- [src/wardline/core/filigree_emit.py:107-122](/home/john/wardline/src/wardline/core/filigree_emit.py:107) - -Evidence: `_is_safe_url()` checks localhost hostnames but not scheme. The Filigree dossier client does not enforce `http`/`https` before `urllib.request.urlopen()`. Several network transports read response and error bodies with unbounded `resp.read()` or `exc.read()`. - -Impact: Config URLs like `file://localhost/...` can pass the localhost check in some paths, and compromised or misconfigured endpoints can return oversized bodies that exhaust memory or produce excessive exception text. - -Remediation: Require `http`/`https` in `_is_safe_url()` and in `FiligreeWorkProvider` transport. Add a shared bounded-read helper for normal and error bodies. Truncate logged/raised response text. Add tests for `file://`, `ftp://`, schemeless URLs, and oversized response bodies. - -### M3. Autofix can report success when the write failed - -Location: -- [src/wardline/core/autofix.py:152-220](/home/john/wardline/src/wardline/core/autofix.py:152) - -Evidence: `applied[rel_path].append(...)` happens before the file write, and the write is wrapped in `contextlib.suppress(Exception)`. - -Impact: `wardline fix` or MCP autofix can tell automation a fix was applied even when the file was not changed, leaving the finding in place and making follow-up scan state confusing. - -Remediation: Write first and handle `OSError` explicitly. Only append/report applied fixes after a successful write. Return structured failures or raise `WardlineError` when a requested write fails. - -### M4. MCP Filigree emission softens protocol rejection into a nested warning - -Locations: -- [src/wardline/core/filigree_emit.py:141-148](/home/john/wardline/src/wardline/core/filigree_emit.py:141) -- [src/wardline/cli/scan.py:196-216](/home/john/wardline/src/wardline/cli/scan.py:196) -- [src/wardline/mcp/server.py:44-58](/home/john/wardline/src/wardline/mcp/server.py:44) -- [src/wardline/mcp/server.py:141-180](/home/john/wardline/src/wardline/mcp/server.py:141) - -Evidence: Core/CLI treat Filigree 3xx/4xx rejection as loud `FiligreeEmitError`. MCP `_emit_filigree()` catches `FiligreeEmitError` and returns `filigree.reachable=false` inside an otherwise successful scan payload. - -Impact: Agents can consume a successful MCP scan summary/gate while tracker emission or reconciliation was rejected, creating drift between local scan state and work-tracker state. - -Remediation: Preserve loud failure semantics for Filigree protocol/client errors in MCP, or expose a top-level `tracker_reconciled=false` / `emission_error` contract that consumers must handle. Align docs and tests with the chosen behavior. - -### M5. Interprocedural call binding over-taints impossible parameters - -Locations: -- [src/wardline/scanner/analyzer.py:334-388](/home/john/wardline/src/wardline/scanner/analyzer.py:334) -- [tests/unit/scanner/rules/test_wave2_engine_precision.py:70-99](/home/john/wardline/tests/unit/scanner/rules/test_wave2_engine_precision.py:70) - -Evidence: Starred taint is appended to every positional parameter, and `**kwargs` taint is appended to positional-only, already-filled, vararg, kw-only, and kwargs slots. The existing test explicitly expects all positional parameters to become contaminated from `*args`. - -Impact: PY-WL-105 and sink rules can report raw flow into parameters that Python call binding could not actually populate. This is a precision regression and can inflate false positives. - -Remediation: Model `inspect.Signature` binding more closely: explicit positional args first, star args only remaining positional/vararg slots, kwargs only unfilled keyword-capable slots. Keep conservative fallback only when static binding cannot determine a safe subset. - -### M6. `AnalysisContext` read-only contract is shallow - -Locations: -- [src/wardline/scanner/context.py:28-45](/home/john/wardline/src/wardline/scanner/context.py:28) -- [src/wardline/scanner/context.py:85-115](/home/john/wardline/src/wardline/scanner/context.py:85) - -Evidence: The docstring says inner mappings are wrapped read-only, but also notes `function_var_taints` inner dicts are left by convention. `__post_init__()` wraps several outer mappings only; nested maps remain mutable for some fields. - -Impact: A rule can mutate nested context state and affect later rules, making rule ordering a hidden input. - -Remediation: Deep-freeze nested mappings or provide isolated per-rule views. Add a test rule that attempts to mutate nested context and assert later rules are unaffected. - -### M7. SARIF serialization reaches into scanner internals - -Locations: -- [src/wardline/core/sarif.py:66-93](/home/john/wardline/src/wardline/core/sarif.py:66) -- [src/wardline/core/sarif.py:117-125](/home/john/wardline/src/wardline/core/sarif.py:117) - -Evidence: `core.sarif` imports private scanner/rule helpers and re-derives sink provenance from `AnalysisContext` internals. - -Impact: `core` is not a pure shared contract layer for SARIF; scanner refactors can silently break SARIF code-flow/provenance output. - -Remediation: Move code-flow/provenance projection to a public scanner explain API or stable DTO, and let SARIF serialize that public contract. - -## Low - -### L1. PY-WL-109 treats `Any` as a non-None promise - -Location: -- [src/wardline/scanner/rules/none_leak.py:66-124](/home/john/wardline/src/wardline/scanner/rules/none_leak.py:66) - -Evidence: `_annotation_allows_none()` recognizes `None`, `Optional`, `Union`, and `| None`, but not `Any` or `typing.Any`. Any other explicit annotation is treated as a non-None promise. - -Impact: Functions annotated `-> Any` can get false-positive None-leak findings. - -Remediation: Treat `Any` and `typing.Any` as not promising non-None. Add direct and string-annotation tests. - -### L2. Live judge cache oracle is tautological and CI guard misses the network job - -Locations: -- [tests/e2e/test_judge_live.py:14-38](/home/john/wardline/tests/e2e/test_judge_live.py:14) -- [tests/unit/test_ci_live_oracles.py:6-14](/home/john/wardline/tests/unit/test_ci_live_oracles.py:6) -- [.github/workflows/ci.yml:84-98](/home/john/wardline/.github/workflows/ci.yml:84) - -Evidence: The live judge test docstring says the second call hits cache, but the assertion allows `prompt_tokens_cached is None` or `>= 0`, including zero. The CI guard checks the live-oracle matrix markers but not the scheduled `network` job. - -Impact: Prompt-cache telemetry or network-job workflow drift can pass unnoticed. - -Remediation: Either require a positive cached-token signal where provider cache is contractual, or remove the cache-hit claim and assert only schema-critical fields. Extend the workflow guard to include the `network` job, marker, schedule condition, and API-key environment. - -### L3. Clarion live oracle setup is fragile - -Locations: -- [tests/e2e/test_clarion_live.py:39-69](/home/john/wardline/tests/e2e/test_clarion_live.py:39) -- [tests/e2e/test_clarion_live.py:142-164](/home/john/wardline/tests/e2e/test_clarion_live.py:142) - -Evidence: Route support is inferred by running `strings` over the binary and searching for a literal route. `_free_port()` binds port `0`, releases it, then the subprocess later tries to bind the chosen port. - -Impact: Valid Clarion builds can be skipped falsely, and port reuse races can make the live oracle flaky. - -Remediation: Prefer runtime capability probing after launch. Let explicit `WARDLINE_CLARION_BIN` proceed to runtime probing. Use a bind-to-port-0 server mode if Clarion supports it, or otherwise remove the open-port race. - -### L4. Protocol/package boundaries need clearer ownership - -Locations: -- [src/wardline/mcp/lsp.py:1-2](/home/john/wardline/src/wardline/mcp/lsp.py:1) -- [src/wardline/cli/lsp.py:1-10](/home/john/wardline/src/wardline/cli/lsp.py:1) -- [src/wardline/mcp/server.py:517-807](/home/john/wardline/src/wardline/mcp/server.py:517) -- [src/wardline/mcp/server.py:834-877](/home/john/wardline/src/wardline/mcp/server.py:834) - -Evidence: LSP lives under the MCP package, and the MCP registry mixes read-only, mutating, and network tools in one registration/dispatch path without central capability enforcement despite tool metadata such as `network=True`. - -Impact: Future read-only/no-network MCP modes have no central enforcement point, and package ownership is muddy. - -Remediation: Move LSP to `wardline.lsp` or `wardline.protocols.lsp` with a compatibility re-export. Add tool capability classes and enforce read/write/network policy at dispatch. - -### L5. Scanner orchestration has hidden global seams - -Locations: -- [src/wardline/scanner/analyzer.py:76-152](/home/john/wardline/src/wardline/scanner/analyzer.py:76) -- [src/wardline/scanner/analyzer.py:319-485](/home/john/wardline/src/wardline/scanner/analyzer.py:319) -- [src/wardline/scanner/analyzer.py:504-778](/home/john/wardline/src/wardline/scanner/analyzer.py:504) -- [src/wardline/scanner/taint/variable_level.py:78-92](/home/john/wardline/src/wardline/scanner/taint/variable_level.py:78) - -Evidence: Analyzer orchestration combines parsing, cache, L1/L2/L3 flow, diagnostics, and rule dispatch. It mutates private contextvars from `variable_level.py` to pass call-site state. - -Impact: Stage boundaries are hard to test independently, and private global/contextvar state increases refactor risk. - -Remediation: Split the scanner into explicit pipeline stages with typed inputs/outputs, and pass taint-analysis context explicitly instead of mutating private globals. - -## Suggested Remediation Order - -1. Fix H1 root confinement first because it is a trust-boundary and possible data-exfiltration issue, especially for `judge`. -2. Fix scanner false negatives next: H5, H6, H7, H8. Add focused failing tests before implementation. -3. Fix evidence identity drift: H3 and H4, then add parity tests between scan, baseline, attest, CLI, and MCP. -4. Harden cache trust (H2) and network/MCP protocol behavior (M1, M2, M4). -5. Tighten CI live-oracle required mode (H9) so future integrations fail loudly when not actually exercised. - -## Verification Notes - -- Verified current tree with read-only commands only. -- No source files were modified. -- No tests were run to avoid cache/coverage writes. -- External protocol references used only official Model Context Protocol documentation: - - [MCP current basic messages](https://modelcontextprotocol.io/specification/2025-06-18/basic/index) - - [MCP tools error handling](https://modelcontextprotocol.io/specification/draft/server/tools) diff --git a/wardline-readonly-audit-2026-06-04.md b/wardline-readonly-audit-2026-06-04.md deleted file mode 100644 index 9bf1e3e3..00000000 --- a/wardline-readonly-audit-2026-06-04.md +++ /dev/null @@ -1,568 +0,0 @@ -# Wardline Read-Only Codebase Audit - -Date: 2026-06-04 -Scope: `/home/john/wardline` -Mode: Comprehensive read-only audit of source, tests, CLI, MCP, static-analysis logic, integrations, and security boundaries. - -## Read-Only Boundary - -This audit was conducted as a read-only review of the codebase. No source files, tests, configs, or tracker state were modified as part of the audit. The only write performed was this requested markdown artifact. - -Seven specialized subagents were dispatched with explicit read-only instructions: - -| Agent | Focus | -| --- | --- | -| Architecture Critic | Package boundaries, cohesion, coupling, structural fragility under `src/` | -| Systems Thinker | Feedback loops, dependency chains, propagation flows, failure modes | -| Python Engineer | Python implementation, typing posture, AST parsing/manipulation, idioms | -| Quality Engineer | Tests, CI, coverage structure, maintainability, validation paths | -| Security Architect | Trust boundaries, external data handling, secure defaults | -| Static Tools Analyst | Static-analysis rules, taint propagation, trust lattice, SCC logic | -| MCP & CLI Specialist | CLI and MCP protocol/server behavior, path confinement, tool parity | - -Tooling note: the available subagent spawning interface did not expose literal `enable_write_tools=false` or `enable_mcp_tools=false` switches. The equivalent boundary was enforced in each subagent prompt: no edits, no `apply_patch`, no write commands, no MCP tools, no tracker changes, and no escaped double quotes in tool arguments. - -## Executive Summary - -No Critical issues were found. The audit identified 5 High, 15 Medium, and 5 Low findings. The highest-risk themes are: - -- Warm-cache scan behavior can diverge from cold scans for L2-backed rules. -- A default MCP waiver write path can bypass root confinement through symlinked `wardline.yaml`. -- Project-controlled config can influence autofix code generation and LLM judge suppression behavior. -- Filigree close-on-fixed feedback lacks a clean-file heartbeat, so fixed findings may stay open. -- MCP/CLI hardening has several retry, schema, notification, and confinement gaps. - -The codebase also shows several strong foundations: shared CLI/MCP orchestration through `core.run`, generally coherent scanner layering, strict mypy/coverage-oriented CI, safe YAML loading/schema validation, fail-soft external integrations, and explicit trust-lattice modeling in the analyzer. - -## Remediation Status - -Resolved in the current remediation pass: - -- H-01: warm-cache L2 bypass no longer skips flow-sensitive call-site state; the warm/cold cache parity test now uses a sink fixture. -- H-02: MCP `waiver_add` passes the project root into waiver writes, rejecting symlinked default config escapes. -- H-03: `autofix.boundary_exception` is validated as an identifier/dotted identifier, and MCP `fix` requires `apply: true` before modifying files. -- H-04: project judge config no longer controls model or write confidence floor unless `trust_judge_config` is explicitly set. -- H-05: Filigree scan-results payloads now carry `scanned_paths`, allowing clean scanned files to participate in close-on-fixed reconciliation. -- M-01: JSON-RPC notifications no longer invoke registered handlers except for initialization lifecycle notifications. -- M-02: MCP tool schemas are closed with `additionalProperties: false`, and unknown tool arguments return `isError`. -- M-03: retrying `baseline_create` and `waiver_add` is idempotent and returns existing state instead of failing. -- M-04: CLI/core scans reject escaping `source_roots` by default; CLI escape now requires `--allow-source-root-escape`. -- M-05: attestation verification now checks `signature.alg`, `signature.key_id`, and signature value. -- M-06: Filigree dossier reads normalize scan-results URLs to the Filigree API base before querying entity associations. -- M-07: Clarion-backed explanations select the exact stored finding by fingerprint/path/line and fall back to local analysis on mismatch. -- M-08: discovery skip rules apply relative to the configured source root, not absolute parent directories. -- M-09: callgraph receiver type tracking no longer collects assignments from nested scopes. -- M-10: sink discovery no longer descends into lambda bodies. -- M-11: `NoneLeak` fall-through analysis handles all-return `try`/`except` paths. -- M-12: shared dossier identity types live in neutral `core.identity`, with Clarion compatibility re-exports. -- M-13: MCP resource/prompt catalogs and shared tool plumbing were split out of `mcp/server.py`, with advertisement snapshot coverage. -- M-14: CI now exposes scheduled/manual live-oracle jobs for Clarion, Legis, and Filigree e2e markers. -- M-15: Filigree unsafe config URL rejection now has mirrored negative coverage. -- L-01: analyzer cache freshness checks use `SummaryCache.has_current()` instead of private `_entries`. -- L-02: `core.protocols` is wired into scan orchestration and the rule registry seam. -- L-03: pack tests use `monkeypatch.syspath_prepend` instead of direct `sys.path` mutation. -- L-04: LSP `Content-Length` framing has a maximum body size and drains oversized frames deterministically. -- L-05: CLI `scan --fix` preserves `strict_defaults` during the post-fix rescan. - -Verification after remediation: - -- `uv run pytest` — 2173 passed, 8 live/network tests deselected, 1 expected symlink-skip warning. -- `uv run mypy src tests` — success. -- `uv run ruff check src tests` — success. - -## Critical Findings - -None found. - -## High Findings - -### H-01: Warm-cache L2 bypass changes rule behavior - -Severity: High -Areas: Static analysis correctness, scanner cache, taint rules -Locations: - -- [src/wardline/scanner/analyzer.py:518](/home/john/wardline/src/wardline/scanner/analyzer.py:518)-525 -- [src/wardline/scanner/rules/_sink_helpers.py:174](/home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py:174)-187 -- [src/wardline/scanner/rules/_sink_helpers.py:268](/home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py:268)-270 -- [src/wardline/scanner/rules/untrusted_to_trusted_callee.py:117](/home/john/wardline/src/wardline/scanner/rules/untrusted_to_trusted_callee.py:117)-119 -- [tests/unit/cli/test_cli.py:191](/home/john/wardline/tests/unit/cli/test_cli.py:191)-218 - -Finding: cached modules with `bypass_l2` restore only selected summary fields, while L2-derived local variable and callsite taints are not rebuilt. Sink rules then fall back to `UNKNOWN_RAW`, and PY-WL-105 can lose caller/callee evidence. Cold and warm scans can therefore produce different diagnostics for the same code. The existing CLI warm-cache test covers a trivial non-sink fixture, so it does not prove the L2-backed sink/callee invariant. - -Impact: false positives and false negatives in the scanner after cache reuse. This is especially risky because cache reuse is a normal operational path, so CI or local dogfooding can disagree with a fresh scan. - -Remediation: - -1. Persist and reload the L2 artifacts that rules consume, including local variable taints and callsite taints. -2. If full L2 persistence is not desired, recompute L2 for cached modules before running rules. -3. Add a cold-vs-warm invariant test that scans the same fixture twice and asserts identical diagnostics. -4. Include fixtures for a safe literal sink, a raw sink, and PY-WL-105 caller/callee propagation. - -### H-02: MCP `waiver_add` can write through an out-of-root symlinked default config path - -Severity: High -Areas: MCP mutating tool, path confinement, waiver persistence -Locations: - -- [src/wardline/mcp/server.py:417](/home/john/wardline/src/wardline/mcp/server.py:417)-429 -- [src/wardline/core/waivers.py:88](/home/john/wardline/src/wardline/core/waivers.py:88)-117 - -Finding: the MCP `_waiver_add` fallback writes to `root / "wardline.yaml"` but calls `add_waiver` without passing `root=root`. `add_waiver` only applies `safe_project_file` when `root` is supplied. A symlinked default config file can therefore redirect writes outside the project root. - -Impact: a project can cause the MCP tool to append waiver data to an arbitrary symlink target reachable by the process. This breaks the MCP server's otherwise explicit path-confinement posture. - -Remediation: - -1. Pass `root=root` from `_waiver_add` to `add_waiver` for the default path. -2. Make `add_waiver` require a root for all writes, or require callers to pass a prevalidated path object. -3. Add symlink escape tests for `waiver_add`, default config reads, default config writes, `resources/read wardline://config`, `scan`, and `fix`. -4. Treat a symlinked config target outside the root as an MCP `isError` response with a clear confinement error. - -### H-03: Config-controlled autofix exception name can generate unsafe Python output - -Severity: High -Areas: Autofix, config trust boundary, MCP fix tool -Locations: - -- [src/wardline/core/autofix.py:80](/home/john/wardline/src/wardline/core/autofix.py:80) -- [src/wardline/core/autofix.py:175](/home/john/wardline/src/wardline/core/autofix.py:175) -- [src/wardline/core/config_schema.py:59](/home/john/wardline/src/wardline/core/config_schema.py:59) -- [src/wardline/core/config.py:36](/home/john/wardline/src/wardline/core/config.py:36)-38 -- [src/wardline/mcp/server.py:437](/home/john/wardline/src/wardline/mcp/server.py:437)-456 - -Finding: `autofix.boundary_exception` is accepted as an arbitrary string and later inserted into an AST node as a name before being unparsed. The MCP fix path can apply changes without a confirmation callback. - -Impact: untrusted project configuration can steer generated source text. Even if malformed names usually fail at AST construction or unparsing time, this is a codemod trust-boundary violation and creates brittle, surprising behavior. - -Remediation: - -1. Validate `autofix.boundary_exception` as either a single valid identifier or a dotted qualified name where every segment passes `str.isidentifier()`. -2. Prefer an allowlist for known safe boundary exception names if project policy permits it. -3. Return a config validation error before any fix planning when the value is invalid. -4. Make MCP fix dry-run by default, or require an explicit `apply: true` argument for file mutation. -5. Add negative tests for malformed names, dotted names, keywords, whitespace, and punctuation. - -### H-04: Project config can steer LLM judge model and suppression threshold - -Severity: High -Areas: LLM judge integration, suppression policy, untrusted project config -Locations: - -- [src/wardline/core/config_schema.py:37](/home/john/wardline/src/wardline/core/config_schema.py:37)-42 -- [src/wardline/core/config.py:324](/home/john/wardline/src/wardline/core/config.py:324)-347 -- [src/wardline/cli/judge.py:95](/home/john/wardline/src/wardline/cli/judge.py:95) -- [src/wardline/core/judge_run.py:81](/home/john/wardline/src/wardline/core/judge_run.py:81)-92 -- [src/wardline/core/judge_run.py:150](/home/john/wardline/src/wardline/core/judge_run.py:150) -- [src/wardline/core/judge_run.py:200](/home/john/wardline/src/wardline/core/judge_run.py:200) -- [src/wardline/core/judge.py:211](/home/john/wardline/src/wardline/core/judge.py:211)-237 -- [src/wardline/core/judge.py:322](/home/john/wardline/src/wardline/core/judge.py:322) - -Finding: project config can set judge model and false-positive floor values. In a hostile or low-trust checkout, config can direct analysis to an unintended model or lower suppression thresholds. - -Impact: a repository under scan can influence the external judge used to evaluate its own findings and can make suppression easier. This weakens judge-based assurance and creates an operator trust-boundary issue. - -Remediation: - -1. Treat model selection and suppression threshold as operator-controlled settings by default. -2. Require an explicit CLI flag such as `--trust-judge-config` before project config can influence these values. -3. Do not allow project config to lower the built-in false-positive floor unless an operator override is present. -4. Record effective judge model, threshold source, and override source in output metadata. -5. Add tests showing that project config is ignored without the trust flag and honored only with the flag. - -### H-05: Filigree close-on-fixed feedback lacks a clean-file heartbeat - -Severity: High -Areas: Filigree integration, feedback loops, finding lifecycle -Locations: - -- [src/wardline/core/filigree_emit.py:58](/home/john/wardline/src/wardline/core/filigree_emit.py:58)-69 -- [src/wardline/scanner/diagnostics.py:39](/home/john/wardline/src/wardline/scanner/diagnostics.py:39)-48 - -Finding: close-on-fixed appears to rely on emitted findings and a `mark_unseen` behavior, but the emitted payload does not include a complete scanned-file inventory or per-file clean heartbeat. The code comment indicates absent fingerprints are swept only for files still represented in the batch. If a file has no current findings, it may not be represented as a cleaned source file. - -Impact: fixing the only finding in a file may fail to close the linked Filigree issue. The external tracker can drift toward stale open issues and reduce trust in scan automation. - -Remediation: - -1. Extend the Filigree payload with explicit scanned source scopes for every analyzed file. -2. Alternatively, emit a per-file clean/reconciliation fact when a file was scanned and produced no findings. -3. Ensure `mark_unseen` receives enough file-level scope to close findings removed from otherwise-clean files. -4. Add a live or contract e2e test: create a finding, bind or emit it to Filigree, fix it, rescan, and assert the file_finding issue closes. - -## Medium Findings - -### M-01: JSON-RPC notifications can silently execute side-effecting MCP handlers - -Severity: Medium -Locations: - -- [src/wardline/mcp/protocol.py:58](/home/john/wardline/src/wardline/mcp/protocol.py:58)-99 - -Finding: requests without an `id` are treated as notifications and no response is sent, but the handler is still invoked. A no-id `tools/call` notification can therefore run side-effecting handlers silently. - -Remediation: - -1. Whitelist legitimate notifications only, such as initialization lifecycle notifications if needed. -2. Reject or ignore no-id calls to `tools/*`, `resources/*`, and `prompts/*`. -3. Add a test proving no-id `tools/call` cannot mutate baseline, waiver, or fix state. - -### M-02: MCP tool schemas allow unknown arguments on mutating tools - -Severity: Medium -Locations: - -- [src/wardline/mcp/server.py:824](/home/john/wardline/src/wardline/mcp/server.py:824)-835 -- [src/wardline/mcp/server.py:922](/home/john/wardline/src/wardline/mcp/server.py:922)-956 -- [src/wardline/mcp/server.py:437](/home/john/wardline/src/wardline/mcp/server.py:437)-456 - -Finding: tool schemas do not consistently set `additionalProperties: false`. Unknown arguments, typoed safety flags, and accidentally ignored user intent can pass through mutating tool calls. - -Remediation: - -1. Add `additionalProperties: false` to all MCP input schemas. -2. Reject unknown arguments in server-side validation before dispatch. -3. Add negative schema tests for typoed mutating-tool arguments. -4. Require explicit `apply: true` or equivalent for mutating tools that change files. - -### M-03: Mutating MCP tools are not retry-safe - -Severity: Medium -Locations: - -- [src/wardline/mcp/server.py:401](/home/john/wardline/src/wardline/mcp/server.py:401)-429 -- [src/wardline/core/baseline.py:100](/home/john/wardline/src/wardline/core/baseline.py:100)-109 -- [src/wardline/core/waivers.py:111](/home/john/wardline/src/wardline/core/waivers.py:111)-117 - -Finding: repeated identical mutating calls can append duplicates or overwrite state without a stable idempotency story. - -Remediation: - -1. Make repeated identical calls return structured success such as `already_exists: true`. -2. Deduplicate waiver entries by stable fingerprint/rule/path tuple. -3. Add expected-version or idempotency-key support for baseline writes. -4. Add retry tests that issue the same MCP mutating call twice. - -### M-04: CLI/core scans are unconfined by default - -Severity: Medium -Locations: - -- [src/wardline/core/run.py:76](/home/john/wardline/src/wardline/core/run.py:76)-93 -- [src/wardline/core/discovery.py:17](/home/john/wardline/src/wardline/core/discovery.py:17)-27 -- [src/wardline/cli/scan.py:137](/home/john/wardline/src/wardline/cli/scan.py:137) -- [tests/unit/mcp/test_server_security.py:74](/home/john/wardline/tests/unit/mcp/test_server_security.py:74)-99 - -Finding: MCP has explicit path-escape tests, but CLI/core scanning defaults to unconfined source roots. That creates different trust behavior across entry points. - -Remediation: - -1. Default `confine_to_root=True` in CLI/core scan paths. -2. Add an explicit `--allow-source-root-escape` CLI option for the less-safe behavior. -3. Include the confinement mode in scan metadata. -4. Add CLI tests matching the MCP path-escape coverage. - -### M-05: Attestation signature metadata is mutable - -Severity: Medium -Locations: - -- [src/wardline/core/attest.py:126](/home/john/wardline/src/wardline/core/attest.py:126)-135 -- [src/wardline/core/attest.py:300](/home/john/wardline/src/wardline/core/attest.py:300)-302 - -Finding: HMAC verification covers the payload/value, but the outer signature metadata such as algorithm and key id is not itself bound or strictly revalidated. - -Remediation: - -1. Require `signature.alg == "HMAC-SHA256"` during verification. -2. Require the stored `key_id` to match the verifying key's derived id. -3. Prefer including signature metadata in the signed canonical envelope. -4. Add tamper tests for algorithm, key id, schema tag, and payload. - -### M-06: Dossier Filigree read path treats a scan-results URL as an API origin - -Severity: Medium -Locations: - -- [src/wardline/filigree/dossier_client.py:78](/home/john/wardline/src/wardline/filigree/dossier_client.py:78)-86 -- [src/wardline/loom_dossier.py:108](/home/john/wardline/src/wardline/loom_dossier.py:108)-109 -- [tests/unit/core/test_config.py:185](/home/john/wardline/tests/unit/core/test_config.py:185)-193 - -Finding: configuration examples/tests treat `filigree.url` as a Loom scan-results endpoint, while dossier association reads append `/api/entity-associations` to that value as though it were an API origin. - -Remediation: - -1. Split configuration into distinct values such as `filigree.scan_results_url` and `filigree.api_base_url`. -2. Or normalize the configured scan-results URL back to an origin before appending association routes. -3. Add tests for the documented URL shape and the dossier association lookup URL. - -### M-07: Clarion-backed explain can pair a requested fingerprint with the wrong stored finding - -Severity: Medium -Locations: - -- [src/wardline/core/explain.py:152](/home/john/wardline/src/wardline/core/explain.py:152)-177 -- [src/wardline/core/explain.py:273](/home/john/wardline/src/wardline/core/explain.py:273)-280 -- [src/wardline/clarion/facts.py:59](/home/john/wardline/src/wardline/clarion/facts.py:59)-91 - -Finding: the Clarion-backed explanation path can collapse an entity query to the first finding in a stored blob instead of selecting the finding matching the requested fingerprint/path/line. - -Remediation: - -1. Pass requested fingerprint, path, line, and rule id into the blob extraction function. -2. Select the exact matching finding when present. -3. Fall back to local explanation when no matching entry exists. -4. Add tests with multiple findings bound to one entity. - -### M-08: Absolute-path skip filter can false-green scans based on checkout location - -Severity: Medium -Locations: - -- [src/wardline/core/discovery.py:17](/home/john/wardline/src/wardline/core/discovery.py:17)-34 -- [src/wardline/core/run.py:209](/home/john/wardline/src/wardline/core/run.py:209)-223 - -Finding: skip logic checks path components directly. If an absolute parent directory contains a skipped component such as `.venv`, `venv`, `.git`, or `.mypy_cache`, an otherwise valid checkout can be skipped entirely. - -Remediation: - -1. Apply skip logic to components relative to the project or source root. -2. Emit a structured diagnostic or run metadata flag when all candidate files are skipped. -3. Add tests for checkouts under paths containing skipped directory names. - -### M-09: Callgraph receiver type inference is polluted by nested-scope assignments - -Severity: Medium -Locations: - -- [src/wardline/scanner/taint/callgraph.py:91](/home/john/wardline/src/wardline/scanner/taint/callgraph.py:91)-114 -- [src/wardline/scanner/taint/callgraph.py:145](/home/john/wardline/src/wardline/scanner/taint/callgraph.py:145)-154 - -Finding: `ast.walk` traverses nested functions, lambdas, and classes while collecting local assignment type information. Inner-scope assignments can pollute outer-scope receiver inference. - -Remediation: - -1. Replace broad `ast.walk` collection with an own-scope visitor. -2. Stop recursion at nested `FunctionDef`, `AsyncFunctionDef`, `ClassDef`, and `Lambda` boundaries. -3. Add tests where nested-scope assignments use the same variable name as the outer scope. - -### M-10: Sink discovery enters lambda bodies without matching taint snapshots - -Severity: Medium -Locations: - -- [src/wardline/scanner/rules/_sink_helpers.py:81](/home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py:81)-97 -- [src/wardline/scanner/rules/_sink_helpers.py:191](/home/john/wardline/src/wardline/scanner/rules/_sink_helpers.py:191)-205 -- [src/wardline/scanner/ast_primitives.py:122](/home/john/wardline/src/wardline/scanner/ast_primitives.py:122)-124 - -Finding: sink discovery can traverse lambda bodies, but the taint state associated with those expressions does not have a matching scope snapshot. This can misattribute trust state in nested expression scopes. - -Remediation: - -1. Apply the same scope-boundary policy used by callgraph/L2 analysis. -2. Or model lambdas as first-class scopes with their own taint snapshots. -3. Add lambda sink tests for trusted and untrusted captures. - -### M-11: PY-WL-109 NoneLeak fall-through analysis is too shallow - -Severity: Medium -Locations: - -- [src/wardline/scanner/rules/none_leak.py:138](/home/john/wardline/src/wardline/scanner/rules/none_leak.py:138)-154 -- [src/wardline/scanner/rules/none_leak.py:183](/home/john/wardline/src/wardline/scanner/rules/none_leak.py:183)-190 -- [tests/unit/scanner/rules/test_none_leak.py:106](/home/john/wardline/tests/unit/scanner/rules/test_none_leak.py:106)-229 - -Finding: fall-through detection handles simple terminal statements but does not robustly model `try`, `match`, and common loop terminal forms. This can over-report missing returns. - -Remediation: - -1. Expand terminal-path analysis for `try`/`except`/`finally`, `match`, and loops whose bodies unconditionally return/raise. -2. When full certainty is not available, prefer a conservative unknown path instead of a confident diagnostic. -3. Add positive and negative tests for `try`, `match`, loop, and nested branch cases. - -### M-12: Dossier identity model depends on Clarion-owned types - -Severity: Medium -Locations: - -- [src/wardline/core/dossier.py:39](/home/john/wardline/src/wardline/core/dossier.py:39) -- [src/wardline/core/dossier.py:428](/home/john/wardline/src/wardline/core/dossier.py:428)-442 -- [src/wardline/filigree/dossier_client.py:27](/home/john/wardline/src/wardline/filigree/dossier_client.py:27) -- [src/wardline/loom_dossier.py:5](/home/john/wardline/src/wardline/loom_dossier.py:5)-8 - -Finding: core dossier and Filigree dossier paths depend on identity/status types owned by the Clarion integration. This weakens package boundaries and makes a product-neutral dossier model depend on one provider. - -Remediation: - -1. Move shared identity types such as `IdentityStatus`, `ContentStatus`, `EntityBinding`, and `content_status` into a neutral module such as `core.identity`. -2. Re-export from Clarion only for compatibility. -3. Update core, Clarion, Filigree, and Loom import paths. -4. Add a small dependency-direction test or import-lint check if the project already uses such tooling. - -### M-13: MCP server is over-concentrated - -Severity: Medium -Locations: - -- [src/wardline/mcp/server.py:132](/home/john/wardline/src/wardline/mcp/server.py:132) -- [src/wardline/mcp/server.py:401](/home/john/wardline/src/wardline/mcp/server.py:401) -- [src/wardline/mcp/server.py:469](/home/john/wardline/src/wardline/mcp/server.py:469) -- [src/wardline/mcp/server.py:551](/home/john/wardline/src/wardline/mcp/server.py:551) -- [src/wardline/mcp/server.py:854](/home/john/wardline/src/wardline/mcp/server.py:854) -- [src/wardline/mcp/server.py:913](/home/john/wardline/src/wardline/mcp/server.py:913) - -Finding: MCP protocol, dependency construction, tool handlers, resource handlers, prompts, and schema advertisement are concentrated in one large server module. - -Impact: local changes to one tool can accidentally affect unrelated MCP surfaces, and security validation rules are harder to enforce consistently. - -Remediation: - -1. Split handlers into modules such as `mcp/tools/scan.py`, `mcp/tools/fix.py`, `mcp/resources.py`, `mcp/prompts.py`, and `mcp/schemas.py`. -2. Keep the top-level server as a dispatcher and registry assembler. -3. Centralize common argument validation, root confinement, and error mapping. -4. Preserve public tool names and add advertisement snapshot tests during the split. - -### M-14: Live Clarion/Legis/Filigree e2e oracles are not CI-gated - -Severity: Medium -Locations: - -- [pyproject.toml:105](/home/john/wardline/pyproject.toml:105)-112 -- [.github/workflows/ci.yml:47](/home/john/wardline/.github/workflows/ci.yml:47)-95 -- [tests/e2e/test_clarion_live.py:1](/home/john/wardline/tests/e2e/test_clarion_live.py:1)-12 - -Finding: important live integration tests are marked as opt-in and are not represented as scheduled or manually triggered CI jobs. - -Remediation: - -1. Add scheduled or workflow-dispatch jobs for `clarion_e2e`, `legis_e2e`, and `filigree_e2e`. -2. Gate those jobs on required service secrets or local service availability. -3. Publish skipped/live status clearly in CI summaries. -4. Keep normal PR CI dependency-free, but run live oracles often enough to catch drift. - -### M-15: Filigree unsafe-config URL guard lacks mirrored negative tests - -Severity: Medium -Locations: - -- [src/wardline/core/config.py:257](/home/john/wardline/src/wardline/core/config.py:257)-285 -- [tests/unit/core/test_config.py:217](/home/john/wardline/tests/unit/core/test_config.py:217)-233 - -Finding: Clarion unsafe URL configuration has negative test coverage, but Filigree's equivalent unsafe-config guard does not have mirrored tests. - -Remediation: - -1. Add Filigree tests that reject unsafe scheme/host combinations under unsafe config. -2. Mirror the Clarion test shape to make the two trust-boundary policies easy to compare. -3. Include a positive test for an explicitly permitted safe Filigree URL. - -## Low Findings - -### L-01: Analyzer reaches into `SummaryCache` private state - -Severity: Low -Locations: - -- [src/wardline/scanner/analyzer.py:173](/home/john/wardline/src/wardline/scanner/analyzer.py:173) -- [src/wardline/scanner/taint/summary_cache.py:88](/home/john/wardline/src/wardline/scanner/taint/summary_cache.py:88)-97 -- [src/wardline/scanner/taint/summary_cache.py:125](/home/john/wardline/src/wardline/scanner/taint/summary_cache.py:125)-134 - -Finding: analyzer logic reaches into `SummaryCache._entries`, which couples orchestration code to cache internals. - -Remediation: - -1. Add a public cache API such as `has_current(path, digest)` or `lookup_current(path, digest)`. -2. Move freshness checks into `SummaryCache`. -3. Update analyzer tests to assert behavior through the public API. - -### L-02: `core.protocols` abstractions are declared but not wired - -Severity: Low -Locations: - -- [src/wardline/core/protocols.py:14](/home/john/wardline/src/wardline/core/protocols.py:14) -- [src/wardline/core/protocols.py:18](/home/john/wardline/src/wardline/core/protocols.py:18) -- [src/wardline/core/run.py:96](/home/john/wardline/src/wardline/core/run.py:96) - -Finding: protocol abstractions exist but are not meaningfully used by the orchestration surface. - -Remediation: - -1. Wire the protocols into `run_scan`/dependency construction if they represent intended extension seams. -2. Otherwise remove or deprecate them to reduce design noise. -3. Add tests only if the protocols are kept as supported extension points. - -### L-03: Some tests mutate `sys.path` without cleanup - -Severity: Low -Locations: - -- [tests/unit/core/test_packs.py:11](/home/john/wardline/tests/unit/core/test_packs.py:11)-14 -- [tests/unit/core/test_judge_run.py:96](/home/john/wardline/tests/unit/core/test_judge_run.py:96)-105 -- [tests/unit/cli/test_cli.py:114](/home/john/wardline/tests/unit/cli/test_cli.py:114)-143 - -Finding: some tests insert paths into `sys.path` directly. This can leak import state across tests. - -Remediation: - -1. Use `monkeypatch.syspath_prepend`. -2. Add fixtures that clean up import/module state after each test. -3. Keep dynamically created modules isolated per test. - -### L-04: LSP input framing lacks a maximum `Content-Length` - -Severity: Low -Locations: - -- [src/wardline/mcp/protocol.py:122](/home/john/wardline/src/wardline/mcp/protocol.py:122)-129 -- [src/wardline/mcp/lsp.py:78](/home/john/wardline/src/wardline/mcp/lsp.py:78) -- [src/wardline/mcp/lsp.py:83](/home/john/wardline/src/wardline/mcp/lsp.py:83)-91 - -Finding: MCP line input has a 10 MB guard, but LSP-style `Content-Length` framing does not appear to enforce a comparable maximum body size. - -Remediation: - -1. Add a maximum LSP body size. -2. Reject or drain oversized frames deterministically. -3. Add tests for exactly-at-limit, over-limit, malformed, and missing-length frames. - -### L-05: CLI `scan --fix` drops `strict_defaults` on post-fix rescan - -Severity: Low -Locations: - -- [src/wardline/cli/scan.py:137](/home/john/wardline/src/wardline/cli/scan.py:137)-178 - -Finding: the first scan honors `strict_defaults`, but the post-fix rescan does not pass that setting through. - -Remediation: - -1. Pass `strict_defaults=strict_defaults` to the post-fix `run_scan` call. -2. Add a CLI regression test where strict defaults affect diagnostics before and after `--fix`. - -## Prioritized Remediation Plan - -1. Close direct trust-boundary vulnerabilities first: fix MCP waiver symlink confinement, validate autofix config values, and prevent untrusted project config from lowering judge assurance. -2. Restore scanner determinism next: make cold and warm scans produce identical L2-backed rule behavior, then add invariant tests. -3. Repair feedback loops: add Filigree clean-file heartbeat/reconciliation and validate close-on-fixed with live or contract e2e coverage. -4. Harden MCP contracts: block side-effecting notifications, reject unknown schema arguments, and make mutating tools idempotent. -5. Improve structural maintainability: split the MCP server, move identity types into a neutral module, and replace private cache access with public APIs. -6. Expand CI and tests: schedule live integration oracles, add Filigree unsafe-config tests, add nested-scope static-analysis fixtures, and clean `sys.path` mutation patterns. - -## Suggested Acceptance Tests - -- Cold scan and warm cached scan of the same fixture produce byte-for-byte equivalent diagnostics for L2-backed sink and PY-WL-105 cases. -- MCP `waiver_add` rejects an out-of-root symlinked `wardline.yaml`. -- Invalid `autofix.boundary_exception` values fail config validation before any AST edit is planned. -- Judge config cannot lower suppression threshold unless an explicit trust flag is supplied. -- A file with one Filigree-backed finding is fixed, rescanned clean, and the associated issue closes. -- No-id JSON-RPC `tools/call` notifications cannot mutate project state. -- MCP mutating tool calls with unknown keys return `isError`. -- CLI scans reject out-of-root source paths by default and allow them only with an explicit escape flag. - -## Audit Limitations - -- This was a static read-only audit. No source edits or test runs were performed as part of remediation. -- Live Clarion, Legis, Filigree, and external LLM judge services were not exercised. -- Findings were synthesized from code inspection and seven specialized read-only reviewer reports. -- The report should be treated as a remediation backlog plus a verification guide, not as proof that every listed bug is currently reproducible under all runtime configurations. diff --git a/www/.nojekyll b/www/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/www/CNAME b/www/CNAME new file mode 100644 index 00000000..9850d9b0 --- /dev/null +++ b/www/CNAME @@ -0,0 +1 @@ +wardline.foundryside.dev diff --git a/www/README.md b/www/README.md new file mode 100644 index 00000000..923e8e3b --- /dev/null +++ b/www/README.md @@ -0,0 +1,142 @@ +# Wardline — front-door site + +Static front door for **Wardline**, a faithful sibling of the Weft Federation hub site at +`~/weft/www/`. Hand-rolled HTML/CSS/JS, no build step, no runtime dependencies. +GitHub-Pages-deployable as-is. + +This www front door is the **canonical root** of `wardline.foundryside.dev`. The MkDocs +reference docs (`~/wardline/docs/`) are served from the **`/docs/` subpath** under the same +domain. The CI `docs-deploy` job assembles both into one GitHub-Pages tree (this `www/` at the +root, `mkdocs build` into `publish/docs/`) and force-pushes it to the `gh-pages` branch — see +**Deployment** below. + +## Files + +| File | Purpose | +|---|---| +| `index.html` | The page: header, hero (what Wardline does + install strip + PY-WL-101 finding panel + metric strip + CI gate example), trust model (3 decorators + 8-state lattice + opt-in explanation), command surface (CLI + MCP + install layers), **federation role** (enrich-only, SEI keying, 3 bindings, sibling member links), footer. Content-complete server-side. | +| `colors_and_type.css` | **Token source of truth, copied verbatim from the Weft design system** (`~/weft/www/colors_and_type.css`). Surfaces, text, accent, the per-member thread palette, radii, elevation, spacing, the mono/display type roles, light theme, and the `ddMenuIn` keyframe. **Do not edit tokens here — re-copy from the design system on any update.** | +| `styles.css` | Wardline layout + components, layered on the tokens. Coral (`--thread-wardline: #F0875E`) is the identity color for left-rules, glyph, eyebrow, and section accents. Amber (`--accent`) is reserved for interactive affordances (links, focus rings), exactly as the Weft hub does. | +| `main.js` | Progressive enhancement only: copy-to-clipboard on the install strip; hover-reveal anchor links on section headings; member row hover treatment. Content-complete with JS disabled. | +| `fonts/` | JetBrains Mono (upright + italic) and Space Grotesk variable TTFs + OFL licenses. Copied verbatim from `~/weft/www/fonts/`. Bundled locally — fully offline, no CDN. | +| `assets/marks/` | Federation glyph SVGs: `wardline.svg`, `weft.svg`, `foundryside.svg`, `loomweave.svg`, `filigree.svg`, `legis.svg`. Copied verbatim from `~/weft/www/assets/marks/`. Inlined in `index.html` to inherit thread color via `currentColor`. | +| `.nojekyll` | Serve files verbatim on GitHub Pages (no Jekyll processing). | + +## Preview locally + +``` +cd /home/john/wardline/www +python3 -m http.server 8000 +``` + +Then open `http://localhost:8000/`. Use `localhost` (not `file://`) so the preloaded fonts +resolve under a normal origin. + +## Design fidelity and deliberate decisions + +### Token copy discipline + +`colors_and_type.css` is copied verbatim from the Weft design system. The comment at its top +says not to edit tokens locally — follow that. On a design-system update, replace the whole file +rather than patching individual tokens. + +### Identity colors + +Coral (`--thread-wardline: #F0875E`) is Wardline's strand. It appears on: +- The header glyph +- Left-rule borders on content cards +- Eyebrow labels on each section +- The trust-flow panel's left rule + +Amber (`--accent: #E9B04A`) is the shared interactive affordance color: +- All `` links +- Focus rings +- The CI gate callout border (shared infrastructure concept, not Wardline-specific) +- The "The federation axiom / connective tissue" facts (amber = shared concern) + +### No rule count + +The brief is explicit: do not state a rule count. The rules section names rule families +qualitatively (trust-boundary leaks, untrusted data reaching deserialization/exec/shell/SQL/SSRF/ +path-traversal, fail-open boundaries, non-rejecting validators) and links to the repo as authority. +The "Four policy rules" phrasing in the README and the "~20 rules" phrasing in members/wardline.md +are both off-limits — they conflict and drift. + +### A-1 binding tag + +Tagged `A-1 · LIVE — until Loomweave-absent path demonstrated end-to-end`, per the brief and +the current weft www hub text. The native Filigree emitter has shipped; the asterisk stays live +until the Loomweave-absent composition path is demonstrated end-to-end. + +### Version + +Shows `v1.0.0rc4` (from the brief). The working branch is `rc5` at the time of writing — noted +as an open fact for the user. + +### Dark only + +The warm espresso theme is canonical and the Weft kit ships no toggle, so none is added. The +`colors_and_type.css` tokens include a full light theme under `[data-theme="light"]` if it is +wanted later. + +### No theme-flash / font-flash + +Both brand faces are ``-ed before first paint. + +### Content-complete without JS + +The entire page is readable with JS disabled. The JS file only adds: +- Copy-to-clipboard on the install command strip +- Hover-reveal anchor links on section headings +- Member row hover treatment + +### Federation section + +The Federation section is first-class — it precedes the footer and has the same weight as the +Trust Model and Commands sections. It covers, in order: the enrich-only axiom, SEI keying, the +three bindings (Wardline→Loomweave, Wardline→Filigree A-1, Wardline→Legis), and a live sibling +member strip linking back to each member's repo. + +### Hero finding panel + +The PY-WL-101 motif is re-colored from the old teal palette onto the warm-Loom palette: +- The panel uses a pinned dark editor surface (`#131E24`/`#0F1A20`) regardless of theme +- Syntax tokens use sky (`#56B7E2`), amber (`#E9B04A`), aqua (`#52C9B8`), warm-emerald (`#5FB98E`) + — colors derived from the Loom thread palette +- The trust leak (`fp-raw`) reads stale-red (`#E2604E`) +- The verdict wash uses `rgba(226, 96, 78, 0.10)` (the Loom stale-red at low opacity) + +## Links — wired to `foundryside-dev` + +- Repo: `github.com/foundryside-dev/wardline` +- Docs: `wardline.foundryside.dev/docs/` (the MkDocs reference docs, served from the subpath) +- Weft hub: `github.com/foundryside-dev/weft` +- Sibling repos: `github.com/foundryside-dev/` (Loomweave repo = `clarion`) + +## Deployment + +This front door owns the site root; the MkDocs docs are served from `/docs/`. Both ship as a +single GitHub-Pages tree assembled by the `docs-deploy` job in `.github/workflows/ci.yml` (runs +only on `push` to `main`): + +1. `cp -r www/. publish/` — this front door becomes the publish root. +2. `mkdocs build --strict -d publish/docs` — the reference docs build into the `/docs/` subpath + (`site_url` in `mkdocs.yml` is pinned to `https://wardline.foundryside.dev/docs/` so internal + links and the canonical resolve under the subpath). +3. `publish/CNAME` (copied from `www/CNAME`) and `publish/.nojekyll` sit at the root; the + `gh-pages` branch is force-pushed with the combined tree. + +**CNAME single source of truth:** `www/CNAME`. There is intentionally no `docs/CNAME` — if there +were, `mkdocs build -d publish/docs` would emit a stray `publish/docs/CNAME`, and a bare +`mkdocs gh-deploy` would publish the docs domain-less. The assembly path is the only correct deploy. + +To preview the **assembled** tree exactly as it ships: + +``` +cd /home/john/wardline +rm -rf publish && cp -r www/. publish/ && uv run mkdocs build --strict -d "$PWD/publish/docs" +cd publish && python3 -m http.server 8000 # root → / · docs → /docs/ +``` + +The trimmed MkDocs landing (`docs/index.md`, no longer using the deleted `overrides/home.html` +template) is a plain reference-docs index; the marketing/front-door role lives here in `www/`. diff --git a/www/assets/marks/filigree.svg b/www/assets/marks/filigree.svg new file mode 100644 index 00000000..7cd82891 --- /dev/null +++ b/www/assets/marks/filigree.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/www/assets/marks/foundryside.svg b/www/assets/marks/foundryside.svg new file mode 100644 index 00000000..35ae8e16 --- /dev/null +++ b/www/assets/marks/foundryside.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/www/assets/marks/legis.svg b/www/assets/marks/legis.svg new file mode 100644 index 00000000..ccce6a41 --- /dev/null +++ b/www/assets/marks/legis.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/www/assets/marks/loomweave.svg b/www/assets/marks/loomweave.svg new file mode 100644 index 00000000..1e6d58de --- /dev/null +++ b/www/assets/marks/loomweave.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/www/assets/marks/wardline.svg b/www/assets/marks/wardline.svg new file mode 100644 index 00000000..8f45fd27 --- /dev/null +++ b/www/assets/marks/wardline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/www/assets/marks/weft.svg b/www/assets/marks/weft.svg new file mode 100644 index 00000000..295a90f2 --- /dev/null +++ b/www/assets/marks/weft.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/www/colors_and_type.css b/www/colors_and_type.css new file mode 100644 index 00000000..1aaef613 --- /dev/null +++ b/www/colors_and_type.css @@ -0,0 +1,302 @@ +/* ============================================================================ + WEFT DESIGN SYSTEM — colors_and_type.css + ---------------------------------------------------------------------------- + The single source of low-level visual tokens for the Weft federation. + + Weft is a family of agent-first, local-first developer tools. This is the + "Loom" revision of the palette: the system stops borrowing the generic + dark-teal-and-cool-blue dev-tool uniform and commits to its own metaphor — + a LOOM. The canonical (dark) theme is a warm crafted ink: an espresso-black + ground, dyed-amber accent, and the per-member "threads" treated as fiber + that can glow on a left-rule. The documented light theme is "Specimen" — a + warm-paper field-ledger / textile swatch book (oxblood ink rule, embroidery + -floss threads), so the two themes read as the same loom in two materials. + + Nothing about the SEMANTICS changed: color is still rationed and always + means a status, a severity, or a member — never decoration. Only the + material moved (teal-clinical -> warm-crafted). Define new colors in `oklch` + from these anchors; don't invent. + + Fonts: bundled locally in fonts/ as variable TTFs (OFL, open source) so the + system works fully offline. JetBrains Mono = product face; Space Grotesk = + brand/display face. See README "Visual Foundations". + ============================================================================ */ + +/* JetBrains Mono — product face (UI, code, body, data). Variable weight. */ +@font-face { + font-family: 'JetBrains Mono'; + src: url('fonts/JetBrainsMono-Variable.ttf') format('truetype'); + font-weight: 100 800; font-style: normal; font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('fonts/JetBrainsMono-Italic-Variable.ttf') format('truetype'); + font-weight: 100 800; font-style: italic; font-display: swap; +} +/* Space Grotesk — brand / display face (wordmark, headlines). Variable weight. */ +@font-face { + font-family: 'Space Grotesk'; + src: url('fonts/SpaceGrotesk-Variable.ttf') format('truetype'); + font-weight: 300 700; font-style: normal; font-display: swap; +} + +/* Brand default: every consumer paints in the product face from first frame, + so no page ever flashes a substitute system font before React/inline styles + apply. Display face is opted into via .t-display / --font-display. */ +html, body { + font-family: var(--font-mono); +} + +:root { + /* ---- Surfaces — "Loom": warm espresso ink (dark is canonical / default) - */ + --surface-base: #14110D; /* app background — warm espresso-black */ + --surface-raised: #1E1A13; /* cards, headers, panels */ + --surface-overlay: #2A2319; /* chips, inputs, secondary fills */ + --surface-hover: #39301F; /* hover fill for interactive surfaces */ + + /* ---- Borders -------------------------------------------------------- */ + --border-default: #332A1F; /* hairline dividers, card edges */ + --border-strong: #4A3C2A; /* inputs, buttons, emphasized edges */ + + /* ---- Text — warm ivory ramp (softer than the old white-on-black) ---- */ + --text-primary: #F2E9D8; /* headings, key values */ + --text-secondary: #B6A78E; /* body, labels */ + --text-muted: #7F6F58; /* metadata, timestamps, hints */ + + /* ---- Accent (dyed amber — the suite's interactive thread) ----------- */ + --accent: #E9B04A; + --accent-hover: #D69A33; + --accent-subtle: rgba(233, 176, 74, 0.16); + + /* ---- Status & semantic ---------------------------------------------- * + * wip stays a cool blue so "in progress" pops against the warm ground. */ + --status-open: #8A7A64; /* warm slate — untouched / backlog */ + --status-wip: #56B7E2; /* sky — in progress (cool pop) */ + --status-done: #897C66; /* warm steel — completed */ + --ready: #5FB98E; /* warm emerald — no blockers, startable */ + --aging: #E9B04A; /* amber — WIP aging (>4h) */ + --stale: #E2604E; /* warm red — WIP stale (>24h) / errors */ + + /* ---- Priority ramp (P0 hottest -> P4 coolest) ----------------------- */ + --prio-0: #E25C49; --prio-1: #EC8A3C; --prio-2: #8A7A64; + --prio-3: #C9BBA0; --prio-4: #C9BBA0; + + /* ---- Severity (scanner findings) ------------------------------------ */ + --sev-critical: #E25C49; --sev-high: #EC8A3C; --sev-medium: #E0B23A; + --sev-low: #56A0E2; --sev-info: #8A7A64; + + /* ---- The Weft thread palette — one accent per federation member ----- * + * Warmed to sit on the espresso ground; still distinct across the wheel * + * and legible on --surface-base. Member identity: glyph color, left-rule * + * (which can carry --glow-thread), badge, header tab. */ + --thread-loomweave: #52C9B8; /* aqua — structure + identity spine */ + --thread-filigree: #56B7E2; /* sky — work state (canonical accent) */ + --thread-wardline: #F0875E; /* coral — trust boundary */ + --thread-legis: #B79BF2; /* violet — governance & law */ + --thread-charter: #E9B04A; /* gold — requirements & verification */ + --thread-shuttle: #8C7C68; /* slate — roadmap thought-bubble (dim) */ + + /* ---- Lacuna — the demo suite (ADJACENT, not part of Weft) ----------- * + * Lacuna is NOT a member. It's the demonstration target. It inherits the * + * whole Weft system so it reads as the same world, then sets itself apart * + * three ways. The "Loom" revision REVERSES the temperature contrast: now * + * that Weft is warm, Lacuna's off-palette goes COOL mauve-ink — still the * + * "MissingNo" that appears in no member thread — plus its cooler specimen * + * surface and the DASHED / ticketed border treatment (vs Weft's solid * + * left-rules). Use it when linking OUT to Lacuna from a Weft surface. */ + --lacuna-accent: #C77FA6; /* dusty mauve — off the federation wheel */ + --lacuna-accent-dim: #8E5A77; /* hovers, secondary marks */ + --lacuna-surface: #1E1922; /* cool mauve-ink raised (vs warm --raised) */ + --lacuna-overlay: #29222F; /* cool chip/input fill */ + --lacuna-border: #3D2F3F; /* mauve-grey hairline */ + --lacuna-flaw: #E2604E; /* a planted lacuna (reuses stale red) */ + + /* ---- Radii ---------------------------------------------------------- */ + --radius-sm: 3px; /* chips, pills, scrollbar */ + --radius: 6px; /* buttons, inputs, cards (Tailwind "rounded") */ + --radius-lg: 8px; /* popovers, dropdowns, modals (shadow-xl surfaces) */ + --radius-full: 9999px; + + /* ---- Elevation ------------------------------------------------------ */ + --shadow-pop: 0 10px 25px rgba(0, 0, 0, 0.50); /* dropdowns/popovers */ + --shadow-modal: 0 20px 50px rgba(0, 0, 0, 0.60); /* modals */ + --glow-accent: 0 0 8px rgba(233, 176, 74, 0.45); /* change-flash */ + + /* ---- Loom signature (new in this revision) -------------------------- * + * --glow-thread : a soft fiber bloom for a member's left-rule. Set * + * --thread on the element (the .thread-* helpers do) and apply * + * box-shadow: var(--glow-thread). Falls back to the accent. * + * --weave-warp : a faint warp-thread texture for AMBIENT surfaces only * + * (hub hero, board background). Never on text or dense chrome — it's a * + * whisper, not a pattern. The light theme overrides this to ledger * + * ruling. Use as a background layer behind real content. */ + --glow-thread: 0 0 7px -1px var(--thread, var(--accent)); + --weave-warp: repeating-linear-gradient(90deg, rgba(233,176,74,0.05) 0 1px, transparent 1px 8px); /* @kind other */ + + /* ---- Semantic aliases (derived; promote repeated literals to tokens) * + * These name the recurring "computed" values the components reach for so * + * the literals live in exactly one place. New surfaces inherit them. */ + --text-on-accent: #1A140A; /* ink on an amber-filled button */ + --focus-ring: 0 0 0 2px var(--accent); /* 2px accent keyboard ring */ + --ring-offset: var(--surface-base); /* ring sits on app bg */ + /* danger (destructive) — deep umber-maroon fill, warm coral ink */ + --danger-fill: rgba(120, 42, 32, 0.50); + --danger-fill-hi: rgba(120, 42, 32, 0.85); + --danger-fg: #F0917E; + --danger-border: #7A2A20; + /* affirmative (ready / pass) — deep emerald fill, warm mint ink */ + --ready-fill: rgba(28, 74, 56, 0.50); + --ready-fg: #6FCB9F; + --ready-border: #2E7D52; + + /* ---- Spacing scale (Tailwind-derived, 4px base) --------------------- */ + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; + --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-12: 48px; + + /* ---- Type families -------------------------------------------------- * + * Two faces, one voice: * + * --font-display : Space Grotesk — the BRAND layer. Wordmark, hub * + * headlines, slide titles. Geometric, technical. * + * --font-mono : JetBrains Mono — the PRODUCT layer. All UI, code, * + * body, data. The terminal-grade signature. * + * Product surfaces stay mono end-to-end; display is for brand moments. */ + --font-display: 'Space Grotesk', 'JetBrains Mono', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, monospace; + --font-sans: 'JetBrains Mono', ui-monospace, monospace; /* product body stays mono */ + + /* ---- Motion --------------------------------------------------------- */ + --ease: cubic-bezier(0.4, 0, 0.2, 1); /* @kind other */ + --dur-fast: 0.15s; /* @kind other */ /* card hover, button states */ + --dur: 0.2s; /* @kind other */ /* panel slide, theme swap */ +} + +/* ---- Light theme — "Specimen": warm-paper field-ledger ---------------- */ +[data-theme="light"] { + --surface-base: #ECE3D1; /* warm paper */ + --surface-raised: #F8F1E3; /* page / card stock */ + --surface-overlay: #E2D7BF; /* chips, inputs */ + --surface-hover: #D8CBB1; + --border-default: #CDBE9F; + --border-strong: #A8966F; + --text-primary: #241E13; /* dark ink */ + --text-secondary: #5C5238; + --text-muted: #897B5F; + --accent: #A33B2C; /* oxblood / madder — the ruling-red ink */ + --accent-hover: #8A2F22; + --accent-subtle: rgba(163, 59, 44, 0.12); + --status-open: #897B5F; + --status-wip: #1E7AB0; + --status-done: #9A8C70; + --ready: #2E7D52; + --aging: #B8862A; + --stale: #B23A28; + --prio-0: #B23A28; --prio-1: #C26A1E; --prio-2: #897B5F; + --prio-3: #A8966F; --prio-4: #A8966F; + --sev-critical: #B23A28; --sev-high: #C26A1E; --sev-medium: #B8862A; + --sev-low: #1E7AB0; --sev-info: #897B5F; + /* embroidery-floss threads — deepened for legibility on paper */ + --thread-loomweave: #118C7E; --thread-filigree: #1E7AB0; --thread-wardline: #CF5630; + --thread-legis: #6E4FC0; --thread-charter: #A9791F; --thread-shuttle: #6E6450; + --lacuna-accent: #A8527E; + --lacuna-accent-dim: #7E3F60; + --lacuna-surface: #E7DCDE; + --lacuna-overlay: #DCCED2; + --lacuna-border: #C4A9B4; + --lacuna-flaw: #B23A28; + --shadow-pop: 0 10px 25px rgba(60, 45, 25, 0.14); + --shadow-modal: 0 20px 50px rgba(60, 45, 25, 0.20); + --glow-accent: 0 0 0 rgba(0, 0, 0, 0); /* paper doesn't glow */ + --glow-thread: 0 0 0 rgba(0, 0, 0, 0); + /* ledger ruling instead of warp threads */ + --weave-warp: repeating-linear-gradient(0deg, transparent 0 27px, rgba(36,30,19,0.045) 27px 28px); /* @kind other */ + --text-on-accent: #F8F1E3; /* paper-white ink on the oxblood button */ + --ring-offset: var(--surface-base); + --danger-fill: rgba(243, 220, 210, 0.90); + --danger-fill-hi: rgba(235, 200, 190, 0.95); + --danger-fg: #9E2A1C; + --danger-border: #D8A293; + --ready-fill: rgba(214, 234, 222, 0.90); + --ready-fg: #2E7D52; + --ready-border: #9CC9AE; +} + +/* ============================================================================ + SEMANTIC TYPE SCALE + Mono-forward, tight, terminal-grade. Sizes are deliberately small and dense + — this is a developer tool, not a marketing page. The dashboard runs at + text-xs (12px) for chrome; these named roles cover documents + UI kits. + ============================================================================ */ + +.weft-type { font-family: var(--font-mono); color: var(--text-primary); + -webkit-font-smoothing: antialiased; font-feature-settings: "liga" 1, "calt" 1; } + +/* Display — hub hero / portfolio headers / slide titles (BRAND face) */ +.t-display { + font-family: var(--font-display); font-weight: 700; + font-size: 46px; line-height: 1.02; letter-spacing: -0.02em; + color: var(--text-primary); +} +.t-h1 { + font-family: var(--font-display); font-weight: 600; + font-size: 30px; line-height: 1.12; letter-spacing: -0.015em; + color: var(--text-primary); +} +/* Brand wordmark helper */ +.t-wordmark { + font-family: var(--font-display); font-weight: 700; + letter-spacing: -0.02em; color: var(--text-primary); +} +.t-h2 { + font-family: var(--font-mono); font-weight: 600; + font-size: 20px; line-height: 1.25; color: var(--text-primary); +} +.t-h3 { + font-family: var(--font-mono); font-weight: 600; + font-size: 15px; line-height: 1.3; color: var(--text-primary); +} +/* Body */ +.t-body { + font-family: var(--font-mono); font-weight: 400; + font-size: 14px; line-height: 1.6; color: var(--text-secondary); + text-wrap: pretty; +} +.t-small { + font-family: var(--font-mono); font-weight: 400; + font-size: 12px; line-height: 1.5; color: var(--text-secondary); +} +/* Label — uppercase section/eyebrow with tracking */ +.t-label { + font-family: var(--font-mono); font-weight: 600; + font-size: 11px; line-height: 1.4; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--text-muted); +} +/* Mono inline — code, ids, tokens, CLI */ +.t-code { + font-family: var(--font-mono); font-weight: 500; + font-size: 13px; line-height: 1.5; color: var(--text-primary); +} +.t-meta { + font-family: var(--font-mono); font-weight: 400; + font-size: 11px; line-height: 1.4; color: var(--text-muted); +} + +/* ---- Member identity helper: paints a strand by its thread color ------ */ +.thread-loomweave { --thread: var(--thread-loomweave); } +.thread-filigree { --thread: var(--thread-filigree); } +.thread-wardline { --thread: var(--thread-wardline); } +.thread-legis { --thread: var(--thread-legis); } +.thread-charter { --thread: var(--thread-charter); } +.thread-shuttle { --thread: var(--thread-shuttle); } + +/* ---- Motion -------------------------------------------------------------- + The system's only ambient motion is brief and never gates visibility. This + popover-entrance keyframe is deliberately safe: its first frame is already + legible (opacity 0.7, a 4px lift), so if a throttled/headless context pauses + the animation the menu still reads. Honour reduced-motion by skipping it. */ +@keyframes ddMenuIn { + from { opacity: 0.7; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + @keyframes ddMenuIn { from { opacity: 1; } to { opacity: 1; } } +} diff --git a/www/fonts/JetBrainsMono-Italic-Variable.ttf b/www/fonts/JetBrainsMono-Italic-Variable.ttf new file mode 100644 index 00000000..5210f735 Binary files /dev/null and b/www/fonts/JetBrainsMono-Italic-Variable.ttf differ diff --git a/www/fonts/JetBrainsMono-OFL.txt b/www/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/www/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/www/fonts/JetBrainsMono-Variable.ttf b/www/fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 00000000..aa310be8 Binary files /dev/null and b/www/fonts/JetBrainsMono-Variable.ttf differ diff --git a/www/fonts/SpaceGrotesk-OFL.txt b/www/fonts/SpaceGrotesk-OFL.txt new file mode 100644 index 00000000..cb512b9a --- /dev/null +++ b/www/fonts/SpaceGrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/www/fonts/SpaceGrotesk-Variable.ttf b/www/fonts/SpaceGrotesk-Variable.ttf new file mode 100644 index 00000000..a1b2e6c2 Binary files /dev/null and b/www/fonts/SpaceGrotesk-Variable.ttf differ diff --git a/www/index.html b/www/index.html new file mode 100644 index 00000000..efddb08d --- /dev/null +++ b/www/index.html @@ -0,0 +1,633 @@ + + + + + +Wardline — trust-boundary analysis for agents and CI + + + + + + + + + + + + +
+
+ + + + + + + + + + + Wardline + ~/wardline +
+ +
+ +
+ + +
+
+ + Trust-boundary analysis · Python scanner · Rust preview +
+ +

Give agents and CI a deterministic trust-boundary gate.

+ +

+ Wardline reads source statically — it never runs your application — and asks one question + of each declared boundary: is the data flowing through this code as trusted as the + code claims? It catches untrusted data reaching trusted producers, turns the result + into structured findings, and gives coding agents an explainable loop from scan to fix. +

+ +
+
+ $ + pip install "wardline[scanner]" + +
+ Get started +
+ + +
+
+ + + + demo.py +
+
1from weft_markers import trusted, external_boundary
+2
+3@external_boundary
+4def read_request(req):
+5    return req.body            # raw, untrusted (EXTERNAL_RAW)
+6
+7@trusted(level="ASSURED")
+8def build_record(req):
+9    return read_request(req)   # claims ASSURED, returns raw — no validation
+ + +
+ EXTERNAL_RAW + + @trusted ASSURED + + ✗ no validation +
+ + +
+ +
+ demo.build_record declares return trust ASSURED + but actually returns EXTERNAL_RAW (less trusted) — + untrusted data reaches a trusted producer. +   demo.py:8 · PY-WL-101 +
+
+
+ + +
+
+ Runtime deps + 0 +
+
+ Trust decorators + 3 +
+
+ Lattice states + 8 +
+
+ Output formats + 4 +
+
+ + +
+
Gate CI with a single command
+ $ wardline scan . --fail-on ERROR +
+ exit 0 — clean + exit 1 — gate tripped + exit 2 — wardline error +
+
+ +
+ + +
+ +

Scan, explain, fix at the boundary, rescan.

+

+ Wardline is built for a human reviewing a gate and for an agent doing the mechanical work. + The scanner emits structured evidence; the agent asks for the taint path; the fix belongs at + the boundary where trust changes, not at a random sink downstream. +

+ +
+
+ 1 +

Declare the edge

+

Use marker decorators in Python, or doc-comment markers in Rust preview code, to name the places where data enters or claims a trust level.

+
+
+ 2 +

Run the gate

+

wardline scan . --fail-on ERROR writes findings and exits cleanly, tripped, or errored with distinct exit codes.

+
+
+ 3 +

Ask why

+

explain-taint, MCP explain_taint, and dossiers show the tainted callee, source boundary, sink, and remediation hint.

+
+
+ 4 +

Keep the evidence

+

Emit JSONL, SARIF, agent-summary JSON, signed governance artifacts, or Filigree lifecycle updates depending on the workflow.

+
+
+
+ + +
+ +

Three decorators. Eight states.

+

+ Declare trust at the source, not in a config file. Wardline reads those declarations and + propagates them across the whole call graph. Everything else is inferred. +

+ + +
+
+ @external_boundary +

+ Marks a function as the entry point for untrusted external data — network requests, file + reads, environment variables. Its return value is tainted EXTERNAL_RAW. +

+
+
+ @trust_boundary +

+ Marks a function that validates or transforms data before passing it inward. The function + is expected to reject or sanitize; Wardline checks that it can. +

+
+
+ @trusted(level="…") +

+ Declares the return trust level a function claims to produce. Wardline verifies the actual + propagated trust level meets that claim — violations become findings. +

+
+
+ + +
+
+ Most trusted + 8-state trust lattice + Least trusted +
+
+ +
+ + INTEGRAL +
+
+ + ASSURED +
+
+ + GUARDED +
+
+ + UNKNOWN_ASSURED +
+
+ + UNKNOWN_GUARDED +
+
+ + EXTERNAL_RAW +
+
+ + UNKNOWN_RAW +
+
+ + MIXED_RAW +
+
+
+ + +
+

+ Opt-in by design. Undecorated code sits in the developer-freedom zone — + unknown-trust, no findings. You declare trust only on the functions that matter, + which is what lets Wardline scan a large untouched codebase (including its own) with zero + noise. Start by annotating the edges where data crosses trust levels; the engine infers the rest. +

+
+ +
+ + +
+ +

A focused trust-boundary scanner, not a broad SAST suite.

+

+ Wardline concentrates on places where trust is declared, transformed, or violated. Python is + the full frontend; Rust support is a preview for command-injection findings. +

+ +
+
+

Boundary integrity

+

Flags trusted producers returning raw data, non-rejecting trust boundaries, assert-only validation, and fail-open handlers around rejection logic.

+
+
+

Python sink families

+

Covers command execution, dynamic code, dynamic imports, deserialization, path traversal, SSRF, SQL injection, XML parsing, templates, native libraries, logging formats, and SMTP sends.

+
+
+

Rust preview

+

wardline scan --lang rust detects untrusted data choosing a program or reaching a shell command line through std::process::Command.

+
+
+

Suppression with posture

+

Baselines, waivers, and judged findings stay visible in the gate vocabulary, so a quiet build can still explain what was evaluated and what was trusted.

+
+
+ +
+
+ Frontend + Primary use + Install +
+
+ Python + Trust lattice, project call graph, boundary and sink rules + pip install "wardline[scanner]" +
+
+ Rust preview + Command-injection slice with crate-aware finding identity + pip install "wardline[rust]" +
+
+
+ + +
+ +

CLI + MCP + agent integration

+

+ Wardline is agent-first: the MCP server exposes the same scan, explanation, posture, and + lifecycle surfaces as the CLI over JSON-RPC with no SDK. Agents can run the full loop without + scraping terminal output. +

+ +
+ +
+
CLI
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
wardline scanscan for trust-boundary violations (SARIF / JSONL / human output)
wardline assuregate: assert the trust posture still holds (pass/fail)
wardline attestproduce a signed assurance bundle
wardline dossierper-entity trust dossier
wardline explain-taintexplain one finding's taint path and remediation hint
wardline rekeymigrate baseline, waiver, and judge stores across fingerprint schemes
wardline judgeopt-in LLM triage — labels findings TRUE/FALSE positive (never runs automatically)
wardline installwire Wardline guidance and MCP registration into your coding agent
+

The CLI reference in the docs is authoritative. --fail-on level, output format flags, and suppression options are covered there.

+
+ + +
+
MCP (JSON-RPC · no SDK)
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
scanscan with a conjunctive where filter + inline explain
scan_file_findingsdry-run findings for one file
decorator_coveragetrust-decorator coverage posture report
dossierthe per-entity trust dossier over MCP
findingsread-only filtered finding query
doctorcheck install wiring, sibling discovery, auth, and server freshness
rekeyprobe or apply fingerprint-keyed store migration
assuregate the trust posture over MCP
attestproduce a signed assurance bundle over MCP
+

Launch with wardline mcp. Run wardline install to wire it automatically. See agent integration docs.

+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandWhat it provides
pip install weft-markersTiny runtime marker package — import @trusted, @external_boundary, @trust_boundary in application code without depending on the full scanner
pip install wardlineZero-dependency base — the library and decorators, no CLI
pip install "wardline[scanner]"The full scanner — wardline CLI, wardline mcp server (adds pyyaml, jsonschema, click)
pip install "wardline[loomweave]"Persist per-entity taint facts to a Loomweave store (adds blake3)
pip install "wardline[rust]"Rust command-injection preview frontend (includes the scanner extra)
+
+ +
+ + +
+ +

Wardline in the Weft Federation

+

+ Wardline is the Weft Federation's trust-policy surface — it answers, statically + and deterministically, whether the data each trust-annotated function works with is as trusted + as it claims. The siblings enrich this picture; they are never required. +

+ +
+

+ Enrich-only, never load-bearing. Wardline scans and analyzes with all siblings absent. + The Filigree emitter is fail-soft (core/filigree_emit.py). Remove any sibling — the scanner keeps working. +

+
+ +
+
+
The connective tissue
+
SEI — Stable Entity Identity
+
+ Wardline keys taint facts and cross-tool bindings on SEI, the durable per-entity identity + owned by Loomweave. A link survives a rename because it keys on the identity, not the name. + Degrades gracefully when the sei capability is absent from the Loomweave instance. +
+
+
+
The authority
+
Wardline owns trust policy
+
+ The trust lattice and its states, the decorator vocabulary, the rule IDs (PY-WL-1xx), the scanner + engine, and the corpus of ground-truth specimens are all Wardline's authority. + The federation hub does not restate rule counts or decorator names — the repo is the source of truth. +
+
+
+ + +
    +
  • + Wardline + + Loomweave + taint facts enrich the entity graph, keyed on SEI — survives any rename +
  • +
  • + Wardline + + Filigree + findings become tracked work via the native Filigree emitter +
  • +
  • + Wardline + + Legis + findings are governed by Legis (git/CI governance & attestations) +
  • +
+ +

+ Full federation context: + federation-map.md · + members/wardline.md +

+ + + + + +
+ +
+ + + + + + + diff --git a/www/main.js b/www/main.js new file mode 100644 index 00000000..cfeeff27 --- /dev/null +++ b/www/main.js @@ -0,0 +1,87 @@ +/* ============================================================================ + WARDLINE — front-door site interactions (progressive enhancement only) + The page is content-complete without JS. This script only layers in: + · copy-to-clipboard on the install command strip + · hover-reveal anchor links on section headings (keyboard accessible) + ============================================================================ */ +(function () { + "use strict"; + + /* ---- Copy to clipboard for the install command strip ---------------- */ + var copyBtn = document.getElementById("install-copy-btn"); + var installText = document.getElementById("install-text"); + + if (copyBtn && installText && navigator.clipboard) { + copyBtn.addEventListener("click", function () { + var text = installText.textContent.trim(); + navigator.clipboard.writeText(text).then(function () { + var original = copyBtn.textContent; + copyBtn.textContent = "copied!"; + copyBtn.setAttribute("aria-label", "Copied!"); + setTimeout(function () { + copyBtn.textContent = original; + copyBtn.setAttribute("aria-label", "Copy install command"); + }, 1600); + }).catch(function () { + /* clipboard unavailable — button does nothing, page still works */ + }); + }); + } + + /* ---- Hover-reveal anchor links on section headings ------------------ * + * For each h2/h3 with an id in the page sections, inject a lightweight * + * "§" anchor link that appears on hover and is keyboard-reachable. */ + var headings = Array.prototype.slice.call( + document.querySelectorAll("main h2[id], main h3[id]") + ); + headings.forEach(function (h) { + var id = h.id; + if (!id) return; + var a = document.createElement("a"); + a.href = "#" + id; + a.textContent = " §"; + a.setAttribute("aria-label", "Link to section: " + (h.textContent.replace(/\s§$/, "").trim())); + a.style.cssText = [ + "font-size: 0.72em", + "font-weight: 400", + "color: var(--text-muted)", + "text-decoration: none", + "opacity: 0", + "transition: opacity 0.15s", + "vertical-align: middle", + "margin-left: 0.35em" + ].join(";"); + h.appendChild(a); + h.style.position = "relative"; + + /* show on parent heading hover */ + h.addEventListener("mouseenter", function () { a.style.opacity = "1"; }); + h.addEventListener("mouseleave", function () { + if (document.activeElement !== a) a.style.opacity = "0"; + }); + /* always visible when focused */ + a.addEventListener("focus", function () { a.style.opacity = "1"; }); + a.addEventListener("blur", function () { a.style.opacity = "0"; }); + }); + + /* ---- Sibling member row hover effect -------------------------------- */ + var memberRows = Array.prototype.slice.call( + document.querySelectorAll(".bindings-member") + ); + memberRows.forEach(function (row) { + row.addEventListener("mouseenter", function () { + row.style.background = "var(--surface-overlay)"; + row.style.textDecoration = "none"; + }); + row.addEventListener("mouseleave", function () { + row.style.background = "var(--surface-raised)"; + }); + row.addEventListener("focus", function () { + row.style.outline = "2px solid var(--accent)"; + row.style.outlineOffset = "2px"; + }); + row.addEventListener("blur", function () { + row.style.outline = ""; + }); + }); +})(); diff --git a/www/styles.css b/www/styles.css new file mode 100644 index 00000000..c18cf01d --- /dev/null +++ b/www/styles.css @@ -0,0 +1,553 @@ +/* ============================================================================ + WARDLINE — front-door site layout + ---------------------------------------------------------------------------- + Layered on colors_and_type.css (the token + type source of truth, copied + verbatim from the Weft design system). Coral (--thread-wardline) is + Wardline's identity color — used for left-rules, glyph, eyebrow accents. + Amber (--accent) is reserved for interactive affordances (links, focus + rings, hover fills), exactly as the Weft hub does. + ============================================================================ */ + +*, *::before, *::after { box-sizing: border-box; } + +html, body { margin: 0; } +body { + background: var(--surface-base); + color: var(--text-primary); + font-family: var(--font-mono); + -webkit-font-smoothing: antialiased; + font-feature-settings: "liga" 1, "calt" 1; +} + +::-webkit-scrollbar { width: 10px; } +::-webkit-scrollbar-track { background: var(--surface-base); } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 5px; } + +/* ---- skip link ------------------------------------------------------------ */ +.skip-link { + position: absolute; left: -9999px; top: var(--space-2); + padding: 6px 14px; background: var(--accent); color: var(--text-on-accent); + border-radius: var(--radius); font-size: 12px; font-weight: 600; z-index: 100; +} +.skip-link:focus { left: var(--space-4); } + +.mark { display: block; flex: 0 0 auto; } + +/* ---- links --------------------------------------------------------------- */ +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } +/* external-link affordance */ +.ext::after { content: " \2197"; font-size: 0.85em; color: var(--text-muted); } + +/* ---- shared section width ------------------------------------------------ */ +.hero, .workflow, .trust-model, .coverage, .commands, .federation, .hub-footer { + max-width: 980px; margin: 0 auto; +} + +/* ============================ HEADER ============================ */ +.hub-header { + display: flex; align-items: center; gap: 22px; + padding: 12px 30px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-raised); + position: sticky; top: 0; z-index: 10; +} +.brand { display: flex; align-items: center; gap: 11px; min-width: 0; } +.brand .mark { color: var(--thread-wardline); } /* coral glyph */ +.wordmark { + font-family: var(--font-display); font-size: 19px; font-weight: 700; + letter-spacing: -0.02em; color: var(--text-primary); white-space: nowrap; +} +.path-hint { font-size: 11px; color: var(--text-muted); margin-left: 2px; } + +.hub-nav { display: flex; gap: 4px; margin-left: auto; flex-wrap: wrap; } +.hub-nav a { + font-size: 12px; color: var(--text-secondary); text-decoration: none; + padding: 6px 11px; border-radius: var(--radius); white-space: nowrap; + transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} +.hub-nav a:hover, .hub-nav a:focus-visible { + background: var(--surface-overlay); color: var(--text-primary); text-decoration: none; +} +.hub-nav a.ext::after { content: " \2197"; font-size: 0.8em; opacity: 0.6; } + +/* ============================ HERO ============================ */ +.hero { padding: 64px 30px 40px; } +.eyebrow { display: flex; align-items: center; gap: 9px; margin-bottom: 22px; } +.eyebrow-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--thread-wardline); /* coral identity color */ +} + +.hero-title { + font-family: var(--font-display); font-weight: 700; + font-size: clamp(36px, 6vw, 52px); letter-spacing: -0.03em; line-height: 1.02; + margin: 0; color: var(--text-primary); +} +.hero-lede { font-size: 16px; max-width: 680px; margin-top: 22px; line-height: 1.6; color: var(--text-secondary); } +.hero-lede strong { color: var(--text-primary); font-weight: 600; } + +/* install strip */ +.install-strip { + display: flex; align-items: center; gap: 12px; + margin-top: 26px; flex-wrap: wrap; +} +.install-cmd { + font-family: var(--font-mono); font-size: 14px; font-weight: 500; + padding: 10px 16px; + background: var(--surface-overlay); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + color: var(--text-primary); + display: flex; align-items: center; gap: 10px; +} +.install-cmd .prompt { color: var(--text-muted); user-select: none; } +.install-copy { + font-family: var(--font-mono); font-size: 11px; font-weight: 600; + padding: 4px 10px; border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); background: transparent; + color: var(--text-secondary); cursor: pointer; + transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} +.install-copy:hover { background: var(--surface-hover); color: var(--text-primary); } +.install-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.install-link { + font-size: 13px; color: var(--accent); font-weight: 600; display: inline-flex; align-items: center; gap: 4px; +} + +/* ---- Hero finding panel (the PY-WL-101 motif) --- */ +.finding-panel { + margin-top: 36px; + background: #1E1A13; /* editor-dark surface, pinned dark in both themes */ + border: 1px solid #332A1F; + border-left: 3px solid var(--thread-wardline); /* coral left-rule */ + border-radius: var(--radius-lg); + overflow: hidden; + font-size: 13px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.40); +} +.finding-panel__bar { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; + background: #14110D; + border-bottom: 1px solid #332A1F; + color: #B6A78E; font-size: 11px; +} +.finding-panel__dot { + width: 9px; height: 9px; border-radius: 50%; + background: currentColor; opacity: 0.4; +} +.finding-panel__filename { margin-left: auto; letter-spacing: 0.02em; } + +/* Code block inside the finding panel */ +.fp-code { + margin: 0; padding: 16px; + font-family: var(--font-mono); font-size: 13px; line-height: 1.65; + color: #F2E9D8; overflow-x: auto; tab-size: 4; + background: #1E1A13; +} +.fp-ln { + display: inline-block; width: 1.5rem; text-align: right; + color: #7F6F58; user-select: none; margin-right: 14px; +} +/* Syntax tokens re-colored onto the warm-Loom palette */ +.fp-kw { color: #56B7E2; } /* sky — keyword */ +.fp-dec { color: #E9B04A; } /* amber — decorator (Wardline's accent) */ +.fp-fn { color: #52C9B8; } /* aqua — function name */ +.fp-str { color: #5FB98E; } /* warm-emerald — string */ +.fp-cmt { color: #7F6F58; font-style: italic; } /* comment — muted */ +.fp-raw { color: #E2604E; font-weight: 600; } /* the trust leak — stale red */ +.fp-arg { color: #B79BF2; } /* violet — argument */ + +/* trust-flow strip */ +.fp-flow { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; + border-top: 1px solid #332A1F; + font-size: 11px; flex-wrap: wrap; +} +.fp-flow__node { + padding: 3px 10px; border-radius: 999px; + border: 1px solid currentColor; white-space: nowrap; font-weight: 600; +} +.fp-flow__node--raw { color: #E2604E; } /* untrusted — stale red */ +.fp-flow__node--claim { color: #5FB98E; } /* claimed trusted — warm emerald */ +.fp-flow__node--bad { color: #E2604E; } /* bad outcome */ +.fp-flow__arrow { color: #7F6F58; flex: none; font-size: 14px; } + +/* verdict strip */ +.fp-verdict { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px 16px; + border-top: 1px solid #332A1F; + background: rgba(226, 96, 78, 0.10); /* stale-red wash on the dark panel */ +} +.fp-verdict__mark { flex: none; font-weight: 700; color: #E2604E; font-size: 14px; line-height: 1.5; } +.fp-verdict__body { + font-family: var(--font-mono); font-size: 12px; line-height: 1.55; + color: #F2E9D8; +} +.fp-verdict__rule { font-weight: 700; color: #E2604E; } +.fp-verdict__body .vt-bad { color: #E2604E; font-weight: 600; } +.fp-verdict__body .vt-good { color: #5FB98E; font-weight: 600; } + +/* ---- Hero metric strip ---- */ +.hero-stats { + display: flex; gap: 44px; flex-wrap: wrap; + margin-top: 32px; padding-top: 26px; + border-top: 1px solid var(--border-default); +} +.stat { display: flex; flex-direction: column; gap: 5px; } +.stat-label { + display: flex; align-items: center; gap: 7px; + font-size: 11px; font-weight: 600; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--text-muted); +} +.stat-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; } +.stat-dot.t-wardline { background: var(--thread-wardline); } +.stat-dot.t-accent { background: var(--accent); } +.stat-dot.t-ready { background: var(--ready); } +.stat-dot.t-muted { background: var(--text-muted); } +.stat-value { + font-family: var(--font-display); font-weight: 700; + font-size: 34px; letter-spacing: -0.02em; line-height: 1; + color: var(--text-primary); +} + +/* ---- CI gate example ---- */ +.ci-gate { + margin-top: 30px; padding: 16px 20px; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-left: 3px solid var(--thread-wardline); + border-radius: 0 var(--radius) var(--radius) 0; +} +.ci-gate__label { margin-bottom: 8px; } +.ci-gate__cmd { + font-family: var(--font-mono); font-size: 13px; color: var(--text-primary); + padding: 8px 12px; background: var(--surface-overlay); + border-radius: var(--radius-sm); display: inline-block; +} +.ci-gate__exit { + display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; +} +.ci-exit-item { font-size: 11.5px; color: var(--text-secondary); } +.ci-exit-item .exit-code { font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); } +.ci-exit-item .exit-ok { color: var(--ready); } +.ci-exit-item .exit-trip { color: var(--stale); } +.ci-exit-item .exit-err { color: var(--aging); } + +/* ============================ TRUST MODEL ============================ */ +.trust-model { padding: 32px 30px 40px; } +.section-label { margin-bottom: 14px; } + +.comp-title { + font-family: var(--font-display); font-size: 26px; font-weight: 600; + letter-spacing: -0.015em; color: var(--text-primary); margin: 0 0 4px; +} +.comp-lede { max-width: 660px; margin-bottom: 20px; font-size: 15px; color: var(--text-secondary); line-height: 1.6; } + +/* Decorators */ +.decorators-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; + margin-bottom: 28px; +} +.decorator-card { + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-left: 3px solid var(--thread-wardline); + border-radius: var(--radius-lg); padding: 16px 18px; +} +.decorator-name { + display: block; font-family: var(--font-mono); font-size: 13px; font-weight: 600; + color: var(--thread-wardline); margin-bottom: 8px; +} +.decorator-body { font-size: 12.5px; color: var(--text-secondary); line-height: 1.55; } + +/* Lattice ramp */ +.lattice-block { + background: var(--surface-raised); border: 1px solid var(--border-default); + border-radius: var(--radius-lg); padding: 20px 22px; +} +.lattice-caption { + display: flex; justify-content: space-between; align-items: center; + font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; +} +.lattice-track { display: flex; flex-direction: column; gap: 2px; } +.tier-row { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; border-radius: var(--radius-sm); + background: var(--surface-overlay); border: 1px solid var(--border-default); + font-size: 12px; +} +.tier-swatch { + flex: none; width: 12px; height: 12px; + border-radius: 3px; border: 1px solid rgba(0,0,0,0.25); +} +.tier-name { font-weight: 600; color: var(--text-primary); } + +/* Opt-in zone callout */ +.optin-block { + margin-top: 22px; padding: 16px 20px; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius) var(--radius) 0; +} +.optin-block__body { font-size: 14px; color: var(--text-secondary); line-height: 1.6; } +.optin-block__body strong { color: var(--text-primary); } + +/* ============================ WORKFLOW ============================ */ +.workflow { padding: 24px 30px 40px; } + +.workflow-steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} +.workflow-step { + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-left: 3px solid var(--thread-wardline); + border-radius: var(--radius-lg); + padding: 16px 18px; +} +.workflow-step__num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + background: color-mix(in oklab, var(--thread-wardline) 18%, transparent); + color: var(--thread-wardline); + font-size: 11px; + font-weight: 700; + margin-bottom: 12px; +} +.workflow-step h3 { + margin: 0 0 7px; + font-size: 14px; + color: var(--text-primary); +} +.workflow-step p { + margin: 0; + font-size: 12.5px; + color: var(--text-secondary); + line-height: 1.55; +} +.workflow-step code { + font-size: 11px; + background: var(--surface-overlay); + padding: 1px 4px; + border-radius: 2px; +} + +/* ============================ COVERAGE ============================ */ +.coverage { padding: 24px 30px 40px; } + +.capability-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} +.capability-card { + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 16px 18px; +} +.capability-card h3 { + margin: 0 0 8px; + font-size: 14px; + color: var(--text-primary); +} +.capability-card p { + margin: 0; + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.55; +} +.capability-card code { + font-size: 11px; + background: var(--surface-overlay); + padding: 1px 4px; + border-radius: 2px; +} + +.support-matrix { + margin-top: 18px; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; +} +.support-row { + display: grid; + grid-template-columns: 0.8fr 1.7fr 1.2fr; + gap: 14px; + padding: 10px 14px; + color: var(--text-secondary); + font-size: 12px; +} +.support-row + .support-row { border-top: 1px solid var(--border-default); } +.support-row--head { + background: var(--surface-overlay); + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.support-row span:first-child { + color: var(--text-primary); + font-weight: 600; +} +.support-row--head span:first-child { + color: var(--text-muted); +} +.support-row code { + font-size: 11px; + background: var(--surface-overlay); + padding: 1px 4px; + border-radius: 2px; +} + +/* ============================ COMMANDS ============================ */ +.commands { padding: 16px 30px 40px; } + +.cmd-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; +} +.cmd-card { + background: var(--surface-raised); border: 1px solid var(--border-default); + border-left: 3px solid var(--thread-wardline); border-radius: var(--radius-lg); + padding: 16px 18px; +} +.cmd-card-label { margin-bottom: 12px; } +.cmd-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.cmd-table tr + tr td { border-top: 1px solid var(--border-default); } +.cmd-table td { padding: 7px 6px; vertical-align: top; } +.cmd-table td:first-child { + font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); + white-space: nowrap; padding-right: 14px; +} +.cmd-table td:last-child { color: var(--text-secondary); } +.cmd-note { font-size: 11px; color: var(--text-muted); margin-top: 12px; line-height: 1.5; } + +.install-table-wrap { margin-top: 22px; } +.install-table { + width: 100%; border-collapse: collapse; font-size: 12px; + background: var(--surface-raised); border: 1px solid var(--border-default); + border-radius: var(--radius-lg); overflow: hidden; +} +.install-table th { + background: var(--surface-overlay); padding: 8px 14px; + text-align: left; font-size: 10px; font-weight: 700; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); + border-bottom: 1px solid var(--border-default); +} +.install-table td { padding: 8px 14px; color: var(--text-secondary); vertical-align: top; } +.install-table tr + tr td { border-top: 1px solid var(--border-default); } +.install-table td:first-child { font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); white-space: nowrap; } + +/* ============================ FEDERATION ============================ */ +.federation { padding: 28px 30px 44px; } + +.fed-intro-block { + padding: 16px 20px; margin: 16px 0 28px; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-left: 3px solid var(--thread-wardline); + border-radius: 0 var(--radius) var(--radius) 0; +} +.fed-intro__body { font-size: 15px; color: var(--text-primary); line-height: 1.55; } +.fed-intro__body em { font-style: normal; color: var(--thread-wardline); font-weight: 600; } + +.facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 0 0 28px; } +.fact { + background: var(--surface-raised); border: 1px solid var(--border-default); + border-left: 3px solid var(--accent); border-radius: var(--radius-lg); + padding: 18px 20px; +} +.fact-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 7px 0 8px; } +.fact-body { font-size: 13px; color: var(--text-secondary); line-height: 1.55; } + +.bindings { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.bindings li { + font-size: 13px; color: var(--text-secondary); line-height: 1.5; + padding: 11px 14px; background: var(--surface-raised); + border: 1px solid var(--border-default); border-radius: var(--radius); + display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; +} +.b-name { font-weight: 600; } +.b-arrow { color: var(--text-muted); } +.b-desc { color: var(--text-secondary); } + +/* ---- small inline status tags ---- */ +.tag { + display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: 0.04em; + padding: 2px 7px; border-radius: var(--radius-sm); white-space: nowrap; margin-left: 2px; +} +.tag-ok { color: var(--ready); background: color-mix(in oklab, var(--ready) 16%, transparent); } +.tag-warn { color: var(--aging); background: color-mix(in oklab, var(--aging) 16%, transparent); } +.tag-dim { color: var(--text-muted); background: var(--surface-overlay); } +.tag-live { color: var(--thread-wardline); background: color-mix(in oklab, var(--thread-wardline) 14%, transparent); } + +.weave-foot { margin-top: 16px; } + +/* ============================ FOOTER ============================ */ +.hub-footer { + border-top: 1px solid var(--border-default); + padding: 20px 30px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; + max-width: 980px; margin: 0 auto; +} +.hub-footer .mark { color: var(--text-muted); } +.foot-note { font-size: 11px; color: var(--text-muted); } +.foot-links { display: flex; gap: 14px; flex-wrap: wrap; margin-left: auto; } +.foot-links a { font-size: 11px; color: var(--text-secondary); } +.foot-links a:hover { color: var(--text-primary); } +.foot-meta { font-size: 11px; color: var(--text-muted); } +.foot-org { + display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-muted); + text-decoration: none; transition: color 0.15s var(--ease); +} +.foot-org .mark { color: inherit; } +.foot-org:hover { color: var(--text-secondary); } +.foot-divider { + width: 100%; height: 0; border: none; + border-top: 1px solid var(--border-default); + margin: 0; +} + +/* ============================ RESPONSIVE ============================ */ +@media (max-width: 720px) { + .facts { grid-template-columns: 1fr; } + .decorators-grid { grid-template-columns: 1fr; } + .cmd-grid { grid-template-columns: 1fr; } + .workflow-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .support-row { grid-template-columns: 1fr; gap: 4px; } +} +@media (max-width: 640px) { + .hub-header { gap: 12px; padding: 12px 18px; } + .path-hint { display: none; } + .hero { padding: 44px 18px 32px; } + .hero-stats { gap: 24px 32px; } + .stat-value { font-size: 28px; } + .workflow, .trust-model, .coverage, .commands, .federation { padding-left: 18px; padding-right: 18px; } + .hub-footer { padding: 18px; } + .foot-links { margin-left: 0; flex-basis: 100%; } +} +@media (max-width: 460px) { + .workflow-steps, + .capability-grid { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; animation: none !important; } +}