flowforge-map 0.2.0__tar.gz → 0.3.0__tar.gz

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 (122) hide show
  1. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/PKG-INFO +23 -5
  2. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/README.md +21 -4
  3. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/agent/server.py +78 -3
  4. flowforge_map-0.3.0/backend/flowforge/api/issues.py +150 -0
  5. flowforge_map-0.3.0/backend/flowforge/api/keys.py +184 -0
  6. flowforge_map-0.3.0/backend/flowforge/api/lock.py +265 -0
  7. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/cli.py +34 -0
  8. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/main.py +5 -0
  9. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/finders.py +42 -0
  10. flowforge_map-0.3.0/backend/flowforge/security/fixes.py +246 -0
  11. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/keystore.py +6 -2
  12. flowforge_map-0.3.0/backend/flowforge/security/lock.py +290 -0
  13. flowforge_map-0.3.0/frontend/dist/assets/index-BmxPFjrF.js +52 -0
  14. flowforge_map-0.3.0/frontend/dist/assets/index-C9vBGBLq.css +1 -0
  15. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/index.html +2 -2
  16. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/pyproject.toml +2 -1
  17. flowforge_map-0.2.0/backend/flowforge/api/keys.py +0 -107
  18. flowforge_map-0.2.0/frontend/dist/assets/index-C-GjahSy.js +0 -52
  19. flowforge_map-0.2.0/frontend/dist/assets/index-CWfr5fii.css +0 -1
  20. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/.gitignore +0 -0
  21. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/LICENSE +0 -0
  22. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/__init__.py +0 -0
  23. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/agent/__init__.py +0 -0
  24. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/__init__.py +0 -0
  25. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/agent.py +0 -0
  26. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/checks.py +0 -0
  27. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/connectors.py +0 -0
  28. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/flowmap.py +0 -0
  29. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/runs.py +0 -0
  30. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/scan.py +0 -0
  31. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/security.py +0 -0
  32. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/stats.py +0 -0
  33. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/checks/__init__.py +0 -0
  34. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/checks/models.py +0 -0
  35. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/checks/probes.py +0 -0
  36. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/__init__.py +0 -0
  37. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/catalog.py +0 -0
  38. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/models.py +0 -0
  39. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/presets.py +0 -0
  40. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/registry.py +0 -0
  41. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/runtime.py +0 -0
  42. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/secrets.py +0 -0
  43. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/example_data.py +0 -0
  44. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/__init__.py +0 -0
  45. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/advice.py +0 -0
  46. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/curly.py +0 -0
  47. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/index.py +0 -0
  48. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/order.py +0 -0
  49. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/reach.py +0 -0
  50. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/rewrite.py +0 -0
  51. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/__init__.py +0 -0
  52. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/diff.py +0 -0
  53. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/fixture.py +0 -0
  54. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/models.py +0 -0
  55. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/planner.py +0 -0
  56. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/service.py +0 -0
  57. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/validate.py +0 -0
  58. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/home.py +0 -0
  59. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/__init__.py +0 -0
  60. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/base.py +0 -0
  61. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/check_node.py +0 -0
  62. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/http_node.py +0 -0
  63. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/llm_node.py +0 -0
  64. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/local_node.py +0 -0
  65. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/mcp_node.py +0 -0
  66. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/mock_node.py +0 -0
  67. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/__init__.py +0 -0
  68. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/facts.py +0 -0
  69. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/files.py +0 -0
  70. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/manifests.py +0 -0
  71. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/mcp_config.py +0 -0
  72. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/models.py +0 -0
  73. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/patterns.py +0 -0
  74. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/python_ast.py +0 -0
  75. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/scanner.py +0 -0
  76. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/services.py +0 -0
  77. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/urls.py +0 -0
  78. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/__init__.py +0 -0
  79. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/cache.py +0 -0
  80. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/critical_path.py +0 -0
  81. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/durations.py +0 -0
  82. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/executor.py +0 -0
  83. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/graph.py +0 -0
  84. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/rate_limit.py +0 -0
  85. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/schema.py +0 -0
  86. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/__init__.py +0 -0
  87. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/findings.py +0 -0
  88. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/logfilter.py +0 -0
  89. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/spa.py +0 -0
  90. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/state.py +0 -0
  91. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/storage.py +0 -0
  92. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/templating.py +0 -0
  93. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/workflows/diamond_mock.json +0 -0
  94. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/workflows/stripe_to_razorpay.json +0 -0
  95. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/.env.example +0 -0
  96. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/.gitignore +0 -0
  97. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/.mcp.json +0 -0
  98. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/README.md +0 -0
  99. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/config.py +0 -0
  100. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/mcp_servers/currency.py +0 -0
  101. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/planner.py +0 -0
  102. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/requirements.txt +0 -0
  103. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/__init__.py +0 -0
  104. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/geo.py +0 -0
  105. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/llm.py +0 -0
  106. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/mcp_tools.py +0 -0
  107. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/weather.py +0 -0
  108. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/tools/packing_list.py +0 -0
  109. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/trips/.gitkeep +0 -0
  110. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/classic.html +0 -0
  111. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  112. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  113. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  114. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  115. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  116. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  117. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  118. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  119. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  120. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  121. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  122. {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flowforge-map
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Map what your app depends on (LLMs, MCP servers, APIs), check their health and security, and see what could run faster. Local and free; the scan needs no AI.
5
5
  Project-URL: Homepage, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator
6
6
  Project-URL: Issues, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator/issues
@@ -29,6 +29,7 @@ Requires-Dist: openai>=1.40
29
29
  Requires-Dist: pydantic>=2.8
30
30
  Requires-Dist: python-dotenv>=1.0
31
31
  Requires-Dist: python-multipart>=0.0.32
32
+ Requires-Dist: segno>=1.6
32
33
  Requires-Dist: uvicorn[standard]>=0.30
33
34
  Description-Content-Type: text/markdown
34
35
 
@@ -113,7 +114,18 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
113
114
  claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
114
115
  ```
115
116
 
116
- Your agent gets seven read-only tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys` and `what_changed`. Every answer is masked (key values never appear), FlowForge never edits your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
117
+ Your agent gets ten tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys`, `get_fix_requests`, `request_key`, `wait_for_keys` and `what_changed`. All read only, except `request_key`, which only asks you for a key by name. Every answer is masked (key values never appear), the agent can't make FlowForge change your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
118
+
119
+ ### Fix what's unsafe, with one click
120
+
121
+ The **Issues** page lists what the scan found unsafe, and every issue has one button:
122
+
123
+ | Issue | Button | What happens |
124
+ |---|---|---|
125
+ | A key written in Python, or in JS/TS that runs on Node | **Fix** | FlowForge shows the change (the key masked). On **Approve**, the key goes to your keys and the line reads it from the environment, on a new branch `flowforge/fix-…`. Your branch and files aren't touched; merge it when you're ready. |
126
+ | `http://`, an unpinned MCP server, a server given the whole disk, a key in `.mcp.json`, Dart or browser code | **Send to agent** | It joins your coding agent's list (`get_fix_requests`). Tell your agent "do the FlowForge fixes". |
127
+
128
+ Fix needs a git repo and the file committed. FlowForge works out the change itself, with no AI. Replace a key that was in your code with its provider anyway: older commits still hold it.
117
129
 
118
130
  ### Keys your coding agent never sees
119
131
 
@@ -126,9 +138,13 @@ flowforge key import # move a .env into FlowForge (then de
126
138
  flowforge run -- python app.py # your app starts with the keys in its environment
127
139
  ```
128
140
 
129
- Each command takes `--repo PATH` (default: the folder you're in). On the map, a 🔑 badge marks a block whose key is missing, and the ⚠ list has a box to paste it into. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
141
+ Each command takes `--repo PATH` (default: the folder you're in). In the dashboard, the **Keys** page does the same with no commands: paste a missing key into its box, or click **Move into FlowForge** for one that's in `.env`. On the map, a 🔑 badge marks a block whose key is missing. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
142
+
143
+ **When your agent adds a dependency that needs a key**, it calls `request_key("STRIPE_SECRET_KEY", "for Stripe in pay.py")`. The map says "Your agent asked for a key", and the new block shows in coral, "key missing". Click it, paste the key on the Keys page, and the agent's `wait_for_keys` returns the moment it's saved. The agent only ever sees the name.
144
+
145
+ **See a key again** in the **Vault**: set a password and scan the QR with an authenticator app (Google Authenticator, Authy, 1Password), once. After that, your password, then the 6-digit code on your phone, open it for 5 minutes. It lists every key with its health: healthy, written in code, in `.env`, can't read, unused, or old. It also shows where each key lives, what uses it, and whether it's a live or test key. Each value shows for 30 seconds. Forgot the password? `flowforge lock reset --forget` drops the lock (your keys stay) and the dashboard says so.
130
146
 
131
- What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, or read your keychain, so it isn't impossible. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
147
+ What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, read your keychain, or reset the lock (the dashboard then shows it was reset), so it isn't impossible. The 2FA stops web pages, anyone at your browser, and anything without your phone. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
132
148
 
133
149
  ### Send someone the map
134
150
 
@@ -255,7 +271,9 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
255
271
  | `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
256
272
  | `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
257
273
  | `GET /security` | The scan's security findings, by level, with the fix and a prompt for each (D31) |
258
- | `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}` | The keys the code reads and their status; save one (write-only: the value never comes back) or forget it (D36) |
274
+ | `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}`, `POST /keys/{name}/import` | The keys the code reads and their status; save one (write-only), move one in from `.env`, or forget it (D36, D37) |
275
+ | `GET /issues`, `POST /issues/{id}/preview\|fix\|send` | Every issue with its button; the masked change; make it on a new branch; send it to your agent (D37) |
276
+ | `GET /lock`, `POST /lock/setup\|confirm\|password\|code\|close`, `GET /vault/keys`, `POST /keys/reveal` | The Vault: its lock (your password, then the authenticator code) and, once open, every key's health and one value at a time (D37, D38) |
259
277
  | `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
