flowforge-map 0.1.0__tar.gz → 0.2.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 (117) hide show
  1. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/PKG-INFO +21 -3
  2. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/README.md +18 -2
  3. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/agent/server.py +27 -2
  4. flowforge_map-0.2.0/backend/flowforge/api/keys.py +107 -0
  5. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/scan.py +3 -0
  6. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/cli.py +102 -0
  7. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/diff.py +27 -2
  8. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/models.py +1 -0
  9. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/planner.py +12 -2
  10. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/service.py +10 -2
  11. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/home.py +14 -0
  12. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/main.py +5 -0
  13. flowforge_map-0.2.0/backend/flowforge/security/keystore.py +280 -0
  14. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/logfilter.py +12 -1
  15. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/README.md +1 -1
  16. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/config.py +3 -0
  17. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/llm.py +2 -2
  18. flowforge_map-0.2.0/frontend/dist/assets/index-C-GjahSy.js +52 -0
  19. flowforge_map-0.2.0/frontend/dist/assets/index-CWfr5fii.css +1 -0
  20. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/index.html +2 -2
  21. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/pyproject.toml +3 -1
  22. flowforge_map-0.1.0/frontend/dist/assets/index-BGT5seIN.css +0 -1
  23. flowforge_map-0.1.0/frontend/dist/assets/index-BoGikST6.js +0 -52
  24. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/.gitignore +0 -0
  25. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/LICENSE +0 -0
  26. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/__init__.py +0 -0
  27. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/agent/__init__.py +0 -0
  28. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/__init__.py +0 -0
  29. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/agent.py +0 -0
  30. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/checks.py +0 -0
  31. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/connectors.py +0 -0
  32. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/flowmap.py +0 -0
  33. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/runs.py +0 -0
  34. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/security.py +0 -0
  35. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/stats.py +0 -0
  36. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/checks/__init__.py +0 -0
  37. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/checks/models.py +0 -0
  38. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/checks/probes.py +0 -0
  39. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/__init__.py +0 -0
  40. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/catalog.py +0 -0
  41. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/models.py +0 -0
  42. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/presets.py +0 -0
  43. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/registry.py +0 -0
  44. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/runtime.py +0 -0
  45. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/secrets.py +0 -0
  46. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/example_data.py +0 -0
  47. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/__init__.py +0 -0
  48. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/advice.py +0 -0
  49. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/curly.py +0 -0
  50. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/index.py +0 -0
  51. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/order.py +0 -0
  52. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/reach.py +0 -0
  53. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/rewrite.py +0 -0
  54. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/__init__.py +0 -0
  55. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/fixture.py +0 -0
  56. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/validate.py +0 -0
  57. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/__init__.py +0 -0
  58. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/base.py +0 -0
  59. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/check_node.py +0 -0
  60. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/http_node.py +0 -0
  61. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/llm_node.py +0 -0
  62. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/local_node.py +0 -0
  63. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/mcp_node.py +0 -0
  64. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/mock_node.py +0 -0
  65. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/__init__.py +0 -0
  66. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/facts.py +0 -0
  67. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/files.py +0 -0
  68. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/manifests.py +0 -0
  69. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/mcp_config.py +0 -0
  70. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/models.py +0 -0
  71. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/patterns.py +0 -0
  72. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/python_ast.py +0 -0
  73. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/scanner.py +0 -0
  74. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/services.py +0 -0
  75. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/urls.py +0 -0
  76. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/__init__.py +0 -0
  77. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/cache.py +0 -0
  78. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/critical_path.py +0 -0
  79. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/durations.py +0 -0
  80. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/executor.py +0 -0
  81. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/graph.py +0 -0
  82. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/rate_limit.py +0 -0
  83. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/schema.py +0 -0
  84. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/__init__.py +0 -0
  85. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/finders.py +0 -0
  86. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/findings.py +0 -0
  87. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/spa.py +0 -0
  88. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/state.py +0 -0
  89. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/storage.py +0 -0
  90. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/templating.py +0 -0
  91. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/workflows/diamond_mock.json +0 -0
  92. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/workflows/stripe_to_razorpay.json +0 -0
  93. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/.env.example +0 -0
  94. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/.gitignore +0 -0
  95. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/.mcp.json +0 -0
  96. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/mcp_servers/currency.py +0 -0
  97. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/planner.py +0 -0
  98. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/requirements.txt +0 -0
  99. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/__init__.py +0 -0
  100. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/geo.py +0 -0
  101. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/mcp_tools.py +0 -0
  102. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/weather.py +0 -0
  103. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/tools/packing_list.py +0 -0
  104. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/trips/.gitkeep +0 -0
  105. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/classic.html +0 -0
  106. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  107. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  108. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  109. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  110. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  111. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  112. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  113. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  114. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  115. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  116. {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  117. {flowforge_map-0.1.0 → flowforge_map-0.2.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.1.0
3
+ Version: 0.2.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
@@ -19,9 +19,11 @@ Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Classifier: Topic :: System :: Monitoring
20
20
  Requires-Python: >=3.12
21
21
  Requires-Dist: anthropic>=1.11.0
22
+ Requires-Dist: cryptography>=43
22
23
  Requires-Dist: fastapi>=0.115
23
24
  Requires-Dist: httpx>=0.27
24
25
  Requires-Dist: jsonschema>=4.26.0
26
+ Requires-Dist: keyring>=25
25
27
  Requires-Dist: mcp>=1.2
26
28
  Requires-Dist: openai>=1.40
27
29
  Requires-Dist: pydantic>=2.8
@@ -53,7 +55,7 @@ What makes it different: **it knows which chain of steps is the bottleneck and s
53
55
 
54
56
  ## Quick start
55
57
 
56
- ### Install (from the first release, v0.1.0)
58
+ ### Install
57
59
 
58
60
  ```bash
59
61
  uvx flowforge-map --example # try it: a sample app's maps in your browser, no keys, nothing sent
@@ -111,7 +113,22 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
111
113
  claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
112
114
  ```
113
115
 
114
- Your agent gets six read-only tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings` 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.
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
+
118
+ ### Keys your coding agent never sees
119
+
120
+ Your agent only needs a key's **name**: the code it writes reads `os.environ["STRIPE_SECRET_KEY"]`. You give FlowForge the value once, and your app gets it when it starts:
121
+
122
+ ```bash
123
+ flowforge key add STRIPE_SECRET_KEY # typed hidden; saved in your OS keychain
124
+ flowforge key list # each key the code reads: saved · in .env · missing
125
+ flowforge key import # move a .env into FlowForge (then delete the .env)
126
+ flowforge run -- python app.py # your app starts with the keys in its environment
127
+ ```
128
+
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`.
130
+
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.
115
132
 
116
133
  ### Send someone the map
117
134
 
@@ -238,6 +255,7 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
238
255
  | `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
239
256
  | `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
240
257
  | `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) |
241
259
  | `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
242
260
  | `GET /workflows`, `GET /workflows/{name}` | The example workflows |
243
261
  | `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
@@ -21,7 +21,7 @@ What makes it different: **it knows which chain of steps is the bottleneck and s
21
21
 
22
22
  ## Quick start
23
23
 
24
- ### Install (from the first release, v0.1.0)
24
+ ### Install
25
25
 
26
26
  ```bash
27
27
  uvx flowforge-map --example # try it: a sample app's maps in your browser, no keys, nothing sent
@@ -79,7 +79,22 @@ 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 six read-only tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings` 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 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.
83
+
84
+ ### Keys your coding agent never sees
85
+
86
+ Your agent only needs a key's **name**: the code it writes reads `os.environ["STRIPE_SECRET_KEY"]`. You give FlowForge the value once, and your app gets it when it starts:
87
+
88
+ ```bash
89
+ flowforge key add STRIPE_SECRET_KEY # typed hidden; saved in your OS keychain
90
+ flowforge key list # each key the code reads: saved · in .env · missing
91
+ flowforge key import # move a .env into FlowForge (then delete the .env)
92
+ flowforge run -- python app.py # your app starts with the keys in its environment
93
+ ```
94
+
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`.
96
+
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.
83
98
 
84
99
  ### Send someone the map
85
100
 
@@ -206,6 +221,7 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
206
221
  | `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
207
222
  | `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
208
223
  | `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) |
209
225
  | `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
210
226
  | `GET /workflows`, `GET /workflows/{name}` | The example workflows |
211
227
  | `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
@@ -1,4 +1,4 @@
1
- """FlowForge as an MCP server (D28, D29): five read-only tools a coding agent can ask about the repo it works in.
1
+ """FlowForge as an MCP server (D28, D29, D36): read-only tools a coding agent can ask about the repo it works in.
2
2
 
3
3
  The map and the flow are read fresh from the repo on every call (plain code, nothing run, no LLM: D20). The
4
4
  dashboard's database supplies what only it knows, when it maps the same repo: measured latencies, past checks,
@@ -28,6 +28,7 @@ from flowforge.flowmap.validate import code_writes, critical
28
28
  from flowforge.nodes.check_node import CheckNode
29
29
  from flowforge.scan import ScanResult, scan
30
30
  from flowforge.scheduler.executor import StepState, run_workflow
31
+ from flowforge.security.keystore import KeyStore, report
31
32
  from flowforge.security.logfilter import Redactor, redact_code, secret_values_for
32
33
  from flowforge.storage import Storage, now_iso
33
34
 
@@ -160,6 +161,10 @@ def headline(tool: str, data: dict[str, Any]) -> str | None:
160
161
  if tool == "get_security_findings":
161
162
  counts = data["summary"]
162
163
  return " · ".join(f"{counts[k]} {k}" for k in ("high", "medium", "low") if counts.get(k)) or "Nothing found"
164
+ if tool == "get_keys":
165
+ counts = data["summary"]
166
+ parts = [f"{counts[k]} {w}" for k, w in (("missing", "missing"), ("env_file", "in .env")) if counts.get(k)]
167
+ return " · ".join(parts) or ("All saved" if data["keys"] else "No keys read")
163
168
  if tool == "what_changed":
164
169
  if not data["saved"]:
165
170
  return "No saved map yet"
@@ -181,6 +186,15 @@ def _security_data(result: ScanResult) -> dict[str, Any]:
181
186
  "note": "Catches known key formats and these patterns, not everything. Values are never shown."}
182
187
 
183
188
 
189
+ def _keys_data(root: Path, result: ScanResult) -> dict[str, Any]:
190
+ """Names and statuses only: this server, which the agent starts, never reads a value (D36)."""
191
+ found = report(KeyStore(root), result, verify=False)
192
+ return {"keys": found, "summary": dict(Counter(k["status"] for k in found if k["status"] != "saved")),
193
+ "run": "flowforge run -- <the command that starts the app>",
194
+ "note": "saved means FlowForge has it on record; this server never reads a value, so the user's "
195
+ "`flowforge key list` is what confirms it can be read"}
196
+
197
+
184
198
  def _flow_data(result: Any) -> dict[str, Any]:
185
199
  if result.flow is None:
186
200
  return {"available": False, "message": result.message}
@@ -218,7 +232,8 @@ def preview(repo: Path, storage: Storage) -> dict[str, Any]:
218
232
  tone = "bad" if "down" in checked else "warn" if "warn" in checked else "ok" if checked else "plain"
219
233
  read = r.flow()
220
234
  flow, advice = _flow_data(read), _advice_data(read)
221
- security = _security_data(read.scan or r.scan())
235
+ scanned = read.scan or r.scan()
236
+ security, keys = _security_data(scanned), _keys_data(r.root, scanned)
222
237
  worst = next((lv for lv in ("high", "medium", "low") if security["summary"].get(lv)), None)
223
238
  cards = [
224
239
  {"tool": "get_map", "headline": headline("get_map", deps), "sub": kinds, "tone": "plain"},
@@ -228,6 +243,8 @@ def preview(repo: Path, storage: Storage) -> dict[str, Any]:
228
243
  {"tool": "get_security_findings", "headline": headline("get_security_findings", security),
229
244
  "sub": "keys in code, plain http, risky MCP servers",
230
245
  "tone": {"high": "bad", "medium": "warn"}.get(worst or "", "ok")},
246
+ {"tool": "get_keys", "headline": headline("get_keys", keys),
247
+ "sub": "names only: the user types each key, hidden", "tone": "warn" if keys["summary"] else "ok"},
231
248
  {"tool": "get_flow", "headline": headline("get_flow", flow) if flow["available"] else None,
232
249
  "sub": "as written → could be" if flow["available"] else flow["message"], "tone": "plain"},
233
250
  {"tool": "get_advice", "headline": (_plural(len(advice["advice"]), "way") + " to be faster")
@@ -312,6 +329,14 @@ def build_server(repo: Path, storage: Storage, *, transport: httpx.AsyncBaseTran
312
329
  shown. Catches known key formats and these patterns, not everything."""
313
330
  return r.answer("get_security_findings", _security_data(r.scan()))
314
331
 
332
+ @server.tool(annotations=READ)
333
+ def get_keys() -> dict[str, Any]:
334
+ """The keys the repo's code reads from its environment (names that look like keys), each with where it is
335
+ read, the dependencies that use it, and its status: saved in FlowForge, only in .env, or missing. You never
336
+ get a value and never need one: write code that reads the name from the environment, then ask the user to
337
+ run the `command` given (they type the key, hidden). Their app gets the keys with `flowforge run -- <app>`."""
338
+ return r.answer("get_keys", _keys_data(r.root, r.scan()))
339
+
315
340
  @server.tool(annotations=READ)
316
341
  def what_changed() -> dict[str, Any]:
317
342
  """After an edit: the dependencies added or removed since the map saved in the dashboard, and the flow's
@@ -0,0 +1,107 @@
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
+ DELETE /keys/{name} → {name, removed}
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections import Counter
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+
16
+ from fastapi import APIRouter, Depends, HTTPException, Request
17
+ from pydantic import BaseModel, ConfigDict
18
+
19
+ from flowforge.api.scan import repo_path
20
+ from flowforge.scan import ScanResult
21
+ from flowforge.scan import scan as scan_repo
22
+ from flowforge.security.keystore import BadKey, KeyStore, report
23
+ from flowforge.state import AppState, app_state
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ class KeyValue(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+ value: str
31
+
32
+
33
+ def learn_saved_keys(s: AppState, repo: Path | None) -> None:
34
+ """The redactor masks every value saved for this folder from now on, wherever it would show (D36)."""
35
+ if repo is None or s.example:
36
+ return
37
+ try:
38
+ s.redactor.learn(KeyStore(repo).env().values())
39
+ except Exception: # a keychain that can't be read must not stop FlowForge
40
+ pass
41
+
42
+
43
+ def _scan(s: AppState, repo: Path) -> ScanResult:
44
+ """The map's stored scan, so a re-scan updates the keys; the folder read again if none is stored."""
45
+ analysis = s.flows["deps"].analysis() if "deps" in s.flows else None
46
+ stored = (analysis or {}).get("scan")
47
+ if stored:
48
+ try:
49
+ return ScanResult.model_validate(stored)
50
+ except ValueError:
51
+ pass
52
+ return scan_repo(repo)
53
+
54
+
55
+ _LOOPBACK = {"localhost", "127.0.0.1", "::1"}
56
+
57
+
58
+ def _this_machine(name: str | None) -> bool:
59
+ chosen = os.getenv("FLOWFORGE_HOST", "")
60
+ return bool(name) and (name in _LOOPBACK or (name == chosen and chosen not in ("0.0.0.0", "::")))
61
+
62
+
63
+ def from_this_machine(request: Request) -> None:
64
+ """Saving or forgetting a key needs the dashboard's own address (D36). A web page can't do it: through DNS
65
+ rebinding its Host is its own name, and from another site its Origin is."""
66
+ host = urlsplit(f"//{request.headers.get('host', '')}").hostname
67
+ origin = request.headers.get("origin")
68
+ if not _this_machine(host) or (origin is not None and not _this_machine(urlsplit(origin).hostname)):
69
+ raise HTTPException(403, "Keys can only be saved from the dashboard on this machine.")
70
+
71
+
72
+ def _writable(s: AppState) -> Path:
73
+ repo = repo_path(s)
74
+ if s.example:
75
+ raise HTTPException(409, "Example data: keys aren't saved. Map your own folder to add keys.")
76
+ if repo is None:
77
+ raise HTTPException(409, "Map a folder first: keys belong to one folder.")
78
+ return repo
79
+
80
+
81
+ @router.get("/keys")
82
+ def keys() -> dict[str, Any]:
83
+ s = app_state()
84
+ repo = repo_path(s)
85
+ if repo is None or not repo.is_dir():
86
+ return {"repo": None, "example": s.example, "summary": {}, "keys": []}
87
+ found = report(KeyStore(repo), _scan(s, repo))
88
+ summary = Counter(k["status"] for k in found if k["status"] != "saved")
89
+ return {"repo": repo.name, "example": s.example, "summary": dict(summary), "keys": found}
90
+
91
+
92
+ @router.put("/keys/{name}", dependencies=[Depends(from_this_machine)])
93
+ def save_key(name: str, body: KeyValue) -> dict[str, Any]:
94
+ s = app_state()
95
+ repo = _writable(s)
96
+ try:
97
+ KeyStore(repo).add(name, body.value)
98
+ except BadKey as e:
99
+ raise HTTPException(400, str(e)) from None
100
+ s.redactor.learn([body.value.strip()])
101
+ return {"name": name, "status": "saved"}
102
+
103
+
104
+ @router.delete("/keys/{name}", dependencies=[Depends(from_this_machine)])
105
+ def remove_key(name: str) -> dict[str, Any]:
106
+ repo = _writable(app_state())
107
+ return {"name": name, "removed": KeyStore(repo).remove(name)}
@@ -110,6 +110,9 @@ def post_scan(body: ScanRequest) -> dict[str, Any]:
110
110
  if not path.is_dir():
111
111
  raise HTTPException(422, "that isn't a folder on this machine")
112
112
  path = path.resolve()
113
+ from flowforge.api.keys import learn_saved_keys # here: keys imports this module
114
+
115
+ learn_saved_keys(s, path) # before the map is stored, so a saved key's value is masked in it (D36)
113
116
  try:
114
117
  choose_repo(s.storage, s.flows, path, body.replace)
115
118
  except RepoChangeError as err:
@@ -6,6 +6,8 @@
6
6
  uv run flowforge scan PATH print the folder's dependencies as JSON; nothing stored, nothing sent
7
7
  uv run flowforge mcp [--repo P] an MCP server for your coding agent, over stdio (D29); P defaults to here
8
8
  uv run flowforge serve --reload (used by `npm run dev`; `serve` is optional)
9
+ uv run flowforge key add NAME save a key for this folder, typed hidden (D36); also: list, remove, import
10
+ uv run flowforge run -- CMD ... start your app with the saved keys in its environment (D36)
9
11
  """
10
12
 
11
13
  from __future__ import annotations
@@ -41,6 +43,14 @@ def parse(argv: list[str] | None) -> argparse.Namespace:
41
43
 
42
44
 
43
45
  def main(argv: list[str] | None = None) -> None:
46
+ argv = sys.argv[1:] if argv is None else argv
47
+ if argv[:1] == ["key"]:
48
+ keys_command(argv[1:])
49
+ return
50
+ if argv[:1] == ["run"]:
51
+ run_with_keys(argv[1:])
52
+ return
53
+
44
54
  import uvicorn
45
55
 
46
56
  args = parse(argv)
@@ -104,5 +114,97 @@ def scan_folder(path: str | None) -> None:
104
114
  print(json.dumps(scan(Path(path).expanduser()).model_dump(mode="json"), indent=2))
105
115
 
106
116
 
117
+ _SHOWN = {"saved": "saved", "unreadable": "can't read", "env_file": "in .env", "missing": "missing"}
118
+
119
+
120
+ def keys_command(argv: list[str]) -> None:
121
+ """`flowforge key add|list|remove|import`: keys for one folder, which only `flowforge run` hands out (D36).
122
+ Values are never printed."""
123
+ from flowforge.security.keystore import NAME, BadKey, KeyStore, report
124
+
125
+ parser = argparse.ArgumentParser(prog="flowforge key", description="Keep your app's keys out of files and away "
126
+ "from your coding agent. Values are never shown.")
127
+ parser.add_argument("action", choices=["add", "list", "remove", "import"])
128
+ parser.add_argument("name", nargs="?", help="add/remove: the env var name, e.g. STRIPE_SECRET_KEY; "
129
+ "import: the file (default .env in the folder)")
130
+ parser.add_argument("--repo", default=".", help="the app's folder (default: here)")
131
+ args = parser.parse_args(argv)
132
+ repo = Path(args.repo).expanduser()
133
+ if not repo.is_dir():
134
+ parser.error(f"{args.repo} is not a folder")
135
+ store = KeyStore(repo)
136
+ if args.action in ("add", "remove") and not (args.name and NAME.fullmatch(args.name)):
137
+ parser.error("name the key as an env var name, e.g. STRIPE_SECRET_KEY")
138
+
139
+ if args.action == "add":
140
+ value = _read_value(args.name)
141
+ try:
142
+ where = store.add(args.name, value)
143
+ except BadKey as e:
144
+ parser.error(str(e))
145
+ print(f"Saved {args.name} in {'your keychain' if where == 'keychain' else 'FlowForge (encrypted file)'}.")
146
+ print(" Your app gets it with: flowforge run -- <the command that starts it>")
147
+ elif args.action == "remove":
148
+ print(f"Removed {args.name}." if store.remove(args.name) else f"{args.name} wasn't saved.")
149
+ elif args.action == "import":
150
+ source = Path(args.name).expanduser() if args.name else repo / ".env"
151
+ if not source.is_file():
152
+ parser.error(f"{source} isn't a file")
153
+ names = store.import_env(source)
154
+ print(f"Saved {len(names)} from {source.name}: {', '.join(names) or 'nothing to save'}.")
155
+ if names:
156
+ print(f" Delete {source.name} once your app runs with `flowforge run -- ...`: a coding agent can read it.")
157
+ else:
158
+ from flowforge.scan import scan
159
+
160
+ found = report(store, scan(repo))
161
+ if not found:
162
+ print("No keys: the code reads none, and none are saved.")
163
+ width = max((len(k["name"]) for k in found), default=0)
164
+ for k in found:
165
+ print(f"{k['name']:<{width}} {_SHOWN[k['status']]:<10} {k['ref'] or ''}")
166
+ todo = [k for k in found if k["command"]]
167
+ if todo:
168
+ print()
169
+ for k in todo:
170
+ print(f" {k['command']}")
171
+
172
+
173
+ def _read_value(name: str) -> str:
174
+ """The key, typed hidden; or one line from a pipe (`pass show x | flowforge key add X`). Never echoed."""
175
+ import getpass
176
+
177
+ if sys.stdin.isatty():
178
+ return getpass.getpass(f"{name} (typed hidden): ")
179
+ return sys.stdin.readline().rstrip("\r\n")
180
+
181
+
182
+ def run_with_keys(argv: list[str]) -> None:
183
+ """`flowforge run [--repo P] -- CMD ...`: CMD starts with this folder's saved keys added to its environment.
184
+ The command is a list (never a shell), and its exit code is FlowForge's (D36)."""
185
+ import subprocess
186
+
187
+ from flowforge.security.keystore import KeyStore
188
+
189
+ parser = argparse.ArgumentParser(prog="flowforge run", description="Start your app with its saved keys.")
190
+ parser.add_argument("--repo", default=".", help="the folder whose keys to use (default: here)")
191
+ parser.add_argument("command", nargs=argparse.REMAINDER, help="-- then the command that starts your app")
192
+ args = parser.parse_args(argv)
193
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
194
+ if not command:
195
+ parser.error("give the command after --, e.g. flowforge run -- python app.py")
196
+ if not Path(args.repo).expanduser().is_dir():
197
+ parser.error(f"{args.repo} is not a folder")
198
+ env = {**os.environ, **KeyStore(Path(args.repo)).env()}
199
+ try:
200
+ code = subprocess.call(command, env=env)
201
+ except FileNotFoundError:
202
+ print(f"flowforge run: {command[0]} not found", file=sys.stderr)
203
+ code = 127
204
+ except KeyboardInterrupt:
205
+ code = 130
206
+ raise SystemExit(code)
207
+
208
+
107
209
  if __name__ == "__main__":
108
210
  main()
@@ -141,5 +141,30 @@ def apply_choices(old: Flow, new: Flow, changes: list[dict[str, Any]],
141
141
 
142
142
  kept_edges = [e for e in edges if e.source in nodes and e.target in nodes]
143
143
  suggestions = [s for s in new.suggestions if s.node in nodes]
144
- return Flow(kind=new.kind, nodes=[nodes[i] for i in order if i in nodes], edges=kept_edges,
145
- suggestions=suggestions), drop_edits
144
+ # the new map as it is (its source, reader, advice, times and actions: D25, D33, D35), with the blocks and lines
145
+ # you chose; advice about a block you declined goes with it
146
+ advice = [a for a in new.advice if all(i in nodes for i in a.nodes)]
147
+ return new.model_copy(deep=True, update={
148
+ "nodes": [nodes[i] for i in order if i in nodes], "edges": kept_edges, "suggestions": suggestions,
149
+ "advice": advice, "actions": _actions_kept(old, new, set(nodes))}), drop_edits
150
+
151
+
152
+ def _actions_kept(old: Flow, new: Flow, kept: set[str]) -> list:
153
+ """Each action's call groups (D33) for the blocks you kept: a call you kept against the re-scan comes back
154
+ into its action's "as written", in the group it had; a call you declined leaves it."""
155
+ restored = kept - {n.id for n in new.nodes}
156
+ old_parts = {a.id: a.parts for a in old.actions}
157
+ out = []
158
+ for action in new.actions:
159
+ parts = [[i for i in p if i in kept] for p in action.parts]
160
+ have = {i for p in parts for i in p}
161
+ for p in old_parts.get(action.id, []):
162
+ if extra := [i for i in p if i in restored and i not in have]:
163
+ # back into the group it ran with, if one of its partners is still there; else on its own
164
+ group = next((g for g in parts if any(i in g for i in p if i not in restored)), None)
165
+ if group is None:
166
+ parts.append(extra)
167
+ else:
168
+ group += extra
169
+ out.append(action.model_copy(update={"parts": [p for p in parts if p]}))
170
+ return out
@@ -153,6 +153,7 @@ class Flow(_Model):
153
153
  written_ms: float | None = None # code flows: every call one after another, as the code is written (D25)
154
154
  patterns: list[str] = Field(default_factory=list) # code flows: languages read by patterns, less exact (D32)
155
155
  actions: list[Action] = Field(default_factory=list) # code flows: one per entry point, slowest first (D33)
156
+ reader: int = 0 # the reader that made it (flowmap.planner.READER); an older one is read again on start (D35)
156
157
 
157
158
  @model_validator(mode="after")
158
159
  def _structure(self) -> Flow:
@@ -56,9 +56,16 @@ class FixturePlanner:
56
56
  APP_ID, TOOLS_ID = "app", "tools"
57
57
 
58
58
 
59
+ # The version of what the scan and the flow reader put on a map. Raise it when a change to either changes a map:
60
+ # maps saved by an older reader are read again on start (D35).
61
+ READER = 1
62
+
63
+
59
64
  class ScanPlanner:
60
65
  """A real repo (D21): scan the folder, draw one block per dependency off the app (or your coding tools)."""
61
66
 
67
+ reader = READER
68
+
62
69
  def __init__(self, root: str | Path):
63
70
  self.root = Path(root)
64
71
 
@@ -95,7 +102,7 @@ def draft_from_scan(result: ScanResult) -> Draft:
95
102
  files=sorted({e.ref.rsplit(":", 1)[0] for d in deps for e in d.evidence})[:200],
96
103
  scan=result.model_dump(mode="json"))
97
104
  facts = [(d.id, d.kind, d.target, d.status, [e.ref for e in d.evidence]) for d in deps]
98
- return Draft(flow=Flow(kind="dependencies", nodes=nodes, edges=edges), trace=None, analysis=analysis,
105
+ return Draft(flow=Flow(kind="dependencies", nodes=nodes, edges=edges, reader=READER), trace=None, analysis=analysis,
99
106
  fact_sheet_hash=fact_hash(facts))
100
107
 
101
108
 
@@ -103,6 +110,8 @@ class CodeFlowPlanner:
103
110
  """A real repo's flow (D25): read its Python, draw the order its entry points call their dependencies, weighted
104
111
  by the latest checks (D24). `latencies` is asked each time, so a re-scan picks up newer checks."""
105
112
 
113
+ reader = READER
114
+
106
115
  def __init__(self, root: str | Path, latencies: Callable[[], dict[str, float]] = dict):
107
116
  self.root = Path(root)
108
117
  self.latencies = latencies
@@ -114,11 +123,12 @@ class CodeFlowPlanner:
114
123
  if result.flow is None:
115
124
  raise NoFlow(result.message or "No flow to draw.")
116
125
  flow = result.flow
126
+ flow.reader = READER
117
127
  steps = [n for n in flow.nodes if n.kind in CALL_KINDS]
118
128
  measured = sum(n.estimate_source == "measured" for n in steps)
119
129
  analysis = Analysis(
120
130
  stages=[
121
- AnalysisStage(title="Read your Python", done=f"{len(result.entries)} entry point"
131
+ AnalysisStage(title="Read your code", done=f"{len(result.entries)} entry point"
122
132
  f"{'s' if len(result.entries) != 1 else ''}, nothing run or sent"),
123
133
  AnalysisStage(title="Followed the calls", done=f"{len(steps)} calls, "
124
134
  f"{sum(n.kind == 'decision' for n in flow.nodes)} decisions"),
@@ -222,9 +222,17 @@ class FlowService:
222
222
  return self.first_map()
223
223
 
224
224
  def first_map(self) -> int | None:
225
- """Draw the first map if there is none. Returns its version, or None without a Planner."""
226
- if self.planner is None or self.store.count_flow_versions():
225
+ """Draw the first map if there is none, or read the folder again when the saved map was made by an older
226
+ reader (D35). Returns the new version, or None when nothing was drawn."""
227
+ if self.planner is None:
227
228
  return None
229
+ if self.store.count_flow_versions():
230
+ latest = self.store.latest_flow_version("accepted")
231
+ if latest is None or (latest["flow"] or {}).get("reader", 0) >= getattr(self.planner, "reader", 0):
232
+ return None
233
+ pending = self.store.latest_flow_version("pending")
234
+ if pending is not None: # a re-scan made by the older reader: read again below, so it is out of date
235
+ self.store.update_flow_version(pending["version"], "discarded")
228
236
  try:
229
237
  draft = self.planner.analyze(0)
230
238
  except NoFlow as why:
@@ -44,6 +44,20 @@ def install_kind() -> str:
44
44
  return "uvx" if "archive-v" in Path(sys.prefix).as_posix() else "pip"
45
45
 
46
46
 
47
+ def command_line(*args: str) -> str:
48
+ """A `flowforge …` command for a person to type, matching how FlowForge was installed (D34, D36)."""
49
+ import shlex
50
+
51
+ kind = install_kind()
52
+ if kind == "checkout":
53
+ prefix = ["uv", "--directory", str(CHECKOUT), "run", "flowforge"]
54
+ elif kind == "uvx":
55
+ prefix = ["uvx", DIST_NAME]
56
+ else:
57
+ prefix = ["flowforge"]
58
+ return shlex.join([*prefix, *args])
59
+
60
+
47
61
  def flowforge_home() -> Path:
48
62
  return Path(os.getenv("FLOWFORGE_HOME") or Path.home() / ".flowforge")
49
63
 
@@ -12,6 +12,7 @@ import json
12
12
  import os
13
13
  from collections.abc import AsyncIterator
14
14
  from contextlib import asynccontextmanager
15
+ from pathlib import Path
15
16
  from typing import Any
16
17
 
17
18
  from dotenv import load_dotenv
@@ -23,6 +24,7 @@ from flowforge.api import agent as agent_api
23
24
  from flowforge.api import checks as checks_api
24
25
  from flowforge.api import connectors as connectors_api
25
26
  from flowforge.api import flowmap as flowmap_api
27
+ from flowforge.api import keys as keys_api
26
28
  from flowforge.api import runs as runs_api
27
29
  from flowforge.api import scan as scan_api
28
30
  from flowforge.api import security as security_api
@@ -85,6 +87,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
85
87
  s.flows["deps"].planner = ScanPlanner(scan_api.EXAMPLE_REPO)
86
88
  s.flows["flow"].planner = CodeFlowPlanner(scan_api.EXAMPLE_REPO, lambda: example_data.latencies(storage))
87
89
  else: # your folder: from --repo or the last one chosen, mapped now if it has no maps yet
90
+ keys_api.learn_saved_keys(s, Path(os.environ["FLOWFORGE_REPO"]) if os.getenv("FLOWFORGE_REPO")
91
+ else scan_api.repo_path(s))
88
92
  scan_api.startup_repo(storage, s.flows)
89
93
  if example: # offline nodes and a seeded history, never mixed with real data (D16)
90
94
  example_data.install(s)
@@ -114,6 +118,7 @@ app.include_router(scan_api.router)
114
118
  app.include_router(checks_api.router)
115
119
  app.include_router(agent_api.router)
116
120
  app.include_router(security_api.router)
121
+ app.include_router(keys_api.router)
117
122
 
118
123
 
119
124
  @app.get("/workflows")