product-forge 0.2.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 (232) hide show
  1. forge_agent_bridge/__init__.py +11 -0
  2. forge_agent_bridge/acp.py +171 -0
  3. forge_agent_bridge/adapter.py +57 -0
  4. forge_agent_bridge/approvals.py +113 -0
  5. forge_agent_bridge/backup.py +67 -0
  6. forge_agent_bridge/broker.py +103 -0
  7. forge_agent_bridge/budgets.py +104 -0
  8. forge_agent_bridge/cli.py +191 -0
  9. forge_agent_bridge/collab.py +133 -0
  10. forge_agent_bridge/context.py +254 -0
  11. forge_agent_bridge/contracts.py +113 -0
  12. forge_agent_bridge/events.py +209 -0
  13. forge_agent_bridge/files.py +134 -0
  14. forge_agent_bridge/install/__init__.py +1 -0
  15. forge_agent_bridge/install/cli.py +99 -0
  16. forge_agent_bridge/install/lifecycle.py +254 -0
  17. forge_agent_bridge/integrity.py +112 -0
  18. forge_agent_bridge/intelligence.py +261 -0
  19. forge_agent_bridge/local_bridge.py +1430 -0
  20. forge_agent_bridge/metadata.py +72 -0
  21. forge_agent_bridge/orchestrator.py +126 -0
  22. forge_agent_bridge/pairing.py +144 -0
  23. forge_agent_bridge/pf_context.py +122 -0
  24. forge_agent_bridge/providers/__init__.py +0 -0
  25. forge_agent_bridge/providers/acp_adapter.py +233 -0
  26. forge_agent_bridge/providers/claude.py +292 -0
  27. forge_agent_bridge/providers/codex.py +223 -0
  28. forge_agent_bridge/providers/copilot.py +116 -0
  29. forge_agent_bridge/providers/fake.py +88 -0
  30. forge_agent_bridge/recovery.py +44 -0
  31. forge_agent_bridge/registry.py +189 -0
  32. forge_agent_bridge/runners.py +111 -0
  33. forge_agent_bridge/secrets.py +116 -0
  34. forge_agent_bridge/ssrf.py +108 -0
  35. forge_agent_bridge/ui/__init__.py +29 -0
  36. forge_agent_bridge/ui/i18n.py +143 -0
  37. forge_agent_bridge/ui/kit.py +689 -0
  38. forge_agent_bridge/ui/wizard.py +317 -0
  39. product_forge/__init__.py +13 -0
  40. product_forge/__main__.py +4 -0
  41. product_forge/_forge_portal.py +1165 -0
  42. product_forge/_installkit.py +1825 -0
  43. product_forge/agents/__init__.py +0 -0
  44. product_forge/agents/agent-ops-coordinator.md +17 -0
  45. product_forge/agents/bedrock_provider.py +120 -0
  46. product_forge/agents/data-analyst-coordinator.md +18 -0
  47. product_forge/agents/evals.py +53 -0
  48. product_forge/agents/executors/pf-extractor.md +16 -0
  49. product_forge/agents/executors/pf-judge.md +16 -0
  50. product_forge/agents/executors/pf-synthesizer.md +14 -0
  51. product_forge/agents/executors/pf-verifier.md +14 -0
  52. product_forge/agents/integration-coordinator.md +16 -0
  53. product_forge/agents/llm_runtime.py +143 -0
  54. product_forge/agents/planning-coordinator.md +17 -0
  55. product_forge/agents/policy.py +108 -0
  56. product_forge/agents/product-intel-coordinator.md +19 -0
  57. product_forge/agents/routing.py +54 -0
  58. product_forge/agents/runtime.py +139 -0
  59. product_forge/agents/tools.py +155 -0
  60. product_forge/agents/workflows.py +98 -0
  61. product_forge/analytics/__init__.py +0 -0
  62. product_forge/analytics/connectors.py +204 -0
  63. product_forge/analytics/engine.py +163 -0
  64. product_forge/analytics/metrics.py +70 -0
  65. product_forge/analytics/sqlvalidate.py +150 -0
  66. product_forge/api/__init__.py +0 -0
  67. product_forge/api/app.py +1625 -0
  68. product_forge/cli/__init__.py +706 -0
  69. product_forge/config.py +120 -0
  70. product_forge/dashboards/__init__.py +0 -0
  71. product_forge/dashboards/render.py +70 -0
  72. product_forge/delegates/__init__.py +1 -0
  73. product_forge/delegates/catalog.py +45 -0
  74. product_forge/delegates/runner.py +66 -0
  75. product_forge/domain/__init__.py +1 -0
  76. product_forge/domain/models.py +365 -0
  77. product_forge/errors.py +68 -0
  78. product_forge/gateway/__init__.py +1 -0
  79. product_forge/gateway/synthesis.py +85 -0
  80. product_forge/install/__init__.py +1 -0
  81. product_forge/install/cli.py +89 -0
  82. product_forge/install/lifecycle.py +348 -0
  83. product_forge/install/managed_runtime.py +192 -0
  84. product_forge/integrations/__init__.py +0 -0
  85. product_forge/integrations/apispec.py +115 -0
  86. product_forge/integrations/aws.py +155 -0
  87. product_forge/integrations/azure.py +100 -0
  88. product_forge/integrations/datacontract.py +71 -0
  89. product_forge/intel/__init__.py +1 -0
  90. product_forge/intel/experiments.py +74 -0
  91. product_forge/intel/health.py +116 -0
  92. product_forge/intel/notebooks.py +58 -0
  93. product_forge/jobs/__init__.py +1 -0
  94. product_forge/jobs/registry.py +140 -0
  95. product_forge/knowledge/__init__.py +0 -0
  96. product_forge/knowledge/extract.py +88 -0
  97. product_forge/knowledge/graph.py +41 -0
  98. product_forge/knowledge/ingest.py +46 -0
  99. product_forge/mcp/__init__.py +0 -0
  100. product_forge/mcp/server.py +197 -0
  101. product_forge/notifications.py +86 -0
  102. product_forge/operations/__init__.py +0 -0
  103. product_forge/operations/approvals.py +46 -0
  104. product_forge/operations/proposals.py +90 -0
  105. product_forge/operations/reports.py +57 -0
  106. product_forge/operations/watches.py +98 -0
  107. product_forge/planning/__init__.py +0 -0
  108. product_forge/planning/prd.py +54 -0
  109. product_forge/planning/prioritize.py +41 -0
  110. product_forge/planning/roadmap.py +34 -0
  111. product_forge/portal/__init__.py +0 -0
  112. product_forge/portal/adapter.py +1030 -0
  113. product_forge/portal/runtime.py +519 -0
  114. product_forge/portal_assets/angular/3rdpartylicenses.txt +378 -0
  115. product_forge/portal_assets/angular/browser/chunk-1Rc4lGsF.js +1 -0
  116. product_forge/portal_assets/angular/browser/chunk-3CbjAJ_e.js +1 -0
  117. product_forge/portal_assets/angular/browser/chunk-6Q7VGqCu.js +5 -0
  118. product_forge/portal_assets/angular/browser/chunk-7Pu1Blam.js +1 -0
  119. product_forge/portal_assets/angular/browser/chunk-9tQEpXMK.js +1 -0
  120. product_forge/portal_assets/angular/browser/chunk-B3Nny4Hq.js +1 -0
  121. product_forge/portal_assets/angular/browser/chunk-B57_NLXQ.js +1 -0
  122. product_forge/portal_assets/angular/browser/chunk-BB5JZCTs.js +1 -0
  123. product_forge/portal_assets/angular/browser/chunk-BE-Wy_fS.js +1 -0
  124. product_forge/portal_assets/angular/browser/chunk-BF7hAyQJ.js +1 -0
  125. product_forge/portal_assets/angular/browser/chunk-BUHV7CDc.js +1 -0
  126. product_forge/portal_assets/angular/browser/chunk-BXHyUfJw.js +1 -0
  127. product_forge/portal_assets/angular/browser/chunk-BYYLUO5t.js +1 -0
  128. product_forge/portal_assets/angular/browser/chunk-BabKVLA2.js +1 -0
  129. product_forge/portal_assets/angular/browser/chunk-BaebHyDv.js +1 -0
  130. product_forge/portal_assets/angular/browser/chunk-BcvxjRzt.js +6 -0
  131. product_forge/portal_assets/angular/browser/chunk-BpaNj9LV.js +5 -0
  132. product_forge/portal_assets/angular/browser/chunk-Bsd27yxw.js +1 -0
  133. product_forge/portal_assets/angular/browser/chunk-BuoTaa1i.js +1 -0
  134. product_forge/portal_assets/angular/browser/chunk-BvumviSm.js +1 -0
  135. product_forge/portal_assets/angular/browser/chunk-Bx56UW4B.js +1 -0
  136. product_forge/portal_assets/angular/browser/chunk-C-syDP2A.js +1 -0
  137. product_forge/portal_assets/angular/browser/chunk-C0Y1QJAk.js +1 -0
  138. product_forge/portal_assets/angular/browser/chunk-C0nXO1-7.js +1 -0
  139. product_forge/portal_assets/angular/browser/chunk-C18uha2k.js +1 -0
  140. product_forge/portal_assets/angular/browser/chunk-CBAf-gZm.js +1 -0
  141. product_forge/portal_assets/angular/browser/chunk-CFk9O-Id.js +1 -0
  142. product_forge/portal_assets/angular/browser/chunk-CI7NSb1M.js +1 -0
  143. product_forge/portal_assets/angular/browser/chunk-CLPLzFYs.js +322 -0
  144. product_forge/portal_assets/angular/browser/chunk-COrc5dSH.js +1 -0
  145. product_forge/portal_assets/angular/browser/chunk-CSXp8raW.js +1 -0
  146. product_forge/portal_assets/angular/browser/chunk-CTSic6yw.js +5 -0
  147. product_forge/portal_assets/angular/browser/chunk-C_7Rm93G.js +1 -0
  148. product_forge/portal_assets/angular/browser/chunk-Cbyie2QS.js +3 -0
  149. product_forge/portal_assets/angular/browser/chunk-Ci5zXqV6.js +1 -0
  150. product_forge/portal_assets/angular/browser/chunk-CkDgYK04.js +5 -0
  151. product_forge/portal_assets/angular/browser/chunk-CsgUuQ0S.js +1 -0
  152. product_forge/portal_assets/angular/browser/chunk-Ct1MVd7z.js +1 -0
  153. product_forge/portal_assets/angular/browser/chunk-Cut2Ourb.js +1 -0
  154. product_forge/portal_assets/angular/browser/chunk-D-7Jr8A3.js +1 -0
  155. product_forge/portal_assets/angular/browser/chunk-DJsvMftW.js +1 -0
  156. product_forge/portal_assets/angular/browser/chunk-DUq33ZpV.js +1 -0
  157. product_forge/portal_assets/angular/browser/chunk-DaUeWbRk.js +1 -0
  158. product_forge/portal_assets/angular/browser/chunk-DfrvIVuy.js +1 -0
  159. product_forge/portal_assets/angular/browser/chunk-DicAb5_-.js +1 -0
  160. product_forge/portal_assets/angular/browser/chunk-DixPCieV.js +1 -0
  161. product_forge/portal_assets/angular/browser/chunk-DtpQQeVl.js +1 -0
  162. product_forge/portal_assets/angular/browser/chunk-DuZy_X7l.js +1 -0
  163. product_forge/portal_assets/angular/browser/chunk-DvwzUgSZ.js +3 -0
  164. product_forge/portal_assets/angular/browser/chunk-EiSuk_kZ.js +1 -0
  165. product_forge/portal_assets/angular/browser/chunk-H_dZuloj.js +1 -0
  166. product_forge/portal_assets/angular/browser/chunk-JrAEILC6.js +1 -0
  167. product_forge/portal_assets/angular/browser/chunk-S0yVTZM9.js +1 -0
  168. product_forge/portal_assets/angular/browser/chunk-Tfobdq9w.js +1 -0
  169. product_forge/portal_assets/angular/browser/chunk-Wd_5yY1t.js +1 -0
  170. product_forge/portal_assets/angular/browser/chunk-X_IBVudt.js +1 -0
  171. product_forge/portal_assets/angular/browser/chunk-XtegKBNb.js +1 -0
  172. product_forge/portal_assets/angular/browser/chunk-jdQ8jbI8.js +1 -0
  173. product_forge/portal_assets/angular/browser/chunk-k_4TaJG-.js +1 -0
  174. product_forge/portal_assets/angular/browser/chunk-kwRnGzdM.js +1 -0
  175. product_forge/portal_assets/angular/browser/chunk-nGNTa8fb.js +1 -0
  176. product_forge/portal_assets/angular/browser/chunk-nf7vIxR7.js +1 -0
  177. product_forge/portal_assets/angular/browser/favicon.ico +0 -0
  178. product_forge/portal_assets/angular/browser/index.html +13 -0
  179. product_forge/portal_assets/angular/browser/main-SCGIMYBN.js +1 -0
  180. product_forge/portal_assets/angular/browser/manifest.json +11 -0
  181. product_forge/portal_assets/angular/browser/styles-WNQNXSWU.css +1 -0
  182. product_forge/portal_assets/app.js +16 -0
  183. product_forge/portal_assets/index.html +28 -0
  184. product_forge/portal_assets/style.css +7 -0
  185. product_forge/reports/__init__.py +0 -0
  186. product_forge/security.py +158 -0
  187. product_forge/services/__init__.py +0 -0
  188. product_forge/skills/index.json +70 -0
  189. product_forge/skills/pf-agent-factory/SKILL.md +45 -0
  190. product_forge/skills/pf-agent-factory/agents/openai.yaml +4 -0
  191. product_forge/skills/pf-analytics/SKILL.md +43 -0
  192. product_forge/skills/pf-analytics/agents/openai.yaml +4 -0
  193. product_forge/skills/pf-apis/SKILL.md +40 -0
  194. product_forge/skills/pf-apis/agents/openai.yaml +4 -0
  195. product_forge/skills/pf-aws/SKILL.md +39 -0
  196. product_forge/skills/pf-aws/agents/openai.yaml +4 -0
  197. product_forge/skills/pf-azure/SKILL.md +38 -0
  198. product_forge/skills/pf-azure/agents/openai.yaml +4 -0
  199. product_forge/skills/pf-dashboards/SKILL.md +42 -0
  200. product_forge/skills/pf-dashboards/agents/openai.yaml +4 -0
  201. product_forge/skills/pf-data-products/SKILL.md +39 -0
  202. product_forge/skills/pf-data-products/agents/openai.yaml +4 -0
  203. product_forge/skills/pf-finops/SKILL.md +39 -0
  204. product_forge/skills/pf-finops/agents/openai.yaml +4 -0
  205. product_forge/skills/pf-governance/SKILL.md +39 -0
  206. product_forge/skills/pf-governance/agents/openai.yaml +4 -0
  207. product_forge/skills/pf-knowledge/SKILL.md +41 -0
  208. product_forge/skills/pf-knowledge/agents/openai.yaml +4 -0
  209. product_forge/skills/pf-prioritization/SKILL.md +39 -0
  210. product_forge/skills/pf-prioritization/agents/openai.yaml +4 -0
  211. product_forge/skills/pf-product-discovery/SKILL.md +42 -0
  212. product_forge/skills/pf-product-discovery/agents/openai.yaml +4 -0
  213. product_forge/skills/pf-product-strategy/SKILL.md +44 -0
  214. product_forge/skills/pf-product-strategy/agents/openai.yaml +4 -0
  215. product_forge/skills/pf-reporting/SKILL.md +39 -0
  216. product_forge/skills/pf-reporting/agents/openai.yaml +4 -0
  217. product_forge/skills/pf-requirements/SKILL.md +39 -0
  218. product_forge/skills/pf-requirements/agents/openai.yaml +4 -0
  219. product_forge/skills/pf-sql/SKILL.md +45 -0
  220. product_forge/skills/pf-sql/agents/openai.yaml +4 -0
  221. product_forge/store/__init__.py +3 -0
  222. product_forge/store/postgres.py +265 -0
  223. product_forge/store/sqlite.py +373 -0
  224. product_forge/ui/__init__.py +29 -0
  225. product_forge/ui/i18n.py +143 -0
  226. product_forge/ui/kit.py +689 -0
  227. product_forge/ui/wizard.py +282 -0
  228. product_forge-0.2.0.dist-info/METADATA +175 -0
  229. product_forge-0.2.0.dist-info/RECORD +232 -0
  230. product_forge-0.2.0.dist-info/WHEEL +4 -0
  231. product_forge-0.2.0.dist-info/entry_points.txt +2 -0
  232. product_forge-0.2.0.dist-info/licenses/LICENSE +182 -0
