Agent CLI contracts
Machine contract for thespider-agent findings, wait, and refine: the JSON envelope, exit codes, and markdown packet. Scripts and coding agents should pin this page, not scrape the human tables.
What is stable
Three contracts ship together and are versioned independently:
| Contract | Version marker | Where |
|---|---|---|
| JSON envelope | schemaVersion: 1 | every --json document |
| Exit codes | this page | findings *, wait, and refine |
| Markdown packet | thespider_packet: 1 | the first line of the packet front matter |
schemaVersion policy
Within a
schemaVersion, fields may only be added. No field is removed, renamed, or changes type. A value that is known to be absent isnull; keys are never omitted. Array fields are[], nevernull. Any other change bumpsschemaVersion.
The envelope is an allow-list the CLI builds, not a pass-through of the server body. Field names match findings export (snake_case).
thespider_packet policy
Within
thespider_packet: 1, headings may only be appended after the last one. Renaming, reordering, removing, or changing the nesting level of any heading requires bumpingthespider_packet. Prose inside a section may change freely; the heading set and its order may not.
stdout purity
With
--json, stdout carries exactly one JSON document terminated by a single newline, and nothing else. All human text, progress ticks, warnings, and errors go to stderr. A non-zero exit still emits one JSON document on stdout. Without--json, stdout carries only the command's primary output (the human table, or the markdown packet); diagnostics still go to stderr.
wait's per-poll progress line goes to stderr in both modes, as does findings copy's "copied"
notice. clipboard's OSC 52 escape sequence is also written to stderr, so a consumer parsing stderr
should expect it.
Commands
thespider-agent [--auth <PATH>] <COMMAND>
findings # alias: finding
list [flags]
show <ID> [flags]
triage <ID> --status <STATUS> [flags] # alias: set-status
packet <ID> [flags]
copy <ID> [flags]
wait --campaign <ID> [flags]
refine [flags] # see the refine section and /docs/guide/refine
findings list
| Flag | Type | Default | Notes |
|---|---|---|---|
--json | bool | false | Emit the envelope; the stdout purity rule applies |
--status <CSV> | string | none | The eight statuses plus open, closed, actionable |
--severity <CSV> | string | none | critical, high, medium, low, info, plus reserved none |
--open | bool | false | Sugar for --status open; conflicts with --status and --actionable |
--actionable | bool | false | Sugar for --status actionable; conflicts with --status and --open |
--campaign <ID> | integer | none | Findings sighted in this campaign; also selects the campaign's profile |
--branch <NAME> | string | none | |
--bucket <CODE> | string | none | |
--category <CODE> | string | none | |
--query <TEXT> / -q | string | none | |
--sort <MODE> | string | priority | priority, updated_desc, updated_asc, title |
--profile <P> | string | project default | security, bugs, or all |
--limit <N> | integer | 100 | Above 500 is a usage error, never a silent clamp |
--offset <N> | integer | 0 | |
--all | bool | false | Complete filtered set via /findings/export; conflicts with --limit/--offset |
findings show / packet / copy
<ID> positional, --json, --profile. packet and copy additionally take --redact.
findings triage
<ID> positional and --status <STATUS> (required), plus --reason, --note, --commit
(repeatable), --decider, --json, --profile. Exactly one finding per invocation — callers loop.
wait
--campaign <ID> (required), --timeout <SECS> (default 1800; 0 means no timeout),
--interval <SECS> (default 15; the real delay adds 0–20 % jitter), --fail-on <CSV> (default
critical,high), --profile, --json.
--fail-on none and --fail-on off are different. none is the reserved severity token
meaning "no severity was assigned", so --fail-on critical,high,none blocks on unclassified
findings too. off — legal only as the entire value — disables the findings verdict, after which
wait can only exit 0 or 4.open vs actionable
Resolved on the server as "everything except these statuses," so an unknown legacy status still lands on the right side.
| Token | Meaning | Used by |
|---|---|---|
open | Not fixed or false_positive | dashboard count, findings list --open |
actionable | Not wont_fix, false_positive, accepted, or fixed | wait exit 0/1, the PR gate's block set |
closed | fixed or false_positive | findings list --status closed |
wait can exit 0 while findings list --open still returns rows. Those rows are accepted risks. Use actionable to decide whether to do work; use open to report state.thespider-agent findings list --actionable --severity critical,high --json | jq '.pagination.total' # work queue
thespider-agent findings list --open --severity critical,high --json | jq '.pagination.total' # dashboard number
Alias membership is defined server-side in exactly one place and the CLI forwards the token verbatim, so the two can never drift.
Exit codes
| Code | Name | wait | findings list/show/packet | findings copy | findings triage | Meaning |
|---|---|---|---|---|---|---|
0 | OK | ✔ | ✔ | ✔ | ✔ | Success. For wait: campaign terminal, failed == 0, no actionable finding matches --fail-on. |
1 | FINDINGS | ✔ | — | — | — | Findings verdict only. Campaign terminal and at least one finding sighted in it is actionable and matches --fail-on. |
2 | (reserved — never produced) | — | — | — | — | Emitted only by clap's own argument parser and by the legacy gate / update --check. The one exit code that never writes a JSON document — see the error envelope. |
3 | TIMEOUT | ✔ | — | — | — | --timeout elapsed before the campaign became terminal. |
4 | DEGRADED | ✔ | — | — | — | Campaign terminal with failed > 0 and no actionable --fail-on match. Nothing may be called clean. |
5 | USAGE | ✔ | ✔ | ✔ | ✔ | Caller's input is wrong: client-side validation failure, or HTTP 400 / 404 / 409 / 413 / 422. |
6 | OPERATIONAL | ✔ | ✔ | ✔ | ✔ | Environment failure: HTTP 401 / 402 / 403 / 429 / 5xx, exhausted retries, unparseable response, missing .thespider/config.toml, unresolvable org or project. |
7 | INTERRUPTED | ✔ | — | — | — | SIGINT/SIGTERM received while waiting. |
8 | CLIPBOARD | — | — | ✔ | — | Packet rendered successfully but the clipboard write failed. The packet is on stdout (human mode) or in packet (JSON mode). |
wait evaluates its verdict in this order:
interrupted -> 7
timeout -> 3
matches > 0 -> 1 (even when failed > 0; `degraded: true` is still set)
failed > 0 -> 4
otherwise -> 0
A findings verdict deliberately outranks degradation: an agent scripted on "fix the findings first"
must not skip real criticals because one slice failed. Degradation always stays visible through
degraded, campaign.failed, and a stderr warning.
wait exit 0 does not mean "no open findings" — it means "nothing actionable". A campaign
whose only critical finding has been triaged accepted exits 0 while
findings list --open --severity critical --json still reports it. Scripts that want the dashboard
number must ask for it explicitly with --status open.wait is not gate
Do not mix their exit codes in one script. gate 2 means "poll again." wait uses 3 for timeout and never emits 2 (clap already uses 2 for bad flags). gate 1 is any new finding; wait 1 respects --fail-on. A triage of accepted or wont_fix quiets both. See PR gate.
JSON envelope
Every --json document starts with the same envelope:
{
"schemaVersion": 1,
"command": "findings.list",
"generatedAt": "2026-07-28T14:03:11Z",
"agentVersion": "0.3.0",
"ok": true,
"exitCode": 0,
"server": "https://thespider.xyz",
"project": { "id": 42, "slug": "payments-api", "selector": "acme/payments-api" },
"profile": "security",
"profileSource": "project-default"
}
command is one of findings.list, findings.show, findings.triage, findings.packet,
findings.copy, wait. profileSource is flag, campaign, project-config, project-default,
or fallback. generatedAt is UTC RFC 3339 at second precision.
findings.list
{
"filters": { "status": "open", "severity": "critical,high", "campaignId": null,
"branch": null, "bucket": null, "category": null, "q": null, "sort": null,
"since": null },
"pagination": { "limit": 100, "offset": 0, "returned": 100, "total": 240,
"truncated": true, "mode": "page" },
"summary": { "total": 240, "open": 180, "severe_open": 12, "needs_retest": 3 },
"findings": [ { "id": 501, "…": "…" } ]
}
filters.status echoes the expanded token ("open" / "actionable"), never the flag name.
filters.since echoes the --since selector exactly as sent (a campaign id as a string, or the ISO
date/timestamp) — a set that was cut off at a point in time is meaningless without saying which.
truncated is offset + returned < total — the CLI never silently paginates. In --all mode
pagination.limit is null, mode is "all", and truncated is false.
Each finding record always carries every one of these keys:
| Field | Type | Null? |
|---|---|---|
id | integer | no |
fingerprint | string | no |
canonical_title | string | no |
canonical_severity | string | yes |
status | string | no |
bucket_code, bucket_name | string | yes |
discovered_in_campaign_id | integer | yes |
campaign_name | string | yes |
primary_file | string | yes |
reports | integer | no |
models, passes, profiles_seen | string | no ([]) |
first_seen_commit, last_seen_commit, fixed_at_commit | string | yes |
first_seen_branch, last_seen_branch | string | yes |
updated_at | string | yes |
web_url | string | no |
How pagination.total and summary.* relate
Both are computed server-side over the same filtered set — the WHERE clause you asked for.
pagination.total is summary.total. summary.open and summary.severe_open additionally
apply the summary predicate status NOT IN ('fixed','false_positive'), which is exactly the open
predicate. Consequences, all true by construction:
- With
--status open(or--open):pagination.total == summary.total == summary.open, and all three equal the number the web dashboard andcampaign.open_findingsshow. - With
--status actionable(or--actionable):pagination.total == summary.total == summary.opentoo, becauseactionable ⊂ open— but the number is smaller than the dashboard's, by exactly the count ofaccepted+wont_fixfindings. That gap is the answer to "how many risks have we deliberately accepted?" - With
--status closed:summary.open == 0. - With no
--status:summary.totalis everything andsummary.openis its open subset.
In --all mode the export endpoint carries no summary block, so the CLI computes summary.open,
summary.severe_open and summary.needs_retest from the exported records themselves. They are
numbers, including 0 — a --all export is the complete filtered set, so the counts are
knowable and a null there would wrongly read as "unqueried". summary.total is the export's own
total. Two more --all caveats: reports, models, passes, and primary_file are derived
from the export's nested reports[] (count; distinct sorted model_code; distinct sorted
pass_code; lexicographically lowest cited path), and profiles_seen is derived from report
provenance rather than from sightings.
findings.show
finding carries the list record plus merged_into_finding_id, fixed_at_branch, notes, and
created_at. Then reports[], sightings[], history[], fixes[], and false_positives[].
findings list. On show, reports,
models, passes, and profiles_seen are derived from the reports[] and sightings[] this
response carries, and those are filtered to the resolved profile. On list the same-named columns
count the whole finding regardless of profile. So for a finding sighted under both profiles,
findings list --profile security may report "reports": 4, "models": ["anthropic/claude-opus-5","anthropic/claude-sonnet-5"] while
findings show --profile security reports "reports": 2, "models": ["anthropic/claude-sonnet-5"] for the same id.
Pass --profile all to make the two agree.One element of reports[]:
{
"id": 702, "ordinal": 1, "title": "…", "severity": "high",
"confidence": null, "cvss_v4_score": null, "cvss_v4_vector": null,
"evidence": "…", "exploit_scenario": null, "impact": "…", "recommendation": "…",
"model_code": "anthropic/claude-sonnet-5", "model_name": "Claude Sonnet 5",
"pass_code": "http", "pass_name": "HTTP surface",
"bucket_code": "core", "profile": "security",
"slice_id": 9, "slice_status": "completed",
"categories": ["authz"],
"cwes": ["CWE-89"],
"files": [ { "path": "src/db.ts", "line": 42 } ],
"reproduction_steps": null, "expected_behavior": null,
"actual_behavior": null, "root_cause": null, "test_suggestion": null
}
categories, cwes, and files come from the server's structured arrays and are copied
verbatim — see What the CLI never does. The five bug fields are
emitted always (null under the security profile) so keys are never omitted.
The other four arrays are answers to four different questions. Each is [] when empty, never
null, and every key below is always present.
sightings[] — where and when this finding has been seen. One row per campaign that sighted it.
{ "campaign_id": 81, "profile": "security", "branch": "main",
"commit": "9f2c1ab", "first_seen_at": "2026-07-21T09:12:04Z", "last_seen_at": "2026-07-28T14:00:51Z" }
campaign_id is an integer; profile, branch, commit, first_seen_at, and last_seen_at are
string|null. profiles_seen on the finding record is the distinct sorted profile set of this
array.
history[] — the triage timeline, newest first. This is what the web UI's history panel shows,
and what findings triage appends to.
{ "id": 3311, "from_status": "new", "to_status": "accepted",
"decider": "thespider-agent@build-01", "actor_user_id": null,
"notes": "risk accepted for the 2026-Q3 release", "created_at": "2026-07-28T14:00:51Z" }
id is an integer, actor_user_id is int|null (null for a CLI-driven change), and
from_status, to_status, decider, notes, created_at are string|null. decider is the
attribution string — history[0].decider is how you check who last moved a finding.
fixes[] — remediation records linked to this finding.
{ "id": 44, "status": "proposed", "description": "parameterized the query",
"pr_url": "https://…/merge_requests/128", "ticket_url": null,
"commits": ["9f2c1ab", "c4d5e6f"], "resolved_at": "2026-07-27T18:22:10Z" }
id is an integer, commits is a string[] ([] when none), and status, description,
pr_url, ticket_url, resolved_at are string|null.
false_positives[] — dismissals, each with the commit it was recorded against.
{ "id": 12, "reason": "test fixture, not reachable in production",
"decider": "alice@acme.example", "applies_until_commit": "9f2c1ab",
"created_at": "2026-07-24T11:03:00Z" }
id is an integer; reason, decider, applies_until_commit, and created_at are
string|null. applies_until_commit is the commit the dismissal was recorded through — the web UI
renders it as "Applies through <commit>".
A merged finding is reported, never silently redirected: merged_into_finding_id names the
canonical id, and findings triage refuses to write against a tombstone.
findings.triage
{ "change": { "finding_id": 501, "from_status": "new", "to_status": "triaged",
"decider": "thespider-agent@build-01", "actor_user_id": null,
"reason": null, "notes": null, "commits": [] } }
decider is never null: it defaults to thespider-agent@<host> (or thespider-agent when no
hostname is available) and is overridable with --decider. It is what the web UI history timeline
attributes the change to.
findings.packet and findings.copy
packet (the full markdown, as a string) plus a finding object with id, fingerprint,
canonical_severity, status, merged_into_finding_id, and web_url. findings.copy adds
copied (bool) and bytes (int). packet is present even when copied is false, so a JSON
consumer always has the payload.
copied means a clipboard method accepted the document, which is not always a verifiable fact.
A local clipboard tool (pbcopy / wl-copy / xclip / xsel) exits non-zero on failure, so its
success is checked. The fallback — an OSC 52 escape sequence written to the terminal — has no reply
of any kind, so all the CLI ever learns is that the bytes were written; the terminal may have
ignored them. When OSC 52 is the only method that claimed the copy, human mode also prints the
packet to stdout and says so on stderr, so you are never left with neither a clipboard nor a
document. copied stays a plain bool in schemaVersion: 1; --json consumers should read
packet, which is always there.
wait
{
"campaign": { "id": 81, "status": "completed", "profile": "security",
"slice_count": 12, "pending": 0, "running": 0, "completed": 11,
"failed": 1, "retryable": 0 },
"degraded": true,
"reason": "terminal",
"waited_seconds": 312,
"polls": 21,
"fail_on": ["critical", "high"],
"verdict": {
"status_filter": "actionable",
"matched": 3,
"truncated": false,
"findings": [
{ "id": 501, "canonical_severity": "critical", "canonical_title": "SQL injection in the payments reader", "status": "new", "primary_file": "src/db.ts" },
{ "id": 517, "canonical_severity": "high", "canonical_title": "Missing authz check on /admin/export", "status": "new", "primary_file": "src/routes/admin.ts" },
{ "id": 522, "canonical_severity": "high", "canonical_title": "Reflected XSS in the search page", "status": "needs_retest", "primary_file": "web/app/search.vue" }
]
},
"summary": { "total": 3, "open": 3, "severe_open": 3, "needs_retest": 0 }
}
campaign.retryable is the subset of campaign.failed that a claim will still be offered, and it is
part of what "terminal" means: wait returns only when pending, running and retryable are all
zero, which is the same "still outstanding" set the server uses for campaign completion, the CI gate
and the daemon's claimable poll — so wait can never report a campaign finished while workers are
still claiming into it. A failed slice whose attempts are spent counts in failed but not in
retryable: it is terminal and degraded (exit 4), never clean. A server older than this field
omits it and it reads 0.
reason is terminal, timeout, or interrupted. verdict.status_filter is "actionable"
whenever the verdict query ran — the one predicate schemaVersion: 1 uses — and null when it did
not, so the envelope states which predicate produced matched rather than leaving a consumer to
assume it is the dashboard's open count. fail_on is [] when the verdict was disabled with
--fail-on off.
reason before trusting any counter. When the loop stops before the campaign is
terminal — a timeout or an interrupt, possibly before the very first poll returned — nothing was
counted, so every campaign.* field except id is null, and verdict.* and summary.* are
null as described below. degraded stays a plain false on that path: it means "nothing is
known to have failed", not "nothing failed". A null means unqueried; a 0 means counted and
genuinely zero.verdict.findings carries up to 20 projected rows in the full findings.list record shape
documented above — each element in the example is abbreviated, but the real document carries every
key of that table. verdict.matched is the complete count of matching findings, and
verdict.truncated is true exactly when matched exceeds the 20-row cap, i.e. when
verdict.findings does not list everything matched counted. The array is never null; it is []
when nothing matched.
The verdict query only runs on a terminal campaign, and unobtained numbers are null, not 0.
When reason is timeout or interrupted, or when --fail-on off disabled the verdict, no
verdict query was ever issued — so verdict.status_filter, verdict.matched, verdict.truncated
and every field of summary are null, and verdict.findings is []. A zero in those fields
always means "counted, and it was zero". Branch on reason (or the exit code) before you read them.
refine
command is refine. Flag semantics live on Refine. Same exit-code numbers as wait, different predicates:
| Code | Name | refine meaning |
|---|---|---|
0 | OK | The selected backlog (actionable ∩ filters) is empty. |
1 | FINDINGS | Work remains after --max-iterations. With --list: work remains now. |
3 | TIMEOUT | A retest wait exceeded --retest-timeout. |
4 | DEGRADED | Any retest campaign terminated with failed > 0 or without a conclusion. |
5 | USAGE | Bad input — including 409 commit_mismatch (both SHAs named) and unbound/unenabled models. |
6 | OPERATIONAL | Environment failure. |
7 | INTERRUPTED | SIGINT/SIGTERM; the worktree is kept and resume hints are printed. |
Precedence when several conditions apply: 7 > 3 > 4 > 1 > 0.
The payload extends the shared envelope fields with:
| Field | Type | Meaning |
|---|---|---|
iterationsUsed | integer | Iterations actually run (0 under --list). |
findingsSelected | integer | Findings selected by the filters for this run. |
fixedNow | integer | Findings a completed retest concluded fixed during the run. |
stillOpen | integer | Selected findings still actionable at exit. |
campaignIds | integer | Every retest campaign created ([] when none). |
worktree | string|null | The worktree path used, or null (--list, --in-place). |
branch | string|null | The original checkout branch recorded on campaigns — never the worktree branch. |
degraded | boolean | Any terminal retest campaign failed or reached no conclusion. |
elapsedMs | integer | Wall-clock duration of the run. |
Error envelope
Emitted on every non-zero terminating path the command itself reaches — including transport and
auth failure (an unreadable or malformed auth store is an operational envelope with exit 6,
not a bare stderr line).
Exit 2 is the one exception. Argument parsing happens in clap, before the command body runs:
an unknown flag, a missing value, or a value your --severity / --status / --fail-on list
rejects makes clap print usage text to stderr and exit 2 with zero bytes on stdout. There
is no JSON document to parse. Branch on 2 before you read stdout:
thespider-agent findings list --severity bogus --json > out.json; rc=$?
case "$rc" in
0) jq '.pagination.total' out.json ;;
2) echo "bad arguments (usage text is on stderr; out.json is empty)" >&2; exit 2 ;;
*) jq -r '.error.message' out.json >&2; exit "$rc" ;;
esac
{
"schemaVersion": 1,
"command": "wait",
"generatedAt": "2026-07-28T14:03:11Z",
"agentVersion": "0.3.0",
"ok": false,
"exitCode": 6,
"error": { "kind": "operational", "code": "invalid_token",
"httpStatus": 401, "message": "HTTP 401: invalid_token" }
}
error.kind is usage or operational. error.code is the server's error code, or one of the
CLI-originated codes auth_store_unreadable, missing_project_config, invalid_limit,
reason_required_for_false_positive, commit_requires_fixed_status,
merged_finding_not_triageable, export_too_large, interrupted, timeout, or null.
auth_store_unreadable is the code behind the auth-failure guarantee above: an auth.toml that is
missing, unreadable, or malformed produces this envelope with exitCode: 6, rather than the bare
exit 1 an unhandled error would give.
project, profile, and the payload keys are omitted on the error envelope when they could not
be resolved. This is the single documented exception to "keys are never omitted": the error envelope
guarantees only schemaVersion, command, generatedAt, agentVersion, ok, exitCode, and
error.
Worked examples
# The AC1 one-liner: open high-severity findings as JSON, no web UI.
thespider-agent findings list --open --severity critical,high --json | jq '.pagination.total'
# The agent work queue, with ids.
thespider-agent findings list --actionable --severity critical,high --json | jq -r '.findings[].id'
# Did we see everything, or is the page truncated?
thespider-agent findings list --json | jq '.pagination | {returned, total, truncated}'
# Block on a campaign, then branch on the documented exit code.
thespider-agent wait --campaign 81 --json > wait.json; rc=$?
case "$rc" in
0) echo "clean" ;;
1) jq -r '.verdict.findings[].canonical_title' wait.json ;;
2) echo "bad arguments; usage text is on stderr and wait.json is empty" >&2; exit 2 ;;
3) echo "timed out; still running" ;;
4) echo "degraded: $(jq '.campaign.failed' wait.json) slice(s) failed" ;;
*) jq -r '.error.message' wait.json >&2; exit "$rc" ;;
esac
Markdown packet
findings packet <ID> writes a version-marked markdown document to stdout; findings copy <ID>
puts the same document on the system clipboard. It is rendered client-side from the finding detail
endpoint — there is no packet endpoint — and the renderer version is pinned to the binary version.
Front matter
---
thespider_packet: 1
finding_id: 501
fingerprint: "sha256:…"
project: "acme/payments-api"
project_id: 42
profile: "security"
status: "new"
severity: "high"
merged_into_finding_id: null
redacted: false
generated_by: "thespider-agent/0.3.0"
generated_at: "2026-07-28T14:03:11Z"
server: "https://thespider.xyz"
web_url: "https://thespider.xyz/projects/42/findings?finding=501"
---
Keys are fixed, always present, in this order. Values are emitted as JSON scalars so model-authored text cannot break the block.
Ordered heading list
Every heading is emitted unconditionally and in this exact order. A section with no content
renders _none_, so a consumer can rely on the heading set rather than on presence.
| # | Heading | Contents |
|---|---|---|
| 1 | # {canonical_title} | the finding title |
| 2 | ## Identity | finding id, fingerprint, project, profile, bucket, status, severity, first/last seen commit and branch, report count, web_url |
| 3 | ## Merge status | _not merged_, or the canonical id to triage instead |
| 4 | ## Location | primary file, then a deduplicated (path, line)-sorted list of every citation |
| 5 | ## Reports | one row per report in r.id order |
| 6 | ## Evidence | per-report blocks, r.id ascending |
| 7 | ## Exploit scenario | same |
| 8 | ## Impact | same |
| 9 | ## Recommendation | same |
| 10 | ## Bug details | the five bug fields; _none_ under the security profile |
| 11 | ## Sightings | campaign, profile, branch, commit, first/last seen |
| 12 | ## Triage history | from → to, decider, actor, notes, timestamp |
Reports appear in ascending report id — the order the server returns — and are never re-sorted
client-side. A file with line: null renders as a bare path; a file with a line renders
path:line, so a real line 0 is never confused with "no line".
Model-authored text is treated as hostile. Inside a free-text block, three kinds of line are backslash-escaped, because each of them can change the heading set a markdown parser sees:
- a level-1 or level-2 ATX heading (
# …,## …) — it would forge a section boundary. Level 3 and below are left intact, so a model's### Detailsstill renders as a subheading; - a fenced-code opener (three or more backticks or tildes) — the packet emits no fences of its own, so a single unbalanced one would leave a fence open for the rest of the document and every later heading would parse as literal code text;
- a setext underline (a line of only
=or only-) — it would promote the line above it to an H1/H2 that is not in the list, with no#anywhere.
File citations — including the primary file: line — are rendered inside a code span wide enough to
survive a backtick in the path; a path containing a newline is emitted as a JSON string literal, so
one citation always stays on one line.
Table cells and the H1 title get one more treatment: the packet is written straight to your
terminal, so every control character in them is rendered as its \u{…} escape rather than passed
through. A finding title carrying \x1b]52;c;… or \r\x1b[2K cannot retitle your window, silently
overwrite your clipboard, or erase and fabricate a line of output.
Redaction
--redact is off by default. The evidence for the highest-severity finding class — a hardcoded
credential — is the secret, so masking by default would destroy the packet's purpose, and the data
already lives on the server. With --redact the assembled document is passed through the same
secret-masking rules run --redact uses and the front matter records redacted: true. The packet
is never truncated.
What the CLI never does
- No local findings cache. Every call is a fresh server read.
- No writes into
.thespider/config.toml. - One finding per
triagecall. Loop if you have many. open/closed/actionableare sent verbatim. Membership is server-side.categories,cwes, andfilescome from structured arrays, not string splitting.
[] for categories, cwes, and files rather than failing. Upgrade
the server to get report provenance in findings show and the packet.Next steps
- The agent — every command and flag.
- Triage — what each status means for the dashboard and the gate.
- PR gate — why
gateandwaitare different tools. - Findings export — the server-side bulk contract.