Findings

A finding is recorded once, as structured data. That record is the source of truth: rupu renders the report page, the Markdown, HTML and PDF exports, and every list and query from it, so an agent never writes a separate report file.

Findings are written by one tool, findings.report, which an agent calls directly, a workflow action: step calls as findings.record, and an outside MCP client calls on rupu cp serve’s /mcp. This page covers where a finding lands, the findings tools, what a report contains, how to query, tag, export and import findings, and how the control plane and the MCP surface serve them. The coverage harness page covers the ledgers around them.

findings.report agent / action step / MCP validated report findings.jsonl + artifact store query & tags CLI / agent / MCP / CP report page control plane exports md / html / pdf
One structured record per finding; every view and export is generated from it.

Where a finding lands

Findings live in the workspace they were recorded in, as lines of findings.jsonl under <workspace>/.rupu/coverage/<target_id>/. The target id is derived deterministically from the workspace and the run’s scope name, so:

Every record carries its run id, model and surface. The run: query key and rupu findings export --run select the findings a run or agentiflow declared and, on the CLI and in the control plane, everything that ran under it (steps, units, launched children). A report’s cross_references may only name findings in the reporting run’s own scope; findings.query lists findings from every scope in the workspace, so to relate one from another scope, name its id in references or description.

The findings tools

ToolAlso acceptedWhat it does
findings.reportreport_finding, findings.recordRecords an issue: a complete report under the full profile, or summary / severity / evidence under summary. Takes an optional tags array. Injected automatically when the agent has a concerns: block; otherwise an explicit tools: grant.
findings.verifyfinding.verifyRecords a verification verdict on a finding that has a full report. Never granted automatically, not even to a concerns: agent: write a verifier agent that lists it. A run can never verify its own finding.
findings.queryquery_findingsLists the agent’s own workspace’s findings matching a query ({q, limit, cursor}, or all: true). Returns {rows, next_cursor, total, tags_in_use}. Explicit tools: grant.
findings.tagtag_findingsAdds or removes tags on one or many findings and returns each finding’s tags before and after. Explicit tools: grant; allowed in readonly mode.
---
name: finding-verifier
description: Re-checks a finding another run filed and records a verdict.
tools: [read_file, grep, glob, findings.verify]
---

The same tool bodies are reachable from three places:

Finding profiles

Every finding is written under one of two profiles:

ProfileWhat findings.report / findings.record accepts
full (default)A complete report object. summary, severity and evidence are rejected: rupu derives them (summary ← title, severity ← rating.risk_rating, evidence.rationale ← root_cause).
summaryThe lightweight summary / severity / evidence record. A report is refused, never silently dropped.

The profile is chosen, highest precedence first, by:

  1. a workflow step’s findings_profile, or rupu run --findings-profile for a standalone run;
  2. the workflow’s defaults.findings_profile;
  3. the agent’s findingsProfile frontmatter;
  4. the built-in default, full.

Remote workflow units (host: / distribute:) resolve the same way; the value travels to the host as --findings-profile. The profile is stored on each finding; records written before profiles existed read back as summary.

---
name: quick-triage
findingsProfile: summary      # this agent records thin findings
concerns:
  - include: owasp-top10-2021
---
Upgrading. Because the default is full, an existing agent that records thin findings is rejected until it declares findingsProfile: summary or its prompt is updated to send a report; agents outside the repo (under ~/.rupu/agents/) need that one-line change. When a full-profile run can record findings (a concerns: block, or findings.report in tools:), finding-writing guidance is appended to its system prompt, so no external reporting-standard file is needed.

What a full report contains

A full report has these fields; every one is required except cwe (which may be an empty list) and artifacts. Typed blocks and classifications are optional additions.

