ghost-api-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Write report.md for a run (FR-47, FR-48), from the database only.
|
|
2
|
+
|
|
3
|
+
Sections: summary and usage, endpoints (status, confidence, samples), hypotheses with
|
|
4
|
+
evidence, the agent's reasoning log, known issues, and lessons for next time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ghost_api import __version__
|
|
13
|
+
from ghost_api.analysis.exposure import Exposure, exposures, summary_line
|
|
14
|
+
from ghost_api.analysis.models import Endpoint
|
|
15
|
+
from ghost_api.capture.personal import LABEL
|
|
16
|
+
from ghost_api.export.confidence import confidence
|
|
17
|
+
from ghost_api.generator.spec import ClientSpec
|
|
18
|
+
from ghost_api.memory.store import RunRow, Store
|
|
19
|
+
from ghost_api.site import site_host
|
|
20
|
+
|
|
21
|
+
RESULT_MARK = {"confirmed": "confirmed", "rejected": "rejected", "inconclusive": "inconclusive"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _network_section(net: dict[str, Any]) -> list[str]:
|
|
25
|
+
"""What the run sent where, and which rules applied."""
|
|
26
|
+
rows = [
|
|
27
|
+
("Site", f"`{net['target']}`" + (" (local target: private addresses allowed)"
|
|
28
|
+
if net.get("local_target") else "")),
|
|
29
|
+
("robots.txt", _md(str(net.get("robots", "")))),
|
|
30
|
+
("Rate limit", f"at most {net.get('rate_limit', 0)} request(s) per second per site"),
|
|
31
|
+
("API calls the pages made", str(net.get("api_calls_seen", 0))),
|
|
32
|
+
("Direct calls Ghost API sent", f"{net.get('direct_calls', 0)} (read-only endpoint tests)"),
|
|
33
|
+
("Other API hosts", ", ".join(f"`{h}`" for h in net.get("third_party_hosts", []))
|
|
34
|
+
or "none"),
|
|
35
|
+
("Saved login used", "yes" if net.get("saved_login") else "no"),
|
|
36
|
+
("LLM calls", str(net.get("llm_calls", 0))),
|
|
37
|
+
] # fmt: skip
|
|
38
|
+
out = ["| | |", "|---|---|", *(f"| {k} | {v} |" for k, v in rows)]
|
|
39
|
+
blocked = net.get("blocked") or []
|
|
40
|
+
out += ["", f"**Blocked by the rules ({len(blocked)}):**" if blocked else
|
|
41
|
+
"Nothing was blocked by the network rules."] # fmt: skip
|
|
42
|
+
out += [f"- {_md(b)}" for b in blocked[:30]]
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_report(store: Store, domain: str, run_id: int, spec: ClientSpec | None = None) -> str:
|
|
47
|
+
run = store.get_run(run_id)
|
|
48
|
+
if run is None:
|
|
49
|
+
raise ValueError(f"run {run_id} not found")
|
|
50
|
+
endpoints = store.current_endpoints(domain)
|
|
51
|
+
hypotheses = store.hypotheses_for_run(run_id)
|
|
52
|
+
steps = store.steps_for_run(run_id)
|
|
53
|
+
lessons = store.lessons_for(domain, limit=10)
|
|
54
|
+
|
|
55
|
+
out: list[str] = [
|
|
56
|
+
f"# Ghost API report: {domain}",
|
|
57
|
+
"",
|
|
58
|
+
f"Generated by Ghost API {__version__} for run #{run.id}. Goal: _{_md(run.goal)}_",
|
|
59
|
+
"",
|
|
60
|
+
"## Summary",
|
|
61
|
+
"",
|
|
62
|
+
*_summary(run, endpoints, spec),
|
|
63
|
+
"",
|
|
64
|
+
"## Endpoints",
|
|
65
|
+
"",
|
|
66
|
+
]
|
|
67
|
+
out += _endpoint_table(endpoints, domain) if endpoints else ["No endpoints were found."]
|
|
68
|
+
exposed = exposures(endpoints, store.requests_for_domain(domain))
|
|
69
|
+
if exposed:
|
|
70
|
+
out += ["", "## Personal data exposed", "", *_exposure_section(exposed)]
|
|
71
|
+
network = [e for e in store.events_for_run(run_id) if e.get("type") == "network"]
|
|
72
|
+
if network:
|
|
73
|
+
out += ["", "## Network and permissions", "", *_network_section(network[-1])]
|
|
74
|
+
out += ["", "## Hypotheses tested", ""]
|
|
75
|
+
out += _hypotheses(hypotheses) or ["No direct-call tests in this run."]
|
|
76
|
+
out += ["", "## How the agent explored", ""]
|
|
77
|
+
out += _steps(steps) or ["The agent was not used in this run."]
|
|
78
|
+
out += ["", "## Known issues", ""]
|
|
79
|
+
out += _issues(endpoints, hypotheses, spec) or ["None found."]
|
|
80
|
+
if lessons:
|
|
81
|
+
out += ["", "## Lessons remembered for next time", ""]
|
|
82
|
+
out += [f"- {_md(lesson)}" for lesson in lessons]
|
|
83
|
+
out += ["", "---", "", _CONFIDENCE_NOTE, ""]
|
|
84
|
+
return "\n".join(out)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _exposure_section(exposed: list[Exposure]) -> list[str]:
|
|
88
|
+
lines = [
|
|
89
|
+
f"**{summary_line(exposed)}.** Ghost API hid these values: they are not in its "
|
|
90
|
+
"database, this report or the generated code. Examples below are masked.",
|
|
91
|
+
"",
|
|
92
|
+
"| Endpoint | Without login | Field | What | Seen | Example |",
|
|
93
|
+
"|---|---|---|---|---|---|",
|
|
94
|
+
]
|
|
95
|
+
for e in exposed:
|
|
96
|
+
for f in e.fields:
|
|
97
|
+
lines.append(
|
|
98
|
+
f"| `{_md(e.label)}` | {'yes' if e.open else 'no'} | `{_md(f['path'])}` "
|
|
99
|
+
f"| {LABEL.get(f['kind'], f['kind'])} ({f['severity']}) | {f['count']} "
|
|
100
|
+
f"| `{_md(f['example'])}` |"
|
|
101
|
+
)
|
|
102
|
+
lines += [
|
|
103
|
+
"",
|
|
104
|
+
"_What to do: if this is your site, stop returning these fields (or require a "
|
|
105
|
+
"login). If it isn't, don't collect or share them, and consider telling the site "
|
|
106
|
+
"owner privately (responsible disclosure)._",
|
|
107
|
+
]
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_CONFIDENCE_NOTE = (
|
|
112
|
+
"_Confidence: verified by live tests +2, callable directly +1, pagination confirmed +1, "
|
|
113
|
+
"2-4 samples +1 or 5+ samples +2, failed -3. High is 4 or more, Medium 2-3, Low below 2._"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summary(run: RunRow, endpoints: list[Endpoint], spec: ClientSpec | None) -> list[str]:
|
|
118
|
+
counts = {
|
|
119
|
+
s: sum(e.status == s for e in endpoints) for s in ("verified", "unverified", "failed")
|
|
120
|
+
}
|
|
121
|
+
outcome = run.status
|
|
122
|
+
if run.status == "running" and run.finished_at is None:
|
|
123
|
+
outcome = "did not finish (stopped unexpectedly)"
|
|
124
|
+
lines = [
|
|
125
|
+
f"- **Outcome:** {outcome}",
|
|
126
|
+
f"- **Endpoints:** {len(endpoints)} ({counts['verified']} verified, "
|
|
127
|
+
f"{counts['unverified']} unverified, {counts['failed']} failed)",
|
|
128
|
+
]
|
|
129
|
+
if spec is not None:
|
|
130
|
+
methods = ", ".join(f"`{m.name}()`" for m in spec.methods)
|
|
131
|
+
lines.append(f"- **Client:** `{spec.class_name}` with {methods}")
|
|
132
|
+
duration = _duration(run.started_at, run.finished_at)
|
|
133
|
+
lines.append(
|
|
134
|
+
f"- **Usage:** {run.llm_calls} LLM call{_s(run.llm_calls)}, {run.tokens:,} tokens, "
|
|
135
|
+
f"{run.request_count} captured request{_s(run.request_count)}"
|
|
136
|
+
f"{', ' + duration if duration else ''}, $0.00 (free tiers)"
|
|
137
|
+
)
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _endpoint_table(endpoints: list[Endpoint], domain: str) -> list[str]:
|
|
142
|
+
rows = [
|
|
143
|
+
"| Endpoint | Status | Confidence | Samples | Auth | Paging | Notes |",
|
|
144
|
+
"|---|---|---|---|---|---|---|",
|
|
145
|
+
]
|
|
146
|
+
host = site_host(domain)
|
|
147
|
+
for ep in endpoints:
|
|
148
|
+
label = ep.label if ep.host == host else f"{ep.method} {ep.host}{ep.path_template}"
|
|
149
|
+
paging = "-"
|
|
150
|
+
if ep.pagination:
|
|
151
|
+
mark = "" if ep.pagination.get("confirmed") else " (unconfirmed)"
|
|
152
|
+
paging = f"{ep.pagination['style']}: `{ep.pagination['param']}`{mark}"
|
|
153
|
+
rows.append(
|
|
154
|
+
f"| `{_md(label)}` | {ep.status} | {confidence(ep)} | {ep.sample_count} | "
|
|
155
|
+
f"{ep.auth_type or '-'} | {paging} | {_md(ep.status_note) or '-'} |"
|
|
156
|
+
)
|
|
157
|
+
return rows
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _hypotheses(hypotheses: list[dict[str, Any]]) -> list[str]:
|
|
161
|
+
lines: list[str] = []
|
|
162
|
+
current = None
|
|
163
|
+
for h in hypotheses:
|
|
164
|
+
if h["endpoint_key"] != current:
|
|
165
|
+
current = h["endpoint_key"]
|
|
166
|
+
if lines:
|
|
167
|
+
lines.append("")
|
|
168
|
+
lines += [f"**`{_md(str(current))}`**", ""]
|
|
169
|
+
result = RESULT_MARK[str(h["result"])]
|
|
170
|
+
lines.append(f"- {result}: {_md(str(h['text']))} ({_md(str(h['evidence']))})")
|
|
171
|
+
return lines
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _steps(steps: list[dict[str, Any]]) -> list[str]:
|
|
175
|
+
lines = []
|
|
176
|
+
for s in steps:
|
|
177
|
+
args = ", ".join(f"{k}={v!r}" for k, v in dict(s["args"]).items())
|
|
178
|
+
lines.append(f"{s['step_no']}. `{s['action']}({_md(args)})`: {_md(str(s['thought']))}")
|
|
179
|
+
lines.append(f" - Result: {_md(str(s['result']))}")
|
|
180
|
+
return lines
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _issues(
|
|
184
|
+
endpoints: list[Endpoint], hypotheses: list[dict[str, Any]], spec: ClientSpec | None
|
|
185
|
+
) -> list[str]:
|
|
186
|
+
issues = []
|
|
187
|
+
for ep in endpoints:
|
|
188
|
+
if ep.status in ("failed", "unverified"):
|
|
189
|
+
issues.append(
|
|
190
|
+
f"- `{_md(ep.label)}` is {ep.status}: {_md(ep.status_note) or 'not tested'}"
|
|
191
|
+
)
|
|
192
|
+
elif ep.status == "discovered":
|
|
193
|
+
issues.append(
|
|
194
|
+
f"- `{_md(ep.label)}` has not been verified yet "
|
|
195
|
+
"(run `ghost-api verify` or `ghost-api refresh`)"
|
|
196
|
+
)
|
|
197
|
+
if ep.replayable is False:
|
|
198
|
+
issues.append(f"- `{_md(ep.label)}` cannot be called outside the browser")
|
|
199
|
+
for h in hypotheses:
|
|
200
|
+
if h["result"] == "inconclusive":
|
|
201
|
+
issues.append(f"- Inconclusive: {_md(str(h['text']))} ({_md(str(h['evidence']))})")
|
|
202
|
+
if spec is not None:
|
|
203
|
+
issues += [f"- Not in the client: {_md(s)}" for s in spec.skipped]
|
|
204
|
+
for cred in spec.credentials:
|
|
205
|
+
issues.append(f"- The client needs `{cred.arg}`: set `{cred.env}` or pass it in")
|
|
206
|
+
return issues
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _duration(started: str, finished: str | None) -> str:
|
|
210
|
+
if not finished:
|
|
211
|
+
return ""
|
|
212
|
+
try:
|
|
213
|
+
seconds = (
|
|
214
|
+
datetime.fromisoformat(finished) - datetime.fromisoformat(started)
|
|
215
|
+
).total_seconds()
|
|
216
|
+
except ValueError:
|
|
217
|
+
return ""
|
|
218
|
+
return f"{seconds:.0f}s"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _s(count: int) -> str:
|
|
222
|
+
return "" if count == 1 else "s"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _md(text: str) -> str:
|
|
226
|
+
"""Safe inside a Markdown table cell or list item."""
|
|
227
|
+
return text.replace("|", "\\|").replace("\n", " ").strip()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""What is stored about a site, as plain JSON for the dashboard and report.html.
|
|
2
|
+
|
|
3
|
+
The dashboard's API and the shareable report use these same shapes, so the React
|
|
4
|
+
components that show them work in both places (see web/src/types.ts).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ghost_api import __version__
|
|
15
|
+
from ghost_api.analysis.analyzer import endpoint_keys
|
|
16
|
+
from ghost_api.analysis.exposure import exposures
|
|
17
|
+
from ghost_api.analysis.models import Endpoint
|
|
18
|
+
from ghost_api.analysis.schema import field_paths, top_level_fields
|
|
19
|
+
from ghost_api.export.confidence import confidence
|
|
20
|
+
from ghost_api.generator.naming import apply_plan
|
|
21
|
+
from ghost_api.memory.store import RunRow, Store
|
|
22
|
+
from ghost_api.runtime import inputs_for, is_callable, load_plan
|
|
23
|
+
from ghost_api.site import site_host
|
|
24
|
+
|
|
25
|
+
SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
26
|
+
|
|
27
|
+
# Code and spec files embedded in report.html (bigger files are listed, not embedded).
|
|
28
|
+
MAX_EMBEDDED_FILE = 300_000
|
|
29
|
+
EMBEDDED_KINDS = ("client", "models", "tests", "openapi")
|
|
30
|
+
# Left out of the shared report: request events (many, with full URLs), endpoint lists
|
|
31
|
+
# (the report has its own, with sensitive examples removed) and screenshots (not embedded).
|
|
32
|
+
SKIPPED_EVENTS = {"request", "endpoints", "screenshot"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def display_label(ep: Endpoint, site: str) -> str:
|
|
36
|
+
"""The label as the site sees it; `site` may be a host or host/section."""
|
|
37
|
+
host = site_host(site)
|
|
38
|
+
return ep.label if ep.host == host else f"{ep.method} {ep.host}{ep.path_template}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def endpoint_summary(ep: Endpoint, site: str) -> dict[str, Any]:
|
|
42
|
+
"""Everything an interface needs to show an endpoint, as plain JSON."""
|
|
43
|
+
host = site_host(site)
|
|
44
|
+
params = [
|
|
45
|
+
{
|
|
46
|
+
"name": p.name,
|
|
47
|
+
"type": p.type,
|
|
48
|
+
"location": "path",
|
|
49
|
+
"kind": "required",
|
|
50
|
+
"examples": p.examples[:3],
|
|
51
|
+
"sensitive": False,
|
|
52
|
+
}
|
|
53
|
+
for p in ep.path_params
|
|
54
|
+
] + [
|
|
55
|
+
{
|
|
56
|
+
"name": p.name,
|
|
57
|
+
"type": p.type,
|
|
58
|
+
"location": "query",
|
|
59
|
+
"kind": p.kind,
|
|
60
|
+
"examples": p.examples[:3],
|
|
61
|
+
"sensitive": p.sensitive,
|
|
62
|
+
}
|
|
63
|
+
for p in ep.query_params
|
|
64
|
+
]
|
|
65
|
+
return {
|
|
66
|
+
"key": ep.key,
|
|
67
|
+
"label": display_label(ep, site),
|
|
68
|
+
"method": ep.method,
|
|
69
|
+
"host": ep.host,
|
|
70
|
+
"path": ep.path_template,
|
|
71
|
+
"third_party": ep.host != host,
|
|
72
|
+
"params": params,
|
|
73
|
+
"samples": ep.sample_count,
|
|
74
|
+
"statuses": ep.statuses,
|
|
75
|
+
"status": ep.status,
|
|
76
|
+
"status_note": ep.status_note,
|
|
77
|
+
"replayable": ep.replayable,
|
|
78
|
+
"auth_type": ep.auth_type,
|
|
79
|
+
"required_headers": ep.required_headers,
|
|
80
|
+
"pagination": ep.pagination,
|
|
81
|
+
"confidence": confidence(ep),
|
|
82
|
+
"fields": top_level_fields(ep.response_schema),
|
|
83
|
+
"field_paths": field_paths(ep.response_schema, limit=40),
|
|
84
|
+
"has_request_body": ep.request_schema is not None,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def endpoint_details(store: Store, domain: str) -> list[dict[str, Any]]:
|
|
89
|
+
"""Summaries plus schemas and example URLs, from the latest run that found endpoints."""
|
|
90
|
+
run_id = store.latest_endpoint_run(domain)
|
|
91
|
+
if run_id is None:
|
|
92
|
+
return []
|
|
93
|
+
details = []
|
|
94
|
+
endpoints = store.current_endpoints(domain)
|
|
95
|
+
requests = store.requests_for_domain(domain)
|
|
96
|
+
found = exposures(endpoints, requests)
|
|
97
|
+
exposed = {e.endpoint_key: e.to_dict() for e in found}
|
|
98
|
+
samples: dict[str, list[Any]] = {}
|
|
99
|
+
for key, req in zip(endpoint_keys(requests), requests, strict=False):
|
|
100
|
+
samples.setdefault(key, []).append(req)
|
|
101
|
+
allowed = store.allowed_endpoints(domain)
|
|
102
|
+
_, names = apply_plan(load_plan(store.load_naming_plan(domain)), endpoints, domain)
|
|
103
|
+
for ep in endpoints:
|
|
104
|
+
summary = endpoint_summary(ep, domain)
|
|
105
|
+
summary.update(
|
|
106
|
+
request_schema=ep.request_schema,
|
|
107
|
+
response_schema=ep.response_schema,
|
|
108
|
+
example_urls=ep.example_urls,
|
|
109
|
+
exposure=exposed.get(ep.key),
|
|
110
|
+
# For Try it / Get data: can Ghost API call it, and with which inputs.
|
|
111
|
+
callable=is_callable(ep, ep.key in allowed),
|
|
112
|
+
allowed=ep.key in allowed,
|
|
113
|
+
operation=names[ep.key].method_name, # for fetch / watch / serve / mcp
|
|
114
|
+
inputs=[asdict(i) for i in inputs_for(ep, samples.get(ep.key, []))],
|
|
115
|
+
)
|
|
116
|
+
details.append(summary)
|
|
117
|
+
return details
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_dict(r: RunRow) -> dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"id": r.id,
|
|
123
|
+
"domain": r.domain,
|
|
124
|
+
"start_url": r.start_url,
|
|
125
|
+
"goal": r.goal,
|
|
126
|
+
"status": r.status,
|
|
127
|
+
"started_at": r.started_at,
|
|
128
|
+
"finished_at": r.finished_at,
|
|
129
|
+
"requests": r.request_count,
|
|
130
|
+
"llm_calls": r.llm_calls,
|
|
131
|
+
"tokens": r.tokens,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def file_kind(name: str) -> str:
|
|
136
|
+
if name.startswith("test_") or name.endswith(".test.ts"):
|
|
137
|
+
return "tests"
|
|
138
|
+
if name.endswith(("_client.py", "_client.ts")):
|
|
139
|
+
return "client"
|
|
140
|
+
if name == "models.py":
|
|
141
|
+
return "models"
|
|
142
|
+
if name.endswith((".yaml", ".yml")):
|
|
143
|
+
return "openapi"
|
|
144
|
+
if name.startswith("report."):
|
|
145
|
+
return "report"
|
|
146
|
+
return "other"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def site_files(folder: Path) -> list[dict[str, Any]]:
|
|
150
|
+
if not folder.is_dir():
|
|
151
|
+
return []
|
|
152
|
+
return [
|
|
153
|
+
{"name": f.name, "size": f.stat().st_size, "kind": file_kind(f.name)}
|
|
154
|
+
for f in sorted(folder.iterdir())
|
|
155
|
+
if f.is_file()
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _public_endpoint(ep: dict[str, Any]) -> dict[str, Any]:
|
|
160
|
+
"""Drop example values of sensitive parameters before sharing."""
|
|
161
|
+
params = [{**p, "examples": []} if p["sensitive"] else p for p in ep["params"]]
|
|
162
|
+
return {**ep, "params": params}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def report_data(
|
|
166
|
+
store: Store, domain: str, run_id: int, folder: Path, report_md: str, generated_at: str
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Everything report.html shows. It is meant to be shared, so it holds no secrets:
|
|
169
|
+
stored requests are already redacted, sensitive examples are dropped, and request
|
|
170
|
+
events (full URLs) are left out."""
|
|
171
|
+
run = store.get_run(run_id)
|
|
172
|
+
if run is None:
|
|
173
|
+
raise ValueError(f"run {run_id} not found")
|
|
174
|
+
files = []
|
|
175
|
+
for f in site_files(folder):
|
|
176
|
+
if f["kind"] not in EMBEDDED_KINDS:
|
|
177
|
+
continue
|
|
178
|
+
path = folder / f["name"]
|
|
179
|
+
content = (
|
|
180
|
+
path.read_text(encoding="utf-8", errors="replace")
|
|
181
|
+
if f["size"] <= MAX_EMBEDDED_FILE
|
|
182
|
+
else None
|
|
183
|
+
)
|
|
184
|
+
files.append({**f, "content": content})
|
|
185
|
+
events = [e for e in store.events_for_run(run_id) if e.get("type") not in SKIPPED_EVENTS]
|
|
186
|
+
return {
|
|
187
|
+
"version": __version__,
|
|
188
|
+
"generated_at": generated_at,
|
|
189
|
+
"domain": domain,
|
|
190
|
+
"run": run_dict(run),
|
|
191
|
+
"endpoints": [_public_endpoint(e) for e in endpoint_details(store, domain)],
|
|
192
|
+
"events": events,
|
|
193
|
+
"lessons": store.lessons_for(domain, limit=20),
|
|
194
|
+
"files": files,
|
|
195
|
+
"report_md": report_md,
|
|
196
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Write rows of records as CSV, Excel or JSON (for `ghost-api fetch` and the dashboard).
|
|
2
|
+
|
|
3
|
+
Rows come from `runtime.flatten`, so nested fields are already dotted columns
|
|
4
|
+
("venue.name"). Columns keep the order they first appear in.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from ghost_api.runtime import flatten
|
|
16
|
+
|
|
17
|
+
Format = Literal["csv", "xlsx", "json"]
|
|
18
|
+
FORMATS: tuple[Format, ...] = ("csv", "xlsx", "json")
|
|
19
|
+
MEDIA_TYPES = {
|
|
20
|
+
"csv": "text/csv; charset=utf-8",
|
|
21
|
+
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
22
|
+
"json": "application/json",
|
|
23
|
+
}
|
|
24
|
+
EXCEL_MAX_CELL = 32_767 # Excel refuses longer cell text
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def columns(rows: list[dict[str, Any]]) -> list[str]:
|
|
28
|
+
seen: dict[str, None] = {}
|
|
29
|
+
for row in rows:
|
|
30
|
+
for key in row:
|
|
31
|
+
seen.setdefault(key, None)
|
|
32
|
+
return list(seen)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_bytes(records: list[Any], fmt: Format) -> bytes:
|
|
36
|
+
"""The records as a file's contents. JSON keeps the original nesting."""
|
|
37
|
+
if fmt == "json":
|
|
38
|
+
return json.dumps(records, ensure_ascii=False, indent=2).encode("utf-8")
|
|
39
|
+
rows = [flatten(r) for r in records]
|
|
40
|
+
cols = columns(rows)
|
|
41
|
+
if fmt == "csv":
|
|
42
|
+
buffer = io.StringIO()
|
|
43
|
+
writer = csv.DictWriter(buffer, fieldnames=cols, extrasaction="ignore")
|
|
44
|
+
writer.writeheader()
|
|
45
|
+
writer.writerows({k: _safe_text(v) for k, v in row.items()} for row in rows)
|
|
46
|
+
return buffer.getvalue().encode("utf-8-sig") # the BOM makes Excel read UTF-8
|
|
47
|
+
return _xlsx(rows, cols)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write(records: list[Any], path: Path, fmt: Format) -> Path:
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
path.write_bytes(to_bytes(records, fmt))
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _xlsx(rows: list[dict[str, Any]], cols: list[str]) -> bytes:
|
|
57
|
+
from openpyxl import Workbook
|
|
58
|
+
from openpyxl.styles import Font
|
|
59
|
+
|
|
60
|
+
book = Workbook()
|
|
61
|
+
sheet = book.active
|
|
62
|
+
assert sheet is not None
|
|
63
|
+
sheet.title = "data"
|
|
64
|
+
sheet.append(cols)
|
|
65
|
+
for cell in sheet[1]:
|
|
66
|
+
cell.font = Font(bold=True)
|
|
67
|
+
for row in rows:
|
|
68
|
+
sheet.append([_cell(row.get(c)) for c in cols])
|
|
69
|
+
sheet.freeze_panes = "A2"
|
|
70
|
+
for i, col in enumerate(cols, start=1):
|
|
71
|
+
width = max([len(col)] + [len(str(r.get(col) or "")) for r in rows[:200]])
|
|
72
|
+
sheet.column_dimensions[sheet.cell(1, i).column_letter].width = min(max(width, 8), 60)
|
|
73
|
+
buffer = io.BytesIO()
|
|
74
|
+
book.save(buffer)
|
|
75
|
+
return buffer.getvalue()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cell(value: Any) -> Any:
|
|
79
|
+
safe = _safe_text(value)
|
|
80
|
+
return safe[:EXCEL_MAX_CELL] if isinstance(safe, str) else safe
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _safe_text(value: Any) -> Any:
|
|
84
|
+
"""Site data must never become a spreadsheet formula ("=HYPERLINK(...)")."""
|
|
85
|
+
if value is None or isinstance(value, bool | int | float):
|
|
86
|
+
return value
|
|
87
|
+
text = str(value)
|
|
88
|
+
return "'" + text if text[:1] in ("=", "+", "-", "@", "\t", "\r") else text
|