260
278
  | `GET /workflows`, `GET /workflows/{name}` | The example workflows |
261
279
  | `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
@@ -79,7 +79,18 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
79
79
  claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
80
80
  ```
81
81
 
82
- Your agent gets seven read-only tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys` and `what_changed`. Every answer is masked (key values never appear), FlowForge never edits your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
82
+ Your agent gets ten tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys`, `get_fix_requests`, `request_key`, `wait_for_keys` and `what_changed`. All read only, except `request_key`, which only asks you for a key by name. Every answer is masked (key values never appear), the agent can't make FlowForge change your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
83
+
84
+ ### Fix what's unsafe, with one click
85
+
86
+ The **Issues** page lists what the scan found unsafe, and every issue has one button:
87
+
88
+ | Issue | Button | What happens |
89
+ |---|---|---|
90
+ | A key written in Python, or in JS/TS that runs on Node | **Fix** | FlowForge shows the change (the key masked). On **Approve**, the key goes to your keys and the line reads it from the environment, on a new branch `flowforge/fix-…`. Your branch and files aren't touched; merge it when you're ready. |
91
+ | `http://`, an unpinned MCP server, a server given the whole disk, a key in `.mcp.json`, Dart or browser code | **Send to agent** | It joins your coding agent's list (`get_fix_requests`). Tell your agent "do the FlowForge fixes". |
92
+
93
+ Fix needs a git repo and the file committed. FlowForge works out the change itself, with no AI. Replace a key that was in your code with its provider anyway: older commits still hold it.
83
94
 
