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/__init__.py
ADDED
abom/agents.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Agent harness adapter + a minimal reference implementation.
|
|
2
|
+
|
|
3
|
+
`AgentHarness` is the swappable seam described in the architecture: the MVP ships
|
|
4
|
+
`SimpleAgent`, but a wrapped open framework (LangGraph, etc.) can implement the
|
|
5
|
+
same interface without changing the workflow.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from .models_router import Router
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Proposal:
|
|
17
|
+
patch_text: str
|
|
18
|
+
rationale: str
|
|
19
|
+
prompt_tokens: int
|
|
20
|
+
completion_tokens: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SYSTEM_PROMPT = (
|
|
24
|
+
"You are a software engineering agent operating inside a regulated boundary. "
|
|
25
|
+
"Given a task and (on retries) the failing test output, return a single unified "
|
|
26
|
+
"diff that makes the test suite pass. Output only the patch."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AgentHarness(Protocol):
|
|
31
|
+
async def propose(self, *, intent: str, iteration: int, feedback: dict | None) -> Proposal:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SimpleAgent:
|
|
36
|
+
"""Thin single-model loop. The reliability runtime (gate + critic) lives in
|
|
37
|
+
the workflow, not here — that separation is the point."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, router: Router):
|
|
40
|
+
self.router = router
|
|
41
|
+
self.client = router.client()
|
|
42
|
+
self._history: list[dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
43
|
+
|
|
44
|
+
async def propose(self, *, intent: str, iteration: int, feedback: dict | None) -> Proposal:
|
|
45
|
+
if iteration == 0:
|
|
46
|
+
self._history.append({"role": "user", "content": f"Task: {intent}"})
|
|
47
|
+
else:
|
|
48
|
+
self._history.append(
|
|
49
|
+
{"role": "user", "content": f"The gate failed. Output:\n{feedback}\nFix it."}
|
|
50
|
+
)
|
|
51
|
+
resp = await self.client.complete(self._history)
|
|
52
|
+
self._history.append({"role": "assistant", "content": resp.text})
|
|
53
|
+
return Proposal(
|
|
54
|
+
patch_text=resp.text,
|
|
55
|
+
rationale=f"iteration {iteration}",
|
|
56
|
+
prompt_tokens=resp.prompt_tokens,
|
|
57
|
+
completion_tokens=resp.completion_tokens,
|
|
58
|
+
)
|
abom/api.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Control API: FastAPI app, auth dependency, and routes (MVP_SPEC §7)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from .config import settings
|
|
12
|
+
from .db import (
|
|
13
|
+
AuditEvent, Project, Run, RunStep, get_session,
|
|
14
|
+
)
|
|
15
|
+
from .schemas import (
|
|
16
|
+
ApprovalDecision, AuditEventOut, ProjectCreate, ProjectOut, RunCreate,
|
|
17
|
+
RunOut, StepOut, VerifyResult,
|
|
18
|
+
)
|
|
19
|
+
from . import audit
|
|
20
|
+
|
|
21
|
+
app = FastAPI(title="ABOM Control API", version="0.1.0")
|
|
22
|
+
|
|
23
|
+
Session = Annotated[AsyncSession, Depends(get_session)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --------------------------------- auth -------------------------------------
|
|
27
|
+
class Principal:
|
|
28
|
+
def __init__(self, subject: str, roles: list[str]):
|
|
29
|
+
self.subject = subject
|
|
30
|
+
self.roles = roles
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def current_principal(authorization: str = Header(default="")) -> Principal:
|
|
34
|
+
"""Validate the bearer token.
|
|
35
|
+
|
|
36
|
+
Dev mode (no OIDC_JWKS_URL): accept the static token and grant all roles.
|
|
37
|
+
Phase-2: verify JWT signature against JWKS and map the `roles` claim.
|
|
38
|
+
"""
|
|
39
|
+
token = authorization.removeprefix("Bearer ").strip()
|
|
40
|
+
if not token:
|
|
41
|
+
raise HTTPException(401, "missing bearer token")
|
|
42
|
+
if not settings.oidc_jwks_url: # dev mode
|
|
43
|
+
if token != settings.dev_static_token:
|
|
44
|
+
raise HTTPException(401, "invalid dev token")
|
|
45
|
+
return Principal("dev@local", ["developer", "operator", "auditor"])
|
|
46
|
+
# TODO: real JWKS validation
|
|
47
|
+
raise HTTPException(501, "OIDC validation not implemented in MVP scaffold")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def require(role: str):
|
|
51
|
+
async def dep(principal: Principal = Depends(current_principal)) -> Principal:
|
|
52
|
+
if role not in principal.roles:
|
|
53
|
+
raise HTTPException(403, f"role '{role}' required")
|
|
54
|
+
return principal
|
|
55
|
+
return dep
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------- health -----------------------------------
|
|
59
|
+
@app.get("/healthz")
|
|
60
|
+
async def healthz():
|
|
61
|
+
return {"status": "ok"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.get("/readyz")
|
|
65
|
+
async def readyz(session: Session):
|
|
66
|
+
await session.execute(select(1))
|
|
67
|
+
return {"status": "ready"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# -------------------------------- projects ----------------------------------
|
|
71
|
+
@app.post("/v1/projects", response_model=ProjectOut, status_code=201)
|
|
72
|
+
async def create_project(body: ProjectCreate, session: Session,
|
|
73
|
+
_: Principal = Depends(require("operator"))):
|
|
74
|
+
proj = Project(name=body.name, repo_url=body.repo_url, test_command=body.test_command)
|
|
75
|
+
session.add(proj)
|
|
76
|
+
await session.commit()
|
|
77
|
+
await session.refresh(proj)
|
|
78
|
+
return proj
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------- runs ------------------------------------
|
|
82
|
+
@app.post("/v1/runs", response_model=RunOut, status_code=201)
|
|
83
|
+
async def create_run(body: RunCreate, session: Session,
|
|
84
|
+
principal: Principal = Depends(require("developer"))):
|
|
85
|
+
if not (await session.execute(select(Project).where(Project.id == body.project_id))).scalar_one_or_none():
|
|
86
|
+
raise HTTPException(404, "project not found")
|
|
87
|
+
run = Run(project_id=body.project_id, created_by=principal.subject, intent=body.intent,
|
|
88
|
+
workload_type=body.workload_type, max_iterations=body.max_iterations, status="pending")
|
|
89
|
+
session.add(run)
|
|
90
|
+
await session.commit()
|
|
91
|
+
await session.refresh(run)
|
|
92
|
+
await _start_workflow(str(run.id), body.max_iterations)
|
|
93
|
+
return run
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.get("/v1/runs/{run_id}", response_model=RunOut)
|
|
97
|
+
async def get_run(run_id: uuid.UUID, session: Session,
|
|
98
|
+
_: Principal = Depends(require("developer"))):
|
|
99
|
+
run = (await session.execute(select(Run).where(Run.id == run_id))).scalar_one_or_none()
|
|
100
|
+
if not run:
|
|
101
|
+
raise HTTPException(404, "run not found")
|
|
102
|
+
return run
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.get("/v1/runs/{run_id}/steps", response_model=list[StepOut])
|
|
106
|
+
async def get_steps(run_id: uuid.UUID, session: Session,
|
|
107
|
+
_: Principal = Depends(require("developer"))):
|
|
108
|
+
rows = (await session.execute(
|
|
109
|
+
select(RunStep).where(RunStep.run_id == run_id).order_by(RunStep.seq)
|
|
110
|
+
)).scalars().all()
|
|
111
|
+
return rows
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.post("/v1/runs/{run_id}/approve")
|
|
115
|
+
async def approve_run(run_id: uuid.UUID, body: ApprovalDecision,
|
|
116
|
+
principal: Principal = Depends(require("operator"))):
|
|
117
|
+
await _signal_workflow(str(run_id), body.decision, principal.subject)
|
|
118
|
+
return {"run_id": str(run_id), "decision": body.decision}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.post("/v1/runs/{run_id}/cancel")
|
|
122
|
+
async def cancel_run(run_id: uuid.UUID, _: Principal = Depends(require("operator"))):
|
|
123
|
+
await _cancel_workflow(str(run_id))
|
|
124
|
+
return {"run_id": str(run_id), "status": "cancelling"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------- audit -----------------------------------
|
|
128
|
+
@app.get("/v1/audit", response_model=list[AuditEventOut])
|
|
129
|
+
async def get_audit(session: Session, run_id: uuid.UUID = Query(...),
|
|
130
|
+
_: Principal = Depends(require("auditor"))):
|
|
131
|
+
rows = (await session.execute(
|
|
132
|
+
select(AuditEvent).where(AuditEvent.run_id == run_id).order_by(AuditEvent.seq)
|
|
133
|
+
)).scalars().all()
|
|
134
|
+
return rows
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.get("/v1/audit/verify", response_model=VerifyResult)
|
|
138
|
+
async def verify_audit(session: Session, run_id: uuid.UUID = Query(...),
|
|
139
|
+
_: Principal = Depends(require("auditor"))):
|
|
140
|
+
rows = (await session.execute(
|
|
141
|
+
select(AuditEvent).where(AuditEvent.run_id == run_id).order_by(AuditEvent.seq)
|
|
142
|
+
)).scalars().all()
|
|
143
|
+
events = [
|
|
144
|
+
{"run_id": str(r.run_id), "seq": r.seq, "event_type": r.event_type, "actor": r.actor,
|
|
145
|
+
"data": r.data, "created_at": r.created_at.isoformat(),
|
|
146
|
+
"prev_hash": r.prev_hash, "hash": r.hash}
|
|
147
|
+
for r in rows
|
|
148
|
+
]
|
|
149
|
+
result = audit.verify_chain(events)
|
|
150
|
+
return VerifyResult(run_id=run_id, event_count=len(events), **result)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ------------------------ Temporal client helpers ---------------------------
|
|
154
|
+
async def _client():
|
|
155
|
+
from temporalio.client import Client
|
|
156
|
+
return await Client.connect(settings.temporal_host, namespace=settings.temporal_namespace)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _start_workflow(run_id: str, max_iterations: int):
|
|
160
|
+
from .orchestration import AgentRunWorkflow
|
|
161
|
+
client = await _client()
|
|
162
|
+
await client.start_workflow(
|
|
163
|
+
AgentRunWorkflow.run, args=[run_id, max_iterations],
|
|
164
|
+
id=f"run-{run_id}", task_queue=settings.task_queue,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _signal_workflow(run_id: str, decision: str, approver: str):
|
|
169
|
+
from .orchestration import AgentRunWorkflow
|
|
170
|
+
client = await _client()
|
|
171
|
+
handle = client.get_workflow_handle(f"run-{run_id}")
|
|
172
|
+
await handle.signal(AgentRunWorkflow.approve, args=[decision, approver])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _cancel_workflow(run_id: str):
|
|
176
|
+
client = await _client()
|
|
177
|
+
await client.get_workflow_handle(f"run-{run_id}").cancel()
|
abom/audit.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Tamper-evident audit log.
|
|
2
|
+
|
|
3
|
+
The hashing functions at the top are PURE (stdlib only) so they can be unit
|
|
4
|
+
tested without a database — see tests/test_audit_chain.py. Persistence helpers
|
|
5
|
+
at the bottom take an async DB session.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Iterable
|
|
14
|
+
|
|
15
|
+
GENESIS = "GENESIS"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_json(obj: Any) -> str:
|
|
19
|
+
"""Deterministic JSON encoding used for hashing."""
|
|
20
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_hash(
|
|
24
|
+
*, run_id: str, seq: int, event_type: str, actor: str, data: Any,
|
|
25
|
+
prev_hash: str, created_at: str,
|
|
26
|
+
) -> str:
|
|
27
|
+
payload = {
|
|
28
|
+
"run_id": str(run_id),
|
|
29
|
+
"seq": seq,
|
|
30
|
+
"event_type": event_type,
|
|
31
|
+
"actor": actor,
|
|
32
|
+
"data": data,
|
|
33
|
+
"prev_hash": prev_hash,
|
|
34
|
+
"created_at": created_at,
|
|
35
|
+
}
|
|
36
|
+
return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AuditRecord:
|
|
41
|
+
run_id: str
|
|
42
|
+
seq: int
|
|
43
|
+
event_type: str
|
|
44
|
+
actor: str
|
|
45
|
+
data: Any
|
|
46
|
+
created_at: str
|
|
47
|
+
prev_hash: str
|
|
48
|
+
hash: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_record(
|
|
52
|
+
*, run_id: str, seq: int, event_type: str, actor: str, data: Any,
|
|
53
|
+
prev_hash: str, created_at: str | None = None,
|
|
54
|
+
) -> AuditRecord:
|
|
55
|
+
created_at = created_at or datetime.now(timezone.utc).isoformat()
|
|
56
|
+
h = compute_hash(
|
|
57
|
+
run_id=run_id, seq=seq, event_type=event_type, actor=actor,
|
|
58
|
+
data=data, prev_hash=prev_hash, created_at=created_at,
|
|
59
|
+
)
|
|
60
|
+
return AuditRecord(run_id, seq, event_type, actor, data, created_at, prev_hash, h)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def verify_chain(events: Iterable[dict]) -> dict:
|
|
64
|
+
"""Recompute the chain. Returns {"valid": bool, "broken_seq": int|None, "reason": str|None}.
|
|
65
|
+
|
|
66
|
+
`events` must be ordered by seq ascending. Each dict needs:
|
|
67
|
+
run_id, seq, event_type, actor, data, created_at, prev_hash, hash.
|
|
68
|
+
"""
|
|
69
|
+
expected_prev = GENESIS
|
|
70
|
+
last_seq = None
|
|
71
|
+
for e in events:
|
|
72
|
+
if last_seq is not None and e["seq"] != last_seq + 1:
|
|
73
|
+
return {"valid": False, "broken_seq": e["seq"], "reason": "non-contiguous seq"}
|
|
74
|
+
if e["prev_hash"] != expected_prev:
|
|
75
|
+
return {"valid": False, "broken_seq": e["seq"], "reason": "prev_hash mismatch"}
|
|
76
|
+
recomputed = compute_hash(
|
|
77
|
+
run_id=e["run_id"], seq=e["seq"], event_type=e["event_type"],
|
|
78
|
+
actor=e["actor"], data=e["data"], prev_hash=e["prev_hash"],
|
|
79
|
+
created_at=e["created_at"],
|
|
80
|
+
)
|
|
81
|
+
if recomputed != e["hash"]:
|
|
82
|
+
return {"valid": False, "broken_seq": e["seq"], "reason": "hash mismatch"}
|
|
83
|
+
expected_prev = e["hash"]
|
|
84
|
+
last_seq = e["seq"]
|
|
85
|
+
return {"valid": True, "broken_seq": None, "reason": None}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------
|
|
89
|
+
# Persistence (requires DB session). Imported lazily to keep hashing pure.
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
async def append_event(session, *, run_id: str, event_type: str, actor: str, data: Any) -> AuditRecord:
|
|
92
|
+
"""Append one event to a run's chain inside an existing transaction.
|
|
93
|
+
|
|
94
|
+
Locks the run's events to compute the next seq + prev_hash atomically.
|
|
95
|
+
"""
|
|
96
|
+
from sqlalchemy import select, func
|
|
97
|
+
from .db import AuditEvent
|
|
98
|
+
|
|
99
|
+
row = (
|
|
100
|
+
await session.execute(
|
|
101
|
+
select(AuditEvent)
|
|
102
|
+
.where(AuditEvent.run_id == run_id)
|
|
103
|
+
.order_by(AuditEvent.seq.desc())
|
|
104
|
+
.limit(1)
|
|
105
|
+
.with_for_update()
|
|
106
|
+
)
|
|
107
|
+
).scalar_one_or_none()
|
|
108
|
+
|
|
109
|
+
seq = (row.seq + 1) if row else 0
|
|
110
|
+
prev_hash = row.hash if row else GENESIS
|
|
111
|
+
rec = make_record(
|
|
112
|
+
run_id=str(run_id), seq=seq, event_type=event_type, actor=actor,
|
|
113
|
+
data=data, prev_hash=prev_hash,
|
|
114
|
+
)
|
|
115
|
+
session.add(
|
|
116
|
+
AuditEvent(
|
|
117
|
+
run_id=run_id, seq=rec.seq, event_type=rec.event_type, actor=rec.actor,
|
|
118
|
+
data=rec.data, prev_hash=rec.prev_hash, hash=rec.hash,
|
|
119
|
+
created_at=datetime.fromisoformat(rec.created_at),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return rec
|
abom/bom.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""ABOM core — Composition Manifest, Action Provenance, and abom-verify.
|
|
2
|
+
|
|
3
|
+
Pure stdlib + abom.audit for the tamper-evident chain. Two artifacts plus a
|
|
4
|
+
policy verifier:
|
|
5
|
+
|
|
6
|
+
* build_composition(...) -> signed Composition Manifest (what the agent IS)
|
|
7
|
+
* append_action(...) -> hash-chained Action Provenance (what the agent DID)
|
|
8
|
+
* verify_abom(...) -> abom-verify: policy findings over an ABOM
|
|
9
|
+
|
|
10
|
+
The signature here is an HMAC stand-in, clearly labelled. Production uses
|
|
11
|
+
detached ed25519 / cosign with keys in Vault/KMS — see ARCHITECTURE.md §3.6.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import hashlib
|
|
17
|
+
|
|
18
|
+
from . import sign as _sign
|
|
19
|
+
from .audit import GENESIS, canonical_json, make_record, verify_chain
|
|
20
|
+
|
|
21
|
+
ABOM_VERSION = "0.1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha256(obj) -> str:
|
|
25
|
+
return hashlib.sha256(canonical_json(obj).encode("utf-8")).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sign(obj: dict, key=None) -> dict:
|
|
29
|
+
"""Sign an ABOM object with a detached ed25519 signature."""
|
|
30
|
+
return _sign.sign_obj(obj, key)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_signature(obj: dict, trusted_keys=None) -> bool:
|
|
34
|
+
return _sign.verify_obj(obj, trusted_keys)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Composition Manifest (what the agent IS) --------------------------------
|
|
38
|
+
def build_composition(agent: dict, components: list[dict], controls: dict,
|
|
39
|
+
*, sign: bool = True, key=None) -> dict:
|
|
40
|
+
"""Build a Composition Manifest (extends CycloneDX ML-BOM), ed25519-signed by default."""
|
|
41
|
+
body = {
|
|
42
|
+
"abom": ABOM_VERSION, "extends": "CycloneDX ML-BOM",
|
|
43
|
+
"type": "CompositionManifest",
|
|
44
|
+
"agent": agent, "components": components, "controls": controls,
|
|
45
|
+
}
|
|
46
|
+
body["composition_sha256"] = _sha256(body)
|
|
47
|
+
return _sign.sign_obj(body, key) if sign else body
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Action Provenance (what the agent DID) ----------------------------------
|
|
51
|
+
def new_chain() -> list:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def append_action(chain: list, *, composition_sha256: str, agent_ref: str, decision: str,
|
|
56
|
+
inputs=None, model_calls=None, tools_invoked=None, data_touched=None,
|
|
57
|
+
policy_decisions=None, approval=None) -> dict:
|
|
58
|
+
"""Append one hash-chained Action Provenance Record to the chain."""
|
|
59
|
+
data = {
|
|
60
|
+
"composition_sha256": composition_sha256,
|
|
61
|
+
"decision": decision,
|
|
62
|
+
"inputs": inputs or [],
|
|
63
|
+
"model_calls": model_calls or [],
|
|
64
|
+
"tools_invoked": tools_invoked or [],
|
|
65
|
+
"data_touched": data_touched or [],
|
|
66
|
+
"policy_decisions": policy_decisions or [],
|
|
67
|
+
"approval": approval,
|
|
68
|
+
}
|
|
69
|
+
prev = chain[-1]["hash"] if chain else GENESIS
|
|
70
|
+
rec = make_record(run_id=agent_ref, seq=len(chain), event_type="ActionProvenance",
|
|
71
|
+
actor="abom-gen", data=data, prev_hash=prev)
|
|
72
|
+
row = dataclasses.asdict(rec)
|
|
73
|
+
chain.append(row)
|
|
74
|
+
return row
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- abom-verify (policy findings over an ABOM) ------------------------------
|
|
78
|
+
DEFAULT_POLICY = {
|
|
79
|
+
"allowed_models": ["local/qwen2.5-coder"],
|
|
80
|
+
"allowed_egress_endpoints": ["internal-kyc.bank"],
|
|
81
|
+
"no_egress_classifications": ["confidential", "restricted"],
|
|
82
|
+
"require_approval_when": "consequential",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verify_abom(composition: dict, chain: list | None = None, policy: dict | None = None) -> dict:
|
|
87
|
+
"""Verify a Composition Manifest (+ optional Action Provenance chain) against policy.
|
|
88
|
+
|
|
89
|
+
With no policy, checks structural integrity only (signature + chain). With a
|
|
90
|
+
policy, also enforces the model allowlist, residency, egress allowlist, and
|
|
91
|
+
approval coverage. Returns {"ok", "findings", "actions", "components"}.
|
|
92
|
+
"""
|
|
93
|
+
policy = policy or {}
|
|
94
|
+
chain = chain or []
|
|
95
|
+
findings: list[dict] = []
|
|
96
|
+
comp_hash = composition.get("composition_sha256")
|
|
97
|
+
declared_models = {c.get("name") for c in composition.get("components", [])
|
|
98
|
+
if c.get("type") == "model"}
|
|
99
|
+
allowed_models = policy.get("allowed_models")
|
|
100
|
+
no_egress = set(policy.get("no_egress_classifications", []))
|
|
101
|
+
allowed_eps = policy.get("allowed_egress_endpoints")
|
|
102
|
+
|
|
103
|
+
if not verify_signature(composition):
|
|
104
|
+
findings.append({"rule": "signature", "severity": "high",
|
|
105
|
+
"detail": "composition signature invalid or missing"})
|
|
106
|
+
cv = verify_chain(chain)
|
|
107
|
+
if not cv["valid"]:
|
|
108
|
+
findings.append({"rule": "chain_integrity", "severity": "high",
|
|
109
|
+
"detail": f"provenance broken at seq {cv['broken_seq']} ({cv['reason']})"})
|
|
110
|
+
|
|
111
|
+
# composition-level: declared models must be on the allowlist (when one is set)
|
|
112
|
+
if allowed_models is not None:
|
|
113
|
+
for nm in sorted(m for m in declared_models if m):
|
|
114
|
+
if nm not in allowed_models:
|
|
115
|
+
findings.append({"rule": "model_allowlist", "component": nm, "severity": "medium",
|
|
116
|
+
"detail": f"declared model not on allowlist: {nm}"})
|
|
117
|
+
|
|
118
|
+
for ev in chain:
|
|
119
|
+
d, seq = ev["data"], ev["seq"]
|
|
120
|
+
if comp_hash and d.get("composition_sha256") != comp_hash:
|
|
121
|
+
findings.append({"rule": "composition_match", "seq": seq, "severity": "high",
|
|
122
|
+
"detail": "action references a different composition"})
|
|
123
|
+
for mc in d.get("model_calls", []):
|
|
124
|
+
m = mc.get("model")
|
|
125
|
+
if allowed_models is not None and m not in allowed_models:
|
|
126
|
+
findings.append({"rule": "model_allowlist", "seq": seq, "severity": "high",
|
|
127
|
+
"detail": f"unapproved model used: {m}"})
|
|
128
|
+
if m not in declared_models:
|
|
129
|
+
findings.append({"rule": "composition_drift", "seq": seq, "severity": "high",
|
|
130
|
+
"detail": f"runtime model not in signed manifest: {m}"})
|
|
131
|
+
for dt in d.get("data_touched", []):
|
|
132
|
+
if dt.get("egress") and dt.get("classification") in no_egress:
|
|
133
|
+
findings.append({"rule": "residency", "seq": seq, "severity": "high",
|
|
134
|
+
"detail": f"{dt.get('classification')} data egressed"})
|
|
135
|
+
for ti in d.get("tools_invoked", []):
|
|
136
|
+
ep = ti.get("endpoint")
|
|
137
|
+
if ep and allowed_eps is not None and ep not in allowed_eps:
|
|
138
|
+
findings.append({"rule": "egress_allowlist", "seq": seq, "severity": "medium",
|
|
139
|
+
"detail": f"egress to unapproved endpoint: {ep}"})
|
|
140
|
+
if any(pd.get("result") == "required" for pd in d.get("policy_decisions", [])):
|
|
141
|
+
ap = d.get("approval")
|
|
142
|
+
if not (ap and ap.get("decision") == "approved"):
|
|
143
|
+
findings.append({"rule": "approval_coverage", "seq": seq, "severity": "high",
|
|
144
|
+
"detail": "consequential action without approval"})
|
|
145
|
+
|
|
146
|
+
return {"ok": len(findings) == 0, "findings": findings,
|
|
147
|
+
"actions": len(chain), "components": len(composition.get("components", []))}
|
abom/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""abom — the Agent Bill of Materials CLI.
|
|
2
|
+
|
|
3
|
+
abom scan . # detect components, emit a signed ABOM
|
|
4
|
+
abom verify abom.json # check signature (and policy, if given)
|
|
5
|
+
abom keygen # show the local ed25519 signing key
|
|
6
|
+
abom version
|
|
7
|
+
|
|
8
|
+
Exit code is non-zero when verification finds violations, so it drops into CI.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from . import __version__, bom, scan as scanner, sign
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
add_completion=False,
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
help="ABOM — know what your AI agents are made of, and prove it.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
TYPE_LABEL = {
|
|
26
|
+
"model": "models", "tool": "tools", "prompt": "prompts", "dataSource": "data sources",
|
|
27
|
+
"policy": "policies / guardrails", "framework": "frameworks", "mcpServer": "MCP servers",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def scan(
|
|
33
|
+
path: str = typer.Argument(".", help="Repository or directory to scan."),
|
|
34
|
+
output: str = typer.Option("abom.json", "-o", "--output", help="Output file ('-' for stdout)."),
|
|
35
|
+
sign_manifest: bool = typer.Option(True, "--sign/--no-sign", help="ed25519-sign the manifest."),
|
|
36
|
+
name: str = typer.Option(None, "--name", help="Override the detected agent name."),
|
|
37
|
+
version: str = typer.Option(None, "--agent-version", help="Override the detected agent version."),
|
|
38
|
+
):
|
|
39
|
+
"""Scan a repo and emit a signed ABOM Composition Manifest."""
|
|
40
|
+
root = Path(path)
|
|
41
|
+
if not root.exists():
|
|
42
|
+
typer.secho(f"path not found: {path}", fg="red", err=True)
|
|
43
|
+
raise typer.Exit(2)
|
|
44
|
+
|
|
45
|
+
result = scanner.scan(root)
|
|
46
|
+
if name:
|
|
47
|
+
result["agent"]["name"] = name
|
|
48
|
+
if version:
|
|
49
|
+
result["agent"]["version"] = version
|
|
50
|
+
manifest = bom.build_composition(
|
|
51
|
+
result["agent"], result["components"], result["controls"], sign=sign_manifest
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
text = json.dumps(manifest, indent=2)
|
|
55
|
+
if output == "-":
|
|
56
|
+
typer.echo(text)
|
|
57
|
+
else:
|
|
58
|
+
Path(output).write_text(text)
|
|
59
|
+
|
|
60
|
+
a = manifest["agent"]
|
|
61
|
+
typer.secho(f"\n ABOM · {a['name']} @ {a['version']}", fg="cyan", bold=True)
|
|
62
|
+
by_type: dict[str, list] = {}
|
|
63
|
+
for c in manifest["components"]:
|
|
64
|
+
by_type.setdefault(c["type"], []).append(c.get("name", "?"))
|
|
65
|
+
if not by_type:
|
|
66
|
+
typer.secho(" no agent components detected (is this an agent repo?)", fg="yellow")
|
|
67
|
+
for t, names in by_type.items():
|
|
68
|
+
typer.echo(f" {TYPE_LABEL.get(t, t):22} {len(names):>2} " + ", ".join(names[:6])
|
|
69
|
+
+ (" …" if len(names) > 6 else ""))
|
|
70
|
+
sig = manifest.get("signature", {})
|
|
71
|
+
if sig:
|
|
72
|
+
typer.secho(f" signed: {sig.get('alg')} · key {sig.get('key_id', '?')}", fg="green")
|
|
73
|
+
typer.echo(f" composition_sha256: {manifest['composition_sha256'][:16]}…")
|
|
74
|
+
if output != "-":
|
|
75
|
+
typer.secho(f" → wrote {output}\n", fg="cyan")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def verify(
|
|
80
|
+
abom_file: str = typer.Argument("abom.json", help="ABOM file to verify."),
|
|
81
|
+
policy_file: str = typer.Option(None, "--policy", "-p", help="Policy JSON to enforce."),
|
|
82
|
+
):
|
|
83
|
+
"""Verify an ABOM's signature, and (with --policy) its compliance."""
|
|
84
|
+
try:
|
|
85
|
+
doc = json.loads(Path(abom_file).read_text())
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
typer.secho(f"could not read {abom_file}: {exc}", fg="red", err=True)
|
|
88
|
+
raise typer.Exit(2)
|
|
89
|
+
if doc.get("type") != "CompositionManifest":
|
|
90
|
+
typer.secho("not a Composition Manifest (verify expects `abom scan` output)", fg="red", err=True)
|
|
91
|
+
raise typer.Exit(2)
|
|
92
|
+
|
|
93
|
+
policy = json.loads(Path(policy_file).read_text()) if policy_file else None
|
|
94
|
+
result = bom.verify_abom(doc, [], policy)
|
|
95
|
+
|
|
96
|
+
if result["ok"]:
|
|
97
|
+
typer.secho(f"\n ✓ VALID — signature OK, {result['components']} components"
|
|
98
|
+
+ (", policy clean" if policy else "") + "\n", fg="green", bold=True)
|
|
99
|
+
raise typer.Exit(0)
|
|
100
|
+
|
|
101
|
+
typer.secho(f"\n ✗ {len(result['findings'])} finding(s):", fg="red", bold=True)
|
|
102
|
+
for f in result["findings"]:
|
|
103
|
+
loc = f.get("component") or (f"seq {f['seq']}" if "seq" in f else "-")
|
|
104
|
+
typer.echo(f" • [{f['severity']}] {f['rule']} ({loc}): {f['detail']}")
|
|
105
|
+
typer.echo("")
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def keygen(show_private: bool = typer.Option(False, "--show-private", help="Print the private key path.")):
|
|
111
|
+
"""Show (or create) the local ed25519 signing key."""
|
|
112
|
+
key = sign.load_or_create_key()
|
|
113
|
+
pub_b64 = sign._pub_b64(key.public_key())
|
|
114
|
+
path = sign.default_key_path()
|
|
115
|
+
typer.secho(" ABOM signing key", fg="cyan", bold=True)
|
|
116
|
+
typer.echo(f" key_id: {sign.key_id(pub_b64)}")
|
|
117
|
+
typer.echo(f" public_key: {pub_b64}")
|
|
118
|
+
if show_private:
|
|
119
|
+
typer.echo(f" private: {path}")
|
|
120
|
+
typer.secho(f" stored at {path} (override with ABOM_KEY)", fg="bright_black")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def version():
|
|
125
|
+
"""Print the abom and ABOM-spec versions."""
|
|
126
|
+
typer.echo(f"abom {__version__} · ABOM spec v{bom.ABOM_VERSION}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
app()
|
abom/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Runtime configuration, loaded from environment / .env (see .env.example)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
model_config = SettingsConfigDict(env_prefix="ABOM_", env_file=".env", extra="ignore")
|
|
9
|
+
|
|
10
|
+
# storage / infra
|
|
11
|
+
database_url: str = "postgresql+asyncpg://abom:abom@localhost:5432/abom"
|
|
12
|
+
temporal_host: str = "localhost:7233"
|
|
13
|
+
temporal_namespace: str = "default"
|
|
14
|
+
task_queue: str = "abom-cli"
|
|
15
|
+
|
|
16
|
+
s3_endpoint: str = "http://localhost:9000"
|
|
17
|
+
s3_access_key: str = "minioadmin"
|
|
18
|
+
s3_secret_key: str = "minioadmin"
|
|
19
|
+
s3_bucket: str = "abom-artifacts"
|
|
20
|
+
|
|
21
|
+
# model serving (local, OpenAI-compatible — e.g. vLLM)
|
|
22
|
+
model_base_url: str = "http://localhost:8001/v1"
|
|
23
|
+
model_name: str = "local/qwen2.5-coder"
|
|
24
|
+
model_use_mock: bool = True # MVP default: run without a GPU
|
|
25
|
+
|
|
26
|
+
# auth
|
|
27
|
+
oidc_jwks_url: str = "" # empty -> dev mode
|
|
28
|
+
oidc_audience: str = "abom"
|
|
29
|
+
dev_static_token: str = "dev-token" # dev only
|
|
30
|
+
|
|
31
|
+
# execution
|
|
32
|
+
sandbox_image: str = "python:3.12-slim"
|
|
33
|
+
workspace_root: str = "/tmp/abom-workspaces"
|
|
34
|
+
gate_timeout_seconds: int = 600
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
settings = Settings()
|