startup-sbom 2.0.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.
Files changed (188) hide show
  1. startup_sbom/__init__.py +45 -0
  2. startup_sbom/__main__.py +6 -0
  3. startup_sbom/ai/__init__.py +84 -0
  4. startup_sbom/ai/a2a.py +208 -0
  5. startup_sbom/ai/acp.py +274 -0
  6. startup_sbom/ai/agents.py +251 -0
  7. startup_sbom/ai/cli.py +289 -0
  8. startup_sbom/ai/grounding.py +321 -0
  9. startup_sbom/ai/index.py +578 -0
  10. startup_sbom/ai/mcp_server.py +360 -0
  11. startup_sbom/ai/orchestrator.py +537 -0
  12. startup_sbom/ai/outputs.py +222 -0
  13. startup_sbom/ai/prompts.py +114 -0
  14. startup_sbom/ai/providers.py +366 -0
  15. startup_sbom/ai/registry.py +417 -0
  16. startup_sbom/ai/tools.py +237 -0
  17. startup_sbom/ai/websearch.py +93 -0
  18. startup_sbom/binaries/__init__.py +20 -0
  19. startup_sbom/binaries/analyze.py +450 -0
  20. startup_sbom/binaries/elf.py +633 -0
  21. startup_sbom/binaries/linker.py +492 -0
  22. startup_sbom/binaries/privileged.py +169 -0
  23. startup_sbom/binaries/shebang.py +119 -0
  24. startup_sbom/boottime.py +90 -0
  25. startup_sbom/cli.py +398 -0
  26. startup_sbom/context.py +70 -0
  27. startup_sbom/data/NOTICE.md +74 -0
  28. startup_sbom/data/attack_techniques.json +1 -0
  29. startup_sbom/data/capec_attack.json +1 -0
  30. startup_sbom/data/cwe.json +1 -0
  31. startup_sbom/data/kev_attack.json +1 -0
  32. startup_sbom/distro.py +456 -0
  33. startup_sbom/errors.py +47 -0
  34. startup_sbom/explain/__init__.py +704 -0
  35. startup_sbom/explain/attribution.py +430 -0
  36. startup_sbom/explain/counterfactual.py +394 -0
  37. startup_sbom/explain/global_.py +145 -0
  38. startup_sbom/explain/provenance.py +144 -0
  39. startup_sbom/explain/ssvc_trace.py +211 -0
  40. startup_sbom/initsys/__init__.py +279 -0
  41. startup_sbom/initsys/base.py +28 -0
  42. startup_sbom/initsys/cmdline.py +1200 -0
  43. startup_sbom/initsys/container.py +163 -0
  44. startup_sbom/initsys/inittab.py +208 -0
  45. startup_sbom/initsys/netconf.py +424 -0
  46. startup_sbom/initsys/openrc.py +338 -0
  47. startup_sbom/initsys/runit.py +235 -0
  48. startup_sbom/initsys/s6.py +177 -0
  49. startup_sbom/initsys/supervisord.py +259 -0
  50. startup_sbom/initsys/systemd/__init__.py +10 -0
  51. startup_sbom/initsys/systemd/analyzer.py +433 -0
  52. startup_sbom/initsys/systemd/boot.py +141 -0
  53. startup_sbom/initsys/systemd/exec.py +84 -0
  54. startup_sbom/initsys/systemd/hardening.py +1283 -0
  55. startup_sbom/initsys/systemd/loader.py +494 -0
  56. startup_sbom/initsys/systemd/parser.py +391 -0
  57. startup_sbom/initsys/systemd/sockets.py +122 -0
  58. startup_sbom/initsys/sysv.py +337 -0
  59. startup_sbom/intel/__init__.py +39 -0
  60. startup_sbom/intel/cli.py +83 -0
  61. startup_sbom/intel/exploits.py +332 -0
  62. startup_sbom/intel/extract.py +372 -0
  63. startup_sbom/intel/fetcher.py +420 -0
  64. startup_sbom/intel/prefetch.py +532 -0
  65. startup_sbom/intel/render.py +188 -0
  66. startup_sbom/intel/search.py +186 -0
  67. startup_sbom/intel/select.py +214 -0
  68. startup_sbom/intel/sources.py +420 -0
  69. startup_sbom/intel/store.py +165 -0
  70. startup_sbom/langpkgs/__init__.py +135 -0
  71. startup_sbom/langpkgs/golang.py +303 -0
  72. startup_sbom/langpkgs/java.py +241 -0
  73. startup_sbom/langpkgs/npm.py +191 -0
  74. startup_sbom/langpkgs/python.py +262 -0
  75. startup_sbom/langpkgs/rust.py +156 -0
  76. startup_sbom/legacy.py +86 -0
  77. startup_sbom/log.py +27 -0
  78. startup_sbom/lz4frame.py +138 -0
  79. startup_sbom/models.py +900 -0
  80. startup_sbom/net.py +327 -0
  81. startup_sbom/pipeline.py +330 -0
  82. startup_sbom/pkgdb/__init__.py +99 -0
  83. startup_sbom/pkgdb/alpm.py +326 -0
  84. startup_sbom/pkgdb/apk.py +264 -0
  85. startup_sbom/pkgdb/base.py +41 -0
  86. startup_sbom/pkgdb/dpkg.py +415 -0
  87. startup_sbom/pkgdb/ownership.py +77 -0
  88. startup_sbom/pkgdb/rpm/__init__.py +23 -0
  89. startup_sbom/pkgdb/rpm/bdb.py +116 -0
  90. startup_sbom/pkgdb/rpm/header.py +281 -0
  91. startup_sbom/pkgdb/rpm/ndb.py +67 -0
  92. startup_sbom/pkgdb/rpm/pgp.py +173 -0
  93. startup_sbom/pkgdb/rpm/reader.py +613 -0
  94. startup_sbom/pkgdb/rpm/sqlite.py +81 -0
  95. startup_sbom/purls.py +192 -0
  96. startup_sbom/py.typed +0 -0
  97. startup_sbom/report/__init__.py +36 -0
  98. startup_sbom/report/assets/report.css +75 -0
  99. startup_sbom/report/assets/report.js +221 -0
  100. startup_sbom/report/cdxvex.py +115 -0
  101. startup_sbom/report/console.py +401 -0
  102. startup_sbom/report/cyclonedx.py +818 -0
  103. startup_sbom/report/graphs.py +313 -0
  104. startup_sbom/report/html.py +625 -0
  105. startup_sbom/report/markdown.py +502 -0
  106. startup_sbom/report/native.py +318 -0
  107. startup_sbom/report/openvex.py +179 -0
  108. startup_sbom/report/pdf/__init__.py +81 -0
  109. startup_sbom/report/pdf/engine.py +142 -0
  110. startup_sbom/report/pdf/templates/components.typ +303 -0
  111. startup_sbom/report/pdf/templates/report.typ +852 -0
  112. startup_sbom/report/pdf/viewmodel.py +1031 -0
  113. startup_sbom/report/sarif.py +363 -0
  114. startup_sbom/report/spdx.py +244 -0
  115. startup_sbom/report/writer.py +185 -0
  116. startup_sbom/rootfs/__init__.py +30 -0
  117. startup_sbom/rootfs/base.py +325 -0
  118. startup_sbom/rootfs/caps.py +79 -0
  119. startup_sbom/rootfs/directory.py +131 -0
  120. startup_sbom/rootfs/tar.py +355 -0
  121. startup_sbom/sources/__init__.py +29 -0
  122. startup_sbom/sources/archive.py +370 -0
  123. startup_sbom/sources/compression.py +181 -0
  124. startup_sbom/sources/engine.py +471 -0
  125. startup_sbom/sources/host.py +128 -0
  126. startup_sbom/sources/oci.py +346 -0
  127. startup_sbom/sources/targets.py +262 -0
  128. startup_sbom/sources/validate.py +205 -0
  129. startup_sbom/supplychain/__init__.py +102 -0
  130. startup_sbom/supplychain/blast.py +199 -0
  131. startup_sbom/supplychain/eol.py +138 -0
  132. startup_sbom/supplychain/graph.py +436 -0
  133. startup_sbom/supplychain/integrity.py +221 -0
  134. startup_sbom/supplychain/provenance.py +494 -0
  135. startup_sbom/supplychain/repos.py +1505 -0
  136. startup_sbom/supplychain/vendors.py +186 -0
  137. startup_sbom/textsafe.py +67 -0
  138. startup_sbom/threat/__init__.py +20 -0
  139. startup_sbom/threat/attack.py +232 -0
  140. startup_sbom/threat/chains.py +276 -0
  141. startup_sbom/threat/cvss.py +151 -0
  142. startup_sbom/threat/cwe.py +168 -0
  143. startup_sbom/threat/facts.py +314 -0
  144. startup_sbom/threat/narrative.py +156 -0
  145. startup_sbom/threat/reachability.py +354 -0
  146. startup_sbom/threat/reverse.py +533 -0
  147. startup_sbom/threat/rules.py +338 -0
  148. startup_sbom/threat/scoring.py +170 -0
  149. startup_sbom/threat/ssvc.py +153 -0
  150. startup_sbom/tui/__init__.py +31 -0
  151. startup_sbom/tui/app.py +213 -0
  152. startup_sbom/tui/commands.py +59 -0
  153. startup_sbom/tui/data.py +225 -0
  154. startup_sbom/tui/integrations.py +157 -0
  155. startup_sbom/tui/messages.py +95 -0
  156. startup_sbom/tui/render.py +561 -0
  157. startup_sbom/tui/safe.py +49 -0
  158. startup_sbom/tui/screens/__init__.py +1 -0
  159. startup_sbom/tui/screens/ai_panel.py +179 -0
  160. startup_sbom/tui/screens/article.py +97 -0
  161. startup_sbom/tui/screens/export.py +119 -0
  162. startup_sbom/tui/screens/home.py +125 -0
  163. startup_sbom/tui/screens/open_report.py +85 -0
  164. startup_sbom/tui/screens/results.py +896 -0
  165. startup_sbom/tui/screens/scan.py +218 -0
  166. startup_sbom/tui/screens/settings.py +306 -0
  167. startup_sbom/tui/screens/target.py +274 -0
  168. startup_sbom/tui/state.py +506 -0
  169. startup_sbom/tui/styles.tcss +318 -0
  170. startup_sbom/tui/widgets.py +67 -0
  171. startup_sbom/viz/__init__.py +9 -0
  172. startup_sbom/viz/svg.py +482 -0
  173. startup_sbom/viz/terminal.py +935 -0
  174. startup_sbom/vulns/__init__.py +25 -0
  175. startup_sbom/vulns/arch.py +145 -0
  176. startup_sbom/vulns/base.py +22 -0
  177. startup_sbom/vulns/epss.py +167 -0
  178. startup_sbom/vulns/kev.py +62 -0
  179. startup_sbom/vulns/match.py +227 -0
  180. startup_sbom/vulns/normalize.py +345 -0
  181. startup_sbom/vulns/nvd.py +277 -0
  182. startup_sbom/vulns/osv.py +651 -0
  183. startup_sbom/vulns/versions.py +543 -0
  184. startup_sbom-2.0.0.dist-info/METADATA +301 -0
  185. startup_sbom-2.0.0.dist-info/RECORD +188 -0
  186. startup_sbom-2.0.0.dist-info/WHEEL +4 -0
  187. startup_sbom-2.0.0.dist-info/entry_points.txt +3 -0
  188. startup_sbom-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,45 @@
