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 ADDED
@@ -0,0 +1,3 @@
1
+ """ABOM — production control plane for agentic AI (Step-1 MVP scaffold)."""
2
+
3
+ __version__ = "0.1.0"
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()