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.
hound_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Hound CLI."""
hound_cli/__main__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Hound cli entrypoint."""
2
+
3
+ import click
4
+
5
+ from hound_cli.agents import agents
6
+ from hound_cli.inbox.command import inbox
7
+ from hound_cli.reports.close import close_report
8
+ from hound_cli.reports.open import open_report
9
+ from hound_cli.reports.render import render_report
10
+ from hound_cli.targets.verification import authorization
11
+ from hound_cli.workflows.launch import engagement_launch, ips, retest_launch
12
+ from hound_cli.workflows.logs import engagement_logs, retest_logs
13
+ from hound_cli.workflows.status import account_status, engagement_status, retest_status
14
+ from hound_cli.workflows.stop import engagement_stop, retest_stop
15
+
16
+ CONTEXT = {"help_option_names": ["-h", "--help"]}
17
+
18
+
19
+ @click.group(context_settings=CONTEXT)
20
+ def main():
21
+ """Hound CLI."""
22
+
23
+
24
+ @main.group()
25
+ def engagements():
26
+ """Launch and manage penetration tests."""
27
+
28
+
29
+ @main.group()
30
+ def retests():
31
+ """Launch and manage retests."""
32
+
33
+
34
+ @main.group()
35
+ def reports():
36
+ """Manage task-scoped customer report reviews."""
37
+
38
+
39
+ engagements.add_command(engagement_launch)
40
+ engagements.add_command(engagement_status)
41
+ engagements.add_command(engagement_logs)
42
+ engagements.add_command(engagement_stop)
43
+
44
+ retests.add_command(retest_launch)
45
+ retests.add_command(retest_status)
46
+ retests.add_command(retest_logs)
47
+ retests.add_command(retest_stop)
48
+
49
+ reports.add_command(open_report)
50
+ reports.add_command(render_report)
51
+ reports.add_command(close_report)
52
+
53
+ main.add_command(agents)
54
+ main.add_command(authorization)
55
+ main.add_command(account_status)
56
+ main.add_command(inbox)
57
+ main.add_command(ips)
58
+
59
+ if __name__ == "__main__":
60
+ main()
hound_cli/agents.py ADDED
@@ -0,0 +1,19 @@
1
+ """List the account-local Hound agent roster."""
2
+
3
+ import click
4
+
5
+ from hound_cli.aws import aws_client, operator_only
6
+ from hound_cli.roster import load_roster
7
+
8
+
9
+ @click.command("agents")
10
+ @operator_only
11
+ def agents() -> None:
12
+ """List Hound agents and their configured contact details."""
13
+ roster = load_roster(aws_client("secretsmanager"))
14
+
15
+ click.echo("Agents:")
16
+ for agent_id, agent in sorted(roster.items()):
17
+ click.echo(f"\n {agent_id}")
18
+ click.echo(f" Email: {agent['email']}")
19
+ click.echo(f" Phone: {agent.get('phone_number') or '—'}")
hound_cli/aws.py ADDED
@@ -0,0 +1,340 @@
1
+ """Account-local AWS access for the Hound CLI."""
2
+
3
+ import json
4
+ import re
5
+ from functools import cache, wraps
6
+
7
+ import boto3
8
+ import click
9
+ from botocore.exceptions import BotoCoreError, ClientError
10
+
11
+ from hound_cli.contracts.domain import domain_id, normalize_domain
12
+ from hound_cli.contracts.workflow import ACTIVE_WORKFLOW_STATUSES, artifacts_bucket_name
13
+
14
+ PENTESTS_TABLE = "hound-pentests"
15
+ OPERATOR_ROLE = "hound-operator"
16
+ REPORT_REVIEWER_ROLE = "hound-report-reviewer"
17
+ REPORT_SESSION_ROLE = "hound-report-session"
18
+ _OPERATOR_ARN = re.compile(rf"^arn:aws:sts::(?P<account_id>[0-9]{{12}}):assumed-role/{OPERATOR_ROLE}/[^/]+$")
19
+ _REPORT_REVIEWER_ARN = re.compile(
20
+ rf"^arn:aws:sts::(?P<account_id>[0-9]{{12}}):assumed-role/{REPORT_REVIEWER_ROLE}/[^/]+$"
21
+ )
22
+
23
+
24
+ def short_task_id(task_id: str) -> str:
25
+ """Return the operator-facing task suffix used in AWS resource names."""
26
+ return task_id[-12:]
27
+
28
+
29
+ @cache
30
+ def _aws_region() -> str:
31
+ """Resolve AWS region from the current session."""
32
+ return boto3.session.Session().region_name or "us-east-1"
33
+
34
+
35
+ @cache
36
+ def _aws_identity() -> tuple[str, str]:
37
+ """Resolve the account and ARN for the current AWS session."""
38
+ try:
39
+ identity = boto3.client("sts", region_name=_aws_region()).get_caller_identity()
40
+ return identity["Account"], identity["Arn"]
41
+ except (BotoCoreError, ClientError, KeyError) as exc:
42
+ raise click.ClickException("Could not verify the active AWS identity.") from exc
43
+
44
+
45
+ def _validate_operator_identity(account_id: str, arn: str) -> str:
46
+ """Return the account ID when the caller is the account-local operator."""
47
+ match = _OPERATOR_ARN.fullmatch(arn)
48
+ if match is None or match.group("account_id") != account_id:
49
+ raise click.ClickException(
50
+ f"Active AWS identity must be an assumed {OPERATOR_ROLE} role; got {arn}. "
51
+ "Select the managed account's operator profile."
52
+ )
53
+ return account_id
54
+
55
+
56
+ def _validate_report_reviewer_identity(account_id: str, arn: str) -> str:
57
+ """Return the account ID when the caller is the account-local report reviewer."""
58
+ match = _REPORT_REVIEWER_ARN.fullmatch(arn)
59
+ if match is None or match.group("account_id") != account_id:
60
+ raise click.ClickException(
61
+ f"Active AWS identity must be an assumed {REPORT_REVIEWER_ROLE} role; got {arn}. "
62
+ "Select the managed account's report-reviewer profile."
63
+ )
64
+ return account_id
65
+
66
+
67
+ def require_operator_identity() -> str:
68
+ """Fail closed unless the active session uses the account-local operator role."""
69
+ return _validate_operator_identity(*_aws_identity())
70
+
71
+
72
+ def require_report_reviewer_identity() -> str:
73
+ """Fail closed unless the active session uses the account-local report-reviewer role."""
74
+ return _validate_report_reviewer_identity(*_aws_identity())
75
+
76
+
77
+ def operator_only(func):
78
+ """Require the normal public workflow-operator identity before running a command."""
79
+
80
+ @wraps(func)
81
+ def wrapped(*args, **kwargs):
82
+ account_id = require_operator_identity()
83
+ click.echo(f"AWS account: {account_id}")
84
+ return func(*args, **kwargs)
85
+
86
+ return wrapped
87
+
88
+
89
+ def report_reviewer_only(func):
90
+ """Require the report-review identity before running a report command."""
91
+
92
+ @wraps(func)
93
+ def wrapped(*args, **kwargs):
94
+ account_id = require_report_reviewer_identity()
95
+ click.echo(f"AWS account: {account_id}")
96
+ return func(*args, **kwargs)
97
+
98
+ return wrapped
99
+
100
+
101
+ @cache
102
+ def _aws_account_id() -> str:
103
+ """Resolve AWS account ID from the current session."""
104
+ return _aws_identity()[0]
105
+
106
+
107
+ def prompt_target(label: str = "Domain") -> str:
108
+ """Return the normalized exact submitted target used as the workflow key."""
109
+ raw = click.prompt(label).strip()
110
+ try:
111
+ return normalize_domain(raw)
112
+ except ValueError as exc:
113
+ raise click.ClickException(f"Invalid target: {raw}") from exc
114
+
115
+
116
+ def get_secret(client, secret_id: str) -> dict | None:
117
+ """Read a JSON secret. Returns None if it doesn't exist."""
118
+ try:
119
+ resp = client.get_secret_value(SecretId=secret_id)
120
+ return json.loads(resp["SecretString"])
121
+ except ClientError as e:
122
+ if e.response["Error"]["Code"] == "ResourceNotFoundException":
123
+ return None
124
+ raise
125
+
126
+
127
+ def aws_client(service: str):
128
+ """Create a boto3 client with the resolved region."""
129
+ return boto3.client(service, region_name=_aws_region())
130
+
131
+
132
+ def put_secret(client, secret_id: str, value: dict) -> None:
133
+ """Create or update a JSON secret."""
134
+ secret_string = json.dumps(value, indent=2)
135
+ try:
136
+ client.put_secret_value(SecretId=secret_id, SecretString=secret_string)
137
+ except ClientError as e:
138
+ if e.response["Error"]["Code"] == "ResourceNotFoundException":
139
+ client.create_secret(Name=secret_id, SecretString=secret_string)
140
+ else:
141
+ raise
142
+
143
+
144
+ def sqs_queue_url(queue_name: str) -> str:
145
+ """Return an account-local Hound queue URL."""
146
+ return f"https://sqs.{_aws_region()}.amazonaws.com/{_aws_account_id()}/{queue_name}"
147
+
148
+
149
+ def _domain_records(
150
+ ddb,
151
+ table_name: str,
152
+ domain: str,
153
+ status_attr: str | None = None,
154
+ status: str | list[str] | None = None,
155
+ infra_status: str | list[str] | None = None,
156
+ ) -> list[dict]:
157
+ """Fetch domain records, optionally filtered by lifecycle or infra status."""
158
+ kwargs = {
159
+ "TableName": table_name,
160
+ "KeyConditionExpression": "#d = :d",
161
+ "ExpressionAttributeNames": {"#d": "domain"},
162
+ "ExpressionAttributeValues": {":d": {"S": domain}},
163
+ }
164
+
165
+ filters = []
166
+
167
+ if isinstance(status, (list, tuple, set, frozenset)):
168
+ if status_attr is None:
169
+ raise ValueError("status_attr is required when status filter is set")
170
+ placeholders = {f":s{i}": {"S": s} for i, s in enumerate(status)}
171
+ filters.append(f"{status_attr} IN ({', '.join(placeholders)})")
172
+ kwargs["ExpressionAttributeValues"].update(placeholders)
173
+ elif status:
174
+ if status_attr is None:
175
+ raise ValueError("status_attr is required when status filter is set")
176
+ filters.append(f"{status_attr} = :s")
177
+ kwargs["ExpressionAttributeValues"][":s"] = {"S": status}
178
+
179
+ if isinstance(infra_status, (list, tuple, set, frozenset)):
180
+ placeholders = {f":is{i}": {"S": s} for i, s in enumerate(infra_status)}
181
+ filters.append(f"infra_status IN ({', '.join(placeholders)})")
182
+ kwargs["ExpressionAttributeValues"].update(placeholders)
183
+ elif infra_status:
184
+ filters.append("infra_status = :is")
185
+ kwargs["ExpressionAttributeValues"][":is"] = {"S": infra_status}
186
+
187
+ if filters:
188
+ kwargs["FilterExpression"] = " AND ".join(filters)
189
+
190
+ records = []
191
+ while True:
192
+ page = ddb.query(**kwargs)
193
+ records.extend(page.get("Items", []))
194
+ last_key = page.get("LastEvaluatedKey")
195
+ if not last_key:
196
+ return records
197
+ kwargs["ExclusiveStartKey"] = last_key
198
+
199
+
200
+ def get_workflow_record(ddb, domain: str, task_id: str) -> dict | None:
201
+ """Read one exact account-local workflow record."""
202
+ response = ddb.get_item(
203
+ TableName=PENTESTS_TABLE,
204
+ Key={"domain": {"S": domain}, "task_id": {"S": task_id}},
205
+ )
206
+ return response.get("Item")
207
+
208
+
209
+ def scan_workflow_records(ddb) -> list[dict]:
210
+ """Read every account-local workflow record for the operator work queue."""
211
+ records = []
212
+ kwargs = {"TableName": PENTESTS_TABLE, "ConsistentRead": False}
213
+ while True:
214
+ page = ddb.scan(**kwargs)
215
+ records.extend(page.get("Items", []))
216
+ last_key = page.get("LastEvaluatedKey")
217
+ if not last_key:
218
+ return records
219
+ kwargs["ExclusiveStartKey"] = last_key
220
+
221
+
222
+ def get_engagement_records(
223
+ ddb,
224
+ domain: str,
225
+ status: str | list[str] | None = None,
226
+ infra_status: str | list[str] | None = None,
227
+ ) -> list[dict]:
228
+ """Fetch engagement records for a domain, optionally filtered by status fields."""
229
+ return _domain_records(
230
+ ddb,
231
+ PENTESTS_TABLE,
232
+ domain,
233
+ status_attr="engagement_status",
234
+ status=status,
235
+ infra_status=infra_status,
236
+ )
237
+
238
+
239
+ def get_retest_records(
240
+ ddb,
241
+ domain: str,
242
+ status: str | list[str] | None = None,
243
+ infra_status: str | list[str] | None = None,
244
+ ) -> list[dict]:
245
+ """Fetch engagement records that have current retest state."""
246
+ records = _domain_records(
247
+ ddb,
248
+ PENTESTS_TABLE,
249
+ domain,
250
+ status_attr="retest_status",
251
+ status=status,
252
+ infra_status=infra_status,
253
+ )
254
+ return [record for record in records if "retest_status" in record]
255
+
256
+
257
+ def active_domain_work(ddb, domain: str) -> list[dict]:
258
+ """Return same-domain engagement records that block launch."""
259
+ records = get_engagement_records(ddb, domain)
260
+ active = []
261
+ for record in records:
262
+ infra_status = record.get("infra_status", {}).get("S")
263
+ if infra_status != "absent":
264
+ active.append(record)
265
+ continue
266
+ if any(
267
+ record.get(attr, {}).get("S") in active_statuses
268
+ for attr, active_statuses in ACTIVE_WORKFLOW_STATUSES.items()
269
+ ):
270
+ active.append(record)
271
+ return active
272
+
273
+
274
+ @cache
275
+ def _report_session(domain: str, task_id: str) -> boto3.Session:
276
+ """Return the task-scoped AWS session used for one report review."""
277
+ account_id = require_report_reviewer_identity()
278
+ role_arn = f"arn:aws:iam::{account_id}:role/{REPORT_SESSION_ROLE}"
279
+ bucket_arn = f"arn:aws:s3:::{artifacts_bucket_name(domain_id(domain), account_id)}"
280
+ renderer_arn = f"arn:aws:lambda:{_aws_region()}:{account_id}:function:hound-report-renderer"
281
+
282
+ session_policy = json.dumps(
283
+ {
284
+ "Version": "2012-10-17",
285
+ "Statement": [
286
+ {
287
+ "Effect": "Allow",
288
+ "Action": "s3:ListBucket",
289
+ "Resource": bucket_arn,
290
+ "Condition": {
291
+ "StringLike": {
292
+ "s3:prefix": [
293
+ f"{task_id}/",
294
+ f"{task_id}/*",
295
+ ]
296
+ }
297
+ },
298
+ },
299
+ {
300
+ "Effect": "Allow",
301
+ "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
302
+ "Resource": f"{bucket_arn}/{task_id}/*",
303
+ },
304
+ {
305
+ "Effect": "Allow",
306
+ "Action": "lambda:InvokeFunction",
307
+ "Resource": renderer_arn,
308
+ },
309
+ ],
310
+ }
311
+ )
312
+
313
+ creds = aws_client("sts").assume_role(
314
+ RoleArn=role_arn,
315
+ RoleSessionName=f"report-review-{domain_id(domain)}",
316
+ Policy=session_policy,
317
+ Tags=[
318
+ {"Key": "domain_id", "Value": domain_id(domain)},
319
+ {"Key": "task_id", "Value": task_id},
320
+ {"Key": "target_domain", "Value": domain},
321
+ ],
322
+ DurationSeconds=900,
323
+ )["Credentials"]
324
+
325
+ return boto3.Session(
326
+ region_name=_aws_region(),
327
+ aws_access_key_id=creds["AccessKeyId"],
328
+ aws_secret_access_key=creds["SecretAccessKey"],
329
+ aws_session_token=creds["SessionToken"],
330
+ )
331
+
332
+
333
+ def report_client(service: str, domain: str, task_id: str, *, config=None):
334
+ """Create an AWS client inside the report review's security boundary."""
335
+ return _report_session(domain, task_id).client(service, region_name=_aws_region(), config=config)
336
+
337
+
338
+ def report_s3_client(domain: str, task_id: str):
339
+ """Create the task-scoped S3 client for a customer report review."""
340
+ return report_client("s3", domain, task_id)
@@ -0,0 +1 @@
1
+ """Minimal service-boundary values needed by the public CLI."""
@@ -0,0 +1,129 @@
1
+ """Target identity and DNS verification values used by the public CLI."""
2
+
3
+ import hashlib
4
+ import ipaddress
5
+ import urllib.parse
6
+
7
+ import dns.exception
8
+ import dns.resolver
9
+ import idna
10
+ from publicsuffixlist import PublicSuffixList
11
+
12
+ _TXT_PREFIX = "hound-verification="
13
+ _icann_psl = PublicSuffixList(only_icann=True)
14
+ _resolver = dns.resolver.Resolver()
15
+ _resolver.timeout = 1.5
16
+ _resolver.lifetime = 2.0
17
+
18
+
19
+ class VerificationUnavailable(RuntimeError):
20
+ """DNS could not determine the target's verification state."""
21
+
22
+
23
+ def _extract_hostname(raw: str) -> str:
24
+ if not raw:
25
+ raise ValueError("Empty target")
26
+
27
+ value = raw.strip()
28
+ parsed = urllib.parse.urlparse(value if "://" in value or value.startswith("//") else f"//{value}")
29
+ try:
30
+ _ = parsed.port
31
+ except ValueError as exc:
32
+ raise ValueError("Invalid target") from exc
33
+
34
+ hostname = (parsed.hostname or "").strip().rstrip(".")
35
+ if not hostname:
36
+ raise ValueError("Invalid target")
37
+ return hostname
38
+
39
+
40
+ def normalize_domain(raw: str) -> str:
41
+ """Return the canonical DNS name or IPv4 workflow key for a target."""
42
+ hostname = _extract_hostname(raw)
43
+ try:
44
+ address = ipaddress.ip_address(hostname)
45
+ except ValueError:
46
+ address = None
47
+ if address is not None:
48
+ if address.version != 4:
49
+ raise ValueError("IPv6 targets are not supported")
50
+ return str(address)
51
+
52
+ try:
53
+ domain = idna.encode(hostname, uts46=True, std3_rules=True).decode("ascii").lower()
54
+ except idna.IDNAError as exc:
55
+ raise ValueError("Invalid domain") from exc
56
+ if "." not in domain or not _icann_psl.privatesuffix(domain):
57
+ raise ValueError("Invalid domain")
58
+ return domain
59
+
60
+
61
+ def is_ip_target(target: str) -> bool:
62
+ """Return whether a canonical target is an IPv4 address."""
63
+ try:
64
+ return ipaddress.ip_address(target).version == 4
65
+ except ValueError:
66
+ return False
67
+
68
+
69
+ def domain_id(domain: str) -> str:
70
+ """Return the stable artifact-routing identifier for a target."""
71
+ return hashlib.sha256(normalize_domain(domain).encode()).hexdigest()[:20]
72
+
73
+
74
+ def derive_root_domain(domain: str) -> str:
75
+ """Return the DNS fallback location for a canonical domain target."""
76
+ root_domain = _icann_psl.privatesuffix(domain)
77
+ if not root_domain:
78
+ raise ValueError(f"Invalid domain: {domain}")
79
+ return root_domain
80
+
81
+
82
+ def verification_hash(account_id: str, domain: str) -> str:
83
+ """Return the account-scoped verification hash for a target domain."""
84
+ if len(account_id) != 12 or not account_id.isdigit():
85
+ raise ValueError("account_id must be a 12-digit AWS account ID")
86
+ canonical_domain = normalize_domain(domain)
87
+ return hashlib.sha256(f"{account_id}:{canonical_domain}".encode()).hexdigest()
88
+
89
+
90
+ def verification_txt_value(expected_hash: str) -> str:
91
+ """Return the exact TXT value Hound expects in DNS."""
92
+ return f"{_TXT_PREFIX}{expected_hash}"
93
+
94
+
95
+ def verification_record_names(domain: str) -> list[str]:
96
+ """Return the DNS names where Hound accepts the verification record."""
97
+ domain = normalize_domain(domain)
98
+ if is_ip_target(domain):
99
+ return []
100
+ root_domain = derive_root_domain(domain)
101
+ return [domain] if root_domain == domain else [domain, root_domain]
102
+
103
+
104
+ def _txt_value(rdata) -> str:
105
+ strings = getattr(rdata, "strings", None)
106
+ if strings is not None:
107
+ return "".join(part.decode() for part in strings)
108
+ return rdata.to_text().strip().strip('"').strip()
109
+
110
+
111
+ def verification_txt_present(domain: str, expected_hash: str) -> bool:
112
+ """Return whether the expected account-scoped TXT value is present."""
113
+ expected = verification_txt_value(expected_hash)
114
+ lookup_error: VerificationUnavailable | None = None
115
+
116
+ for location in verification_record_names(domain):
117
+ try:
118
+ answers = _resolver.resolve(location, "TXT")
119
+ except dns.resolver.NXDOMAIN, dns.resolver.NoAnswer:
120
+ continue
121
+ except (dns.exception.Timeout, dns.resolver.NoNameservers) as exc:
122
+ lookup_error = VerificationUnavailable(str(exc))
123
+ continue
124
+ if any(_txt_value(rdata) == expected for rdata in answers):
125
+ return True
126
+
127
+ if lookup_error:
128
+ raise lookup_error
129
+ return False
@@ -0,0 +1,76 @@
1
+ """Workflow values the public CLI must send or interpret."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+
6
+ SKIP_DNS_CHECK_FIELD = "skip_dns_check"
7
+
8
+ ENGAGEMENT_RELAUNCH_STATUSES = ("cancelled", "failed")
9
+ RETEST_LAUNCH_STATUSES = ("completed", "failed", "cancelled")
10
+ ACTIVE_INFRA_STATUSES = frozenset(
11
+ {
12
+ "provisioning",
13
+ "provisioned",
14
+ "destroying",
15
+ "provisioning_failed",
16
+ "destroy_failed",
17
+ }
18
+ )
19
+
20
+ ACTIVE_WORKFLOW_STATUSES = {
21
+ "engagement_status": frozenset({"queued", "running"}),
22
+ "retest_status": frozenset({"queued", "running"}),
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RuntimeConfig:
28
+ """One workflow run's complete effective runtime selection."""
29
+
30
+ eip_allocation_id: str | None
31
+ residential_proxy_enabled: bool
32
+ relay_enabled: bool
33
+
34
+
35
+ def runtime_config_from_record(record: Mapping[str, object]) -> RuntimeConfig:
36
+ """Read the runtime fields displayed or reused by the public CLI."""
37
+ attribute = record.get("runtime")
38
+ if not isinstance(attribute, Mapping) or not isinstance(attribute.get("M"), Mapping):
39
+ raise ValueError("Workflow record runtime must be a DynamoDB map")
40
+
41
+ runtime = attribute["M"]
42
+ booleans: dict[str, bool] = {}
43
+ for field in ("relay_enabled", "residential_proxy_enabled"):
44
+ value = runtime.get(field)
45
+ if not isinstance(value, Mapping) or not isinstance(value.get("BOOL"), bool):
46
+ raise ValueError(f"Workflow record runtime.{field} must be a DynamoDB boolean")
47
+ booleans[field] = value["BOOL"]
48
+
49
+ eip_allocation_id = None
50
+ if "eip_allocation_id" in runtime:
51
+ value = runtime["eip_allocation_id"]
52
+ if not isinstance(value, Mapping) or not isinstance(value.get("S"), str):
53
+ raise ValueError("Workflow record runtime.eip_allocation_id must be a DynamoDB string")
54
+ eip_allocation_id = value["S"]
55
+
56
+ return RuntimeConfig(
57
+ eip_allocation_id=eip_allocation_id,
58
+ residential_proxy_enabled=booleans["residential_proxy_enabled"],
59
+ relay_enabled=booleans["relay_enabled"],
60
+ )
61
+
62
+
63
+ def runtime_launch_value(runtime: RuntimeConfig) -> dict[str, object]:
64
+ """Encode a runtime selection for the public launch message."""
65
+ value: dict[str, object] = {
66
+ "residential_proxy_enabled": runtime.residential_proxy_enabled,
67
+ "relay_enabled": runtime.relay_enabled,
68
+ }
69
+ if runtime.eip_allocation_id is not None:
70
+ value["eip_allocation_id"] = runtime.eip_allocation_id
71
+ return value
72
+
73
+
74
+ def artifacts_bucket_name(domain_id: str, account_id: str) -> str:
75
+ """Return the account-local artifact bucket for a target-domain identity."""
76
+ return f"hound-artifacts-{domain_id}-{account_id}"
@@ -0,0 +1 @@
1
+ """Agent inbox commands."""