abom-cli 0.1.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.
- abom/__init__.py +3 -0
- abom/agents.py +58 -0
- abom/api.py +177 -0
- abom/audit.py +122 -0
- abom/bom.py +147 -0
- abom/cli.py +130 -0
- abom/config.py +37 -0
- abom/db.py +127 -0
- abom/execution.py +73 -0
- abom/models_router.py +84 -0
- abom/orchestration.py +237 -0
- abom/policy.py +46 -0
- abom/scan.py +207 -0
- abom/schemas.py +79 -0
- abom/sign.py +91 -0
- abom_cli-0.1.0.dist-info/METADATA +108 -0
- abom_cli-0.1.0.dist-info/RECORD +19 -0
- abom_cli-0.1.0.dist-info/WHEEL +4 -0
- abom_cli-0.1.0.dist-info/entry_points.txt +2 -0
abom/scan.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""abom scan — statically detect an AI agent's components from a repository.
|
|
2
|
+
|
|
3
|
+
Produces the `agent`, `components`, and `controls` of an ABOM Composition
|
|
4
|
+
Manifest by inspecting dependency manifests, source, prompt files, and MCP
|
|
5
|
+
configs. Pure stdlib. Best-effort and conservative: it reports what it
|
|
6
|
+
can see, with where it saw it (`detected_from`).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import tomllib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# --- known signals -----------------------------------------------------------
|
|
17
|
+
FRAMEWORKS = {
|
|
18
|
+
"langchain": "LangChain", "langchain-core": "LangChain", "langgraph": "LangGraph",
|
|
19
|
+
"crewai": "CrewAI", "llama-index": "LlamaIndex", "llama_index": "LlamaIndex",
|
|
20
|
+
"autogen": "AutoGen", "pyautogen": "AutoGen", "autogen-agentchat": "AutoGen",
|
|
21
|
+
"semantic-kernel": "Semantic Kernel", "haystack-ai": "Haystack",
|
|
22
|
+
"smolagents": "smolagents", "openai-agents": "OpenAI Agents SDK",
|
|
23
|
+
"google-adk": "Google ADK", "google-genai": "Google GenAI", "mcp": "Model Context Protocol",
|
|
24
|
+
"dspy": "DSPy", "dspy-ai": "DSPy", "pydantic-ai": "Pydantic AI",
|
|
25
|
+
}
|
|
26
|
+
MODEL_SDKS = {
|
|
27
|
+
"openai": "OpenAI", "anthropic": "Anthropic", "mistralai": "Mistral",
|
|
28
|
+
"cohere": "Cohere", "google-generativeai": "Google", "vertexai": "Google Vertex",
|
|
29
|
+
"boto3": "AWS Bedrock", "ollama": "Ollama", "vllm": "vLLM",
|
|
30
|
+
"huggingface-hub": "Hugging Face", "transformers": "Hugging Face",
|
|
31
|
+
"groq": "Groq", "together": "Together", "replicate": "Replicate",
|
|
32
|
+
}
|
|
33
|
+
VECTOR_STORES = {
|
|
34
|
+
"chromadb": "Chroma", "pinecone-client": "Pinecone", "pinecone": "Pinecone",
|
|
35
|
+
"weaviate-client": "Weaviate", "qdrant-client": "Qdrant", "faiss-cpu": "FAISS",
|
|
36
|
+
"faiss-gpu": "FAISS", "pgvector": "pgvector", "pymilvus": "Milvus", "lancedb": "LanceDB",
|
|
37
|
+
}
|
|
38
|
+
GUARDRAILS = {
|
|
39
|
+
"guardrails-ai": "Guardrails AI", "nemoguardrails": "NeMo Guardrails",
|
|
40
|
+
"llm-guard": "LLM Guard", "presidio-analyzer": "Presidio", "rebuff": "Rebuff",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
MODEL_NAME_RE = re.compile(
|
|
44
|
+
r"\b(gpt-4[\w.\-]*|gpt-3\.5[\w.\-]*|o[134][\w.\-]*|chatgpt[\w.\-]*|"
|
|
45
|
+
r"claude-[\w.\-]+|gemini-[\w.\-]+|mistral[\w.\-]*|mixtral[\w.\-]*|"
|
|
46
|
+
r"llama-?[23][\w.\-]*|qwen[\w.\-]*|command-r[\w.\-]*|deepseek[\w.\-]*|phi-?[34][\w.\-]*)\b",
|
|
47
|
+
re.IGNORECASE,
|
|
48
|
+
)
|
|
49
|
+
TOOL_RE = re.compile(r"@(?:tool|function_tool|tool\([^)]*\))\s*(?:\n\s*)*def\s+(\w+)|"
|
|
50
|
+
r"@(?:tool|function_tool)\b[\s\S]{0,80}?def\s+(\w+)")
|
|
51
|
+
MCP_CONFIG_NAMES = {"mcp.json", ".mcp.json", "claude_desktop_config.json", "mcp_config.json", ".cursor/mcp.json"}
|
|
52
|
+
IGNORE_DIRS = {".git", "node_modules", ".venv", "venv", "env", "__pycache__", "dist", "build",
|
|
53
|
+
".mypy_cache", ".pytest_cache", ".tox", ".ruff_cache", "site-packages", ".next"}
|
|
54
|
+
TEXT_EXT = {".py", ".js", ".ts", ".tsx", ".yaml", ".yml", ".json", ".toml", ".env", ".cfg", ".ini", ".md", ".txt"}
|
|
55
|
+
MAX_FILE = 1_000_000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read(p: Path) -> str:
|
|
59
|
+
try:
|
|
60
|
+
return p.read_text(encoding="utf-8", errors="ignore")
|
|
61
|
+
except Exception:
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sha256(text: str) -> str:
|
|
66
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _iter_files(root: Path):
|
|
70
|
+
for p in root.rglob("*"):
|
|
71
|
+
if not p.is_file():
|
|
72
|
+
continue
|
|
73
|
+
if any(part in IGNORE_DIRS for part in p.parts):
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
if p.stat().st_size > MAX_FILE:
|
|
77
|
+
continue
|
|
78
|
+
except OSError:
|
|
79
|
+
continue
|
|
80
|
+
yield p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _norm_dep(name: str) -> str:
|
|
84
|
+
return re.split(r"[<>=!~ ;\[]", name.strip(), maxsplit=1)[0].strip().lower().replace("_", "-")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_dependencies(root: Path) -> set[str]:
|
|
88
|
+
deps: set[str] = set()
|
|
89
|
+
for req in list(root.glob("requirements*.txt")) + list(root.glob("**/requirements*.txt")):
|
|
90
|
+
if any(part in IGNORE_DIRS for part in req.parts):
|
|
91
|
+
continue
|
|
92
|
+
for line in _read(req).splitlines():
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if line and not line.startswith(("#", "-")):
|
|
95
|
+
deps.add(_norm_dep(line))
|
|
96
|
+
pp = root / "pyproject.toml"
|
|
97
|
+
if pp.exists():
|
|
98
|
+
try:
|
|
99
|
+
data = tomllib.loads(_read(pp))
|
|
100
|
+
for d in data.get("project", {}).get("dependencies", []) or []:
|
|
101
|
+
deps.add(_norm_dep(d))
|
|
102
|
+
poetry = data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
103
|
+
for d in poetry:
|
|
104
|
+
deps.add(_norm_dep(d))
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
pkg = root / "package.json"
|
|
108
|
+
if pkg.exists():
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(_read(pkg))
|
|
111
|
+
for section in ("dependencies", "devDependencies"):
|
|
112
|
+
deps.update(_norm_dep(k) for k in data.get(section, {}))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
deps.discard("")
|
|
116
|
+
return deps
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _agent_meta(root: Path) -> dict:
|
|
120
|
+
name, version = root.resolve().name, "0.0.0"
|
|
121
|
+
pp = root / "pyproject.toml"
|
|
122
|
+
if pp.exists():
|
|
123
|
+
try:
|
|
124
|
+
data = tomllib.loads(_read(pp))
|
|
125
|
+
proj = data.get("project", {})
|
|
126
|
+
name = proj.get("name", name)
|
|
127
|
+
version = proj.get("version", version)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
pkg = root / "package.json"
|
|
131
|
+
if pkg.exists():
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(_read(pkg))
|
|
134
|
+
name = data.get("name", name)
|
|
135
|
+
version = data.get("version", version)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
return {"name": name, "version": version}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def scan(root: Path) -> dict:
|
|
142
|
+
root = Path(root)
|
|
143
|
+
deps = parse_dependencies(root)
|
|
144
|
+
components: list[dict] = []
|
|
145
|
+
seen: set[tuple] = set()
|
|
146
|
+
|
|
147
|
+
def add(comp: dict):
|
|
148
|
+
key = (comp["type"], comp.get("name"))
|
|
149
|
+
if key not in seen:
|
|
150
|
+
seen.add(key)
|
|
151
|
+
components.append(comp)
|
|
152
|
+
|
|
153
|
+
# 1. from dependencies
|
|
154
|
+
for d in sorted(deps):
|
|
155
|
+
if d in FRAMEWORKS:
|
|
156
|
+
add({"type": "framework", "name": FRAMEWORKS[d], "detected_from": f"dependency:{d}"})
|
|
157
|
+
if d in MODEL_SDKS:
|
|
158
|
+
add({"type": "model", "name": f"{MODEL_SDKS[d]} (SDK)", "provider": MODEL_SDKS[d],
|
|
159
|
+
"egress": d not in ("vllm", "ollama", "transformers"),
|
|
160
|
+
"detected_from": f"dependency:{d}"})
|
|
161
|
+
if d in VECTOR_STORES:
|
|
162
|
+
add({"type": "dataSource", "kind": "vector-store", "name": VECTOR_STORES[d],
|
|
163
|
+
"detected_from": f"dependency:{d}"})
|
|
164
|
+
if d in GUARDRAILS:
|
|
165
|
+
add({"type": "policy", "kind": "guardrail", "name": GUARDRAILS[d],
|
|
166
|
+
"detected_from": f"dependency:{d}"})
|
|
167
|
+
|
|
168
|
+
# 2. concrete model names, prompt files, tools, MCP servers — from source
|
|
169
|
+
model_names: set[str] = set()
|
|
170
|
+
for p in _iter_files(root):
|
|
171
|
+
suffix = p.suffix.lower()
|
|
172
|
+
rel = str(p.relative_to(root))
|
|
173
|
+
# prompt files
|
|
174
|
+
if suffix == ".prompt" or "prompt" in p.parent.name.lower() and suffix in (".txt", ".md"):
|
|
175
|
+
text = _read(p)
|
|
176
|
+
add({"type": "prompt", "name": rel, "sha256": _sha256(text),
|
|
177
|
+
"detected_from": f"file:{rel}"})
|
|
178
|
+
# mcp configs
|
|
179
|
+
if p.name in MCP_CONFIG_NAMES or rel in MCP_CONFIG_NAMES:
|
|
180
|
+
try:
|
|
181
|
+
cfg = json.loads(_read(p))
|
|
182
|
+
for server in (cfg.get("mcpServers") or {}):
|
|
183
|
+
add({"type": "mcpServer", "name": server, "detected_from": f"file:{rel}"})
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
if suffix not in TEXT_EXT:
|
|
187
|
+
continue
|
|
188
|
+
text = _read(p)
|
|
189
|
+
for m in MODEL_NAME_RE.findall(text):
|
|
190
|
+
model_names.add(m if isinstance(m, str) else next(filter(None, m)))
|
|
191
|
+
if suffix == ".py" and ("@tool" in text or "@function_tool" in text):
|
|
192
|
+
for groups in TOOL_RE.findall(text):
|
|
193
|
+
fn = next((g for g in groups if g), None)
|
|
194
|
+
if fn:
|
|
195
|
+
add({"type": "tool", "name": fn, "detected_from": f"file:{rel}"})
|
|
196
|
+
|
|
197
|
+
for name in sorted(model_names):
|
|
198
|
+
provider = "external" if not name.lower().startswith(("llama", "qwen", "mistral", "mixtral", "phi", "deepseek")) else "open-weight"
|
|
199
|
+
add({"type": "model", "name": name, "provenance": "referenced in source",
|
|
200
|
+
"egress": provider == "external", "detected_from": "source"})
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"agent": _agent_meta(root),
|
|
204
|
+
"components": components,
|
|
205
|
+
"controls": {"scan": "static", "scanned_path": str(root.resolve().name),
|
|
206
|
+
"egress": "unknown (static scan)"},
|
|
207
|
+
}
|
abom/schemas.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Pydantic request/response models for the Control API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectCreate(BaseModel):
|
|
12
|
+
name: str
|
|
13
|
+
repo_url: str
|
|
14
|
+
test_command: str = "pytest -q"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectOut(BaseModel):
|
|
18
|
+
id: uuid.UUID
|
|
19
|
+
name: str
|
|
20
|
+
repo_url: str
|
|
21
|
+
test_command: str
|
|
22
|
+
|
|
23
|
+
model_config = {"from_attributes": True}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunCreate(BaseModel):
|
|
27
|
+
project_id: uuid.UUID
|
|
28
|
+
intent: str = Field(min_length=3)
|
|
29
|
+
workload_type: Literal["dev_agent"] = "dev_agent"
|
|
30
|
+
max_iterations: int = Field(default=4, ge=1, le=8)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RunOut(BaseModel):
|
|
34
|
+
id: uuid.UUID
|
|
35
|
+
project_id: uuid.UUID
|
|
36
|
+
intent: str
|
|
37
|
+
status: str
|
|
38
|
+
model: str
|
|
39
|
+
max_iterations: int
|
|
40
|
+
prompt_tokens: int
|
|
41
|
+
completion_tokens: int
|
|
42
|
+
created_at: datetime
|
|
43
|
+
updated_at: datetime
|
|
44
|
+
|
|
45
|
+
model_config = {"from_attributes": True}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StepOut(BaseModel):
|
|
49
|
+
seq: int
|
|
50
|
+
type: str
|
|
51
|
+
payload: dict[str, Any]
|
|
52
|
+
created_at: datetime
|
|
53
|
+
|
|
54
|
+
model_config = {"from_attributes": True}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ApprovalDecision(BaseModel):
|
|
58
|
+
decision: Literal["approved", "rejected"]
|
|
59
|
+
comment: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuditEventOut(BaseModel):
|
|
63
|
+
seq: int
|
|
64
|
+
event_type: str
|
|
65
|
+
actor: str
|
|
66
|
+
data: dict[str, Any]
|
|
67
|
+
prev_hash: str
|
|
68
|
+
hash: str
|
|
69
|
+
created_at: datetime
|
|
70
|
+
|
|
71
|
+
model_config = {"from_attributes": True}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class VerifyResult(BaseModel):
|
|
75
|
+
run_id: uuid.UUID
|
|
76
|
+
valid: bool
|
|
77
|
+
broken_seq: int | None = None
|
|
78
|
+
reason: str | None = None
|
|
79
|
+
event_count: int
|
abom/sign.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""ed25519 signing for ABOM — real detached signatures over canonical JSON.
|
|
2
|
+
|
|
3
|
+
A signing key lives at ``~/.abom/signing_key.pem`` (override with ``ABOM_KEY``).
|
|
4
|
+
The public key and a short key id are embedded in the signature, so verification
|
|
5
|
+
is self-contained. In production a Notary / key registry pins the set of trusted
|
|
6
|
+
key ids; ``verify_obj(obj, trusted_keys=...)`` enforces that.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
17
|
+
Ed25519PrivateKey,
|
|
18
|
+
Ed25519PublicKey,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .audit import canonical_json
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_key_path() -> Path:
|
|
25
|
+
return Path(os.environ.get("ABOM_KEY", str(Path.home() / ".abom" / "signing_key.pem")))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_or_create_key(path: Path | None = None) -> Ed25519PrivateKey:
|
|
29
|
+
path = Path(path or default_key_path())
|
|
30
|
+
if path.exists():
|
|
31
|
+
return serialization.load_pem_private_key(path.read_bytes(), password=None)
|
|
32
|
+
key = Ed25519PrivateKey.generate()
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
path.write_bytes(
|
|
35
|
+
key.private_bytes(
|
|
36
|
+
serialization.Encoding.PEM,
|
|
37
|
+
serialization.PrivateFormat.PKCS8,
|
|
38
|
+
serialization.NoEncryption(),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
os.chmod(path, 0o600)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pub_b64(pub: Ed25519PublicKey) -> str:
|
|
49
|
+
raw = pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
50
|
+
return base64.b64encode(raw).decode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def key_id(pub_b64: str) -> str:
|
|
54
|
+
return hashlib.sha256(base64.b64decode(pub_b64)).hexdigest()[:16]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sign_obj(obj: dict, key: Ed25519PrivateKey | None = None) -> dict:
|
|
58
|
+
"""Return ``obj`` with a detached ed25519 ``signature`` over its canonical form."""
|
|
59
|
+
key = key or load_or_create_key()
|
|
60
|
+
body = {k: v for k, v in obj.items() if k != "signature"}
|
|
61
|
+
sig = key.sign(canonical_json(body).encode("utf-8"))
|
|
62
|
+
pub_b64 = _pub_b64(key.public_key())
|
|
63
|
+
return {
|
|
64
|
+
**obj,
|
|
65
|
+
"signature": {
|
|
66
|
+
"alg": "ed25519",
|
|
67
|
+
"public_key": pub_b64,
|
|
68
|
+
"key_id": key_id(pub_b64),
|
|
69
|
+
"value": base64.b64encode(sig).decode(),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_obj(obj: dict, trusted_keys: set[str] | None = None) -> bool:
|
|
75
|
+
"""Verify the embedded ed25519 signature. If ``trusted_keys`` is given, the
|
|
76
|
+
signer's key id must be in it."""
|
|
77
|
+
sig = obj.get("signature") or {}
|
|
78
|
+
if sig.get("alg") != "ed25519":
|
|
79
|
+
return False
|
|
80
|
+
pub_b64, value = sig.get("public_key"), sig.get("value")
|
|
81
|
+
if not pub_b64 or not value:
|
|
82
|
+
return False
|
|
83
|
+
if trusted_keys is not None and key_id(pub_b64) not in trusted_keys:
|
|
84
|
+
return False
|
|
85
|
+
try:
|
|
86
|
+
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64))
|
|
87
|
+
body = {k: v for k, v in obj.items() if k != "signature"}
|
|
88
|
+
pub.verify(base64.b64decode(value), canonical_json(body).encode("utf-8"))
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abom-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ABOM — the Agent Bill of Materials. Scan, sign, and verify what your AI agents are made of.
|
|
5
|
+
Project-URL: Homepage, https://abom.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/josephassiga/abom
|
|
7
|
+
Project-URL: Specification, https://github.com/josephassiga/abom/tree/main/spec
|
|
8
|
+
Author: ABOM Contributors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: agent,ai,ai-security,cyclonedx,llm,ml-bom,provenance,sbom,supply-chain
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: cryptography>=42.0
|
|
19
|
+
Requires-Dist: typer>=0.12
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
22
|
+
Requires-Dist: jsonschema>=4.21; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.7; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
26
|
+
Provides-Extra: server
|
|
27
|
+
Requires-Dist: alembic>=1.13; extra == 'server'
|
|
28
|
+
Requires-Dist: asyncpg>=0.30; extra == 'server'
|
|
29
|
+
Requires-Dist: boto3>=1.35; extra == 'server'
|
|
30
|
+
Requires-Dist: fastapi>=0.115; extra == 'server'
|
|
31
|
+
Requires-Dist: httpx>=0.27; extra == 'server'
|
|
32
|
+
Requires-Dist: pydantic-settings>=2.6; extra == 'server'
|
|
33
|
+
Requires-Dist: pydantic>=2.9; extra == 'server'
|
|
34
|
+
Requires-Dist: python-jose[cryptography]>=3.3; extra == 'server'
|
|
35
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'server'
|
|
36
|
+
Requires-Dist: temporalio>=1.8; extra == 'server'
|
|
37
|
+
Requires-Dist: uvicorn[standard]>=0.32; extra == 'server'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# abom-cli
|
|
41
|
+
|
|
42
|
+
The reference implementation of [ABOM](../spec/) — the Agent Bill of Materials.
|
|
43
|
+
Scan a repo, emit a **signed** Composition Manifest, and **verify** it.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install abom-cli # (until published: pip install -e .)
|
|
47
|
+
abom scan . # → abom.json (signed with ed25519)
|
|
48
|
+
abom verify abom.json # check signature
|
|
49
|
+
abom verify abom.json --policy policy.json # + enforce a policy (exit 1 on violations)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
| Command | What it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `abom scan [PATH]` | Detect agent components (models, prompts, tools, MCP servers, frameworks, vector stores, guardrails) and emit a signed Composition Manifest. `-o -` writes to stdout. |
|
|
57
|
+
| `abom verify [FILE]` | Verify the ed25519 signature; with `--policy`, enforce model allowlist / residency / egress / approval rules. Non-zero exit on findings (CI-friendly). |
|
|
58
|
+
| `abom keygen` | Show (or create) the local ed25519 signing key (`~/.abom/signing_key.pem`, override with `ABOM_KEY`). |
|
|
59
|
+
| `abom version` | Print the tool and spec versions. |
|
|
60
|
+
|
|
61
|
+
### Example
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ abom scan .
|
|
65
|
+
ABOM · my-agent @ 1.2.0
|
|
66
|
+
models 3 gpt-4o-mini, claude-3-5-sonnet, OpenAI (SDK)
|
|
67
|
+
frameworks 2 LangChain, LangGraph
|
|
68
|
+
MCP servers 2 filesystem, github
|
|
69
|
+
tools 1 lookup_customer
|
|
70
|
+
prompts 1 prompts/system.txt
|
|
71
|
+
signed: ed25519 · key 5846eabc738b3542
|
|
72
|
+
→ wrote abom.json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How detection works
|
|
76
|
+
|
|
77
|
+
`abom scan` is a static scanner (pure stdlib + `cryptography`):
|
|
78
|
+
- **Dependencies** (`requirements*.txt`, `pyproject.toml`, `package.json`) → frameworks, model SDKs, vector stores, guardrails.
|
|
79
|
+
- **Source** → concrete model names (`gpt-4o`, `claude-*`, …) and `@tool`-decorated functions.
|
|
80
|
+
- **Prompt files** (`*.prompt`, `prompts/*.txt|md`) → hashed.
|
|
81
|
+
- **MCP configs** (`mcp.json`, `claude_desktop_config.json`, …) → MCP servers.
|
|
82
|
+
|
|
83
|
+
Each component records `detected_from` so the manifest is auditable. The output
|
|
84
|
+
validates against [`spec/abom-0.1.schema.json`](../spec/abom-0.1.schema.json).
|
|
85
|
+
|
|
86
|
+
## Signing
|
|
87
|
+
|
|
88
|
+
`abom scan` signs with **ed25519** (`cryptography`). The key lives at
|
|
89
|
+
`~/.abom/signing_key.pem` (override with `ABOM_KEY`); the public key + a short
|
|
90
|
+
`key_id` are embedded so `abom verify` is self-contained. A Notary / key registry
|
|
91
|
+
pins trusted key ids in production.
|
|
92
|
+
|
|
93
|
+
## Dev
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
make install # pip install -e ".[dev]"
|
|
97
|
+
make test # pytest (audit chain, scanner, signing)
|
|
98
|
+
make scan && make verify
|
|
99
|
+
make build # wheel + sdist + twine check
|
|
100
|
+
python demo/demo.py # generate → verify → tamper-evidence walkthrough
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## What else is in this package
|
|
104
|
+
|
|
105
|
+
`src/abom/` also contains a **prototype control-plane** (`api.py`, `db.py`,
|
|
106
|
+
`orchestration.py`, the Notary) behind the optional `[server]` extra — the
|
|
107
|
+
beginnings of the commercial layer. It is **not** required for `scan`/`verify`
|
|
108
|
+
and is not part of the v0.1 spec. See [MVP_SPEC.md](MVP_SPEC.md).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
abom/__init__.py,sha256=ZMeFiJTyfJkr7vXP-G2aGc3a6iIVrmMTeGX5auXz-v0,101
|
|
2
|
+
abom/agents.py,sha256=kgzkSIukMybcCDOyZoN8Xm45Ly2kolP9fUvmMvWyGp0,1999
|
|
3
|
+
abom/api.py,sha256=hek2SCygDxq6r17xMbOIIjU2UXAvkrjVhSvgUUkPR2Q,6769
|
|
4
|
+
abom/audit.py,sha256=ZzNjvJNNMXK3KYMdcgUzP5fVi5qePP3lp_2Jk48OBcg,4128
|
|
5
|
+
abom/bom.py,sha256=ot5cKd1ngkXxkNGpiGHr3kmjXmyMRK-p1CDe7PjY-bY,6771
|
|
6
|
+
abom/cli.py,sha256=0zToq9X8xvJgdlWbnY4UE-3RXLfVqh98Tutgnd9kJC8,4976
|
|
7
|
+
abom/config.py,sha256=4tEwFglXjVKA82f8hOrG_nv96WN3TQ_tKua24X5gW4I,1233
|
|
8
|
+
abom/db.py,sha256=Qq65ExI5Hvoosn8MCm1hpTXePnjuKK6TX82bOPYy5-o,5769
|
|
9
|
+
abom/execution.py,sha256=V-pijLQVwu25c3nta8377Ykm1yRpPoCwHhJZ99SxAe8,2699
|
|
10
|
+
abom/models_router.py,sha256=OfbE0cFHVejAe4gUno_uIBxDyjyg6rNsRSJxNdl0hPo,2927
|
|
11
|
+
abom/orchestration.py,sha256=s0IhJSVYyRDJRoLSTTSD6VhrffgS7V_CYiNOQ8uCeZE,10135
|
|
12
|
+
abom/policy.py,sha256=cgad67d8ycEHiELaELed8tyf1-i0S3citG6AV1OUXng,1471
|
|
13
|
+
abom/scan.py,sha256=1Kijg7FSSejd4IHwo11CUzaCbZT-QqvMsrEdhVYp_xQ,8379
|
|
14
|
+
abom/schemas.py,sha256=L46khKUR4pKfXiWFAXSPkUNlKgDOBBWhWLqyPQTN1o0,1577
|
|
15
|
+
abom/sign.py,sha256=Aze5qykBTAJ2wQcaeppfcbTogQP2qLZ4xcsbqWmoIMs,3068
|
|
16
|
+
abom_cli-0.1.0.dist-info/METADATA,sha256=K0-hPwTqTLmPD4T89utfbC8D5PfU2vrYPBd3ZA6xLGI,4690
|
|
17
|
+
abom_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
18
|
+
abom_cli-0.1.0.dist-info/entry_points.txt,sha256=BW8BE6sSWrJAnjvPKN-VtT8ah_LnuhaXIFRYGzE39lQ,38
|
|
19
|
+
abom_cli-0.1.0.dist-info/RECORD,,
|