GroupFields
Identitytitle, category, attack_vector, cwe (CWE-<n> ids)
Ownershipownership (owner, product, affected_component, source_repository), tickets
Ratingrating: impact, risk_rating, risk_factor (Low/Medium/High/Critical), likelihood (Low/Medium/High), cvss_v3
Narrativedescription, impact, location (input / output), root_cause, call_chain (source → hop → sink)
Proofevidence (at least one claim, each optionally pinned to a workspace-relative file + lines), replication_steps (at least one), artifacts, blocks
Fixremediation, recommended_patch (a unified diff), ci_cd_detection, regression_test
Linkscross_references (finding ids in the reporting run’s own scope), references

Rules are enforced at write time, and a rejected call returns every problem at once with its field path, so the agent can fix them all in one retry. Unknown keys are rejected at the top level of the call, in the report and in each evidence block. The serialized report must fit [findings].report_max_bytes (default 256 KiB). Some fields are set by rupu, never by the agent, and are left out of the schema the agent is shown: the verification status (unverified / confirmed / disputed / inconclusive, set by verification runs — a call that supplies it is rejected), each evidence claim’s sha256 (rupu hashes the claim’s file at write time; a claim whose file is not in the workspace is stored without one), and an artifact’s hash, size, kind, storage and host.

Sentinels

Where information genuinely cannot be determined, a field uses a sentinel instead of being omitted or guessed. On the structured fields only the exact sentinels listed for that field are accepted.

SentinelUsed for
Unknownowner, product, affected component, source repository, attack vector, CVSS score, tickets
Not Applicablesource repository, when no source-controlled code is involved
None Providedtickets, when none are mentioned
Nonecross references, when there is no related finding
Not Provided — <justification>call chain, recommended patch, CI/CD detection, regression test (the justification must be non-empty)

Unknown is not accepted for the patch, CI/CD detection or regression test: provide the item or say why it could not be produced.

Evidence blocks

Alongside the prose evidence claims, a report may carry typed blocks, each an object tagged by kind and accepting exactly its own fields:

text   code_slice   diff   table   image   hexdump
disasm   decompile   http_exchange   scan_output   pcap_ref

Three of them name a file: image, hexdump and pcap_ref. That file, in the block’s artifact.path, is verified and stored exactly like a proof-of-concept artifact (below) and counts against the same per-finding limits, but it doesn’t appear in the PoC list. A bad path is rejected like an artifact path, with the field named report.blocks[<i>].artifact.path. The control plane renders every block kind in an “Evidence blocks” section, with image blocks shown inline.

{ "kind": "http_exchange",
  "request":  "GET /api/notes?sort=title%27-- HTTP/1.1\nHost: notes.example.test",
  "response": "HTTP/1.1 500 Internal Server Error\n..." }

{ "kind": "image",
  "artifact": { "path": "poc/error-page.png" },
  "caption": "Stack trace leaked by the sort parameter" }

Artifacts and the content-addressed store

report.artifacts[].path lists proof-of-concept files (scripts, outputs, harnesses) as workspace-relative paths; the agent supplies only the path. At write time rupu hashes each one:

[findings]
artifact_max_bytes       = 524288000    # copy cap per artifact file (500 MiB)
artifact_max_files       = 500          # files per report
artifact_total_max_bytes = 2147483648   # bytes copied per report (2 GiB)
report_max_bytes         = 262144       # serialized report budget (256 KiB)
ticket_patterns          = ["ABC-[0-9]+"] # extra ticket formats for the guidance
export_id_prefix         = "VULN"       # export numbering prefix (global config only)

Findings from remote units

A remote workflow unit (host: / distribute:) runs findings.report on its host, so its artifacts are copied into that host’s store. Every rupu run also streams its coverage — runs, file touches, concern assertions, findings, engagement assets and tag events — to $RUPU_HOME/runs/<run_id>/coverage.jsonl, which the host’s connector delivers to the coordinator when the unit ends, success or failure, on every transport. The coordinator merges it under its own workspace’s targets and records the unit’s artifacts stored: external with the host’s id (a unit placed on the coordinator’s own local host shares its store, so its artifacts stay copied).

