hound-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ """Launch account-local Hound engagements and retests."""
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ import click
9
+ import questionary
10
+ from botocore.exceptions import BotoCoreError, ClientError
11
+
12
+ from hound_cli.aws import (
13
+ _aws_account_id,
14
+ active_domain_work,
15
+ aws_client,
16
+ get_engagement_records,
17
+ get_workflow_record,
18
+ operator_only,
19
+ scan_workflow_records,
20
+ short_task_id,
21
+ sqs_queue_url,
22
+ )
23
+ from hound_cli.contracts.workflow import (
24
+ ACTIVE_INFRA_STATUSES,
25
+ ACTIVE_WORKFLOW_STATUSES,
26
+ ENGAGEMENT_RELAUNCH_STATUSES,
27
+ RETEST_LAUNCH_STATUSES,
28
+ SKIP_DNS_CHECK_FIELD,
29
+ RuntimeConfig,
30
+ runtime_config_from_record,
31
+ runtime_launch_value,
32
+ )
33
+ from hound_cli.targets.configure import prepare_target_access
34
+ from hound_cli.targets.verification import require_launch_authorization
35
+ from hound_cli.ui import MENU_STYLE
36
+ from hound_cli.workflows.selection import select_record, selected_domain
37
+
38
+ CAPABILITY_PARAMETERS = {
39
+ "relay": "/hound/capabilities/relay",
40
+ "residential_proxy": "/hound/capabilities/residential-proxy",
41
+ }
42
+ LAUNCH_RECEIPT_TIMEOUT_SECONDS = 60
43
+ LAUNCH_RECEIPT_POLL_SECONDS = 1
44
+ SKIP_DNS_CHECK_HELP = "Skip live DNS authorization checks after confirming authorization out of band."
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class EligibleEip:
49
+ """An unassigned persistent Hound Elastic IP."""
50
+
51
+ allocation_id: str
52
+ public_ip: str
53
+ name: str | None
54
+
55
+ @property
56
+ def title(self) -> str:
57
+ suffix = f" — {self.name}" if self.name else ""
58
+ return f"{self.public_ip} ({self.allocation_id}){suffix}"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class RuntimeSelection:
63
+ """One launch runtime plus its operator-facing EIP label."""
64
+
65
+ runtime: RuntimeConfig
66
+ eip_label: str
67
+
68
+
69
+ def _select_with_default(message: str, choices: list[questionary.Choice], default: object) -> object:
70
+ """Start on the default while styling only the moving cursor as selected."""
71
+ default_choice = next((choice for choice in choices if choice.value == default), None)
72
+ if default_choice is None:
73
+ raise ValueError(f"Invalid default selection: {default}")
74
+ ordered_choices = [default_choice, *(choice for choice in choices if choice is not default_choice)]
75
+ return questionary.select(message, choices=ordered_choices, style=MENU_STYLE).unsafe_ask()
76
+
77
+
78
+ def _record_status(record: dict) -> str:
79
+ statuses = [
80
+ f"{attr.removesuffix('_status')}={record[attr]['S']}" for attr in ACTIVE_WORKFLOW_STATUSES if attr in record
81
+ ]
82
+ return ", ".join(statuses) or "?"
83
+
84
+
85
+ def _active_domain_precheck(ddb, domain: str) -> None:
86
+ active = active_domain_work(ddb, domain)
87
+ if not active:
88
+ return
89
+
90
+ status_summary = ", ".join(
91
+ (
92
+ f"{short_task_id(record['task_id']['S'])} "
93
+ f"({_record_status(record)}/{record.get('infra_status', {}).get('S', '?')})"
94
+ )
95
+ for record in sorted(active, key=lambda record: record["task_id"]["S"], reverse=True)
96
+ )
97
+ raise click.ClickException(f"Domain {domain} already has active Hound work: {status_summary}")
98
+
99
+
100
+ def _capabilities() -> dict[str, bool]:
101
+ """Read both exact account capability parameters in one request."""
102
+ names = list(CAPABILITY_PARAMETERS.values())
103
+ try:
104
+ response = aws_client("ssm").get_parameters(Names=names, WithDecryption=False)
105
+ except (BotoCoreError, ClientError) as exc:
106
+ raise click.ClickException("Could not read account runtime capabilities.") from exc
107
+
108
+ returned = {parameter.get("Name"): parameter for parameter in response.get("Parameters", [])}
109
+ invalid = response.get("InvalidParameters", [])
110
+ if invalid or set(returned) != set(names):
111
+ missing = sorted(set(names) - set(returned) | set(invalid))
112
+ raise click.ClickException(f"Missing account runtime capability parameter(s): {', '.join(missing)}")
113
+
114
+ capabilities = {}
115
+ for capability, name in CAPABILITY_PARAMETERS.items():
116
+ parameter = returned[name]
117
+ if parameter.get("Type") != "String" or parameter.get("Value") not in {"enabled", "disabled"}:
118
+ raise click.ClickException(
119
+ f"Invalid account runtime capability {name}: expected a String with value enabled or disabled"
120
+ )
121
+ capabilities[capability] = parameter["Value"] == "enabled"
122
+ return capabilities
123
+
124
+
125
+ def _eligible_eips() -> list[EligibleEip]:
126
+ """Discover unassigned persistent Hound EIPs directly from EC2."""
127
+ try:
128
+ response = aws_client("ec2").describe_addresses(Filters=[{"Name": "tag:app", "Values": ["hound"]}])
129
+ except (BotoCoreError, ClientError) as exc:
130
+ raise click.ClickException("Could not discover persistent Hound Elastic IPs.") from exc
131
+
132
+ eligible = []
133
+ for address in response.get("Addresses", []):
134
+ tags = {tag.get("Key"): tag.get("Value") for tag in address.get("Tags", []) if isinstance(tag.get("Key"), str)}
135
+ allocation_id = address.get("AllocationId")
136
+ public_ip = address.get("PublicIp")
137
+ if tags.get("app") != "hound" or "task_id" in tags:
138
+ continue
139
+ if isinstance(allocation_id, str) and isinstance(public_ip, str):
140
+ eligible.append(EligibleEip(allocation_id, public_ip, tags.get("Name")))
141
+ return sorted(eligible, key=lambda address: (address.name or "", address.public_ip, address.allocation_id))
142
+
143
+
144
+ @click.command()
145
+ @operator_only
146
+ def ips():
147
+ """List persistent Hound Elastic IPs."""
148
+ addresses = _eligible_eips()
149
+ if not addresses:
150
+ click.echo("No persistent Hound Elastic IPs.")
151
+ return
152
+ for address in addresses:
153
+ click.echo(address.title)
154
+
155
+
156
+ def _runtime_selection(
157
+ capabilities: dict[str, bool],
158
+ eips: list[EligibleEip],
159
+ current: RuntimeConfig | None = None,
160
+ ) -> RuntimeSelection:
161
+ """Prompt for one complete runtime map, preselecting current values."""
162
+ current_eip = current.eip_allocation_id if current else None
163
+ choices = [questionary.Choice("Dynamically assigned per-run IP", value="__dynamic__")]
164
+ choices.extend(questionary.Choice(address.title, value=address.allocation_id) for address in eips)
165
+ available_ids = {address.allocation_id for address in eips}
166
+ if current_eip is not None and current_eip not in available_ids:
167
+ raise click.ClickException(
168
+ f"Current persistent EIP {current_eip} is no longer eligible. Clean it up or choose a launchable record."
169
+ )
170
+
171
+ selected_eip = _select_with_default("Cloud IP", choices, current_eip or "__dynamic__")
172
+ if selected_eip is None:
173
+ raise click.Abort()
174
+
175
+ if capabilities["residential_proxy"]:
176
+ residential_proxy_enabled = questionary.confirm(
177
+ "Use the account residential proxy?",
178
+ default=None,
179
+ instruction="(y/n)",
180
+ ).unsafe_ask()
181
+ if residential_proxy_enabled is None:
182
+ raise click.Abort()
183
+ else:
184
+ residential_proxy_enabled = False
185
+
186
+ if capabilities["relay"]:
187
+ relay_enabled = questionary.confirm(
188
+ "Allow Hound to request operator assistance for CAPTCHAs or similar verification challenges?",
189
+ default=None,
190
+ instruction="(y/n)",
191
+ ).unsafe_ask()
192
+ if relay_enabled is None:
193
+ raise click.Abort()
194
+ else:
195
+ relay_enabled = False
196
+
197
+ allocation_id = None if selected_eip == "__dynamic__" else selected_eip
198
+ selected = next((address for address in eips if address.allocation_id == allocation_id), None)
199
+ return RuntimeSelection(
200
+ runtime=RuntimeConfig(
201
+ eip_allocation_id=allocation_id,
202
+ residential_proxy_enabled=residential_proxy_enabled,
203
+ relay_enabled=relay_enabled,
204
+ ),
205
+ eip_label=selected.title if selected else "Dynamically assigned per-run IP",
206
+ )
207
+
208
+
209
+ def _record_is_active(record: dict) -> bool:
210
+ if record.get("infra_status", {}).get("S") in ACTIVE_INFRA_STATUSES:
211
+ return True
212
+ return any(
213
+ record.get(attribute, {}).get("S") in statuses for attribute, statuses in ACTIVE_WORKFLOW_STATUSES.items()
214
+ )
215
+
216
+
217
+ def _same_eip_users(ddb, domain: str, task_id: str, allocation_id: str | None) -> list[dict]:
218
+ """Find other active records using a selected persistent EIP."""
219
+ if allocation_id is None:
220
+ return []
221
+
222
+ users = []
223
+ for record in scan_workflow_records(ddb):
224
+ if (
225
+ record.get("domain", {}).get("S") == domain and record.get("task_id", {}).get("S") == task_id
226
+ ) or not _record_is_active(record):
227
+ continue
228
+ try:
229
+ runtime = runtime_config_from_record(record)
230
+ except ValueError:
231
+ continue
232
+ if runtime.eip_allocation_id == allocation_id:
233
+ users.append(record)
234
+ return sorted(users, key=lambda record: record["task_id"]["S"], reverse=True)
235
+
236
+
237
+ def _review_launch(
238
+ *,
239
+ domain: str,
240
+ workflow: str,
241
+ task_id: str,
242
+ selection: RuntimeSelection,
243
+ same_eip_users: list[dict],
244
+ authorization: str,
245
+ workflow_status: str | None = None,
246
+ ) -> None:
247
+ click.echo("\nLaunch review")
248
+ click.echo(f" Domain: {domain}")
249
+ click.echo(f" Workflow: {workflow}")
250
+ click.echo(f" Task ID: {task_id}")
251
+ if workflow == "retest":
252
+ click.echo(f" Completed engagement: {task_id}")
253
+ if workflow_status is not None:
254
+ click.echo(f" Current status: {workflow_status}")
255
+ click.echo(f" Cloud IP: {selection.eip_label}")
256
+ click.echo(f" Authorization: {authorization}")
257
+ click.echo(f" Residential proxy: {'enabled' if selection.runtime.residential_proxy_enabled else 'disabled'}")
258
+ click.echo(f" Operator assistance: {'enabled' if selection.runtime.relay_enabled else 'disabled'}")
259
+ if same_eip_users:
260
+ click.echo("\n Warning: the selected persistent EIP also appears on active work:")
261
+ for record in same_eip_users:
262
+ click.echo(
263
+ f" {record['domain']['S']} — {short_task_id(record['task_id']['S'])} "
264
+ f"({_record_status(record)}/{record.get('infra_status', {}).get('S', '?')})"
265
+ )
266
+
267
+ click.confirm("\nQueue launch?", default=None, abort=True)
268
+
269
+
270
+ def _queue_launch(
271
+ queue_name: str,
272
+ domain: str,
273
+ task_id: str,
274
+ runtime: RuntimeConfig,
275
+ *,
276
+ skip_dns_check: bool,
277
+ ) -> None:
278
+ message: dict[str, object] = {
279
+ "domain": domain,
280
+ "task_id": task_id,
281
+ "runtime": runtime_launch_value(runtime),
282
+ }
283
+ if skip_dns_check:
284
+ message[SKIP_DNS_CHECK_FIELD] = True
285
+ aws_client("sqs").send_message(
286
+ QueueUrl=sqs_queue_url(queue_name),
287
+ MessageBody=json.dumps(message),
288
+ )
289
+
290
+
291
+ def _wait_for_launch_record(
292
+ ddb,
293
+ domain: str,
294
+ task_id: str,
295
+ workflow: str,
296
+ runtime: RuntimeConfig,
297
+ ) -> dict:
298
+ """Wait until the provisioner claim and runtime replacement are observable."""
299
+ status_attr = f"{workflow}_status"
300
+ deadline = time.monotonic() + LAUNCH_RECEIPT_TIMEOUT_SECONDS
301
+ while time.monotonic() < deadline:
302
+ record = get_workflow_record(ddb, domain, task_id)
303
+ if record is not None:
304
+ try:
305
+ observed_runtime = runtime_config_from_record(record)
306
+ except ValueError:
307
+ observed_runtime = None
308
+ status = record.get(status_attr, {}).get("S")
309
+ infra_status = record.get("infra_status", {}).get("S")
310
+ if observed_runtime == runtime and (status in {"queued", "running"} or infra_status != "absent"):
311
+ return record
312
+ time.sleep(LAUNCH_RECEIPT_POLL_SECONDS)
313
+ raise click.ClickException(
314
+ "Launch request was sent, but its workflow record was not observable within 60 seconds. "
315
+ f"Check with: hound status --domain {domain} --task-id {task_id}"
316
+ )
317
+
318
+
319
+ def _show_receipt(domain: str, workflow: str, task_id: str, selection: RuntimeSelection) -> None:
320
+ noun = "engagements" if workflow == "engagement" else "retests"
321
+ click.echo("\nLaunch accepted")
322
+ click.echo(f" AWS account: {_aws_account_id()}")
323
+ click.echo(f" Domain: {domain}")
324
+ click.echo(f" Workflow: {workflow}")
325
+ click.echo(f" Task ID: {task_id}")
326
+ click.echo(f" Cloud IP: {selection.eip_label}")
327
+ click.echo(f" Residential proxy: {'enabled' if selection.runtime.residential_proxy_enabled else 'disabled'}")
328
+ click.echo(f" Operator assistance: {'enabled' if selection.runtime.relay_enabled else 'disabled'}")
329
+ click.echo("\nFollow-up")
330
+ click.echo(f" hound status --domain {domain} --task-id {task_id}")
331
+ click.echo(f" hound {noun} logs --domain {domain} --task-id {task_id}")
332
+ click.echo(f" hound {noun} stop --domain {domain} --task-id {task_id}")
333
+
334
+
335
+ def _select_engagement_launch(records: list[dict]) -> dict | None:
336
+ """Select a failed/cancelled engagement for relaunch, or create a new one."""
337
+ if not records:
338
+ return None
339
+
340
+ records = sorted(records, key=lambda record: record["task_id"]["S"], reverse=True)
341
+ choices = [questionary.Choice("Create a new engagement", value="__new__")]
342
+ choices.extend(
343
+ questionary.Choice(
344
+ f"Relaunch {record['engagement_status']['S']} — {short_task_id(record['task_id']['S'])}",
345
+ value=record,
346
+ )
347
+ for record in records
348
+ )
349
+ selected = _select_with_default("Engagement", choices, "__new__")
350
+ if selected is None:
351
+ raise click.Abort()
352
+ return None if selected == "__new__" else selected
353
+
354
+
355
+ def _launch(
356
+ *,
357
+ workflow: str,
358
+ domain_option: str | None,
359
+ skip_dns_check: bool,
360
+ ) -> None:
361
+ domain = selected_domain(domain_option)
362
+ ddb = aws_client("dynamodb")
363
+ _active_domain_precheck(ddb, domain)
364
+ current = None
365
+ current_status = None
366
+ if workflow == "engagement":
367
+ record = _select_engagement_launch(get_engagement_records(ddb, domain, status=ENGAGEMENT_RELAUNCH_STATUSES))
368
+ if record is None:
369
+ task_id = str(uuid.uuid7())
370
+ else:
371
+ task_id = record["task_id"]["S"]
372
+ try:
373
+ current = runtime_config_from_record(record)
374
+ except ValueError as exc:
375
+ raise click.ClickException(f"Engagement record has invalid runtime: {exc}") from exc
376
+ current_status = record["engagement_status"]["S"]
377
+ else:
378
+ records = [
379
+ record
380
+ for record in get_engagement_records(ddb, domain, status="completed")
381
+ if record.get("infra_status", {}).get("S") == "absent"
382
+ and record.get("retest_status", {}).get("S") in (None, *RETEST_LAUNCH_STATUSES)
383
+ ]
384
+ if not records:
385
+ raise click.ClickException(f"No retestable completed engagements for {domain}")
386
+ record = select_record(records, label="retest", status_attr="retest_status")
387
+ task_id = record["task_id"]["S"]
388
+ current_status = record.get("retest_status", {}).get("S", "not started")
389
+ try:
390
+ current = runtime_config_from_record(record)
391
+ except ValueError as exc:
392
+ raise click.ClickException(f"Retest record has invalid runtime: {exc}") from exc
393
+
394
+ prepare_target_access(domain)
395
+ authorization = require_launch_authorization(domain, skip_dns_check=skip_dns_check)
396
+ capabilities = _capabilities()
397
+ eips = _eligible_eips()
398
+ selection = _runtime_selection(capabilities, eips, current=current)
399
+ eip_users = _same_eip_users(ddb, domain, task_id, selection.runtime.eip_allocation_id)
400
+ _review_launch(
401
+ domain=domain,
402
+ workflow=workflow,
403
+ task_id=task_id,
404
+ selection=selection,
405
+ same_eip_users=eip_users,
406
+ authorization=authorization,
407
+ workflow_status=current_status,
408
+ )
409
+
410
+ queue_name = f"hound-{workflow}-provisioner"
411
+ _queue_launch(
412
+ queue_name,
413
+ domain,
414
+ task_id,
415
+ selection.runtime,
416
+ skip_dns_check=skip_dns_check,
417
+ )
418
+ _wait_for_launch_record(ddb, domain, task_id, workflow, selection.runtime)
419
+ _show_receipt(domain, workflow, task_id, selection)
420
+
421
+
422
+ def _launch_options(command):
423
+ command = click.option("--domain", help="Use this exact target domain.")(command)
424
+ return click.option(
425
+ "--skip-dns-check",
426
+ is_flag=True,
427
+ help=SKIP_DNS_CHECK_HELP,
428
+ )(command)
429
+
430
+
431
+ @click.command(name="launch")
432
+ @_launch_options
433
+ @operator_only
434
+ def engagement_launch(domain: str | None, skip_dns_check: bool):
435
+ """Start a new penetration test or relaunch a previous one."""
436
+ _launch(workflow="engagement", domain_option=domain, skip_dns_check=skip_dns_check)
437
+
438
+
439
+ @click.command(name="launch")
440
+ @_launch_options
441
+ @operator_only
442
+ def retest_launch(domain: str | None, skip_dns_check: bool):
443
+ """Start or relaunch a retest for a completed penetration test."""
444
+ _launch(workflow="retest", domain_option=domain, skip_dns_check=skip_dns_check)
@@ -0,0 +1,197 @@
1
+ """View workflow log streams.
2
+
3
+ Shows the full log stream — thinking, subagent spawns, and tool calls.
4
+ Use --tail to keep polling for new events.
5
+
6
+ Usage:
7
+ hound engagements logs
8
+ hound engagements logs --tail
9
+ hound retests logs
10
+ hound retests logs --tail
11
+ """
12
+
13
+ import json
14
+ import signal
15
+ import sys
16
+ import time
17
+
18
+ import click
19
+ from botocore.exceptions import ClientError
20
+
21
+ from hound_cli.aws import (
22
+ aws_client,
23
+ get_engagement_records,
24
+ get_retest_records,
25
+ get_workflow_record,
26
+ operator_only,
27
+ )
28
+ from hound_cli.workflows.selection import canonical_task_id, select_record, selected_domain
29
+
30
+ STATUSES_WITH_LOGS = ["running", "completed", "failed", "cancelled"]
31
+
32
+ POLL_INTERVAL_SECONDS = 5
33
+
34
+
35
+ def _log_group(task_id: str, workflow: str) -> str:
36
+ """Derive CloudWatch log group from task ID and workflow."""
37
+ return f"/ecs/hound-task-{task_id[-12:]}-{workflow}"
38
+
39
+
40
+ def _extract_message(raw: str) -> str | None:
41
+ """Extract the message field from a JSON log line, or return raw text."""
42
+ try:
43
+ return json.loads(raw).get("message")
44
+ except json.JSONDecodeError, AttributeError:
45
+ return raw
46
+
47
+
48
+ def _print_events(events: list[dict]) -> int:
49
+ """Print log events, return count printed."""
50
+ count = 0
51
+ for event in events:
52
+ message = _extract_message(event["message"])
53
+ if message:
54
+ click.echo(message.strip())
55
+ click.echo()
56
+ count += 1
57
+ return count
58
+
59
+
60
+ def _event_identity(event: dict) -> str:
61
+ """Return the stable CloudWatch identity used to suppress tail overlap."""
62
+ event_id = event.get("eventId")
63
+ if isinstance(event_id, str):
64
+ return event_id
65
+ return json.dumps(
66
+ {
67
+ "timestamp": event.get("timestamp"),
68
+ "ingestionTime": event.get("ingestionTime"),
69
+ "logStreamName": event.get("logStreamName"),
70
+ "message": event.get("message"),
71
+ },
72
+ sort_keys=True,
73
+ )
74
+
75
+
76
+ def _print_logs(logs, log_group: str, tail: bool) -> None:
77
+ """Consume every FilterLogEvents page, then poll from the latest timestamp."""
78
+ base_kwargs = {"logGroupName": log_group}
79
+ kwargs = base_kwargs.copy()
80
+ count = 0
81
+ cursor_timestamp: int | None = None
82
+ cursor_event_ids: set[str] = set()
83
+
84
+ while True:
85
+ resp = logs.filter_log_events(**kwargs)
86
+ events = resp.get("events", [])
87
+ unseen_events = []
88
+ for event in events:
89
+ timestamp = event["timestamp"]
90
+ if cursor_timestamp is None or timestamp > cursor_timestamp:
91
+ cursor_timestamp = timestamp
92
+ cursor_event_ids.clear()
93
+ elif timestamp < cursor_timestamp:
94
+ continue
95
+
96
+ event_id = _event_identity(event)
97
+ if event_id in cursor_event_ids:
98
+ continue
99
+ cursor_event_ids.add(event_id)
100
+ unseen_events.append(event)
101
+
102
+ count += _print_events(unseen_events)
103
+
104
+ # FilterLogEvents pages may be empty even when more pages contain
105
+ # events. Consume nextToken until the service says pagination is done.
106
+ request_token = kwargs.get("nextToken")
107
+ next_token = resp.get("nextToken")
108
+ if next_token and next_token != request_token:
109
+ kwargs["nextToken"] = next_token
110
+ continue
111
+
112
+ if not tail:
113
+ break
114
+
115
+ time.sleep(POLL_INTERVAL_SECONDS)
116
+ kwargs = base_kwargs.copy()
117
+ if cursor_timestamp is not None:
118
+ # startTime is inclusive. Re-read the cursor millisecond and
119
+ # suppress only the event IDs already printed at that timestamp,
120
+ # so late events sharing the timestamp cannot be skipped.
121
+ kwargs["startTime"] = cursor_timestamp
122
+
123
+ if count == 0:
124
+ click.echo(" No matching log entries found.")
125
+
126
+
127
+ def _show_logs(
128
+ get_records,
129
+ status_attr: str,
130
+ label: str,
131
+ workflow: str,
132
+ tail: bool,
133
+ domain_option: str | None,
134
+ task_id: str | None,
135
+ ) -> None:
136
+ """Prompt for a workflow record and print its log stream."""
137
+ if task_id is not None and domain_option is None:
138
+ raise click.UsageError("--task-id requires --domain")
139
+ domain = selected_domain(domain_option)
140
+
141
+ ddb = aws_client("dynamodb")
142
+ if task_id is not None:
143
+ task_id = canonical_task_id(task_id)
144
+ exact = get_workflow_record(ddb, domain, task_id)
145
+ records = [exact] if exact and exact.get(status_attr, {}).get("S") in STATUSES_WITH_LOGS else []
146
+ else:
147
+ records = get_records(ddb, domain, status=STATUSES_WITH_LOGS)
148
+ if not records:
149
+ raise click.ClickException(f"No {label}s with logs for {domain}")
150
+
151
+ record = select_record(records, label=label, status_attr=status_attr, task_id=task_id)
152
+ task_id = record["task_id"]["S"]
153
+ status = record[status_attr]["S"]
154
+ log_group = _log_group(task_id, workflow)
155
+
156
+ click.echo(f"\n Status: {status}")
157
+ click.echo(f" Task ID: {task_id}")
158
+ if tail:
159
+ signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
160
+ click.echo(" Ctrl+C to stop")
161
+ click.echo()
162
+
163
+ cw = aws_client("logs")
164
+ try:
165
+ _print_logs(cw, log_group, tail)
166
+ except ClientError as e:
167
+ if e.response["Error"]["Code"] == "ResourceNotFoundException":
168
+ raise click.ClickException("Log group not found — may have expired.") from None
169
+ raise
170
+
171
+
172
+ @click.command(name="logs")
173
+ @click.option("--tail", "-t", is_flag=True, help="Keep polling for new events.")
174
+ @click.option("--domain", help="Use this exact target domain.")
175
+ @click.option("--task-id", help="Use one exact task; requires --domain.")
176
+ @operator_only
177
+ def engagement_logs(tail: bool, domain: str | None, task_id: str | None):
178
+ """View the log stream for an engagement."""
179
+ _show_logs(
180
+ get_engagement_records,
181
+ "engagement_status",
182
+ "engagement",
183
+ "engagement",
184
+ tail,
185
+ domain,
186
+ task_id,
187
+ )
188
+
189
+
190
+ @click.command(name="logs")
191
+ @click.option("--tail", "-t", is_flag=True, help="Keep polling for new events.")
192
+ @click.option("--domain", help="Use this exact target domain.")
193
+ @click.option("--task-id", help="Use one exact task; requires --domain.")
194
+ @operator_only
195
+ def retest_logs(tail: bool, domain: str | None, task_id: str | None):
196
+ """View the log stream for a retest."""
197
+ _show_logs(get_retest_records, "retest_status", "retest", "retest", tail, domain, task_id)