1
+ """startup-sbom: what runs at boot, what it is made of, and how it can be attacked.
2
+
3
+ Library use::
4
+
5
+ from startup_sbom import ScanOptions, scan
6
+
7
+ report = scan("oci-dir:./image", ScanOptions(offline=True))
8
+ for threat in report.threats:
9
+ print(threat.ssvc.decision if threat.ssvc else "-", threat.title)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ try:
18
+ __version__ = version("startup-sbom")
19
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
20
+ __version__ = "0.0.0+unknown"
21
+
22
+ if TYPE_CHECKING:
23
+ from startup_sbom.context import ScanOptions
24
+ from startup_sbom.models import Report
25
+
26
+ __all__ = ["Report", "ScanOptions", "__version__", "scan"]
27
+
28
+
29
+ def scan(target: str, options: ScanOptions | None = None, **kwargs: Any) -> Report:
30
+ """Analyse ``target`` (see :func:`startup_sbom.pipeline.scan`)."""
31
+ from startup_sbom.pipeline import scan as _scan
32
+
33
+ return _scan(target, options, **kwargs)
34
+
35
+
36
+ def __getattr__(name: str) -> Any:
37
+ if name == "ScanOptions":
38
+ from startup_sbom.context import ScanOptions
39
+
40
+ return ScanOptions
41
+ if name == "Report":
42
+ from startup_sbom.models import Report
43
+
44
+ return Report
45
+ raise AttributeError(name)
@@ -0,0 +1,6 @@
1
+ """``python -m startup_sbom``."""
2
+
3
+ from startup_sbom.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,84 @@
1
+ """Agentic AI analysis over a finished scan report (optional ``ai`` extra).
2
+
3
+ The deterministic report stays the source of truth. Agents only *read* it through
4
+ paged, size-capped, spotlighted tools, every output is machine-checked against the
5
+ report ids and numbers (:mod:`startup_sbom.ai.grounding`), and the result is an
6
+ :class:`~startup_sbom.models.AIAssessment` stored in ``report.ai``::
7
+
8
+ from startup_sbom.ai import analyze
9
+
10
+ report.ai = analyze(report, profile="cheap", max_cost_usd=0.50)
11
+
12
+ Importing this package is cheap: ``pydantic_ai``, ``mcp`` and ``fasta2a`` are only
13
+ imported by the submodules that need them, with a friendly error when the extra
14
+ is missing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from startup_sbom.errors import StartupSBOMError
23
+
24
+ if TYPE_CHECKING:
25
+ from startup_sbom.ai.orchestrator import analyze, analyze_async
26
+ from startup_sbom.ai.outputs import render_assessment
27
+ from startup_sbom.ai.providers import available_providers
28
+
29
+ __all__ = [
30
+ "AIBudgetError",
31
+ "AIConfigError",
32
+ "AIError",
33
+ "AIUnavailableError",
34
+ "analyze",
35
+ "analyze_async",
36
+ "available_providers",
37
+ "render_assessment",
38
+ "require",
39
+ ]
40
+
41
+ INSTALL_HINT = "pip install 'startup-sbom[ai]'"
42
+ SERVER_INSTALL_HINT = "pip install 'startup-sbom[agents-server]'"
43
+
44
+
45
+ class AIError(StartupSBOMError):
46
+ """Base class for errors raised by the AI layer."""
47
+
48
+
49
+ class AIUnavailableError(AIError):
50
+ """An optional dependency of the AI layer is not installed."""
51
+
52
+
53
+ class AIConfigError(AIError):
54
+ """Provider, model, agent or server configuration is invalid."""
55
+
56
+
57
+ class AIBudgetError(AIError):
58
+ """A run hit its usage limits or its ``max_cost_usd`` guard."""
59
+
60
+
61
+ def require(module: str, *, hint: str = INSTALL_HINT) -> Any:
62
+ """Import ``module`` or raise :class:`AIUnavailableError` naming the extra to install."""
63
+ try:
64
+ return importlib.import_module(module)
65
+ except ImportError as exc:
66
+ raise AIUnavailableError(
67
+ f"the AI features need the optional dependency "
68
+ f"{module.split('.', maxsplit=1)[0]!r}: {hint}"
69
+ ) from exc
70
+
71
+
72
+ _LAZY = {
73
+ "analyze": "startup_sbom.ai.orchestrator",
74
+ "analyze_async": "startup_sbom.ai.orchestrator",
75
+ "render_assessment": "startup_sbom.ai.outputs",
76
+ "available_providers": "startup_sbom.ai.providers",
77
+ }
78
+
79
+
80
+ def __getattr__(name: str) -> Any:
81
+ module = _LAZY.get(name)
82
+ if module is None:
83
+ raise AttributeError(name)
84
+ return getattr(importlib.import_module(module), name)
startup_sbom/ai/a2a.py ADDED
@@ -0,0 +1,208 @@
1
+ """Serve the orchestrator and specialists over A2A 1.0 with ``fasta2a``.
2
+
3
+ Pydantic AI 2 removed ``Agent.to_a2a()``; the replacement is building ``FastA2A``
4
+ around ``fasta2a.pydantic_ai.AgentWorker``, with two fixes verified against the
5
+ official ``a2a-sdk`` client:
6
+
7
+ 1. **deps**: the worker calls ``agent.run(message_history=...)`` without deps, so the
8
+ lifespan binds them with ``agent.override(deps=...)`` (contextvars propagate into
9
+ the worker's task group).
10
+ 2. **timestamps**: fasta2a 2.1.1 writes naive timestamps the a2a-sdk (protobuf JSON)
11
+ rejects; :class:`UTCStorage` rewrites them as UTC ``...Z``.
12
+
13
+ The orchestrator card is served at the root; each local specialist gets its own card
14
+ under ``/agents/<name>``. Binds 127.0.0.1 by default; a bearer token
15
+ (``STARTUP_SBOM_AGENT_TOKEN``) is required on any other interface.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import AsyncIterator, Sequence
21
+ from contextlib import AsyncExitStack, ExitStack, asynccontextmanager
22
+ from datetime import UTC, datetime
23
+ from typing import Any
24
+
25
+ from startup_sbom.ai import SERVER_INSTALL_HINT, AIUnavailableError
26
+ from startup_sbom.ai.acp import TokenAuthMiddleware
27
+ from startup_sbom.ai.index import ReportIndex
28
+ from startup_sbom.ai.orchestrator import ModelSet, build_orchestrator
29
+ from startup_sbom.ai.registry import AgentRegistry, AgentSpec
30
+ from startup_sbom.ai.tools import AgentDeps, report_toolset
31
+ from startup_sbom.models import Report
32
+
33
+ try:
34
+ from fasta2a import FastA2A
35
+ from fasta2a.broker import InMemoryBroker
36
+ from fasta2a.pydantic_ai import AgentWorker
37
+ from fasta2a.schema import AgentProvider, Artifact, Message, Skill, Task, TaskState
38
+ from fasta2a.storage import InMemoryStorage
39
+ from pydantic_ai import Agent
40
+ from starlette.applications import Starlette
41
+ from starlette.routing import BaseRoute, Mount
42
+ from starlette.types import ASGIApp
43
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
44
+ raise AIUnavailableError(f"the A2A server needs 'fasta2a': {SERVER_INSTALL_HINT}") from exc
45
+
46
+ PROVIDER = AgentProvider(
47
+ organization="startup-sbom", url="https://github.com/morpheuslord/Startup-SBOM"
48
+ )
49
+
50
+
51
+ class UTCStorage(InMemoryStorage): # type: ignore[misc] # fasta2a ships no types
52
+ """``InMemoryStorage`` whose task timestamps are UTC with a ``Z`` suffix.
53
+
54
+ fasta2a 2.1.1 writes ``datetime.now().isoformat()`` (naive, no zone); the official
55
+ a2a-sdk client fails to parse that protobuf timestamp. Worth fixing upstream.
56
+ """
57
+
58
+ @staticmethod
59
+ def _utc(task: Task) -> Task:
60
+ status = task.get("status")
61
+ if status is not None:
62
+ status["timestamp"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
63
+ return task
64
+
65
+ async def submit_task(self, context_id: str, message: Message) -> Task:
66
+ return self._utc(await super().submit_task(context_id, message))
67
+
68
+ async def update_task(
69
+ self,
70
+ task_id: str,
71
+ state: TaskState,
72
+ new_artifacts: list[Artifact] | None = None,
73
+ new_messages: list[Message] | None = None,
74
+ ) -> Task:
75
+ return self._utc(await super().update_task(task_id, state, new_artifacts, new_messages))
76
+
77
+
78
+ def agent_app(
79
+ agent: Agent[AgentDeps, Any],
80
+ deps: AgentDeps,
81
+ *,
82
+ name: str,
83
+ description: str,
84
+ url: str,
85
+ skills: list[Skill],
86
+ version: str,
87
+ extra_toolsets: bool = True,
88
+ ) -> FastA2A:
89
+ """One A2A app (card + JSON-RPC) around ``agent`` with deps bound in the lifespan."""
90
+ storage, broker = UTCStorage(), InMemoryBroker()
91
+ worker = AgentWorker(agent=agent, broker=broker, storage=storage)
92
+
93
+ @asynccontextmanager
94
+ async def lifespan(app: FastA2A) -> AsyncIterator[None]:
95
+ async with AsyncExitStack() as stack:
96
+ await stack.enter_async_context(app.task_manager)
97
+ await stack.enter_async_context(agent)
98
+ overrides = ExitStack()
99
+ stack.enter_context(overrides)
100
+ if extra_toolsets:
101
+ overrides.enter_context(agent.override(deps=deps, toolsets=[report_toolset()]))
102
+ else:
103
+ overrides.enter_context(agent.override(deps=deps))
104
+ await stack.enter_async_context(worker.run())
105
+ yield
106
+
107
+ return FastA2A(
108
+ storage=storage,
109
+ broker=broker,
110
+ name=name,
111
+ url=url,
112
+ version=version,
113
+ description=description,
114
+ provider=PROVIDER,
115
+ skills=skills,
116
+ default_input_modes=["text/plain"],
117
+ default_output_modes=["application/json"],
118
+ lifespan=lifespan,
119
+ )
120
+
121
+
122
+ def _skill(spec: AgentSpec) -> Skill:
123
+ return Skill(
124
+ id=spec.name,
125
+ name=spec.name.replace("_", " "),
126
+ description=spec.description,
127
+ tags=list(dict.fromkeys([*spec.tags, "security", "sbom"])),
128
+ input_modes=["text/plain"],
129
+ output_modes=["application/json"],
130
+ )
131
+
132
+
133
+ def build_a2a_apps(
134
+ report: Report,
135
+ *,
136
+ models: ModelSet,
137
+ registry: AgentRegistry,
138
+ base_url: str = "http://127.0.0.1:8770",
139
+ ) -> dict[str, FastA2A]:
140
+ """``{"": orchestrator app, "<name>": specialist app, ...}`` keyed by mount name."""
141
+ from startup_sbom import __version__
142
+
143
+ deps = AgentDeps(index=ReportIndex(report))
144
+ apps: dict[str, FastA2A] = {}
145
+ orchestrator = build_orchestrator(registry, models)
146
+ apps[""] = agent_app(
147
+ orchestrator,
148
+ deps,
149
+ name="startup-sbom-orchestrator",
150
+ description=(
151
+ "Lead analyst over one startup-sbom scan report: delegates to specialists "
152
+ "and returns one grounded, machine-checked assessment (read-only)."
153
+ ),
154
+ url=base_url,
155
+ skills=[_skill(spec) for spec in registry.specs()],
156
+ version=__version__,
157
+ extra_toolsets=False, # the orchestrator already owns its toolsets
158
+ )
159
+ for spec in registry.specs():
160
+ if spec.build is None:
161
+ continue # remote agents are consumed, never re-exposed
162
+ model = models.orchestrator if spec.tier != "cheap" else models.specialist
163
+ apps[spec.name] = agent_app(
164
+ spec.build(model),
165
+ deps,
166
+ name=f"startup-sbom-{spec.name.replace('_', '-')}",
167
+ description=spec.description,
168
+ url=f"{base_url}/agents/{spec.name}",
169
+ skills=[_skill(spec)],
170
+ version=__version__,
171
+ extra_toolsets=spec.inject_report_tools,
172
+ )
173
+ return apps
174
+
175
+
176
+ def build_a2a_app(
177
+ report: Report,
178
+ *,
179
+ models: ModelSet,
180
+ registry: AgentRegistry,
181
+ base_url: str = "http://127.0.0.1:8770",
182
+ token: str | None = None,
183
+ extra_routes: Sequence[BaseRoute] = (),
184
+ ) -> ASGIApp:
185
+ """One ASGI app: orchestrator at ``/``, specialists at ``/agents/<name>``.
186
+
187
+ Mounted Starlette apps get no lifespan events, so the parent lifespan enters each
188
+ sub-app's lifespan (task manager, ``agent.override(deps=...)``, worker) itself.
189
+ ``extra_routes`` (e.g. a ``Mount("/acp", ...)``) are matched before the root app.
190
+ """
191
+ apps = build_a2a_apps(report, models=models, registry=registry, base_url=base_url)
192
+
193
+ @asynccontextmanager
194
+ async def lifespan(_: Starlette) -> AsyncIterator[None]:
195
+ async with AsyncExitStack() as stack:
196
+ for sub in apps.values():
197
+ await stack.enter_async_context(sub.router.lifespan_context(sub))
198
+ yield
199
+
200
+ routes: list[BaseRoute] = [
201
+ Mount(f"/agents/{name}", app=sub) for name, sub in apps.items() if name
202
+ ]
203
+ routes.extend(extra_routes)
204
+ routes.append(Mount("/", app=apps[""]))
205
+ app: ASGIApp = Starlette(routes=routes, lifespan=lifespan)
206
+ if token:
207
+ app = TokenAuthMiddleware(app, token)
208
+ return app
startup_sbom/ai/acp.py ADDED
@@ -0,0 +1,274 @@
1
+ """ACP REST facade: serve the agent registry over the Agent Communication Protocol.
2
+
3
+ ``acp-sdk`` is archived (it merged into A2A in 2025 and breaks on uvicorn >= 0.35), so
4
+ this is a dependency-free Starlette implementation of the subset ACP clients use:
5
+ ``GET /ping``, ``GET /agents``, ``GET /agents/{name}`` (manifest), ``POST /runs``
6
+ (``sync`` | ``async`` | ``stream``) and ``GET /runs/{id}``. Only *local* agents
7
+ (built-ins and allow-listed plugins) are exposed; remote agents are never proxied.
8
+
9
+ Binds 127.0.0.1 by default. A bearer token (``STARTUP_SBOM_AGENT_TOKEN``) is
10
+ optional on loopback and **required** on any other interface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import ipaddress
17
+ import json
18
+ import uuid
19
+ from collections.abc import Awaitable, Callable
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from typing import Any
23
+
24
+ from startup_sbom.ai import INSTALL_HINT, AIConfigError, AIUnavailableError
25
+ from startup_sbom.ai.index import ReportIndex, sanitize
26
+ from startup_sbom.ai.orchestrator import ModelSet, build_orchestrator
27
+ from startup_sbom.ai.registry import AgentRegistry, AgentSpec
28
+ from startup_sbom.ai.tools import AgentDeps, report_toolset
29
+ from startup_sbom.models import Report
30
+ from startup_sbom.textsafe import clean
31
+
32
+ try:
33
+ from pydantic import BaseModel
34
+ from starlette.applications import Starlette
35
+ from starlette.requests import Request
36
+ from starlette.responses import JSONResponse, Response, StreamingResponse
37
+ from starlette.routing import Route
38
+ from starlette.types import ASGIApp, Receive, Scope, Send
39
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
40
+ raise AIUnavailableError(f"the agent servers need 'starlette': {INSTALL_HINT}") from exc
41
+
42
+ MAX_BODY_BYTES = 262_144
43
+ MAX_PROMPT_CHARS = 20_000
44
+ TOKEN_ENV = "STARTUP_SBOM_AGENT_TOKEN" # noqa: S105 - env var name, not a secret
45
+
46
+
47
+ def is_loopback_host(host: str) -> bool:
48
+ """Whether ``host`` only binds/means the loopback interface."""
49
+ if host in ("localhost", "localhost.localdomain"):
50
+ return True
51
+ try:
52
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
53
+ except ValueError:
54
+ return False
55
+
56
+
57
+ def require_token(host: str, token: str | None) -> None:
58
+ """Refuse to serve agents on a non-loopback interface without a bearer token."""
59
+ if token is None and not is_loopback_host(host):
60
+ raise AIConfigError(
61
+ f"refusing to bind {host!r} without authentication: set {TOKEN_ENV} (or bind 127.0.0.1)"
62
+ )
63
+
64
+
65
+ class TokenAuthMiddleware:
66
+ """Require ``Authorization: Bearer <token>`` on every request."""
67
+
68
+ def __init__(self, app: ASGIApp, token: str) -> None:
69
+ self.app = app
70
+ self._expected = f"Bearer {token}".encode()
71
+
72
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
73
+ if scope["type"] == "http":
74
+ headers = dict(scope.get("headers") or [])
75
+ if headers.get(b"authorization") != self._expected:
76
+ response = JSONResponse(
77
+ {"code": "unauthorized", "message": "missing or wrong bearer token"},
78
+ status_code=401,
79
+ )
80
+ await response(scope, receive, send)
81
+ return
82
+ await self.app(scope, receive, send)
83
+
84
+
85
+ # --------------------------------------------------------------------------- facade
86
+ Runner = Callable[[str], Awaitable[tuple[str, str]]]
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class _Served:
91
+ manifest: dict[str, Any]
92
+ run: Runner
93
+
94
+
95
+ def _manifest(spec: AgentSpec) -> dict[str, Any]:
96
+ return {
97
+ "name": spec.name,
98
+ "description": spec.description,
99
+ "metadata": {
100
+ "tags": list(spec.tags),
101
+ "framework": "pydantic-ai",
102
+ "capabilities": [{"name": "grounded-analysis"}],
103
+ "author": {"name": "startup-sbom"},
104
+ },
105
+ "input_content_types": ["text/plain"],
106
+ "output_content_types": ["application/json"],
107
+ }
108
+
109
+
110
+ def _runner(spec: AgentSpec, models: ModelSet, deps: AgentDeps) -> Runner:
111
+ model = models.orchestrator if spec.tier != "cheap" else models.specialist
112
+
113
+ async def run(prompt: str) -> tuple[str, str]:
114
+ assert spec.build is not None # noqa: S101 - remote specs are filtered out
115
+ agent = spec.build(model)
116
+ toolsets = [report_toolset()] if spec.inject_report_tools else None
117
+ result = await agent.run(prompt, deps=deps, toolsets=toolsets)
118
+ output = result.output
119
+ if isinstance(output, BaseModel):
120
+ return output.model_dump_json(), "application/json"
121
+ return json.dumps(sanitize(output), default=str), "application/json"
122
+
123
+ return run
124
+
125
+
126
+ def _now() -> str:
127
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
128
+
129
+
130
+ def _error(code: str, message: str, status: int) -> JSONResponse:
131
+ return JSONResponse({"code": code, "message": message}, status_code=status)
132
+
133
+
134
+ def build_acp_app(
135
+ report: Report,
136
+ *,
137
+ models: ModelSet,
138
+ registry: AgentRegistry,
139
+ include_orchestrator: bool = True,
140
+ token: str | None = None,
141
+ ) -> ASGIApp:
142
+ """The ACP facade app over ``registry`` (local agents) plus the orchestrator."""
143
+ deps = AgentDeps(index=ReportIndex(report))
144
+ served: dict[str, _Served] = {}
145
+ for spec in registry.specs():
146
+ if spec.build is None:
147
+ continue # never proxy remote agents
148
+ served[spec.name] = _Served(_manifest(spec), _runner(spec, models, deps))
149
+ if include_orchestrator:
150
+ orchestrator = build_orchestrator(registry, models)
151
+
152
+ async def run_orchestrator(prompt: str) -> tuple[str, str]:
153
+ result = await orchestrator.run(prompt, deps=deps)
154
+ return result.output.model_dump_json(), "application/json"
155
+
156
+ served["orchestrator"] = _Served(
157
+ {
158
+ "name": "orchestrator",
159
+ "description": (
160
+ "Lead analyst: delegates to the specialists and synthesises one "
161
+ "grounded assessment of the loaded scan report"
162
+ ),
163
+ "metadata": {"tags": ["orchestrator"], "framework": "pydantic-ai"},
164
+ "input_content_types": ["text/plain"],
165
+ "output_content_types": ["application/json"],
166
+ },
167
+ run_orchestrator,
168
+ )
169
+ runs: dict[str, dict[str, Any]] = {}
170
+ background: set[asyncio.Task[None]] = set()
171
+
172
+ async def ping(_: Request) -> Response:
173
+ return JSONResponse({})
174
+
175
+ async def list_agents(_: Request) -> Response:
176
+ return JSONResponse({"agents": [s.manifest for s in served.values()]})
177
+
178
+ async def get_agent(request: Request) -> Response:
179
+ entry = served.get(request.path_params["name"])
180
+ if entry is None:
181
+ return _error("not_found", "agent not found", 404)
182
+ return JSONResponse(entry.manifest)
183
+
184
+ async def get_run(request: Request) -> Response:
185
+ run = runs.get(request.path_params["run_id"])
186
+ if run is None:
187
+ return _error("not_found", "run not found", 404)
188
+ return JSONResponse(run)
189
+
190
+ async def create_run(request: Request) -> Response:
191
+ body_bytes = await request.body()
192
+ if len(body_bytes) > MAX_BODY_BYTES:
193
+ return _error("invalid_input", "request body too large", 413)
194
+ try:
195
+ body = json.loads(body_bytes)
196
+ except ValueError:
197
+ return _error("invalid_input", "body is not JSON", 400)
198
+ if not isinstance(body, dict):
199
+ return _error("invalid_input", "body must be a JSON object", 400)
200
+ entry = served.get(str(body.get("agent_name", "")))
201
+ if entry is None:
202
+ return _error("not_found", "agent not found", 404)
203
+ parts = [
204
+ str(part.get("content") or "")
205
+ for message in body.get("input") or []
206
+ if isinstance(message, dict)
207
+ for part in message.get("parts") or []
208
+ if isinstance(part, dict)
209
+ ]
210
+ prompt = clean("\n".join(parts), max_len=MAX_PROMPT_CHARS, keep_newlines=True)
211
+ run: dict[str, Any] = {
212
+ "run_id": str(uuid.uuid4()),
213
+ "agent_name": body["agent_name"],
214
+ "session_id": body.get("session_id"),
215
+ "status": "in-progress",
216
+ "output": [],
217
+ "error": None,
218
+ "created_at": _now(),
219
+ "finished_at": None,
220
+ }
221
+ runs[run["run_id"]] = run
222
+
223
+ async def execute() -> None:
224
+ try:
225
+ content, ctype = await entry.run(prompt)
226
+ run["output"] = [
227
+ {
228
+ "role": f"agent/{run['agent_name']}",
229
+ "parts": [{"content_type": ctype, "content": content}],
230
+ "created_at": _now(),
231
+ "completed_at": _now(),
232
+ }
233
+ ]
234
+ run["status"] = "completed"
235
+ except Exception as exc: # noqa: BLE001 - reported to the caller as a failed run
236
+ run["status"] = "failed"
237
+ run["error"] = {
238
+ "code": "server_error",
239
+ "message": clean(str(exc), max_len=300),
240
+ }
241
+ run["finished_at"] = _now()
242
+
243
+ mode = body.get("mode", "sync")
244
+ if mode == "async":
245
+ task = asyncio.create_task(execute())
246
+ background.add(task)
247
+ task.add_done_callback(background.discard)
248
+ return JSONResponse(run, status_code=202)
249
+ if mode == "stream":
250
+
251
+ async def sse() -> Any:
252
+ yield f"data: {json.dumps({'type': 'run.created', 'run': run})}\n\n"
253
+ await execute()
254
+ for message in run["output"]:
255
+ for part in message["parts"]:
256
+ yield f"data: {json.dumps({'type': 'message.part', 'part': part})}\n\n"
257
+ yield f"data: {json.dumps({'type': 'run.' + run['status'], 'run': run})}\n\n"
258
+
259
+ return StreamingResponse(sse(), media_type="text/event-stream")
260
+ await execute()
261
+ return JSONResponse(run)
262
+
263
+ app: ASGIApp = Starlette(
264
+ routes=[
265
+ Route("/ping", ping),
266
+ Route("/agents", list_agents),
267
+ Route("/agents/{name}", get_agent),
268
+ Route("/runs", create_run, methods=["POST"]),
269
+ Route("/runs/{run_id}", get_run),
270
+ ]
271
+ )
272
+ if token:
273
+ app = TokenAuthMiddleware(app, token)
274
+ return app