attestari-mcp 0.1.0__tar.gz
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.
- attestari_mcp-0.1.0/.gitignore +47 -0
- attestari_mcp-0.1.0/LICENSE +21 -0
- attestari_mcp-0.1.0/PKG-INFO +74 -0
- attestari_mcp-0.1.0/README.md +52 -0
- attestari_mcp-0.1.0/attestari_mcp/__init__.py +3 -0
- attestari_mcp-0.1.0/attestari_mcp/address.py +17 -0
- attestari_mcp-0.1.0/attestari_mcp/answer.py +240 -0
- attestari_mcp-0.1.0/attestari_mcp/lookup.py +182 -0
- attestari_mcp-0.1.0/attestari_mcp/server.py +247 -0
- attestari_mcp-0.1.0/pyproject.toml +74 -0
- attestari_mcp-0.1.0/tests/__init__.py +0 -0
- attestari_mcp-0.1.0/tests/conftest.py +147 -0
- attestari_mcp-0.1.0/tests/test_answer.py +114 -0
- attestari_mcp-0.1.0/tests/test_lookup.py +121 -0
- attestari_mcp-0.1.0/tests/test_server.py +186 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
.next/
|
|
3
|
+
out/
|
|
4
|
+
.vercel/
|
|
5
|
+
.env
|
|
6
|
+
.env.local
|
|
7
|
+
.env.*.local
|
|
8
|
+
*.tsbuildinfo
|
|
9
|
+
next-env.d.ts
|
|
10
|
+
.DS_Store
|
|
11
|
+
|
|
12
|
+
# analysis pipeline
|
|
13
|
+
analysis/.venv/
|
|
14
|
+
analysis/api/.venv/
|
|
15
|
+
analysis/api/.pytest_cache/
|
|
16
|
+
analysis/api/.ruff_cache/
|
|
17
|
+
analysis/evidence/
|
|
18
|
+
analysis/**/__pycache__/
|
|
19
|
+
analysis/.pytest_cache/
|
|
20
|
+
analysis/runs/*/prompts.json
|
|
21
|
+
analysis/.hypothesis/
|
|
22
|
+
|
|
23
|
+
# infra: files rendered from templates by the scripts, applied and removed
|
|
24
|
+
infra/.*.rendered.*
|
|
25
|
+
|
|
26
|
+
analysis/corpus/.scratch/
|
|
27
|
+
|
|
28
|
+
# accounts service
|
|
29
|
+
accounts/.venv/
|
|
30
|
+
accounts/.pytest_cache/
|
|
31
|
+
accounts/.ruff_cache/
|
|
32
|
+
accounts/**/__pycache__/
|
|
33
|
+
|
|
34
|
+
# the MCP server (mcp/, docs/app.attestari.ai/phase-1-mcp-plan.md)
|
|
35
|
+
mcp/.venv/
|
|
36
|
+
mcp/.pytest_cache/
|
|
37
|
+
mcp/.ruff_cache/
|
|
38
|
+
mcp/dist/
|
|
39
|
+
mcp/**/__pycache__/
|
|
40
|
+
|
|
41
|
+
# web app: what the end-to-end tests leave behind
|
|
42
|
+
webapp/e2e/.outbox/
|
|
43
|
+
webapp/e2e/.results/
|
|
44
|
+
webapp/e2e/.outbox-*/
|
|
45
|
+
webapp/e2e/.results-*/
|
|
46
|
+
.next-*/
|
|
47
|
+
webapp/e2e/.report/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Elite360 Corporation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: attestari-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Attestari's verdict on an npm or PyPI package version, over MCP: the lookup API's answer, asked before a package is installed.
|
|
5
|
+
Project-URL: Homepage, https://attestari.ai
|
|
6
|
+
Project-URL: Documentation, https://attestari.ai/pricing#line-2
|
|
7
|
+
Author: Attestari
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: attestari,mcp,model-context-protocol,security,supply-chain
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: httpx2>=2.13
|
|
17
|
+
Requires-Dist: mcp<3,>=2.3
|
|
18
|
+
Requires-Dist: pydantic>=2.9
|
|
19
|
+
Requires-Dist: starlette>=0.40
|
|
20
|
+
Requires-Dist: uvicorn>=0.30
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# attestari-mcp
|
|
24
|
+
|
|
25
|
+
<!-- mcp-name: ai.attestari/attestari -->
|
|
26
|
+
|
|
27
|
+
Attestari's verdict on an npm or PyPI package version, over the Model Context Protocol. The answer is the Attestari
|
|
28
|
+
lookup API's: the grade, the decision, the confirmed findings by reason code and place, and the methodology version
|
|
29
|
+
and document hash the decision cites. "Not examined" is its own answer and is never a sign that a package is safe.
|
|
30
|
+
|
|
31
|
+
Every lookup needs an Attestari API key (the Developer tier and above, https://attestari.ai/pricing#line-2) and is
|
|
32
|
+
counted as one lookup, as through the API. What is sent is the ecosystem, the package name and the version, and
|
|
33
|
+
nothing else.
|
|
34
|
+
|
|
35
|
+
## Run it locally
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
ATTESTARI_API_KEY=att_live_... uvx attestari-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It speaks MCP over standard input and output and opens no port. In an MCP client's configuration:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"attestari": {
|
|
47
|
+
"command": "uvx",
|
|
48
|
+
"args": ["attestari-mcp"],
|
|
49
|
+
"env": { "ATTESTARI_API_KEY": "att_live_..." }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`ATTESTARI_API_URL` changes the lookup API's address (default `https://api.attestari.ai`).
|
|
56
|
+
|
|
57
|
+
## Tools
|
|
58
|
+
|
|
59
|
+
- `lookup_package`: one package (`ecosystem`: `npm` or `pypi`; `name`; `version`, or none for the latest version
|
|
60
|
+
examined).
|
|
61
|
+
- `lookup_packages`: up to 20 packages at once, each counted as one lookup.
|
|
62
|
+
|
|
63
|
+
Both only read. Each answer says its outcome: `graded`, `not_gradeable` (examined, no opinion given), `not_a_server`
|
|
64
|
+
(out of scope), `not_examined`, `under_re_examination`, `refused` (the key, the plan or a limit) or `unavailable`.
|
|
65
|
+
|
|
66
|
+
## What the answer carries from a package
|
|
67
|
+
|
|
68
|
+
Its name, its version, and the file paths of its findings and capabilities with their line numbers. No excerpt of a
|
|
69
|
+
package's code or text is passed on: text written by a package's publisher never reaches the agent through this
|
|
70
|
+
server.
|
|
71
|
+
|
|
72
|
+
## Licence
|
|
73
|
+
|
|
74
|
+
MIT.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# attestari-mcp
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: ai.attestari/attestari -->
|
|
4
|
+
|
|
5
|
+
Attestari's verdict on an npm or PyPI package version, over the Model Context Protocol. The answer is the Attestari
|
|
6
|
+
lookup API's: the grade, the decision, the confirmed findings by reason code and place, and the methodology version
|
|
7
|
+
and document hash the decision cites. "Not examined" is its own answer and is never a sign that a package is safe.
|
|
8
|
+
|
|
9
|
+
Every lookup needs an Attestari API key (the Developer tier and above, https://attestari.ai/pricing#line-2) and is
|
|
10
|
+
counted as one lookup, as through the API. What is sent is the ecosystem, the package name and the version, and
|
|
11
|
+
nothing else.
|
|
12
|
+
|
|
13
|
+
## Run it locally
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
ATTESTARI_API_KEY=att_live_... uvx attestari-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It speaks MCP over standard input and output and opens no port. In an MCP client's configuration:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"attestari": {
|
|
25
|
+
"command": "uvx",
|
|
26
|
+
"args": ["attestari-mcp"],
|
|
27
|
+
"env": { "ATTESTARI_API_KEY": "att_live_..." }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`ATTESTARI_API_URL` changes the lookup API's address (default `https://api.attestari.ai`).
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
- `lookup_package`: one package (`ecosystem`: `npm` or `pypi`; `name`; `version`, or none for the latest version
|
|
38
|
+
examined).
|
|
39
|
+
- `lookup_packages`: up to 20 packages at once, each counted as one lookup.
|
|
40
|
+
|
|
41
|
+
Both only read. Each answer says its outcome: `graded`, `not_gradeable` (examined, no opinion given), `not_a_server`
|
|
42
|
+
(out of scope), `not_examined`, `under_re_examination`, `refused` (the key, the plan or a limit) or `unavailable`.
|
|
43
|
+
|
|
44
|
+
## What the answer carries from a package
|
|
45
|
+
|
|
46
|
+
Its name, its version, and the file paths of its findings and capabilities with their line numbers. No excerpt of a
|
|
47
|
+
package's code or text is passed on: text written by a package's publisher never reaches the agent through this
|
|
48
|
+
server.
|
|
49
|
+
|
|
50
|
+
## Licence
|
|
51
|
+
|
|
52
|
+
MIT.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The agent's address, as the MCP server's balancer saw it (docs/app.attestari.ai/phase-1-mcp-plan.md, decision 11).
|
|
2
|
+
|
|
3
|
+
The same rule the lookup API reads its own callers' addresses by (attestari_api/app.py, `client_address`, with the
|
|
4
|
+
last entry): Google's front end writes the connection's address last in `X-Forwarded-For`, after anything the caller
|
|
5
|
+
sent, so a prefix the caller wrote buys nothing; through a load balancer the list ends `<client>,<balancer>`, and a
|
|
6
|
+
last entry that is one of the balancer's own addresses is set aside, so the entry before it is the client's. The
|
|
7
|
+
lookup API then counts the call against this address (an IPv6 one by its /64), never against the MCP server's.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def agent_address(forwarded_for: str | None, edge: frozenset[str], fallback: str = "unknown") -> str:
|
|
14
|
+
forwarded = [p.strip() for p in (forwarded_for or "").split(",") if p.strip()]
|
|
15
|
+
if len(forwarded) > 1 and forwarded[-1] in edge:
|
|
16
|
+
forwarded = forwarded[:-1]
|
|
17
|
+
return forwarded[-1] if forwarded else fallback
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""What a tool answers: a projection of the lookup API's answer (docs/app.attestari.ai/mcp-threat-model.md, T8).
|
|
2
|
+
|
|
3
|
+
The lookup API's full verdict carries a package's own bytes in a few places: each capability's `excerpt` (the call as
|
|
4
|
+
written) and `target`, the delegation's `module` and `spec`. A tool's answer leaves every one of them out. What it
|
|
5
|
+
keeps from a package is the package's name and version and the file paths of its findings and capabilities, each with
|
|
6
|
+
its line; every other text in it is Attestari's (rule texts, reason codes, the sentences below). An agent reading the
|
|
7
|
+
answer reads nothing a package's publisher wrote but names, versions and paths.
|
|
8
|
+
|
|
9
|
+
"Not examined" is an outcome of its own, and so are a refusal and an outage: no answer is ever an empty success.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
Outcome = Literal["graded", "not_gradeable", "not_a_server", "not_examined", "under_re_examination", "refused", "unavailable"]
|
|
20
|
+
|
|
21
|
+
SENTENCES: dict[str, str] = {
|
|
22
|
+
"graded": "Examined and graded under the methodology named.",
|
|
23
|
+
"not_gradeable": "Examined, but not graded: a guard of the methodology failed and no opinion is given. Not graded is never safe.",
|
|
24
|
+
"not_a_server": "Examined: this package is not an MCP server, which is outside what Attestari grades.",
|
|
25
|
+
"not_examined": "Attestari has not examined this package version. Not examined is never safe.",
|
|
26
|
+
"under_re_examination": "The decision is under re-examination: no grade is served until it is decided again.",
|
|
27
|
+
}
|
|
28
|
+
# The refusals the lookup API names, in Attestari's words.
|
|
29
|
+
REFUSALS: dict[str, str] = {
|
|
30
|
+
"key_required": "The lookup API needs an Attestari API key.",
|
|
31
|
+
"invalid_key": "The Attestari API key was refused: it does not exist or was revoked.",
|
|
32
|
+
"subscription_required": "The organization's plan includes no lookups.",
|
|
33
|
+
"trial_allowance_reached": "The trial's lookups for this month are used; lookups resume when the plan is paid or next month.",
|
|
34
|
+
"rate_limited": "Too many lookups a minute for this key or address; retry after the time given.",
|
|
35
|
+
"bad_request": "The package was not asked for as the lookup API reads it.",
|
|
36
|
+
}
|
|
37
|
+
UNAVAILABLE = "The Attestari lookup API did not answer just now; nothing is known of this package from this call."
|
|
38
|
+
# As the lookup API reads them (attestari_api/app.py: SEVERITY_ORDER, MDT, MDT_CATEGORIES).
|
|
39
|
+
SEVERITY_ORDER = ("info", "low", "medium", "high")
|
|
40
|
+
MDT = "model_directed_text"
|
|
41
|
+
MDT_CATEGORIES = ("exfiltrate_data", "conceal_from_user", "override_user")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Methodology(BaseModel):
|
|
45
|
+
version: str
|
|
46
|
+
document_sha256: str
|
|
47
|
+
url: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FindingRef(BaseModel):
|
|
51
|
+
check: str = Field(description="the check that made the finding (Attestari's name)")
|
|
52
|
+
severity: str
|
|
53
|
+
decision: str = Field(description="confirmed, noted, or withdrawn_on_appeal")
|
|
54
|
+
rule: str = Field(description="the rule, in Attestari's words")
|
|
55
|
+
location: str | None = Field(description="file:line in the package (the path is the package's)")
|
|
56
|
+
evidence_id: str = Field(description="SHA-256 of the evidence the finding was verified against")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CapabilityRef(BaseModel):
|
|
60
|
+
kind: str = Field(description="network, filesystem, process, environment, credentials or remote_code")
|
|
61
|
+
tag: str
|
|
62
|
+
location: str = Field(description="file:line in the package (the path is the package's)")
|
|
63
|
+
evidence_id: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PackageAnswer(BaseModel):
|
|
67
|
+
ecosystem: str
|
|
68
|
+
name: str
|
|
69
|
+
version: str | None = Field(description="the version the answer is about; the version asked for when none was examined")
|
|
70
|
+
outcome: Outcome
|
|
71
|
+
summary: str = Field(description="the outcome in one sentence, in Attestari's words")
|
|
72
|
+
grade: str | None = Field(default=None, description="A, B, C or F when graded; never given otherwise")
|
|
73
|
+
reason_codes: list[str] = Field(default_factory=list[str])
|
|
74
|
+
finding_count: int | None = Field(default=None, description="confirmed findings")
|
|
75
|
+
highest_severity: str | None = None
|
|
76
|
+
classes: list[str] = Field(default_factory=list[str], description="the model-directed categories of the confirmed findings")
|
|
77
|
+
findings: list[FindingRef] = Field(default_factory=list[FindingRef])
|
|
78
|
+
capabilities: list[CapabilityRef] = Field(default_factory=list[CapabilityRef], description="what the source can reach; never a finding")
|
|
79
|
+
evidence_available_on: str | None = Field(default=None, description="when a high or medium finding's evidence is published (day 30)")
|
|
80
|
+
methodology: Methodology | None = None
|
|
81
|
+
run_id: str | None = None
|
|
82
|
+
decided_at: str | None = None
|
|
83
|
+
notice: str | None = Field(default=None, description="set when the newest examination was incomplete on Attestari's side")
|
|
84
|
+
retry_after: int | None = Field(default=None, description="seconds, when the lookup was refused for a rate")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dict(value: Any) -> dict[str, Any]:
|
|
88
|
+
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dicts(value: Any) -> list[dict[str, Any]]:
|
|
92
|
+
return [cast(dict[str, Any], v) for v in cast(list[Any], value) if isinstance(v, dict)] if isinstance(value, list) else []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _strs(value: Any) -> list[str]:
|
|
96
|
+
return [str(v) for v in cast(list[Any], value)] if isinstance(value, list) else []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _str(value: Any) -> str | None:
|
|
100
|
+
return value if isinstance(value, str) else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _int(value: Any) -> int | None:
|
|
104
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _methodology(value: Any) -> Methodology | None:
|
|
108
|
+
m = _dict(value)
|
|
109
|
+
version, sha, url = _str(m.get("version")), _str(m.get("document_sha256")), _str(m.get("url"))
|
|
110
|
+
return Methodology(version=version, document_sha256=sha, url=url) if version and sha and url else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def project(status: int, body: Any, ecosystem: str, name: str, version: str | None) -> PackageAnswer:
|
|
114
|
+
"""The tool's answer to one lookup, from the lookup API's status and body. Unknown shapes are an outage, never a
|
|
115
|
+
success: what cannot be read is not passed on."""
|
|
116
|
+
said = _dict(body)
|
|
117
|
+
kind = said.get("status")
|
|
118
|
+
|
|
119
|
+
def answer(outcome: Outcome, summary: str, served: str | None = version) -> PackageAnswer:
|
|
120
|
+
return PackageAnswer(ecosystem=ecosystem, name=name, version=served, outcome=outcome, summary=summary)
|
|
121
|
+
|
|
122
|
+
if status == 404 and kind == "not_examined":
|
|
123
|
+
return answer("not_examined", SENTENCES["not_examined"])
|
|
124
|
+
if status in (400, 401, 402, 403, 422, 429) or kind in REFUSALS:
|
|
125
|
+
reason = kind if isinstance(kind, str) and kind in REFUSALS else ("rate_limited" if status == 429 else "bad_request")
|
|
126
|
+
return answer("refused", REFUSALS[reason]).model_copy(
|
|
127
|
+
update={"reason_codes": [reason], "retry_after": _int(said.get("retry_after"))}
|
|
128
|
+
)
|
|
129
|
+
subject = _dict(said.get("subject"))
|
|
130
|
+
if status != 200 or not subject:
|
|
131
|
+
return answer("unavailable", UNAVAILABLE)
|
|
132
|
+
|
|
133
|
+
served = _str(subject.get("version")) or version
|
|
134
|
+
methodology = _methodology(said.get("methodology"))
|
|
135
|
+
if kind == "under_re_examination":
|
|
136
|
+
return answer("under_re_examination", SENTENCES["under_re_examination"], served).model_copy(
|
|
137
|
+
update={"methodology": methodology, "run_id": _str(said.get("run_id")), "decided_at": _str(said.get("decided_at"))}
|
|
138
|
+
)
|
|
139
|
+
decision = said.get("decision")
|
|
140
|
+
if kind != "examined" or decision not in ("graded", "not_gradeable", "not_a_server"):
|
|
141
|
+
return answer("unavailable", UNAVAILABLE)
|
|
142
|
+
|
|
143
|
+
outcome = cast(Outcome, decision)
|
|
144
|
+
if not _vouched(
|
|
145
|
+
answer(outcome, SENTENCES[outcome], served).model_copy(
|
|
146
|
+
update={"grade": _str(said.get("grade")) if outcome == "graded" else None, "methodology": methodology}
|
|
147
|
+
),
|
|
148
|
+
subject,
|
|
149
|
+
ecosystem,
|
|
150
|
+
name,
|
|
151
|
+
version,
|
|
152
|
+
):
|
|
153
|
+
return answer("unavailable", UNAVAILABLE)
|
|
154
|
+
base = answer(outcome, SENTENCES[outcome], served).model_copy(
|
|
155
|
+
update={
|
|
156
|
+
"grade": _str(said.get("grade")) if outcome == "graded" else None,
|
|
157
|
+
"methodology": methodology,
|
|
158
|
+
"notice": _str(said.get("notice")),
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if isinstance(said.get("findings"), list):
|
|
163
|
+
# The full verdict, projected: every field that carries a package's own bytes is left out (T8).
|
|
164
|
+
findings = _dicts(said.get("findings"))
|
|
165
|
+
confirmed = [f for f in findings if f.get("decision") == "confirmed"]
|
|
166
|
+
severities = [str(f.get("severity")) for f in confirmed]
|
|
167
|
+
provenance = _dict(said.get("provenance"))
|
|
168
|
+
return base.model_copy(
|
|
169
|
+
update={
|
|
170
|
+
"reason_codes": _strs(said.get("reason_codes")),
|
|
171
|
+
"finding_count": len(confirmed),
|
|
172
|
+
"highest_severity": max(severities, key=_rank) if severities else None,
|
|
173
|
+
"classes": sorted({t for f in confirmed if f.get("check") == MDT for t in _strs(f.get("tags")) if t in MDT_CATEGORIES}),
|
|
174
|
+
"findings": [
|
|
175
|
+
FindingRef(
|
|
176
|
+
check=str(f.get("check")),
|
|
177
|
+
severity=str(f.get("severity")),
|
|
178
|
+
decision=str(f.get("decision")),
|
|
179
|
+
rule=str(f.get("rule")),
|
|
180
|
+
location=_place(f.get("location")),
|
|
181
|
+
evidence_id=str(f.get("evidence_id")),
|
|
182
|
+
)
|
|
183
|
+
for f in findings
|
|
184
|
+
],
|
|
185
|
+
"capabilities": [
|
|
186
|
+
CapabilityRef(
|
|
187
|
+
kind=str(c.get("kind")),
|
|
188
|
+
tag=str(c.get("tag")),
|
|
189
|
+
location=_place(c.get("location")) or "",
|
|
190
|
+
evidence_id=str(c.get("evidence_id")),
|
|
191
|
+
)
|
|
192
|
+
for c in _dicts(said.get("capabilities"))
|
|
193
|
+
],
|
|
194
|
+
"run_id": _str(provenance.get("run_id")),
|
|
195
|
+
"decided_at": _str(provenance.get("decided_at")),
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# The summary: what a key whose evidence waits for day 30 is answered, with the date.
|
|
200
|
+
return base.model_copy(
|
|
201
|
+
update={
|
|
202
|
+
"finding_count": _int(said.get("finding_count")),
|
|
203
|
+
"highest_severity": _str(said.get("highest_severity")),
|
|
204
|
+
"classes": _strs(said.get("classes")),
|
|
205
|
+
"evidence_available_on": _str(said.get("evidence_available_on")),
|
|
206
|
+
"decided_at": _str(said.get("decided_at")),
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
GRADES = ("A", "B", "C", "F")
|
|
212
|
+
PLACE_AT_MOST = 300
|
|
213
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _place(value: Any) -> str | None:
|
|
217
|
+
"""A file:line from a package: its publisher's text, bounded and without control characters (M-12)."""
|
|
218
|
+
text = _str(value)
|
|
219
|
+
return None if text is None else _CONTROL.sub("", text)[:PLACE_AT_MOST]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _normal(ecosystem: str, name: str) -> str:
|
|
223
|
+
return re.sub(r"[-_.]+", "-", name).lower() if ecosystem == "pypi" else name
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _vouched(answer: PackageAnswer, subject: dict[str, Any], ecosystem: str, name: str, version: str | None) -> bool:
|
|
227
|
+
"""What a "graded" answer must hold before it is passed on (M-5): a grade of the ladder, the methodology, and the
|
|
228
|
+
package and version asked about."""
|
|
229
|
+
said_name, said_version = _str(subject.get("name")), _str(subject.get("version"))
|
|
230
|
+
if said_name is None or _normal(ecosystem, said_name) != _normal(ecosystem, name) or subject.get("ecosystem") != ecosystem:
|
|
231
|
+
return False
|
|
232
|
+
if version is not None and said_version != version:
|
|
233
|
+
return False
|
|
234
|
+
if answer.outcome == "graded" and (answer.grade not in GRADES or answer.methodology is None):
|
|
235
|
+
return False
|
|
236
|
+
return answer.methodology is not None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _rank(severity: str) -> int:
|
|
240
|
+
return SEVERITY_ORDER.index(severity) if severity in SEVERITY_ORDER else len(SEVERITY_ORDER)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Asking the lookup API (https://api.attestari.ai) about a package, and nothing else.
|
|
2
|
+
|
|
3
|
+
Two ways, one for each way the server runs:
|
|
4
|
+
|
|
5
|
+
- **Direct** (the package run locally, over stdio): the customer's key in `Authorization`, as a program of theirs
|
|
6
|
+
would send it. The lookup API counts and limits the call as any keyed call, against the customer's own address.
|
|
7
|
+
- **On an agent's behalf** (the remote server at mcp.attestari.ai; plan decision 11): the MCP server's own Google
|
|
8
|
+
identity token in `Authorization`, and the agent's key (`Attestari-Agent: key att_live_...`) and address
|
|
9
|
+
(`Attestari-Agent-Address`) beside it. The lookup API believes those two headers from the MCP server's service
|
|
10
|
+
account alone, and every bucket it then takes is the agent's: its key's, or its address's for a key refused or not
|
|
11
|
+
yet remembered. The MCP server's own address is charged nothing.
|
|
12
|
+
|
|
13
|
+
What is sent is the ecosystem, the name and the version (price book line 2). Nothing here logs a key or a token.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
from urllib.parse import quote
|
|
27
|
+
|
|
28
|
+
import httpx2
|
|
29
|
+
|
|
30
|
+
from attestari_mcp import __version__
|
|
31
|
+
from attestari_mcp.answer import PackageAnswer, project
|
|
32
|
+
|
|
33
|
+
KEY = re.compile(r"^att_live_[0-9A-Za-z]{43}$")
|
|
34
|
+
# What the lookup API can be asked about, checked before anything is sent: a name or version of another shape is
|
|
35
|
+
# answered "refused" here and never reaches the API.
|
|
36
|
+
# npm's own rule (MCP review M-4): no name or scope begins with "." or "_", so no segment is "." or "..".
|
|
37
|
+
NPM_NAME = re.compile(r"^(@[A-Za-z0-9~-][A-Za-z0-9._~-]{0,212}/)?[A-Za-z0-9~-][A-Za-z0-9._~-]{0,213}$")
|
|
38
|
+
PYPI_NAME = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,198}[A-Za-z0-9])?$")
|
|
39
|
+
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+_!-]{0,99}$")
|
|
40
|
+
ECOSYSTEMS = ("npm", "pypi")
|
|
41
|
+
AT_ONCE = 4
|
|
42
|
+
TIMEOUT_SECONDS = 10.0
|
|
43
|
+
METADATA_IDENTITY_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
|
|
44
|
+
TOKEN_MARGIN_SECONDS = 300.0
|
|
45
|
+
# A refusal that names the key, not the package: the rest of a batch is answered the same without being sent (M-1).
|
|
46
|
+
KEY_REFUSALS = ("key_required", "invalid_key", "subscription_required", "trial_allowance_reached")
|
|
47
|
+
LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger("attestari_mcp")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Direct:
|
|
54
|
+
key: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class OnBehalf:
|
|
59
|
+
"""`agent` is what the lookup API is told the call is for: `key att_live_...` (and, from milestone 1.3, `grant
|
|
60
|
+
<key row id>`); `address` is the agent's address as the MCP server's balancer saw it."""
|
|
61
|
+
|
|
62
|
+
agent: str
|
|
63
|
+
address: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Credential = Direct | OnBehalf
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Identity(Protocol):
|
|
70
|
+
async def token(self) -> str: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def path_of(ecosystem: str, name: str, version: str | None) -> str | None:
|
|
74
|
+
"""The lookup API's path for the package, or None when the package cannot be one of its ecosystem's."""
|
|
75
|
+
if ecosystem not in ECOSYSTEMS or len(name) > 214:
|
|
76
|
+
return None
|
|
77
|
+
if not (NPM_NAME if ecosystem == "npm" else PYPI_NAME).match(name):
|
|
78
|
+
return None
|
|
79
|
+
if version is not None and not VERSION.match(version):
|
|
80
|
+
return None
|
|
81
|
+
# A scoped npm name keeps its one slash: the API reads /v1/verdict/npm/@scope/name[/version].
|
|
82
|
+
path = f"/v1/verdict/{ecosystem}/" + "/".join(quote(part, safe="@") for part in name.split("/"))
|
|
83
|
+
path = path if version is None else f"{path}/{quote(version, safe='')}"
|
|
84
|
+
# The path the HTTP client will send is the path built, or nothing is sent (M-4).
|
|
85
|
+
return path if httpx2.URL("https://lookup.invalid" + path).raw_path.decode("ascii") == path else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def secure_base(url: str) -> bool:
|
|
89
|
+
"""The lookup API's address takes a key only over https, or plain http to this machine (M-11)."""
|
|
90
|
+
parsed = httpx2.URL(url)
|
|
91
|
+
return parsed.scheme == "https" or (parsed.scheme == "http" and parsed.host in LOCAL_HOSTS)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Lookup:
|
|
95
|
+
def __init__(self, base_url: str, http: httpx2.AsyncClient, identity: Identity | None = None) -> None:
|
|
96
|
+
if not secure_base(base_url):
|
|
97
|
+
raise ValueError("the lookup API's address must be https")
|
|
98
|
+
self.base_url = base_url.rstrip("/")
|
|
99
|
+
self.http = http
|
|
100
|
+
self.identity = identity
|
|
101
|
+
|
|
102
|
+
async def one(self, ecosystem: str, name: str, version: str | None, credential: Credential) -> PackageAnswer:
|
|
103
|
+
path = path_of(ecosystem, name, version)
|
|
104
|
+
if path is None:
|
|
105
|
+
return project(400, {"status": "bad_request"}, ecosystem, name, version)
|
|
106
|
+
headers = {"Accept": "application/json", "User-Agent": f"attestari-mcp/{__version__}"}
|
|
107
|
+
if isinstance(credential, Direct):
|
|
108
|
+
headers["Authorization"] = f"Bearer {credential.key}"
|
|
109
|
+
else:
|
|
110
|
+
if self.identity is None:
|
|
111
|
+
return project(503, None, ecosystem, name, version)
|
|
112
|
+
try:
|
|
113
|
+
headers["Authorization"] = f"Bearer {await self.identity.token()}"
|
|
114
|
+
except (httpx2.HTTPError, ValueError, KeyError):
|
|
115
|
+
return project(503, None, ecosystem, name, version)
|
|
116
|
+
headers["Attestari-Agent"] = credential.agent
|
|
117
|
+
headers["Attestari-Agent-Address"] = credential.address
|
|
118
|
+
try:
|
|
119
|
+
answer = await self.http.get(self.base_url + path, headers=headers, timeout=TIMEOUT_SECONDS)
|
|
120
|
+
except httpx2.HTTPError:
|
|
121
|
+
return project(503, None, ecosystem, name, version)
|
|
122
|
+
try:
|
|
123
|
+
body: Any = answer.json()
|
|
124
|
+
except ValueError:
|
|
125
|
+
body = None
|
|
126
|
+
if isinstance(credential, OnBehalf) and answer.status_code == 400:
|
|
127
|
+
# The lookup API refused the MCP server's own call: a fault of ours, never the agent's request (M-6).
|
|
128
|
+
log.error(json.dumps({"message": "the lookup API refused the MCP server's call on an agent's behalf", "status": 400}))
|
|
129
|
+
return project(503, None, ecosystem, name, version)
|
|
130
|
+
return project(answer.status_code, body, ecosystem, name, version)
|
|
131
|
+
|
|
132
|
+
async def many(self, asked: list[tuple[str, str, str | None]], credential: Credential) -> list[PackageAnswer]:
|
|
133
|
+
"""Each package one lookup, a few at a time, answered in the order asked. The first is asked alone: when the
|
|
134
|
+
key itself is refused, the rest are answered the same and never sent (M-1)."""
|
|
135
|
+
if not asked:
|
|
136
|
+
return []
|
|
137
|
+
first = await self.one(*asked[0], credential)
|
|
138
|
+
refused = [r for r in first.reason_codes if r in KEY_REFUSALS] if first.outcome == "refused" else []
|
|
139
|
+
if refused:
|
|
140
|
+
return [first] + [project(401, {"status": refused[0]}, ecosystem, name, version) for ecosystem, name, version in asked[1:]]
|
|
141
|
+
gate = asyncio.Semaphore(AT_ONCE)
|
|
142
|
+
|
|
143
|
+
async def each(one: tuple[str, str, str | None]) -> PackageAnswer:
|
|
144
|
+
async with gate:
|
|
145
|
+
return await self.one(*one, credential)
|
|
146
|
+
|
|
147
|
+
return [first, *await asyncio.gather(*(each(one) for one in asked[1:]))]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class MetadataIdentity:
|
|
151
|
+
"""The MCP server's own Google identity token for the lookup API (Cloud Run's metadata server), kept until five
|
|
152
|
+
minutes before it expires."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, audience: str, http: httpx2.AsyncClient) -> None:
|
|
155
|
+
self.audience = audience
|
|
156
|
+
self.http = http
|
|
157
|
+
self._token: str | None = None
|
|
158
|
+
self._expires = 0.0
|
|
159
|
+
self._lock = asyncio.Lock()
|
|
160
|
+
|
|
161
|
+
async def token(self) -> str:
|
|
162
|
+
async with self._lock:
|
|
163
|
+
if self._token is None or time.time() > self._expires - TOKEN_MARGIN_SECONDS:
|
|
164
|
+
answer = await self.http.get(
|
|
165
|
+
METADATA_IDENTITY_URL,
|
|
166
|
+
params={"audience": self.audience, "format": "full"},
|
|
167
|
+
headers={"Metadata-Flavor": "Google"},
|
|
168
|
+
timeout=TIMEOUT_SECONDS,
|
|
169
|
+
)
|
|
170
|
+
answer.raise_for_status()
|
|
171
|
+
self._token = answer.text.strip()
|
|
172
|
+
self._expires = float(_claims(self._token)["exp"])
|
|
173
|
+
return self._token
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _claims(token: str) -> dict[str, Any]:
|
|
177
|
+
"""The payload of a JWT, read without checking it: only to know when our own token expires."""
|
|
178
|
+
payload = token.split(".")[1]
|
|
179
|
+
claims: Any = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
180
|
+
if not isinstance(claims, dict):
|
|
181
|
+
raise ValueError("not a token")
|
|
182
|
+
return claims # pyright: ignore[reportUnknownVariableType]
|