devops-bot-sdk 1.7.43__tar.gz → 1.7.46__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 (304) hide show
  1. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/cli.py +18 -7
  6. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/config.py +59 -31
  7. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/faqs.py +11 -16
  8. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/firewall.py +233 -38
  9. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/help_data/areas.json +13 -4
  10. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/help_data/commands.json +11 -1
  11. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/help_data/help.json +5 -1
  12. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/help_data/pages.json +1 -1
  13. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/help_data/releases.json +20 -0
  14. devops_bot_sdk-1.7.46/sdk/help_data/snapshot.json +17 -0
  15. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/helpdesk_kb.md +45 -33
  16. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/swagger.py +0 -6
  17. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/api.py +79 -19
  18. devops_bot_sdk-1.7.46/sdk/ui/ip_allowlist.py +199 -0
  19. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/models.py +8 -6
  20. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/security.py +22 -2
  21. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/server.py +26 -9
  22. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/app.js +62 -0
  23. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/index.html +22 -0
  24. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/styles.css +9 -0
  25. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/updater.py +13 -5
  26. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_firewall_open_on_update.py +147 -15
  27. devops_bot_sdk-1.7.46/tests/test_one_allowed_ip.py +153 -0
  28. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_swagger_command.py +1 -8
  29. devops_bot_sdk-1.7.46/tests/test_ui_bruteforce_guard.py +94 -0
  30. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ui_gate.py +57 -73
  31. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ui_lifespan.py +5 -5
  32. devops_bot_sdk-1.7.43/sdk/help_data/snapshot.json +0 -17
  33. devops_bot_sdk-1.7.43/sdk/ui/ip_allowlist.py +0 -258
  34. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/README.md +0 -0
  35. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  36. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  37. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/devops_bot_sdk.egg-info/requires.txt +0 -0
  38. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  39. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/pyproject.toml +0 -0
  40. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/__init__.py +0 -0
  41. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/classifier.py +0 -0
  42. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/collectors/__init__.py +0 -0
  43. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/collectors/common.py +0 -0
  44. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/collectors/linux_auditd.py +0 -0
  45. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/collectors/macos_eslogger.py +0 -0
  46. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/collectors/windows_eventlog.py +0 -0
  47. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/models.py +0 -0
  48. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/os_setup.py +0 -0
  49. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/recorder.py +0 -0
  50. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/redact.py +0 -0
  51. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/settings.py +0 -0
  52. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/shipper.py +0 -0
  53. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/activity/spool.py +0 -0
  54. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agent_sharing.py +0 -0
  55. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agentd.py +0 -0
  56. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/__init__.py +0 -0
  57. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/activity.py +0 -0
  58. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/builder.py +0 -0
  59. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/chat.py +0 -0
  60. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/connectors.py +0 -0
  61. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/engines/__init__.py +0 -0
  62. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/engines/claude_code.py +0 -0
  63. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/engines/http.py +0 -0
  64. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/engines/mcp.py +0 -0
  65. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/engines/n8n.py +0 -0
  66. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/engines/shell.py +0 -0
  67. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/integrations.py +0 -0
  68. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/inventory.py +0 -0
  69. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/library/__init__.py +0 -0
  70. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/manifest.py +0 -0
  71. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/prompt_studio.py +0 -0
  72. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/prompt_template.py +0 -0
  73. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/prompt_vars.py +0 -0
  74. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/run_log.py +0 -0
  75. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/schedules.py +0 -0
  76. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/secrets.py +0 -0
  77. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/ticket_run.py +0 -0
  78. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/usage.py +0 -0
  79. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/agents/varstore.py +0 -0
  80. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/browser_profile.py +0 -0
  81. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/claude_usage.py +0 -0
  82. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/client.py +0 -0
  83. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/collectors/__init__.py +0 -0
  84. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/collectors/files.py +0 -0
  85. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/collectors/process.py +0 -0
  86. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/collectors/screenshot.py +0 -0
  87. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/crucial.py +0 -0
  88. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/custom_agents.py +0 -0
  89. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/deferred_tickets.py +0 -0
  90. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/departments.py +0 -0
  91. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/design_verify/__init__.py +0 -0
  92. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/design_verify/assets.py +0 -0
  93. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/design_verify/bootstrap.py +0 -0
  94. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/design_verify/browser.py +0 -0
  95. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/design_verify/compare.py +0 -0
  96. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/design_verify/loop.py +0 -0
  97. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/estimator.py +0 -0
  98. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/exceptions.py +0 -0
  99. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/fallback_handoff.py +0 -0
  100. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/formats.py +0 -0
  101. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/git_ops.py +0 -0
  102. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/github_fetch.py +0 -0
  103. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/github_ingest.py +0 -0
  104. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/graph_recon.py +0 -0
  105. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/graphify.py +0 -0
  106. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/help_actions.py +0 -0
  107. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/helpdesk.py +0 -0
  108. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/hooks/__init__.py +0 -0
  109. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/hooks/crucial_guard.py +0 -0
  110. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/instructions_template.py +0 -0
  111. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/intake.py +0 -0
  112. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ipc/__init__.py +0 -0
  113. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ipc/electron_bridge.py +0 -0
  114. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ipc/handlers.py +0 -0
  115. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/journal.py +0 -0
  116. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/local_exec.py +0 -0
  117. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/log_colors.py +0 -0
  118. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/memory_guard.py +0 -0
  119. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/model_policy.py +0 -0
  120. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/models/__init__.py +0 -0
  121. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/models/envelope.py +0 -0
  122. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/models/requests.py +0 -0
  123. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/models/responses.py +0 -0
  124. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/models/snapshots.py +0 -0
  125. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/phase_timer.py +0 -0
  126. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/plan_run.py +0 -0
  127. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/platform_compat.py +0 -0
  128. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/prd_progress.py +0 -0
  129. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/proof_readers.py +0 -0
  130. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/providers.py +0 -0
  131. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/py.typed +0 -0
  132. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/resume.py +0 -0
  133. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/router.py +0 -0
  134. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/run_auto.py +0 -0
  135. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/schedule_tickets.py +0 -0
  136. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/schedule_timeline.py +0 -0
  137. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/scheduler.py +0 -0
  138. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/scope_plan.py +0 -0
  139. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/self_report.py +0 -0
  140. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/self_report_local.py +0 -0
  141. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/sse.py +0 -0
  142. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/task_file.py +0 -0
  143. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/task_types.py +0 -0
  144. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/test.py +0 -0
  145. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/test_env.py +0 -0
  146. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/test_pipeline.py +0 -0
  147. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ticket_board.py +0 -0
  148. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ticket_bulk.py +0 -0
  149. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ticket_fields.py +0 -0
  150. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ticket_plan.py +0 -0
  151. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ticket_pools.py +0 -0
  152. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/tickets.py +0 -0
  153. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/tiers/__init__.py +0 -0
  154. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/tiers/mini_agent.py +0 -0
  155. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/__init__.py +0 -0
  156. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/dashboard.py +0 -0
  157. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/health.py +0 -0
  158. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/jobs.py +0 -0
  159. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/runner.py +0 -0
  160. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/service.py +0 -0
  161. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/session.py +0 -0
  162. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/setup_manager.py +0 -0
  163. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/agents.html +0 -0
  164. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/chat.html +0 -0
  165. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/custom-agents.html +0 -0
  166. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/default-agents.html +0 -0
  167. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/faqs.html +0 -0
  168. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/help.html +0 -0
  169. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/report.html +0 -0
  170. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/runs.html +0 -0
  171. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/schedules.html +0 -0
  172. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/setup.html +0 -0
  173. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/static/tickets.html +0 -0
  174. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/ui/swagger.py +0 -0
  175. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/url_access.py +0 -0
  176. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/vps_health.py +0 -0
  177. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/vps_identity.py +0 -0
  178. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/sdk/write_hook.py +0 -0
  179. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/setup.cfg +0 -0
  180. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_claude_runs.py +0 -0
  181. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_collectors.py +0 -0
  182. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_core.py +0 -0
  183. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_live_tracking.py +0 -0
  184. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_macos.py +0 -0
  185. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_os_setup.py +0 -0
  186. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_activity_pipeline.py +0 -0
  187. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_activity.py +0 -0
  188. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_activity_tracking.py +0 -0
  189. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_chat_features.py +0 -0
  190. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_crud_cli.py +0 -0
  191. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_listing.py +0 -0
  192. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_prompt_async.py +0 -0
  193. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_prompt_models.py +0 -0
  194. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_run_ticket.py +0 -0
  195. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_schedules.py +0 -0
  196. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_sharing.py +0 -0
  197. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agent_workspace_settings.py +0 -0
  198. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_agents_connectors_builder.py +0 -0
  199. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_allowed_tools_config.py +0 -0
  200. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_approval_park.py +0 -0
  201. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_auto_agent_dispatch.py +0 -0
  202. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_auto_ticket_queue.py +0 -0
  203. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_browser_profile.py +0 -0
  204. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_cheap_tier.py +0 -0
  205. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_claude_usage_gate.py +0 -0
  206. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_client_jira_projects.py +0 -0
  207. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_config_token_diagnostics.py +0 -0
  208. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_configure_token_owner.py +0 -0
  209. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_console_encoding.py +0 -0
  210. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_crash_recovery.py +0 -0
  211. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_create_safe_retry.py +0 -0
  212. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_deferred_tickets.py +0 -0
  213. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_departments.py +0 -0
  214. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_done_transition.py +0 -0
  215. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_epic_decompose_run.py +0 -0
  216. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_epic_inherited_agent_id.py +0 -0
  217. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_epic_session_isolation.py +0 -0
  218. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_estimator.py +0 -0
  219. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_fallback_cli.py +0 -0
  220. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_fallback_cli_upgrade.py +0 -0
  221. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_fallback_rate_limit.py +0 -0
  222. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_faqs.py +0 -0
  223. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_formats.py +0 -0
  224. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_git_action.py +0 -0
  225. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_github_fetch.py +0 -0
  226. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_github_ingest.py +0 -0
  227. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_graph_off_by_default.py +0 -0
  228. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_graph_recon.py +0 -0
  229. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_graphify_build.py +0 -0
  230. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_graphify_hook_install.py +0 -0
  231. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_health.py +0 -0
  232. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_help_actions.py +0 -0
  233. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_help_attachments.py +0 -0
  234. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_help_questions.py +0 -0
  235. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_helpdesk.py +0 -0
  236. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_infographic_data.py +0 -0
  237. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_instructions_template.py +0 -0
  238. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_intake_job_outcome.py +0 -0
  239. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_intake_plan_review.py +0 -0
  240. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_journal.py +0 -0
  241. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_limit_fallback_handoff.py +0 -0
  242. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_local_exec_provider.py +0 -0
  243. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_log_colors_backlog.py +0 -0
  244. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_mcp_route_contract.py +0 -0
  245. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_memory_guard.py +0 -0
  246. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_model_policy.py +0 -0
  247. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_panel_help.py +0 -0
  248. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_panel_ticket_schedules.py +0 -0
  249. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_parallel_tickets.py +0 -0
  250. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_phase_timer.py +0 -0
  251. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_plan_ahead.py +0 -0
  252. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_plan_new_command.py +0 -0
  253. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_plan_run_activity.py +0 -0
  254. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_pool_pr_modes.py +0 -0
  255. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_pr_identity.py +0 -0
  256. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_prd_artifact.py +0 -0
  257. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_project_path_resolution.py +0 -0
  258. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_proof_readers.py +0 -0
  259. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_providers.py +0 -0
  260. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_quiet_run_log.py +0 -0
  261. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_recon_session_isolation.py +0 -0
  262. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_resume.py +0 -0
  263. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_router.py +0 -0
  264. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_run_auto_agent_only.py +0 -0
  265. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_run_outputs.py +0 -0
  266. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_run_verdict_is_honest.py +0 -0
  267. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_schedule_api.py +0 -0
  268. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_schedule_outputs_and_plans.py +0 -0
  269. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_schedule_tickets.py +0 -0
  270. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_schedule_timeline.py +0 -0
  271. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_scheduler.py +0 -0
  272. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_scheduler_transient_retry.py +0 -0
  273. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_self_report.py +0 -0
  274. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_service_token_warning.py +0 -0
  275. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_stale_panel_restart.py +0 -0
  276. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_task_file_and_plan_run.py +0 -0
  277. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_task_file_format_doc.py +0 -0
  278. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_theme_switch.py +0 -0
  279. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_board.py +0 -0
  280. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_bulk.py +0 -0
  281. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_cli.py +0 -0
  282. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_detail_view.py +0 -0
  283. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_fields.py +0 -0
  284. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_plan.py +0 -0
  285. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_pools.py +0 -0
  286. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_run_resolve_by_id.py +0 -0
  287. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_schedule_and_agent.py +0 -0
  288. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ticket_speed_fixes.py +0 -0
  289. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_tier_flag_parity.py +0 -0
  290. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_ui_no_native_dialogs.py +0 -0
  291. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_update_timer.py +0 -0
  292. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_updater_extras.py +0 -0
  293. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_updater_ignores_cwd.py +0 -0
  294. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_updater_pep668.py +0 -0
  295. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_updater_reports_the_truth.py +0 -0
  296. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_url_access.py +0 -0
  297. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_version_flag.py +0 -0
  298. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_vps_health.py +0 -0
  299. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_vps_only_reporting.py +0 -0
  300. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_windows_loop_identity.py +0 -0
  301. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_windows_panel_task.py +0 -0
  302. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_windows_stop_and_pidfile.py +0 -0
  303. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_windows_task_paths.py +0 -0
  304. {devops_bot_sdk-1.7.43 → devops_bot_sdk-1.7.46}/tests/test_write_hook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.7.43
