lighthouse-cli 0.16.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.
Files changed (49) hide show
  1. lighthouse/__init__.py +6 -0
  2. lighthouse/__main__.py +9 -0
  3. lighthouse/commands/__init__.py +0 -0
  4. lighthouse/commands/agents.py +310 -0
  5. lighthouse/commands/builds.py +85 -0
  6. lighthouse/commands/collectors.py +147 -0
  7. lighthouse/commands/connect.py +717 -0
  8. lighthouse/commands/connections.py +201 -0
  9. lighthouse/commands/evals.py +198 -0
  10. lighthouse/commands/kb.py +224 -0
  11. lighthouse/commands/language.py +56 -0
  12. lighthouse/commands/limits.py +61 -0
  13. lighthouse/commands/login.py +207 -0
  14. lighthouse/commands/models.py +61 -0
  15. lighthouse/commands/runs.py +157 -0
  16. lighthouse/commands/spend.py +84 -0
  17. lighthouse/commands/stack.py +103 -0
  18. lighthouse/commands/terms.py +50 -0
  19. lighthouse/commands/whoami.py +80 -0
  20. lighthouse/config.py +141 -0
  21. lighthouse/credentials.py +233 -0
  22. lighthouse/device.py +124 -0
  23. lighthouse/exits.py +78 -0
  24. lighthouse/flags.py +61 -0
  25. lighthouse/http.py +355 -0
  26. lighthouse/i18n.py +250 -0
  27. lighthouse/locales/ar.json +343 -0
  28. lighthouse/locales/de.json +343 -0
  29. lighthouse/locales/en.json +343 -0
  30. lighthouse/locales/es.json +343 -0
  31. lighthouse/locales/fr.json +343 -0
  32. lighthouse/locales/ga.json +343 -0
  33. lighthouse/locales/it.json +343 -0
  34. lighthouse/locales/ja.json +343 -0
  35. lighthouse/locales/nl.json +343 -0
  36. lighthouse/locales/pt.json +343 -0
  37. lighthouse/locales/zh-Hans.json +343 -0
  38. lighthouse/locales/zh-Hant.json +343 -0
  39. lighthouse/main.py +229 -0
  40. lighthouse/out.py +169 -0
  41. lighthouse/py.typed +0 -0
  42. lighthouse/sealing.py +448 -0
  43. lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
  44. lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
  45. lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
  46. lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
  47. lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  48. lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
  49. lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,717 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse connect <provider>`: open the provider's own approval.
4
+
5
+ lighthouse connect # what is connected, and what can be
6
+ lighthouse connect github
7
+ lighthouse connect aws --no-browser
8
+ lighthouse connect gitlab --reuse-local # offers `glab`'s sign-in; asks first
9
+ security find-generic-password -s gh-ci -w | \
10
+ lighthouse connect github --credential-stdin --access read_write \
11
+ --target organisation=example-air # an existing token, as you
12
+ ... | lighthouse connect github --credential-stdin \
13
+ --deployment-key "$(terraform output -raw deployment_public_key)"
14
+
15
+ **What this command does and does not do.** It opens the portal's Connect page
16
+ for this tenant, which is where the provider's own approval is started:
17
+ GitHub's app installation, GitLab's OAuth approval, Datadog's integration
18
+ approval, the cloud roles' stack. It does not write
19
+ the connection itself, and that is deliberate rather than unfinished: the CLI
20
+ holds a *delegated* token, and a delegated token may never administer
21
+ (`docs/adr/0012-delegated-credentials.md`).
22
+
23
+ It opens `/connect` rather than `/connect/<provider>`, because the portal has
24
+ one Connect page and not one per connector. A deep link that 404s is worse
25
+ than a page with the right card on it, so the provider is named in this
26
+ command's own output instead.
27
+ Connecting a provider gives LightHouse standing access to a company's estate,
28
+ so it is approved by a person in the portal, under their own second factor,
29
+ and audited there — never by a token sitting in a shell history or a CI
30
+ variable.
31
+
32
+ So what the CLI saves a developer is the part that is actually tedious: finding
33
+ the right screen, with the right scopes, for the right tenant. One command
34
+ instead of four clicks.
35
+
36
+ **What is offered comes from the service.** The listing reads
37
+ `GET /api/v1/connectors`, the same catalogue the portal's Connect page shows:
38
+ each connector's `state` is `available` (connect it now), `engineer` (your
39
+ engineer configures it in your deployment; it is read today) or `coming`. The
40
+ CLI keeps no list of its own except as a fallback for a role that may not read
41
+ the catalogue, and says so when it uses it.
42
+
43
+ **Reusing a local sign-in.** Where `gh`, `glab` or a Datadog key is already set
44
+ up on this machine, `--reuse-local` offers to use it instead of a fresh
45
+ approval. It is offered **only on an explicit yes**: never on `--yes`, never
46
+ under `--no-input`, and never without naming exactly which local credential
47
+ would be read and where it would go. A tool that quietly harvested the
48
+ credentials it found on a developer's laptop would be indistinguishable from
49
+ one that should not be trusted. On that yes, and only then, the credential is
50
+ asked of the tool that owns it (`gh auth token`, `glab config get token`, or
51
+ `DD_API_KEY` and `DD_APP_KEY` from this shell, with `DD_SITE`), and handed
52
+ straight to the same path as `--credential-stdin` below: sealed on this
53
+ machine to your deployment where it can keep it, as you, never printed.
54
+ Refused, and said why, under `--yes`, under `--no-input`, when standard input
55
+ is not a terminal, on any answer but `yes`, and when the tool will not give
56
+ one.
57
+
58
+ **Feeding an existing token (`--credential-stdin`).** A token that already
59
+ exists (in a laptop's Keychain, in `gh auth token`, in a password manager) is
60
+ connected exactly as if the client's administrator had pasted it on the
61
+ Connect page: the same `POST /api/v1/connectors/<id>/token`, the same check at
62
+ the provider, the same sealing, the same audit entry naming them. Three rules
63
+ make that safe to script:
64
+
65
+ - **It is the person, not the CLI's token.** Connecting is administration, and
66
+ a delegated token administers nothing (ADR 0012), so this request carries
67
+ the person's own Cloudflare Access session for the tenant host instead,
68
+ from `cloudflared access token` (they sign in once with `cloudflared access
69
+ login https://<host>`, second factor included). The service sees a client
70
+ administrator at their own keyboard, which is what they are; the delegated
71
+ token is not sent at all. AAL staff and machine keys are refused by the
72
+ service as they are on the page.
73
+ - **The credential is read from standard input, never from an argument.** So
74
+ it is in no shell history, no process listing and no log. It is never
75
+ printed: the answer is the connection's card, which holds a fingerprint and
76
+ no credential.
77
+ - **Read is the default.** `--access read_write` is the administrator's
78
+ explicit choice, and the service refuses a token whose scopes disagree with
79
+ it (`credential_can_write`, `credential_cannot_write`), as the page does.
80
+ - **Finance, aviation, ERP and HR read only (contracts 1.27, 1.28).**
81
+ `--access read_write` for one of them is refused here, before standard input is read, with the
82
+ card's own reason (`read_only_connector`), and the service and the
83
+ deployment refuse it too.
84
+
85
+ **Sealed on this machine (contract 1.18).** Where the tenant's deployment
86
+ advertises a sealing key and both sides switched `secret.put` on
87
+ (`GET /api/v1/connectors/sealing`), the token is sealed here, to the
88
+ deployment's own key (`lighthouse/sealing.py`), and only the sealed blob, its
89
+ keyed fingerprint and the connection id it was sealed for are sent: the
90
+ control plane never sees the token, and the deployment writes it to the
91
+ client's own secrets store and checks it at the provider. With
92
+ `--deployment-key` (or `LIGHTHOUSE_DEPLOYMENT_KEY`), the deployment's public
93
+ key as the client's own infrastructure printed it, the advertisement must be
94
+ signed by exactly that key: a substituted sealing key is refused before
95
+ anything is sealed, and a deployment that cannot seal is refused rather than
96
+ sent the token in the clear. Without a pin, the advertisement is checked
97
+ against the keys the control plane says the deployment registered (the
98
+ Connect page's trust), and where nothing can seal, the token is sent as
99
+ before and sealed under the tenant's own key, which the answer says.
100
+ """
101
+ from __future__ import annotations
102
+
103
+ import argparse
104
+ import base64
105
+ import getpass
106
+ import re
107
+ import shutil
108
+ import subprocess # noqa: S404 # nosec B404 - asks `gh`/`glab` if they are signed in
109
+ from dataclasses import dataclass
110
+ from typing import Any
111
+
112
+ from lighthouse import sealing
113
+ from lighthouse.device import open_browser
114
+ from lighthouse.exits import FAILED, USAGE, CliError
115
+ from lighthouse.main import Command, Context
116
+ from lighthouse.out import Out
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Provider:
121
+ name: str
122
+ label: str
123
+ family: str # the service's family (1.26), or cloud for a deployment target
124
+ state: str # available | engineer | upgrade | coming | onboarding
125
+ scopes: str
126
+ local: str | None = None # the local tool whose sign-in could be reused
127
+ why: str = ""
128
+ kind: str = "" # what it holds (1.26): Mail, CRM, Service desk ...
129
+ read_only_why: str = "" # 1.27: why it connects read-only (finance, aviation), or ""
130
+
131
+ @property
132
+ def built(self) -> bool:
133
+ """Whether LightHouse reads it today: connectable in the portal, or
134
+ configured by the client's engineer."""
135
+ return self.state in ("available", "engineer")
136
+
137
+
138
+ # The local tool whose sign-in `--reuse-local` could offer, by connector id.
139
+ LOCAL = {"github": "gh", "gitlab": "glab", "datadog": "datadog"}
140
+ FAMILY = {"github": "code", "gitlab": "code", "datadog": "observability"}
141
+
142
+ # What is known without asking the service: the three connectors LightHouse
143
+ # connects today, with the read-only scopes the control plane asks for, and the
144
+ # four places the engine can be deployed. **The service's catalogue
145
+ # (`GET /api/v1/connectors`) is the authority**: `connect` reads it and uses this
146
+ # table only when it cannot (a role that may not read it, say), and for the
147
+ # deployment targets, which are not connectors.
148
+ FALLBACK: tuple[Provider, ...] = (
149
+ Provider("aws", "Amazon Web Services", "cloud", "available",
150
+ "a scoped role created by the one-click stack; read-only outside the engine"),
151
+ Provider("azure", "Microsoft Azure", "cloud", "available",
152
+ "a scoped app registration created by the Bicep stack"),
153
+ Provider("gcp", "Google Cloud", "cloud", "available",
154
+ "a scoped service account created by the Terraform stack"),
155
+ Provider("docker", "Your own servers (Docker or Podman)", "cloud", "available",
156
+ "no cloud credential: Compose runs the engine in your own estate"),
157
+ Provider("github", "GitHub", "code", "available",
158
+ "contents:read, metadata:read, issues:read, pull_requests:read", local="gh"),
159
+ Provider("gitlab", "GitLab", "code", "available",
160
+ "read_api", local="glab"),
161
+ Provider("datadog", "Datadog", "observability", "available",
162
+ "monitors_read, incident_read, notebooks_read", local="datadog"),
163
+ # The engine reads these; a client's own engineer configures them in the
164
+ # deployment's catalogue, so they are not connectable in the portal yet.
165
+ Provider("gdrive", "Google Drive", "data", "engineer", "drive.readonly"),
166
+ Provider("confluence", "Confluence", "data", "engineer", "read:content:confluence"),
167
+ Provider("jira", "Jira", "data", "engineer", "read:jira-work"),
168
+ Provider("upload", "File upload", "data", "engineer", "none: you upload the files"),
169
+ # The engine does not read these yet.
170
+ Provider("sharepoint", "SharePoint", "data", "coming", "Sites.Selected (read)"),
171
+ Provider("onedrive", "OneDrive", "data", "coming", "Files.Read.All"),
172
+ Provider("s3", "Amazon S3", "data", "coming",
173
+ "s3:GetObject, s3:ListBucket on the named prefix"),
174
+ Provider("azure-blob", "Azure Blob Storage", "data", "coming", "Storage Blob Data Reader"),
175
+ Provider("salesforce", "Salesforce", "data", "coming",
176
+ "api, refresh_token (read-only profile)"),
177
+ Provider("servicenow", "ServiceNow", "data", "coming", "snc_read_only"),
178
+ Provider("slack", "Slack", "data", "coming", "channels:history, files:read"),
179
+ Provider("teams", "Microsoft Teams", "data", "coming", "ChannelMessage.Read.All"),
180
+ Provider("postgres", "PostgreSQL", "data", "coming", "a read-only role you create"),
181
+ Provider("snowflake", "Snowflake", "data", "coming", "a read-only role you create"),
182
+ )
183
+ BY_NAME = {p.name: p for p in FALLBACK}
184
+
185
+
186
+ def from_catalogue(card: dict[str, Any]) -> Provider:
187
+ """A provider as the service describes it: its `state` is the service's own
188
+ (`available` to connect now, `engineer` where the client's engineer
189
+ configures it, `coming` where the engine does not read it yet)."""
190
+ name = str(card.get("connector"))
191
+ return Provider(name, str(card.get("name") or name),
192
+ str(card.get("family") or FAMILY.get(name, "data")),
193
+ str(card.get("state") or ("available" if card.get("available") else "coming")),
194
+ ", ".join(card.get("scopes") or []) or str(card.get("brings") or ""),
195
+ local=LOCAL.get(name), why=str(card.get("why") or ""),
196
+ kind=str(card.get("kind") or ""),
197
+ read_only_why=str(card.get("read_only_why") or ""))
198
+
199
+
200
+ def connect_url(host: str) -> str:
201
+ """Where a person approves a connection: the portal's one Connect page.
202
+
203
+ Not `/connect/<provider>`: the portal has a single page listing every
204
+ connector, so a per-provider path would be a 404 — and a link that 404s
205
+ teaches a developer to stop following this command's links.
206
+ """
207
+ return f"https://{host}/connect"
208
+
209
+
210
+ def _configure(ap: argparse.ArgumentParser) -> None:
211
+ ap.add_argument("provider", nargs="?",
212
+ help="the provider to connect; `list` (or nothing) to list them")
213
+ ap.add_argument("--family", default=None,
214
+ help="with list: one family (google, microsoft, oracle, crm, ...)")
215
+ ap.add_argument("--kind", default=None,
216
+ help="with list: what it holds (crm, mail, service-desk, ...)")
217
+ ap.add_argument("--search", default=None,
218
+ help="with list: words to find in the name, family, kind or description "
219
+ "(\"email\" finds Mail, \"tickets\" finds Service desk)")
220
+ ap.add_argument("--state", default=None,
221
+ choices=("available", "engineer", "upgrade", "coming", "onboarding"),
222
+ help="with list: only connectors in this state")
223
+ ap.add_argument("--no-browser", action="store_true", dest="no_browser",
224
+ help="print the approval URL instead of opening it")
225
+ ap.add_argument("--reuse-local", action="store_true", dest="reuse_local",
226
+ help="offer to reuse a sign-in already on this machine (asks first)")
227
+ ap.add_argument("--all", action="store_true", dest="every",
228
+ help="with no provider: list the coming ones too")
229
+ ap.add_argument("--credential-stdin", action="store_true", dest="credential_stdin",
230
+ help="connect an existing token, read from standard input, as yourself "
231
+ "(needs `cloudflared access login https://<host>`)")
232
+ ap.add_argument("--access", choices=("read", "read_write"), default="read",
233
+ help="with --credential-stdin: read (the default) or read_write")
234
+ ap.add_argument("--target", action="append", default=[], metavar="KEY=VALUE",
235
+ help="with --credential-stdin: what the connection points at "
236
+ "(organisation=..., instance_url=..., site=...); repeatable")
237
+ ap.add_argument("--deployment", default=None,
238
+ help="with --credential-stdin: the deployment that keeps the credential, "
239
+ "where the tenant has more than one")
240
+ ap.add_argument("--deployment-key", default=None, dest="deployment_key", metavar="KEY",
241
+ help="with --credential-stdin: pin the deployment's public key (base64 or "
242
+ "PEM, as your infrastructure printed it); the token is sealed only to "
243
+ "a sealing key that key signed (or LIGHTHOUSE_DEPLOYMENT_KEY)")
244
+
245
+
246
+ # -- an existing token, as the person --------------------------------------------
247
+
248
+ _TARGET_KEY = re.compile(r"^[a-z_]{1,40}$")
249
+ _HOST = re.compile(r"^[a-z0-9][a-z0-9.-]{0,252}[a-z0-9]$")
250
+
251
+
252
+ def person_session(host: str) -> str:
253
+ """The person's own Cloudflare Access session for `host`, from
254
+ `cloudflared access token`. Its output is the token and is never printed;
255
+ a failure says how to sign in rather than what cloudflared said."""
256
+ hint = f"sign in first: cloudflared access login https://{host}"
257
+ tool = shutil.which("cloudflared")
258
+ if not _HOST.match(host) or tool is None:
259
+ raise CliError("connecting a token needs your own portal session, from cloudflared",
260
+ code=USAGE, error="no_session", hint=hint)
261
+ try:
262
+ # A fixed argument vector, the tool's resolved path and a checked
263
+ # hostname: never a shell.
264
+ done = subprocess.run( # noqa: S603 # nosec B603
265
+ [tool, "access", "token", f"-app=https://{host}"],
266
+ capture_output=True, timeout=30, check=False, shell=False, text=True)
267
+ except (OSError, subprocess.SubprocessError):
268
+ raise CliError("cloudflared did not answer", code=USAGE, error="no_session",
269
+ hint=hint) from None
270
+ token = (done.stdout or "").strip()
271
+ if done.returncode != 0 or not token or any(c.isspace() for c in token):
272
+ raise CliError("there is no portal session for this host on this machine",
273
+ code=USAGE, error="no_session", hint=hint)
274
+ return token
275
+
276
+
277
+ def _targets(pairs: list[str]) -> dict[str, str]:
278
+ out: dict[str, str] = {}
279
+ for pair in pairs:
280
+ key, sep, value = pair.partition("=")
281
+ if not sep or not _TARGET_KEY.match(key) or not value.strip():
282
+ raise CliError(f"--target is KEY=VALUE, not {key!r}", code=USAGE,
283
+ error="bad_target")
284
+ out[key] = value.strip()
285
+ return out
286
+
287
+
288
+ def _read_credential(ctx: Context) -> str:
289
+ """The credential, from standard input. Piped, it is read whole (a Datadog
290
+ key pair and an Atlassian credential are two lines; a Google key is a
291
+ file); at a terminal, it is asked for without echo."""
292
+ import sys
293
+ stream = ctx.stdin or sys.stdin
294
+ if stream.isatty():
295
+ if getattr(ctx.args, "no_input", False):
296
+ raise CliError("pipe the credential in: --no-input is set", code=USAGE,
297
+ error="no_credential")
298
+ got = getpass.getpass("Paste the credential (not shown): ")
299
+ else:
300
+ got = stream.read()
301
+ got = got.rstrip("\r\n")
302
+ if len(got.strip()) < 8:
303
+ raise CliError("no credential on standard input", code=USAGE, error="no_credential",
304
+ hint="pipe it in: security find-generic-password -s <name> -w | "
305
+ "lighthouse connect <provider> --credential-stdin")
306
+ return got
307
+
308
+
309
+ def _pin(ctx: Context) -> bytes | None:
310
+ text = getattr(ctx.args, "deployment_key", None) or ctx.env.get("LIGHTHOUSE_DEPLOYMENT_KEY")
311
+ if not text:
312
+ return None
313
+ try:
314
+ return sealing.load_deployment_key(text)
315
+ except sealing.SealingError as e:
316
+ raise CliError(str(e), code=USAGE, error="bad_deployment_key") from None
317
+
318
+
319
+ def _sealer(ctx: Context, client: Any, provider: Provider) -> dict[str, Any] | None:
320
+ """Where this credential can be sealed to, or None to send it as before.
321
+
322
+ A pinned deployment key makes sealing a requirement: nothing is sent in
323
+ the clear, and an advertisement that key did not sign stops everything."""
324
+ pinned = _pin(ctx)
325
+ try:
326
+ info = client.get("/api/v1/connectors/sealing") or {}
327
+ except CliError as e:
328
+ # A control plane before 1.18 has no such route.
329
+ if pinned is None and e.extra.get("status") in (404, 405):
330
+ return None
331
+ raise
332
+ wanted = getattr(ctx.args, "deployment", None)
333
+ ready = [d for d in info.get("deployments") or [] if d.get("ready") and d.get("sealing")
334
+ and (wanted is None or d.get("deployment") == wanted)]
335
+ if provider.name not in (info.get("connectors") or []) or not ready:
336
+ if pinned is not None:
337
+ why = next((d.get("why") for d in info.get("deployments") or [] if d.get("why")), "")
338
+ raise CliError("your deployment cannot keep this credential in your own store, so "
339
+ "it was not sent", code=FAILED, error="sealing_unavailable",
340
+ hint=why or None)
341
+ return None
342
+ if len(ready) > 1:
343
+ raise CliError("more than one deployment can keep it: name one with --deployment",
344
+ code=USAGE, error="deployment_required",
345
+ hint=", ".join(sorted(str(d["deployment"]) for d in ready)))
346
+ dep = ready[0]
347
+ full = f"{info.get('tenant')}/{dep['deployment']}"
348
+ keys = [pinned] if pinned is not None else [
349
+ base64.b64decode(k) for k in dep.get("deployment_keys") or []]
350
+ if not sealing.verify_advert(dep["sealing"], full, keys):
351
+ raise CliError("the sealing key LightHouse relayed is not signed by "
352
+ + ("the deployment key you pinned" if pinned else
353
+ "your deployment's registered key")
354
+ + "; nothing was sent", code=FAILED, error="sealing_key_unverified")
355
+ return {"deployment": dep["deployment"], "full": full, "public": dep["sealing"]["public"],
356
+ "fingerprint_key": base64.b64decode(info.get("fingerprint_key") or ""),
357
+ "pinned": pinned is not None}
358
+
359
+
360
+ def _sealed_body(where: dict[str, Any], provider: Provider, credential: str,
361
+ target: dict[str, str]) -> dict[str, Any]:
362
+ try:
363
+ fields = sealing.credential_fields(provider.name, credential, target)
364
+ except sealing.SealingError as e:
365
+ raise CliError(str(e), code=USAGE, error="bad_credential") from None
366
+ cid = sealing.connection_id()
367
+ body = {"sealed": sealing.seal(where["public"], sealing.canonical(fields),
368
+ deployment=where["full"], connector=provider.name, name=cid),
369
+ "fingerprint": sealing.fingerprint(fields, where["fingerprint_key"]),
370
+ "connection": cid, "deployment": where["deployment"], "target": target,
371
+ "sealed_by": "cli"}
372
+ fields.clear()
373
+ return body
374
+
375
+
376
+ def _connect_token(ctx: Context, provider: Provider, credential: str | None = None,
377
+ target: dict[str, str] | None = None) -> dict[str, Any]:
378
+ """`POST /api/v1/connectors/<id>/token` as the person: the credential
379
+ sealed here to the deployment where it can keep it (1.18), otherwise in
380
+ the request body and nowhere else. Dropped once sent either way. Given
381
+ `credential` (a reused local sign-in), standard input is not read."""
382
+ if provider.state != "available":
383
+ raise CliError(f"{provider.label} cannot be connected with a token here "
384
+ f"({provider.state})", code=USAGE, error="not_connectable")
385
+ target = {**_targets(getattr(ctx.args, "target", []) or []), **(target or {})}
386
+ access = getattr(ctx.args, "access", "read") or "read"
387
+ if access != "read" and (provider.name in sealing.WAVE_046
388
+ or provider.name in sealing.WAVE_047):
389
+ # 1.27: finance and aviation read only; refused here, before anything
390
+ # is read from standard input or sealed.
391
+ raise CliError(provider.read_only_why or f"{provider.label} connects read-only; "
392
+ "connect it with --access read", code=USAGE, error="read_only_connector")
393
+ credential = credential if credential is not None else _read_credential(ctx)
394
+ host = ctx.host
395
+ client = ctx.new_client(host, None, session=person_session(host))
396
+ path = f"/api/v1/connectors/{provider.name}/token"
397
+ where = None
398
+ try:
399
+ where = _sealer(ctx, client, provider)
400
+ if where is None and (provider.name in sealing.ENTERPRISE
401
+ or provider.name in sealing.WAVE_046
402
+ or provider.name in sealing.WAVE_047):
403
+ # Contracts 1.26 to 1.28: these are sealed here or not sent at all.
404
+ engine = ("0.47.1" if provider.name in sealing.WAVE_047
405
+ else "0.46.0" if provider.name in sealing.WAVE_046 else "0.45.0")
406
+ raise CliError(f"{provider.label}'s credential is only ever sealed to your "
407
+ f"deployment, which cannot keep it yet (engine {engine} with its own "
408
+ "store); nothing was sent", code=FAILED, error="sealing_required")
409
+ if where is None:
410
+ body: dict[str, Any] = {"credential": credential, "target": target}
411
+ else:
412
+ body = _sealed_body(where, provider, credential, target)
413
+ if access != "read":
414
+ body["access"] = access
415
+ try:
416
+ got = client.post(path, body) or {}
417
+ except CliError as e:
418
+ # The deployment rotated its sealing key since it was read: seal
419
+ # again to the new one, once.
420
+ if where is None or e.error not in ("sealing_key_changed", "conflict"):
421
+ raise
422
+ where = _sealer(ctx, client, provider)
423
+ if where is None:
424
+ raise
425
+ body = {**_sealed_body(where, provider, credential, target),
426
+ **({"access": access} if access != "read" else {})}
427
+ got = client.post(path, body) or {}
428
+ finally:
429
+ credential = ""
430
+ client.close()
431
+ pending = bool(got.get("pending"))
432
+ return {"ok": True, "provider": provider.name, "label": provider.label,
433
+ "connected": not pending, "pending": pending, "connection": got.get("id"),
434
+ "access": got.get("access"),
435
+ "can_write": got.get("can_write"), "write_scopes": got.get("write_scopes") or [],
436
+ "access_note": got.get("access_note"), "state": got.get("state"),
437
+ "fingerprint": got.get("fingerprint"), "replaced": got.get("replaced") or [],
438
+ "card": got.get("label"),
439
+ "sealed_by": got.get("sealed_by") or ("cli" if where else None),
440
+ "pinned": bool(where and where["pinned"]),
441
+ "deployment": got.get("deployment"), "custody": got.get("custody"),
442
+ "reason": got.get("reason") if pending else None,
443
+ "command": (got.get("command") or {}).get("command_id") if pending else None}
444
+
445
+
446
+ # -- detecting a local sign-in ---------------------------------------------------
447
+
448
+ def _ran_ok(argv: list[str]) -> bool:
449
+ """Whether a local tool reports itself signed in. Its output is never read
450
+ and never printed: a token could be in it."""
451
+ if shutil.which(argv[0]) is None:
452
+ return False
453
+ try:
454
+ # A fixed argument vector from the table above: never a shell, and never
455
+ # anything a person typed.
456
+ done = subprocess.run(argv, capture_output=True, timeout=10, # noqa: S603 # nosec B603
457
+ check=False, shell=False)
458
+ except (OSError, subprocess.SubprocessError):
459
+ return False
460
+ return done.returncode == 0
461
+
462
+
463
+ def local_signin(provider: Provider, env: dict[str, str]) -> dict[str, Any] | None:
464
+ """What is already signed in on this machine for `provider`, or None.
465
+
466
+ Only the *fact* of a sign-in is established — never the credential. The
467
+ credential is read only after a person says yes, and then by the tool that
468
+ owns it, not by reading its files.
469
+ """
470
+ if provider.local == "gh" and _ran_ok(["gh", "auth", "status"]):
471
+ return {"tool": "gh", "how": "`gh auth token` would be asked for the token"}
472
+ if provider.local == "glab" and _ran_ok(["glab", "auth", "status"]):
473
+ return {"tool": "glab", "how": "`glab config get token` would be asked for the token"}
474
+ if provider.local == "datadog" and env.get("DD_API_KEY") and env.get("DD_APP_KEY"):
475
+ return {"tool": "environment",
476
+ "how": "DD_API_KEY and DD_APP_KEY are set in this shell"}
477
+ return None
478
+
479
+
480
+ def catalogue(ctx: Context) -> tuple[dict[str, Provider], list[dict[str, Any]], str]:
481
+ """The connectors and the connections, from `GET /api/v1/connectors`.
482
+
483
+ Returns `(providers by name, connections, source)`. Where the service will
484
+ not say (exit 3 or 4: a role that may not read it), the built-in table
485
+ stands in, `source` says `built-in`, and the command warns, so a label is
486
+ never presented as the service's when it is the CLI's.
487
+ """
488
+ query = _filters(ctx)
489
+ try:
490
+ body = ctx.client().get("/api/v1/connectors", **query) or {}
491
+ except CliError as e:
492
+ if e.code not in (3, 4):
493
+ raise
494
+ ctx.out.warn(f"could not read the connector catalogue ({e.error}); "
495
+ f"showing what this CLI knows")
496
+ return dict(BY_NAME), [], "built-in"
497
+ found = {str(c.get("connector")): from_catalogue(c) for c in body.get("connectors") or []}
498
+ # Deployment targets are not connectors, so the catalogue does not list
499
+ # them; a filtered listing is the catalogue's alone.
500
+ for p in FALLBACK:
501
+ if p.family == "cloud" and not query:
502
+ found.setdefault(p.name, p)
503
+ live = [c for c in body.get("connections") or [] if c.get("state") != "disconnected"]
504
+ return found, live, "service"
505
+
506
+
507
+ def _filters(ctx: Context) -> dict[str, str]:
508
+ """`--family`, `--kind`, `--search` and `--state`, as the service's query
509
+ parameters (contract 1.26). The service matches them: synonyms, case and
510
+ partial words, the same rules as the portal and the docs."""
511
+ got = {"family": getattr(ctx.args, "family", None), "kind": getattr(ctx.args, "kind", None),
512
+ "q": getattr(ctx.args, "search", None), "state": getattr(ctx.args, "state", None)}
513
+ return {k: str(v) for k, v in got.items() if v}
514
+
515
+
516
+ def _local_match(p: Provider, query: dict[str, str]) -> bool:
517
+ """The built-in table's stand-in for the service's filters: exact family,
518
+ kind and state, and every search word somewhere in the label or family."""
519
+ def norm(v: str) -> str:
520
+ return re.sub(r"[\s_\-]+", " ", v.strip().lower())
521
+ hay = norm(f"{p.name} {p.label} {p.family} {p.kind} {p.scopes}")
522
+ return ((not query.get("family") or norm(p.family) == norm(query["family"]))
523
+ and (not query.get("kind") or norm(p.kind) == norm(query["kind"]))
524
+ and (not query.get("state") or p.state == query["state"])
525
+ and all(w in hay for w in norm(query.get("q", "")).split()))
526
+
527
+
528
+ def _listing(ctx: Context) -> dict[str, Any]:
529
+ known, connections, source = catalogue(ctx)
530
+ live = {str(c.get("connector")) for c in connections}
531
+ query = _filters(ctx)
532
+ every = getattr(ctx.args, "every", False) or bool(query)
533
+ rows = [p for p in known.values() if (p.built or every)
534
+ and (source == "service" or _local_match(p, query))]
535
+ return {"ok": True, "source": source, "filters": query, "providers": [
536
+ {"provider": p.name, "label": p.label, "family": p.family, "kind": p.kind,
537
+ "status": "connected" if p.name in live else p.state,
538
+ "scopes": p.scopes,
539
+ "connect": connect_url(ctx.host)} for p in rows],
540
+ "connected": len(live)}
541
+
542
+
543
+ def _show_listing(out: Out, body: dict[str, Any]) -> None:
544
+ out.table(["provider", "what", "family", "kind", "status", "read-only scopes"],
545
+ [[p["provider"], p["label"], p["family"], p.get("kind", ""), p["status"],
546
+ p["scopes"]] for p in body["providers"]])
547
+ if not body["providers"]:
548
+ out.note("no connector matches; ask for one from Help, or tell your Atlantic contact")
549
+ out.note("`lighthouse connect <provider>` opens the approval; `--all` lists the coming "
550
+ "ones too; `connect list --family F --kind K --search WORDS` narrows the list")
551
+
552
+
553
+ def connect(ctx: Context) -> dict[str, Any]:
554
+ name = getattr(ctx.args, "provider", None)
555
+ if not name or name == "list":
556
+ return _listing(ctx)
557
+ known, _connections, _source = catalogue(ctx)
558
+ provider = known.get(name.lower())
559
+ if provider is None:
560
+ raise CliError(f"{name!r} is not a provider LightHouse knows", code=USAGE,
561
+ error="unknown_provider",
562
+ hint="run `lighthouse connect --all` to see every one")
563
+ if getattr(ctx.args, "credential_stdin", False):
564
+ return _connect_token(ctx, provider)
565
+ url = connect_url(ctx.host)
566
+ out: dict[str, Any] = {"ok": True, "provider": provider.name, "label": provider.label,
567
+ "status": provider.state,
568
+ "approval_url": url, "scopes": provider.scopes,
569
+ "opened": False, "reuse": None}
570
+ if provider.state == "coming":
571
+ out["message"] = (f"connecting {provider.label} is coming; the page lists it and says so")
572
+ elif provider.state == "engineer":
573
+ out["message"] = provider.why or (f"{provider.label} is configured by your engineer in "
574
+ f"your deployment, not connected from the portal")
575
+ if getattr(ctx.args, "reuse_local", False):
576
+ found = local_signin(provider, ctx.env)
577
+ if found is None:
578
+ out["reuse"] = {"offered": False,
579
+ "reason": f"no local sign-in for {provider.label} on this machine"}
580
+ else:
581
+ out["reuse"] = _offer_reuse(ctx, provider, found)
582
+ if out["reuse"].get("sent"):
583
+ return {**out["reuse"]["connection"], "reuse": {
584
+ k: v for k, v in out["reuse"].items() if k != "connection"}}
585
+ if not ctx.args.no_browser:
586
+ out["opened"] = open_browser(url, opener=ctx.opener)
587
+ return out
588
+
589
+
590
+ DD_SITES = {v: k for k, v in sealing.DATADOG_ENGINE_SITES.items()}
591
+
592
+
593
+ def _tool_output(argv: list[str]) -> str | None:
594
+ """The first line a local tool prints, or None. Never printed or logged:
595
+ it is a credential."""
596
+ tool = shutil.which(argv[0])
597
+ if tool is None:
598
+ return None
599
+ try:
600
+ # A fixed argument vector from this module, the tool's resolved path:
601
+ # never a shell, never anything typed.
602
+ done = subprocess.run([tool, *argv[1:]], capture_output=True, # noqa: S603 # nosec B603
603
+ timeout=15, check=False, shell=False, text=True)
604
+ except (OSError, subprocess.SubprocessError):
605
+ return None
606
+ got = (done.stdout or "").strip().splitlines()
607
+ if done.returncode != 0 or not got or any(c.isspace() for c in got[0].strip()):
608
+ return None
609
+ return got[0].strip()
610
+
611
+
612
+ def read_local(provider: Provider, env: dict[str, str]) -> tuple[str, dict[str, str]] | None:
613
+ """The credential, from the tool that owns it, and what it points at.
614
+ Called only after a person typed yes."""
615
+ if provider.local == "gh":
616
+ token = _tool_output(["gh", "auth", "token"])
617
+ return (token, {}) if token else None
618
+ if provider.local == "glab":
619
+ host = (env.get("GITLAB_HOST") or "gitlab.com").strip()
620
+ if not re.match(r"^[a-z0-9][a-z0-9.-]{0,252}$", host.lower()):
621
+ return None
622
+ token = _tool_output(["glab", "config", "get", "token", "--host", host])
623
+ target = {} if host == "gitlab.com" else {"instance_url": f"https://{host}"}
624
+ return (token, target) if token else None
625
+ if provider.local == "datadog":
626
+ api, app = env.get("DD_API_KEY", "").strip(), env.get("DD_APP_KEY", "").strip()
627
+ if not api or not app:
628
+ return None
629
+ site = DD_SITES.get(env.get("DD_SITE", "").strip().lower(), "")
630
+ return f"{api}\n{app}", ({"site": site} if site else {})
631
+ return None
632
+
633
+
634
+ def _offer_reuse(ctx: Context, provider: Provider, found: dict[str, Any]) -> dict[str, Any]:
635
+ """Ask, in as many words, before touching a credential this machine holds.
636
+
637
+ `--yes` does not answer this one, and neither does a pipe. A blanket yes on
638
+ the command line is consent to the command's own work, not consent to hand
639
+ a developer's personal GitHub token to a service, and the two must not be
640
+ the same keystroke; a `yes` piped into standard input is not a person.
641
+ """
642
+ import sys
643
+ question = (f"Reuse the {found['tool']} sign-in on this machine for {provider.label}? "
644
+ f"{found['how']}. It would be sealed on this machine and sent to {ctx.host} "
645
+ f"as you, to be kept in your own deployment where it can, or under your "
646
+ f"tenant's own key")
647
+ refusal = ("no_input" if getattr(ctx.args, "no_input", False) else
648
+ "yes_flag" if getattr(ctx.args, "yes", False) else
649
+ "not_interactive" if not (ctx.stdin or sys.stdin).isatty() else
650
+ "not_connectable" if provider.state != "available" else None)
651
+ if refusal is not None:
652
+ why = {"no_input": "--no-input is set", "yes_flag": "--yes is not consent to hand "
653
+ "over a credential", "not_interactive": "standard input is not a terminal",
654
+ "not_connectable": f"{provider.label} is not connected with a token"}[refusal]
655
+ ctx.out.note(f"{question}: not asked, because {why}")
656
+ return {"offered": True, "accepted": False, "reason": refusal}
657
+ ctx.out.say(question + ".")
658
+ answer = (ctx.reader or input)("Type yes to use it, anything else to approve in the "
659
+ "browser instead: ").strip().lower()
660
+ if answer != "yes":
661
+ return {"offered": True, "accepted": False, "reason": "declined"}
662
+ got = read_local(provider, ctx.env)
663
+ if got is None:
664
+ return {"offered": True, "accepted": True, "sent": False, "tool": found["tool"],
665
+ "reason": "local_read_failed",
666
+ "message": f"{found['tool']} did not give a credential; nothing was sent"}
667
+ credential, target = got
668
+ try:
669
+ connected = _connect_token(ctx, provider, credential=credential, target=target)
670
+ finally:
671
+ credential = ""
672
+ return {"offered": True, "accepted": True, "sent": True, "tool": found["tool"],
673
+ "connection": connected}
674
+
675
+
676
+ def _show(out: Out, body: dict[str, Any]) -> None:
677
+ if "providers" in body:
678
+ _show_listing(out, body)
679
+ return
680
+ if body.get("pending"):
681
+ out.pairs([("sent to your own store", body.get("card") or body["label"]),
682
+ ("deployment", body.get("deployment") or ""),
683
+ ("access", "can write" if body.get("can_write") else "read only"),
684
+ ("connection", body.get("connection") or "")])
685
+ out.note(body.get("reason") or "waiting for your deployment to keep it")
686
+ if body.get("custody"):
687
+ out.note(body["custody"])
688
+ if body.get("sealed_by") == "cli" and not body.get("pinned"):
689
+ out.note("pin --deployment-key to check the sealing key against your own "
690
+ "infrastructure's record of the deployment")
691
+ return
692
+ if body.get("connected"):
693
+ out.pairs([("connected", body.get("card") or body["label"]),
694
+ ("access", "can write" if body.get("can_write") else "read only"),
695
+ ("state", body.get("state") or ""),
696
+ ("connection", body.get("connection") or "")])
697
+ if body.get("access_note"):
698
+ out.note(body["access_note"])
699
+ if body.get("custody"):
700
+ out.note(body["custody"])
701
+ return
702
+ out.pairs([("provider", body["label"]), ("status", body["status"]),
703
+ ("read-only scopes", body["scopes"]), ("approve at", body["approval_url"])])
704
+ out.note(f"the Connect page lists every connector; use the {body['label']} card")
705
+ if body.get("message"):
706
+ out.warn(body["message"])
707
+ reuse = body.get("reuse")
708
+ if reuse and reuse.get("message"):
709
+ out.warn(reuse["message"])
710
+ elif reuse and reuse.get("offered") and not reuse.get("accepted"):
711
+ out.note("not reusing a local sign-in")
712
+ if body["opened"]:
713
+ out.note("opened in your browser")
714
+
715
+
716
+ COMMANDS = (Command("connect", "open a provider's own approval for this tenant",
717
+ _configure, connect, _show),)