platformforge 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.
Files changed (659) hide show
  1. platformforge/__init__.py +7 -0
  2. platformforge/__main__.py +5 -0
  3. platformforge/_forge_portal.py +913 -0
  4. platformforge/_graphstudio.py +376 -0
  5. platformforge/_graphview.py +181 -0
  6. platformforge/_installkit.py +1706 -0
  7. platformforge/agents/__init__.py +156 -0
  8. platformforge/agents/bench.py +61 -0
  9. platformforge/agents/contextpack.py +137 -0
  10. platformforge/agents/contracts.py +394 -0
  11. platformforge/agents/coordinators.py +98 -0
  12. platformforge/agents/critic.py +113 -0
  13. platformforge/agents/debate.py +161 -0
  14. platformforge/agents/executors.py +160 -0
  15. platformforge/agents/guardian.py +48 -0
  16. platformforge/agents/mirrors.py +240 -0
  17. platformforge/agents/orchestrator.py +295 -0
  18. platformforge/agents/planner.py +116 -0
  19. platformforge/agents/playbook.py +51 -0
  20. platformforge/agents/referee.py +132 -0
  21. platformforge/agents/reviewers.py +113 -0
  22. platformforge/agents/roster.py +893 -0
  23. platformforge/agents/runledger.py +85 -0
  24. platformforge/agents/runstore.py +74 -0
  25. platformforge/agents/specialists.py +73 -0
  26. platformforge/agents/taskspec.py +94 -0
  27. platformforge/agents/uniqueness.py +115 -0
  28. platformforge/agents/verifier.py +106 -0
  29. platformforge/aiplat/__init__.py +4 -0
  30. platformforge/aiplat/models.py +175 -0
  31. platformforge/analytics/__init__.py +6 -0
  32. platformforge/analytics/capacity.py +109 -0
  33. platformforge/analytics/debt.py +92 -0
  34. platformforge/analytics/dx.py +69 -0
  35. platformforge/analytics/finops_v4.py +171 -0
  36. platformforge/analytics/goldenpath.py +131 -0
  37. platformforge/analytics/history.py +233 -0
  38. platformforge/analytics/measurement.py +149 -0
  39. platformforge/analytics/models.py +127 -0
  40. platformforge/analytics/opsanalytics.py +119 -0
  41. platformforge/analytics/policyintel.py +139 -0
  42. platformforge/analytics/reliability.py +106 -0
  43. platformforge/analytics/soak.py +135 -0
  44. platformforge/analytics/store.py +254 -0
  45. platformforge/bench.py +174 -0
  46. platformforge/cases/__init__.py +13 -0
  47. platformforge/cases/casefile.py +106 -0
  48. platformforge/cases/context_audit.py +134 -0
  49. platformforge/cases/ledgers.py +174 -0
  50. platformforge/cases/replay.py +142 -0
  51. platformforge/cases/routing_audit.py +162 -0
  52. platformforge/caveman/__init__.py +11 -0
  53. platformforge/caveman/compress.py +156 -0
  54. platformforge/cicd/__init__.py +16 -0
  55. platformforge/cicd/backstage.py +51 -0
  56. platformforge/cicd/dockerfile.py +64 -0
  57. platformforge/cicd/github_actions.py +122 -0
  58. platformforge/cicd/gitlab_ci.py +56 -0
  59. platformforge/cicd/gitops.py +130 -0
  60. platformforge/cli/__init__.py +0 -0
  61. platformforge/cli/fleetcmd.py +457 -0
  62. platformforge/cli/main.py +3832 -0
  63. platformforge/cloud/__init__.py +8 -0
  64. platformforge/cloud/aws.py +254 -0
  65. platformforge/cloud/common.py +69 -0
  66. platformforge/cloud/multicloud.py +143 -0
  67. platformforge/collect/__init__.py +11 -0
  68. platformforge/collect/detect.py +254 -0
  69. platformforge/context/__init__.py +10 -0
  70. platformforge/context/capsule.py +60 -0
  71. platformforge/context/gateway.py +227 -0
  72. platformforge/context/refs.py +49 -0
  73. platformforge/context/sufficiency.py +50 -0
  74. platformforge/core/__init__.py +1 -0
  75. platformforge/core/hashing.py +39 -0
  76. platformforge/core/ownership.py +220 -0
  77. platformforge/core/receipts.py +74 -0
  78. platformforge/core/redaction.py +97 -0
  79. platformforge/core/store.py +119 -0
  80. platformforge/core/workspace.py +89 -0
  81. platformforge/data/contracts/agent-handoff.schema.json +26 -0
  82. platformforge/data/contracts/agent-run.schema.json +27 -0
  83. platformforge/data/contracts/agent-spec.schema.json +43 -0
  84. platformforge/data/contracts/capability.schema.json +23 -0
  85. platformforge/data/contracts/change-event.schema.json +22 -0
  86. platformforge/data/contracts/change-intent.schema.json +62 -0
  87. platformforge/data/contracts/change-plan.schema.json +43 -0
  88. platformforge/data/contracts/collector-receipt.schema.json +36 -0
  89. platformforge/data/contracts/context-pack.schema.json +29 -0
  90. platformforge/data/contracts/debate.schema.json +31 -0
  91. platformforge/data/contracts/distribution-manifest-v1.schema.json +81 -0
  92. platformforge/data/contracts/drift-event.schema.json +21 -0
  93. platformforge/data/contracts/fact.schema.json +35 -0
  94. platformforge/data/contracts/finding.schema.json +20 -0
  95. platformforge/data/contracts/graph-v2.schema.json +67 -0
  96. platformforge/data/contracts/graph.schema.json +40 -0
  97. platformforge/data/contracts/install-receipt-v1.schema.json +49 -0
  98. platformforge/data/contracts/observation.schema.json +93 -0
  99. platformforge/data/contracts/receipt.schema.json +21 -0
  100. platformforge/data/contracts/recommendation.schema.json +32 -0
  101. platformforge/data/contracts/refusal.schema.json +16 -0
  102. platformforge/data/contracts/slo-contract.schema.json +25 -0
  103. platformforge/data/contracts/task-spec.schema.json +37 -0
  104. platformforge/data/contracts/workspace-manifest-v1.schema.json +62 -0
  105. platformforge/data/evals/cases/agent-budget-exhaustion/case.yaml +6 -0
  106. platformforge/data/evals/cases/agent-conflicting-specialists/case.yaml +11 -0
  107. platformforge/data/evals/cases/agent-fleet-optimization/case.yaml +13 -0
  108. platformforge/data/evals/cases/agent-host-no-subagents/case.yaml +6 -0
  109. platformforge/data/evals/cases/agent-incident-multi/case.yaml +13 -0
  110. platformforge/data/evals/cases/agent-missing-evidence/case.yaml +6 -0
  111. platformforge/data/evals/cases/agent-multi-domain-audit/case.yaml +14 -0
  112. platformforge/data/evals/cases/agent-production-change/case.yaml +15 -0
  113. platformforge/data/evals/cases/agent-security-mandatory/case.yaml +12 -0
  114. platformforge/data/evals/cases/agent-simple-iac/case.yaml +14 -0
  115. platformforge/data/evals/cases/approval-integrity-seal/case.yaml +6 -0
  116. platformforge/data/evals/cases/capacity-hot/case.yaml +7 -0
  117. platformforge/data/evals/cases/capacity-hot/fixture/capacity.json +5 -0
  118. platformforge/data/evals/cases/catalog-noowner/case.yaml +7 -0
  119. platformforge/data/evals/cases/catalog-noowner/fixture/catalog-info.yaml +6 -0
  120. platformforge/data/evals/cases/contract-fact/case.yaml +5 -0
  121. platformforge/data/evals/cases/contract-fact/fixture/doc.json +2 -0
  122. platformforge/data/evals/cases/cosign-unverified/case.yaml +8 -0
  123. platformforge/data/evals/cases/cosign-unverified/fixture/sig.json +33 -0
  124. platformforge/data/evals/cases/expected-delta-refusal/case.yaml +6 -0
  125. platformforge/data/evals/cases/finops-noattr/case.yaml +9 -0
  126. platformforge/data/evals/cases/finops-noattr/fixture/costs.json +7 -0
  127. platformforge/data/evals/cases/fleet-inv-absent-allocation-unknown/case.yaml +7 -0
  128. platformforge/data/evals/cases/fleet-inv-centrality-not-criticality/case.yaml +7 -0
  129. platformforge/data/evals/cases/fleet-inv-correlation-not-causality/case.yaml +7 -0
  130. platformforge/data/evals/cases/fleet-inv-dx-no-person-metrics/case.yaml +7 -0
  131. platformforge/data/evals/cases/fleet-inv-federation-restricted-denied/case.yaml +7 -0
  132. platformforge/data/evals/cases/fleet-inv-federation-secret-denied/case.yaml +7 -0
  133. platformforge/data/evals/cases/fleet-inv-history-window-deterministic/case.yaml +7 -0
  134. platformforge/data/evals/cases/fleet-inv-low-util-not-deletable/case.yaml +7 -0
  135. platformforge/data/evals/cases/fleet-inv-member-key-deterministic/case.yaml +7 -0
  136. platformforge/data/evals/cases/fleet-inv-missing-denominator/case.yaml +7 -0
  137. platformforge/data/evals/cases/fleet-inv-partial-coverage-visible/case.yaml +7 -0
  138. platformforge/data/evals/cases/fleet-inv-plan-changeintent-only/case.yaml +7 -0
  139. platformforge/data/evals/cases/fleet-inv-report-all-cited/case.yaml +7 -0
  140. platformforge/data/evals/cases/fleet-inv-small-sample-confidence/case.yaml +7 -0
  141. platformforge/data/evals/cases/fleet-inv-uncertain-optimization-suppressed/case.yaml +7 -0
  142. platformforge/data/evals/cases/gha-dangerous/case.yaml +12 -0
  143. platformforge/data/evals/cases/gha-dangerous/fixture/.github/workflows/ci.yaml +11 -0
  144. platformforge/data/evals/cases/gitops-v2/case.yaml +11 -0
  145. platformforge/data/evals/cases/gitops-v2/fixture/argo.yaml +41 -0
  146. platformforge/data/evals/cases/graph-k8s-blast/case.yaml +7 -0
  147. platformforge/data/evals/cases/graph-k8s-blast/fixture/deploy.yaml +15 -0
  148. platformforge/data/evals/cases/iac-module-nopin/case.yaml +8 -0
  149. platformforge/data/evals/cases/iac-module-nopin/fixture/main.tf +3 -0
  150. platformforge/data/evals/cases/iac-plan-replace/case.yaml +8 -0
  151. platformforge/data/evals/cases/iac-plan-replace/fixture/plan.json +12 -0
  152. platformforge/data/evals/cases/iac-sg-open/case.yaml +8 -0
  153. platformforge/data/evals/cases/iac-sg-open/fixture/main.tf +9 -0
  154. platformforge/data/evals/cases/iam-oidc/case.yaml +8 -0
  155. platformforge/data/evals/cases/iam-oidc/fixture/policy.json +11 -0
  156. platformforge/data/evals/cases/iam-wildcard-positive/case.yaml +8 -0
  157. platformforge/data/evals/cases/iam-wildcard-positive/fixture/policy.json +3 -0
  158. platformforge/data/evals/cases/k8s-bad-actor/case.yaml +19 -0
  159. platformforge/data/evals/cases/k8s-bad-actor/fixture/app.yaml +97 -0
  160. platformforge/data/evals/cases/k8s-beta-api/case.yaml +8 -0
  161. platformforge/data/evals/cases/k8s-beta-api/fixture/cr.yaml +7 -0
  162. platformforge/data/evals/cases/k8s-dep-api-boundary/case.yaml +15 -0
  163. platformforge/data/evals/cases/k8s-dep-api-boundary/fixture/app.yaml +25 -0
  164. platformforge/data/evals/cases/k8s-dep-api-clean/case.yaml +12 -0
  165. platformforge/data/evals/cases/k8s-dep-api-clean/fixture/app.yaml +29 -0
  166. platformforge/data/evals/cases/k8s-dep-api-unknown/case.yaml +11 -0
  167. platformforge/data/evals/cases/k8s-dep-api-unknown/fixture/app.yaml +25 -0
  168. platformforge/data/evals/cases/k8s-dep-api-v120/case.yaml +12 -0
  169. platformforge/data/evals/cases/k8s-dep-api-v120/fixture/app.yaml +25 -0
  170. platformforge/data/evals/cases/k8s-dep-api-v129/case.yaml +9 -0
  171. platformforge/data/evals/cases/k8s-dep-api-v129/fixture/app.yaml +25 -0
  172. platformforge/data/evals/cases/k8s-hpa-noreq/case.yaml +8 -0
  173. platformforge/data/evals/cases/k8s-hpa-noreq/fixture/app.yaml +28 -0
  174. platformforge/data/evals/cases/k8s-limits-boundary/case.yaml +10 -0
  175. platformforge/data/evals/cases/k8s-limits-boundary/fixture/app.yaml +15 -0
  176. platformforge/data/evals/cases/k8s-limits-negative/case.yaml +8 -0
  177. platformforge/data/evals/cases/k8s-limits-negative/fixture/deploy.yaml +15 -0
  178. platformforge/data/evals/cases/k8s-limits-positive/case.yaml +8 -0
  179. platformforge/data/evals/cases/k8s-limits-positive/fixture/deploy.yaml +14 -0
  180. platformforge/data/evals/cases/k8s-nopdb/case.yaml +7 -0
  181. platformforge/data/evals/cases/k8s-nopdb/fixture/app.yaml +34 -0
  182. platformforge/data/evals/cases/knowledge-registry/case.yaml +10 -0
  183. platformforge/data/evals/cases/kyverno-dep/case.yaml +10 -0
  184. platformforge/data/evals/cases/kyverno-dep/fixture/pol.yaml +18 -0
  185. platformforge/data/evals/cases/kyverno-legacy/case.yaml +8 -0
  186. platformforge/data/evals/cases/kyverno-legacy/fixture/pol.yaml +7 -0
  187. platformforge/data/evals/cases/live-aws-collect/case.yaml +11 -0
  188. platformforge/data/evals/cases/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
  189. platformforge/data/evals/cases/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
  190. platformforge/data/evals/cases/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
  191. platformforge/data/evals/cases/live-drift/case.yaml +8 -0
  192. platformforge/data/evals/cases/live-drift/fixture/after.json +22 -0
  193. platformforge/data/evals/cases/live-drift/fixture/before.json +22 -0
  194. platformforge/data/evals/cases/live-incident/case.yaml +7 -0
  195. platformforge/data/evals/cases/live-incident/fixture/changes.json +8 -0
  196. platformforge/data/evals/cases/live-incident/fixture/incident.json +7 -0
  197. platformforge/data/evals/cases/live-k8s-collect/case.yaml +10 -0
  198. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
  199. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
  200. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
  201. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
  202. platformforge/data/evals/cases/live-multicluster/case.yaml +7 -0
  203. platformforge/data/evals/cases/live-multicluster/fixture/clusters.json +6 -0
  204. platformforge/data/evals/cases/live-plan/case.yaml +7 -0
  205. platformforge/data/evals/cases/live-plan/fixture/drift.json +8 -0
  206. platformforge/data/evals/cases/live-reconcile/case.yaml +7 -0
  207. platformforge/data/evals/cases/live-reconcile/fixture/desired.json +7 -0
  208. platformforge/data/evals/cases/live-reconcile/fixture/observed.json +18 -0
  209. platformforge/data/evals/cases/live-secrets-boundary/case.yaml +8 -0
  210. platformforge/data/evals/cases/live-secrets-boundary/fixture/envelope.json +16 -0
  211. platformforge/data/evals/cases/live-temporal-graph/case.yaml +10 -0
  212. platformforge/data/evals/cases/live-temporal-graph/fixture/graph.json +22 -0
  213. platformforge/data/evals/cases/ops-approval/case.yaml +6 -0
  214. platformforge/data/evals/cases/ops-convergence/case.yaml +6 -0
  215. platformforge/data/evals/cases/ops-execution-safety/case.yaml +6 -0
  216. platformforge/data/evals/cases/ops-intent/case.yaml +6 -0
  217. platformforge/data/evals/cases/ops-planning/case.yaml +6 -0
  218. platformforge/data/evals/cases/ops-policy/case.yaml +6 -0
  219. platformforge/data/evals/cases/ops-rollback/case.yaml +6 -0
  220. platformforge/data/evals/cases/ops-verification/case.yaml +6 -0
  221. platformforge/data/evals/cases/rollback-argocd-revision/case.yaml +6 -0
  222. platformforge/data/evals/cases/rollback-blind-inverse/case.yaml +6 -0
  223. platformforge/data/evals/cases/rollback-git-commit/case.yaml +6 -0
  224. platformforge/data/evals/cases/rollback-impossible/case.yaml +6 -0
  225. platformforge/data/evals/cases/rollback-k8s-annotation-restore/case.yaml +6 -0
  226. platformforge/data/evals/cases/rollback-k8s-scale/case.yaml +6 -0
  227. platformforge/data/evals/cases/rollback-material-tamper/case.yaml +6 -0
  228. platformforge/data/evals/cases/rollback-terraform-replan/case.yaml +6 -0
  229. platformforge/data/evals/cases/rollback-unknown/case.yaml +6 -0
  230. platformforge/data/evals/cases/routing-specialist/case.yaml +9 -0
  231. platformforge/data/evals/cases/rtk-roundtrip/case.yaml +5 -0
  232. platformforge/data/evals/cases/rtk-roundtrip/fixture/verbose.txt +300 -0
  233. platformforge/data/evals/cases/sbom-vuln/case.yaml +9 -0
  234. platformforge/data/evals/cases/sbom-vuln/fixture/sbom.json +9 -0
  235. platformforge/data/evals/cases/secret-prose-mixed/fixture/app.env +1 -0
  236. platformforge/data/evals/cases/secret-prose-mixed/fixture/policy-notes.md +7 -0
  237. platformforge/data/evals/cases/secrets-noleak/case.yaml +5 -0
  238. platformforge/data/evals/cases/secrets-noleak/fixture/app.env +2 -0
  239. platformforge/data/evals/cases/slo-fastburn/case.yaml +8 -0
  240. platformforge/data/evals/cases/slo-fastburn/fixture/events.yaml +2 -0
  241. platformforge/data/evals/cases/slo-fastburn/fixture/slo.yaml +4 -0
  242. platformforge/data/evals/cases/slo-noinput/case.yaml +8 -0
  243. platformforge/data/evals/cases/slo-noinput/fixture/events.yaml +1 -0
  244. platformforge/data/evals/cases/slo-noinput/fixture/slo.yaml +4 -0
  245. platformforge/data/evals/cases/sot-conflict/case.yaml +6 -0
  246. platformforge/data/evals/cases/token-budget/case.yaml +6 -0
  247. platformforge/data/evals/cases/token-budget/fixture/big.py +50 -0
  248. platformforge/data/evals/cases/xr-delete/case.yaml +8 -0
  249. platformforge/data/evals/cases/xr-delete/fixture/xr.yaml +10 -0
  250. platformforge/data/evals/cases/xr-graph-chain/case.yaml +8 -0
  251. platformforge/data/evals/cases/xr-graph-chain/fixture/xrd.yaml +90 -0
  252. platformforge/data/evals/cases/xr-negative-crd/case.yaml +8 -0
  253. platformforge/data/evals/cases/xr-negative-crd/fixture/widget.yaml +19 -0
  254. platformforge/data/evals/cases/xr-upbound-mr/case.yaml +8 -0
  255. platformforge/data/evals/cases/xr-upbound-mr/fixture/bucket.yaml +12 -0
  256. platformforge/data/evals/qpt-bench/change-review/case.yaml +5 -0
  257. platformforge/data/evals/qpt-bench/change-review/fixture/plan.json +9 -0
  258. platformforge/data/evals/qpt-bench/gha-security/case.yaml +5 -0
  259. platformforge/data/evals/qpt-bench/gha-security/fixture/ci.yml +10 -0
  260. platformforge/data/evals/qpt-bench/iam-investigate/case.yaml +5 -0
  261. platformforge/data/evals/qpt-bench/iam-investigate/fixture/policy.json +6 -0
  262. platformforge/data/evals/qpt-bench/incident-analysis/case.yaml +5 -0
  263. platformforge/data/evals/qpt-bench/incident-analysis/fixture/query.json +5 -0
  264. platformforge/data/evals/qpt-bench/incident-analysis/fixture/rules.yaml +6 -0
  265. platformforge/data/evals/qpt-bench/k8s-review/case.yaml +6 -0
  266. platformforge/data/evals/qpt-bench/k8s-review/fixture/deploy.yaml +15 -0
  267. platformforge/data/evals/qpt-bench/tf-security/case.yaml +5 -0
  268. platformforge/data/evals/qpt-bench/tf-security/fixture/main.tf +17 -0
  269. platformforge/data/knowledge/aws/eks.yaml +20 -0
  270. platformforge/data/knowledge/aws/iam.yaml +28 -0
  271. platformforge/data/knowledge/aws/network-exposure.yaml +19 -0
  272. platformforge/data/knowledge/aws/organizations.yaml +19 -0
  273. platformforge/data/knowledge/crossplane/composition-providers.yaml +20 -0
  274. platformforge/data/knowledge/crossplane/managed-resources.yaml +26 -0
  275. platformforge/data/knowledge/crossplane/v1-v2.yaml +25 -0
  276. platformforge/data/knowledge/kubernetes/api-deprecations.yaml +33 -0
  277. platformforge/data/knowledge/kubernetes/autoscaling-rbac.yaml +27 -0
  278. platformforge/data/knowledge/kubernetes/gateway-api.yaml +20 -0
  279. platformforge/data/knowledge/kubernetes/pod-security.yaml +26 -0
  280. platformforge/data/knowledge/sources.yaml +516 -0
  281. platformforge/data/knowledge/terraform/lifecycle.yaml +18 -0
  282. platformforge/data/knowledge/terraform/plan-semantics.yaml +26 -0
  283. platformforge/data/lab/chaos-pod-kill/chaos.yaml +3 -0
  284. platformforge/data/lab/chaos-pod-kill/facts.json +13 -0
  285. platformforge/data/lab/fleets/acme/capacity.yaml +35 -0
  286. platformforge/data/lab/fleets/acme/costs.yaml +25 -0
  287. platformforge/data/lab/fleets/acme/events.yaml +41 -0
  288. platformforge/data/lab/fleets/acme/fleet.yaml +51 -0
  289. platformforge/data/lab/fleets/acme/graph.yaml +45 -0
  290. platformforge/data/lab/fleets/acme/policies.yaml +28 -0
  291. platformforge/data/lab/fleets/acme/requests.yaml +24 -0
  292. platformforge/data/lab/scenarios/argo-bad-rollout/expected.yaml +6 -0
  293. platformforge/data/lab/scenarios/argo-bad-rollout/fixture/app.yaml +9 -0
  294. platformforge/data/lab/scenarios/aws-dump/expected.yaml +19 -0
  295. platformforge/data/lab/scenarios/aws-dump/fixture/compute.json +4 -0
  296. platformforge/data/lab/scenarios/aws-dump/fixture/iam.json +7 -0
  297. platformforge/data/lab/scenarios/aws-dump/fixture/network.json +6 -0
  298. platformforge/data/lab/scenarios/aws-dump/fixture/org.json +4 -0
  299. platformforge/data/lab/scenarios/aws-dump/fixture/storage.json +4 -0
  300. platformforge/data/lab/scenarios/container-unsigned/expected.yaml +5 -0
  301. platformforge/data/lab/scenarios/container-unsigned/fixture/supply.json +11 -0
  302. platformforge/data/lab/scenarios/cost-idle-workload/expected.yaml +5 -0
  303. platformforge/data/lab/scenarios/cost-idle-workload/fixture/costs.json +12 -0
  304. platformforge/data/lab/scenarios/crossplane-platform/expected.yaml +16 -0
  305. platformforge/data/lab/scenarios/crossplane-platform/fixture/negative.yaml +18 -0
  306. platformforge/data/lab/scenarios/crossplane-platform/fixture/xrd.yaml +90 -0
  307. platformforge/data/lab/scenarios/demo-platform/expected.yaml +16 -0
  308. platformforge/data/lab/scenarios/demo-platform/fixture/.github/workflows/ci.yml +10 -0
  309. platformforge/data/lab/scenarios/demo-platform/fixture/deploy.yaml +16 -0
  310. platformforge/data/lab/scenarios/demo-platform/fixture/infra.tf +5 -0
  311. platformforge/data/lab/scenarios/fleet-ai/expected.yaml +12 -0
  312. platformforge/data/lab/scenarios/fleet-capacity/expected.yaml +12 -0
  313. platformforge/data/lab/scenarios/fleet-cost/expected.yaml +18 -0
  314. platformforge/data/lab/scenarios/fleet-federation/expected.yaml +11 -0
  315. platformforge/data/lab/scenarios/fleet-golden-path/expected.yaml +18 -0
  316. platformforge/data/lab/scenarios/fleet-op-hotspot/expected.yaml +11 -0
  317. platformforge/data/lab/scenarios/fleet-ownership/expected.yaml +17 -0
  318. platformforge/data/lab/scenarios/fleet-partial/expected.yaml +10 -0
  319. platformforge/data/lab/scenarios/fleet-policy-friction/expected.yaml +10 -0
  320. platformforge/data/lab/scenarios/fleet-recurring-incident/expected.yaml +12 -0
  321. platformforge/data/lab/scenarios/fleet-report/expected.yaml +13 -0
  322. platformforge/data/lab/scenarios/fleet-risk/expected.yaml +18 -0
  323. platformforge/data/lab/scenarios/gitops-drift/expected.yaml +6 -0
  324. platformforge/data/lab/scenarios/gitops-drift/fixture/app.yaml +12 -0
  325. platformforge/data/lab/scenarios/k8s-bad-readiness/expected.yaml +6 -0
  326. platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/deploy.yaml +23 -0
  327. platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/netpol.yaml +9 -0
  328. platformforge/data/lab/scenarios/k8s-missing-limits/expected.yaml +6 -0
  329. platformforge/data/lab/scenarios/k8s-missing-limits/fixture/deploy.yaml +26 -0
  330. platformforge/data/lab/scenarios/k8s-missing-limits/fixture/netpol.yaml +9 -0
  331. platformforge/data/lab/scenarios/k8s-single-replica/expected.yaml +5 -0
  332. platformforge/data/lab/scenarios/k8s-single-replica/fixture/deploy.yaml +26 -0
  333. platformforge/data/lab/scenarios/k8s-single-replica/fixture/netpol.yaml +4 -0
  334. platformforge/data/lab/scenarios/live-aws-collect/expected.yaml +4 -0
  335. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
  336. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
  337. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
  338. platformforge/data/lab/scenarios/live-k8s-collect/expected.yaml +4 -0
  339. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
  340. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
  341. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
  342. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
  343. platformforge/data/lab/scenarios/live-kind/expected.yaml +14 -0
  344. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_configmaps.denied.txt +1 -0
  345. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_deployments.apps.json +25 -0
  346. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_pods.json +17 -0
  347. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_secrets.json +13 -0
  348. platformforge/data/lab/scenarios/network-policy-missing/expected.yaml +6 -0
  349. platformforge/data/lab/scenarios/network-policy-missing/fixture/deploy.yaml +26 -0
  350. platformforge/data/lab/scenarios/ops-bad-rollout/expected.yaml +3 -0
  351. platformforge/data/lab/scenarios/ops-bad-rollout/fixture/ops.yaml +33 -0
  352. platformforge/data/lab/scenarios/ops-break-glass/expected.yaml +3 -0
  353. platformforge/data/lab/scenarios/ops-break-glass/fixture/ops.yaml +21 -0
  354. platformforge/data/lab/scenarios/ops-broken-selector/expected.yaml +3 -0
  355. platformforge/data/lab/scenarios/ops-broken-selector/fixture/ops.yaml +21 -0
  356. platformforge/data/lab/scenarios/ops-changed-plan-hash/expected.yaml +3 -0
  357. platformforge/data/lab/scenarios/ops-changed-plan-hash/fixture/ops.yaml +20 -0
  358. platformforge/data/lab/scenarios/ops-conflicting-operation/expected.yaml +3 -0
  359. platformforge/data/lab/scenarios/ops-conflicting-operation/fixture/ops.yaml +21 -0
  360. platformforge/data/lab/scenarios/ops-expired-approval/expected.yaml +3 -0
  361. platformforge/data/lab/scenarios/ops-expired-approval/fixture/ops.yaml +20 -0
  362. platformforge/data/lab/scenarios/ops-partial-failure/expected.yaml +3 -0
  363. platformforge/data/lab/scenarios/ops-partial-failure/fixture/ops.yaml +24 -0
  364. platformforge/data/lab/scenarios/ops-policy-denial/expected.yaml +3 -0
  365. platformforge/data/lab/scenarios/ops-policy-denial/fixture/ops.yaml +17 -0
  366. platformforge/data/lab/scenarios/ops-replica-drift/expected.yaml +3 -0
  367. platformforge/data/lab/scenarios/ops-replica-drift/fixture/ops.yaml +28 -0
  368. platformforge/data/lab/scenarios/ops-rollback-annotate/expected.yaml +3 -0
  369. platformforge/data/lab/scenarios/ops-rollback-annotate/fixture/ops.yaml +41 -0
  370. platformforge/data/lab/scenarios/ops-rollback-gitops/expected.yaml +3 -0
  371. platformforge/data/lab/scenarios/ops-rollback-gitops/fixture/ops.yaml +38 -0
  372. platformforge/data/lab/scenarios/ops-rollback-impossible/expected.yaml +3 -0
  373. platformforge/data/lab/scenarios/ops-rollback-impossible/fixture/ops.yaml +34 -0
  374. platformforge/data/lab/scenarios/ops-rollback-scale/expected.yaml +3 -0
  375. platformforge/data/lab/scenarios/ops-rollback-scale/fixture/ops.yaml +40 -0
  376. platformforge/data/lab/scenarios/ops-rollback-terraform/expected.yaml +3 -0
  377. platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/ops.yaml +38 -0
  378. platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/tf/tfplan.bin +1 -0
  379. platformforge/data/lab/scenarios/ops-stale-observation/expected.yaml +3 -0
  380. platformforge/data/lab/scenarios/ops-stale-observation/fixture/ops.yaml +20 -0
  381. platformforge/data/lab/scenarios/secret-exposure/expected.yaml +5 -0
  382. platformforge/data/lab/scenarios/secret-exposure/fixture/app.env +2 -0
  383. platformforge/data/lab/scenarios/slo-burn/expected.yaml +5 -0
  384. platformforge/data/lab/scenarios/slo-burn/fixture/events.yaml +2 -0
  385. platformforge/data/lab/scenarios/slo-burn/fixture/slo.yaml +4 -0
  386. platformforge/data/lab/scenarios/terraform-iam-wildcard/expected.yaml +5 -0
  387. platformforge/data/lab/scenarios/terraform-iam-wildcard/fixture/policy.json +14 -0
  388. platformforge/data/lab/scenarios/terraform-public-bucket/expected.yaml +6 -0
  389. platformforge/data/lab/scenarios/terraform-public-bucket/fixture/main.tf +5 -0
  390. platformforge/data/portable_skills/platformforge-agents/SKILL.md +47 -0
  391. platformforge/data/portable_skills/platformforge-change/SKILL.md +41 -0
  392. platformforge/data/portable_skills/platformforge-core/SKILL.md +62 -0
  393. platformforge/data/portable_skills/platformforge-economy/SKILL.md +42 -0
  394. platformforge/data/portable_skills/platformforge-finops/SKILL.md +34 -0
  395. platformforge/data/portable_skills/platformforge-fleet/SKILL.md +39 -0
  396. platformforge/data/portable_skills/platformforge-governance/SKILL.md +76 -0
  397. platformforge/data/portable_skills/platformforge-graph/SKILL.md +46 -0
  398. platformforge/data/portable_skills/platformforge-lab/SKILL.md +39 -0
  399. platformforge/data/portable_skills/platformforge-sdd/SKILL.md +39 -0
  400. platformforge/data/portable_skills/platformforge-security/SKILL.md +38 -0
  401. platformforge/data/portable_skills/platformforge-sre/SKILL.md +41 -0
  402. platformforge/data/rules/catalog/finops.yaml +26 -0
  403. platformforge/data/rules/catalog/gitops.yaml +161 -0
  404. platformforge/data/rules/catalog/iac.yaml +84 -0
  405. platformforge/data/rules/catalog/k8s.yaml +322 -0
  406. platformforge/data/rules/catalog/orchestration.yaml +99 -0
  407. platformforge/data/rules/catalog/product.yaml +42 -0
  408. platformforge/data/rules/catalog/routing.yaml +122 -0
  409. platformforge/data/rules/catalog/security.yaml +159 -0
  410. platformforge/data/rules/catalog/sre.yaml +67 -0
  411. platformforge/diagnose/__init__.py +75 -0
  412. platformforge/distribution/__init__.py +18 -0
  413. platformforge/distribution/bundle.py +127 -0
  414. platformforge/distribution/hostflow.py +247 -0
  415. platformforge/distribution/models.py +80 -0
  416. platformforge/distribution/service.py +225 -0
  417. platformforge/economy/__init__.py +27 -0
  418. platformforge/economy/bench.py +66 -0
  419. platformforge/economy/budget.py +198 -0
  420. platformforge/economy/cache.py +287 -0
  421. platformforge/economy/checkpoint.py +112 -0
  422. platformforge/economy/collection.py +142 -0
  423. platformforge/economy/doctor.py +207 -0
  424. platformforge/economy/engine.py +158 -0
  425. platformforge/economy/envelope.py +61 -0
  426. platformforge/economy/fleetpack.py +86 -0
  427. platformforge/economy/ledger.py +232 -0
  428. platformforge/economy/plan.py +131 -0
  429. platformforge/economy/pricing.py +101 -0
  430. platformforge/economy/qpt.py +286 -0
  431. platformforge/economy/reconcile.py +86 -0
  432. platformforge/economy/verifyplan.py +107 -0
  433. platformforge/economy/waste.py +123 -0
  434. platformforge/evals/__init__.py +16 -0
  435. platformforge/evals/coverage.py +140 -0
  436. platformforge/evals/fleet_invariants.py +260 -0
  437. platformforge/evals/ops_invariants.py +158 -0
  438. platformforge/evals/runner.py +541 -0
  439. platformforge/federation/__init__.py +4 -0
  440. platformforge/federation/node.py +135 -0
  441. platformforge/finops/__init__.py +14 -0
  442. platformforge/finops/alloc.py +31 -0
  443. platformforge/finops/costs.py +79 -0
  444. platformforge/finops/focus.py +100 -0
  445. platformforge/finops/graph_cost.py +51 -0
  446. platformforge/finops/ingest.py +144 -0
  447. platformforge/finops/insights.py +193 -0
  448. platformforge/finops/unit.py +36 -0
  449. platformforge/fleet/__init__.py +5 -0
  450. platformforge/fleet/loader.py +73 -0
  451. platformforge/fleet/models.py +155 -0
  452. platformforge/fleet/orggraph.py +172 -0
  453. platformforge/fleet/query.py +154 -0
  454. platformforge/forge/__init__.py +9 -0
  455. platformforge/forge/collect.py +88 -0
  456. platformforge/forge/delegate.py +77 -0
  457. platformforge/forge/manifest.py +106 -0
  458. platformforge/freeze/__init__.py +22 -0
  459. platformforge/freeze/exceptions.py +56 -0
  460. platformforge/freeze/manifest.py +167 -0
  461. platformforge/freeze/snapshots.py +108 -0
  462. platformforge/graph/__init__.py +39 -0
  463. platformforge/graph/backend.py +174 -0
  464. platformforge/graph/bench.py +245 -0
  465. platformforge/graph/build.py +130 -0
  466. platformforge/graph/delivery.py +183 -0
  467. platformforge/graph/diff.py +292 -0
  468. platformforge/graph/events.py +116 -0
  469. platformforge/graph/migrate.py +44 -0
  470. platformforge/graph/model.py +264 -0
  471. platformforge/graph/persist.py +97 -0
  472. platformforge/graph/query.py +290 -0
  473. platformforge/graph/temporal.py +78 -0
  474. platformforge/graph/vocab.py +63 -0
  475. platformforge/graphview.py +94 -0
  476. platformforge/iac/__init__.py +7 -0
  477. platformforge/iac/drift.py +53 -0
  478. platformforge/iac/plan.py +84 -0
  479. platformforge/iac/terraform.py +123 -0
  480. platformforge/install/__init__.py +1 -0
  481. platformforge/install/service.py +421 -0
  482. platformforge/k8s/__init__.py +8 -0
  483. platformforge/k8s/helm.py +152 -0
  484. platformforge/k8s/hubble.py +87 -0
  485. platformforge/k8s/manifests.py +513 -0
  486. platformforge/knowledge/__init__.py +1 -0
  487. platformforge/knowledge/packs.py +157 -0
  488. platformforge/knowledge/registry.py +249 -0
  489. platformforge/knowledge/select.py +127 -0
  490. platformforge/lab/__init__.py +5 -0
  491. platformforge/lab/chaos.py +71 -0
  492. platformforge/lab/fleetlab.py +351 -0
  493. platformforge/lab/opslab.py +271 -0
  494. platformforge/lab/runner.py +219 -0
  495. platformforge/live/__init__.py +36 -0
  496. platformforge/live/budget.py +92 -0
  497. platformforge/live/capability.py +80 -0
  498. platformforge/live/collectors/__init__.py +6 -0
  499. platformforge/live/collectors/aws.py +370 -0
  500. platformforge/live/collectors/aws_transport.py +98 -0
  501. platformforge/live/collectors/k8s.py +389 -0
  502. platformforge/live/collectors/k8s_transport.py +85 -0
  503. platformforge/live/cursors.py +48 -0
  504. platformforge/live/drift.py +130 -0
  505. platformforge/live/envelope.py +85 -0
  506. platformforge/live/federation.py +113 -0
  507. platformforge/live/fixtures.py +168 -0
  508. platformforge/live/identity.py +258 -0
  509. platformforge/live/incident.py +200 -0
  510. platformforge/live/models.py +435 -0
  511. platformforge/live/reconcile.py +382 -0
  512. platformforge/live/remediate.py +169 -0
  513. platformforge/live/store.py +256 -0
  514. platformforge/live/topology.py +249 -0
  515. platformforge/mcp/__init__.py +7 -0
  516. platformforge/mcp/parity.py +69 -0
  517. platformforge/mcp/registry.py +427 -0
  518. platformforge/mcp/server.py +84 -0
  519. platformforge/mcp/tools.py +732 -0
  520. platformforge/models/__init__.py +21 -0
  521. platformforge/models/base.py +214 -0
  522. platformforge/observe/__init__.py +26 -0
  523. platformforge/observe/capacity.py +39 -0
  524. platformforge/observe/dr.py +67 -0
  525. platformforge/observe/grafana.py +76 -0
  526. platformforge/observe/incident.py +58 -0
  527. platformforge/observe/otel.py +135 -0
  528. platformforge/observe/postmortem.py +72 -0
  529. platformforge/observe/prometheus.py +88 -0
  530. platformforge/observe/slo.py +125 -0
  531. platformforge/ops/__init__.py +8 -0
  532. platformforge/ops/actions.py +215 -0
  533. platformforge/ops/analytics.py +83 -0
  534. platformforge/ops/approval.py +275 -0
  535. platformforge/ops/autorem.py +101 -0
  536. platformforge/ops/config.py +175 -0
  537. platformforge/ops/engine.py +758 -0
  538. platformforge/ops/envelope.py +135 -0
  539. platformforge/ops/executors/__init__.py +1 -0
  540. platformforge/ops/executors/base.py +211 -0
  541. platformforge/ops/executors/git.py +77 -0
  542. platformforge/ops/executors/gitops.py +134 -0
  543. platformforge/ops/executors/terraform.py +84 -0
  544. platformforge/ops/gates.py +135 -0
  545. platformforge/ops/goldenpath.py +180 -0
  546. platformforge/ops/material.py +181 -0
  547. platformforge/ops/models.py +421 -0
  548. platformforge/ops/operation.py +335 -0
  549. platformforge/ops/opgraph.py +233 -0
  550. platformforge/ops/policy.py +275 -0
  551. platformforge/ops/preconditions.py +109 -0
  552. platformforge/ops/registry.py +101 -0
  553. platformforge/ops/risk.py +216 -0
  554. platformforge/ops/rollback.py +287 -0
  555. platformforge/ops/rollback_builders.py +163 -0
  556. platformforge/ops/runbook.py +269 -0
  557. platformforge/ops/simulate.py +125 -0
  558. platformforge/ops/source_of_truth.py +198 -0
  559. platformforge/ops/store.py +157 -0
  560. platformforge/ops/verify.py +190 -0
  561. platformforge/optimize/__init__.py +4 -0
  562. platformforge/optimize/engine.py +109 -0
  563. platformforge/optimize/models.py +68 -0
  564. platformforge/optimize/scan.py +196 -0
  565. platformforge/plan/__init__.py +107 -0
  566. platformforge/portable_cli.py +199 -0
  567. platformforge/portal_adapter.py +740 -0
  568. platformforge/portal_assets/chunk-7_l-b4Tr.js +1 -0
  569. platformforge/portal_assets/chunk-B0Iad6pD.js +1 -0
  570. platformforge/portal_assets/chunk-B605XnCF.js +1 -0
  571. platformforge/portal_assets/chunk-B7N3TXcn.js +1 -0
  572. platformforge/portal_assets/chunk-BAj6yGem.js +1 -0
  573. platformforge/portal_assets/chunk-BGUitn2r.js +1 -0
  574. platformforge/portal_assets/chunk-BVKnCv85.js +1 -0
  575. platformforge/portal_assets/chunk-BYyM9V_w.js +1 -0
  576. platformforge/portal_assets/chunk-BaQMhq-G.js +1 -0
  577. platformforge/portal_assets/chunk-BavOrxVm.js +1 -0
  578. platformforge/portal_assets/chunk-Bb0C24YV.js +1 -0
  579. platformforge/portal_assets/chunk-BfCFGPzs.js +1 -0
  580. platformforge/portal_assets/chunk-C2t4OUVu.js +1 -0
  581. platformforge/portal_assets/chunk-C3pI4QjU.js +1 -0
  582. platformforge/portal_assets/chunk-C4gpbtbP.js +1 -0
  583. platformforge/portal_assets/chunk-CGWeHhKY.js +1 -0
  584. platformforge/portal_assets/chunk-CJCT86Dr.js +1 -0
  585. platformforge/portal_assets/chunk-CXcsLDPJ.js +1 -0
  586. platformforge/portal_assets/chunk-CcjxoLxw.js +1 -0
  587. platformforge/portal_assets/chunk-D-gBPXHa.js +1 -0
  588. platformforge/portal_assets/chunk-D3eZogIe.js +1 -0
  589. platformforge/portal_assets/chunk-DQU0VyoQ.js +1 -0
  590. platformforge/portal_assets/chunk-DqWNkF1O.js +1 -0
  591. platformforge/portal_assets/chunk-DuPPkSIE.js +3 -0
  592. platformforge/portal_assets/chunk-DyIHOW_a.js +1 -0
  593. platformforge/portal_assets/chunk-Fx4zSGL5.js +1 -0
  594. platformforge/portal_assets/chunk-UBKt05Rg.js +1 -0
  595. platformforge/portal_assets/chunk-htnJDQEY.js +1 -0
  596. platformforge/portal_assets/chunk-kXRFMGqe.js +1 -0
  597. platformforge/portal_assets/chunk-qe3u763a.js +1 -0
  598. platformforge/portal_assets/favicon.ico +0 -0
  599. platformforge/portal_assets/index.html +13 -0
  600. platformforge/portal_assets/main-QDFVZK3T.js +5 -0
  601. platformforge/portal_assets/styles-MQEBDFDX.css +1 -0
  602. platformforge/product/__init__.py +10 -0
  603. platformforge/product/backstage.py +49 -0
  604. platformforge/product/catalog.py +74 -0
  605. platformforge/product/crossplane.py +341 -0
  606. platformforge/product/golden_paths/__init__.py +5 -0
  607. platformforge/product/golden_paths/engine.py +97 -0
  608. platformforge/product/golden_paths/library.yaml +198 -0
  609. platformforge/product/golden_paths/model.py +84 -0
  610. platformforge/product/maturity.py +107 -0
  611. platformforge/product/scorecards.py +84 -0
  612. platformforge/resources.py +36 -0
  613. platformforge/risk/__init__.py +5 -0
  614. platformforge/risk/engine.py +95 -0
  615. platformforge/risk/signals.py +49 -0
  616. platformforge/routing/__init__.py +13 -0
  617. platformforge/routing/decision.py +191 -0
  618. platformforge/routing/router.py +328 -0
  619. platformforge/rtk/__init__.py +9 -0
  620. platformforge/rtk/compact.py +127 -0
  621. platformforge/rtk/parsers.py +199 -0
  622. platformforge/rules/__init__.py +5 -0
  623. platformforge/rules/engine.py +345 -0
  624. platformforge/sandbox/__init__.py +7 -0
  625. platformforge/sandbox/env.py +151 -0
  626. platformforge/sandbox/review.py +92 -0
  627. platformforge/sdd/__init__.py +8 -0
  628. platformforge/sdd/lifecycle.py +239 -0
  629. platformforge/security/__init__.py +19 -0
  630. platformforge/security/cosign.py +67 -0
  631. platformforge/security/iam.py +112 -0
  632. platformforge/security/identity.py +84 -0
  633. platformforge/security/kyverno.py +106 -0
  634. platformforge/security/sbom.py +59 -0
  635. platformforge/security/scan.py +63 -0
  636. platformforge/security/slsa.py +86 -0
  637. platformforge/security/supply.py +62 -0
  638. platformforge/tokensave/__init__.py +20 -0
  639. platformforge/tokensave/budget.py +62 -0
  640. platformforge/tokensave/estimate.py +21 -0
  641. platformforge/tokensave/index.py +172 -0
  642. platformforge/tokensave/ledger.py +121 -0
  643. platformforge/tokensave/packs.py +285 -0
  644. platformforge/ui/__init__.py +28 -0
  645. platformforge/ui/app.py +465 -0
  646. platformforge/ui/home.py +113 -0
  647. platformforge/ui/i18n.py +143 -0
  648. platformforge/ui/kit.py +689 -0
  649. platformforge/ui/screen.py +628 -0
  650. platformforge/ui/tui.py +94 -0
  651. platformforge/ui/wizard.py +309 -0
  652. platformforge/workspace/__init__.py +13 -0
  653. platformforge/workspace/models.py +35 -0
  654. platformforge/workspace/service.py +69 -0
  655. platformforge-0.1.0.dist-info/METADATA +212 -0
  656. platformforge-0.1.0.dist-info/RECORD +659 -0
  657. platformforge-0.1.0.dist-info/WHEEL +4 -0
  658. platformforge-0.1.0.dist-info/entry_points.txt +3 -0
  659. platformforge-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,913 @@
