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,185 @@
1
+ """AWS SSO / IAM Identity Center OIDC device-code acquirer.
2
+
3
+ Implements the same flow as ``aws sso login`` but vends STS
4
+ credentials directly into briar's env-var convention.
5
+
6
+ Three-step OIDC dance per the SSO spec:
7
+ 1. RegisterClient → client_id, client_secret (cache friendly)
8
+ 2. StartDeviceAuthorization → device_code, user_code, verification_uri
9
+ 3. CreateToken (polled) → access_token
10
+
11
+ Then exchange the SSO access_token for STS credentials via
12
+ ``sso.get_role_credentials(account_id, role_name, access_token)``.
13
+
14
+ Stores AWS_<COMPANY>_ACCESS_KEY_ID, _SECRET_ACCESS_KEY,
15
+ _SESSION_TOKEN, _REGION + records expires_at so the CLI can warn
16
+ the operator about the upcoming rotation."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from briar.auth._acquirer import CredentialAcquirer, Credentials, CredentialExpired
25
+ from briar.auth._prompt import PromptIO
26
+ from briar.env_vars import CredEnv
27
+
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ class AwsSsoAcquirer(CredentialAcquirer):
33
+ kind = "aws-sso"
34
+ display_name = "AWS IAM Identity Center (SSO device-code flow)"
35
+
36
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
37
+ if not company:
38
+ raise ValueError("aws-sso: --company is required")
39
+
40
+ try:
41
+ import boto3
42
+ import botocore.exceptions
43
+ except ImportError:
44
+ raise RuntimeError("aws-sso: boto3 not installed — run `pip install briar-cli` (base)")
45
+
46
+ prompt.info("==> AWS IAM Identity Center — device flow")
47
+ start_url = prompt.prompt(" SSO start URL (e.g. https://acme.awsapps.com/start): ").strip()
48
+ sso_region = prompt.prompt(" SSO region [us-east-1]: ").strip() or "us-east-1"
49
+ if not start_url:
50
+ raise ValueError("aws-sso: SSO start URL required")
51
+
52
+ oidc = boto3.client("sso-oidc", region_name=sso_region)
53
+ sso = boto3.client("sso", region_name=sso_region)
54
+
55
+ # Step 1: register a client (one-shot per session).
56
+ reg = oidc.register_client(clientName="briar-cli", clientType="public")
57
+ client_id = reg["clientId"]
58
+ client_secret = reg["clientSecret"]
59
+
60
+ # Step 2: kick off device auth.
61
+ device = oidc.start_device_authorization(
62
+ clientId=client_id,
63
+ clientSecret=client_secret,
64
+ startUrl=start_url,
65
+ )
66
+ verification_uri = device.get("verificationUriComplete") or device["verificationUri"]
67
+ user_code = device["userCode"]
68
+ device_code = device["deviceCode"]
69
+ interval = int(device.get("interval", 5))
70
+ expires_in = int(device.get("expiresIn", 600))
71
+
72
+ prompt.info(f" 1. Open {verification_uri}")
73
+ prompt.info(f" 2. Verify the code: {user_code}")
74
+ prompt.info(f" 3. Approve the briar-cli device")
75
+ prompt.info(f" polling every {interval}s for up to {expires_in}s …")
76
+ prompt.open_url(verification_uri)
77
+
78
+ # Step 3: poll for the SSO access token.
79
+ def _poll_once() -> Optional[Dict[str, Any]]:
80
+ try:
81
+ return oidc.create_token(
82
+ clientId=client_id,
83
+ clientSecret=client_secret,
84
+ grantType="urn:ietf:params:oauth:grant-type:device_code",
85
+ deviceCode=device_code,
86
+ )
87
+ except botocore.exceptions.ClientError as exc:
88
+ code = exc.response.get("Error", {}).get("Code", "")
89
+ if code in ("AuthorizationPendingException", "SlowDownException"):
90
+ return None
91
+ raise
92
+ except Exception as exc: # noqa: BLE001
93
+ log.debug("aws-sso poll error: %s", exc)
94
+ return None
95
+
96
+ try:
97
+ token_resp = prompt.poll(every=interval, max_wait=expires_in, fn=_poll_once)
98
+ except TimeoutError:
99
+ raise RuntimeError("aws-sso: timed out waiting for authorisation")
100
+
101
+ access_token = token_resp["accessToken"] # type: ignore[index]
102
+
103
+ # Step 4: enumerate accounts/roles the operator has access to.
104
+ accounts = []
105
+ for page in sso.get_paginator("list_accounts").paginate(accessToken=access_token):
106
+ accounts.extend(page.get("accountList", []))
107
+ if not accounts:
108
+ raise RuntimeError("aws-sso: no accounts visible to this SSO identity")
109
+
110
+ if len(accounts) == 1:
111
+ account = accounts[0]
112
+ else:
113
+ prompt.info(" Multiple accounts available:")
114
+ for i, a in enumerate(accounts, start=1):
115
+ prompt.info(f" [{i}] {a['accountId']} {a.get('accountName', '')}")
116
+ idx = int(prompt.prompt(" Select account number: ").strip())
117
+ account = accounts[idx - 1]
118
+ account_id = account["accountId"]
119
+
120
+ roles = []
121
+ for page in sso.get_paginator("list_account_roles").paginate(accessToken=access_token, accountId=account_id):
122
+ roles.extend(page.get("roleList", []))
123
+ if not roles:
124
+ raise RuntimeError(f"aws-sso: no roles for account {account_id}")
125
+ if len(roles) == 1:
126
+ role = roles[0]
127
+ else:
128
+ prompt.info(f" Roles available for account {account_id}:")
129
+ for i, r in enumerate(roles, start=1):
130
+ prompt.info(f" [{i}] {r['roleName']}")
131
+ idx = int(prompt.prompt(" Select role number: ").strip())
132
+ role = roles[idx - 1]
133
+ role_name = role["roleName"]
134
+
135
+ # Step 5: exchange for STS credentials.
136
+ creds_resp = sso.get_role_credentials(
137
+ accessToken=access_token,
138
+ accountId=account_id,
139
+ roleName=role_name,
140
+ )
141
+ rc = creds_resp["roleCredentials"]
142
+
143
+ # Expiry is milliseconds since epoch in the SSO response.
144
+ expires_at_ms = rc.get("expiration")
145
+ expires_at = datetime.fromtimestamp(int(expires_at_ms) / 1000, tz=timezone.utc) if expires_at_ms else None
146
+
147
+ region = prompt.prompt(f" Default region for {company} extractions [{sso_region}]: ").strip() or sso_region
148
+
149
+ return Credentials(
150
+ provider_kind=self.kind,
151
+ entries={
152
+ CredEnv.AWS_KEY_ID.for_company(company): rc["accessKeyId"],
153
+ CredEnv.AWS_SECRET.for_company(company): rc["secretAccessKey"],
154
+ CredEnv.AWS_SESSION.for_company(company): rc.get("sessionToken", ""),
155
+ CredEnv.AWS_REGION.for_company(company): region,
156
+ },
157
+ expires_at=expires_at,
158
+ metadata={
159
+ "auth_mode": "sso-device",
160
+ "account_id": account_id,
161
+ "role_name": role_name,
162
+ "start_url": start_url,
163
+ "sso_region": sso_region,
164
+ },
165
+ )
166
+
167
+ def refresh(self, *, company: str, existing: Credentials) -> Credentials:
168
+ """SSO refresh requires the operator's still-valid SSO access
169
+ token (cached in ``~/.aws/sso/cache/`` by `aws sso login`).
170
+ We don't cache it ourselves yet — punt to a full re-acquire."""
171
+ raise CredentialExpired(
172
+ f"aws-sso: refresh not implemented yet — run "
173
+ f"`briar auth login --provider aws-sso --company {company}` to re-acquire"
174
+ )
175
+
176
+ @classmethod
177
+ def writes(cls, *, company: str) -> List[str]:
178
+ if not company:
179
+ return []
180
+ return [
181
+ CredEnv.AWS_KEY_ID.for_company(company),
182
+ CredEnv.AWS_SECRET.for_company(company),
183
+ CredEnv.AWS_SESSION.for_company(company),
184
+ CredEnv.AWS_REGION.for_company(company),
185
+ ]
@@ -0,0 +1,54 @@
1
+ """AWS static-access-key acquirer.
2
+
3
+ Prompts for AccessKeyId + SecretAccessKey + region. No session
4
+ token (use ``aws-sso`` or external aws-vault/granted for STS-backed
5
+ credentials with auto-rotation)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import List
10
+
11
+ from briar.auth._acquirer import CredentialAcquirer, Credentials
12
+ from briar.auth._prompt import PromptIO
13
+ from briar.env_vars import CredEnv
14
+
15
+
16
+ class AwsStaticAcquirer(CredentialAcquirer):
17
+ kind = "aws-static"
18
+ display_name = "AWS static access key (paste)"
19
+
20
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
21
+ if not company:
22
+ raise ValueError("aws-static: --company is required")
23
+ prompt.info("==> AWS static keys")
24
+ prompt.info(" Paste an IAM user's AccessKeyId + SecretAccessKey.")
25
+ prompt.info(" The key needs only the permissions briar's extractors call:")
26
+ prompt.info(" ec2:Describe* rds:Describe* ecs:List*+Describe*")
27
+ prompt.info(" lambda:List*+Get* logs:Describe* sqs:List*+Get*")
28
+ prompt.info(" sts:GetCallerIdentity")
29
+
30
+ kid = prompt.prompt(" AccessKeyId (AKIA…): ").strip()
31
+ secret = prompt.prompt(" SecretAccessKey: ", secret=True).strip()
32
+ region = prompt.prompt(" Default region [us-east-1]: ").strip() or "us-east-1"
33
+ if not (kid and secret):
34
+ raise ValueError("aws-static: key id and secret required")
35
+
36
+ return Credentials(
37
+ provider_kind=self.kind,
38
+ entries={
39
+ CredEnv.AWS_KEY_ID.for_company(company): kid,
40
+ CredEnv.AWS_SECRET.for_company(company): secret,
41
+ CredEnv.AWS_REGION.for_company(company): region,
42
+ },
43
+ metadata={"auth_mode": "static-iam-user"},
44
+ )
45
+
46
+ @classmethod
47
+ def writes(cls, *, company: str) -> List[str]:
48
+ if not company:
49
+ return []
50
+ return [
51
+ CredEnv.AWS_KEY_ID.for_company(company),
52
+ CredEnv.AWS_SECRET.for_company(company),
53
+ CredEnv.AWS_REGION.for_company(company),
54
+ ]
@@ -0,0 +1,60 @@
1
+ """Bitbucket Cloud app-password acquirer.
2
+
3
+ Walks the user through app-password generation at
4
+ bitbucket.org → personal settings → app passwords, then prompts
5
+ for workspace + username + paste of the generated password.
6
+
7
+ Stores ``BITBUCKET_<COMPANY>_USERNAME``, ``BITBUCKET_<COMPANY>_APP_PASSWORD``,
8
+ ``BITBUCKET_<COMPANY>_WORKSPACE`` (per-company because app passwords
9
+ are tied to a user × workspace pair, unlike GitHub PATs)."""
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import List
14
+
15
+ from briar.auth._acquirer import CredentialAcquirer, Credentials
16
+ from briar.auth._prompt import PromptIO
17
+ from briar.env_vars import CredEnv
18
+
19
+
20
+ _SETTINGS_URL = "https://bitbucket.org/account/settings/app-passwords/"
21
+
22
+
23
+ class BitbucketAppPasswordAcquirer(CredentialAcquirer):
24
+ kind = "bitbucket-app-password"
25
+ display_name = "Bitbucket Cloud app password (paste)"
26
+
27
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
28
+ if not company:
29
+ raise ValueError("bitbucket-app-password: --company is required")
30
+ prompt.info("==> Bitbucket Cloud — app password")
31
+ prompt.info(f" 1. Open {_SETTINGS_URL}")
32
+ prompt.info(" 2. Click 'Create app password'")
33
+ prompt.info(" 3. Required scopes: Repositories: Read + Write, Pull requests: Read + Write")
34
+ prompt.info(" 4. Copy the generated password (you only see it once)")
35
+ prompt.open_url(_SETTINGS_URL)
36
+
37
+ workspace = prompt.prompt(" Bitbucket workspace slug: ").strip()
38
+ username = prompt.prompt(" Bitbucket username (NOT email): ").strip()
39
+ password = prompt.prompt(" paste app password: ", secret=True).strip()
40
+ if not (workspace and username and password):
41
+ raise ValueError("bitbucket-app-password: all three fields required")
42
+
43
+ return Credentials(
44
+ provider_kind=self.kind,
45
+ entries={
46
+ CredEnv.BITBUCKET_WORKSPACE.for_company(company): workspace,
47
+ CredEnv.BITBUCKET_USERNAME.for_company(company): username,
48
+ CredEnv.BITBUCKET_APP_PASSWORD.for_company(company): password,
49
+ },
50
+ )
51
+
52
+ @classmethod
53
+ def writes(cls, *, company: str) -> List[str]:
54
+ if not company:
55
+ return []
56
+ return [
57
+ CredEnv.BITBUCKET_WORKSPACE.for_company(company),
58
+ CredEnv.BITBUCKET_USERNAME.for_company(company),
59
+ CredEnv.BITBUCKET_APP_PASSWORD.for_company(company),
60
+ ]
@@ -0,0 +1,129 @@
1
+ """GitHub OAuth device flow acquirer.
2
+
3
+ Implements the device-flow grant against GitHub's OAuth endpoints
4
+ (``github.com/login/device/code`` + ``github.com/login/oauth/access_token``).
5
+ The user visits the verification URL, enters a short code, and the
6
+ CLI polls until authorisation completes. The CLI never sees the
7
+ password — same security shape as ``gh auth login``.
8
+
9
+ Requires an OAuth App's client_id. Env var: ``BRIAR_GITHUB_CLIENT_ID``
10
+ (global; one OAuth app per briar install). To create one:
11
+ https://github.com/settings/applications/new — set "Device flow"
12
+ to enabled, callback URL can be anything (unused for device flow).
13
+
14
+ Stores ``GITHUB_TOKEN``."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ import urllib.parse
22
+ import urllib.request
23
+ from typing import List
24
+
25
+ from briar.auth._acquirer import CredentialAcquirer, Credentials
26
+ from briar.auth._prompt import PromptIO
27
+
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ _DEVICE_CODE_URL = "https://github.com/login/device/code"
32
+ _TOKEN_URL = "https://github.com/login/oauth/access_token"
33
+ _DEFAULT_SCOPES = "repo,read:org"
34
+
35
+
36
+ def _post_form(url: str, fields: dict) -> dict:
37
+ """POST application/x-www-form-urlencoded with Accept: application/json.
38
+ Returns parsed JSON. Raises on non-2xx."""
39
+ data = urllib.parse.urlencode(fields).encode("utf-8")
40
+ req = urllib.request.Request(
41
+ url,
42
+ data=data,
43
+ headers={"Accept": "application/json", "User-Agent": "briar-cli"},
44
+ method="POST",
45
+ )
46
+ with urllib.request.urlopen(req, timeout=15) as resp:
47
+ return json.loads(resp.read())
48
+
49
+
50
+ class GithubDeviceAcquirer(CredentialAcquirer):
51
+ kind = "github-device"
52
+ display_name = "GitHub OAuth device flow"
53
+
54
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
55
+ client_id = os.environ.get("BRIAR_GITHUB_CLIENT_ID", "").strip()
56
+ if not client_id:
57
+ raise RuntimeError(
58
+ "github-device: BRIAR_GITHUB_CLIENT_ID env var required. "
59
+ "Register an OAuth App at https://github.com/settings/applications/new "
60
+ "with 'Device flow' enabled, then export the client_id."
61
+ )
62
+
63
+ # Step 1: request a device code.
64
+ try:
65
+ init = _post_form(
66
+ _DEVICE_CODE_URL,
67
+ {"client_id": client_id, "scope": _DEFAULT_SCOPES},
68
+ )
69
+ except Exception as exc: # noqa: BLE001
70
+ raise RuntimeError(f"github-device: device-code request failed: {exc}")
71
+
72
+ device_code = init.get("device_code") or ""
73
+ user_code = init.get("user_code") or ""
74
+ verification_uri = init.get("verification_uri") or "https://github.com/login/device"
75
+ interval = max(int(init.get("interval", 5)), 1)
76
+ expires_in = int(init.get("expires_in", 900))
77
+
78
+ if not (device_code and user_code):
79
+ raise RuntimeError(f"github-device: malformed device-code response: {init}")
80
+
81
+ # Step 2: prompt user.
82
+ prompt.info("==> GitHub OAuth — device flow")
83
+ prompt.info(f" 1. Open {verification_uri}")
84
+ prompt.info(f" 2. Enter code: {user_code}")
85
+ prompt.info(f" 3. Authorise the app (scopes: {_DEFAULT_SCOPES})")
86
+ prompt.info(f" polling every {interval}s for up to {expires_in}s …")
87
+ prompt.open_url(verification_uri)
88
+
89
+ # Step 3: poll the token endpoint.
90
+ def _poll_once():
91
+ try:
92
+ resp = _post_form(
93
+ _TOKEN_URL,
94
+ {
95
+ "client_id": client_id,
96
+ "device_code": device_code,
97
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
98
+ },
99
+ )
100
+ except Exception as exc: # noqa: BLE001
101
+ log.debug("github-device: poll error (will retry): %s", exc)
102
+ return None
103
+ err = resp.get("error", "")
104
+ # `authorization_pending` is the normal "user hasn't acted yet"
105
+ # response — keep polling. `slow_down` means double the interval
106
+ # (rare; servers rarely send it). Other errors abort.
107
+ if err == "authorization_pending":
108
+ return None
109
+ if err == "slow_down":
110
+ return None
111
+ if err:
112
+ raise RuntimeError(f"github-device: OAuth error: {err} — {resp.get('error_description', '')}")
113
+ token = resp.get("access_token", "")
114
+ return token or None
115
+
116
+ try:
117
+ token = prompt.poll(every=interval, max_wait=expires_in, fn=_poll_once)
118
+ except TimeoutError:
119
+ raise RuntimeError("github-device: timed out waiting for authorisation")
120
+
121
+ return Credentials(
122
+ provider_kind=self.kind,
123
+ entries={"GITHUB_TOKEN": str(token)},
124
+ metadata={"scopes": _DEFAULT_SCOPES, "auth_mode": "oauth-device"},
125
+ )
126
+
127
+ @classmethod
128
+ def writes(cls, *, company: str) -> List[str]:
129
+ return ["GITHUB_TOKEN"]
@@ -0,0 +1,44 @@
1
+ """GitHub PAT (Personal Access Token) acquirer.
2
+
3
+ Walks the user through manual token generation on github.com →
4
+ settings → developer settings → personal access tokens, then
5
+ prompts for the paste. Stores ``GITHUB_TOKEN`` (no per-company
6
+ suffix — GitHub PATs are workspace-wide in this codebase)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List
11
+
12
+ from briar.auth._acquirer import CredentialAcquirer, Credentials
13
+ from briar.auth._prompt import PromptIO
14
+
15
+
16
+ _TOKENS_URL = "https://github.com/settings/tokens"
17
+
18
+
19
+ class GithubPatAcquirer(CredentialAcquirer):
20
+ kind = "github-pat"
21
+ display_name = "GitHub Personal Access Token (paste)"
22
+
23
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
24
+ prompt.info("==> GitHub PAT — manual token generation")
25
+ prompt.info(f" 1. Open {_TOKENS_URL}")
26
+ prompt.info(" 2. Click 'Generate new token (classic)'")
27
+ prompt.info(" 3. Required scopes:")
28
+ prompt.info(" repo (read PRs, push commits)")
29
+ prompt.info(" read:org (org membership for filters)")
30
+ prompt.info(" 4. Set expiration to 90 days (or your org policy)")
31
+ prompt.info(" 5. Copy the token (ghp_… or github_pat_…)")
32
+ prompt.open_url(_TOKENS_URL)
33
+ token = prompt.prompt(" paste token: ", secret=True).strip()
34
+ if not token:
35
+ raise ValueError("github-pat: empty token")
36
+ return Credentials(
37
+ provider_kind=self.kind,
38
+ entries={"GITHUB_TOKEN": token},
39
+ metadata={"label": f"briar-{company or 'default'}"},
40
+ )
41
+
42
+ @classmethod
43
+ def writes(cls, *, company: str) -> List[str]:
44
+ return ["GITHUB_TOKEN"]
@@ -0,0 +1,80 @@
1
+ """Infisical machine-identity acquirer — "log into Infisical".
2
+
3
+ This is the bootstrap-the-bootstrap flow: every other store backend
4
+ authenticates with credentials the operator already has (AWS keys
5
+ in their ~/.aws, Vault token in their shell, GitHub PAT generated
6
+ on github.com). Infisical's machine identity is generated FROM
7
+ within Infisical's web UI, so we walk the operator through that.
8
+
9
+ The result is THREE values pasted into briar's local store (the
10
+ ``envfile`` backend by default — usually ``~/.config/briar/secrets.env``
11
+ on a laptop). Subsequent processes that load that file then have
12
+ ``INFISICAL_CLIENT_ID/_SECRET/_PROJECT_ID`` in env, so both
13
+ ``InfisicalBootstrap`` (startup hydrate) and ``InfisicalStore``
14
+ (per-name read/write) can talk to Infisical.
15
+
16
+ Once these are set, ``briar auth login --provider github-pat
17
+ --store infisical`` writes the acquired GitHub PAT INTO Infisical,
18
+ not the local envfile. Same with every other acquirer."""
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import List
23
+
24
+ from briar.auth._acquirer import CredentialAcquirer, Credentials, DestinationPolicy
25
+ from briar.auth._prompt import PromptIO
26
+
27
+
28
+ _MACHINE_IDENTITY_URL = "https://app.infisical.com/personal-settings/machine-identities"
29
+
30
+
31
+ class InfisicalAcquirer(CredentialAcquirer):
32
+ kind = "infisical"
33
+ display_name = "Infisical machine identity (paste)"
34
+ # The captured credentials are how briar talks to Infisical itself
35
+ # — they cannot be stored INSIDE Infisical (chicken-and-egg). The
36
+ # CLI forces --store=envfile when this policy is set.
37
+ destination_policy = DestinationPolicy.BOOTSTRAP_LOCAL
38
+
39
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
40
+ prompt.info("==> Infisical machine identity")
41
+ prompt.info(" This is the bootstrap step — once these three values land in your")
42
+ prompt.info(" local store, briar can subsequently use Infisical as a CredentialStore")
43
+ prompt.info(" (`briar auth login ... --store infisical`).")
44
+ prompt.info("")
45
+ prompt.info(f" 1. Open {_MACHINE_IDENTITY_URL}")
46
+ prompt.info(" 2. Click 'Create Identity', name it `briar-cli`")
47
+ prompt.info(" 3. Select 'Universal Auth' as the auth method → create")
48
+ prompt.info(" 4. Open the new identity → 'Create Client Secret'")
49
+ prompt.info(" 5. Add the identity to your project: project Settings → Access Control →")
50
+ prompt.info(" Machine Identities → Add identity → grant 'Secrets / Read' + 'Write'")
51
+ prompt.info(" 6. Copy: Client ID + Client Secret (you only see the secret once) + Project ID")
52
+ prompt.open_url(_MACHINE_IDENTITY_URL)
53
+
54
+ client_id = prompt.prompt(" INFISICAL_CLIENT_ID (UUID): ").strip()
55
+ client_secret = prompt.prompt(" INFISICAL_CLIENT_SECRET: ", secret=True).strip()
56
+ project_id = prompt.prompt(" INFISICAL_PROJECT_ID (UUID, project Settings → General): ").strip()
57
+ env_slug = prompt.prompt(" INFISICAL_ENV slug [prod]: ").strip() or "prod"
58
+ host = prompt.prompt(" INFISICAL_HOST [https://app.infisical.com]: ").strip() or "https://app.infisical.com"
59
+
60
+ if not (client_id and client_secret and project_id):
61
+ raise ValueError("infisical: client_id + client_secret + project_id all required")
62
+
63
+ return Credentials(
64
+ provider_kind=self.kind,
65
+ entries={
66
+ "INFISICAL_CLIENT_ID": client_id,
67
+ "INFISICAL_CLIENT_SECRET": client_secret,
68
+ "INFISICAL_PROJECT_ID": project_id,
69
+ "INFISICAL_ENV": env_slug,
70
+ "INFISICAL_HOST": host,
71
+ },
72
+ metadata={"auth_mode": "universal-auth-machine-identity"},
73
+ )
74
+
75
+ @classmethod
76
+ def writes(cls, *, company: str) -> List[str]:
77
+ # The three required vars (env + host are listed in acquire() but
78
+ # not surfaced here — they're operational defaults the doctor
79
+ # doesn't need to flag as missing).
80
+ return ["INFISICAL_CLIENT_ID", "INFISICAL_CLIENT_SECRET", "INFISICAL_PROJECT_ID"]
@@ -0,0 +1,102 @@
1
+ """Jira browser-session-cookie acquirer.
2
+
3
+ Walks the user through DevTools cookie extraction. For tenants where
4
+ the user cannot generate an API token (SSO policy, restricted
5
+ account types).
6
+
7
+ Stores ``JIRA_<COMPANY>_URL``, ``JIRA_<COMPANY>_TENANT_SESSION_TOKEN``,
8
+ and sets ``JIRA_<COMPANY>_AUTH_KIND=session``. ``cloud.session.token``
9
+ + ``atlassian.xsrf.token`` are accepted as optional extras.
10
+
11
+ Parses the JWT payload to record ``expires_at`` so the CLI can warn
12
+ about the upcoming rotation."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import logging
19
+ from datetime import datetime, timezone
20
+ from typing import List, Optional
21
+
22
+ from briar.auth._acquirer import CredentialAcquirer, Credentials
23
+ from briar.auth._prompt import PromptIO
24
+ from briar.env_vars import CredEnv
25
+
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ def _decode_jwt_exp(jwt: str) -> Optional[datetime]:
31
+ """Return the ``exp`` claim as a tz-aware UTC datetime, or None
32
+ if the token isn't a 3-segment JWT or doesn't carry an exp.
33
+ Silently tolerates malformed segments — the cookie is the source
34
+ of truth; expiry detection is a nice-to-have."""
35
+ parts = jwt.split(".")
36
+ if len(parts) != 3:
37
+ return None
38
+ payload = parts[1]
39
+ padded = payload + "=" * (-len(payload) % 4)
40
+ try:
41
+ claims = json.loads(base64.urlsafe_b64decode(padded))
42
+ except Exception: # noqa: BLE001
43
+ return None
44
+ exp = claims.get("exp")
45
+ if not isinstance(exp, int):
46
+ return None
47
+ return datetime.fromtimestamp(exp, tz=timezone.utc)
48
+
49
+
50
+ class JiraSessionAcquirer(CredentialAcquirer):
51
+ kind = "jira-session"
52
+ display_name = "Jira browser session cookie (DevTools paste)"
53
+
54
+ def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
55
+ if not company:
56
+ raise ValueError("jira-session: --company is required")
57
+
58
+ prompt.info("==> Jira browser-session cookie")
59
+ prompt.info(" 1. Log into your Jira tenant in the browser (https://<org>.atlassian.net)")
60
+ prompt.info(" 2. DevTools → Application → Cookies → <your tenant>")
61
+ prompt.info(" 3. Click the `tenant.session.token` row")
62
+ prompt.info(" 4. DOUBLE-CLICK the Value cell and copy the full value (starts with `eyJ`)")
63
+ prompt.info(" (drag-selecting often truncates the start — use double-click)")
64
+
65
+ url = prompt.prompt(" Jira URL (https://<org>.atlassian.net): ").strip().rstrip("/")
66
+ tenant_token = prompt.prompt(" paste tenant.session.token: ", secret=True).strip()
67
+ cloud_token = prompt.prompt(" paste cloud.session.token (optional, ENTER to skip): ", secret=True).strip()
68
+ xsrf_token = prompt.prompt(" paste atlassian.xsrf.token (optional, ENTER to skip): ", secret=True).strip()
69
+
70
+ if not (url and tenant_token):
71
+ raise ValueError("jira-session: URL + tenant.session.token required (at minimum)")
72
+
73
+ entries = {
74
+ CredEnv.JIRA_URL.for_company(company): url,
75
+ CredEnv.JIRA_TENANT_SESSION_TOKEN.for_company(company): tenant_token,
76
+ CredEnv.JIRA_AUTH_KIND.for_company(company): "session",
77
+ }
78
+ if cloud_token:
79
+ entries[CredEnv.JIRA_SESSION_TOKEN.for_company(company)] = cloud_token
80
+ if xsrf_token:
81
+ entries[CredEnv.JIRA_XSRF_TOKEN.for_company(company)] = xsrf_token
82
+
83
+ expires_at = _decode_jwt_exp(tenant_token)
84
+ return Credentials(
85
+ provider_kind=self.kind,
86
+ entries=entries,
87
+ expires_at=expires_at,
88
+ metadata={"auth_mode": "browser-session"},
89
+ )
90
+
91
+ @classmethod
92
+ def writes(cls, *, company: str) -> List[str]:
93
+ if not company:
94
+ return []
95
+ # The mandatory writes; cloud/xsrf are optional and not listed
96
+ # (doctor reports them as nice-to-have via JiraSessionAuth's
97
+ # required_env_vars).
98
+ return [
99
+ CredEnv.JIRA_URL.for_company(company),
100
+ CredEnv.JIRA_TENANT_SESSION_TOKEN.for_company(company),
101
+ CredEnv.JIRA_AUTH_KIND.for_company(company),
102
+ ]