When no stream arrives, it can’t be collected or merged, or it may be incomplete (read while the unit may still have been running), the step shows a StepWarning — in the control plane’s run view, the Situation Room, the CLI live view and rupu workflow show-run — and the unit is not failed. With workspace: sync the unit’s returned delta still carries its .rupu/coverage/: the coordinator drops it when the complete stream merged every line, and applies it otherwise (a duplicate beats a loss).

Remote artifacts, pulled on view

The bytes of a remote unit’s artifact stay on the host until someone views them: the first preview or download through the control plane pulls the blob over whatever transport the host uses (SSH runs the hidden rupu __findings artifact; an HTTP host serves GET /api/findings/artifacts/:sha256; a tunnel node streams it; a bucket worker uploaded it to artifacts/<sha256> when its run finished), checks it against the recorded size and sha256, and keeps it in the coordinator’s store. A recorded size over the coordinator’s own artifact_max_bytes is refused without contacting the host. Concurrent first views share one pull. If the bytes can’t be had (host unreachable, node offline, blob missing, host too old, failed verification), the response is a 404 with {"unavailable": "<reason>"}, nothing is stored, and the next view tries again.

Artifacts are served with nosniff and a sandboxing Content-Security-Policy: text inline as plain text, PNG/JPEG/GIF/WebP images inline (recognised by their bytes, never by name), everything else as an attachment. Opening a finding never pulls from a host on its own; a remote image block loads only when you click it, and a binary artifact only when you download it.

rupu findings schema

rupu findings schema                 Print the embedded draft-07 JSON Schema of a finding report
rupu findings schema --advertised    Print the simplified copy used in tool definitions

The schema is embedded in the binary and held in lockstep with the validator by a test, so external prompts and tools can be generated from rupu rather than maintained by hand. Over MCP the same schema is the findings.schema operator tool’s answer and the rupu://schema/finding-report resource.

Querying findings

One single-line query language filters findings on every surface: rupu findings list and tags, the agent tool findings.query, the same tool over MCP, and the control plane’s Findings page. One evaluator serves all of them, so a query means the same thing everywhere.

severity>=high tag:class:sqli -tag:false-positive -has:poc "sql injection"

Grammar

An invalid query never runs. It is refused with the offending token’s index and a code: unknown_key, empty_value, bad_value, bad_operator, unclosed_quote or bad_quote.

Fields

KeyMatches
severity (sev)info, low, medium, high, critical; also >=, >, <=, <
tagan effective tag (normalized, so Class:SQLi is class:sqli)
hastags, report, poc (the report has artifacts), cwe
projectworkspace name or id (CLI and control plane only)
cwe79 or CWE-79, compared by number: the report’s CWEs, the one the concern names or, when it names none, the first MITRE reference URL — CWE-79 never matches CWE-798
owner, productthe report’s ownership, case-insensitive
verifiedunverified, confirmed, disputed, inconclusive (no verification counts as unverified)
profilefull, summary
scopeline, file, repo, host, endpoint, resource
concernconcern id, case-insensitive
agentagent name or codename, case-insensitive
workflowworkflow name, case-insensitive (CLI and control plane only)
filepath prefix
runrun or agentiflow id; the CLI and control plane also match everything that ran under it (steps, units, launched children, down the tree)
idfinding id

Examples

# quote anything your shell would treat as a redirect: > and < are redirects
rupu findings list 'severity>=high' tag:needs-poc -has:poc
rupu findings list 'owner:"payments team"' run:run_01J…
rupu findings list --limit 20 cwe:89,79 file:src/api/
rupu findings list --ids-only -has:tags
rupu --format json findings list verified:confirmed project:notes-service