3
+ Version: 1.7.46
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.7.43
3
+ Version: 1.7.46
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -235,6 +235,7 @@ tests/test_log_colors_backlog.py
235
235
  tests/test_mcp_route_contract.py
236
236
  tests/test_memory_guard.py
237
237
  tests/test_model_policy.py
238
+ tests/test_one_allowed_ip.py
238
239
  tests/test_panel_help.py
239
240
  tests/test_panel_ticket_schedules.py
240
241
  tests/test_parallel_tickets.py
@@ -279,6 +280,7 @@ tests/test_ticket_run_resolve_by_id.py
279
280
  tests/test_ticket_schedule_and_agent.py
280
281
  tests/test_ticket_speed_fixes.py
281
282
  tests/test_tier_flag_parity.py
283
+ tests/test_ui_bruteforce_guard.py
282
284
  tests/test_ui_gate.py
283
285
  tests/test_ui_lifespan.py
284
286
  tests/test_ui_no_native_dialogs.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.7.43
3
+ Version: 1.7.46
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -34,7 +34,7 @@ Rules:
34
34
  - All data egress through submit_webhook only
35
35
  """
36
36
 
37
- __version__ = "1.7.43" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
37
+ __version__ = "1.7.46" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
38
38
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
39
39
 
40
40
  from sdk.client import BackendClient
@@ -104,11 +104,17 @@ Command surface:
104
104
  default; `--apply` executes them and needs root / Administrator. The host
105
105
  firewall is a separate layer the allowlist cannot reach, and the two drifting
106
106
  apart is what silently blinded every VPS when the backend's egress IP moved.
107
- `--any` opens the port to every source instead, leaving
108
- IPAllowlistMiddleware as the gate — the more robust choice when the
109
- backend's egress IP can move, since a stale per-IP kernel rule drops the
110
- SYN before the allowlist is ever consulted. This is what runs
111
- automatically after a self-update. See sdk.firewall.
107
+ `--apply` also removes the open-to-any rule and any rule for an IP no
108
+ longer allowed; this is what runs automatically after a self-update.
109
+ `--any` opens the port to every source instead (not recommended: every
110
+ scanner then reaches the panel). See sdk.firewall.
111
+
112
+ agentos allow-ip [IP | --clear]
113
+ Show, set or clear THE one outside IP allowed to reach the panel (besides
114
+ this machine and the backend). Only one at a time: a new IP replaces the
115
+ old one in the allowlist and the host firewall. Same-machine only — this
116
+ command over SSH, or the panel opened at http://127.0.0.1:8765 on the box;
117
+ the remote API refuses. The firewall half needs root. See sdk.firewall.
112
118
 
113
119
  agentos swagger [--open] [--json [PATH]] [--port N]
114
120
  Where the panel's Swagger UI is and whether it can actually be reached:
@@ -223,6 +229,10 @@ def main() -> None:
223
229
  _github_command(rest)
224
230
  return
225
231
 
232
+ if noun == "allow-ip":
233
+ from sdk import firewall
234
+ sys.exit(firewall.allow_ip_cli(rest))
235
+
226
236
  if noun == "firewall":
227
237
  from sdk import firewall
228
238
  sys.exit(firewall.cli(rest))
@@ -264,7 +274,7 @@ def main() -> None:
264
274
 
265
275
 
266
276
  def _print_usage() -> None:
267
- print("usage: agentos <configure|chat|agent|ticket|integration|default-agents|agentd|github|report|browser|fallback|help|doctor|firewall|activity-setup|swagger> ...")
277
+ print("usage: agentos <configure|chat|agent|ticket|integration|default-agents|agentd|github|report|browser|fallback|help|doctor|firewall|allow-ip|activity-setup|swagger> ...")
268
278
  print()
269
279
  print(' agentos version (or --version) # installed SDK version, Python and install path')
270
280
  print(' agentos help "<your problem>" [--file PATH]… # DevOps Bot Help: reads your logs + the SDK (+ screenshots/logs you attach)')
@@ -284,7 +294,8 @@ def _print_usage() -> None:
284
294
  print(" agentos report <submit|list|show ID> # report work Jira/GitHub never saw")
285
295
  print(" agentos browser <login URL|status|logout> # sign in once; private links become readable")
286
296
  print(" agentos fallback <install|login|status|logout> # the AI Draftsman CLI used when claude is not working")
287
- print(" agentos firewall [--apply] [--revoke IP] [--port N] # open the UI port to the allowlisted IPs")
297
+ print(" agentos allow-ip [IP | --clear] # the ONE outside IP allowed to reach the panel")
298
+ print(" agentos firewall [--apply] [--revoke IP] [--port N] # limit the UI port to the allowlisted IPs")
288
299
  print(" agentos firewall --any [--apply] # open the UI port to any source (allowlist is the gate)")
289
300
  print(" agentos swagger [--open] [--json [PATH]] # where /docs is, and can you reach it")
290
301
 
@@ -227,36 +227,64 @@ def _parse_ip_list(raw: str) -> list[str]:
227
227
 
228
228
 
229
229
  def get_allowed_ips() -> list[str]:
230
- """Client IPs permitted to reach the ``agentos-ui`` API (``/api/ui/*``).
230
+ """Client IPs permitted to reach the ``agentos-ui`` server: the built-in backend
231
+ IP(s) plus AT MOST ONE user IP.
231
232
 
232
- This is the allowlist for the *inbound* half of the two-way link: the AI
233
- backend (and only it) may call back into the SDK server. Loopback is always
234
- trusted separately — this list is for the AI-backend IP(s) you whitelist.
233
+ ONE user IP at a time is the rule — the panel is reachable from a single
234
+ outside address, so a leaked token is useless anywhere else and the host
235
+ firewall (`sdk.firewall.ensure_restricted`) admits exactly that address.
236
+ Loopback and this host's own IPs are always trusted separately.
235
237
 
236
- Sources, merged (all optional), each accepting a comma/space-separated or
237
- bracketed list of strings:
238
- - ``.env`` key ``AGENTOS_UI_ALLOWED_IPS`` (``~/.agentos/.env`` or CWD ``.env``)
239
- - process env ``AGENTOS_UI_ALLOWED_IPS``
240
- - config.toml key ``allowed_ips``
241
-
242
- Returns a de-duplicated list (empty means "loopback only"). Supplying the
243
- AI-backend IP here later is all that's needed to open the link — no code
244
- change. CIDR/prefix entries are supported by the middleware.
238
+ The user IP is chosen by :func:`user_allowed_ip`; anything else configured is
239
+ ignored and reported by :func:`ignored_allowed_ips`.
245
240
  """
246
- sources = [
247
- ",".join(_DEFAULT_ALLOWED_IPS), # built-in backend IP(s) — always allowed
248
- _dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
249
- os.environ.get("AGENTOS_UI_ALLOWED_IPS", ""),
250
- _load_raw().get("allowed_ips", ""),
251
- ]
241
+ out = list(_DEFAULT_ALLOWED_IPS)
242
+ user = user_allowed_ip()
243
+ if user and user not in out:
244
+ out.append(user)
245
+ return out
246
+
247
+
248
+ def _single_address(value: str) -> bool:
249
+ """True for one host address (a bare IP, or a /32 / /128) — not a range."""
250
+ import ipaddress
251
+ try:
252
+ return ipaddress.ip_network(value.strip(), strict=False).num_addresses == 1
253
+ except ValueError:
254
+ return False
255
+
256
+
257
+ def _configured_user_ips() -> list[str]:
258
+ """Every non-backend entry configured anywhere, oldest source first:
259
+ ``.env``, process env, then config.toml (the runtime / API-added one)."""
252
260
  out: list[str] = []
253
- for src in sources:
261
+ for src in (_dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
262
+ os.environ.get("AGENTOS_UI_ALLOWED_IPS", ""),
263
+ _load_raw().get("allowed_ips", "")):
254
264
  for ip in _parse_ip_list(src):
255
- if ip not in out:
265
+ if ip not in _DEFAULT_ALLOWED_IPS and ip not in out:
256
266
  out.append(ip)
257
267
  return out
258
268
 
259
269
 
270
+ def user_allowed_ip() -> str | None:
271
+ """The ONE user IP allowed to reach the panel, or None.
272
+
273
+ The most recent wins: the runtime entry (config.toml — set by
274
+ ``POST /api/ui/allowlist``) over ``.env``, and within a source the LAST entry,
275
+ since the documented way to add an IP is to append it. CIDR ranges are never
276
+ chosen — a range is many IPs.
277
+ """
278
+ candidates = [ip for ip in _configured_user_ips() if _single_address(ip)]
279
+ return candidates[-1] if candidates else None
280
+
281
+
282
+ def ignored_allowed_ips() -> list[str]:
283
+ """Configured user IPs/ranges that are NOT allowed, because only one may be."""
284
+ keep = user_allowed_ip()
285
+ return [ip for ip in _configured_user_ips() if ip != keep]
286
+
287
+
260
288
  # Built-in baseline of tools a headless agent run may use. Kept here (not in
261
289
  # local_exec) so it is one configurable setting rather than a literal buried in the
262
290
  # spawn code: the set WILL grow, and a new tool must be addable without editing
@@ -434,23 +462,23 @@ def journal_enabled() -> bool:
434
462
 
435
463
 
436
464
  def add_allowed_ip(ip: str) -> list[str]:
437
- """Whitelist an IP/CIDR at RUNTIME (persisted to config.toml ``allowed_ips``).
465
+ """Make ``ip`` THE user IP at runtime (persisted to config.toml ``allowed_ips``).
438
466
 
439
- Idempotent. The middleware re-reads the allowlist per request, so the IP is
440
- honoured immediately (no restart) and stays until disabled. An IP already
441
- permanently whitelisted via ``.env`` is left as-is (no runtime duplicate).
442
- Returns the merged allowlist.
467
+ Only one user IP may reach the panel at a time, so this REPLACES whichever was
468
+ there before — including one from ``.env``, which it outranks while set.
469
+ Idempotent. The middleware sees it on the next request; the caller updates the
470
+ host firewall. A backend IP is already allowed and is left alone. Returns the
471
+ effective allowlist.
443
472
  """
444
473
  ip = (ip or "").strip()
445
474
  if not ip:
446
475
  raise ValueError("ip is required")
447
- if ip in permanent_allowed_ips(): # already permanent — nothing to add
476
+ if not _single_address(ip):
477
+ raise ValueError(f"{ip} is a range — only one IP may be allowed at a time")
478
+ if ip in _DEFAULT_ALLOWED_IPS:
448
479
  return get_allowed_ips()
449
480
  raw = _load_raw() # preserves token_enc / base_url / etc.
450
- entries = _parse_ip_list(raw.get("allowed_ips", ""))
451
- if ip not in entries:
452
- entries.append(ip)
453
- raw["allowed_ips"] = ", ".join(entries)
481
+ raw["allowed_ips"] = ip
454
482
  _save_raw(raw)
455
483
  return get_allowed_ips()
456
484
 
@@ -21,22 +21,17 @@ FAQS: list[tuple[str, str, str]] = [
21
21
  ("Basics", "How do I open the control panel from another machine?",
22
22
  "**Easiest — an SSH tunnel (nothing to open):** on your machine run "
23
23
  "`ssh -L 8765:127.0.0.1:8765 you@SERVER`, then browse `http://127.0.0.1:8765`.\n"
24
- "**Directly by IP:**\n"
25
- "1. On YOUR machine, find your public IP: `curl -s https://api.ipify.org; echo` → prints e.g. `203.0.113.7`\n"
26
- "2. On the SERVER, allowlist it: `echo 'AGENTOS_UI_ALLOWED_IPS=203.0.113.7' >> ~/.agentos/.env` — if that "
27
- "line is already there, add the IP to it instead (only the first line counts): "
28
- "`sed -i 's/^AGENTOS_UI_ALLOWED_IPS=.*/&,203.0.113.7/' ~/.agentos/.env`\n"
29
- "3. Check it: `grep AGENTOS_UI_ALLOWED_IPS ~/.agentos/.env` → `AGENTOS_UI_ALLOWED_IPS=203.0.113.7` "
30
- "(or your earlier IPs, then `,203.0.113.7`)\n"
31
- "4. Open the port: `agentos firewall --apply` (needs sudo rights; if it can't, it prints the `ufw` command to run)\n"
32
- "**On a Windows server** (PowerShell): `(Invoke-RestMethod https://api.ipify.org)` for your IP; "
33
- "`Add-Content \"$env:USERPROFILE\\.agentos\\.env\" \"AGENTOS_UI_ALLOWED_IPS=203.0.113.7\"` the first time, or "
34
- "`(Get-Content $f) -replace '^(AGENTOS_UI_ALLOWED_IPS=.*)$','$1,203.0.113.7' | Set-Content $f` "
35
- "(with `$f = \"$env:USERPROFILE\\.agentos\\.env\"`) to add to an existing line; "
36
- "`agentos firewall --apply` in an Administrator PowerShell. In cmd: "
37
- "`echo AGENTOS_UI_ALLOWED_IPS=203.0.113.7>> \"%USERPROFILE%\\.agentos\\.env\"` (no space before `>>`).\n"
38
- "5. Browse `http://SERVER-IP:8765` and paste your co_ token. It works at once — no restart. "
39
- "`agentos swagger` shows whether the port is reachable and which allowlisted IPs the firewall still blocks."),
24
+ "**Directly by IP** — only ONE outside IP can open the panel at a time, and only someone on the "
25
+ "server can set it:\n"
26
+ "1. On YOUR machine, find your public IP: `curl -s https://api.ipify.org; echo` → prints e.g. `203.0.113.7` "
27
+ "(Windows PowerShell: `Invoke-RestMethod https://api.ipify.org`)\n"
28
+ "2. On the SERVER (over SSH): `agentos allow-ip 203.0.113.7` — this REPLACES the previous IP, which loses access. "
29
+ "Or open the panel on the server itself (`http://127.0.0.1:8765`) → Dashboard → **Remote access**.\n"
30
+ "3. If it says the firewall needs root: `sudo agentos firewall --apply` (Windows: `agentos firewall --apply` "
31
+ "in an Administrator PowerShell).\n"
32
+ "4. Browse `http://SERVER-IP:8765` and paste your co_ token. `agentos allow-ip` shows the allowed IP; "
33
+ "`agentos allow-ip --clear` removes it. The remote API cannot change it, so a leaked token cannot "
34
+ "open the panel to another address."),
40
35
  ("Basics", "How do I use devops-bot-sdk on another machine?",
41
36
  "Install it there too — the same checklist (Python, git, SSH key, repo access, claude or ad), then "
42
37
  "`pip install \"git+ssh://git@github.com/consultancy-outfit/DevOpsBot-SDK.git\"` and `agentos configure` "
@@ -19,10 +19,12 @@ from the *same* list the middleware enforces, so the two layers cannot drift.
19
19
  agentos firewall --any --apply# open the port, leaving the allowlist as the gate
20
20
  agentos firewall --revoke IP # print the matching delete rule
21
21
 
22
- ``--any`` is also what runs automatically after a self-update
23
- (``updater._ensure_firewall``), because a pip upgrade replaces code and
24
- nothing else — a host whose kernel drops the SYNs stays broken across every
25
- update while reporting a healthy version.
22
+ After a self-update ``updater._ensure_firewall`` runs :func:`ensure_restricted`:
23
+ it adds a per-IP rule for every allowlisted address and REMOVES the open-to-any
24
+ rule. It used to run ``--any``, and that left every VPS with 8765 open to the
25
+ internet: each brute-force/scanner request then reached Python before the
26
+ allowlist refused it, and a flood pinned the panel at 100% CPU. Open-to-any is
27
+ now only kept when the operator chose it with ``AGENTOS_FIREWALL_OPEN_ANY=1``.
26
28
 
27
29
  Applying is deliberately opt-in: editing a live firewall is exactly the operation
28
30
  that can lock an operator out of their own box, so the default is to print and let
@@ -30,6 +32,7 @@ a human read it first.
30
32
  """
31
33
  from __future__ import annotations
32
34
 
35
+ import ipaddress
33
36
  import os
34
37
  import platform
35
38
  import shutil
@@ -111,10 +114,13 @@ def allow_any_rules(port: int, system: str | None = None) -> list[str]:
111
114
  corrected in seconds without root on the box, which is exactly what a
112
115
  kernel rule cannot offer.
113
116
 
114
- So: open the port, keep the decision in the allowlist. Use
115
- :func:`allow_rules` instead when the host must not accept a connection from
116
- an unlisted address at all (a shared or hostile network), and accept that a
117
- backend IP change then needs a root session on every host.
117
+ That reasoning held for *access* but not for *load*: every request from the
118
+ internet still costs a Python round-trip before the allowlist refuses it, and
119
+ a brute-force flood took panels to 100% CPU. So this is no longer the default
120
+ — :func:`ensure_restricted` is — and is kept only for an operator who opts
121
+ in with ``AGENTOS_FIREWALL_OPEN_ANY=1``. A backend IP move
122
+ is still self-healing: the new IP goes in ``_DEFAULT_ALLOWED_IPS`` and the
123
+ next self-update adds its rule.
118
124
  """
119
125
  sysname = (system or _system()).lower()
120
126
 
@@ -201,6 +207,17 @@ def ensure_open(port: int | None = None, *, apply: bool = True) -> tuple[bool, s
201
207
  who = "Administrator" if sysname == "windows" else "root (sudo)"
202
208
  return (False, f"TCP {port} is not open and opening it needs {who}:\n{printable}")
203
209
 
210
+ failed = _apply_rules(rules)
211
+ if failed:
212
+ return (False, "could not open TCP %d: %s" % (port, "; ".join(failed)))
213
+ return (True, f"opened TCP {port} inbound")
214
+
215
+
216
+ def _apply_rules(rules: list[str]) -> list[str]:
217
+ """Run each rule (already privileged, so a printed ``sudo `` is stripped).
218
+
219
+ Returns one ``"<cmd>: <reason>"`` per failure — empty means all applied.
220
+ """
204
221
  failed: list[str] = []
205
222
  for rule in rules:
206
223
  cmd = rule[len("sudo "):] if rule.startswith("sudo ") else rule
@@ -214,9 +231,130 @@ def ensure_open(port: int | None = None, *, apply: bool = True) -> tuple[bool, s
214
231
  if proc.returncode != 0:
215
232
  tail = (proc.stderr or proc.stdout or "").strip().splitlines()
216
233
  failed.append(f"{cmd}: {tail[-1][:120] if tail else f'exit {proc.returncode}'}")
234
+ return failed
235
+
236
+
237
+ def _net(value: str):
238
+ """``value`` as an ip_network, or None — so ``1.2.3.4`` and ``1.2.3.4/32`` match."""
239
+ try:
240
+ return ipaddress.ip_network(value.strip(), strict=False)
241
+ except ValueError:
242
+ return None
243
+
244
+
245
+ def open_any_opted_in() -> bool:
246
+ """Has the operator chosen to keep the port open to any source?
247
+
248
+ Only an explicit ``AGENTOS_FIREWALL_OPEN_ANY=1`` — read from the process env
249
+ or ``~/.agentos/.env``, like every other ``AGENTOS_UI_*`` toggle. (The removed
250
+ ``AGENTOS_UI_PUBLIC=1`` used to imply it.)
251
+ """
252
+ try:
253
+ from sdk.ui.ip_allowlist import _TRUE, _setting
254
+ except Exception: # noqa: BLE001 — never fail open on an import hiccup
255
+ return False
256
+ return _setting("AGENTOS_FIREWALL_OPEN_ANY").lower() in _TRUE
257
+
258
+
259
+ def ensure_restricted(port: int | None = None, *, apply: bool = True,
260
+ ips: list[str] | None = None) -> tuple[bool, str]:
261
+ """Limit ``port`` to EXACTLY the allowlisted IPs. Returns ``(changed, message)``.
262
+ Never raises.
263
+
264
+ Adds a per-IP rule for each allowlisted address that lacks one, THEN removes
265
+ the open-to-any rule and every per-IP rule for an address no longer allowed
266
+ (only one user IP may reach the panel, so replacing it must close the old
267
+ one) — in that order, and the removals are skipped if any add failed, so the
268
+ backend never loses its route mid-change. Same contract as
269
+ :func:`ensure_open` otherwise: unreadable state is never treated as closed,
270
+ an unprivileged run only returns the commands, and macOS never applies.
271
+
272
+ A message that needs the operator to act is multi-line (it carries commands),
273
+ which is what ``updater._ensure_firewall`` keys on to surface it.
274
+ """
275
+ port = ui_port() if port is None else port
276
+ sysname = _system()
277
+ if ips is None:
278
+ try:
279
+ from sdk.config import get_allowed_ips
280
+ ips = get_allowed_ips()
281
+ except Exception: # noqa: BLE001
282
+ ips = []
283
+
284
+ try:
285
+ sources = port_sources(port, sysname, collapse=False)
286
+ except Exception: # noqa: BLE001 — a status probe must never break an update
287
+ sources = None
288
+
289
+ inactive_note = ""
290
+ if sysname == "linux" and _ufw_inactive():
291
+ inactive_note = ("\nNOTE: ufw is INACTIVE, so no rule is enforced and the port is open "
292
+ "to everyone.\nEnable it once you have allowed SSH:\n"
293
+ " sudo ufw allow OpenSSH && sudo ufw enable")
294
+
295
+ if sources is None:
296
+ rules = allow_rules(ips, port, sysname) + [revoke_any_rule(port, sysname)]
297
+ printable = " " + "\n ".join(rules)
298
+ ok, tool = _tool_present()
299
+ why = tool if not ok else "needs root to read the firewall state"
300
+ return (False, f"could not read the firewall state ({why}) — to restrict TCP {port} "
301
+ f"to the allowlist yourself:\n{printable}")
302
+
303
+ have = {n for n in (_net(x) for x in sources) if n is not None}
304
+ want = {n for n in (_net(ip) for ip in ips) if n is not None}
305
+ missing = [ip for ip in ips if _net(ip) is not None and _net(ip) not in have]
306
+ stale = sorted(x for x in sources if _net(x) is not None and _net(x) not in want)
307
+ open_any = "Anywhere" in sources
308
+
309
+ if not missing and not open_any and not stale:
310
+ return (False, f"TCP {port} is restricted to the allowlist" + inactive_note)
311
+
312
+ removals = ([revoke_any_rule(port, sysname)] if open_any else []) + [
313
+ revoke_rule(x, port, sysname) for x in stale]
314
+ rules = allow_rules(missing, port, sysname) + removals
315
+ printable = " " + "\n ".join(rules)
316
+ what = "; ".join(w for w in (
317
+ f"TCP {port} is open to ANY source" if open_any else "",
318
+ f"missing a rule for: {', '.join(missing)}" if missing else "",
319
+ f"still allows: {', '.join(stale)}" if stale else "",
320
+ ) if w)
321
+
322
+ if not apply or sysname == "darwin":
323
+ return (False, f"{what}. To restrict it to the allowlist:\n{printable}" + inactive_note)
324
+ if not _is_privileged():
325
+ who = "Administrator" if sysname == "windows" else "root (sudo)"
326
+ return (False, f"{what} and restricting it needs {who}:\n{printable}" + inactive_note)
327
+
328
+ failed = _apply_rules(allow_rules(missing, port, sysname))
217
329
  if failed:
218
- return (False, "could not open TCP %d: %s" % (port, "; ".join(failed)))
219
- return (True, f"opened TCP {port} inbound")
330
+ # The any-rule stays: removing it now could cut the backend off.
331
+ return (False, f"could not add allowlist rules for TCP {port}: {'; '.join(failed)}"
332
+ f"\nThe open-to-any rule was left in place.")
333
+ if removals:
334
+ _apply_rules(removals)
335
+ still = port_sources(port, sysname, collapse=False) or set()
336
+ left = sorted(x for x in still
337
+ if x == "Anywhere" or (_net(x) is not None and _net(x) not in want))
338
+ if left:
339
+ return (True, f"TCP {port} still allows {', '.join(left)} — a rule the SDK did "
340
+ f"not create. Find and delete it:\n "
341
+ + ("sudo ufw status numbered" if sysname == "linux" else
342
+ "netsh advfirewall firewall show rule name=all dir=in")
343
+ + inactive_note)
344
+ return (True, f"restricted TCP {port} to the allowlist: {', '.join(ips)}" + inactive_note)
345
+
346
+
347
+ def sync_rules(port: int | None = None) -> tuple[bool, str]:
348
+ """Bring the host firewall in line with the allowlist after it changed.
349
+
350
+ Called when the one user IP is set or cleared (CLI or panel), so the new IP's
351
+ rule is added and the old one's removed straight away rather than at the next
352
+ self-update. Leaves a deliberately open port alone. Never raises.
353
+ """
354
+ if open_any_opted_in():
355
+ return (False, "port is open to any source by choice (AGENTOS_FIREWALL_OPEN_ANY) — "
356
+ "no per-IP rules to change")
357
+ return ensure_restricted(port)
220
358
 
221
359
 
222
360
  def revoke_rule(ip: str, port: int, system: str | None = None) -> str:
@@ -310,7 +448,8 @@ def _parse_netsh(text: str, port: int) -> set[str]:
310
448
  return out
311
449
 
312
450
 
313
- def port_sources(port: int, system: str | None = None) -> set[str] | None:
451
+ def port_sources(port: int, system: str | None = None, *,
452
+ collapse: bool = True) -> set[str] | None:
314
453
  """Which sources the HOST firewall currently allows to ``port``.
315
454
 
316
455
  ``{"Anywhere"}`` means open to the world. **``None`` means the state could not
@@ -322,6 +461,9 @@ def port_sources(port: int, system: str | None = None) -> set[str] | None:
322
461
  (`sdk.config.get_allowed_ips`) and the host firewall are separate layers, and
323
462
  the interesting question is where they DISAGREE — an IP the app permits but the
324
463
  kernel drops looks exactly like a broken backend.
464
+
465
+ ``collapse=False`` keeps the per-IP sources alongside ``"Anywhere"`` — what
466
+ :func:`ensure_restricted` needs to know which IP rules already exist.
325
467
  """
326
468
  sysname = (system or _system()).lower()
327
469
  if sysname == "linux":
@@ -329,17 +471,27 @@ def port_sources(port: int, system: str | None = None) -> set[str] | None:
329
471
  if text is None:
330
472
  return None
331
473
  got = _parse_ufw(text, port)
332
- return {"Anywhere"} if "Anywhere" in got else got
474
+ return {"Anywhere"} if collapse and "Anywhere" in got else got
333
475
  if sysname == "windows":
334
476
  text = _run(["netsh", "advfirewall", "firewall", "show", "rule",
335
477
  "name=all", "dir=in"], timeout=25.0)
336
478
  if text is None:
337
479
  return None
338
480
  got = _parse_netsh(text, port)
339
- return {"Anywhere"} if "Anywhere" in got else got
481
+ return {"Anywhere"} if collapse and "Anywhere" in got else got
340
482
  return None # macOS pf and anything else: not readable this way
341
483
 
342
484
 
485
+ def _ufw_inactive() -> bool:
486
+ """True only when ufw positively reports ``Status: inactive``.
487
+
488
+ Then every ufw rule is decoration and the port is open regardless — the
489
+ "per-IP rules are applied but the flood still arrives" case.
490
+ """
491
+ text = _run(["ufw", "status"])
492
+ return bool(text) and "status: inactive" in text.lower()
493
+
494
+
343
495
  def _is_privileged() -> bool:
344
496
  if _system() == "windows":
345
497
  try:
@@ -382,10 +534,9 @@ def cli(argv: list[str]) -> int:
382
534
  print(f"Allowed : {', '.join(ips) if ips else '(none — loopback only)'}")
383
535
 
384
536
  # `--any` opens the port to every source and leaves the decision to
385
- # IPAllowlistMiddleware. This is what the post-update hook uses; see
386
- # `allow_any_rules` for why a per-IP kernel rule is the more fragile of the
387
- # two. It does NOT need an allowlist to be populated first, so it is checked
388
- # before the "nothing to open" bail below.
537
+ # IPAllowlistMiddleware. NOT the default any more (see `allow_any_rules`):
538
+ # every internet scanner then reaches Python. It does NOT need an allowlist to
539
+ # be populated first, so it is checked before the "nothing to open" bail below.
389
540
  if "--any" in argv or "--open" in argv:
390
541
  current = port_sources(port, sysname)
391
542
  if current:
@@ -403,7 +554,10 @@ def cli(argv: list[str]) -> int:
403
554
  for r in rules:
404
555
  print(f" {r}")
405
556
  print("\nAccess is still gated by the app allowlist above — an unlisted IP is\n"
406
- "refused by IPAllowlistMiddleware even with the port open.")
557
+ "refused by IPAllowlistMiddleware even with the port open.\n"
558
+ "WARNING: every scanner on the internet can then reach the panel process;\n"
559
+ "a brute-force flood costs CPU even though it is refused. The next\n"
560
+ "self-update narrows it back unless AGENTOS_FIREWALL_OPEN_ANY=1 is set.")
407
561
  if "--apply" not in argv:
408
562
  print("\n(dry run — nothing changed. Re-run with --apply to execute.)")
409
563
  return 0
@@ -417,6 +571,9 @@ def cli(argv: list[str]) -> int:
417
571
  return 0
418
572
 
419
573
  rules = allow_rules(ips, port, sysname)
574
+ if "Anywhere" in (port_sources(port, sysname) or set()):
575
+ rules.append(revoke_any_rule(port, sysname))
576
+ print(f"\nTCP {port} is currently open to ANY source — the last rule removes that.")
420
577
  print("\nFirewall rules for this host:\n")
421
578
  for r in rules:
422
579
  print(f" {r}")
@@ -445,27 +602,65 @@ def cli(argv: list[str]) -> int:
445
602
  print(f"\nerror: --apply needs {who}.")
446
603
  return 1
447
604
 
448
- failed = 0
449
- print()
450
- for r in rules:
451
- # The printed form carries `sudo` for humans; we are already privileged.
452
- cmd = r[len("sudo "):] if r.startswith("sudo ") else r
605
+ changed, msg = ensure_restricted(port, apply=True, ips=ips)
606
+ print(f"\n{msg}")
607
+ return 0 if changed or "restricted to the allowlist" in msg else 1
608
+
609
+
610
+ def allow_ip_cli(argv: list[str]) -> int:
611
+ """``agentos allow-ip [IP | --clear]`` — set, show or clear THE one user IP.
612
+
613
+ Only one outside IP may reach the panel at a time; setting a new one replaces
614
+ the old one in the allowlist and in the host firewall. This command is one of
615
+ the two ways to change it — both need someone on the machine (this one over
616
+ SSH, or the panel opened at http://127.0.0.1:8765 on the box); the remote API
617
+ refuses. Run it as the user the panel runs as, so it edits that user's
618
+ ~/.agentos/config.toml; the firewall half needs root and prints the command
619
+ otherwise.
620
+ """
621
+ from sdk import config
622
+
623
+ def show() -> None:
624
+ user = config.user_allowed_ip()
625
+ print(f"Allowed user IP : {user or '(none — only this machine and the backend)'}")
626
+ print(f"Backend IPs : {', '.join(config._DEFAULT_ALLOWED_IPS)}")
627
+ ignored = config.ignored_allowed_ips()
628
+ if ignored:
629
+ print(f"Ignored : {', '.join(ignored)} (only one user IP is allowed at a time)")
630
+
631
+ args = [a for a in argv if a.strip()]
632
+ if not args or args[0] in ("--show", "show"):
633
+ show()
634
+ return 0
635
+
636
+ if args[0] in ("--clear", "clear", "--remove"):
637
+ user = config.user_allowed_ip()
638
+ runtime = [ip for ip in config.runtime_allowed_ips() if ip == user]
639
+ if not runtime:
640
+ if user:
641
+ print(f"{user} comes from AGENTOS_UI_ALLOWED_IPS in ~/.agentos/.env — "
642
+ "remove it there.")
643
+ return 1
644
+ print("No user IP is allowed — nothing to clear.")
645
+ return 0
646
+ config.remove_allowed_ip(user)
647
+ print(f"✓ removed {user}")
648
+ else:
649
+ ip = args[0]
650
+ before = config.user_allowed_ip()
453
651
  try:
454
- proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
455
- except Exception as exc: # noqa: BLE001 — never leave the firewall half-known
456
- print(f" ✗ {cmd}\n {exc}")
457
- failed += 1
458
- continue
459
- tail = (proc.stdout or proc.stderr or "").strip().splitlines()
460
- note = tail[-1][:100] if tail else ""
461
- if proc.returncode == 0:
462
- print(f" ✓ {cmd}\n {note}" if note else f" ✓ {cmd}")
463
- else:
464
- print(f" ✗ {cmd} (exit {proc.returncode})\n {note}")
465
- failed += 1
466
-
467
- print(f"\n{len(rules) - failed}/{len(rules)} applied.")
468
- return 1 if failed else 0
652
+ config.add_allowed_ip(ip)
653
+ except ValueError as exc:
654
+ print(f"error: {exc}")
655
+ return 2
656
+ print(f"✓ {ip} is now the one IP allowed to reach the panel")
657
+ if before and before != ip:
658
+ print(f" (replaced {before} — it no longer has access)")
659
+
660
+ changed, msg = sync_rules()
661
+ print(("↻ " if changed else "") + msg)
662
+ show()
663
+ return 0
469
664
 
470
665
 
471
666
  if __name__ == "__main__": # pragma: no cover