84
95
  ### Keys your coding agent never sees
85
96
 
@@ -92,9 +103,13 @@ flowforge key import # move a .env into FlowForge (then de
92
103
  flowforge run -- python app.py # your app starts with the keys in its environment
93
104
  ```
94
105
 
95
- Each command takes `--repo PATH` (default: the folder you're in). On the map, a 🔑 badge marks a block whose key is missing, and the ⚠ list has a box to paste it into. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
106
+ Each command takes `--repo PATH` (default: the folder you're in). In the dashboard, the **Keys** page does the same with no commands: paste a missing key into its box, or click **Move into FlowForge** for one that's in `.env`. On the map, a 🔑 badge marks a block whose key is missing. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
107
+
108
+ **When your agent adds a dependency that needs a key**, it calls `request_key("STRIPE_SECRET_KEY", "for Stripe in pay.py")`. The map says "Your agent asked for a key", and the new block shows in coral, "key missing". Click it, paste the key on the Keys page, and the agent's `wait_for_keys` returns the moment it's saved. The agent only ever sees the name.
109
+
110
+ **See a key again** in the **Vault**: set a password and scan the QR with an authenticator app (Google Authenticator, Authy, 1Password), once. After that, your password, then the 6-digit code on your phone, open it for 5 minutes. It lists every key with its health: healthy, written in code, in `.env`, can't read, unused, or old. It also shows where each key lives, what uses it, and whether it's a live or test key. Each value shows for 30 seconds. Forgot the password? `flowforge lock reset --forget` drops the lock (your keys stay) and the dashboard says so.
96
111
 
97
- What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, or read your keychain, so it isn't impossible. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
112
+ What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, read your keychain, or reset the lock (the dashboard then shows it was reset), so it isn't impossible. The 2FA stops web pages, anyone at your browser, and anything without your phone. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
98
113
 
99
114
  ### Send someone the map
100
115
 
@@ -221,7 +236,9 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
221
236
  | `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
222
237
  | `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
223
238
  | `GET /security` | The scan's security findings, by level, with the fix and a prompt for each (D31) |
224
- | `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}` | The keys the code reads and their status; save one (write-only: the value never comes back) or forget it (D36) |
239
+ | `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}`, `POST /keys/{name}/import` | The keys the code reads and their status; save one (write-only), move one in from `.env`, or forget it (D36, D37) |
240
+ | `GET /issues`, `POST /issues/{id}/preview\|fix\|send` | Every issue with its button; the masked change; make it on a new branch; send it to your agent (D37) |
241
+ | `GET /lock`, `POST /lock/setup\|confirm\|password\|code\|close`, `GET /vault/keys`, `POST /keys/reveal` | The Vault: its lock (your password, then the authenticator code) and, once open, every key's health and one value at a time (D37, D38) |
225
242
  | `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
226
243
  | `GET /workflows`, `GET /workflows/{name}` | The example workflows |
