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
lighthouse/out.py ADDED
@@ -0,0 +1,169 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """What the command line prints, and where.
4
+
5
+ Three rules, each of them something a pipeline depends on:
6
+
7
+ - **`--json` is the whole of standard output.** One JSON document, nothing
8
+ else: no progress line, no spinner, no "signing you in…". Anything a person
9
+ would want to read goes to standard error instead, so `lighthouse spend
10
+ --json | jq` works even while the command is talking to the person running
11
+ it.
12
+ - **No colour and no cursor control unless standard output is a terminal.**
13
+ Also honoured: `NO_COLOR` (any value), `TERM=dumb`, and `CI`. A log file
14
+ full of escape sequences is a log file nobody greps.
15
+ - **Nothing secret is ever printed.** `redact()` is applied to every value on
16
+ its way out, and `tests/test_out.py` holds it to that.
17
+
18
+ Tables are plain: two spaces between columns, no box drawing, so a column can
19
+ be cut with `cut -f` after `tr -s ' ' '\\t'` and the output reads the same in a
20
+ terminal, a pipe and a GitLab job log.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ import unicodedata
29
+ from typing import Any, TextIO
30
+
31
+ from lighthouse.i18n import label, t
32
+
33
+ # A credential's shape, wherever it appears. The CLI holds tokens in the
34
+ # keychain and never needs to print one, so anything matching here is a bug
35
+ # caught before it reaches a terminal, a log or a bug report.
36
+ _SECRET = re.compile(
37
+ r"\b(lhat|lhrt|lhdc|lhac|aalcp)-[A-Za-z0-9_-]{4,}" # LightHouse credentials
38
+ r"|\bgh[pousr]_[A-Za-z0-9]{16,}" # GitHub tokens
39
+ r"|\bglpat-[A-Za-z0-9_-]{16,}" # GitLab tokens
40
+ r"|\bAKIA[0-9A-Z]{16}\b" # AWS access key ids
41
+ r"|\bsk-[A-Za-z0-9]{20,}") # model provider keys
42
+ REDACTED = "[redacted]"
43
+
44
+ _COLOURS = {"reset": "\x1b[0m", "bold": "\x1b[1m", "dim": "\x1b[2m",
45
+ "red": "\x1b[31m", "green": "\x1b[32m", "yellow": "\x1b[33m"}
46
+
47
+
48
+ def width(text: str) -> int:
49
+ """Terminal columns `text` takes: two for each East Asian wide or fullwidth
50
+ character, none for a combining mark or a format character (Arabic
51
+ diacritics, bidi marks), one for the rest. Arabic is left as the service
52
+ sent it, in logical order: a terminal's own shaping shows it, and
53
+ reversing it here would break it."""
54
+ cols = 0
55
+ for ch in text:
56
+ if unicodedata.combining(ch) or unicodedata.category(ch) in ("Mn", "Me", "Cf"):
57
+ continue
58
+ cols += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
59
+ return cols
60
+
61
+
62
+ def pad(text: str, cols: int) -> str:
63
+ """`text` left-aligned in `cols` terminal columns (`str.ljust` by width)."""
64
+ return text + " " * max(0, cols - width(text))
65
+
66
+
67
+ def redact(value: Any) -> Any:
68
+ """`value` with anything shaped like a credential replaced."""
69
+ if isinstance(value, str):
70
+ return _SECRET.sub(REDACTED, value)
71
+ if isinstance(value, dict):
72
+ return {k: redact(v) for k, v in value.items()}
73
+ if isinstance(value, (list, tuple)):
74
+ return [redact(v) for v in value]
75
+ return value
76
+
77
+
78
+ class Out:
79
+ """Where a command writes. One instance per run, on the parsed options."""
80
+
81
+ def __init__(self, *, as_json: bool = False, quiet: bool = False,
82
+ stdout: TextIO | None = None, stderr: TextIO | None = None,
83
+ env: dict[str, str] | None = None, isatty: bool | None = None) -> None:
84
+ self.as_json = as_json
85
+ self.quiet = quiet
86
+ self.stdout = stdout if stdout is not None else sys.stdout
87
+ self.stderr = stderr if stderr is not None else sys.stderr
88
+ self.env = env if env is not None else dict(os.environ)
89
+ if isatty is None:
90
+ isatty = bool(getattr(self.stdout, "isatty", lambda: False)())
91
+ # Set by a command that writes JSON Lines itself (`runs tail`), so
92
+ # `main` does not then print a closing document after it and leave
93
+ # standard output a mixture of the two.
94
+ self.streamed = False
95
+ self.colour = bool(isatty) and not self.as_json and not (
96
+ self.env.get("NO_COLOR") is not None
97
+ or self.env.get("TERM") == "dumb"
98
+ or self.env.get("CI"))
99
+
100
+ # -- the machine's channel ---------------------------------------------------
101
+
102
+ def json(self, body: Any) -> None:
103
+ """The one document `--json` prints. Sorted keys, so a golden test and
104
+ a `diff` both mean something."""
105
+ print(json.dumps(redact(body), indent=2, sort_keys=True, default=str),
106
+ file=self.stdout)
107
+
108
+ def json_line(self, body: Any) -> None:
109
+ """One JSON document on its own line, for a command that streams
110
+ (`runs tail`). Marks the stream, so no closing document follows."""
111
+ self.streamed = True
112
+ print(json.dumps(redact(body), sort_keys=True, default=str), file=self.stdout)
113
+
114
+ # -- the person's channel ----------------------------------------------------
115
+
116
+ def paint(self, text: str, colour: str) -> str:
117
+ if not self.colour or colour not in _COLOURS:
118
+ return text
119
+ return f"{_COLOURS[colour]}{text}{_COLOURS['reset']}"
120
+
121
+ def say(self, text: str = "") -> None:
122
+ """A line for the person. Standard output in human mode, standard error
123
+ under `--json`, so it never lands in the document."""
124
+ if self.quiet:
125
+ return
126
+ print(redact(text), file=self.stderr if self.as_json else self.stdout)
127
+
128
+ def note(self, text: str) -> None:
129
+ """Always to standard error: progress, prompts and advice, in both modes."""
130
+ if not self.quiet:
131
+ print(self.paint(redact(text), "dim"), file=self.stderr)
132
+
133
+ def warn(self, text: str) -> None:
134
+ print(self.paint(f"{t('out.warning')}: {redact(text)}", "yellow"), file=self.stderr)
135
+
136
+ def fail(self, text: str, *, hint: str | None = None) -> None:
137
+ print(self.paint(f"{t('out.error')}: {redact(text)}", "red"), file=self.stderr)
138
+ if hint:
139
+ print(self.paint(f" {redact(hint)}", "dim"), file=self.stderr)
140
+
141
+ def done(self, text: str) -> None:
142
+ self.say(self.paint(text, "green"))
143
+
144
+ # -- tables --------------------------------------------------------------------
145
+
146
+ def table(self, headers: list[str], rows: list[list[Any]]) -> None:
147
+ """Columns padded to the widest cell, two spaces between. No borders:
148
+ nothing to strip before the output is useful to another program."""
149
+ if self.quiet:
150
+ return
151
+ headers = [label(h) for h in headers]
152
+ body = [[("" if c is None else str(redact(c))) for c in row] for row in rows]
153
+ if not body:
154
+ self.say(t("out.nothing"))
155
+ return
156
+ widths = [max(width(h), *(width(r[i]) for r in body)) for i, h in enumerate(headers)]
157
+ self.say(" ".join(self.paint(pad(h, widths[i]), "bold")
158
+ for i, h in enumerate(headers)).rstrip())
159
+ for row in body:
160
+ self.say(" ".join(pad(cell, widths[i]) for i, cell in enumerate(row)).rstrip())
161
+
162
+ def pairs(self, items: list[tuple[str, Any]]) -> None:
163
+ if self.quiet or not items:
164
+ return
165
+ items = [(label(k), v) for k, v in items]
166
+ cols = max(width(k) for k, _ in items)
167
+ for key, value in items:
168
+ shown = "—" if value is None or value == "" else str(redact(value))
169
+ self.say(f"{self.paint(pad(key, cols), 'bold')} {shown}")
lighthouse/py.typed ADDED
File without changes
lighthouse/sealing.py ADDED
@@ -0,0 +1,448 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Sealing a credential on this machine to the client's own deployment
4
+ (contract 1.18).
5
+
6
+ `lighthouse connect <id> --credential-stdin` seals the token here, to the
7
+ X25519 key the deployment advertised, so the control plane relays a blob it
8
+ cannot open and the token never leaves this machine in the clear. The
9
+ primitive is RFC 9180 HPKE, base mode (DHKEM(X25519, HKDF-SHA256),
10
+ HKDF-SHA256, AES-256-GCM, empty AAD), from `cryptography`'s own `hpke.Suite`,
11
+ with the `info` the engine binds:
12
+
13
+ aal-sealed/1\\n<tenant>/<deployment>\\n<connector>\\n<connection id>
14
+
15
+ and the wire form `hpke1.<kid>.<base64url of enc || ciphertext>`.
16
+
17
+ **Pinning.** The advertisement is signed with the deployment's own Ed25519
18
+ key. Given that key as the client's own infrastructure printed it
19
+ (`--deployment-key`, or `LIGHTHOUSE_DEPLOYMENT_KEY`), the CLI checks the
20
+ signature against it and nothing else, so a sealing key substituted on the
21
+ way (by anyone, AAL's control plane included) is caught before anything is
22
+ sealed. Without a pin it checks against the keys the control plane says the
23
+ deployment registered, which is the same trust as the Connect page's.
24
+
25
+ The mapping from what is pasted to the fields the engine stores, the
26
+ canonical bytes and the keyed fingerprint are the service's
27
+ (`aal_control/sealing.py`) to the byte, and the tests hold both to the same
28
+ vectors (docs/api/sealing-vectors.json).
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import hashlib
34
+ import hmac
35
+ import json
36
+ import re
37
+ import secrets
38
+ from collections.abc import Mapping
39
+ from typing import Any
40
+
41
+ PROTOCOL = "aal-sealed/1"
42
+ SUITE = "hpke-base-x25519-hkdfsha256-aes256gcm"
43
+ ADVERT_LINE = "aal-sealing-key/1"
44
+ _KID = re.compile(r"^[0-9a-f]{16}$")
45
+ _ARMOUR = re.compile(r"-----BEGIN PUBLIC KEY-----.*?-----END PUBLIC KEY-----", re.S)
46
+ DATADOG_ENGINE_SITES = {"us1": "datadoghq.com", "us3": "us3.datadoghq.com",
47
+ "us5": "us5.datadoghq.com", "eu1": "datadoghq.eu",
48
+ "ap1": "ap1.datadoghq.com", "us1-fed": "ddog-gov.com"}
49
+ CONNECTORS = ("confluence", "datadog", "gdrive", "github", "gitlab", "gmail", "jira",
50
+ # Contract 1.26: sealed here, never sent in the clear.
51
+ "dynamics", "hubspot", "monday", "netsuite", "notion", "oracle-db",
52
+ "oracle-fusion", "salesforce", "servicenow", "zendesk",
53
+ # 1.27: finance and aviation, sealed where pasted, read-only.
54
+ "bank-statements", "binance", "coinbase-prime", "dfns", "fireblocks", "kraken",
55
+ "kyriba", "quickbooks", "revolut", "stripe", "xero",
56
+ "amadeus", "cirium", "eccairs", "flightaware", "intelex", "oag", "qpulse",
57
+ "sabre", "sita-bags", "sita-flights", "xapi",
58
+ # 1.28: ERP and HR, sealed where pasted, read-only.
59
+ "sap-s4hana", "sap-successfactors", "workday")
60
+
61
+
62
+ class SealingError(ValueError):
63
+ """A credential that cannot be sealed as given. Names a field, never a value."""
64
+
65
+
66
+ def key_id(raw_public: bytes) -> str:
67
+ return hashlib.sha256(raw_public).hexdigest()[:16]
68
+
69
+
70
+ def info_for(deployment: str, connector: str, name: str) -> bytes:
71
+ return "\n".join((PROTOCOL, deployment, connector, name)).encode("ascii")
72
+
73
+
74
+ def advert_payload(deployment: str, kid: str, public_b64: str) -> bytes:
75
+ return "\n".join((ADVERT_LINE, deployment, kid, public_b64)).encode("ascii")
76
+
77
+
78
+ def load_deployment_key(text: str) -> bytes:
79
+ """The deployment's Ed25519 public key as its infrastructure printed it:
80
+ the raw 32 bytes in base64, or a PEM `PUBLIC KEY` block."""
81
+ text = text.strip()
82
+ if _ARMOUR.search(text):
83
+ from cryptography.hazmat.primitives import serialization
84
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
85
+ key = serialization.load_pem_public_key(text.encode("ascii"))
86
+ if not isinstance(key, Ed25519PublicKey):
87
+ raise SealingError("the deployment key is an Ed25519 public key")
88
+ return key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
89
+ try:
90
+ raw = base64.b64decode(text, validate=True)
91
+ except ValueError:
92
+ raise SealingError("the deployment key is 32 bytes in base64, or a PEM block") from None
93
+ if len(raw) != 32:
94
+ raise SealingError("the deployment key is 32 bytes in base64, or a PEM block")
95
+ return raw
96
+
97
+
98
+ def verify_advert(advert: Any, deployment: str, keys: list[bytes]) -> bool:
99
+ """Whether the advertisement is the deployment's own: this suite, a
100
+ 32-byte key whose id is `kid`, signed by one of `keys`."""
101
+ from cryptography.exceptions import InvalidSignature
102
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
103
+ if not isinstance(advert, dict):
104
+ return False
105
+ public, kid, sig = advert.get("public"), advert.get("kid"), advert.get("signature")
106
+ if not (advert.get("protocol") == PROTOCOL and advert.get("suite") == SUITE
107
+ and isinstance(public, str) and isinstance(kid, str) and _KID.match(kid)
108
+ and isinstance(sig, str)):
109
+ return False
110
+ try:
111
+ raw = base64.b64decode(public, validate=True)
112
+ signature = base64.b64decode(sig, validate=True)
113
+ except ValueError:
114
+ return False
115
+ if len(raw) != 32 or key_id(raw) != kid:
116
+ return False
117
+ for k in keys:
118
+ try:
119
+ Ed25519PublicKey.from_public_bytes(k).verify(
120
+ signature, advert_payload(deployment, kid, public))
121
+ return True
122
+ except (InvalidSignature, ValueError):
123
+ continue
124
+ return False
125
+
126
+
127
+ def seal(public_b64: str, plaintext: bytes, *, deployment: str, connector: str,
128
+ name: str) -> str:
129
+ from cryptography.hazmat.primitives import hpke
130
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
131
+ raw = base64.b64decode(public_b64, validate=True)
132
+ suite = hpke.Suite(hpke.KEM.X25519, hpke.KDF.HKDF_SHA256, hpke.AEAD.AES_256_GCM)
133
+ blob = suite.encrypt(plaintext, X25519PublicKey.from_public_bytes(raw),
134
+ info=info_for(deployment, connector, name))
135
+ return f"hpke1.{key_id(raw)}." + base64.urlsafe_b64encode(blob).decode("ascii").rstrip("=")
136
+
137
+
138
+ def canonical(fields: Mapping[str, str]) -> bytes:
139
+ return json.dumps(dict(fields), sort_keys=True, separators=(",", ":"),
140
+ ensure_ascii=False).encode("utf-8")
141
+
142
+
143
+ def fingerprint(fields: Mapping[str, str], key: bytes) -> str:
144
+ return hmac.new(key, canonical(fields), hashlib.sha256).hexdigest()[:32]
145
+
146
+
147
+ def connection_id() -> str:
148
+ """A new connection's id, chosen here so the seal can be bound to it."""
149
+ alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
150
+ return "cx-" + "".join(secrets.choice(alphabet) for _ in range(12))
151
+
152
+
153
+ def _two_lines(secret: str, first: str, second: str) -> tuple[str, str]:
154
+ a, sep, b = secret.strip().partition("\n")
155
+ if not sep or not a.strip() or not b.strip():
156
+ raise SealingError(f"the credential is two lines: {first}, then {second}")
157
+ return a.strip(), b.strip()
158
+
159
+
160
+ def credential_fields(connector: str, secret: str, target: Mapping[str, Any]) -> dict[str, str]:
161
+ """What was piped in, and the identifiers named, as the engine stores them
162
+ (the service's `sealing.credential_fields`, line for line)."""
163
+ target = target or {}
164
+ if connector not in CONNECTORS:
165
+ raise SealingError("that connector's credential is not kept in a deployment")
166
+ secret = secret.strip()
167
+ if not secret:
168
+ raise SealingError("the credential is empty")
169
+ if connector == "github":
170
+ return {"token": secret}
171
+ if connector == "gitlab":
172
+ out = {"token": secret}
173
+ if target.get("instance_url"):
174
+ out["instance_url"] = str(target["instance_url"]).strip().rstrip("/")
175
+ return out
176
+ if connector in ("jira", "confluence"):
177
+ email, token = _two_lines(secret, "the account's email address", "the API token")
178
+ site = str(target.get("base_url") or "").strip().rstrip("/")
179
+ if not site:
180
+ raise SealingError("base_url is your site: --target base_url=https://your-site"
181
+ ".atlassian.net")
182
+ return {"email": email, "token": token, "site": site}
183
+ if connector in ENTERPRISE:
184
+ return _enterprise_fields(connector, secret, target)
185
+ if connector in WAVE_046:
186
+ return _wave_fields(connector, secret, target)
187
+ if connector in WAVE_047:
188
+ return _erp_hr_fields(connector, secret, target)
189
+ if connector == "datadog":
190
+ api_key, app_key = _two_lines(secret, "the API key", "the application key")
191
+ engine_site = DATADOG_ENGINE_SITES.get(str(target.get("site") or "us1"))
192
+ if engine_site is None:
193
+ raise SealingError("site is one of Datadog's own sites")
194
+ return {"api_key": api_key, "app_key": app_key, "site": engine_site}
195
+ out = {"service_account": secret}
196
+ user = str(target.get("user") or "").strip()
197
+ if user:
198
+ out["subject"] = user
199
+ elif connector == "gmail":
200
+ raise SealingError("user is the mailbox to read: --target user=<address>")
201
+ return out
202
+
203
+
204
+ ENTERPRISE = ("dynamics", "hubspot", "monday", "netsuite", "notion", "oracle-db",
205
+ "oracle-fusion", "salesforce", "servicenow", "zendesk")
206
+
207
+
208
+ def _named(target: Mapping[str, Any], key: str, words: str) -> str:
209
+ got = str(target.get(key) or "").strip().rstrip("/")
210
+ if not got:
211
+ raise SealingError(f"{key} is {words}")
212
+ return got
213
+
214
+
215
+ def _pem_after(secret: str, n: int, words: tuple[str, ...]) -> tuple[list[str], str]:
216
+ """`n` one-line fields, then a PEM: what a person pastes for a
217
+ certificate route that also needs an id or two."""
218
+ lines = secret.strip().split("\n")
219
+ head = [x.strip() for x in lines[:n]]
220
+ pem = "\n".join(lines[n:]).strip()
221
+ if len(head) < n or not all(head) or any(h.startswith("-----") for h in head) \
222
+ or not pem.startswith("-----BEGIN") or "PRIVATE KEY-----" not in pem:
223
+ raise SealingError("the credential is " + ", then ".join(words))
224
+ return head, pem
225
+
226
+
227
+ def _enterprise_fields(connector: str, secret: str, target: Mapping[str, Any]) -> dict[str, str]:
228
+ """The 1.26 connectors' credentials as the engine stores them
229
+ (`connector_secrets.SHAPES`). Hosts and names come from what was named;
230
+ ids that look like secrets (a consumer key, a client id) are pasted with
231
+ the credential, never put in a target."""
232
+ if connector == "salesforce":
233
+ (key,), pem = _pem_after(secret, 1, ("the connected app's consumer key",
234
+ "the PEM of its private key"))
235
+ return {"instance_url": _named(target, "instance_url", "your My Domain"),
236
+ "client_id": key, "username": _named(target, "username",
237
+ "the integration user"),
238
+ "certificate": pem}
239
+ if connector == "hubspot":
240
+ if not secret.startswith("pat-"):
241
+ raise SealingError("the credential is a HubSpot private app's access token, pat-")
242
+ return {"token": secret}
243
+ if connector == "servicenow":
244
+ a, b = _two_lines(secret, "the client id or user name", "its secret or password")
245
+ base = _named(target, "instance_url", "https://<instance>.service-now.com")
246
+ if target.get("sign_in") == "integration_user":
247
+ return {"instance_url": base, "username": a, "password": b}
248
+ if target.get("sign_in") == "oauth_client":
249
+ return {"instance_url": base, "client_id": a, "client_secret": b}
250
+ raise SealingError("sign_in is oauth_client or integration_user")
251
+ if connector == "monday":
252
+ return {"token": secret}
253
+ if connector == "zendesk":
254
+ email, token = _two_lines(secret, "the agent's email address", "the API token")
255
+ return {"subdomain": _named(target, "subdomain", "your Zendesk's name"),
256
+ "email": email, "token": token}
257
+ if connector == "notion":
258
+ if not secret.startswith(("ntn_", "secret_")):
259
+ raise SealingError("the credential is a Notion internal integration's secret, ntn_")
260
+ return {"token": secret}
261
+ if connector == "dynamics":
262
+ if "PRIVATE KEY-----" not in secret or "BEGIN CERTIFICATE" not in secret:
263
+ raise SealingError("the credential is the PEM of your app's private key and its "
264
+ "certificate")
265
+ return {"tenant_id": _named(target, "tenant_id", "your directory's id"),
266
+ "client_id": _named(target, "client_id", "your app's id"),
267
+ "environment_url": _named(target, "environment_url",
268
+ "https://<org>.crm.dynamics.com"),
269
+ "certificate": secret}
270
+ if connector == "oracle-db":
271
+ lines = [x.strip() for x in secret.strip().split("\n")]
272
+ if len(lines) != 3 or not all(lines):
273
+ raise SealingError("the credential is three lines: the connect string, the user, "
274
+ "then its password")
275
+ return {"dsn": lines[0], "user": lines[1], "password": lines[2]}
276
+ if connector == "netsuite":
277
+ (cid, kid), pem = _pem_after(secret, 2, ("the client id", "the certificate id",
278
+ "the PEM of its private key"))
279
+ return {"account_id": _named(target, "account_id", "your NetSuite account id"),
280
+ "client_id": cid, "certificate_id": kid, "certificate": pem}
281
+ if "PRIVATE KEY-----" not in secret or "BEGIN CERTIFICATE" not in secret:
282
+ raise SealingError("the credential is the PEM of the private key and its certificate")
283
+ return {"pod_url": _named(target, "pod_url", "https://<pod>.fa.<region>.oraclecloud.com"),
284
+ "issuer": _named(target, "issuer", "the trusted issuer's name"),
285
+ "username": _named(target, "username", "the integration user"),
286
+ "certificate": secret}
287
+
288
+ # Contract 1.27 (engine 0.46.0): finance and aviation, read-only, sealed where
289
+ # pasted. The fields are the engine's `connector_secrets.SHAPES` and
290
+ # `connector_secrets_aviation.SHAPES`; `view_only` (yes or no) is sealed as
291
+ # `view_only_confirmed`, the client's word that a key whose permissions the
292
+ # provider does not state can only read.
293
+ WAVE_046 = ("bank-statements", "binance", "coinbase-prime", "dfns", "fireblocks", "kraken",
294
+ "kyriba", "quickbooks", "revolut", "stripe", "xero",
295
+ "amadeus", "cirium", "eccairs", "flightaware", "intelex", "oag", "qpulse", "sabre",
296
+ "sita-bags", "sita-flights", "xapi")
297
+ _VIEW_ONLY = ("fireblocks", "dfns", "coinbase-prime", "kraken", "binance", "revolut", "kyriba",
298
+ "stripe")
299
+ # Target field -> the engine's field, where the names differ or are optional.
300
+ _OPTIONAL = {"fireblocks": ("region",), "dfns": ("region", "service_account_id"),
301
+ "binance": ("environment",), "revolut": ("environment",),
302
+ "quickbooks": ("environment",), "amadeus": ("environment",),
303
+ "sabre": ("environment",)}
304
+
305
+
306
+ def _lines(secret: str, words: tuple[str, ...]) -> list[str]:
307
+ """Exactly len(words) one-line values, in order."""
308
+ got = [x.strip() for x in secret.strip().split("\n")]
309
+ if len(got) != len(words) or not all(got):
310
+ raise SealingError(f"the credential is {len(words)} lines: " + ", then ".join(words))
311
+ return got
312
+
313
+
314
+ def _wave_fields(connector: str, secret: str, target: Mapping[str, Any]) -> dict[str, str]:
315
+ """The 1.27 connectors' credentials as the engine stores them."""
316
+ out = _wave_secret(connector, secret, target)
317
+ for k in _OPTIONAL.get(connector, ()):
318
+ if str(target.get(k) or "").strip():
319
+ out[k] = str(target[k]).strip()
320
+ if connector in _VIEW_ONLY and str(target.get("view_only") or "").strip():
321
+ if target["view_only"] not in ("yes", "no"):
322
+ raise SealingError("view_only is yes or no")
323
+ out["view_only_confirmed"] = str(target["view_only"])
324
+ return out
325
+
326
+
327
+ def _wave_secret(connector: str, secret: str, target: Mapping[str, Any]) -> dict[str, str]:
328
+ if connector == "fireblocks":
329
+ (key,), pem = _pem_after(secret, 1, ("the API key", "the PEM of the API user's "
330
+ "private key"))
331
+ return {"api_key": key, "private_key": pem}
332
+ if connector in ("dfns",):
333
+ return {"token": secret}
334
+ if connector == "coinbase-prime":
335
+ a, b, c = _lines(secret, ("the access key", "the passphrase", "the signing key"))
336
+ return {"access_key": a, "passphrase": b, "signing_key": c}
337
+ if connector == "kraken":
338
+ a, b = _two_lines(secret, "the API key", "its private key")
339
+ return {"api_key": a, "api_secret": b}
340
+ if connector == "binance":
341
+ if "PRIVATE KEY-----" in secret:
342
+ (key,), pem = _pem_after(secret, 1, ("the API key", "the PEM of its Ed25519 "
343
+ "private key"))
344
+ return {"api_key": key, "private_key": pem}
345
+ a, b = _two_lines(secret, "the API key", "its secret")
346
+ return {"api_key": a, "api_secret": b}
347
+ if connector == "bank-statements":
348
+ (host_key,), pem = _pem_after(secret, 1, ("the server's host key fingerprint, "
349
+ "SHA256:...", "the PEM of the drop "
350
+ "account's private key"))
351
+ if not host_key.startswith("SHA256:"):
352
+ raise SealingError("the first line is the server's host key fingerprint, SHA256:...")
353
+ return {"location": _named(target, "location", "sftp://<user>@<host>/<path>"),
354
+ "host_key": host_key, "private_key": pem}
355
+ if connector == "revolut":
356
+ (cid, code), pem = _pem_after(secret, 2, ("the client ID", "the authorisation code "
357
+ "or refresh token", "the PEM of the "
358
+ "certificate's private key"))
359
+ grant = str(target.get("grant") or "authorization_code")
360
+ if grant not in ("authorization_code", "refresh_token"):
361
+ raise SealingError("grant is authorization_code or refresh_token")
362
+ return {"client_id": cid, grant: code, "private_key": pem,
363
+ "issuer": _named(target, "issuer", "the redirect domain you gave Revolut")}
364
+ if connector == "xero":
365
+ got = [x.strip() for x in secret.strip().split("\n")]
366
+ if len(got) not in (2, 3) or not all(got):
367
+ raise SealingError("the credential is the client ID, then the client secret, then "
368
+ "a refresh token where the app was consented to")
369
+ return dict(zip(("client_id", "client_secret", "refresh_token"), got, strict=False))
370
+ if connector == "quickbooks":
371
+ a, b, c = _lines(secret, ("the client ID", "the client secret", "the refresh token"))
372
+ return {"client_id": a, "client_secret": b, "refresh_token": c,
373
+ "realm_id": _named(target, "realm_id", "your company's id")}
374
+ if connector == "kyriba":
375
+ a, b = _two_lines(secret, "the API client's id", "its secret")
376
+ return {"client_id": a, "client_secret": b,
377
+ "auth_url": _named(target, "auth_url", "https://<host>.kyriba.com"),
378
+ "api_url": _named(target, "api_url", "https://<host>.kyriba.com")}
379
+ if connector == "stripe":
380
+ if not secret.startswith(("rk_live_", "rk_test_")):
381
+ raise SealingError("the credential is a restricted key, rk_live_ or rk_test_; a "
382
+ "secret key can move money")
383
+ return {"api_key": secret}
384
+ if connector in ("amadeus", "sabre", "eccairs"):
385
+ a, b = _two_lines(secret, "the client id", "its secret")
386
+ if connector == "sabre" and not a.startswith("V1:"):
387
+ raise SealingError("the first line is Sabre's V1:<user>:<group>:<domain>")
388
+ out = {"client_id": a, "client_secret": b}
389
+ if connector == "eccairs" and str(target.get("sign_in_url") or "").strip():
390
+ out["token_url"] = str(target["sign_in_url"]).strip()
391
+ return out
392
+ if connector in ("flightaware", "sita-bags"):
393
+ return {"api_key": secret}
394
+ if connector == "oag":
395
+ return {"subscription_key": secret}
396
+ if connector == "cirium":
397
+ a, b = _two_lines(secret, "the application id", "its key")
398
+ return {"app_id": a, "app_key": b}
399
+ if connector == "sita-flights":
400
+ got = [x.strip() for x in secret.strip().split("\n")]
401
+ if len(got) == 1 and got[0]:
402
+ return {"api_key": got[0]}
403
+ if len(got) == 3 and all(got):
404
+ return {"api_key": got[0], "client_id": got[1], "client_secret": got[2]}
405
+ raise SealingError("the credential is the API key, and for OAuth the client id and "
406
+ "secret on two more lines")
407
+ if connector == "qpulse":
408
+ a, b = _two_lines(secret, "the integration user", "its password")
409
+ return {"base_url": _named(target, "base_url", "Q-Pulse's web services address"),
410
+ "user": a, "password": b,
411
+ "database": _named(target, "database", "the Q-Pulse database")}
412
+ if connector == "intelex":
413
+ return {"base_url": _named(target, "base_url", "your Intelex address"), "api_key": secret}
414
+ a, b = _two_lines(secret, "the LRS key", "its secret")
415
+ return {"endpoint": _named(target, "endpoint", "your LRS's xAPI endpoint"), "key": a,
416
+ "secret": b}
417
+
418
+
419
+ # Contract 1.28 (engine 0.47.1): ERP and HR, read-only, sealed where pasted.
420
+ # The fields are the engine's `connector_secrets.SHAPES`: `api_url`,
421
+ # `token_url`, `client_id`, `client_secret`, `username`, `password`,
422
+ # `company_id`, `host`, `tenant` and `refresh_token`. The token endpoint is
423
+ # named as `sign_in_url` (a field called token_* is refused as a credential).
424
+ WAVE_047 = ("sap-s4hana", "sap-successfactors", "workday")
425
+
426
+
427
+ def _erp_hr_fields(connector: str, secret: str, target: Mapping[str, Any]) -> dict[str, str]:
428
+ if connector == "workday":
429
+ a, b, c = _lines(secret, ("the client id", "the client secret", "the refresh token"))
430
+ return {"host": _named(target, "host", "your Workday host, <name>.workday.com"),
431
+ "tenant": _named(target, "tenant", "your Workday tenant"),
432
+ "client_id": a, "client_secret": b, "refresh_token": c}
433
+ sign_in = ("oauth" if connector == "sap-successfactors"
434
+ else str(target.get("sign_in") or "oauth"))
435
+ if sign_in not in ("oauth", "user"):
436
+ raise SealingError("sign_in is oauth or user")
437
+ first, second = (("the client id", "its secret") if sign_in == "oauth"
438
+ else ("the user", "its password"))
439
+ a, b = _two_lines(secret, first, second)
440
+ api = _named(target, "api_url", "your API host, https://<host>")
441
+ if connector == "sap-successfactors":
442
+ return {"api_url": api, "company_id": _named(target, "company_id", "your company id"),
443
+ "token_url": _named(target, "sign_in_url", "your tenant's token URL"),
444
+ "client_id": a, "client_secret": b}
445
+ if sign_in == "user":
446
+ return {"api_url": api, "username": a, "password": b}
447
+ return {"api_url": api, "token_url": _named(target, "sign_in_url", "your token URL"),
448
+ "client_id": a, "client_secret": b}
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: lighthouse-cli
3
+ Version: 0.16.0
4
+ Summary: The LightHouse command line: sign in, connect your estate, deploy agents, watch runs
5
+ License-Expression: Apache-2.0
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Typing :: Typed
8
+ Requires-Python: >=3.11
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Requires-Dist: httpx==0.28.1
12
+ Requires-Dist: keyring==25.7.0
13
+ Requires-Dist: cryptography==50.0.2
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest==9.1.1; extra == "dev"
16
+ Requires-Dist: pytest-cov==7.1.0; extra == "dev"
17
+ Requires-Dist: ruff==0.16.10; extra == "dev"
18
+ Requires-Dist: mypy==2.4.0; extra == "dev"
19
+ Requires-Dist: bandit[toml]==1.9.4; extra == "dev"
20
+ Requires-Dist: jsonschema==4.25.1; extra == "dev"
21
+ Provides-Extra: build
22
+ Requires-Dist: pyinstaller==6.22.3; extra == "build"
23
+ Dynamic: license-file