list spans every registered project; put --limit N and --ids-only before the query words. An agent with findings.query in its tools: (or a workflow action: findings.query step) sends {q, limit, cursor}; limit defaults to 50 (at most 500), cursor is the previous page’s next_cursor, and all: true returns every match unpaged — handy for fanning out over the results. Each row is slim: id, title, severity, scope, location, concern_id, tags, declared_at, run_id.

steps:
  - id: tagged
    action: findings.query
    with: { q: "tag:needs-poc severity>=high", all: true }
  - id: poc
    agent: poc-writer
    for_each: "{{ (steps.tagged.output | fromjson).rows | tojson }}"
    max_parallel: 4
    prompt: |
      Write a proof of concept for finding {{ item.id }}: {{ item.title }}
      ({{ item.location }}). When it works, tag it `has-poc` with findings.tag.

project: and workflow: need provenance that only the CLI and control plane have, so the agent tool and MCP refuse a query that uses them; there run: is an exact match on the run id.

Tagging findings

A finding can carry free-form tags (needs-poc, class:sqli, status:triaged) used to sort, filter and queue work. Tags never change a finding’s report, severity or id.

Who can tag

WhoHow
Agentsfindings.report takes an optional tags array, so a finding is tagged as it is declared. findings.query and findings.tag are explicit tools: grants (tools: [read_file, findings.query, findings.tag]); findings.tag is allowed in readonly mode since it annotates the ledger and never touches the workspace’s files. findings.query returns tags_in_use, so agents reuse tags instead of inventing near-duplicates. Both work only on the agent’s own workspace, and a batch with an unknown id or a finding that would pass the cap is rejected whole.
Workflow steps and MCPAn action: step, and an MCP client on rupu cp serve’s /mcp, call the same findings.query and findings.tag tools (one implementation, same inputs and results), and findings.record accepts tags.
Operatorsrupu findings tag / tags, the control plane’s Findings page and finding pages, or the findings.tag operator tool over MCP (recorded client: "mcp" beside via: "cp").
rupu findings tag fnd_01J… --add needs-poc --add class:sqli
rupu findings tag fnd_01J… --remove needs-poc
rupu findings tags                       # tags in use, with counts
rupu findings tags severity:critical      # ...over the findings a query selects

# tag everything a query matches: a lone `-` reads ids from stdin
rupu findings list --ids-only tag:class:sqli | rupu findings tag - --add needs-poc
# a workflow action step
- id: mark
  action: findings.tag
  with: { finding_ids: ["fnd_01J…"], add: ["needs-poc"] }

rupu findings tag works across projects, atomically per workspace: an unknown id or a workspace that couldn’t be changed makes it exit non-zero after the others have kept their changes. A placed unit tags findings in its own workspace; those events travel to the coordinator in the run’s coverage stream, as tags lines next to the findings lines, and are merged into the coordinator’s tag log by event id (a replayed event is dropped as a duplicate), so tags added on the coordinator while the unit ran survive. Tagging a finding that exists only in a remote host’s own ledgers, never ingested into the coordinator, is not supported.

Exporting reports

A stored finding exports as Markdown, self-contained HTML, or PDF — one finding, or a whole project. All three carry the same sections in the same order, and every field of the report: CWE, verification, ticket notes, claim-to-artifact links, each call-chain hop’s role, artifact kind and host, and provenance (surface, concern, scope, location). A summary-profile finding exports as a short document, marked as a summary finding.

rupu findings export [--id <fnd_…>]… [--project <ws_id|path>] [--run <run_id>]
                     [--severity <critical|high|medium|low|info>]
                     [--owner <name>] [--cwe <CWE-n>] [--include-summaries]
                     [--to md|html|pdf] [--split] [--title <text>] -o <path>

The document format is --to (default md), not the global --format. Filters combine; --severity keeps that severity and worse; --run keeps findings declared by that run (or agentiflow) and everything that ran under it; --cwe compares by number, as the query’s cwe: does. A single --id alone writes a stand-alone document; anything else writes a project report over the selection (title, summary, index, then one section per finding), or with --split a zip of index.md plus one file per finding. Summary-profile findings are left out unless you pass --include-summaries (naming one with --id counts as asking). -o may be an existing directory to receive the generated file name. Finding ids are unique within a project, not across projects: when a single --id exists in several, the export refuses and names them, and --project picks one.

