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.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- 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
|
+
]
|
briar/auth/_acquirer.py
ADDED
|
@@ -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"]
|