humanbound 2.0.0__tar.gz → 2.0.2__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 (109) hide show
  1. {humanbound-2.0.0 → humanbound-2.0.2}/PKG-INFO +1 -7
  2. {humanbound-2.0.0 → humanbound-2.0.2}/README.md +0 -6
  3. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/__init__.py +1 -1
  4. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound.egg-info/PKG-INFO +1 -7
  5. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound.egg-info/SOURCES.txt +9 -1
  6. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/connect.py +365 -16
  7. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/test.py +54 -10
  8. humanbound-2.0.2/humanbound_cli/engine/compliance.py +192 -0
  9. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/local_runner.py +4 -1
  10. humanbound-2.0.2/humanbound_cli/templates/compliance/banking.yaml +19 -0
  11. humanbound-2.0.2/humanbound_cli/templates/compliance/ecommerce.yaml +18 -0
  12. humanbound-2.0.2/humanbound_cli/templates/compliance/eu-ai-act.yaml +12 -0
  13. humanbound-2.0.2/humanbound_cli/templates/compliance/healthcare.yaml +18 -0
  14. humanbound-2.0.2/humanbound_cli/templates/compliance/insurance.yaml +19 -0
  15. humanbound-2.0.2/humanbound_cli/templates/compliance/legal.yaml +18 -0
  16. humanbound-2.0.2/humanbound_cli/templates/report_base.html +238 -0
  17. {humanbound-2.0.0 → humanbound-2.0.2}/pyproject.toml +7 -3
  18. {humanbound-2.0.0 → humanbound-2.0.2}/LICENSE +0 -0
  19. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/bot.py +0 -0
  20. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/callbacks.py +0 -0
  21. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/orchestrators.py +0 -0
  22. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/py.typed +0 -0
  23. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/runner.py +0 -0
  24. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound/schemas.py +0 -0
  25. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound.egg-info/dependency_links.txt +0 -0
  26. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound.egg-info/entry_points.txt +0 -0
  27. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound.egg-info/requires.txt +0 -0
  28. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound.egg-info/top_level.txt +0 -0
  29. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/__init__.py +0 -0
  30. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/adapters/__init__.py +0 -0
  31. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/adapters/promptfoo.py +0 -0
  32. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/adapters/pyrit.py +0 -0
  33. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/client.py +0 -0
  34. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/__init__.py +0 -0
  35. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/_report_helper.py +0 -0
  36. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/api_keys.py +0 -0
  37. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/assessments.py +0 -0
  38. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/auth.py +0 -0
  39. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/campaigns.py +0 -0
  40. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/completion.py +0 -0
  41. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/config_cmd.py +0 -0
  42. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/docs.py +0 -0
  43. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/experiments.py +0 -0
  44. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/findings.py +0 -0
  45. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/firewall.py +0 -0
  46. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/guardrails.py +0 -0
  47. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/logs.py +0 -0
  48. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/mcp.py +0 -0
  49. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/members.py +0 -0
  50. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/monitor.py +0 -0
  51. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/orgs.py +0 -0
  52. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/posture.py +0 -0
  53. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/projects.py +0 -0
  54. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/providers.py +0 -0
  55. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/redteam.py +0 -0
  56. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/report.py +0 -0
  57. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/scan.py +0 -0
  58. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/sentinel.py +0 -0
  59. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/upload_logs.py +0 -0
  60. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/commands/webhooks.py +0 -0
  61. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/config.py +0 -0
  62. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/connectors/__init__.py +0 -0
  63. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/connectors/microsoft.py +0 -0
  64. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/__init__.py +0 -0
  65. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/bot.py +0 -0
  66. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/callbacks.py +0 -0
  67. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/__init__.py +0 -0
  68. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/azureopenai.py +0 -0
  69. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/claude.py +0 -0
  70. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/gemini.py +0 -0
  71. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/grok.py +0 -0
  72. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/ollama.py +0 -0
  73. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/llm/openai.py +0 -0
  74. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/__init__.py +0 -0
  75. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/base.py +0 -0
  76. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/behavioral_qa/__init__.py +0 -0
  77. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/behavioral_qa/config.py +0 -0
  78. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/behavioral_qa/generator.py +0 -0
  79. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/behavioral_qa/judge.py +0 -0
  80. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/behavioral_qa/orchestrator.py +0 -0
  81. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_agentic/__init__.py +0 -0
  82. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_agentic/config.py +0 -0
  83. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_agentic/generator.py +0 -0
  84. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_agentic/judge.py +0 -0
  85. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_agentic/orchestrator.py +0 -0
  86. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_single_turn/__init__.py +0 -0
  87. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_single_turn/config.py +0 -0
  88. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_single_turn/generator.py +0 -0
  89. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_single_turn/judge.py +0 -0
  90. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/orchestrators/owasp_single_turn/orchestrator.py +0 -0
  91. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/platform_runner.py +0 -0
  92. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/presenter.py +0 -0
  93. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/runner.py +0 -0
  94. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/schemas.py +0 -0
  95. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/engine/scope.py +0 -0
  96. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/exceptions.py +0 -0
  97. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/extractors/__init__.py +0 -0
  98. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/extractors/openapi.py +0 -0
  99. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/extractors/repo.py +0 -0
  100. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/main.py +0 -0
  101. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/mcp_server.py +0 -0
  102. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/py.typed +0 -0
  103. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/pytest_plugin/__init__.py +0 -0
  104. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/pytest_plugin/fixtures.py +0 -0
  105. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/pytest_plugin/report.py +0 -0
  106. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/report.py +0 -0
  107. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/report_builder.py +0 -0
  108. {humanbound-2.0.0 → humanbound-2.0.2}/humanbound_cli/templates/logo.svg +0 -0
  109. {humanbound-2.0.0 → humanbound-2.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humanbound
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Humanbound — open-source AI agent red-team engine, SDK, and CLI.
5
5
  Author-email: Humanbound <hello@humanbound.ai>
6
6
  Maintainer-email: Demetris Gerogiannis <hello@humanbound.ai>, Kostas Siabanis <hello@humanbound.ai>
@@ -196,9 +196,3 @@ trademark policy. The code is open; the name is not.
196
196
  The sibling project [`humanbound-firewall`](https://github.com/humanbound/humanbound-firewall)
197
197
  is dual-licensed (AGPL-3.0 + commercial) — different product, different
198
198
  license strategy.
199
-
200
- ---
201
-
202
- <p align="center">
203
- <sub><em>Humanbound is the trading name of AI and Me Single-Member Private Company, incorporated in Greece.</em></sub>
204
- </p>
@@ -134,9 +134,3 @@ trademark policy. The code is open; the name is not.
134
134
  The sibling project [`humanbound-firewall`](https://github.com/humanbound/humanbound-firewall)
135
135
  is dual-licensed (AGPL-3.0 + commercial) — different product, different
136
136
  license strategy.
137
-
138
- ---
139
-
140
- <p align="center">
141
- <sub><em>Humanbound is the trading name of AI and Me Single-Member Private Company, incorporated in Greece.</em></sub>
142
- </p>
@@ -12,7 +12,7 @@ from `humanbound` for stability.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- __version__ = "2.0.0"
15
+ __version__ = "2.0.1"
16
16
 
17
17
  from humanbound.bot import Bot, ResponseExtractor
18
18
  from humanbound.callbacks import EngineCallbacks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humanbound
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Humanbound — open-source AI agent red-team engine, SDK, and CLI.
5
5
  Author-email: Humanbound <hello@humanbound.ai>
6
6
  Maintainer-email: Demetris Gerogiannis <hello@humanbound.ai>, Kostas Siabanis <hello@humanbound.ai>
@@ -196,9 +196,3 @@ trademark policy. The code is open; the name is not.
196
196
  The sibling project [`humanbound-firewall`](https://github.com/humanbound/humanbound-firewall)
197
197
  is dual-licensed (AGPL-3.0 + commercial) — different product, different
198
198
  license strategy.
199
-
200
- ---
201
-
202
- <p align="center">
203
- <sub><em>Humanbound is the trading name of AI and Me Single-Member Private Company, incorporated in Greece.</em></sub>
204
- </p>
@@ -60,6 +60,7 @@ humanbound_cli/connectors/microsoft.py
60
60
  humanbound_cli/engine/__init__.py
61
61
  humanbound_cli/engine/bot.py
62
62
  humanbound_cli/engine/callbacks.py
63
+ humanbound_cli/engine/compliance.py
63
64
  humanbound_cli/engine/local_runner.py
64
65
  humanbound_cli/engine/platform_runner.py
65
66
  humanbound_cli/engine/presenter.py
@@ -96,4 +97,11 @@ humanbound_cli/extractors/repo.py
96
97
  humanbound_cli/pytest_plugin/__init__.py
97
98
  humanbound_cli/pytest_plugin/fixtures.py
98
99
  humanbound_cli/pytest_plugin/report.py
99
- humanbound_cli/templates/logo.svg
100
+ humanbound_cli/templates/logo.svg
101
+ humanbound_cli/templates/report_base.html
102
+ humanbound_cli/templates/compliance/banking.yaml
103
+ humanbound_cli/templates/compliance/ecommerce.yaml
104
+ humanbound_cli/templates/compliance/eu-ai-act.yaml
105
+ humanbound_cli/templates/compliance/healthcare.yaml
106
+ humanbound_cli/templates/compliance/insurance.yaml
107
+ humanbound_cli/templates/compliance/legal.yaml
@@ -3,6 +3,8 @@
3
3
  """Connect command — unified entry point for agent and platform onboarding."""
4
4
 
5
5
  import json
6
+ import random
7
+ import threading
6
8
  import time
7
9
  from pathlib import Path
8
10
  from urllib.parse import urlparse
@@ -13,11 +15,216 @@ from rich.panel import Panel
13
15
 
14
16
  from ..client import HumanboundClient
15
17
  from ..exceptions import APIError, NotAuthenticatedError
18
+ from .test import _load_integration, _resolve_context
16
19
 
17
20
  console = Console()
18
21
 
19
22
  SCAN_TIMEOUT = 180
20
23
 
24
+ # -- Scan progress UI ----------------------------------------------------------
25
+
26
+ _SCAN_PHASES = {
27
+ "endpoint": [
28
+ "Connecting to your bot...",
29
+ "Chatting with your bot...",
30
+ "Exploring capabilities...",
31
+ "Wrapping up conversation...",
32
+ ],
33
+ "text": [
34
+ "Reading your prompt...",
35
+ ],
36
+ "agentic": [
37
+ "Analysing agent tools...",
38
+ ],
39
+ "reflect": [
40
+ "Extracting scope...",
41
+ "Classifying intents...",
42
+ "Assessing risk profile...",
43
+ "Finalising analysis...",
44
+ ],
45
+ }
46
+
47
+ _PLAYFUL_MESSAGES = [
48
+ "Kicking the tires...",
49
+ "Poking around...",
50
+ "Asking nicely...",
51
+ "Pretending to be a user...",
52
+ "Checking under the hood...",
53
+ "Shaking the tree...",
54
+ "Looking for breadcrumbs...",
55
+ "Testing the waters...",
56
+ "Playing twenty questions...",
57
+ "Seeing what sticks...",
58
+ "Connecting the dots...",
59
+ "Reading between the lines...",
60
+ "Following the clues...",
61
+ "Pulling on threads...",
62
+ "Mapping the terrain...",
63
+ "Snooping around...",
64
+ "Warming up the neurons...",
65
+ "Brewing some insights...",
66
+ ]
67
+
68
+
69
+ def _scan_with_progress(client: HumanboundClient, sources: list, timeout: int, phases: list):
70
+ """Run POST /scan with rotating status messages."""
71
+ result: dict = {}
72
+ error: Exception | None = None
73
+
74
+ def do_scan():
75
+ nonlocal result, error
76
+ try:
77
+ result = client.post(
78
+ "scan",
79
+ data={"sources": sources},
80
+ include_project=False,
81
+ timeout=timeout,
82
+ )
83
+ except Exception as e:
84
+ error = e
85
+
86
+ thread = threading.Thread(target=do_scan)
87
+ scan_start = time.time()
88
+ thread.start()
89
+
90
+ playful = _PLAYFUL_MESSAGES.copy()
91
+ random.shuffle(playful)
92
+
93
+ phase_idx = 0
94
+ playful_idx = 0
95
+ rotate_interval = 4
96
+
97
+ with console.status("") as status:
98
+ while thread.is_alive():
99
+ elapsed = time.time() - scan_start
100
+ phase = phases[phase_idx % len(phases)] if phases else "Scanning..."
101
+ fun = playful[playful_idx % len(playful)]
102
+ status.update(
103
+ f"[bold]{phase}[/bold] [dim]({elapsed:.0f}s)[/dim]\n"
104
+ f" [dim italic]{fun}[/dim italic]"
105
+ )
106
+ thread.join(timeout=rotate_interval)
107
+ playful_idx += 1
108
+ if playful_idx % 2 == 0:
109
+ phase_idx += 1
110
+
111
+ if error:
112
+ raise error
113
+
114
+ return result
115
+
116
+
117
+ def _display_scope(scope: dict):
118
+ """Render scope: business context + permitted/restricted intents."""
119
+ business_scope = scope.get("overall_business_scope", "")
120
+ intents = scope.get("intents", {})
121
+ permitted = intents.get("permitted", [])
122
+ restricted = intents.get("restricted", [])
123
+
124
+ parts = []
125
+ if business_scope:
126
+ parts.append(business_scope[:500] + ("..." if len(business_scope) > 500 else ""))
127
+
128
+ if permitted and isinstance(permitted, list):
129
+ parts.append("")
130
+ parts.append("[bold green]Permitted:[/bold green]")
131
+ for intent in permitted[:10]:
132
+ parts.append(f" [green]•[/green] {str(intent)[:80]}")
133
+ if len(permitted) > 10:
134
+ parts.append(f" [dim]... and {len(permitted) - 10} more[/dim]")
135
+
136
+ if restricted and isinstance(restricted, list):
137
+ parts.append("")
138
+ parts.append("[bold red]Restricted:[/bold red]")
139
+ for intent in restricted[:10]:
140
+ parts.append(f" [red]•[/red] {str(intent)[:80]}")
141
+ if len(restricted) > 10:
142
+ parts.append(f" [dim]... and {len(restricted) - 10} more[/dim]")
143
+
144
+ if not permitted and not restricted:
145
+ parts.append("")
146
+ parts.append("[dim]No intents extracted — the LLM may need more context.[/dim]")
147
+ parts.append("[dim]Try adding --prompt with a system prompt file for better results.[/dim]")
148
+
149
+ console.print(
150
+ Panel(
151
+ "\n".join(parts),
152
+ title="Scope",
153
+ border_style="blue",
154
+ )
155
+ )
156
+
157
+
158
+ def _risk_bar(level: str) -> str:
159
+ fill_map = {"LOW": 4, "MEDIUM": 8, "HIGH": 12}
160
+ color_map = {"LOW": "green", "MEDIUM": "yellow", "HIGH": "red"}
161
+ filled = fill_map.get(level, 8)
162
+ color = color_map.get(level, "white")
163
+ return f"[{color}]{'█' * filled}[/{color}][dim]{'░' * (12 - filled)}[/dim]"
164
+
165
+
166
+ def _display_dashboard(name: str, risk_profile: dict, has_integration: bool, has_telemetry: bool):
167
+ """Compact risk dashboard rendered after a successful Platform scan."""
168
+ risk_level = risk_profile.get("risk_level", "?")
169
+ industry = risk_profile.get("industry", "unknown")
170
+ risk_color = {"HIGH": "red", "MEDIUM": "yellow", "LOW": "green"}.get(risk_level, "white")
171
+
172
+ lines = []
173
+ lines.append(
174
+ f" Risk {_risk_bar(risk_level)} "
175
+ f"[{risk_color}][bold]{risk_level}[/bold][/{risk_color}] · {industry}"
176
+ )
177
+
178
+ pii = risk_profile.get("handles_pii", False)
179
+ fin = risk_profile.get("handles_financial_data", False)
180
+ health = risk_profile.get("handles_health_data", False)
181
+ pii_i = "[yellow]⚠ PII[/yellow]" if pii else "[dim]○ PII[/dim]"
182
+ fin_i = "[yellow]⚠ Financial[/yellow]" if fin else "[dim]○ Financial[/dim]"
183
+ hea_i = "[yellow]⚠ Health[/yellow]" if health else "[dim]○ Health[/dim]"
184
+ lines.append(f" Data {pii_i} {fin_i} {hea_i}")
185
+
186
+ integ = "[green]✓ configured[/green]" if has_integration else "[dim]✗ none[/dim]"
187
+ tele = "[green]✓ enabled[/green]" if has_telemetry else "[dim]✗ disabled[/dim]"
188
+ lines.append(f" Integ {integ} Telemetry {tele}")
189
+
190
+ regs = risk_profile.get("applicable_regulations", [])
191
+ if regs:
192
+ reg_str = " ".join(f"[cyan]{r.upper()}[/cyan]" for r in regs)
193
+ lines.append(f" Regs {reg_str}")
194
+
195
+ rationale = risk_profile.get("risk_rationale", "")
196
+ if rationale:
197
+ if len(rationale) > 120:
198
+ rationale = rationale[:117] + "..."
199
+ lines.append("")
200
+ lines.append(f" [dim italic]{rationale}[/dim italic]")
201
+
202
+ console.print(
203
+ Panel(
204
+ "\n".join(lines),
205
+ title=f"[bold]{name}[/bold]",
206
+ border_style=risk_color,
207
+ )
208
+ )
209
+
210
+
211
+ def _get_source_description(prompt: str, endpoint: str, repo: str, openapi: str) -> str:
212
+ """Short human description of which --flag sources were used."""
213
+ sources = []
214
+ if prompt:
215
+ sources.append(f"prompt ({Path(prompt).name})")
216
+ if endpoint:
217
+ path = Path(endpoint)
218
+ if path.is_file():
219
+ sources.append(f"endpoint ({path.name})")
220
+ else:
221
+ sources.append("endpoint (inline)")
222
+ if repo:
223
+ sources.append(f"repo ({Path(repo).name})")
224
+ if openapi:
225
+ sources.append(f"openapi ({Path(openapi).name})")
226
+ return ", ".join(sources)
227
+
21
228
 
22
229
  def _print_next(suggestions: list):
23
230
  """Print Next: suggestions block."""
@@ -187,29 +394,34 @@ def connect_command(
187
394
 
188
395
 
189
396
  def _connect_agent(endpoint, name, prompt, repo, openapi, context, level, yes, timeout):
190
- """Agent path: probe -> create project -> auto-test -> show results."""
191
- client = HumanboundClient()
397
+ """Agent path: dispatch to Platform flow (authenticated) or local flow (anonymous).
192
398
 
193
- if not client.is_authenticated():
194
- console.print("[red]Not authenticated.[/red] Run 'hb login' first.")
195
- raise SystemExit(1)
196
-
197
- if not client.organisation_id:
198
- console.print("[yellow]No organisation selected.[/yellow]")
199
- console.print("Use 'hb switch <id>' to select an organisation first.")
200
- raise SystemExit(1)
201
-
202
- # Count extraction sources
399
+ Platform flow creates a project on humanbound.ai with full scope, risk profile,
400
+ regulatory mapping, and auto-test. Local flow derives scope + lightweight
401
+ compliance from the user's configured LLM provider and writes scope.yaml.
402
+ """
203
403
  source_flags = [prompt, endpoint, repo, openapi]
204
404
  if not any(source_flags):
205
405
  console.print("[yellow]No extraction source provided.[/yellow]")
206
406
  console.print("Use --endpoint, --prompt, --repo, or --openapi to specify a source.")
207
407
  raise SystemExit(1)
208
408
 
209
- # Default name from hostname if not provided
210
409
  if not name:
211
- name = _derive_agent_name(endpoint)
410
+ name = _derive_agent_name(endpoint) if endpoint else "local-agent"
411
+
412
+ client = HumanboundClient()
413
+ if client.is_authenticated() and client.organisation_id:
414
+ _connect_agent_platform(
415
+ client, endpoint, name, prompt, repo, openapi, context, level, yes, timeout
416
+ )
417
+ else:
418
+ _connect_agent_local(endpoint, name, prompt, repo, openapi, context, level, yes, timeout)
419
+
212
420
 
421
+ def _connect_agent_platform(
422
+ client, endpoint, name, prompt, repo, openapi, context, level, yes, timeout
423
+ ):
424
+ """Platform flow: POST /scan -> create project -> auto-test -> dashboard."""
213
425
  console.print(f"\n[bold]Connecting agent:[/bold] {name}\n")
214
426
 
215
427
  try:
@@ -388,6 +600,144 @@ def _connect_agent(endpoint, name, prompt, repo, openapi, context, level, yes, t
388
600
  raise SystemExit(1)
389
601
 
390
602
 
603
+ # -- Agent path (local) --------------------------------------------------------
604
+
605
+
606
+ def _connect_agent_local(endpoint, name, prompt, repo, openapi, context, level, yes, timeout):
607
+ """Local flow: use the user's LLM to derive scope + lightweight compliance.
608
+
609
+ Writes ./scope.yaml in the current directory. Does not create a project,
610
+ does not call the Platform. Prints a note after completion pointing at
611
+ `hb login` for regulatory compliance + persistence + team features.
612
+ """
613
+ from ..engine.llm import get_llm_pinger
614
+ from ..engine.local_runner import _resolve_provider
615
+ from ..engine.scope import resolve as resolve_scope
616
+
617
+ console.print("\n[dim](not authenticated — running local scope extraction)[/dim]")
618
+ console.print(f"[bold]Connecting agent:[/bold] {name}\n")
619
+
620
+ try:
621
+ provider = _resolve_provider()
622
+ llm = get_llm_pinger(provider)
623
+ except ValueError as e:
624
+ console.print(f"[red]{e}[/red]")
625
+ raise SystemExit(1)
626
+
627
+ integration = None
628
+ if endpoint:
629
+ try:
630
+ integration = _load_integration(endpoint)
631
+ chat_ep = integration.get("chat_completion", {}).get("endpoint", "")
632
+ console.print(
633
+ f" [green]✓[/green] Endpoint source: [dim]{chat_ep or '(from config)'}[/dim]"
634
+ )
635
+ except Exception as e:
636
+ console.print(f" [yellow]![/yellow] Endpoint could not be loaded: {e}")
637
+
638
+ if repo:
639
+ console.print(f" [green]✓[/green] Scanning repository: [dim]{repo}[/dim]")
640
+ if prompt:
641
+ console.print(f" [green]✓[/green] Loading prompt: [dim]{Path(prompt).name}[/dim]")
642
+ if openapi:
643
+ console.print(f" [green]✓[/green] Parsing OpenAPI: [dim]{Path(openapi).name}[/dim]")
644
+
645
+ console.print()
646
+
647
+ with console.status("[dim]Extracting scope via local LLM...[/dim]"):
648
+ try:
649
+ scope = resolve_scope(
650
+ repo_path=repo,
651
+ prompt_path=prompt,
652
+ scope_path=None,
653
+ integration=integration,
654
+ llm_pinger=llm,
655
+ )
656
+ except Exception as e:
657
+ console.print(f"[red]Scope extraction failed:[/red] {e}")
658
+ raise SystemExit(1)
659
+
660
+ # Lightweight compliance overlay — detect domain, apply template + EU AI Act
661
+ from ..engine.compliance import (
662
+ apply_eu_ai_act_only,
663
+ apply_template,
664
+ detect_domain,
665
+ domain_label,
666
+ )
667
+
668
+ domain = detect_domain(scope)
669
+ if domain:
670
+ console.print(f" [green]✓[/green] Detected domain: [bold]{domain_label(domain)}[/bold]")
671
+ scope = apply_template(scope, domain, include_eu_ai_act=True)
672
+ console.print(
673
+ " [green]✓[/green] Applied compliance overlay ([dim]domain template + EU AI Act[/dim])"
674
+ )
675
+ else:
676
+ scope = apply_eu_ai_act_only(scope)
677
+ console.print(
678
+ " [dim]No domain-specific template matched — EU AI Act overlay applied.[/dim]"
679
+ )
680
+
681
+ console.print()
682
+ _display_scope(scope)
683
+
684
+ output_path = Path.cwd() / "scope.yaml"
685
+ try:
686
+ _write_scope_yaml(scope, output_path)
687
+ console.print(f"\n [green]✓[/green] Wrote [bold]{output_path.name}[/bold]")
688
+ except Exception as e:
689
+ console.print(f"\n [yellow]Could not write scope.yaml:[/yellow] {e}")
690
+
691
+ _print_platform_note()
692
+
693
+ _print_next(
694
+ [
695
+ (f"hb test --scope {output_path.name}", "Run a security test with this scope"),
696
+ ("hb login", "Sign in for regulatory compliance + team features"),
697
+ ]
698
+ )
699
+
700
+
701
+ def _write_scope_yaml(scope: dict, path: Path):
702
+ """Serialize scope dict to a .yaml file in the canonical template shape."""
703
+ try:
704
+ import yaml
705
+ except ImportError:
706
+ path.write_text(json.dumps(scope, indent=2))
707
+ return
708
+
709
+ intents = scope.get("intents", {})
710
+ document = {
711
+ "business_scope": scope.get("overall_business_scope", ""),
712
+ "permitted": intents.get("permitted", []),
713
+ "restricted": intents.get("restricted", []),
714
+ "more_info": scope.get("more_info", ""),
715
+ }
716
+ path.write_text(yaml.safe_dump(document, sort_keys=False, default_flow_style=False))
717
+
718
+
719
+ def _print_platform_note():
720
+ """Note shown after local scope extraction summarising the Platform features.
721
+
722
+ Kept factual and community-neutral — this is an OSS CLI, not a sales page.
723
+ """
724
+ lines = [
725
+ "Scope & policies saved locally.",
726
+ "",
727
+ "Regulatory mapping (FCA, EU AI Act, HIPAA, IDD, CRA 2015), threat",
728
+ "prioritisation with citations, and persistent project history are",
729
+ "available when signed in with [bold]hb login[/bold].",
730
+ ]
731
+ console.print()
732
+ console.print(
733
+ Panel(
734
+ "\n".join(lines),
735
+ title="[bold]Local result[/bold]",
736
+ border_style="cyan",
737
+ )
738
+ )
739
+
740
+
391
741
  # -- Platform path -------------------------------------------------------------
392
742
 
393
743
 
@@ -669,8 +1019,7 @@ def _auto_test(client, project_id, default_integration, context=None, level="uni
669
1019
  # Build configuration with integration + optional context
670
1020
  configuration = {"integration": default_integration}
671
1021
  if context:
672
- ctx_path = Path(context)
673
- ctx_value = ctx_path.read_text().strip() if ctx_path.is_file() else context
1022
+ ctx_value = _resolve_context(context)
674
1023
  if len(ctx_value) > 1500:
675
1024
  console.print(
676
1025
  f"[red]Context too long ({len(ctx_value)} chars). Maximum is 1,500.[/red]"
@@ -128,6 +128,26 @@ def _load_integration(value: str) -> dict:
128
128
  raise SystemExit(1)
129
129
 
130
130
 
131
+ def _resolve_context(value: str) -> str:
132
+ """Return the literal --context string, or the file contents if value is a path.
133
+
134
+ Wraps Path.is_file() in a try/except because stat() raises OSError with
135
+ errno ENAMETOOLONG (63) on macOS / ENAMETOOLONG on Linux when the argument
136
+ is a long string (e.g. a multi-line prompt passed inline). Before this
137
+ helper existed, `hb test --context "<long literal>"` crashed at the
138
+ Path(value).is_file() call.
139
+ """
140
+ if not value:
141
+ return ""
142
+ try:
143
+ path = Path(value)
144
+ if path.is_file():
145
+ return path.read_text().strip()
146
+ except OSError:
147
+ pass
148
+ return value
149
+
150
+
131
151
  def _print_next(suggestions: list):
132
152
  """Print Next: suggestions block."""
133
153
  console.print("\n[dim]Next:[/dim]")
@@ -303,7 +323,7 @@ def test_command(
303
323
  category_short = test_category.split("/")[-1]
304
324
  name = f"cli-{category_short}-{timestamp}"
305
325
 
306
- # --- Runner selection (login is the only switch) ---
326
+ # --- Runner selection (login + project is the switch) ---
307
327
  try:
308
328
  runner = get_runner(force_local=local)
309
329
  except Exception:
@@ -323,6 +343,33 @@ def test_command(
323
343
  console.print("[red]Not authenticated.[/red] Run 'hb login' first.")
324
344
  console.print("[dim]Local engine coming soon in the open-core release.[/dim]")
325
345
  raise SystemExit(1)
346
+ elif not local:
347
+ # Runner fell to LocalTestRunner without the user asking for it. The only
348
+ # way that happens after the auth check above is "signed in but no
349
+ # project selected." Without this guard the user gets a misleading
350
+ # "No LLM provider configured" error from LocalRunner, when what they
351
+ # actually need is to select a project.
352
+ from ..client import HumanboundClient
353
+
354
+ _c = HumanboundClient()
355
+ if _c.is_authenticated():
356
+ console.print(
357
+ "[yellow]You're signed in, but no project is selected for this test.[/yellow]"
358
+ )
359
+ console.print()
360
+ console.print(" Choose one:")
361
+ console.print(" [bold]hb projects list[/bold] see your projects")
362
+ console.print(" [bold]hb projects use <id>[/bold] use an existing project")
363
+ console.print(
364
+ " [bold]hb connect --endpoint X[/bold] create a new project "
365
+ "from an agent config"
366
+ )
367
+ console.print(
368
+ " [bold]hb test --local ...[/bold] run against a local LLM "
369
+ "(requires HB_PROVIDER + HB_API_KEY)"
370
+ )
371
+ console.print()
372
+ raise SystemExit(1)
326
373
 
327
374
  console.print(f"\n[bold]Starting security test:[/bold] {name}\n")
328
375
  console.print(f" Category: {test_category}")
@@ -366,15 +413,12 @@ def test_command(
366
413
  console.print(" Depth: [yellow]blackbox[/yellow]")
367
414
 
368
415
  # Context: string or path to .txt file (max 1500 chars)
369
- ctx_value = ""
370
- if context:
371
- ctx_path = Path(context)
372
- ctx_value = ctx_path.read_text().strip() if ctx_path.is_file() else context
373
- if len(ctx_value) > 1500:
374
- console.print(
375
- f"[red]Context too long ({len(ctx_value)} chars). Maximum is 1,500.[/red]"
376
- )
377
- raise SystemExit(1)
416
+ ctx_value = _resolve_context(context) if context else ""
417
+ if ctx_value and len(ctx_value) > 1500:
418
+ console.print(
419
+ f"[red]Context too long ({len(ctx_value)} chars). Maximum is 1,500.[/red]"
420
+ )
421
+ raise SystemExit(1)
378
422
 
379
423
  # Build TestConfig (canonical shape — same for both runners)
380
424
  config = TestConfig(