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
briar/agent/tools.py ADDED
@@ -0,0 +1,355 @@
1
+ """Agent tools — small, audited primitives the LLM can call.
2
+
3
+ Every tool returns a string (stdout / file content / error message); the
4
+ agent loop wraps that in a `tool_result` block. Inputs are validated
5
+ against tight allowlists before execution — the model cannot run
6
+ arbitrary shell, only the verbs we declare safe.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ import shlex
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List
18
+
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ # Bash commands the agent is allowed to run. Anchored at the start of the
24
+ # command (after `shlex.split`). Anything matching `_FORBIDDEN_TOKENS`
25
+ # anywhere in the line is rejected even if the leading verb passed.
26
+ _ALLOWED_VERBS = {
27
+ "git",
28
+ "gh",
29
+ "ls",
30
+ "cat",
31
+ "head",
32
+ "tail",
33
+ "grep",
34
+ "rg",
35
+ "find",
36
+ "wc",
37
+ "pwd",
38
+ "echo",
39
+ "cd", # only as a prefix, e.g. `cd worktree && git status`
40
+ "python",
41
+ "python3",
42
+ "pytest",
43
+ "ruff",
44
+ "black",
45
+ "mypy",
46
+ "npm",
47
+ "node",
48
+ "make",
49
+ "tee",
50
+ "sort",
51
+ "uniq",
52
+ }
53
+
54
+ _FORBIDDEN_TOKENS = (
55
+ "rm -rf",
56
+ "sudo",
57
+ "su ",
58
+ "ssh ",
59
+ "scp ",
60
+ "curl",
61
+ "wget",
62
+ " ;rm",
63
+ "&&rm",
64
+ "|rm ",
65
+ "shutdown",
66
+ "reboot",
67
+ "kill -9 1",
68
+ "chmod 777",
69
+ "/dev/null > /dev",
70
+ ">/dev/sda",
71
+ "mkfs",
72
+ "dd if=",
73
+ # No force-push / amend / squash by mandate
74
+ "--force",
75
+ "-f origin",
76
+ "--amend",
77
+ "rebase",
78
+ "squash",
79
+ "filter-branch",
80
+ "reset --hard",
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class ToolError(Exception):
86
+ message: str
87
+
88
+ def __str__(self) -> str:
89
+ return self.message
90
+
91
+
92
+ class BashTool:
93
+ name = "bash"
94
+ description = "Run a shell command. Allowlisted verbs only: git, gh, curl, ls, cat, head, tail, grep, find, wc, python/pytest/ruff/black/mypy, npm/node, make. NEVER --force, --amend, rebase, squash, rm -rf, sudo. Returns stdout+stderr. Non-zero exit becomes a tool_result with the error visible to you."
95
+
96
+ INPUT_SCHEMA: Dict[str, Any] = {
97
+ "type": "object",
98
+ "properties": {
99
+ "command": {
100
+ "type": "string",
101
+ "description": "The shell command to run. Pipes and && chaining are fine; forbidden tokens are rejected.",
102
+ },
103
+ "cwd": {
104
+ "type": "string",
105
+ "description": "Working directory for the command. Should be the PR worktree for git operations.",
106
+ },
107
+ "timeout_s": {
108
+ "type": "integer",
109
+ "description": "Max seconds before the process is killed. Default 60, hard ceiling 300.",
110
+ },
111
+ },
112
+ "required": ["command", "cwd"],
113
+ }
114
+
115
+ def __init__(self, base_cwd: Path) -> None:
116
+ self._base = base_cwd
117
+
118
+ def run(self, command: str, cwd: str, timeout_s: int = 60) -> str:
119
+ if timeout_s > 300:
120
+ timeout_s = 300
121
+ self._validate(command)
122
+ path = self._resolve_cwd(cwd)
123
+ log.info("bash-tool: cwd=%s cmd=%s", path, command[:120])
124
+ proc = subprocess.run(
125
+ command,
126
+ shell=True,
127
+ cwd=str(path),
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=timeout_s,
131
+ )
132
+ out = proc.stdout.rstrip()
133
+ err = proc.stderr.rstrip()
134
+ body_parts: List[str] = [f"$ {command}", f"(exit {proc.returncode}, cwd={path})"]
135
+ if out:
136
+ body_parts.append(out)
137
+ if err:
138
+ body_parts.append("STDERR:")
139
+ body_parts.append(err)
140
+ return "\n".join(body_parts)
141
+
142
+ @classmethod
143
+ def _validate(cls, command: str) -> None:
144
+ lowered = command.lower()
145
+ for tok in _FORBIDDEN_TOKENS:
146
+ if tok in lowered:
147
+ raise ToolError(f"bash-tool rejected: forbidden token {tok!r} in command")
148
+ # Parse the leading verb of every &&-chained chunk and confirm
149
+ # each is allowlisted. We can't shlex.split the whole thing —
150
+ # `&&` and `|` are operators not args — so split on those first.
151
+ chunks = re.split(r"\s*(?:&&|\|\||\||;)\s*", command)
152
+ for chunk in chunks:
153
+ if not chunk.strip():
154
+ continue
155
+ try:
156
+ parts = shlex.split(chunk)
157
+ except ValueError as exc:
158
+ raise ToolError(f"bash-tool rejected: cannot parse shell tokens — {exc}") from exc
159
+ if not parts:
160
+ continue
161
+ verb = parts[0]
162
+ if verb not in _ALLOWED_VERBS:
163
+ raise ToolError(f"bash-tool rejected: verb {verb!r} not in allowlist {sorted(_ALLOWED_VERBS)!r}")
164
+
165
+ def _resolve_cwd(self, cwd: str) -> Path:
166
+ path = Path(cwd).expanduser().resolve()
167
+ # Must be a subdir of base_cwd or /tmp. Keeps the agent inside
168
+ # its sandbox even if it dreams up `/etc` or similar.
169
+ for allowed_root in (self._base.resolve(), Path("/tmp").resolve(), Path("/var/lib/briar").resolve()):
170
+ try:
171
+ path.relative_to(allowed_root)
172
+ return path
173
+ except ValueError:
174
+ continue
175
+ raise ToolError(f"bash-tool rejected: cwd {path} outside allowed roots {self._base}/, /tmp/, /var/lib/briar/")
176
+
177
+
178
+ class ReadFileTool:
179
+ name = "read_file"
180
+ description = "Read the contents of a file. Useful for inspecting source files before editing."
181
+
182
+ INPUT_SCHEMA: Dict[str, Any] = {
183
+ "type": "object",
184
+ "properties": {
185
+ "path": {"type": "string", "description": "Absolute path to the file"},
186
+ },
187
+ "required": ["path"],
188
+ }
189
+
190
+ def __init__(self, allowed_roots: List[Path]) -> None:
191
+ self._roots = [r.resolve() for r in allowed_roots]
192
+
193
+ def run(self, path: str) -> str:
194
+ p = self._validate(path)
195
+ log.debug("read_file-tool: path=%s", p)
196
+ try:
197
+ return p.read_text(encoding="utf-8", errors="replace")
198
+ except OSError as exc:
199
+ raise ToolError(f"read_file: {exc}") from exc
200
+
201
+ def _validate(self, path: str) -> Path:
202
+ p = Path(path).expanduser().resolve()
203
+ for root in self._roots:
204
+ try:
205
+ p.relative_to(root)
206
+ return p
207
+ except ValueError:
208
+ continue
209
+ raise ToolError(f"read_file rejected: {p} outside allowed roots {self._roots}")
210
+
211
+
212
+ class WriteFileTool:
213
+ name = "write_file"
214
+ description = "Overwrite a file with new contents. Caller is responsible for preserving content they want to keep."
215
+
216
+ INPUT_SCHEMA: Dict[str, Any] = {
217
+ "type": "object",
218
+ "properties": {
219
+ "path": {"type": "string", "description": "Absolute path"},
220
+ "content": {"type": "string", "description": "Full file content"},
221
+ },
222
+ "required": ["path", "content"],
223
+ }
224
+
225
+ def __init__(self, allowed_roots: List[Path]) -> None:
226
+ self._roots = [r.resolve() for r in allowed_roots]
227
+
228
+ def run(self, path: str, content: str) -> str:
229
+ p = self._validate(path)
230
+ log.info("write_file-tool: path=%s bytes=%d", p, len(content))
231
+ p.parent.mkdir(parents=True, exist_ok=True)
232
+ p.write_text(content, encoding="utf-8")
233
+ return f"wrote {len(content)} bytes to {p}"
234
+
235
+ def _validate(self, path: str) -> Path:
236
+ p = Path(path).expanduser().resolve()
237
+ for root in self._roots:
238
+ try:
239
+ p.relative_to(root)
240
+ return p
241
+ except ValueError:
242
+ continue
243
+ raise ToolError(f"write_file rejected: {p} outside allowed roots {self._roots}")
244
+
245
+
246
+ class EditFileTool:
247
+ name = "edit_file"
248
+ description = "Replace one occurrence of old_text with new_text inside a file. The old_text must match exactly once."
249
+
250
+ INPUT_SCHEMA: Dict[str, Any] = {
251
+ "type": "object",
252
+ "properties": {
253
+ "path": {"type": "string"},
254
+ "old_text": {"type": "string"},
255
+ "new_text": {"type": "string"},
256
+ },
257
+ "required": ["path", "old_text", "new_text"],
258
+ }
259
+
260
+ def __init__(self, allowed_roots: List[Path]) -> None:
261
+ self._roots = [r.resolve() for r in allowed_roots]
262
+
263
+ def run(self, path: str, old_text: str, new_text: str) -> str:
264
+ p = self._validate(path)
265
+ if not p.exists():
266
+ raise ToolError(f"edit_file: {p} does not exist")
267
+ content = p.read_text(encoding="utf-8")
268
+ occurrences = content.count(old_text)
269
+ if occurrences == 0:
270
+ raise ToolError(f"edit_file: old_text not found in {p}")
271
+ if occurrences > 1:
272
+ raise ToolError(f"edit_file: old_text matches {occurrences} times in {p} — make it more specific")
273
+ p.write_text(content.replace(old_text, new_text, 1), encoding="utf-8")
274
+ log.info("edit_file-tool: path=%s delta=%+d bytes", p, len(new_text) - len(old_text))
275
+ return f"replaced 1 occurrence in {p} ({len(new_text) - len(old_text):+d} bytes)"
276
+
277
+ def _validate(self, path: str) -> Path:
278
+ p = Path(path).expanduser().resolve()
279
+ for root in self._roots:
280
+ try:
281
+ p.relative_to(root)
282
+ return p
283
+ except ValueError:
284
+ continue
285
+ raise ToolError(f"edit_file rejected: {p} outside allowed roots {self._roots}")
286
+
287
+
288
+ class SendMessageTool:
289
+ """Send a message via a runbook-configured channel.
290
+
291
+ The LLM picks a channel by its handle (the key under the company's
292
+ ``messages:`` block in the runbook YAML), not by vendor. The tool
293
+ resolves the handle → `MessageBinding` → `MessageWriter` →
294
+ `send(target, body, **extras)`. The LLM no longer needs to know
295
+ which vendor (Jira vs GitHub vs Slack) is wired up for each channel
296
+ — that's runbook config.
297
+
298
+ Bound by `AgentRunner` only when the company's runbook entry has
299
+ a non-empty ``messages:`` block. If empty, this tool is NOT in
300
+ the agent's tool list (the bash escape hatch via `gh`/`curl`
301
+ stays available)."""
302
+
303
+ name = "send_message"
304
+ description = (
305
+ "Send a message via a named runbook channel. The set of available channels comes from the company's "
306
+ "runbook config and is listed below at agent-start time. Use this INSTEAD of `gh pr comment` / "
307
+ "`gh api .../replies` / curl-against-Jira-or-Bitbucket. `target` is the destination — for jira-comment "
308
+ "/ jira-transition it's a ticket key (PROJ-42); for github-pr-comment / bitbucket-pr-comment it's "
309
+ "owner/repo#42; for slack-channel / telegram-chat target is unused (the channel/chat ID is bound). "
310
+ "`extras` carries optional per-writer fields (status for jira-transition, file_path + line for "
311
+ "inline PR comments)."
312
+ )
313
+
314
+ INPUT_SCHEMA: Dict[str, Any] = {
315
+ "type": "object",
316
+ "properties": {
317
+ "channel": {"type": "string", "description": "Handle from the company's runbook `messages:` block (e.g. ticket_comment, ops_chat)"},
318
+ "target": {"type": "string", "description": "Destination identifier; vendor-specific shape (see tool description)"},
319
+ "body": {"type": "string", "description": "Message body"},
320
+ "extras": {"type": "object", "description": "Optional per-writer fields (status, file_path, line, title)", "additionalProperties": True},
321
+ },
322
+ "required": ["channel", "body"],
323
+ }
324
+
325
+ def __init__(self, *, messages: Dict[str, Any], company: str = "") -> None:
326
+ """`messages` is a dict of {handle: MessageBinding-like} — the
327
+ runbook's per-company config. Empty dict disables the tool
328
+ (it'll still bind but every send returns an error)."""
329
+ self._messages = dict(messages or {})
330
+ self._company = company
331
+
332
+ def channels(self) -> List[str]:
333
+ return sorted(self._messages.keys())
334
+
335
+ def run(self, channel: str, body: str, target: str = "", extras: Dict[str, Any] = None) -> str:
336
+ binding = self._messages.get(channel)
337
+ if binding is None:
338
+ known = ", ".join(self.channels()) or "(none configured)"
339
+ raise ToolError(f"send_message: unknown channel {channel!r}. Known channels: {known}")
340
+ from briar.messaging import make_writer
341
+
342
+ kind = getattr(binding, "kind", "") or (binding.get("kind", "") if isinstance(binding, dict) else "")
343
+ config = getattr(binding, "config", None) or (binding.get("config", {}) if isinstance(binding, dict) else {})
344
+ try:
345
+ writer = make_writer(kind, company=self._company, config=dict(config or {}))
346
+ except Exception as exc: # noqa: BLE001
347
+ raise ToolError(f"send_message: cannot construct writer for channel={channel} kind={kind!r}: {exc}") from exc
348
+
349
+ if not writer.is_available():
350
+ raise ToolError(f"send_message: channel={channel} kind={kind} has missing credentials")
351
+
352
+ result = writer.send(target=target, body=body, **(extras or {}))
353
+ if not result.ok:
354
+ raise ToolError(f"send_message: channel={channel} kind={kind} failed — {result.detail}")
355
+ return f"sent via channel={channel} kind={kind}" + (f" ref={result.ref}" if result.ref else "")
briar/auth/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """`briar auth` — interactive credential acquisition + management.
2
+
3
+ Three abstractions:
4
+
5
+ - ``CredentialAcquirer`` (this package's ``_acquirer.py``) — the
6
+ *write* side: one provider × auth-style per implementation. Walks
7
+ the user through whatever interactive flow the vendor requires
8
+ (OAuth device flow, paste a token, browser-extract a cookie, …)
9
+ and returns a ``Credentials`` bundle.
10
+
11
+ - ``CredentialStore`` (``briar.credentials._store``) — the
12
+ *persistence* side: file, AWS Secrets Manager, SSM, Vault. The
13
+ ``briar auth login`` command pairs any acquirer with any store.
14
+
15
+ - ``CredentialBootstrap`` (``briar.credentials._bootstraps``) — the
16
+ *bulk-hydrate* side: pull a whole environment from a remote vault
17
+ at process startup (Infisical today). Orthogonal to acquisition;
18
+ meant for already-provisioned secrets.
19
+
20
+ Each axis is independent: acquire via GitHub OAuth → persist to
21
+ Vault; or acquire via paste-a-token → persist to env-file; or skip
22
+ acquisition entirely and let CredentialBootstrap hydrate from
23
+ Infisical at startup."""
24
+
25
+ from __future__ import annotations
26
+
27
+ from briar.auth._acquirer import CredentialAcquirer, CredentialExpired, Credentials
28
+ from briar.auth._acquirers import ACQUIRERS, AcquirerRegistry, make_acquirer
29
+ from briar.auth._prompt import MockPromptIO, PromptIO, TerminalPromptIO
30
+
31
+
32
+ __all__ = [
33
+ "ACQUIRERS",
34
+ "AcquirerRegistry",
35
+ "CredentialAcquirer",
36
+ "CredentialExpired",
37
+ "Credentials",
38
+ "MockPromptIO",
39
+ "PromptIO",
40
+ "TerminalPromptIO",
41
+ "make_acquirer",
42
+ ]
@@ -0,0 +1,121 @@
1
+ """`CredentialAcquirer` — interactive write side of credential
2
+ management. Symmetric to ``CredentialStore`` (read side) and
3
+ ``CredentialBootstrap`` (bulk-hydrate side).
4
+
5
+ Each provider × auth-style gets its own acquirer (``github-device``,
6
+ ``github-pat``, ``aws-static``, ``aws-sso``, ``jira-token``,
7
+ ``jira-session``, …). Strategy + Registry, same shape as every other
8
+ plugin family in the codebase.
9
+
10
+ The acquirer's job ends at "produce a typed Credentials bundle".
11
+ Persistence is the ``CredentialStore``'s job — the two abstractions
12
+ are deliberately decoupled so the operator can pair any acquirer
13
+ with any store (paste a GitHub token, persist it to Vault; SSO into
14
+ AWS, persist into env-file)."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import enum
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from typing import ClassVar, Dict, List, Optional
23
+
24
+ from briar.auth._prompt import PromptIO
25
+
26
+
27
+ class DestinationPolicy(enum.Enum):
28
+ """Where the acquired credentials are persisted.
29
+
30
+ ``EXTERNAL`` (default) — vendor credentials like a GitHub PAT or
31
+ AWS STS bundle. These can go in any ``CredentialStore`` — operator
32
+ picks via ``--store``.
33
+
34
+ ``BOOTSTRAP_LOCAL`` — the credentials DESCRIBE HOW TO REACH a
35
+ ``CredentialStore`` (e.g. Infisical machine-identity, Vault
36
+ address + token). They cannot be stored INSIDE that store —
37
+ chicken-and-egg. Always persisted to the local ``envfile`` store;
38
+ ``--store`` is ignored with a warning if the operator passes it."""
39
+
40
+ EXTERNAL = "external"
41
+ BOOTSTRAP_LOCAL = "bootstrap"
42
+
43
+
44
+ class CredentialExpired(Exception):
45
+ """Raised by ``CredentialAcquirer.refresh`` when the existing
46
+ bundle cannot be renewed without a fresh interactive login.
47
+ Distinct from generic errors so the CLI can prompt the operator
48
+ to re-run ``briar auth login`` instead of bailing."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Credentials:
53
+ """Provider-agnostic credential bundle.
54
+
55
+ Why a flat ``entries`` dict keyed by env-var name instead of a
56
+ typed model per provider: every ``CredentialStore`` already
57
+ speaks env-var names (``store.read("AWS_ACME_ACCESS_KEY_ID")``).
58
+ A flat dict lets the store ingest the bundle uniformly without
59
+ knowing each provider's shape — the same decoupling that lets
60
+ you mix-and-match acquirer × store."""
61
+
62
+ provider_kind: str
63
+ entries: Dict[str, str]
64
+ expires_at: Optional[datetime] = None
65
+ metadata: Dict[str, str] = field(default_factory=dict)
66
+
67
+ @property
68
+ def names(self) -> List[str]:
69
+ return sorted(self.entries.keys())
70
+
71
+
72
+ class CredentialAcquirer(ABC):
73
+ """One vendor's interactive login flow.
74
+
75
+ Concrete subclasses encode (a) which provider, (b) which
76
+ auth-style (device flow vs paste vs SSO), and (c) which env
77
+ vars get written. They DO NOT persist — the caller (the
78
+ ``briar auth`` command) writes the returned ``Credentials``
79
+ through a chosen ``CredentialStore``."""
80
+
81
+ kind: ClassVar[str] = ""
82
+ # Human-friendly display name; falls back to `kind` if empty.
83
+ display_name: ClassVar[str] = ""
84
+ # Where the result lands. Most acquirers obtain vendor credentials
85
+ # that can be persisted to any store (EXTERNAL). Store-bootstrap
86
+ # acquirers (Infisical, Vault) obtain the credentials needed to
87
+ # talk to THAT store, so they must persist locally.
88
+ destination_policy: ClassVar[DestinationPolicy] = DestinationPolicy.EXTERNAL
89
+
90
+ @abstractmethod
91
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
92
+ """Walk the user through this provider's login flow.
93
+
94
+ May open a browser, prompt for paste, poll a device-code
95
+ endpoint, etc. Implementations must catch their own
96
+ provider-specific errors and translate to a user-facing
97
+ message — only ``CredentialExpired`` and ``CliError`` should
98
+ propagate."""
99
+
100
+ def refresh(self, *, company: str, existing: Credentials) -> Credentials:
101
+ """Renew an existing bundle without re-prompting where
102
+ possible (OAuth refresh tokens, STS re-vend, …).
103
+
104
+ Default: raise ``CredentialExpired`` — most paste-based flows
105
+ can't refresh without a fresh interactive login. Override in
106
+ OAuth / SSO acquirers."""
107
+ raise CredentialExpired(
108
+ f"{self.kind}: cannot refresh non-OAuth credentials — "
109
+ f"run `briar auth login --provider {self.kind} --company {company}`"
110
+ )
111
+
112
+ @classmethod
113
+ @abstractmethod
114
+ def writes(cls, *, company: str) -> List[str]:
115
+ """Env-var names this acquirer writes. Symmetric to the
116
+ ``required_env_vars`` declared by ``RepositoryProvider`` /
117
+ ``TrackerProvider`` / etc. — the doctor cross-checks
118
+ acquired-vs-required to surface drift."""
119
+
120
+
121
+ __all__ = ["CredentialAcquirer", "CredentialExpired", "Credentials", "DestinationPolicy"]
@@ -0,0 +1,62 @@
1
+ """Concrete ``CredentialAcquirer`` adapters + registry.
2
+
3
+ Adding a new acquirer = one module + one entry in ``ACQUIRERS``.
4
+ Same Strategy + Registry pattern as ``_trackers/``, ``_providers/``,
5
+ ``_clouds/``, ``_writers/``, ``_jira_auth.py``."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Dict, Tuple, Type
10
+
11
+ from briar._registry import build_registry
12
+ from briar.auth._acquirer import CredentialAcquirer
13
+ from briar.auth._acquirers.aws_sso import AwsSsoAcquirer
14
+ from briar.auth._acquirers.aws_static import AwsStaticAcquirer
15
+ from briar.auth._acquirers.bitbucket import BitbucketAppPasswordAcquirer
16
+ from briar.auth._acquirers.github_device import GithubDeviceAcquirer
17
+ from briar.auth._acquirers.github_pat import GithubPatAcquirer
18
+ from briar.auth._acquirers.infisical import InfisicalAcquirer
19
+ from briar.auth._acquirers.jira_session import JiraSessionAcquirer
20
+ from briar.auth._acquirers.jira_token import JiraTokenAcquirer
21
+ from briar.auth._acquirers.linear import LinearApiKeyAcquirer
22
+ from briar.errors import CliError
23
+
24
+
25
+ ACQUIRERS: Dict[str, Type[CredentialAcquirer]] = build_registry(
26
+ (
27
+ GithubDeviceAcquirer,
28
+ GithubPatAcquirer,
29
+ BitbucketAppPasswordAcquirer,
30
+ AwsStaticAcquirer,
31
+ AwsSsoAcquirer,
32
+ JiraTokenAcquirer,
33
+ JiraSessionAcquirer,
34
+ LinearApiKeyAcquirer,
35
+ InfisicalAcquirer,
36
+ ),
37
+ kind="credential acquirer",
38
+ name_attr="kind",
39
+ )
40
+
41
+
42
+ class AcquirerRegistry:
43
+ """Factory + introspection — mirrors ``TrackerRegistry`` /
44
+ ``JiraAuthRegistry`` shape."""
45
+
46
+ @classmethod
47
+ def kinds(cls) -> Tuple[str, ...]:
48
+ return tuple(ACQUIRERS.keys())
49
+
50
+ @classmethod
51
+ def make(cls, kind: str) -> CredentialAcquirer:
52
+ cls_obj = ACQUIRERS.get(kind)
53
+ if cls_obj is None:
54
+ known = ", ".join(sorted(ACQUIRERS.keys()))
55
+ raise CliError(f"unknown credential acquirer {kind!r}; known: {known}")
56
+ return cls_obj()
57
+
58
+
59
+ make_acquirer = AcquirerRegistry.make
60
+
61
+
62
+ __all__ = ["ACQUIRERS", "AcquirerRegistry", "make_acquirer"]