briar-cli 1.1.1__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 (179) hide show
  1. briar/__init__.py +8 -0
  2. briar/__main__.py +11 -0
  3. briar/_registry.py +36 -0
  4. briar/agent/__init__.py +16 -0
  5. briar/agent/_llm.py +90 -0
  6. briar/agent/_llms/__init__.py +40 -0
  7. briar/agent/_llms/anthropic_llm.py +185 -0
  8. briar/agent/_llms/bedrock.py +154 -0
  9. briar/agent/_llms/gemini.py +152 -0
  10. briar/agent/_llms/openai_llm.py +129 -0
  11. briar/agent/runner.py +364 -0
  12. briar/agent/tools.py +355 -0
  13. briar/auth/__init__.py +42 -0
  14. briar/auth/_acquirer.py +121 -0
  15. briar/auth/_acquirers/__init__.py +62 -0
  16. briar/auth/_acquirers/aws_sso.py +185 -0
  17. briar/auth/_acquirers/aws_static.py +54 -0
  18. briar/auth/_acquirers/bitbucket.py +60 -0
  19. briar/auth/_acquirers/github_device.py +129 -0
  20. briar/auth/_acquirers/github_pat.py +44 -0
  21. briar/auth/_acquirers/infisical.py +80 -0
  22. briar/auth/_acquirers/jira_session.py +102 -0
  23. briar/auth/_acquirers/jira_token.py +61 -0
  24. briar/auth/_acquirers/linear.py +46 -0
  25. briar/auth/_prompt.py +155 -0
  26. briar/cli.py +149 -0
  27. briar/commands/__init__.py +48 -0
  28. briar/commands/agent.py +692 -0
  29. briar/commands/auth.py +281 -0
  30. briar/commands/base.py +46 -0
  31. briar/commands/context.py +143 -0
  32. briar/commands/dashboard.py +61 -0
  33. briar/commands/extract.py +79 -0
  34. briar/commands/iac.py +44 -0
  35. briar/commands/runbook.py +110 -0
  36. briar/commands/secrets.py +165 -0
  37. briar/commands/version.py +17 -0
  38. briar/credentials/__init__.py +48 -0
  39. briar/credentials/_bootstrap.py +93 -0
  40. briar/credentials/_bootstraps/__init__.py +80 -0
  41. briar/credentials/_bootstraps/infisical.py +115 -0
  42. briar/credentials/_store.py +63 -0
  43. briar/credentials/aws_secrets.py +102 -0
  44. briar/credentials/envfile.py +171 -0
  45. briar/credentials/infisical.py +183 -0
  46. briar/credentials/ssm.py +83 -0
  47. briar/credentials/vault.py +109 -0
  48. briar/dashboard/__init__.py +13 -0
  49. briar/dashboard/collectors.py +1354 -0
  50. briar/dashboard/server.py +154 -0
  51. briar/dashboard/templates/index.html +678 -0
  52. briar/decorators.py +48 -0
  53. briar/env_vars.py +92 -0
  54. briar/error_policy.py +273 -0
  55. briar/errors.py +48 -0
  56. briar/extract/__init__.py +52 -0
  57. briar/extract/_cloud.py +157 -0
  58. briar/extract/_clouds/__init__.py +39 -0
  59. briar/extract/_clouds/aws.py +190 -0
  60. briar/extract/_clouds/azure.py +152 -0
  61. briar/extract/_clouds/gcp.py +135 -0
  62. briar/extract/_gh.py +159 -0
  63. briar/extract/_provider.py +218 -0
  64. briar/extract/_providers/__init__.py +60 -0
  65. briar/extract/_providers/bitbucket.py +276 -0
  66. briar/extract/_providers/github.py +277 -0
  67. briar/extract/_tracker.py +124 -0
  68. briar/extract/_trackers/__init__.py +44 -0
  69. briar/extract/_trackers/_jira_auth.py +258 -0
  70. briar/extract/_trackers/bitbucket.py +131 -0
  71. briar/extract/_trackers/github_issues.py +139 -0
  72. briar/extract/_trackers/jira.py +191 -0
  73. briar/extract/_trackers/linear.py +172 -0
  74. briar/extract/_user_filter.py +150 -0
  75. briar/extract/active_tickets.py +71 -0
  76. briar/extract/active_work.py +87 -0
  77. briar/extract/aws_infra.py +79 -0
  78. briar/extract/aws_services/__init__.py +31 -0
  79. briar/extract/aws_services/base.py +25 -0
  80. briar/extract/aws_services/ecs.py +43 -0
  81. briar/extract/aws_services/lambda_.py +38 -0
  82. briar/extract/aws_services/logs.py +39 -0
  83. briar/extract/aws_services/rds.py +35 -0
  84. briar/extract/aws_services/sqs.py +25 -0
  85. briar/extract/base.py +290 -0
  86. briar/extract/code_hotspots.py +134 -0
  87. briar/extract/codebase_conventions.py +72 -0
  88. briar/extract/composer.py +85 -0
  89. briar/extract/github_deployments.py +106 -0
  90. briar/extract/language_detectors/__init__.py +24 -0
  91. briar/extract/language_detectors/base.py +29 -0
  92. briar/extract/language_detectors/go.py +26 -0
  93. briar/extract/language_detectors/node.py +42 -0
  94. briar/extract/language_detectors/python.py +41 -0
  95. briar/extract/pr_archaeology.py +131 -0
  96. briar/extract/pr_review_context.py +123 -0
  97. briar/extract/reviewer_profile.py +141 -0
  98. briar/extract/ticket_archaeology.py +115 -0
  99. briar/extract/ticket_context.py +95 -0
  100. briar/formatting/__init__.py +67 -0
  101. briar/formatting/base.py +25 -0
  102. briar/formatting/csv.py +34 -0
  103. briar/formatting/json.py +19 -0
  104. briar/formatting/quiet.py +29 -0
  105. briar/formatting/table.py +97 -0
  106. briar/formatting/yaml.py +35 -0
  107. briar/iac/__init__.py +18 -0
  108. briar/iac/config_file.py +114 -0
  109. briar/iac/models.py +232 -0
  110. briar/iac/reference_map.py +33 -0
  111. briar/iac/runbook/__init__.py +32 -0
  112. briar/iac/runbook/executor.py +365 -0
  113. briar/iac/runbook/models.py +156 -0
  114. briar/iac/runbook/scheduler.py +187 -0
  115. briar/iac/scaffold/__init__.py +25 -0
  116. briar/iac/scaffold/_composer.py +308 -0
  117. briar/iac/scaffold/_knowledge.py +119 -0
  118. briar/iac/scaffold/archetypes/__init__.py +26 -0
  119. briar/iac/scaffold/archetypes/base.py +85 -0
  120. briar/iac/scaffold/archetypes/engineer.py +64 -0
  121. briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
  122. briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
  123. briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
  124. briar/iac/scaffold/archetypes/triager.py +50 -0
  125. briar/iac/scaffold/base.py +19 -0
  126. briar/iac/scaffold/implementation.py +59 -0
  127. briar/iac/scaffold/pr_fixes.py +52 -0
  128. briar/iac/scaffold/rules/__init__.py +64 -0
  129. briar/iac/scaffold/rules/base.py +121 -0
  130. briar/iac/scaffold/rules/commit_as_human.md +22 -0
  131. briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
  132. briar/iac/scaffold/rules/no_force_push.md +19 -0
  133. briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
  134. briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
  135. briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
  136. briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
  137. briar/iac/scaffold/shapes/__init__.py +38 -0
  138. briar/iac/scaffold/shapes/base.py +17 -0
  139. briar/iac/scaffold/shapes/one_shot.py +48 -0
  140. briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
  141. briar/iac/scaffold/shapes/triage.py +49 -0
  142. briar/iac/scaffold/sources/__init__.py +26 -0
  143. briar/iac/scaffold/sources/aws.py +69 -0
  144. briar/iac/scaffold/sources/base.py +61 -0
  145. briar/iac/scaffold/sources/bitbucket.py +164 -0
  146. briar/iac/scaffold/sources/github.py +155 -0
  147. briar/iac/scaffold/sources/jira.py +147 -0
  148. briar/iac/scaffold/triggers/__init__.py +23 -0
  149. briar/iac/scaffold/triggers/base.py +24 -0
  150. briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
  151. briar/iac/scaffold/triggers/github_webhook.py +52 -0
  152. briar/iac/scaffold/triggers/manual.py +16 -0
  153. briar/iac/scaffold/triggers/schedule_cron.py +37 -0
  154. briar/log_context.py +73 -0
  155. briar/logging.py +68 -0
  156. briar/messaging/__init__.py +66 -0
  157. briar/messaging/_writer.py +90 -0
  158. briar/messaging/bitbucket_pr_comment.py +93 -0
  159. briar/messaging/github_pr_comment.py +90 -0
  160. briar/messaging/jira_comment.py +63 -0
  161. briar/messaging/jira_transition.py +71 -0
  162. briar/messaging/slack_channel.py +73 -0
  163. briar/messaging/telegram_chat.py +68 -0
  164. briar/notify/__init__.py +44 -0
  165. briar/notify/_sink.py +27 -0
  166. briar/notify/email.py +59 -0
  167. briar/notify/pagerduty.py +67 -0
  168. briar/notify/slack.py +49 -0
  169. briar/notify/telegram.py +48 -0
  170. briar/pagination.py +37 -0
  171. briar/settings.py +3 -0
  172. briar/storage/__init__.py +73 -0
  173. briar/storage/base.py +137 -0
  174. briar/storage/file.py +111 -0
  175. briar/storage/postgres.py +353 -0
  176. briar_cli-1.1.1.dist-info/METADATA +1031 -0
  177. briar_cli-1.1.1.dist-info/RECORD +179 -0
  178. briar_cli-1.1.1.dist-info/WHEEL +4 -0
  179. briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,63 @@