1
+ #!/usr/bin/env python3
2
+ # forge_portal.py — Forge Portal local server (stdlib-only, vendored).
3
+ #
4
+ # Byte-parity vendored module: every Forge ships this file verbatim as
5
+ # `<pkg>/_forge_portal.py`. It knows NOTHING about any specific forge —
6
+ # a forge supplies a PortalAdapter (see `PORTAL ADAPTER CONTRACT` below)
7
+ # which registers typed operations. The server handles HTTP, sessions,
8
+ # CSRF, payload/rate limits, plan tokens, SSE and static assets.
9
+ #
10
+ # Security model (docs/fase0/security-matrix.md):
11
+ # - bind 127.0.0.1 only, ephemeral port
12
+ # - 128-bit session token → X-Forge-Session header on every /api call
13
+ # - POST: Origin loopback allowlist + Content-Type: application/json
14
+ # - no shell: operations are the adapter's typed registry only
15
+ # - mutating ops need `confirm` literal and, when applicable, plan_token
16
+ # - 1 MiB payload cap, 10 mutating POST/min per process, per-op timeout
17
+ # - responses pass through a secret-redacting allowlist scrub
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import secrets
26
+ import socket
27
+ import threading
28
+ import time
29
+ import urllib.parse
30
+ from collections import deque
31
+ from collections.abc import Callable, Iterable
32
+ from dataclasses import dataclass, field
33
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ SCHEMA = "forge/PortalApi/1"
38
+ API_PREFIX = "/api/v1"
39
+ SESSION_HEADER = "X-Forge-Session"
40
+ SESSION_COOKIE = "forge_session"
41
+ MAX_BODY = 1 << 20 # 1 MiB
42
+ MUTATION_WINDOW_S = 60.0
43
+ MUTATION_LIMIT = 10
44
+ CSP = (
45
+ "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
46
+ "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; "
47
+ "base-uri 'self'; form-action 'self'"
48
+ )
49
+
50
+ _SECRET_KEYS = ("token", "secret", "password", "cookie", "authorization", "api_key", "apikey", "credential")
51
+
52
+ # Keys that LOOK secret-shaped but are client-usable by design: the plan
53
+ # token must round-trip through the client for confirm-then-apply to work.
54
+ # Everything else matching _SECRET_KEYS is scrubbed.
55
+ _REDACT_EXEMPT = frozenset({"plan_token"})
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # errors / refusals
60
+
61
+
62
+ class PortalError(Exception):
63
+ """Structured error → error envelope. `code` is stable, never translated."""
64
+
65
+ def __init__(
66
+ self,
67
+ code: str,
68
+ message: str,
69
+ *,
70
+ http: int = 400,
71
+ severity: str = "error",
72
+ retryable: bool = False,
73
+ action: str | None = None,
74
+ message_key: str | None = None,
75
+ details: dict | None = None,
76
+ ) -> None:
77
+ super().__init__(message)
78
+ self.code = code
79
+ self.http = http
80
+ self.severity = severity
81
+ self.retryable = retryable
82
+ self.action = action
83
+ self.message_key = message_key
84
+ self.details = details
85
+
86
+ def body(self) -> dict:
87
+ body: dict[str, Any] = {
88
+ "code": self.code,
89
+ "message": str(self),
90
+ "severity": self.severity,
91
+ "retryable": self.retryable,
92
+ }
93
+ if self.message_key:
94
+ body["message_key"] = self.message_key
95
+ if self.details:
96
+ body["details"] = self.details
97
+ if self.action:
98
+ body["action"] = self.action
99
+ return body
100
+
101
+
102
+ class SseResult:
103
+ """Handler may return this to stream `events` then finish."""
104
+
105
+ def __init__(self, events: Iterable[dict]) -> None:
106
+ self.events = events
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # request context / operations
111
+
112
+
113
+ @dataclass
114
+ class RequestContext:
115
+ handler: PortalHandler
116
+ params: dict[str, str]
117
+ query: dict[str, list[str]]
118
+ body: dict | None
119
+ correlation_id: str
120
+
121
+ def require(self, key: str) -> str:
122
+ val = (self.body or {}).get(key)
123
+ if val is None or not str(val).strip():
124
+ raise PortalError(
125
+ "PORTAL-MISSING-FIELD",
126
+ f"required field '{key}' is missing",
127
+ message_key="error.generic",
128
+ details={"field": key},
129
+ )
130
+ return str(val)
131
+
132
+
133
+ @dataclass
134
+ class Operation:
135
+ method: str
136
+ path: str # e.g. "/health" or "/tasks/{task_id}/events"
137
+ handler: Callable[[RequestContext], Any]
138
+ mutating: bool = False
139
+ confirm: str | None = None # literal required in body["confirm"]
140
+ needs_plan_token: bool = False
141
+ timeout: float = 30.0
142
+
143
+ def match(self, path: str) -> dict[str, str] | None:
144
+ want = self.path.split("/")
145
+ got = path.split("/")
146
+ if len(want) != len(got):
147
+ return None
148
+ params: dict[str, str] = {}
149
+ for w, g in zip(want, got):
150
+ if w.startswith("{") and w.endswith("}"):
151
+ params[w[1:-1]] = g
152
+ elif w != g:
153
+ return None
154
+ return params
155
+
156
+
157
+ @dataclass
158
+ class PortalAdapter:
159
+ """Base adapter — a forge subclasses this (or constructs one via
160
+ `register()`) to expose its real services. Every op maps to real
161
+ service calls; nothing here fabricates state."""
162
+
163
+ forge_id: str = "the-forge"
164
+ forge_name: str = "Forge Portal"
165
+ forge_version: str = "0.0.0"
166
+ cli: str = "forge"
167
+ theme: str = "the-forge"
168
+ federated: bool = False
169
+ assets_dir: Path | None = None
170
+ workspace: str | None = None
171
+
172
+ ops: list[Operation] = field(default_factory=list)
173
+
174
+ def register(self, op: Operation) -> None:
175
+ self.ops.append(op)
176
+
177
+ # --- capability surface -------------------------------------------------
178
+ def descriptor(self) -> dict:
179
+ return {
180
+ "forge_id": self.forge_id,
181
+ "forge_name": self.forge_name,
182
+ "forge_version": self.forge_version,
183
+ "cli": self.cli,
184
+ "theme": self.theme,
185
+ "portal_version": portal_version(),
186
+ "federated": self.federated,
187
+ "capabilities": self.capabilities(),
188
+ "nav": self.nav(),
189
+ "workspace": {"root": self.workspace, "name": workspace_name(self.workspace)},
190
+ }
191
+
192
+ def capabilities(self) -> list[dict]:
193
+ return []
194
+
195
+ def nav(self) -> list[dict]:
196
+ return []
197
+
198
+ def health(self) -> dict:
199
+ return {
200
+ "state": "NOT_INSTALLED",
201
+ "checks": [],
202
+ "workspace": self.workspace,
203
+ "profile": None,
204
+ "updated_at": None,
205
+ }
206
+
207
+ def account_environment(self) -> dict[str, Any]:
208
+ return detect_account_environment(self)
209
+
210
+
211
+ def portal_version() -> str:
212
+ return "1.0.0"
213
+
214
+
215
+ def workspace_name(root: str | None) -> str | None:
216
+ if not root:
217
+ return None
218
+ return Path(root).name or root
219
+
220
+
221
+ def _environment_prefix(forge_id: str) -> str:
222
+ """Map forge id to portable account-variable prefix."""
223
+ import re
224
+
225
+ return re.sub(r"[^A-Z0-9]+", "_", forge_id.upper()).strip("_")
226
+
227
+
228
+ def detect_account_environment(adapter: PortalAdapter) -> dict[str, Any]:
229
+ """Validate installer-owned account variables without reading secrets.
230
+
231
+ Values are paths published by ``install.*``. The portal reports presence
232
+ and filesystem validity only; it never scans or echoes arbitrary env vars.
233
+ """
234
+ prefix = _environment_prefix(adapter.forge_id)
235
+ specs = (
236
+ ("HOME", "directory", True),
237
+ ("INSTALL_ROOT", "directory", True),
238
+ ("VENV", "directory", True),
239
+ ("PYTHON", "file", True),
240
+ ("CLI", "file", True),
241
+ ("LAUNCHER", "file", False),
242
+ )
243
+ variables: list[dict[str, Any]] = []
244
+ required_present = True
245
+ required_valid = True
246
+ any_present = False
247
+ for suffix, kind, required in specs:
248
+ name = f"{prefix}_{suffix}"
249
+ raw = os.environ.get(name, "").strip()
250
+ present = bool(raw)
251
+ any_present = any_present or present
252
+ path = Path(raw).expanduser() if present else None
253
+ valid = bool(path and (path.is_dir() if kind == "directory" else path.is_file()))
254
+ if required:
255
+ required_present = required_present and present
256
+ required_valid = required_valid and valid
257
+ variables.append(
258
+ {
259
+ "name": name,
260
+ "required": required,
261
+ "present": present,
262
+ "valid": valid if present else None,
263
+ "kind": kind,
264
+ "path": raw if present else None,
265
+ }
266
+ )
267
+ if required_present and required_valid:
268
+ state = "READY"
269
+ elif any_present and required_valid:
270
+ state = "PARTIAL"
271
+ elif any_present:
272
+ state = "INVALID"
273
+ else:
274
+ state = "MISSING"
275
+ return {
276
+ "schema": "forge/InstallEnvironment/v1",
277
+ "scope": "user",
278
+ "forge_id": adapter.forge_id,
279
+ "prefix": prefix,
280
+ "state": state,
281
+ "variables": variables,
282
+ "windows_aliases": os.name == "nt",
283
+ }
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # forge-portal local presence — already-defined locations only:
288
+ # FORGE_PORTAL_HOME env, ~/.forge/installations/forge-portal.json
289
+ # (forge/InstallationManifest/v1 registry), ~/.forge/installs/forge-portal/.
290
+ # Never fabricates paths; every reported location exists on disk.
291
+
292
+
293
+ def detect_forge_portal() -> dict[str, Any]:
294
+ """Whether a local forge-portal install exists in a defined location."""
295
+ locations: list[dict[str, str]] = []
296
+
297
+ env_root = os.environ.get("FORGE_PORTAL_HOME", "").strip()
298
+ env_ok = bool(env_root) and Path(env_root).is_dir()
299
+ if env_ok:
300
+ locations.append({"kind": "env:FORGE_PORTAL_HOME", "path": env_root})
301
+
302
+ home = Path(os.environ.get("FORGE_HOME_OVERRIDE") or Path.home())
303
+ manifest_path = home / ".forge" / "installations" / "forge-portal.json"
304
+ install_root: str | None = None
305
+ installed_at: str | None = None
306
+ manifest_ok = manifest_path.is_file()
307
+ if manifest_ok:
308
+ locations.append({"kind": "registry", "path": str(manifest_path)})
309
+ try:
310
+ doc = json.loads(manifest_path.read_text(encoding="utf-8"))
311
+ if isinstance(doc, dict):
312
+ install_root = doc.get("install_root") or None
313
+ installed_at = doc.get("installed_at") or None
314
+ except (OSError, ValueError):
315
+ pass # unreadable manifest still proves a registered install
316
+
317
+ if install_root and not Path(install_root).is_dir():
318
+ install_root = None # manifest points somewhere gone — honest
319
+ if install_root:
320
+ locations.append({"kind": "install_root", "path": install_root})
321
+ elif not manifest_ok:
322
+ default_root = home / ".forge" / "installs" / "forge-portal"
323
+ if default_root.is_dir():
324
+ install_root = str(default_root)
325
+ locations.append({"kind": "install_root", "path": install_root})
326
+
327
+ root = install_root or (env_root if env_ok else None)
328
+ return {
329
+ "state": "installed" if root or manifest_ok else "vendored",
330
+ "install_root": root,
331
+ "manifest": str(manifest_path) if manifest_ok else None,
332
+ "installed_at": installed_at,
333
+ "locations": locations,
334
+ }
335
+
336
+
337
+ def _with_portal_presence(data: Any) -> Any:
338
+ """Attach local forge-portal presence to health/status payloads."""
339
+ if not isinstance(data, dict):
340
+ return data
341
+ out = dict(data)
342
+ out["forge_portal"] = detect_forge_portal()
343
+ return out
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # plan tokens
348
+
349
+
350
+ def make_plan_token(secret: bytes, plan: dict) -> str:
351
+ raw = json.dumps(plan, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
352
+ sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()
353
+ payload = f"{int(time.time())}:{hashlib.sha256(raw).hexdigest()[:16]}:{sig}"
354
+ return secrets.token_urlsafe(8) + "." + urllib.parse.quote(payload, safe="")
355
+
356
+
357
+ def check_plan_token(secret: bytes, token: str, plan: dict, max_age_s: float = 900.0) -> bool:
358
+ try:
359
+ _nonce, payload = token.split(".", 1)
360
+ ts_s, plan_hash, sig = urllib.parse.unquote(payload).split(":")
361
+ ts = int(ts_s)
362
+ except (ValueError, AttributeError):
363
+ return False
364
+ if time.time() - ts > max_age_s:
365
+ return False
366
+ raw = json.dumps(plan, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
367
+ if hashlib.sha256(raw).hexdigest()[:16] != plan_hash:
368
+ return False
369
+ expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
370
+ return hmac.compare_digest(expected, sig)
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # redaction
375
+
376
+
377
+ def redact(value: Any) -> Any:
378
+ if isinstance(value, dict):
379
+ out = {}
380
+ for k, v in value.items():
381
+ if k in _REDACT_EXEMPT:
382
+ out[k] = v
383
+ elif any(s in k.lower() for s in _SECRET_KEYS):
384
+ out[k] = "«redacted»"
385
+ else:
386
+ out[k] = redact(v)
387
+ return out
388
+ if isinstance(value, list):
389
+ return [redact(v) for v in value]
390
+ return value
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # HTTP layer
395
+
396
+
397
+ class PortalHandler(BaseHTTPRequestHandler):
398
+ server: PortalServer
399
+ protocol_version = "HTTP/1.1"
400
+
401
+ # silence per-request stderr logging — portal is a local tool
402
+ def log_message(self, fmt: str, *args: Any) -> None:
403
+ self.server.log(fmt % args if args else fmt)
404
+
405
+ # -- envelope ----------------------------------------------------------
406
+ def _send_json(self, status: int, payload: dict, *, extra_headers: dict | None = None) -> None:
407
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
408
+ self.send_response(status)
409
+ self.send_header("Content-Type", "application/json; charset=utf-8")
410
+ self.send_header("Content-Length", str(len(body)))
411
+ for k, v in (extra_headers or {}).items():
412
+ self.send_header(k, v)
413
+ self._security_headers()
414
+ self.end_headers()
415
+ self.wfile.write(body)
416
+
417
+ def _security_headers(self) -> None:
418
+ self.send_header("X-Content-Type-Options", "nosniff")
419
+ self.send_header("Referrer-Policy", "no-referrer")
420
+ self.send_header("Content-Security-Policy", CSP)
421
+ self.send_header("Cache-Control", "no-store")
422
+
423
+ def _envelope(self, correlation: str, *, data: Any = None, error: dict | None = None) -> dict:
424
+ env: dict[str, Any] = {"schema": SCHEMA, "correlation_id": correlation}
425
+ if error is not None:
426
+ env["error"] = error
427
+ else:
428
+ env["data"] = data
429
+ return env
430
+
431
+ # -- early rejection ---------------------------------------------------
432
+ def _reject(self, status: int, payload: dict) -> None:
433
+ """Send an error after draining any request body still on the wire.
434
+
435
+ Responding without consuming the pending POST body leaves unread
436
+ data in the socket; the kernel then resets the connection and the
437
+ client never sees the response (WinError 10053 / ECONNRESET).
438
+ Drain up to a hard ceiling, then close cleanly.
439
+ """
440
+ if self.command in ("POST", "PUT", "PATCH"):
441
+ try:
442
+ length = int(self.headers.get("Content-Length") or 0)
443
+ except ValueError:
444
+ length = 0
445
+ remaining = min(length, 8 << 20)
446
+ while remaining > 0:
447
+ chunk = self.rfile.read(min(remaining, 1 << 16))
448
+ if not chunk:
449
+ break
450
+ remaining -= len(chunk)
451
+ self.close_connection = True
452
+ self._send_json(status, payload, extra_headers={"Connection": "close"})
453
+ return
454
+ self._send_json(status, payload)
455
+
456
+ # -- session -----------------------------------------------------------
457
+ def _session_ok(self) -> bool:
458
+ token = self.headers.get(SESSION_HEADER)
459
+ if not token or not hmac.compare_digest(token, self.server.session_token):
460
+ return False
461
+ cookie = self.headers.get("Cookie") or ""
462
+ if SESSION_COOKIE in cookie:
463
+ jar = dict(p.strip().split("=", 1) for p in cookie.split(";") if "=" in p)
464
+ cval = jar.get(SESSION_COOKIE, "")
465
+ if cval and not hmac.compare_digest(cval, self.server.session_token):
466
+ return False
467
+ return True
468
+
469
+ def _origin_ok(self) -> bool:
470
+ origin = self.headers.get("Origin")
471
+ if origin is None:
472
+ return True # non-browser client (curl) — session header still required
473
+ host = urllib.parse.urlparse(origin).hostname or ""
474
+ return host in ("127.0.0.1", "localhost", "::1")
475
+
476
+ def _host_ok(self) -> bool:
477
+ # DNS-rebinding guard: even with a valid session/origin, a browser
478
+ # pointed at attacker.example resolved to 127.0.0.1 must not reach
479
+ # the API or the assets — refuse any non-loopback Host early.
480
+ raw = (self.headers.get("Host") or "").strip().lower()
481
+ if raw.startswith("["):
482
+ host = raw[1:].split("]")[0]
483
+ else:
484
+ host = raw.split(":")[0]
485
+ return host in ("127.0.0.1", "localhost", "::1")
486
+
487
+ # -- routing -----------------------------------------------------------
488
+ def do_GET(self) -> None:
489
+ if not self._host_ok():
490
+ self._reject(403, self._envelope(self._cid(), error=_err("PORTAL-HOST", "host must be loopback")))
491
+ return
492
+ parsed = urllib.parse.urlparse(self.path)
493
+ path = parsed.path
494
+ if path.startswith(API_PREFIX + "/"):
495
+ self._api("GET", path, parsed)
496
+ else:
497
+ self._static(path, parsed)
498
+
499
+ def do_POST(self) -> None:
500
+ if not self._host_ok():
501
+ self._reject(403, self._envelope(self._cid(), error=_err("PORTAL-HOST", "host must be loopback")))
502
+ return
503
+ parsed = urllib.parse.urlparse(self.path)
504
+ if not parsed.path.startswith(API_PREFIX + "/"):
505
+ self._reject(404, self._envelope(self._cid(), error=_err("PORTAL-NOT-FOUND", "not found")))
506
+ return
507
+ self._api("POST", parsed.path, parsed)
508
+
509
+ # -- api ---------------------------------------------------------------
510
+ def _cid(self) -> str:
511
+ return self.headers.get("X-Correlation-Id") or secrets.token_hex(6)
512
+
513
+ def _api(self, method: str, path: str, parsed: urllib.parse.ParseResult) -> None:
514
+ cid = self._cid()
515
+ rel = path[len(API_PREFIX):] or "/"
516
+
517
+ if not self._session_ok():
518
+ self._reject(
519
+ 403,
520
+ self._envelope(
521
+ cid,
522
+ error=_err("PORTAL-SESSION", "missing or invalid session", message_key="error.session"),
523
+ ),
524
+ )
525
+ return
526
+
527
+ op = self._find_op(method, rel)
528
+ if op is None:
529
+ self._send_json(404, self._envelope(cid, error=_err("PORTAL-NOT-FOUND", f"no such operation: {rel}")))
530
+ return
531
+ params = op[1]
532
+ operation = op[0]
533
+
534
+ if method == "POST":
535
+ if not self._origin_ok():
536
+ self._reject(
537
+ 403,
538
+ self._envelope(cid, error=_err("PORTAL-ORIGIN", "cross-origin request refused")),
539
+ )
540
+ return
541
+ ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip()
542
+ if ctype != "application/json":
543
+ self._reject(
544
+ 415,
545
+ self._envelope(cid, error=_err("PORTAL-CONTENT-TYPE", "Content-Type must be application/json")),
546
+ )
547
+ return
548
+ if operation.mutating and not self.server.rate_limiter.allow():
549
+ self._reject(
550
+ 429,
551
+ self._envelope(
552
+ cid,
553
+ error=_err("PORTAL-RATE-LIMITED", "too many mutating requests", retryable=True),
554
+ ),
555
+ )
556
+ return
557
+
558
+ body = None
559
+ if method == "POST":
560
+ length = int(self.headers.get("Content-Length") or 0)
561
+ if length > MAX_BODY:
562
+ self._reject(
563
+ 413,
564
+ self._envelope(cid, error=_err("PORTAL-PAYLOAD-TOO-LARGE", "body exceeds 1 MiB")),
565
+ )
566
+ return
567
+ raw = self.rfile.read(length) if length else b"{}"
568
+ try:
569
+ parsed_body = json.loads(raw or b"{}")
570
+ except json.JSONDecodeError:
571
+ self._send_json(400, self._envelope(cid, error=_err("PORTAL-BAD-JSON", "body is not valid JSON")))
572
+ return
573
+ if not isinstance(parsed_body, dict):
574
+ self._send_json(400, self._envelope(cid, error=_err("PORTAL-BAD-JSON", "body must be a JSON object")))
575
+ return
576
+ body = parsed_body
577
+ if operation.confirm is not None and body.get("confirm") != operation.confirm:
578
+ self._send_json(
579
+ 400,
580
+ self._envelope(
581
+ cid,
582
+ error=_err(
583
+ "PORTAL-CONFIRM",
584
+ f'operation requires confirm: "{operation.confirm}"',
585
+ details={"expected": operation.confirm},
586
+ ),
587
+ ),
588
+ )
589
+ return
590
+ if operation.needs_plan_token and not body.get("plan_token"):
591
+ self._send_json(
592
+ 400,
593
+ self._envelope(cid, error=_err("PORTAL-PLAN-REQUIRED", "plan_token is required")),
594
+ )
595
+ return
596
+
597
+ ctx = RequestContext(
598
+ handler=self,
599
+ params=params,
600
+ query=urllib.parse.parse_qs(parsed.query),
601
+ body=body,
602
+ correlation_id=cid,
603
+ )
604
+
605
+ try:
606
+ result = self.server.run_with_timeout(operation.handler, ctx, operation.timeout)
607
+ except PortalError as e:
608
+ self._send_json(e.http, self._envelope(cid, error=e.body()))
609
+ return
610
+ except TimeoutError:
611
+ self._send_json(
612
+ 504,
613
+ self._envelope(
614
+ cid,
615
+ error=_err("PORTAL-TIMEOUT", f"operation exceeded {operation.timeout:.0f}s", retryable=True),
616
+ ),
617
+ )
618
+ return
619
+ except Exception as e: # noqa: BLE001 — outermost boundary; never leak internals
620
+ self.server.log(f"handler error on {rel}: {type(e).__name__}")
621
+ self._send_json(
622
+ 500,
623
+ self._envelope(
624
+ cid,
625
+ error=_err("PORTAL-INTERNAL", "internal error", retryable=False),
626
+ ),
627
+ )
628
+ return
629
+
630
+ if isinstance(result, SseResult):
631
+ self._stream_sse(result, cid)
632
+ return
633
+ self._send_json(200, self._envelope(cid, data=redact(result)))
634
+
635
+ def _find_op(self, method: str, rel: str) -> tuple[Operation, dict[str, str]] | None:
636
+ for op in self.server.adapter.ops:
637
+ if op.method != method:
638
+ continue
639
+ params = op.match(rel)
640
+ if params is not None:
641
+ return op, params
642
+ return None
643
+
644
+ def _stream_sse(self, result: SseResult, cid: str) -> None:
645
+ self.send_response(200)
646
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
647
+ self.send_header("Cache-Control", "no-store")
648
+ self.send_header("X-Accel-Buffering", "no")
649
+ self._security_headers()
650
+ self.end_headers()
651
+ try:
652
+ for event in result.events:
653
+ payload = json.dumps(redact(event), ensure_ascii=False)
654
+ self.wfile.write(f"data: {payload}\n\n".encode())
655
+ self.wfile.flush()
656
+ except (BrokenPipeError, ConnectionResetError):
657
+ self.server.log("sse client disconnected")
658
+
659
+ # -- static ------------------------------------------------------------
660
+ def _static(self, path: str, parsed: urllib.parse.ParseResult) -> None:
661
+ assets = self.server.adapter.assets_dir
662
+ if assets is None:
663
+ self.send_error(404)
664
+ return
665
+ url_q = urllib.parse.parse_qs(parsed.query)
666
+ session_ok = url_q.get("forge_session", [""])[0] == self.server.session_token
667
+
668
+ rel = urllib.parse.unquote(path).lstrip("/")
669
+ # normalize — traversal refuses hard
670
+ norm = str(Path(rel))
671
+ if norm.startswith(("..", "/")) or ".." in Path(rel).parts:
672
+ self.send_error(404)
673
+ return
674
+ target = (assets / norm).resolve()
675
+ try:
676
+ target.relative_to(assets.resolve())
677
+ except ValueError:
678
+ self.send_error(404)
679
+ return
680
+
681
+ if not target.is_file():
682
+ # SPA fallback: any extension-less path serves index.html
683
+ index = assets / "index.html"
684
+ if index.is_file() and "." not in Path(rel).name:
685
+ target = index
686
+ else:
687
+ self.send_error(404)
688
+ return
689
+
690
+ ctype = _content_type(target)
691
+ data = target.read_bytes()
692
+ self.send_response(200)
693
+ self.send_header("Content-Type", ctype)
694
+ self.send_header("Content-Length", str(len(data)))
695
+ self._security_headers()
696
+ if session_ok:
697
+ self.send_header(
698
+ "Set-Cookie",
699
+ f"{SESSION_COOKIE}={self.server.session_token}; SameSite=Strict; HttpOnly; Path=/",
700
+ )
701
+ self.end_headers()
702
+ self.wfile.write(data)
703
+
704
+
705
+ def _err(code: str, message: str, *, retryable: bool = False, message_key: str | None = None, details: dict | None = None) -> dict:
706
+ body: dict[str, Any] = {
707
+ "code": code,
708
+ "message": message,
709
+ "severity": "error",
710
+ "retryable": retryable,
711
+ }
712
+ if message_key:
713
+ body["message_key"] = message_key
714
+ if details:
715
+ body["details"] = details
716
+ return body
717
+
718
+
719
+ _TYPES = {
720
+ ".html": "text/html; charset=utf-8",
721
+ ".css": "text/css; charset=utf-8",
722
+ ".js": "text/javascript; charset=utf-8",
723
+ ".mjs": "text/javascript; charset=utf-8",
724
+ ".json": "application/json; charset=utf-8",
725
+ ".svg": "image/svg+xml",
726
+ ".png": "image/png",
727
+ ".ico": "image/x-icon",
728
+ ".woff": "font/woff",
729
+ ".woff2": "font/woff2",
730
+ ".map": "application/json",
731
+ ".txt": "text/plain; charset=utf-8",
732
+ }
733
+
734
+
735
+ def _content_type(path: Path) -> str:
736
+ return _TYPES.get(path.suffix.lower(), "application/octet-stream")
737
+
738
+
739
+ # ---------------------------------------------------------------------------
740
+ # rate limiter
741
+
742
+
743
+ class RateLimiter:
744
+ def __init__(self, limit: int = MUTATION_LIMIT, window: float = MUTATION_WINDOW_S) -> None:
745
+ self.limit = limit
746
+ self.window = window
747
+ self._hits: deque[float] = deque()
748
+ self._lock = threading.Lock()
749
+
750
+ def allow(self) -> bool:
751
+ now = time.monotonic()
752
+ with self._lock:
753
+ while self._hits and now - self._hits[0] > self.window:
754
+ self._hits.popleft()
755
+ if len(self._hits) >= self.limit:
756
+ return False
757
+ self._hits.append(now)
758
+ return True
759
+
760
+
761
+ # ---------------------------------------------------------------------------
762
+ # server
763
+
764
+
765
+ class PortalServer(ThreadingHTTPServer):
766
+ daemon_threads = True
767
+ allow_reuse_address = True
768
+
769
+ def __init__(
770
+ self,
771
+ adapter: PortalAdapter,
772
+ *,
773
+ host: str = "127.0.0.1",
774
+ port: int = 0,
775
+ quiet: bool = True,
776
+ ) -> None:
777
+ if host not in ("127.0.0.1", "localhost", "::1"):
778
+ raise ValueError("portal binds loopback only")
779
+ self.adapter = adapter
780
+ self.session_token = secrets.token_urlsafe(24)
781
+ self._plan_secret = secrets.token_bytes(32)
782
+ self.rate_limiter = RateLimiter()
783
+ self._quiet = quiet
784
+ self._executor = __import__("concurrent.futures", fromlist=["ThreadPoolExecutor"]).ThreadPoolExecutor(
785
+ max_workers=8, thread_name_prefix="forge-portal"
786
+ )
787
+ super().__init__((host, port), PortalHandler)
788
+
789
+ # -- helpers used by the handler --------------------------------------
790
+ def log(self, msg: str) -> None:
791
+ if not self._quiet:
792
+ print(f"[forge-portal] {msg}")
793
+
794
+ def run_with_timeout(self, fn: Callable[[RequestContext], Any], ctx: RequestContext, timeout: float) -> Any:
795
+ fut = self._executor.submit(fn, ctx)
796
+ return fut.result(timeout=timeout)
797
+
798
+ def sign_plan(self, plan: dict) -> str:
799
+ return make_plan_token(self._plan_secret, plan)
800
+
801
+ def check_plan(self, token: str, plan: dict) -> bool:
802
+ return check_plan_token(self._plan_secret, token, plan)
803
+
804
+ # -- public API ---------------------------------------------------------
805
+ @property
806
+ def port(self) -> int:
807
+ return self.server_address[1]
808
+
809
+ @property
810
+ def url(self) -> str:
811
+ return f"http://127.0.0.1:{self.port}/?forge_session={self.session_token}"
812
+
813
+ def serve_in_thread(self) -> threading.Thread:
814
+ t = threading.Thread(target=self.serve_forever, name="forge-portal-http", daemon=True)
815
+ t.start()
816
+ return t
817
+
818
+
819
+ def default_operations(adapter: PortalAdapter) -> list[Operation]:
820
+ """Core read-only surface every portal gets for free, mapped to
821
+ adapter methods. Forges add mutating + domain ops on top."""
822
+
823
+ def _get(attr: str, fallback: Any):
824
+ fn = getattr(adapter, attr, None) or fallback
825
+
826
+ def h(ctx: RequestContext) -> Any:
827
+ if fn is None:
828
+ raise PortalError("PORTAL-UNSUPPORTED", f"capability '{attr}' not available on this forge", http=404)
829
+ return fn(ctx) if _wants_ctx(fn) else fn()
830
+
831
+ return h
832
+
833
+ return [
834
+ Operation(
835
+ "GET",
836
+ "/bootstrap",
837
+ lambda ctx: {
838
+ "descriptor": adapter.descriptor(),
839
+ "health": _with_portal_presence(adapter.health()),
840
+ "account_environment": adapter.account_environment(),
841
+ },
842
+ ),
843
+ Operation("GET", "/health", _environment_presence_op(_presence_op(_get("health", None)), adapter)),
844
+ Operation("GET", "/environment", lambda ctx: adapter.account_environment()),
845
+ Operation("GET", "/capabilities", lambda ctx: adapter.capabilities()),
846
+ Operation(
847
+ "GET",
848
+ "/installation/status",
849
+ _environment_presence_op(
850
+ _presence_op(_get("installation_status", adapter.health)), adapter
851
+ ),
852
+ ),
853
+ ]
854
+
855
+
856
+ def _presence_op(handler: Callable[[RequestContext], Any]) -> Callable[[RequestContext], Any]:
857
+ def h(ctx: RequestContext) -> Any:
858
+ return _with_portal_presence(handler(ctx))
859
+
860
+ return h
861
+
862
+
863
+ def _environment_presence_op(
864
+ handler: Callable[[RequestContext], Any], adapter: PortalAdapter
865
+ ) -> Callable[[RequestContext], Any]:
866
+ def h(ctx: RequestContext) -> Any:
867
+ data = handler(ctx)
868
+ if isinstance(data, dict):
869
+ data = dict(data)
870
+ data["account_environment"] = adapter.account_environment()
871
+ return data
872
+
873
+ return h
874
+
875
+
876
+ def _wants_ctx(fn: Callable) -> bool:
877
+ try:
878
+ import inspect
879
+
880
+ return len(inspect.signature(fn).parameters) >= 1
881
+ except (TypeError, ValueError):
882
+ return False
883
+
884
+
885
+ def free_port() -> int:
886
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
887
+ s.bind(("127.0.0.1", 0))
888
+ return int(s.getsockname()[1])
889
+
890
+
891
+ def serve(adapter: PortalAdapter, *, port: int = 0, open_url: bool = True, quiet: bool = True) -> None:
892
+ """Blocking entry point used by `<cli> portal`."""
893
+ server = PortalServer(adapter, port=port, quiet=quiet)
894
+ url = server.url
895
+ print(f"forge-portal listening at {url}")
896
+ if open_url:
897
+ try:
898
+ import webbrowser
899
+
900
+ webbrowser.open(url)
901
+ except Exception as e: # noqa: BLE001 — browser open is best-effort
902
+ print(f"could not open browser: {e}")
903
+ try:
904
+ server.serve_forever()
905
+ except KeyboardInterrupt:
906
+ pass
907
+ finally:
908
+ server.shutdown()
909
+ server.server_close()
910
+
911
+
912
+ if __name__ == "__main__": # manual smoke: python forge_portal.py
913
+ serve(PortalAdapter(), quiet=False)