# one finding as a PDF, into ./reports
rupu findings export --id fnd_01J… --to pdf -o ./reports

# everything high or worse in one project, as a single HTML report
rupu findings export --project ~/src/notes-service --severity high --to html \
  --title "Notes service assessment" -o assessment.html

# one file per finding plus an index, for one run
rupu findings export --run run_01J… --to pdf --split -o run-findings.zip

Numbering

Exports number findings within their project — by severity (critical first), then declaration time, then id — as <PREFIX>-NNN (SEC-001, SEC-002, …). Numbers are assigned before any filter applies, so a finding keeps its number whatever a report leaves out, and the CLI and the control plane agree. A number is a point-in-time display label (recording a new critical finding renumbers the ones after it); the fnd_ id is the stable handle, and a project report’s index lists both. Files are named <NUMBER> - <Short Title>.<ext>, e.g. SEC-003 - SQL injection in the search endpoint.pdf.

The prefix is [findings].export_id_prefix (default SEC), read from the global config only — a repo’s .rupu/config.toml never changes it, since it ends up in file names and document text. It must match ^[A-Za-z][A-Za-z0-9_-]{0,15}$; anything else is ignored with a warning.

Formats

FormatNotes
mdPlain Markdown. Report text is kept as Markdown but neutralised so it can’t restructure the document (line-leading < escaped, headings demoted, unclosed fences closed). image blocks are referenced by path.
htmlOne self-contained file with inline CSS; loads nothing from the network and runs no script, with a strict embedded CSP. Raw HTML in report text is neutralised. image blocks are inlined as data: URIs.
pdfRendered in-process with Typst — no headless browser, no external tools, bundled fonts (so no CJK or emoji glyphs). Behind the default-on pdf cargo feature; a build without it still exports Markdown and HTML.

Only image blocks whose file is in this machine’s store, is a raster image by its leading bytes, is at most 4 MiB and still matches its hash are embedded; anything else is shown by path, sha256 and size with a note. An export never fetches from another host, even for a file the finding page would pull on view.

Importing older reports

Findings recorded before the full profile existed are summary findings whose report was written separately as Markdown. rupu findings import attaches those reports to their findings — a one-time, best-effort migration aid, not an input path: nothing else writes a report onto an existing finding. Over MCP the findings.import operator tool does the same.

rupu findings import <PATH>… [--id <fnd_…>] [--dry-run]
Note. The ledger lock only excludes writers that take it. Run an import when no agents are recording findings, and stop long-running processes started from an older rupu first (a session worker, cp serve). Once you’ve checked the result, delete the backups — each is a full copy of the ledger, and a workspace sync or a commit of .rupu/ would carry it along.

In the control plane

The control-plane web UI (rupu cp serve) renders stored reports directly; no export step is needed to read one.

The same data is available over the API: GET /api/findings?q=… (slim rows plus facets and tags_unavailable; an invalid q is a 400 naming the token), GET /api/findings/:id (the full report with per-claim evidence_status, tag_history and tags_editable), GET /api/findings/:id/artifacts/:sha256, GET /api/findings/:id/export?format=md|html|pdf, POST /api/findings/export, and GET/POST /api/findings/tags. Because an id is unique only within its project, the per-finding routes take ?ws_id=, and answer 409 naming the projects when an id is ambiguous without it; see the HTTP API.

Over MCP

rupu cp serve’s /mcp gives an MCP client (Claude Code, Cursor, Claude Desktop) two ways at findings; both are described on the MCP & tools page.

# serve the operator findings tools, wait and messages only
rupu cp serve --no-web --tools 'runs.*,wait,messages.*,findings.*'