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.
- lighthouse/__init__.py +6 -0
- lighthouse/__main__.py +9 -0
- lighthouse/commands/__init__.py +0 -0
- lighthouse/commands/agents.py +310 -0
- lighthouse/commands/builds.py +85 -0
- lighthouse/commands/collectors.py +147 -0
- lighthouse/commands/connect.py +717 -0
- lighthouse/commands/connections.py +201 -0
- lighthouse/commands/evals.py +198 -0
- lighthouse/commands/kb.py +224 -0
- lighthouse/commands/language.py +56 -0
- lighthouse/commands/limits.py +61 -0
- lighthouse/commands/login.py +207 -0
- lighthouse/commands/models.py +61 -0
- lighthouse/commands/runs.py +157 -0
- lighthouse/commands/spend.py +84 -0
- lighthouse/commands/stack.py +103 -0
- lighthouse/commands/terms.py +50 -0
- lighthouse/commands/whoami.py +80 -0
- lighthouse/config.py +141 -0
- lighthouse/credentials.py +233 -0
- lighthouse/device.py +124 -0
- lighthouse/exits.py +78 -0
- lighthouse/flags.py +61 -0
- lighthouse/http.py +355 -0
- lighthouse/i18n.py +250 -0
- lighthouse/locales/ar.json +343 -0
- lighthouse/locales/de.json +343 -0
- lighthouse/locales/en.json +343 -0
- lighthouse/locales/es.json +343 -0
- lighthouse/locales/fr.json +343 -0
- lighthouse/locales/ga.json +343 -0
- lighthouse/locales/it.json +343 -0
- lighthouse/locales/ja.json +343 -0
- lighthouse/locales/nl.json +343 -0
- lighthouse/locales/pt.json +343 -0
- lighthouse/locales/zh-Hans.json +343 -0
- lighthouse/locales/zh-Hant.json +343 -0
- lighthouse/main.py +229 -0
- lighthouse/out.py +169 -0
- lighthouse/py.typed +0 -0
- lighthouse/sealing.py +448 -0
- lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
- lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
- lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
- lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
- lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
- 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
|