227
244
  | `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import logging
12
+ import time
12
13
  import uuid
13
14
  from collections import Counter
14
15
  from pathlib import Path
@@ -19,6 +20,8 @@ from mcp.server.mcpserver import MCPServer
19
20
  from mcp.types import ToolAnnotations
20
21
 
21
22
  from flowforge.api.checks import CHECKABLE, check_workflow, command_text, confirmed_commands, target_for
23
+ from flowforge.api.issues import FIX_REQUESTS_SETTING, fix_requests
24
+ from flowforge.api.keys import add_key_request, clear_key_requests
22
25
  from flowforge.api.scan import REPO_SETTING
23
26
  from flowforge.checks import starts_process
24
27
  from flowforge.connectors.registry import Registry
@@ -28,7 +31,7 @@ from flowforge.flowmap.validate import code_writes, critical
28
31
  from flowforge.nodes.check_node import CheckNode
29
32
  from flowforge.scan import ScanResult, scan
30
33
  from flowforge.scheduler.executor import StepState, run_workflow
31
- from flowforge.security.keystore import KeyStore, report
34
+ from flowforge.security.keystore import NAME, KeyStore, report
32
35
  from flowforge.security.logfilter import Redactor, redact_code, secret_values_for
33
36
  from flowforge.storage import Storage, now_iso
34
37
 
@@ -43,10 +46,12 @@ INSTRUCTIONS = (
43
46
  "each with the file:line it comes from, checks their health, and shows the order the code calls them, the "
44
47
  "critical path and which calls could run together, and what is unsafe (keys in code, plain http, risky MCP "
45
48
  "servers). It reads code without running it and sends nothing to any "
46
- "LLM. Answers are masked: key values never appear. It never edits code: you make the change, then call "
47
- "what_changed to confirm."
49
+ "LLM. Answers are masked: key values never appear. It doesn't edit code for you: you make the change, "
50
+ "then call what_changed to confirm. When the user says \"do the FlowForge fixes\", call get_fix_requests."
48
51
  )
49
52
  READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
53
+ ASK = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
54
+ MAX_WAIT_S = 110
50
55
  CHECKS = ToolAnnotations(read_only_hint=True, open_world_hint=True) # makes requests and may start servers
51
56
 
52
57
 
@@ -165,6 +170,12 @@ def headline(tool: str, data: dict[str, Any]) -> str | None:
165
170
  counts = data["summary"]
166
171
  parts = [f"{counts[k]} {w}" for k, w in (("missing", "missing"), ("env_file", "in .env")) if counts.get(k)]
167
172
  return " · ".join(parts) or ("All saved" if data["keys"] else "No keys read")
173
+ if tool == "request_key":
174
+ return f"Asked for {data['name']}"
175
+ if tool == "wait_for_keys":
176
+ return "All saved" if data["done"] else f"{_plural(len(data['waiting']), 'key')} still missing"
177
+ if tool == "get_fix_requests":
178
+ return _plural(len(data["requests"]), "fix", "fixes") + " sent to you" if data["requests"] else "Nothing sent"
168
179
  if tool == "what_changed":
169
180
  if not data["saved"]:
170
181
  return "No saved map yet"
@@ -195,6 +206,20 @@ def _keys_data(root: Path, result: ScanResult) -> dict[str, Any]:
195
206
  "`flowforge key list` is what confirms it can be read"}
196
207
 
197
208
 
209
+ def _fix_requests_data(r: _Repo) -> dict[str, Any]:
210
+ """The user's queue (D37), without the issues the code no longer has. Only when the dashboard maps this repo."""
211
+ if not r.shared:
212
+ return {"requests": [], "message": "The dashboard doesn't map this repo, so nothing was sent from it."}
213
+ queue = fix_requests(r.storage)
214
+ here = str(r.root)
215
+ now = {f.id for f in r.scan().findings}
216
+ done = [q for q in queue if q["repo"] == here and q["id"] not in now] # the code no longer has it
217
+ if done:
218
+ r.storage.set_setting(FIX_REQUESTS_SETTING, json.dumps([q for q in queue if q not in done]))
219
+ live = [q for q in queue if q["repo"] == here and q["id"] in now]
220
+ return {"requests": [{k: q.get(k) for k in ("id", "kind", "title", "ref", "fix", "prompt")} for q in live]}
221
+
222
+
198
223
  def _flow_data(result: Any) -> dict[str, Any]:
199
224
  if result.flow is None:
200
225
  return {"available": False, "message": result.message}
@@ -251,6 +276,8 @@ def preview(repo: Path, storage: Storage) -> dict[str, Any]:
251
276
  if advice["available"] else None,
252
277
  "sub": f"saves ~{_seconds(advice['saved_ms'])}, the change written out" if advice["available"] else None,
253
278
  "tone": "warn" if advice["available"] and advice["advice"] else "plain"},
279
+ {"tool": "get_fix_requests", "headline": headline("get_fix_requests", _fix_requests_data(r)),
280
+ "sub": "issues you send from the Issues page", "tone": "plain"},
254
281
  {"tool": "what_changed", "headline": None, "sub": "After your agent edits: what it added or removed",
255
282
  "tone": "plain"},
256
283
  ]
@@ -337,6 +364,54 @@ def build_server(repo: Path, storage: Storage, *, transport: httpx.AsyncBaseTran
337
364
  run the `command` given (they type the key, hidden). Their app gets the keys with `flowforge run -- <app>`."""
338
365
  return r.answer("get_keys", _keys_data(r.root, r.scan()))
339
366
 
367
+ @server.tool(annotations=ASK)
368
+ def request_key(name: str, why: str) -> dict[str, Any]:
369
+ """Ask the user for a key your change needs (D38), e.g. after adding Stripe: name="STRIPE_SECRET_KEY",
370
+ why="for Stripe in pay.py". It shows first on FlowForge's Keys page. You never get or send the value: write
371
+ code that reads the name from the environment, tell the user to paste the key on the Keys page, then call
372
+ wait_for_keys. Their app gets it with `flowforge run`."""
373
+ if not NAME.fullmatch(name):
374
+ raise ValueError("name must be an env var name: letters, digits and _, not starting with a digit")
375
+ add_key_request(r.storage, r.root, name, why.strip() or "asked by your coding agent")
376
+ saved = KeyStore(r.root).status([name], verify=False)[name] == "saved"
377
+ if saved:
378
+ clear_key_requests(r.storage, r.root, [name])
379
+ where = "" if r.shared else " (open the dashboard on this repo: `flowforge --repo " + str(r.root) + "`)"
380
+ return r.answer("request_key", {
381
+ "name": name, "status": "saved" if saved else "requested",
382
+ "message": f"{name} is saved already: use it." if saved else
383
+ f"Ask the user to paste {name} on FlowForge's Keys page{where}; it's at the top. Then call wait_for_keys."})
384
+
385
+ @server.tool(annotations=READ)
386
+ async def wait_for_keys(names: list[str], seconds: int = 60) -> dict[str, Any]:
387
+ """Wait until the user has saved these keys on the Keys page (D38): returns as soon as all are saved, or
388
+ after `seconds` (at most 110; call again to keep waiting). Reads only which names are saved, never a
389
+ value."""
390
+ import asyncio
391
+
392
+ wanted = [n for n in dict.fromkeys(names) if NAME.fullmatch(n)]
393
+ deadline = time.monotonic() + max(1, min(MAX_WAIT_S, int(seconds)))
394
+ store = KeyStore(r.root)
395
+ while True:
396
+ status = store.status(wanted, verify=False)
397
+ saved = [n for n in wanted if status[n] == "saved"]
398
+ if len(saved) == len(wanted) or time.monotonic() >= deadline:
399
+ break
400
+ await asyncio.sleep(1)
401
+ clear_key_requests(r.storage, r.root, saved)
402
+ waiting = [n for n in wanted if n not in saved]
403
+ return r.answer("wait_for_keys", {
404
+ "saved": saved, "waiting": waiting, "done": not waiting,
405
+ "message": "All saved: run the app with `flowforge run -- <its command>`." if not waiting else
406
+ f"Still waiting for {', '.join(waiting)}: call wait_for_keys again, or remind the user."})
407
+
408
+ @server.tool(annotations=READ)
409
+ def get_fix_requests() -> dict[str, Any]:
410
+ """The issues the user sent you from FlowForge's Issues page ("do the FlowForge fixes"): each with its
411
+ file:line, what is wrong, how to fix it and the whole change described (masked: no key value). An issue leaves
412
+ the list once the code no longer has it, so call what_changed after your edit to confirm."""
413
+ return r.answer("get_fix_requests", _fix_requests_data(r))
414
+
340
415
  @server.tool(annotations=READ)
341
416
  def what_changed() -> dict[str, Any]:
342
417
  """After an edit: the dependencies added or removed since the map saved in the dashboard, and the flow's
@@ -0,0 +1,150 @@
1
+ """The Issues page (D37): every security finding with the button it gets. Fix (FlowForge changes the code on a new
2
+ branch) or Send to agent (queued for the coding agent's `get_fix_requests`).
3
+
4
+ GET /issues {repo, example, summary: {high, medium, low}, issues: [{...finding, action, state, branch}]}
5
+ (read from the code each time)
6
+ POST /issues/{id}/preview the change Fix will make, masked, and whether it can be made now
7
+ POST /issues/{id}/fix make it: a new branch, the key saved → {id, branch, commit, file, name}
8
+ POST /issues/{id}/send queue it for the agent → {id, state: "sent"}
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ from collections import Counter
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from fastapi import APIRouter, Depends, HTTPException
20
+
21
+ from flowforge.api.keys import from_this_machine, learn_saved_keys
22
+ from flowforge.api.scan import repo_path
23
+ from flowforge.scan import scan as scan_repo
24
+ from flowforge.scan.models import Finding
25
+ from flowforge.security.fixes import CantFix, apply_fix, fixable, preview
26
+ from flowforge.state import AppState, app_state
27
+ from flowforge.storage import Storage, now_iso
28
+
29
+ router = APIRouter()
30
+
31
+ FIX_REQUESTS_SETTING = "fix_requests" # [{repo, id, kind, title, ref, fix, prompt, sent_at}]: what the agent is asked
32
+ FIXES_SETTING = "fixes_made" # [{repo, id, branch, commit, at}]: fixes made on a branch, not yet merged
33
+ # Issue ids repeat across repos (kind, path, line), so every entry names its folder and only counts there (Codex review)
34
+ EXAMPLE_WHY = "Example data: FlowForge shows the change but makes none. Map your own repo to fix it."
35
+
36
+
37
+ def _load(storage: Storage, key: str, empty: Any) -> Any:
38
+ try:
39
+ return json.loads(storage.setting(key) or "null") or empty
40
+ except ValueError:
41
+ return empty
42
+
43
+
44
+ def _entries(storage: Storage, key: str) -> list[dict[str, Any]]:
45
+ """A list setting's entries that name their folder (older ones didn't, and are dropped)."""
46
+ found = _load(storage, key, [])
47
+ return [e for e in found if isinstance(e, dict) and e.get("repo")] if isinstance(found, list) else []
48
+
49
+
50
+ def fix_requests(storage: Storage, repo: Path | None = None) -> list[dict[str, Any]]:
51
+ """The queue, or only `repo`'s part of it."""
52
+ queue = _entries(storage, FIX_REQUESTS_SETTING)
53
+ return queue if repo is None else [r for r in queue if r["repo"] == str(repo.resolve())]
54
+
55
+
56
+ def _fixes(storage: Storage, repo: Path) -> dict[str, dict[str, Any]]:
57
+ """The fixes made in `repo`, by issue id."""
58
+ return {m["id"]: m for m in _entries(storage, FIXES_SETTING) if m["repo"] == str(repo.resolve())}
59
+
60
+
61
+ def _branch_exists(repo: Path, branch: str) -> bool:
62
+ done = subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo,
63
+ capture_output=True)
64
+ return done.returncode == 0
65
+
66
+
67
+ def _repo(s: AppState) -> Path:
68
+ repo = repo_path(s)
69
+ if repo is None or not repo.is_dir():
70
+ raise HTTPException(409, "Map a folder first.")
71
+ return repo
72
+
73
+
74
+ def _finding(repo: Path, issue_id: str) -> Finding:
75
+ """The issue as the code is now (read again, so a fix starts from the current file)."""
76
+ found = next((f for f in scan_repo(repo).findings if f.id == issue_id), None)
77
+ if found is None:
78
+ raise HTTPException(404, "That issue isn't in the code any more. Re-scan.")
79
+ return found
80
+
81
+
82
+ @router.get("/issues")
83
+ def issues() -> dict[str, Any]:
84
+ s = app_state()
85
+ repo = repo_path(s)
86
+ if repo is None or not repo.is_dir():
87
+ return {"repo": None, "example": s.example, "summary": {}, "issues": []}
88
+ result = scan_repo(repo) # the code as it is now: a merged fix or an agent's edit shows at once
89
+ sent = {r["id"] for r in fix_requests(s.storage, repo)}
90
+ made = _fixes(s.storage, repo)
91
+ out = []
92
+ for f in result.findings:
93
+ branch = made.get(f.id, {}).get("branch")
94
+ if branch and not _branch_exists(repo, branch):
95
+ branch = None # the branch was deleted: the issue is open again
96
+ state = "fixed" if branch else "sent" if f.id in sent else "open"
97
+ out.append({**f.model_dump(mode="json"), "action": "fix" if fixable(repo, f) else "agent",
98
+ "state": state, "branch": branch})
99
+ return {"repo": repo.name, "example": s.example, "summary": dict(Counter(f.level for f in result.findings)),
100
+ "issues": out}
101
+
102
+
103
+ @router.post("/issues/{issue_id}/preview")
104
+ def preview_fix(issue_id: str) -> dict[str, Any]:
105
+ s = app_state()
106
+ repo = _repo(s)
107
+ try:
108
+ shown = preview(repo, _finding(repo, issue_id))
109
+ except CantFix as e:
110
+ raise HTTPException(409, str(e)) from None
111
+ if s.example:
112
+ shown.update(can_apply=False, why_not=EXAMPLE_WHY)
113
+ return shown
114
+
115
+
116
+ @router.post("/issues/{issue_id}/fix", dependencies=[Depends(from_this_machine)])
117
+ def fix(issue_id: str) -> dict[str, Any]:
118
+ s = app_state()
119
+ repo = _repo(s)
120
+ if s.example:
121
+ raise HTTPException(409, EXAMPLE_WHY)
122
+ made = _fixes(s.storage, repo)
123
+ if made.get(issue_id) and _branch_exists(repo, made[issue_id]["branch"]):
124
+ raise HTTPException(409, f"Already fixed on {made[issue_id]['branch']}: merge that branch.")
125
+ found = _finding(repo, issue_id)
126
+ if not fixable(repo, found):
127
+ raise HTTPException(409, "FlowForge doesn't fix this one itself: send it to your agent.")
128
+ try:
129
+ done = apply_fix(repo, found)
130
+ except CantFix as e:
131
+ raise HTTPException(409, str(e)) from None
132
+ learn_saved_keys(s, repo)
133
+ here = str(repo.resolve())
134
+ kept = [m for m in _entries(s.storage, FIXES_SETTING) if (m["repo"], m.get("id")) != (here, issue_id)]
135
+ kept.append({"repo": here, "id": issue_id, "branch": done["branch"], "commit": done["commit"], "at": now_iso()})
136
+ s.storage.set_setting(FIXES_SETTING, json.dumps(kept))
137
+ return done
138
+
139
+
140
+ @router.post("/issues/{issue_id}/send", dependencies=[Depends(from_this_machine)])
141
+ def send(issue_id: str) -> dict[str, Any]:
142
+ s = app_state()
143
+ repo = _repo(s)
144
+ found = _finding(repo, issue_id)
145
+ here = str(repo.resolve())
146
+ queue = [r for r in fix_requests(s.storage) if (r["repo"], r["id"]) != (here, issue_id)]
147
+ queue.append({"repo": here, "id": found.id, "kind": found.kind, "title": found.title, "ref": found.ref,
148
+ "fix": found.fix, "prompt": found.prompt, "sent_at": now_iso()})
149
+ s.storage.set_setting(FIX_REQUESTS_SETTING, json.dumps(queue))
150
+ return {"id": issue_id, "state": "sent"}
@@ -0,0 +1,184 @@
1
+ """Keys the coding agent never sees (D36): their statuses for the map, and the dashboard's write-only box.
2
+
3
+ GET /keys {repo, example, summary: {missing, env_file}, keys: [{name, status, ref, deps, command}]}
4
+ PUT /keys/{name} {"value": "..."} → {name, status: "saved"}; the value is never sent back
5
+ POST /keys/{name}/import the value of `name` in the folder's .env, saved (D37) → {name, status: "saved"}
6
+ DELETE /keys/{name} → {name, removed}
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections import Counter
13
+ from collections.abc import Iterable
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import urlsplit
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Request
19
+ from pydantic import BaseModel, ConfigDict
20
+
21
+ from flowforge.api.scan import repo_path
22
+ from flowforge.home import command_line
23
+ from flowforge.scan import ScanResult
24
+ from flowforge.scan import scan as scan_repo
25
+ from flowforge.security.keystore import BadKey, KeyStore, report
26
+ from flowforge.state import AppState, app_state
27
+ from flowforge.storage import Storage, now_iso
28
+
29
+ router = APIRouter()
30
+
31
+ KEY_REQUESTS_SETTING = "key_requests" # [{repo, name, why, at}]: keys your agent asked for (D38), never a value
32
+
33
+
34
+ def key_requests(storage: Storage, repo: Path) -> list[dict[str, Any]]:
35
+ """`repo`'s open requests, oldest first."""
36
+ try:
37
+ found = json.loads(storage.setting(KEY_REQUESTS_SETTING) or "[]")
38
+ except ValueError:
39
+ return []
40
+ here = str(repo.resolve())
41
+ return [r for r in found if isinstance(r, dict) and r.get("repo") == here] if isinstance(found, list) else []
42
+
43
+
44
+ def _all_requests(storage: Storage) -> list[dict[str, Any]]:
45
+ try:
46
+ found = json.loads(storage.setting(KEY_REQUESTS_SETTING) or "[]")
47
+ except ValueError:
48
+ return []
49
+ return [r for r in found if isinstance(r, dict) and r.get("repo")] if isinstance(found, list) else []
50
+
51
+
52
+ def add_key_request(storage: Storage, repo: Path, name: str, why: str) -> None:
53
+ here = str(repo.resolve())
54
+ kept = [r for r in _all_requests(storage) if (r["repo"], r.get("name")) != (here, name)]
55
+ kept.append({"repo": here, "name": name, "why": why[:200], "at": now_iso()})
56
+ storage.set_setting(KEY_REQUESTS_SETTING, json.dumps(kept))
57
+
58
+
59
+ def clear_key_requests(storage: Storage, repo: Path, names: Iterable[str]) -> None:
60
+ """A request ends once its key is saved."""
61
+ here, done = str(repo.resolve()), set(names)
62
+ found = _all_requests(storage)
63
+ kept = [r for r in found if not (r["repo"] == here and r.get("name") in done)]
64
+ if len(kept) != len(found):
65
+ storage.set_setting(KEY_REQUESTS_SETTING, json.dumps(kept))
66
+
67
+
68
+ class KeyValue(BaseModel):
69
+ model_config = ConfigDict(extra="forbid")
70
+ value: str
71
+
72
+
73
+ def learn_saved_keys(s: AppState, repo: Path | None) -> None:
74
+ """The redactor masks every value saved for this folder from now on, wherever it would show (D36)."""
75
+ if repo is None:
76
+ return
77
+ try:
78
+ s.redactor.learn(KeyStore(repo).env().values())
79
+ except Exception: # a keychain that can't be read must not stop FlowForge
80
+ pass
81
+
82
+
83
+ def stored_scan(s: AppState, repo: Path) -> ScanResult:
84
+ """The map's stored scan, so a re-scan updates the keys; the folder read again if none is stored."""
85
+ analysis = s.flows["deps"].analysis() if "deps" in s.flows else None
86
+ stored = (analysis or {}).get("scan")
87
+ if stored:
88
+ try:
89
+ return ScanResult.model_validate(stored)
90
+ except ValueError:
91
+ pass
92
+ return scan_repo(repo)
93
+
94
+
95
+ _LOOPBACK = {"localhost", "127.0.0.1", "::1"}
96
+
97
+
98
+ def _loopback_peer(address: str | None) -> bool:
99
+ """The connection itself comes from this machine (127.0.0.0/8 or ::1)."""
100
+ import ipaddress
101
+
102
+ try:
103
+ ip = ipaddress.ip_address(address or "")
104
+ except ValueError:
105
+ return False
106
+ mapped = getattr(ip, "ipv4_mapped", None) # ::ffff:127.0.0.1 on a dual-stack socket
107
+ return (mapped or ip).is_loopback
108
+
109
+
110
+ def from_this_machine(request: Request) -> None:
111
+ """Saving a key, a fix, the lock and the key view need the dashboard on this machine (D36, D37). The caller's real
112
+ address must be this machine (with --host 0.0.0.0 another machine could fake the headers: Codex review), and
113
+ Host and Origin must name it too, so a web page can't reach it through DNS rebinding or from another site."""
114
+ host = urlsplit(f"//{request.headers.get('host', '')}").hostname
115
+ origin = request.headers.get("origin")
116
+ peer = request.client.host if request.client else None
117
+ if (not _loopback_peer(peer) or host not in _LOOPBACK
118
+ or (origin is not None and urlsplit(origin).hostname not in _LOOPBACK)):
119
+ raise HTTPException(403, "This works only from the dashboard on this machine (open it at localhost).")
120
+
121
+
122
+ def _writable(s: AppState) -> Path:
123
+ """The folder keys are saved for. Example data saves too (D37): its keys belong to the example's folder."""
124
+ repo = repo_path(s)
125
+ if repo is None:
126
+ raise HTTPException(409, "Map a folder first: keys belong to one folder.")
127
+ return repo
128
+
129
+
130
+ @router.get("/keys")
131
+ def keys() -> dict[str, Any]:
132
+ s = app_state()
133
+ repo = repo_path(s)
134
+ if repo is None or not repo.is_dir():
135
+ return {"repo": None, "example": s.example, "summary": {}, "keys": []}
136
+ store = KeyStore(repo)
137
+ asked = key_requests(s.storage, repo)
138
+ clear_key_requests(s.storage, repo, [n for n in store.names() if any(r["name"] == n for r in asked)])
139
+ asked = [r for r in asked if r["name"] not in set(store.names())]
140
+ found = report(store, stored_scan(s, repo), also=[r["name"] for r in asked])
141
+ for k in found:
142
+ k["requested"] = any(r["name"] == k["name"] for r in asked)
143
+ summary = Counter(k["status"] for k in found if k["status"] != "saved")
144
+ return {"repo": repo.name, "example": s.example, "summary": dict(summary), "keys": found,
145
+ "requests": [{"name": r["name"], "why": r["why"], "at": r["at"]} for r in asked],
146
+ "run": command_line("run", "--repo", str(repo), "--")} # + how you start your app
147
+
148
+
149
+ @router.put("/keys/{name}", dependencies=[Depends(from_this_machine)])
150
+ def save_key(name: str, body: KeyValue) -> dict[str, Any]:
151
+ s = app_state()
152
+ repo = _writable(s)
153
+ try:
154
+ KeyStore(repo).add(name, body.value)
155
+ except BadKey as e:
156
+ raise HTTPException(400, str(e)) from None
157
+ s.redactor.learn([body.value.strip()])
158
+ clear_key_requests(s.storage, repo, [name])
159
+ return {"name": name, "status": "saved"}
160
+
161
+
162
+ @router.post("/keys/{name}/import", dependencies=[Depends(from_this_machine)])
163
+ def import_key(name: str) -> dict[str, Any]:
164
+ """Move one key in from .env: the server reads that value from the file, so nothing is copied by hand (D37)."""
165
+ from dotenv import dotenv_values
166
+
167
+ s = app_state()
168
+ repo = _writable(s)
169
+ value = dotenv_values(repo / ".env").get(name) if (repo / ".env").is_file() else None
170
+ if not value or not value.strip():
171
+ raise HTTPException(404, f"{name} isn't set in .env.")
172
+ try:
173
+ KeyStore(repo).add(name, value)
174
+ except BadKey as e:
175
+ raise HTTPException(400, str(e)) from None
176
+ s.redactor.learn([value.strip()])
177
+ clear_key_requests(s.storage, repo, [name])
178
+ return {"name": name, "status": "saved"}
179
+
180
+
181
+ @router.delete("/keys/{name}", dependencies=[Depends(from_this_machine)])
182
+ def remove_key(name: str) -> dict[str, Any]:
183
+ repo = _writable(app_state())
184
+ return {"name": name, "removed": KeyStore(repo).remove(name)}