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,115 @@
1
+ """Upload reviewed task artifacts to S3 and securely wipe local copies.
2
+
3
+ Usage:
4
+ hound reports close
5
+ """
6
+
7
+ import contextlib
8
+ import mimetypes
9
+ import os
10
+ import secrets as secrets_module
11
+ from pathlib import Path
12
+
13
+ import click
14
+ from boto3.exceptions import S3UploadFailedError
15
+ from botocore.exceptions import BotoCoreError, ClientError
16
+
17
+ from hound_cli.aws import report_reviewer_only, report_s3_client, short_task_id
18
+ from hound_cli.contracts.domain import domain_id
19
+ from hound_cli.contracts.workflow import artifacts_bucket_name
20
+ from hound_cli.reports.reviews import select_open_review
21
+
22
+ LOCAL_REVIEW_STATE = (".git", ".hound-local")
23
+
24
+
25
+ def _secure_wipe_file(path: Path) -> None:
26
+ """Overwrite a file with random bytes before deleting.
27
+
28
+ Best-effort on SSDs — wear leveling means old data may persist in
29
+ flash cells. Full-disk encryption is the real protection.
30
+ """
31
+ try:
32
+ size = path.stat().st_size
33
+ if size > 0:
34
+ with open(path, "r+b") as f:
35
+ remaining = size
36
+ while remaining:
37
+ chunk_size = min(remaining, 1024 * 1024)
38
+ f.write(secrets_module.token_bytes(chunk_size))
39
+ remaining -= chunk_size
40
+ f.flush()
41
+ os.fsync(f.fileno())
42
+ path.unlink()
43
+ except OSError:
44
+ path.unlink(missing_ok=True)
45
+
46
+
47
+ def _secure_wipe_directory(directory: Path) -> None:
48
+ """Recursively overwrite and delete all files, then remove directories."""
49
+ if directory.is_symlink():
50
+ directory.unlink()
51
+ return
52
+ paths = sorted(directory.rglob("*"), key=lambda path: len(path.parts), reverse=True)
53
+ for path in paths:
54
+ if path.is_file() or path.is_symlink():
55
+ if path.is_symlink():
56
+ path.unlink()
57
+ else:
58
+ _secure_wipe_file(path)
59
+ for path in paths:
60
+ if path.is_dir():
61
+ with contextlib.suppress(OSError):
62
+ path.rmdir()
63
+ with contextlib.suppress(OSError):
64
+ directory.rmdir()
65
+ if directory.exists() or directory.is_symlink():
66
+ raise OSError(f"Secure wipe did not remove directory completely: {directory}")
67
+
68
+
69
+ def _upload_review(review_path: Path, bucket: str, task_id: str, s3) -> None:
70
+ """Upload local task files without removing existing S3 objects."""
71
+
72
+ def walk_error(error: OSError) -> None:
73
+ raise error
74
+
75
+ try:
76
+ for directory, directories, filenames in review_path.walk(on_error=walk_error):
77
+ if directory == review_path:
78
+ directories[:] = [name for name in directories if name not in LOCAL_REVIEW_STATE]
79
+ for filename in filenames:
80
+ path = directory / filename
81
+ if path.is_symlink():
82
+ continue
83
+ key = f"{task_id}/{path.relative_to(review_path).as_posix()}"
84
+ content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
85
+ s3.upload_file(str(path), bucket, key, ExtraArgs={"ContentType": content_type})
86
+ except (OSError, BotoCoreError, ClientError, S3UploadFailedError) as exc:
87
+ raise click.ClickException(
88
+ f"Could not upload the report review to S3: {exc} Local files were preserved."
89
+ ) from exc
90
+
91
+
92
+ @click.command(name="close")
93
+ @report_reviewer_only
94
+ def close_report():
95
+ """Upload one reviewed task to S3 and wipe its local copy."""
96
+ account_id, domain, task_id, review_path = select_open_review("close")
97
+
98
+ bucket = artifacts_bucket_name(domain_id(domain), account_id)
99
+
100
+ click.echo(f"\n Account: {account_id}")
101
+ click.echo(f" Domain: {domain}")
102
+ click.echo(f" Task: {short_task_id(task_id)}")
103
+ click.echo(f" Prefix: s3://{bucket}/{task_id}/")
104
+ click.confirm("\nUpload and wipe?", default=None, abort=True)
105
+
106
+ _upload_review(review_path, bucket, task_id, report_s3_client(domain, task_id))
107
+ click.echo(" Uploaded local task to S3")
108
+
109
+ click.echo(" Wiping local files...")
110
+ _secure_wipe_directory(review_path)
111
+ for directory in (review_path.parent, review_path.parent.parent):
112
+ if directory.exists() and not any(directory.iterdir()):
113
+ directory.rmdir()
114
+
115
+ click.echo(f"\n Closed and wiped {domain} / {short_task_id(task_id)}")
@@ -0,0 +1,69 @@
1
+ """Layout and filesystem rules for local report reviews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ PENTEST_REPORT = "pentest"
9
+ RETEST_REPORT = "retest"
10
+ CUSTOMER_REPORT_SOURCE_PATHS = {
11
+ PENTEST_REPORT: {
12
+ "executive_summary.md": Path("engagement/artifacts/debriefer/output/executive_summary.md"),
13
+ "technical_report.md": Path("engagement/artifacts/debriefer/output/technical_report.md"),
14
+ "hardening_report.md": Path("engagement/artifacts/debriefer/output/hardening_report.md"),
15
+ "coverage_summary.md": Path("engagement/artifacts/debriefer/output/coverage_summary.md"),
16
+ },
17
+ RETEST_REPORT: {
18
+ "retest_report.md": Path("retest/artifacts/retester/retest_report.md"),
19
+ },
20
+ }
21
+ CUSTOMER_REPORT_TYPES = tuple(CUSTOMER_REPORT_SOURCE_PATHS)
22
+ CUSTOMER_DELIVERABLE_DIR = Path("deliverables")
23
+ CUSTOMER_PDF_FILENAME_SUFFIXES = {
24
+ PENTEST_REPORT: "_Pentest_Report.pdf",
25
+ RETEST_REPORT: "_Pentest_Retest_Report.pdf",
26
+ }
27
+
28
+
29
+ class ReportFileError(ValueError):
30
+ """A local report path or filename is unsafe or unusable."""
31
+
32
+
33
+ def assert_confined_path(root: Path, path: Path, label: str) -> None:
34
+ """Reject paths that escape root or traverse any symlink component."""
35
+ try:
36
+ relative = path.relative_to(root)
37
+ except ValueError as exc:
38
+ raise ReportFileError(f"{label} is outside its required directory: {path}") from exc
39
+
40
+ current = root
41
+ if current.is_symlink():
42
+ raise ReportFileError(f"{label} must not traverse symlinks: {current}")
43
+ for component in relative.parts:
44
+ current /= component
45
+ if current.is_symlink():
46
+ raise ReportFileError(f"{label} must not traverse symlinks: {current}")
47
+
48
+ root_resolved = root.resolve(strict=False)
49
+ if not path.resolve(strict=False).is_relative_to(root_resolved):
50
+ raise ReportFileError(f"{label} escapes its required directory: {path}")
51
+
52
+
53
+ def read_customer_report_sources(review_path: Path, report_type: str) -> dict[str, bytes]:
54
+ """Read the known source files that exist for one report package."""
55
+ sources: dict[str, bytes] = {}
56
+ for name, relative in CUSTOMER_REPORT_SOURCE_PATHS[report_type].items():
57
+ path = review_path / relative
58
+ assert_confined_path(review_path, path, "Customer report source")
59
+ if path.is_file():
60
+ sources[name] = path.read_bytes()
61
+ return sources
62
+
63
+
64
+ def customer_pdf_relative_path(report_type: str, customer_name: str) -> Path:
65
+ """Return the safe customer-facing PDF path for one review."""
66
+ filename_name = re.sub(r"[\W_]+", "_", customer_name.strip()).strip("_")
67
+ if not filename_name:
68
+ raise ReportFileError("Customer name must contain a letter or number for the PDF filename.")
69
+ return CUSTOMER_DELIVERABLE_DIR / f"{filename_name}{CUSTOMER_PDF_FILENAME_SUFFIXES[report_type]}"
@@ -0,0 +1,113 @@
1
+ """Pull task-scoped customer-facing artifacts for local review.
2
+
3
+ Downloads the full artifact tree and logs from S3 into
4
+ reports/{account_id}/{domain}/{task_id}/, preserving S3 keys relative to the
5
+ task prefix.
6
+
7
+ Usage:
8
+ hound reports open
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ import click
14
+ import questionary
15
+
16
+ from hound_cli.aws import (
17
+ _aws_account_id,
18
+ aws_client,
19
+ get_engagement_records,
20
+ get_workflow_record,
21
+ report_reviewer_only,
22
+ report_s3_client,
23
+ short_task_id,
24
+ )
25
+ from hound_cli.contracts.domain import domain_id
26
+ from hound_cli.contracts.workflow import artifacts_bucket_name
27
+ from hound_cli.reports.files import ReportFileError, assert_confined_path
28
+ from hound_cli.reports.reviews import customer_report_review_dir
29
+ from hound_cli.ui import MENU_STYLE
30
+ from hound_cli.workflows.selection import canonical_task_id, selected_domain
31
+
32
+
33
+ def _download_s3_prefix(s3, bucket: str, prefix: str, local_dir: Path) -> int:
34
+ """Download all objects under an S3 prefix as-is.
35
+
36
+ Returns the count of files downloaded.
37
+ """
38
+ paginator = s3.get_paginator("list_objects_v2")
39
+ count = 0
40
+ for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
41
+ for obj in page.get("Contents", []):
42
+ key = obj["Key"]
43
+ relative = key[len(prefix) :]
44
+ if not relative:
45
+ continue
46
+ if Path(relative).parts[0] in {".git", ".hound-local"}:
47
+ raise click.ClickException("Downloaded report artifacts must not contain local review state.")
48
+ local_path = local_dir / relative
49
+ try:
50
+ assert_confined_path(local_dir, local_path, "Downloaded report artifact")
51
+ except ReportFileError as exc:
52
+ raise click.ClickException(str(exc)) from exc
53
+ local_path.parent.mkdir(parents=True, exist_ok=True)
54
+ s3.download_file(bucket, key, str(local_path))
55
+ count += 1
56
+ return count
57
+
58
+
59
+ @click.command(name="open")
60
+ @click.option("--domain", help="Use this exact target domain.")
61
+ @click.option("--task-id", help="Use one exact task; requires --domain.")
62
+ @report_reviewer_only
63
+ def open_report(domain: str | None, task_id: str | None):
64
+ """Pull task artifacts for local review."""
65
+ if task_id is not None and domain is None:
66
+ raise click.UsageError("--task-id requires --domain")
67
+ domain = selected_domain(domain)
68
+ account_id = _aws_account_id()
69
+ bucket = artifacts_bucket_name(domain_id(domain), account_id)
70
+
71
+ ddb = aws_client("dynamodb")
72
+ if task_id is not None:
73
+ task_id = canonical_task_id(task_id)
74
+ record = get_workflow_record(ddb, domain, task_id)
75
+ task_ids = [task_id] if record else []
76
+ else:
77
+ task_ids = sorted(
78
+ (record["task_id"]["S"] for record in get_engagement_records(ddb, domain)),
79
+ reverse=True,
80
+ )
81
+ if not task_ids:
82
+ raise click.ClickException(f"No task records found for {domain}")
83
+ if len(task_ids) == 1:
84
+ task_id = task_ids[0]
85
+ else:
86
+ choices = [short_task_id(candidate) for candidate in task_ids]
87
+ selected = questionary.select("Task ID", choices=choices, style=MENU_STYLE).unsafe_ask()
88
+ if selected is None:
89
+ raise click.Abort()
90
+ task_id = task_ids[choices.index(selected)]
91
+
92
+ prefix = f"{task_id}/"
93
+ try:
94
+ review_path = customer_report_review_dir(account_id, domain, task_id)
95
+ except ValueError as exc:
96
+ raise click.ClickException(str(exc)) from exc
97
+
98
+ if review_path.exists():
99
+ if not review_path.is_dir() or review_path.is_symlink():
100
+ raise click.ClickException(f"Review path is not a local directory: {review_path}")
101
+ click.echo(f"\n Review already open at {review_path}")
102
+ return
103
+
104
+ click.echo(f"\n Pulling s3://{bucket}/{prefix}")
105
+ click.echo(f" To: {review_path}")
106
+ click.confirm("\nProceed?", default=True, abort=True)
107
+
108
+ s3 = report_s3_client(domain, task_id=task_id)
109
+ review_path.mkdir(parents=True, exist_ok=True)
110
+ file_count = _download_s3_prefix(s3, bucket, prefix, review_path)
111
+ click.echo(f" Downloaded {file_count} files")
112
+
113
+ click.echo(f"\nReview open at {review_path}")
@@ -0,0 +1,116 @@
1
+ """Render reviewed customer reports through the restricted AWS renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import questionary
11
+
12
+ from hound_cli.aws import report_reviewer_only, short_task_id
13
+ from hound_cli.reports.files import (
14
+ CUSTOMER_REPORT_SOURCE_PATHS,
15
+ CUSTOMER_REPORT_TYPES,
16
+ PENTEST_REPORT,
17
+ RETEST_REPORT,
18
+ ReportFileError,
19
+ assert_confined_path,
20
+ customer_pdf_relative_path,
21
+ read_customer_report_sources,
22
+ )
23
+ from hound_cli.reports.reviews import select_open_review
24
+ from hound_cli.reports.service import ReportServiceError, render_customer_report
25
+ from hound_cli.ui import MENU_STYLE
26
+
27
+ REPORT_TYPE_LABELS = {
28
+ PENTEST_REPORT: "Penetration test report",
29
+ RETEST_REPORT: "Penetration test retest report",
30
+ }
31
+
32
+
33
+ def _available_report_types(review_path: Path) -> list[str]:
34
+ """Return report packages represented in the local review."""
35
+ available: list[str] = []
36
+ for report_type in CUSTOMER_REPORT_TYPES:
37
+ found_source = False
38
+ for relative in CUSTOMER_REPORT_SOURCE_PATHS[report_type].values():
39
+ path = review_path / relative
40
+ assert_confined_path(review_path, path, "Customer report source")
41
+ found_source = found_source or path.is_file()
42
+ if found_source:
43
+ available.append(report_type)
44
+ return available
45
+
46
+
47
+ def _select_report_type(review_path: Path) -> str:
48
+ """Select a report type only when the review contains more than one."""
49
+ available = _available_report_types(review_path)
50
+ if not available:
51
+ raise ReportFileError("No penetration test or retest report sources were found in this review.")
52
+ if len(available) == 1:
53
+ return available[0]
54
+
55
+ choices = [
56
+ questionary.Choice(title=REPORT_TYPE_LABELS[report_type], value=report_type) for report_type in available
57
+ ]
58
+ selected = questionary.select("Report to render", choices=choices, style=MENU_STYLE).unsafe_ask()
59
+ if selected is None:
60
+ raise click.Abort()
61
+ return selected
62
+
63
+
64
+ def _publish_pdf(review_path: Path, report_type: str, customer_name: str, pdf_bytes: bytes) -> Path:
65
+ output = review_path / customer_pdf_relative_path(report_type, customer_name)
66
+ assert_confined_path(review_path, output, "Customer PDF")
67
+ output.parent.mkdir(parents=True, exist_ok=True)
68
+ temporary_path: Path | None = None
69
+ try:
70
+ with tempfile.NamedTemporaryFile(
71
+ mode="wb",
72
+ dir=output.parent,
73
+ prefix=f".{output.name}.",
74
+ suffix=".tmp",
75
+ delete=False,
76
+ ) as temporary:
77
+ temporary.write(pdf_bytes)
78
+ temporary.flush()
79
+ os.fsync(temporary.fileno())
80
+ temporary_path = Path(temporary.name)
81
+ os.replace(temporary_path, output)
82
+ finally:
83
+ if temporary_path is not None:
84
+ temporary_path.unlink(missing_ok=True)
85
+ return output
86
+
87
+
88
+ @click.command(name="render")
89
+ @click.option("--customer-name", help="Customer or application name shown on the cover.")
90
+ @report_reviewer_only
91
+ def render_report(customer_name: str | None) -> None:
92
+ """Render one reviewed customer report package as a PDF."""
93
+ account_id, domain, task_id, review_path = select_open_review("render")
94
+
95
+ try:
96
+ report_type = _select_report_type(review_path)
97
+ if customer_name is None:
98
+ default_name = domain.split(".", 1)[0].replace("-", " ").title()
99
+ customer_name = click.prompt("Customer name", default=default_name)
100
+
101
+ sources = read_customer_report_sources(review_path, report_type)
102
+ pdf_bytes = render_customer_report(
103
+ domain,
104
+ task_id,
105
+ report_type,
106
+ customer_name,
107
+ sources,
108
+ )
109
+ output = _publish_pdf(review_path, report_type, customer_name, pdf_bytes)
110
+ except (ReportFileError, ReportServiceError) as exc:
111
+ raise click.ClickException(str(exc)) from exc
112
+
113
+ click.echo(f"\n Account: {account_id}")
114
+ click.echo(f" Domain: {domain}")
115
+ click.echo(f" Task: {short_task_id(task_id)}")
116
+ click.echo(f"\nRendered customer PDF:\n {output}")
@@ -0,0 +1,120 @@
1
+ """Shared discovery and selection for local customer report reviews."""
2
+
3
+ import os
4
+ import uuid
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import questionary
9
+
10
+ from hound_cli.aws import _aws_account_id, short_task_id
11
+ from hound_cli.contracts.domain import normalize_domain
12
+ from hound_cli.reports.files import ReportFileError, assert_confined_path
13
+ from hound_cli.ui import MENU_STYLE
14
+
15
+ OpenReview = tuple[str, str, str, Path]
16
+
17
+
18
+ def customer_reports_dir() -> Path:
19
+ """Return the explicit override or the normal per-user report root."""
20
+ configured = os.environ.get("HOUND_REPORTS_DIR")
21
+ if configured:
22
+ root = Path(configured)
23
+ if not root.is_absolute():
24
+ raise click.ClickException("HOUND_REPORTS_DIR must be an absolute path")
25
+ return root
26
+ return Path.home() / "Hound" / "reports"
27
+
28
+
29
+ def customer_report_review_dir(account_id: str, domain: str, task_id: str) -> Path:
30
+ """Return the only permitted local directory for one customer review."""
31
+ if len(account_id) != 12 or not account_id.isdigit():
32
+ raise ValueError(f"Invalid AWS account ID: {account_id}")
33
+ if normalize_domain(domain) != domain:
34
+ raise ValueError(f"Customer report domain is not normalized: {domain}")
35
+ try:
36
+ parsed_task_id = uuid.UUID(task_id)
37
+ except ValueError as exc:
38
+ raise ValueError(f"Customer report task ID is not a UUID: {task_id}") from exc
39
+ if str(parsed_task_id) != task_id:
40
+ raise ValueError(f"Customer report task ID is not canonical: {task_id}")
41
+
42
+ root = customer_reports_dir()
43
+ review_path = root / account_id / domain / task_id
44
+ assert_confined_path(root, review_path, "Customer report storage path")
45
+ return review_path
46
+
47
+
48
+ def find_open_reviews(account_id: str | None = None) -> list[OpenReview]:
49
+ """Return valid local reviews, optionally for one account, newest first."""
50
+ base = customer_reports_dir()
51
+ if not base.exists():
52
+ return []
53
+ try:
54
+ assert_confined_path(base, base, "Customer reports directory")
55
+ except ReportFileError as exc:
56
+ raise click.ClickException(str(exc)) from exc
57
+
58
+ results: list[OpenReview] = []
59
+ for account_dir in sorted(base.iterdir()):
60
+ if account_id is not None and account_dir.name != account_id:
61
+ continue
62
+ if len(account_dir.name) == 12 and account_dir.name.isdigit():
63
+ try:
64
+ assert_confined_path(base, account_dir, "Customer report account directory")
65
+ except ReportFileError as exc:
66
+ raise click.ClickException(str(exc)) from exc
67
+ if not account_dir.is_dir() or len(account_dir.name) != 12 or not account_dir.name.isdigit():
68
+ continue
69
+ for domain_dir in sorted(account_dir.iterdir()):
70
+ if not domain_dir.name.startswith("."):
71
+ try:
72
+ assert_confined_path(base, domain_dir, "Customer report domain directory")
73
+ except ReportFileError as exc:
74
+ raise click.ClickException(str(exc)) from exc
75
+ if not domain_dir.is_dir() or domain_dir.name.startswith("."):
76
+ continue
77
+ for task_dir in sorted(domain_dir.iterdir()):
78
+ if not task_dir.name.startswith("."):
79
+ try:
80
+ assert_confined_path(base, task_dir, "Customer report task directory")
81
+ except ReportFileError as exc:
82
+ raise click.ClickException(str(exc)) from exc
83
+ if not task_dir.is_dir() or task_dir.name.startswith("."):
84
+ continue
85
+ try:
86
+ expected = customer_report_review_dir(
87
+ account_dir.name,
88
+ domain_dir.name,
89
+ task_dir.name,
90
+ )
91
+ except ValueError as exc:
92
+ raise click.ClickException(str(exc)) from exc
93
+ if task_dir != expected:
94
+ raise click.ClickException(f"Customer report task is outside its required directory: {task_dir}")
95
+ results.append(
96
+ (
97
+ account_dir.name,
98
+ domain_dir.name,
99
+ task_dir.name,
100
+ task_dir,
101
+ )
102
+ )
103
+ results.sort(key=lambda review: review[2], reverse=True)
104
+ return results
105
+
106
+
107
+ def select_open_review(action: str) -> OpenReview:
108
+ """Select one local review for an operator action."""
109
+ account_id = _aws_account_id()
110
+ reviews = find_open_reviews(account_id)
111
+ if not reviews:
112
+ raise click.ClickException(
113
+ f"No open reviews for active AWS account {account_id}. Run 'hound reports open' first."
114
+ )
115
+ if len(reviews) == 1:
116
+ return reviews[0]
117
+
118
+ choices = [f"{domain} / {short_task_id(task_id)}" for _, domain, task_id, _ in reviews]
119
+ selected = questionary.select(f"Review to {action}", choices=choices, style=MENU_STYLE).unsafe_ask()
120
+ return reviews[choices.index(selected)]
@@ -0,0 +1,81 @@
1
+ """Direct client for the account-local customer report renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+
9
+ from botocore.config import Config
10
+ from botocore.exceptions import BotoCoreError, ClientError
11
+
12
+ from hound_cli.aws import report_client
13
+
14
+
15
+ class ReportServiceError(RuntimeError):
16
+ """The renderer could not complete the requested operation."""
17
+
18
+
19
+ REPORT_RENDERER_FUNCTION_NAME = "hound-report-renderer"
20
+ RENDERER_CLIENT_CONFIG = Config(read_timeout=150)
21
+
22
+
23
+ def _invoke_renderer(
24
+ domain: str,
25
+ task_id: str,
26
+ request: dict[str, object],
27
+ ) -> dict[str, object]:
28
+ payload = json.dumps(request, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
29
+ try:
30
+ response = report_client("lambda", domain, task_id, config=RENDERER_CLIENT_CONFIG).invoke(
31
+ FunctionName=REPORT_RENDERER_FUNCTION_NAME,
32
+ InvocationType="RequestResponse",
33
+ Payload=payload,
34
+ )
35
+ response_payload = response["Payload"].read()
36
+ except (BotoCoreError, ClientError, KeyError, OSError) as exc:
37
+ message = "Could not reach the customer report renderer. Local report data was preserved."
38
+ raise ReportServiceError(message) from exc
39
+
40
+ if response.get("FunctionError"):
41
+ raise ReportServiceError("The customer report renderer failed. Local report data was preserved.")
42
+ try:
43
+ result = json.loads(response_payload)
44
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
45
+ raise ReportServiceError("The customer report renderer returned an invalid response.") from exc
46
+ if not isinstance(result, dict):
47
+ raise ReportServiceError("The customer report renderer returned an invalid response.")
48
+ if result.get("ok") is not True:
49
+ error = result.get("error")
50
+ message = error.get("message") if isinstance(error, dict) else None
51
+ if not isinstance(message, str) or not message.strip():
52
+ message = "The customer report renderer rejected the report."
53
+ raise ReportServiceError(message)
54
+ return result
55
+
56
+
57
+ def _encode_sources(sources: dict[str, bytes]) -> dict[str, str]:
58
+ return {name: base64.b64encode(content).decode("ascii") for name, content in sources.items()}
59
+
60
+
61
+ def render_customer_report(
62
+ domain: str,
63
+ task_id: str,
64
+ report_type: str,
65
+ company_name: str,
66
+ sources: dict[str, bytes],
67
+ ) -> bytes:
68
+ result = _invoke_renderer(
69
+ domain,
70
+ task_id,
71
+ {
72
+ "operation": "render",
73
+ "report_type": report_type,
74
+ "company_name": company_name,
75
+ "sources": _encode_sources(sources),
76
+ },
77
+ )
78
+ try:
79
+ return base64.b64decode(result["pdf_base64"])
80
+ except (KeyError, TypeError, binascii.Error) as exc:
81
+ raise ReportServiceError("The customer report renderer returned an invalid response.") from exc
hound_cli/roster.py ADDED
@@ -0,0 +1,64 @@
1
+ """Validate and load the account-local Hound agent roster."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import click
8
+
9
+ from hound_cli.aws import get_secret
10
+
11
+ ROSTER_SECRET_ID = "hound/agents"
12
+ _AGENT_ID = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
13
+ _E164_PHONE_NUMBER = re.compile(r"^\+[1-9]\d{7,14}$")
14
+
15
+
16
+ def validate_agent_id(value: str) -> str:
17
+ """Return a lowercase DNS-safe stable agent ID."""
18
+ if not isinstance(value, str) or not _AGENT_ID.fullmatch(value):
19
+ raise click.ClickException(f"Invalid agent ID in hound/agents: {value!r}.")
20
+ return value
21
+
22
+
23
+ def validate_phone_number(value: str) -> str:
24
+ """Return an exact E.164 agent phone number."""
25
+ normalized = value.strip()
26
+ if not _E164_PHONE_NUMBER.fullmatch(normalized):
27
+ raise click.ClickException("The selected agent must have a valid E.164 phone_number in hound/agents.")
28
+ return normalized
29
+
30
+
31
+ def validate_roster(roster: dict) -> dict:
32
+ """Return a validated coordinate-only Hound agent roster."""
33
+ if not isinstance(roster, dict) or not roster:
34
+ raise click.ClickException("Agent roster must be a non-empty JSON object.")
35
+
36
+ for agent_id, agent in roster.items():
37
+ validate_agent_id(agent_id)
38
+ if not isinstance(agent, dict):
39
+ raise click.ClickException("Agent roster entries must map agent IDs to JSON objects.")
40
+
41
+ email = agent.get("email")
42
+ if not isinstance(email, str) or not email.strip():
43
+ raise click.ClickException(f"Agent roster entry '{agent_id}' must contain a non-empty email.")
44
+
45
+ phone_number = agent.get("phone_number")
46
+ if phone_number is not None:
47
+ try:
48
+ validate_phone_number(phone_number)
49
+ except (AttributeError, click.ClickException) as exc:
50
+ raise click.ClickException(
51
+ f"Agent roster entry '{agent_id}' phone_number must use E.164 format, for example +12125550123."
52
+ ) from exc
53
+
54
+ return roster
55
+
56
+
57
+ def load_roster(client) -> dict:
58
+ """Load the validated agent roster from Secrets Manager."""
59
+ roster = get_secret(client, ROSTER_SECRET_ID)
60
+ if roster is None:
61
+ raise click.ClickException(
62
+ f"Agent roster not found at '{ROSTER_SECRET_ID}'. Create it in Secrets Manager first."
63
+ )
64
+ return validate_roster(roster)
@@ -0,0 +1 @@
1
+ """Target configuration commands."""