1
+ """`CredentialStore` — Strategy contract for credential resolution.
2
+
3
+ Today, every cred is read directly from process env vars (via
4
+ ``CredEnv``). This abstraction lets you keep the same call sites
5
+ (``store.read(env_name)``) while swapping the backing source —
6
+ EnvFile (today), AWS Secrets Manager, SSM Parameter Store, Vault.
7
+
8
+ Four verbs:
9
+ - ``read(name)``: look up one var by canonical name (e.g.
10
+ ``AWS_ACME_ACCESS_KEY_ID``). Returns ``""`` if not found —
11
+ callers check truthiness.
12
+ - ``list()``: enumerate names of all credentials known to the store
13
+ (used by ``briar secrets doctor`` to report set/missing).
14
+ - ``fingerprint(name)``: md5 of the stored value (for rotation detection
15
+ without exposing the value itself).
16
+ - ``expires_at(name)``: parse expiration for time-bound creds (AWS STS
17
+ session tokens). Returns ISO-8601 string or ``""``."""
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from typing import ClassVar, List
23
+
24
+
25
+ class CredentialStore(ABC):
26
+ kind: ClassVar[str] = ""
27
+
28
+ @abstractmethod
29
+ def read(self, name: str) -> str:
30
+ """Return the value for `name`, or ``""`` if missing."""
31
+
32
+ @abstractmethod
33
+ def write(self, name: str, value: str) -> None:
34
+ """Persist a credential. Overwrites existing values of the
35
+ same name. Used by ``briar auth login`` after a successful
36
+ acquire. Failures raise — callers propagate so the operator
37
+ sees the underlying SDK error."""
38
+
39
+ @abstractmethod
40
+ def delete(self, name: str) -> bool:
41
+ """Remove a credential. Returns True if a value existed and was
42
+ removed, False if the name was unknown. Used by
43
+ ``briar auth logout``."""
44
+
45
+ @abstractmethod
46
+ def list(self) -> List[str]:
47
+ """Enumerate every credential name this store knows."""
48
+
49
+ def fingerprint(self, name: str) -> str:
50
+ """Default: md5 of the value. Override for stores that can
51
+ compute hashes server-side."""
52
+ import hashlib
53
+
54
+ value = self.read(name)
55
+ if not value:
56
+ return ""
57
+ return hashlib.md5(value.encode("utf-8")).hexdigest()
58
+
59
+ def expires_at(self, name: str) -> str:
60
+ """Default: no expiration tracking. AWS STS session tokens
61
+ encode expiry inside the credential itself — overridden by
62
+ the EnvFile / AwsSecretsManager stores."""
63
+ return ""
@@ -0,0 +1,102 @@
1
+ """AWS Secrets Manager `CredentialStore`.
2
+
3
+ Reads ``briar/<name>`` secrets at first call and caches in-memory for
4
+ the process lifetime (SecretsManager charges per API call). Region
5
+ comes from ``AWS_REGION`` env var or the boto3 default; auth uses the
6
+ ambient credential chain (IAM role on EC2, SSO profile locally,
7
+ static keys via env).
8
+
9
+ Composite secrets (a single ``SecretId`` whose ``SecretString`` is a
10
+ JSON object) are also supported via the ``key`` argument convention:
11
+ ``store.read("BITBUCKET_ACME_USERNAME")`` → looks up secret
12
+ ``briar/BITBUCKET_ACME_USERNAME``; if the SecretString parses as
13
+ JSON, the ``value`` key (or ``BITBUCKET_ACME_USERNAME``) wins."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from typing import Dict, List
20
+
21
+ from briar.credentials._store import CredentialStore
22
+
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ class AwsSecretsManagerStore(CredentialStore):
28
+ kind = "aws-secretsmanager"
29
+ PREFIX = "briar/"
30
+
31
+ def __init__(self) -> None:
32
+ self._client = None
33
+ self._cache: Dict[str, str] = {}
34
+
35
+ def _make_client(self):
36
+ if self._client is not None:
37
+ return self._client
38
+ import boto3
39
+
40
+ self._client = boto3.client("secretsmanager")
41
+ return self._client
42
+
43
+ def read(self, name: str) -> str:
44
+ if name in self._cache:
45
+ return self._cache[name]
46
+ client = self._make_client()
47
+ try:
48
+ resp = client.get_secret_value(SecretId=f"{self.PREFIX}{name}")
49
+ except Exception as exc: # noqa: BLE001
50
+ log.debug("aws-secretsmanager read miss name=%s err=%s", name, exc)
51
+ self._cache[name] = ""
52
+ return ""
53
+ value = resp.get("SecretString") or ""
54
+ # Composite-secret support: if SecretString parses as JSON,
55
+ # look for `{value}` or the bare key. Otherwise treat as scalar.
56
+ try:
57
+ parsed = json.loads(value)
58
+ if isinstance(parsed, dict):
59
+ value = str(parsed.get("value") or parsed.get(name) or value)
60
+ except (ValueError, TypeError):
61
+ pass
62
+ self._cache[name] = value
63
+ return value
64
+
65
+ def write(self, name: str, value: str) -> None:
66
+ """Upsert via PutSecretValue; falls through to CreateSecret on
67
+ first-time write."""
68
+ client = self._make_client()
69
+ secret_id = f"{self.PREFIX}{name}"
70
+ try:
71
+ client.put_secret_value(SecretId=secret_id, SecretString=value)
72
+ except Exception as exc: # noqa: BLE001
73
+ if "resourcenotfoundexception" in str(exc).lower() or "not found" in str(exc).lower():
74
+ client.create_secret(Name=secret_id, SecretString=value)
75
+ else:
76
+ log.exception("aws-secretsmanager write name=%s", name)
77
+ raise
78
+ self._cache[name] = value
79
+
80
+ def delete(self, name: str) -> bool:
81
+ """Force-delete with no recovery window."""
82
+ client = self._make_client()
83
+ secret_id = f"{self.PREFIX}{name}"
84
+ try:
85
+ client.delete_secret(SecretId=secret_id, ForceDeleteWithoutRecovery=True)
86
+ except Exception as exc: # noqa: BLE001
87
+ if "resourcenotfoundexception" in str(exc).lower():
88
+ return False
89
+ raise
90
+ self._cache.pop(name, None)
91
+ return True
92
+
93
+ def list(self) -> List[str]:
94
+ client = self._make_client()
95
+ out: List[str] = []
96
+ paginator = client.get_paginator("list_secrets")
97
+ for page in paginator.paginate():
98
+ for entry in page.get("SecretList", []) or []:
99
+ full = entry.get("Name") or ""
100
+ if full.startswith(self.PREFIX):
101
+ out.append(full[len(self.PREFIX) :])
102
+ return sorted(out)
@@ -0,0 +1,171 @@
1
+ """`EnvFileStore` — thin wrapper over process env vars (which is
2
+ where `/etc/briar/secrets.env` lands once systemd reads it via
3
+ ``EnvironmentFile=``).
4
+
5
+ This is the only `CredentialStore` backend that needs to work today.
6
+ It exposes the existing env-var surface through the new abstraction
7
+ so `briar secrets doctor` and any future store-backed code can use
8
+ one API regardless of where credentials live."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+ from typing import List
17
+
18
+ from briar.credentials._store import CredentialStore
19
+
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ # Where the secrets file lives. Resolution chain (first match wins):
25
+ # 1. $BRIAR_SECRETS_FILE — explicit operator override
26
+ # 2. /etc/briar/secrets.env — if already present (droplet convention,
27
+ # systemd reads it via EnvironmentFile=)
28
+ # 3. $XDG_CONFIG_HOME/briar/secrets.env (or ~/.config/briar/secrets.env)
29
+ # — laptop default (XDG-compliant)
30
+ # The chain "just works" on both deploy shapes without requiring the
31
+ # laptop user to set BRIAR_SECRETS_FILE manually.
32
+ def _secrets_path() -> Path:
33
+ explicit = os.environ.get("BRIAR_SECRETS_FILE", "").strip()
34
+ if explicit:
35
+ return Path(explicit)
36
+ system = Path("/etc/briar/secrets.env")
37
+ if system.exists():
38
+ return system
39
+ base_str = os.environ.get("XDG_CONFIG_HOME", "").strip()
40
+ base = Path(base_str) if base_str else Path.home() / ".config"
41
+ return base / "briar" / "secrets.env"
42
+
43
+
44
+ _NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
45
+
46
+
47
+ def _validate_name(name: str) -> None:
48
+ if not _NAME_RE.match(name):
49
+ raise ValueError(f"EnvFileStore: invalid env-var name {name!r} — must match {_NAME_RE.pattern}")
50
+
51
+
52
+ # Canonical credential name prefixes — used by `list()` to enumerate
53
+ # which env vars are "credentials" vs unrelated process state.
54
+ # Updating CredEnv? Update this list too.
55
+ _KNOWN_PREFIXES: tuple = (
56
+ "AWS_",
57
+ "GITHUB_",
58
+ "BITBUCKET_",
59
+ "JIRA_",
60
+ "LINEAR_",
61
+ "CLAUDE_CODE_OAUTH_TOKEN",
62
+ "ANTHROPIC_API_KEY",
63
+ "OPENAI_API_KEY",
64
+ "GEMINI_API_KEY",
65
+ "BRIAR_DATABASE_URL",
66
+ "TELEGRAM_",
67
+ "SLACK_",
68
+ "SMTP_",
69
+ "EMAIL_",
70
+ "PAGERDUTY_",
71
+ )
72
+
73
+
74
+ class EnvFileStore(CredentialStore):
75
+ kind = "envfile"
76
+
77
+ def read(self, name: str) -> str:
78
+ return os.environ.get(name, "")
79
+
80
+ def write(self, name: str, value: str) -> None:
81
+ """Update ``os.environ`` for the running process AND persist
82
+ durably to the secrets file. Atomic via temp-file + rename.
83
+
84
+ Idempotent: a line with the same KEY is replaced in-place;
85
+ new keys are appended at the end.
86
+
87
+ Parent directory is auto-created (``mkdir(parents=True,
88
+ exist_ok=True)``) — the XDG default path
89
+ (``~/.config/briar/``) doesn't exist on first use.
90
+
91
+ File-write failures **raise** ``OSError`` AFTER ``os.environ``
92
+ has been updated. Two reasons: (1) the calling process still
93
+ benefits from the in-memory update; (2) the caller (typically
94
+ ``briar auth login``) needs the exception so the per-key
95
+ ok/FAIL summary reflects what was actually durable. Previous
96
+ behaviour was to swallow the error and lie — fixed."""
97
+ _validate_name(name)
98
+ os.environ[name] = value
99
+
100
+ path = _secrets_path()
101
+ try:
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ if not path.exists():
104
+ path.touch(mode=0o600)
105
+ except OSError as exc:
106
+ raise OSError(f"envfile-store: could not prepare {path}: {exc}") from exc
107
+
108
+ try:
109
+ existing = path.read_text()
110
+ except OSError as exc:
111
+ raise OSError(f"envfile-store: could not read {path}: {exc}") from exc
112
+
113
+ new_line = f"{name}={value}\n"
114
+ # Match `KEY=value` (possibly with leading whitespace, possibly
115
+ # `export KEY=value`) so we replace in place rather than appending
116
+ # a duplicate that shadows the first one.
117
+ pattern = re.compile(rf"^(?:export\s+)?{re.escape(name)}=.*\n?", re.MULTILINE)
118
+ if pattern.search(existing):
119
+ updated = pattern.sub(new_line, existing, count=1)
120
+ else:
121
+ tail = "" if existing.endswith("\n") or not existing else "\n"
122
+ updated = existing + tail + new_line
123
+
124
+ try:
125
+ tmp = path.with_suffix(path.suffix + ".tmp")
126
+ tmp.write_text(updated)
127
+ os.chmod(tmp, 0o600)
128
+ tmp.replace(path)
129
+ except OSError as exc:
130
+ raise OSError(f"envfile-store: could not persist {name} to {path}: {exc}") from exc
131
+
132
+ def delete(self, name: str) -> bool:
133
+ """Remove from both ``os.environ`` and the persisted file.
134
+ Returns True iff something was actually removed (from either
135
+ location). Logout-friendly: if neither has the value, return
136
+ False so the CLI can report "nothing to do"."""
137
+ _validate_name(name)
138
+ env_had = name in os.environ
139
+ if env_had:
140
+ del os.environ[name]
141
+
142
+ path = _secrets_path()
143
+ file_had = False
144
+ if path.exists():
145
+ try:
146
+ existing = path.read_text()
147
+ except OSError:
148
+ return env_had
149
+ pattern = re.compile(rf"^(?:export\s+)?{re.escape(name)}=.*\n?", re.MULTILINE)
150
+ if pattern.search(existing):
151
+ file_had = True
152
+ updated = pattern.sub("", existing, count=1)
153
+ try:
154
+ tmp = path.with_suffix(path.suffix + ".tmp")
155
+ tmp.write_text(updated)
156
+ os.chmod(tmp, 0o600)
157
+ tmp.replace(path)
158
+ except OSError as exc:
159
+ log.warning("envfile-store: could not persist deletion of %s: %s", name, exc)
160
+
161
+ return env_had or file_had
162
+
163
+ def list(self) -> List[str]:
164
+ return sorted(k for k in os.environ if any(k.startswith(p) for p in _KNOWN_PREFIXES))
165
+
166
+ def expires_at(self, name: str) -> str:
167
+ """AWS STS session tokens carry expiry inside the token itself,
168
+ but parsing that requires the STS GetSessionToken API. For
169
+ env-file creds we can't tell — return ``""`` and let the
170
+ operator rotate based on the local SSO session timeout."""
171
+ return ""
@@ -0,0 +1,183 @@
1
+ """Infisical `CredentialStore` — symmetric counterpart to
2
+ ``InfisicalBootstrap``.
3
+
4
+ The Bootstrap is the BULK-HYDRATE side (pull every secret from a
5
+ project at process startup → into ``os.environ``). This Store is the
6
+ READ/WRITE-PER-NAME side (read/write/delete one secret on demand —
7
+ used by ``briar auth login --store infisical`` and any future
8
+ on-demand fetch).
9
+
10
+ Both share the same machine-identity credentials:
11
+ INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET + INFISICAL_PROJECT_ID
12
+ (+ optional INFISICAL_ENV, defaults to ``prod``;
13
+ + optional INFISICAL_HOST, defaults to https://app.infisical.com)
14
+
15
+ These are the values an `InfisicalAcquirer` walks the operator
16
+ through capturing — "logging into Infisical" in the briar UX is
17
+ really "give briar the machine-identity credentials it needs to
18
+ talk to Infisical from now on".
19
+
20
+ Lazy-imports ``infisical_sdk`` so it's an opt-in extra:
21
+ ``pip install briar-cli[infisical]``."""
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib
26
+ import logging
27
+ import os
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from briar.credentials._store import CredentialStore
31
+
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ def _import_infisical_sdk() -> Optional[Any]:
37
+ try:
38
+ return importlib.import_module("infisical_sdk")
39
+ except ImportError:
40
+ return None
41
+
42
+
43
+ class InfisicalStore(CredentialStore):
44
+ kind = "infisical"
45
+ DEFAULT_HOST = "https://app.infisical.com"
46
+ DEFAULT_ENV = "prod"
47
+ SECRET_PATH = "/" # KV-style flat namespace under the project
48
+
49
+ def __init__(self) -> None:
50
+ self._client_id = os.environ.get("INFISICAL_CLIENT_ID", "")
51
+ self._client_secret = os.environ.get("INFISICAL_CLIENT_SECRET", "")
52
+ self._project_id = os.environ.get("INFISICAL_PROJECT_ID", "")
53
+ self._env = os.environ.get("INFISICAL_ENV", self.DEFAULT_ENV)
54
+ self._host = os.environ.get("INFISICAL_HOST", self.DEFAULT_HOST)
55
+ self._client = None
56
+ self._cache: Dict[str, str] = {}
57
+
58
+ def _build_client(self):
59
+ """Universal-auth bind. Same dance as InfisicalBootstrap so a
60
+ process that has both store + bootstrap pointed at the same
61
+ Infisical project doesn't double-authenticate."""
62
+ if self._client is not None:
63
+ return self._client
64
+ sdk = _import_infisical_sdk()
65
+ if sdk is None:
66
+ raise RuntimeError("infisical_sdk not installed — run `pip install briar-cli[infisical]`")
67
+ if not all((self._client_id, self._client_secret, self._project_id)):
68
+ raise RuntimeError(
69
+ "InfisicalStore: missing INFISICAL_CLIENT_ID + _CLIENT_SECRET + _PROJECT_ID "
70
+ "— run `briar auth login --provider infisical` to set them"
71
+ )
72
+ self._client = sdk.InfisicalSDKClient(host=self._host)
73
+ self._client.auth.universal_auth.login(
74
+ client_id=self._client_id,
75
+ client_secret=self._client_secret,
76
+ )
77
+ return self._client
78
+
79
+ # ── read side ────────────────────────────────────────────────────
80
+
81
+ def read(self, name: str) -> str:
82
+ """Silent miss when Infisical isn't configured — matches
83
+ ``EnvFileStore`` semantics so the doctor can audit without
84
+ forcing every caller to install the extra."""
85
+ if name in self._cache:
86
+ return self._cache[name]
87
+ if not all((self._client_id, self._client_secret, self._project_id)):
88
+ self._cache[name] = ""
89
+ return ""
90
+ try:
91
+ client = self._build_client()
92
+ resp = client.secrets.get_secret_by_name(
93
+ secret_name=name,
94
+ project_id=self._project_id,
95
+ environment_slug=self._env,
96
+ secret_path=self.SECRET_PATH,
97
+ )
98
+ except Exception as exc: # noqa: BLE001 — translate to "missing" per the store contract
99
+ log.debug("infisical-store read miss name=%s err=%s", name, exc)
100
+ self._cache[name] = ""
101
+ return ""
102
+ # Tolerate both attribute + dict shapes (SDK versions differ).
103
+ value = getattr(resp, "secretValue", None)
104
+ if value is None and isinstance(resp, dict):
105
+ value = resp.get("secretValue") or (resp.get("secret") or {}).get("secretValue")
106
+ if value is None:
107
+ secret = getattr(resp, "secret", None)
108
+ value = getattr(secret, "secretValue", None) if secret is not None else None
109
+ value = str(value or "")
110
+ self._cache[name] = value
111
+ return value
112
+
113
+ # ── write side ───────────────────────────────────────────────────
114
+
115
+ def write(self, name: str, value: str) -> None:
116
+ """Upsert. Try ``update`` first — falls back to ``create`` if
117
+ the secret doesn't exist yet (the symmetric pattern used by
118
+ ``AwsSecretsManagerStore.write``)."""
119
+ client = self._build_client()
120
+ try:
121
+ client.secrets.update_secret_by_name(
122
+ secret_name=name,
123
+ project_id=self._project_id,
124
+ environment_slug=self._env,
125
+ secret_path=self.SECRET_PATH,
126
+ secret_value=value,
127
+ )
128
+ except Exception as exc: # noqa: BLE001
129
+ msg = str(exc).lower()
130
+ if "not found" in msg or "notfound" in msg or "404" in msg:
131
+ client.secrets.create_secret_by_name(
132
+ secret_name=name,
133
+ project_id=self._project_id,
134
+ environment_slug=self._env,
135
+ secret_path=self.SECRET_PATH,
136
+ secret_value=value,
137
+ )
138
+ else:
139
+ log.exception("infisical-store write name=%s", name)
140
+ raise
141
+ self._cache[name] = value
142
+
143
+ def delete(self, name: str) -> bool:
144
+ client = self._build_client()
145
+ try:
146
+ client.secrets.delete_secret_by_name(
147
+ secret_name=name,
148
+ project_id=self._project_id,
149
+ environment_slug=self._env,
150
+ secret_path=self.SECRET_PATH,
151
+ )
152
+ except Exception as exc: # noqa: BLE001
153
+ msg = str(exc).lower()
154
+ if "not found" in msg or "notfound" in msg or "404" in msg:
155
+ return False
156
+ raise
157
+ self._cache.pop(name, None)
158
+ return True
159
+
160
+ # ── enumeration ──────────────────────────────────────────────────
161
+
162
+ def list(self) -> List[str]:
163
+ """Empty list when not configured — symmetric to read()."""
164
+ if not all((self._client_id, self._client_secret, self._project_id)):
165
+ return []
166
+ try:
167
+ client = self._build_client()
168
+ resp = client.secrets.list_secrets(
169
+ project_id=self._project_id,
170
+ environment_slug=self._env,
171
+ secret_path=self.SECRET_PATH,
172
+ )
173
+ except Exception as exc: # noqa: BLE001
174
+ log.debug("infisical-store list failed err=%s", exc)
175
+ return []
176
+ names: List[str] = []
177
+ for s in (getattr(resp, "secrets", None) or []) if not isinstance(resp, dict) else (resp.get("secrets") or []):
178
+ n = getattr(s, "secretKey", None)
179
+ if n is None and isinstance(s, dict):
180
+ n = s.get("secretKey", "")
181
+ if n:
182
+ names.append(str(n))
183
+ return sorted(names)
@@ -0,0 +1,83 @@
1
+ """AWS SSM Parameter Store `CredentialStore`.
2
+
3
+ SSM is significantly cheaper than Secrets Manager (free for the first
4
+ ~10k standard parameter ops/month). Use ``SecureString`` parameters
5
+ with KMS encryption — this store always passes ``WithDecryption=True``.
6
+
7
+ Convention: parameter names are prefixed with ``/briar/`` so a single
8
+ IAM policy can grant access by path."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Dict, List
14
+
15
+ from briar.credentials._store import CredentialStore
16
+
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ class SsmParameterStore(CredentialStore):
22
+ kind = "ssm"
23
+ PREFIX = "/briar/"
24
+
25
+ def __init__(self) -> None:
26
+ self._client = None
27
+ self._cache: Dict[str, str] = {}
28
+
29
+ def _make_client(self):
30
+ if self._client is not None:
31
+ return self._client
32
+ import boto3
33
+
34
+ self._client = boto3.client("ssm")
35
+ return self._client
36
+
37
+ def read(self, name: str) -> str:
38
+ if name in self._cache:
39
+ return self._cache[name]
40
+ client = self._make_client()
41
+ try:
42
+ resp = client.get_parameter(Name=f"{self.PREFIX}{name}", WithDecryption=True)
43
+ except Exception as exc: # noqa: BLE001
44
+ log.debug("ssm read miss name=%s err=%s", name, exc)
45
+ self._cache[name] = ""
46
+ return ""
47
+ value = str((resp.get("Parameter") or {}).get("Value") or "")
48
+ self._cache[name] = value
49
+ return value
50
+
51
+ def write(self, name: str, value: str) -> None:
52
+ """Upsert via PutParameter with Overwrite=True. SecureString
53
+ type forces KMS encryption (default key under the account)."""
54
+ client = self._make_client()
55
+ client.put_parameter(
56
+ Name=f"{self.PREFIX}{name}",
57
+ Value=value,
58
+ Type="SecureString",
59
+ Overwrite=True,
60
+ )
61
+ self._cache[name] = value
62
+
63
+ def delete(self, name: str) -> bool:
64
+ client = self._make_client()
65
+ try:
66
+ client.delete_parameter(Name=f"{self.PREFIX}{name}")
67
+ except Exception as exc: # noqa: BLE001
68
+ if "parameternotfound" in str(exc).lower():
69
+ return False
70
+ raise
71
+ self._cache.pop(name, None)
72
+ return True
73
+
74
+ def list(self) -> List[str]:
75
+ client = self._make_client()
76
+ out: List[str] = []
77
+ paginator = client.get_paginator("get_parameters_by_path")
78
+ for page in paginator.paginate(Path=self.PREFIX, Recursive=True, WithDecryption=False):
79
+ for entry in page.get("Parameters", []) or []:
80
+ full = entry.get("Name") or ""
81
+ if full.startswith(self.PREFIX):
82
+ out.append(full[len(self.PREFIX) :])
83
+ return sorted(out)