@@ -0,0 +1,11 @@
1
+ """Forge Agent Bridge — universal agent session layer.
2
+
3
+ The Bridge owns provider connections, session lifecycle, normalized
4
+ events and approval routing. The Portal owns experience; the agent
5
+ runtime executes; policies authorize. Public surface only — consumers
6
+ (product-forge, forge-portal) import these contracts, never internals.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+ EVENT_SCHEMA = "forge/AgentBridgeEvent/v1"
11
+ CONTRACT_SCHEMA = "forge/AgentBridgeContract/v1"
@@ -0,0 +1,171 @@
1
+ """ACP client (§19.2) — JSON-RPC over stdio: handshake, version
2
+ negotiation, session lifecycle, prompt, streaming, cancel.
3
+
4
+ Transport: newline-delimited JSON-RPC 2.0 on the agent's stdin/stdout.
5
+ No SDK is vendored; the wire contract is documented ACP semantics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ import json
12
+ import subprocess
13
+ import threading
14
+ from collections.abc import Iterator
15
+ from typing import Any
16
+
17
+ from .adapter import BridgeError, ProviderUnavailableError
18
+
19
+ _ids = itertools.count(1)
20
+ PROTOCOL_VERSION = 1
21
+
22
+
23
+ class AcpClient:
24
+ """Minimal ACP stdio client. One client per spawned agent process."""
25
+
26
+ def __init__(self, cmd: list[str], timeout_s: float = 30.0):
27
+ self.cmd, self.timeout_s = cmd, timeout_s
28
+ self.proc: subprocess.Popen[bytes] | None = None
29
+ self._pending: dict[int, dict[str, Any]] = {}
30
+ self._notifications: list[dict[str, Any]] = []
31
+ self._lock = threading.Lock()
32
+ self._reader: threading.Thread | None = None
33
+ self.protocol_version: int | None = None
34
+
35
+ # -- transport ---------------------------------------------------------
36
+
37
+ def start(self) -> None:
38
+ try:
39
+ self.proc = subprocess.Popen(
40
+ self.cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
41
+ stderr=subprocess.DEVNULL)
42
+ except FileNotFoundError as exc:
43
+ raise ProviderUnavailableError(f"agent executable not found: {self.cmd[0]}") from exc
44
+ self._reader = threading.Thread(target=self._read_loop, daemon=True)
45
+ self._reader.start()
46
+
47
+ def _read_loop(self) -> None:
48
+ assert self.proc and self.proc.stdout
49
+ for line in self.proc.stdout:
50
+ try:
51
+ msg = json.loads(line)
52
+ except json.JSONDecodeError:
53
+ continue
54
+ mid = msg.get("id")
55
+ if mid in self._pending:
56
+ self._pending[mid] = msg
57
+ else:
58
+ self._notifications.append(msg)
59
+
60
+ def request_async(self, method: str,
61
+ params: dict[str, Any] | None = None) -> int:
62
+ """Send a request without waiting; returns the request id."""
63
+ if not self.proc or not self.proc.stdin:
64
+ raise BridgeError("FAB-TRANSPORT", "client not started")
65
+ rid = next(_ids)
66
+ self._pending[rid] = {}
67
+ self.proc.stdin.write(
68
+ json.dumps({"jsonrpc": "2.0", "id": rid, "method": method,
69
+ "params": params or {}}).encode() + b"\n")
70
+ self.proc.stdin.flush()
71
+ return rid
72
+
73
+ def response_ready(self, rid: int) -> dict[str, Any] | None:
74
+ return self._pending.get(rid) or None
75
+
76
+ def resolve(self, rid: int) -> dict[str, Any]:
77
+ """Pop a ready response; raises on RPC error."""
78
+ resp = self._pending.pop(rid, {})
79
+ if "error" in resp:
80
+ e = resp["error"]
81
+ raise BridgeError(f"FAB-RPC-{e.get('code', 'ERR')}",
82
+ str(e.get("message", e)))
83
+ return resp.get("result", {})
84
+
85
+ def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
86
+ rid = self.request_async(method, params)
87
+ import time
88
+ deadline = time.monotonic() + self.timeout_s
89
+ while time.monotonic() < deadline:
90
+ if self.response_ready(rid):
91
+ return self.resolve(rid)
92
+ time.sleep(0.01)
93
+ self._pending.pop(rid, None)
94
+ raise BridgeError("FAB-TIMEOUT", f"{method} timed out after {self.timeout_s}s")
95
+
96
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
97
+ if self.proc and self.proc.stdin:
98
+ self.proc.stdin.write(
99
+ json.dumps({"jsonrpc": "2.0", "method": method,
100
+ "params": params or {}}).encode() + b"\n")
101
+ self.proc.stdin.flush()
102
+
103
+ def drain_notifications(self) -> list[dict[str, Any]]:
104
+ """Non-blocking: pop all queued notifications (turn already done)."""
105
+ with self._lock:
106
+ out, self._notifications = self._notifications, []
107
+ return out
108
+
109
+ def notifications(self) -> Iterator[dict[str, Any]]:
110
+ while True:
111
+ if self._notifications:
112
+ yield self._notifications.pop(0)
113
+ else:
114
+ import time
115
+ time.sleep(0.02)
116
+ if self.proc and self.proc.poll() is not None and not self._notifications:
117
+ return
118
+
119
+ # -- ACP semantics -------------------------------------------------------
120
+
121
+ def initialize(self) -> dict[str, Any]:
122
+ res = self.request("initialize", {
123
+ "protocolVersion": PROTOCOL_VERSION,
124
+ "clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True},
125
+ "terminal": False}})
126
+ self.protocol_version = res.get("protocolVersion", PROTOCOL_VERSION)
127
+ return res
128
+
129
+ def new_session(self, cwd: str = ".", mcp_servers: list[dict] | None = None) -> str:
130
+ res = self.request("session/new", {"cwd": cwd, "mcpServers": mcp_servers or []})
131
+ sid = res.get("sessionId")
132
+ if not sid:
133
+ raise BridgeError("FAB-SESSION", "session/new returned no sessionId")
134
+ return str(sid)
135
+
136
+ def prompt(self, session_id: str, text: str) -> dict[str, Any]:
137
+ return self.request("session/prompt", {
138
+ "sessionId": session_id,
139
+ "prompt": [{"type": "text", "text": text}]})
140
+
141
+ def prompt_async(self, session_id: str, text: str) -> int:
142
+ """Start a turn without blocking — notifications stream while the
143
+ response is in flight. Resolve via resolve(rid)."""
144
+ return self.request_async("session/prompt", {
145
+ "sessionId": session_id,
146
+ "prompt": [{"type": "text", "text": text}]})
147
+
148
+ def load_session(self, session_id: str, cwd: str = ".",
149
+ mcp_servers: list[dict] | None = None) -> dict[str, Any]:
150
+ """ACP session/load — resumes a previous session, replaying its
151
+ transcript as session/update notifications."""
152
+ return self.request("session/load", {
153
+ "sessionId": session_id, "cwd": cwd,
154
+ "mcpServers": mcp_servers or []})
155
+
156
+ def cancel(self, session_id: str) -> None:
157
+ self.notify("session/cancel", {"sessionId": session_id})
158
+
159
+ def close(self) -> None:
160
+ if self.proc:
161
+ with _suppress_all():
162
+ self.proc.terminate()
163
+ self.proc.wait(timeout=3)
164
+ if self.proc.poll() is None:
165
+ self.proc.kill()
166
+ self.proc = None
167
+
168
+
169
+ class _suppress_all:
170
+ def __enter__(self): return None
171
+ def __exit__(self, *a): return True
@@ -0,0 +1,57 @@
1
+ """Provider adapter interface (§5.2) — typed errors, no simulated capability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from .contracts import AgentSession, ProviderDescriptor
9
+
10
+
11
+ class BridgeError(Exception):
12
+ """Typed bridge error — code is part of the contract."""
13
+
14
+ def __init__(self, code: str, message: str, *, provider: str | None = None):
15
+ super().__init__(message)
16
+ self.code, self.message, self.provider = code, message, provider
17
+
18
+
19
+ class ProviderUnavailableError(BridgeError):
20
+ def __init__(self, msg: str, provider: str | None = None):
21
+ super().__init__("FAB-UNAVAILABLE", msg, provider=provider)
22
+
23
+
24
+ class UnsupportedCapabilityError(BridgeError):
25
+ def __init__(self, capability: str, provider: str | None = None):
26
+ super().__init__("FAB-UNSUPPORTED",
27
+ f"capability '{capability}' not supported", provider=provider)
28
+
29
+
30
+ class AuthenticationError(BridgeError):
31
+ def __init__(self, msg: str, provider: str | None = None):
32
+ super().__init__("FAB-AUTH", msg, provider=provider)
33
+
34
+
35
+ class SessionError(BridgeError):
36
+ pass
37
+
38
+
39
+ @runtime_checkable
40
+ class ProviderAdapter(Protocol):
41
+ """§5.2 contract — unsupported features raise UnsupportedCapabilityError
42
+ or report unavailable state; never simulated behavior."""
43
+
44
+ def detect(self) -> ProviderDescriptor: ...
45
+ def get_version(self) -> str | None: ...
46
+ def get_auth_status(self) -> str: ...
47
+ def initialize(self) -> None: ...
48
+ def create_session(self, **kwargs: Any) -> AgentSession: ...
49
+ def resume_session(self, session_ref: str) -> AgentSession: ...
50
+ def list_sessions(self) -> list[AgentSession]: ...
51
+ def send_prompt(self, session_id: str, prompt: str,
52
+ attachments: list[dict[str, Any]] | None = None) -> None: ...
53
+ def stream_events(self, session_id: str) -> Iterator[dict[str, Any]]: ...
54
+ def respond_to_approval(self, approval_id: str, approved: bool) -> None: ...
55
+ def cancel(self, session_id: str) -> None: ...
56
+ def close_session(self, session_id: str) -> None: ...
57
+ def dispose(self) -> None: ...
@@ -0,0 +1,113 @@
1
+ """Approval tokens — bound, expiring, single-use authorizations (§25.4).
2
+
3
+ Replaces client-supplied ``approved: true`` booleans. The server mints an
4
+ approval request when a mutating op is attempted; the actor resolves it;
5
+ the resolved token's argument digest must match the retried operation
6
+ exactly — argument tampering invalidates it. Tokens are one-time.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import secrets as _secrets
13
+ import threading
14
+ import time
15
+ from typing import Any
16
+
17
+ from .adapter import BridgeError
18
+
19
+ TTL_S = 600.0 # 10 min
20
+
21
+
22
+ def digest_args(args: dict[str, Any]) -> str:
23
+ """Stable digest of the operation arguments being approved."""
24
+ canon = json.dumps(args, sort_keys=True, separators=(",", ":"))
25
+ return hashlib.sha256(canon.encode()).hexdigest()
26
+
27
+
28
+ class ApprovalStore:
29
+ """Pending and resolved approvals; resolved grants are one-time."""
30
+
31
+ def __init__(self) -> None:
32
+ self._pending: dict[str, dict[str, Any]] = {}
33
+ self._grants: dict[str, dict[str, Any]] = {}
34
+ self._lock = threading.Lock()
35
+
36
+ def request(self, *, session_id: str, actor: str, resource: str,
37
+ action: str, args: dict[str, Any], risk: str = "medium",
38
+ scope: str = "session", turn_id: str = "") -> dict[str, Any]:
39
+ """Create a pending approval bound to op+args digest."""
40
+ ap = {
41
+ "approval_id": f"ap_{_secrets.token_hex(8)}",
42
+ "session_id": session_id, "turn_id": turn_id,
43
+ "actor": actor, "resource": resource, "action": action,
44
+ "args_digest": digest_args(args), "scope": scope, "risk": risk,
45
+ "requested_at": time.time(), "expires_at": time.time() + TTL_S,
46
+ "state": "pending",
47
+ }
48
+ self._pending[ap["approval_id"]] = ap
49
+ return dict(ap)
50
+
51
+ def resolve(self, approval_id: str, *, approved: bool,
52
+ actor: str) -> dict[str, Any]:
53
+ with self._lock:
54
+ ap = self._pending.pop(approval_id, None)
55
+ if not ap:
56
+ raise BridgeError("FAB-NOT-FOUND", "unknown approval_id")
57
+ if time.time() > ap["expires_at"]:
58
+ raise BridgeError("FAB-VALIDATION", "approval request expired")
59
+ if not approved:
60
+ return {"resolved": True, "granted": False,
61
+ "approval_id": approval_id}
62
+ token = _secrets.token_urlsafe(24)
63
+ self._grants[token] = {
64
+ "approval_id": approval_id, "session_id": ap["session_id"],
65
+ "actor": actor, "requester": ap["actor"],
66
+ "resource": ap["resource"], "action": ap["action"],
67
+ "args_digest": ap["args_digest"], "scope": ap["scope"],
68
+ "risk": ap["risk"], "decided_at": time.time(),
69
+ "expires_at": time.time() + TTL_S, "consumed": False,
70
+ }
71
+ return {"resolved": True, "granted": True,
72
+ "approval_id": approval_id, "approval_token": token,
73
+ "expires_at": self._grants[token]["expires_at"]}
74
+
75
+ def consume(self, token: str, *, session_id: str, resource: str,
76
+ action: str, args: dict[str, Any],
77
+ actor: str | None = None) -> dict[str, Any]:
78
+ """Verify + consume a grant: op must match digest exactly, once.
79
+
80
+ Consumption is atomic under the store lock: two simultaneous
81
+ requests cannot both observe ``consumed == False``.
82
+ """
83
+ with self._lock:
84
+ g = self._grants.get(token)
85
+ if not g:
86
+ raise BridgeError("FAB-FORBIDDEN", "invalid approval token")
87
+ if g["consumed"]:
88
+ raise BridgeError("FAB-FORBIDDEN",
89
+ "approval token already consumed")
90
+ if time.time() > g["expires_at"]:
91
+ raise BridgeError("FAB-FORBIDDEN", "approval token expired")
92
+ if g["session_id"] != session_id or g["resource"] != resource \
93
+ or g["action"] != action:
94
+ raise BridgeError("FAB-FORBIDDEN",
95
+ "approval token does not cover this operation")
96
+ if g["args_digest"] != digest_args(args):
97
+ raise BridgeError("FAB-FORBIDDEN",
98
+ "operation arguments differ from approved")
99
+ if actor is not None and g["requester"] and \
100
+ actor != g["requester"]:
101
+ raise BridgeError(
102
+ "FAB-FORBIDDEN",
103
+ "approval was requested by a different actor")
104
+ g["consumed"] = True
105
+ return {"consumed": True, "approval_id": g["approval_id"],
106
+ "actor": g["actor"]}
107
+
108
+ def pending_for(self, session_id: str) -> list[dict[str, Any]]:
109
+ return [dict(a) for a in self._pending.values()
110
+ if a["session_id"] == session_id]
111
+
112
+
113
+ APPROVALS = ApprovalStore()
@@ -0,0 +1,67 @@
1
+ """Cycle 28.6 — backup/restore of bridge state.
2
+
3
+ Backs up: session metadata, event history, context bindings, MCP registry,
4
+ budgets, collab, audit. **Never** backs up secrets: pair.token, pairing
5
+ device tokens, and any *.secret/keyring files are excluded by construction.
6
+ Manifest records what was captured so restore is verifiable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import tarfile
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # never exported — credentials must not leave the device (§25.8, §28.6)
19
+ EXCLUDED = {"pair.token", "pairing.json", "secrets.json", "keyring.json"}
20
+ INCLUDED_SUFFIXES = (".json", ".jsonl")
21
+
22
+
23
+ def create_backup(state_dir: Path, out: Path | None = None) -> dict[str, Any]:
24
+ """Tarball of non-secret state under `state_dir` → out (or state_dir/)."""
25
+ out = out or (state_dir / f"backup-{int(time.time())}.tar.gz")
26
+ manifest: dict[str, Any] = {"created_at": time.time(),
27
+ "files": [], "excluded": []}
28
+ with tempfile.TemporaryDirectory() as td, \
29
+ tarfile.open(out, "w:gz") as tar:
30
+ for f in sorted(state_dir.rglob("*")):
31
+ if not f.is_file():
32
+ continue
33
+ rel = f.relative_to(state_dir)
34
+ if f.name in EXCLUDED or any(
35
+ p.name in EXCLUDED for p in rel.parents):
36
+ manifest["excluded"].append(str(rel))
37
+ continue
38
+ if f.suffix not in INCLUDED_SUFFIXES:
39
+ manifest["excluded"].append(str(rel))
40
+ continue
41
+ tar.add(f, arcname=str(rel))
42
+ manifest["files"].append(
43
+ {"path": str(rel), "bytes": f.stat().st_size})
44
+ mf = Path(td) / "manifest.json"
45
+ mf.write_text(json.dumps(manifest, indent=1))
46
+ tar.add(mf, arcname="manifest.json")
47
+ manifest["out"] = str(out)
48
+ return manifest
49
+
50
+
51
+ def restore_backup(backup: Path, state_dir: Path) -> dict[str, Any]:
52
+ """Extract a backup into `state_dir`. Refuses secret paths — a backup
53
+ must never inject credentials (they are re-paired, not restored)."""
54
+ restored, refused = [], []
55
+ with tarfile.open(backup) as tar:
56
+ for m in tar.getmembers():
57
+ name = Path(m.name).name
58
+ if m.name.startswith("/") or ".." in Path(m.name).parts:
59
+ refused.append(m.name) # traversal guard
60
+ continue
61
+ if name in EXCLUDED:
62
+ refused.append(m.name) # no secrets restore
63
+ continue
64
+ if m.isfile():
65
+ tar.extract(m, state_dir, filter="data")
66
+ restored.append(m.name)
67
+ return {"restored": restored, "refused": refused}
@@ -0,0 +1,103 @@
1
+ """Permission broker — central authorization decisions (§25.3).
2
+
3
+ Validates identity, session, product/workspace, operation, tool, policy;
4
+ decides allow / deny / require-approval; records the decision. The broker
5
+ complements — never replaces — the owning service's own authorization.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any
11
+
12
+ from .adapter import BridgeError
13
+
14
+ # Operations that always require a bound approval token before execution.
15
+ APPROVAL_REQUIRED: dict[tuple[str, str], str] = {
16
+ ("files", "upload"): "file-write",
17
+ ("files", "restore"): "file-write",
18
+ ("sessions", "spawn"): "process-spawn",
19
+ ("runners", "register"): "runner-registration",
20
+ }
21
+ # Risk classification per action family.
22
+ RISK = {"file-write": "high", "process-spawn": "high",
23
+ "runner-registration": "medium", "prompt": "low",
24
+ "share": "medium", "approve": "medium", "handoff": "high"}
25
+
26
+
27
+ def require_scope(actor: dict[str, Any], required: str) -> None:
28
+ """Enforce route permissions; a conversational device is not admin."""
29
+ scopes = {str(s) for s in (actor.get("scopes") or [])}
30
+ if actor.get("kind") == "master" or "admin" in scopes or required in scopes:
31
+ return
32
+ grants = {
33
+ "agent:read": {"agent", "agent:read", "session:read", "provider:read"},
34
+ "agent:execute": {"agent", "agent:execute", "session:manage"},
35
+ "session:read": {"agent", "agent:read", "session:read"},
36
+ "session:manage": {"agent", "agent:execute", "session:manage"},
37
+ "provider:read": {"agent", "agent:read", "provider:read"},
38
+ "approval:read": {"agent", "agent:read", "approval:read"},
39
+ "approval:resolve": {"agent", "agent:execute", "approval:resolve"},
40
+ "files:read": {"agent", "agent:read", "files:read"},
41
+ }
42
+ if not scopes.intersection(grants.get(required, set())):
43
+ raise BridgeError("FAB-FORBIDDEN", f"scope required: {required}")
44
+
45
+
46
+ class Decision(dict):
47
+ """Authorization decision record — auditable."""
48
+
49
+
50
+ class Broker:
51
+ """Central op authorization; decisions are recorded, never silent."""
52
+
53
+ def __init__(self) -> None:
54
+ self.log: list[dict[str, Any]] = []
55
+
56
+ def authorize(self, *, actor: dict[str, Any] | None, op: str,
57
+ resource: str, session_id: str = "",
58
+ product_id: str = "", workspace_id: str = "",
59
+ tool: str = "", args: dict[str, Any] | None = None,
60
+ principal: str | None = None,
61
+ collab: Any = None) -> dict[str, Any]:
62
+ """Return {allow, require_approval, risk, reason} — or raise FORBIDDEN.
63
+
64
+ Order: identity → operation risk → ownership/observer → approval need.
65
+ """
66
+ args = args or {}
67
+ if actor is None:
68
+ self._record("deny", op, resource, "", "no-identity")
69
+ raise BridgeError("FAB-AUTH", "authentication required")
70
+
71
+ # identity: session tokens carry scopes; master carries 'admin'
72
+ scopes = actor.get("scopes") or ["admin"]
73
+ risk = RISK.get(op.split(":")[-1] if ":" in op else op, "medium")
74
+
75
+ # session-scope authorization via collab when a session is bound —
76
+ # ``principal`` is the caller-resolved actor (session tokens can't
77
+ # impersonate; master may assert for local dev).
78
+ if session_id and collab is not None:
79
+ who = principal or actor.get("actor", "local")
80
+ if op not in ("read", "comment"):
81
+ collab.assert_can_act(session_id, who) # raises
82
+
83
+ key = (resource.split(":")[0], op.split(":")[-1])
84
+ needs = APPROVAL_REQUIRED.get(key)
85
+ dec = {
86
+ "allow": True, "require_approval": needs is not None,
87
+ "approval_kind": needs, "risk": risk,
88
+ "actor": actor.get("actor", "?"), "op": op,
89
+ "resource": resource, "session_id": session_id,
90
+ "product_id": product_id, "workspace_id": workspace_id,
91
+ "tool": tool, "scopes": scopes,
92
+ }
93
+ self._record("allow" if not needs else "require-approval",
94
+ op, resource, dec["actor"], dec["approval_kind"] or "-")
95
+ return Decision(dec)
96
+
97
+ def _record(self, verdict: str, op: str, res: str,
98
+ actor: str, detail: str) -> None:
99
+ self.log.append({"ts": time.time(), "verdict": verdict, "op": op,
100
+ "resource": res, "actor": actor, "detail": detail})
101
+
102
+
103
+ BROKER = Broker()
@@ -0,0 +1,104 @@
1
+ """Budget ledger (§23.4) — limits per scope/window over real usage.
2
+
3
+ Records token usage from usage.updated events; checks limits BEFORE a
4
+ prompt is accepted. Provider-reported tokens are real data; when a
5
+ provider reports nothing the entry is flagged estimated.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .adapter import BridgeError
16
+ from .contracts import utcnow
17
+ from .local_bridge import state_dir
18
+
19
+
20
+ def _day() -> str:
21
+ return datetime.now(UTC).strftime("%Y-%m-%d")
22
+
23
+
24
+ def _month() -> str:
25
+ return datetime.now(UTC).strftime("%Y-%m")
26
+
27
+
28
+ class BudgetLedger:
29
+ def __init__(self, path: Path | None = None) -> None:
30
+ self.path = path or (state_dir() / "budget.json")
31
+
32
+ def _load(self) -> dict[str, Any]:
33
+ try:
34
+ return json.loads(self.path.read_text())
35
+ except (OSError, json.JSONDecodeError):
36
+ return {"limits": {}, "usage": {}, "entries": {}}
37
+
38
+ def _save(self, data: dict[str, Any]) -> None:
39
+ self.path.parent.mkdir(parents=True, exist_ok=True)
40
+ self.path.write_text(json.dumps(data, indent=1))
41
+ self.path.chmod(0o600)
42
+
43
+ # -- limits --------------------------------------------------------------
44
+
45
+ def set_limit(self, scope: str, key: str, window: str,
46
+ tokens: int) -> dict[str, Any]:
47
+ """scope: user|workspace|product|provider|session · window: day|month."""
48
+ if window not in ("day", "month"):
49
+ raise BridgeError("FAB-VALIDATION", "window must be day|month")
50
+ data = self._load()
51
+ data["limits"][f"{scope}:{key}:{window}"] = tokens
52
+ self._save(data)
53
+ return {"scope": scope, "key": key, "window": window, "tokens": tokens}
54
+
55
+ def limits(self) -> dict[str, Any]:
56
+ return dict(self._load()["limits"])
57
+
58
+ # -- usage ---------------------------------------------------------------
59
+
60
+ def record(self, scope_map: dict[str, str], tokens_in: int,
61
+ tokens_out: int, *, estimated: bool = False,
62
+ idempotency_key: str | None = None) -> bool:
63
+ """scope_map: {user:.., workspace:.., product:.., provider:.., session:..}."""
64
+ data = self._load()
65
+ entries = data.setdefault("entries", {})
66
+ if idempotency_key and idempotency_key in entries:
67
+ return False
68
+ for scope, key in scope_map.items():
69
+ if not key:
70
+ continue
71
+ for window, wkey in (("day", _day()), ("month", _month())):
72
+ k = f"{scope}:{key}:{window}:{wkey}"
73
+ u = data["usage"].setdefault(
74
+ k, {"in": 0, "out": 0, "estimated": estimated, "n": 0})
75
+ u["in"] += tokens_in
76
+ u["out"] += tokens_out
77
+ u["n"] += 1
78
+ u["estimated"] = u["estimated"] or estimated
79
+ u["updated_at"] = utcnow()
80
+ if idempotency_key:
81
+ entries[idempotency_key] = {"in": tokens_in, "out": tokens_out,
82
+ "estimated": estimated, "at": utcnow()}
83
+ self._save(data)
84
+ return True
85
+
86
+ def check(self, scope_map: dict[str, str]) -> None:
87
+ """Raise FAB-BUDGET when any configured limit is already exceeded."""
88
+ data = self._load()
89
+ for scope, key in scope_map.items():
90
+ if not key:
91
+ continue
92
+ for window, wkey in (("day", _day()), ("month", _month())):
93
+ lim = data["limits"].get(f"{scope}:{key}:{window}")
94
+ if lim is None:
95
+ continue
96
+ u = data["usage"].get(f"{scope}:{key}:{window}:{wkey}")
97
+ used = (u["in"] + u["out"]) if u else 0
98
+ if used >= lim:
99
+ raise BridgeError(
100
+ "FAB-BUDGET",
101
+ f"{scope}:{key} {window} budget {lim} tokens exhausted")
102
+
103
+ def summary(self) -> dict[str, Any]:
104
+ return self._load()