mystack-rig 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/encrypt.py ADDED
@@ -0,0 +1,1182 @@
1
+ """
2
+ Ansible-compatible encryption for `rig vars encrypt` / decrypt.
3
+
4
+ Data keys are autogenerated and wrapped under a personal master key in
5
+ ``.rig/vars.{identity}.yml``, then materialized under ``/tmp/.rig/`` for
6
+ ``ansible-vault``. Shared secrets live in ``.rig/vars.yml``; personal secrets
7
+ in the identity file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import getpass
13
+ import os
14
+ import re
15
+ import secrets
16
+ import subprocess
17
+ import sys
18
+ from typing import Callable, Optional, TextIO
19
+
20
+ from cli.vault_keys import (
21
+ DEFAULT_DATA_KEY_PATH,
22
+ PERSONAL_DATA_KEY_PATH,
23
+ SHARED_DATA_KEY_PATH,
24
+ WRAP_FIELD_NAMES,
25
+ is_personal_secret_name,
26
+ personal_vars_path,
27
+ read_data_key_file,
28
+ shared_vars_path,
29
+ unlock_data_keys,
30
+ write_data_key_file,
31
+ )
32
+
33
+ # Re-export for callers that imported these from encrypt.
34
+ __all__ = [
35
+ "DEFAULT_DATA_KEY_PATH",
36
+ "PERSONAL_DATA_KEY_PATH",
37
+ "SHARED_DATA_KEY_PATH",
38
+ "VSCODE_BROWSER_PASSWORD_VAR",
39
+ ]
40
+
41
+ _VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
42
+
43
+ # Vaulted password for code-server browser login (``rig dev --browser``).
44
+ VSCODE_BROWSER_PASSWORD_VAR = "vscode_browser_password"
45
+
46
+
47
+ def read_data_key(path: str = DEFAULT_DATA_KEY_PATH) -> Optional[str]:
48
+ return read_data_key_file(path)
49
+
50
+
51
+ def write_data_key(data_key: str, path: str = DEFAULT_DATA_KEY_PATH) -> None:
52
+ write_data_key_file(data_key, path)
53
+
54
+
55
+ def ensure_data_key(
56
+ path: str = DEFAULT_DATA_KEY_PATH,
57
+ input_fn: Optional[Callable[..., str]] = None,
58
+ secret_fn: Optional[Callable[..., str]] = None,
59
+ project_dir: Optional[str] = None,
60
+ ) -> str:
61
+ """
62
+ Ensure ephemeral data keys exist under ``/tmp/.rig``.
63
+
64
+ Prefer unlocking wraps from ``vars.{identity}.yml`` with the personal master
65
+ key. Falls back to legacy prompt-only creation when no identity wraps exist
66
+ and ``path`` was requested explicitly.
67
+ """
68
+ existing = read_data_key(path)
69
+ if existing is not None:
70
+ return existing
71
+
72
+ root = resolve_project_dir(project_dir)
73
+ # Prefer unwrapping master-key wraps when present.
74
+ try:
75
+ from cli.vault_keys import read_wrap_fields, personal_vars_path, resolve_identity
76
+
77
+ wraps = read_wrap_fields(personal_vars_path(root, resolve_identity()))
78
+ if wraps:
79
+ personal_path, shared_path = unlock_data_keys(
80
+ root,
81
+ input_fn=input_fn,
82
+ secret_fn=secret_fn,
83
+ require_wraps=True,
84
+ )
85
+ if path == SHARED_DATA_KEY_PATH:
86
+ key = read_data_key(shared_path)
87
+ else:
88
+ key = read_data_key(personal_path)
89
+ if key:
90
+ if path not in (personal_path, shared_path, DEFAULT_DATA_KEY_PATH):
91
+ write_data_key(key, path)
92
+ return key
93
+ except ValueError:
94
+ pass
95
+
96
+ ask_secret = secret_fn or getpass.getpass
97
+ ask = input_fn or input
98
+ print(f"Temporary data key not found at {path}")
99
+ print("This path is under /tmp and will not survive a reboot.")
100
+ print("Prefer 'rig init' to create master-key-wrapped data keys.")
101
+ try:
102
+ data_key = ask_secret("Enter data key to create: ").strip()
103
+ except Exception:
104
+ data_key = ask("Enter data key to create: ").strip()
105
+ if not data_key:
106
+ raise ValueError("Data key is required")
107
+
108
+ try:
109
+ confirm = ask_secret("Confirm data key: ").strip()
110
+ except Exception:
111
+ confirm = ask("Confirm data key: ").strip()
112
+ if confirm != data_key:
113
+ raise ValueError("Data keys do not match")
114
+
115
+ write_data_key(data_key, path)
116
+ # Also materialize dual keys for callers that expect both paths.
117
+ write_data_key(data_key, PERSONAL_DATA_KEY_PATH)
118
+ write_data_key(data_key, SHARED_DATA_KEY_PATH)
119
+ write_data_key(data_key, DEFAULT_DATA_KEY_PATH)
120
+ print(f"Saved temporary data key to {path}")
121
+ return data_key
122
+
123
+
124
+ def validate_var_name(name: str) -> str:
125
+ name = name.strip()
126
+ if not name or not _VAR_NAME_RE.match(name):
127
+ raise ValueError(
128
+ "Variable name must match [A-Za-z_][A-Za-z0-9_]* "
129
+ f"(got {name!r})"
130
+ )
131
+ return name
132
+
133
+
134
+ def resolve_project_dir(start: Optional[str] = None) -> str:
135
+ """Prefer the nearest ancestor with ``rig.yml`` (cwd or git subfolder).
136
+
137
+ Falls back to the git toplevel when no ``rig.yml`` is found above ``start``,
138
+ then to cwd. This matches ``rig init``, which writes into the current
139
+ directory even when that directory is nested inside a larger git repo.
140
+ """
141
+ start = os.path.abspath(start or os.getcwd())
142
+ cursor = start
143
+ while True:
144
+ if os.path.isfile(os.path.join(cursor, "rig.yml")):
145
+ return cursor
146
+ parent = os.path.dirname(cursor)
147
+ if parent == cursor:
148
+ break
149
+ cursor = parent
150
+
151
+ try:
152
+ result = subprocess.run(
153
+ ["git", "rev-parse", "--show-toplevel"],
154
+ cwd=start,
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=10,
158
+ check=False,
159
+ )
160
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
161
+ return start
162
+ if result.returncode == 0 and result.stdout.strip():
163
+ return os.path.abspath(result.stdout.strip())
164
+ return start
165
+
166
+
167
+ def vars_path_for_project(
168
+ project_dir: Optional[str] = None,
169
+ *,
170
+ personal: bool = False,
171
+ identity: Optional[str] = None,
172
+ ) -> str:
173
+ """Return shared ``vars.yml`` or identity ``vars.{id}.yml`` path."""
174
+ root = resolve_project_dir(project_dir)
175
+ if personal:
176
+ return personal_vars_path(root, identity)
177
+ return shared_vars_path(root)
178
+
179
+
180
+ def _remove_top_level_key(content: str, name: str) -> str:
181
+ """Remove a top-level YAML key and its indented block from vars.yml text."""
182
+ if not content:
183
+ return ""
184
+ pattern = re.compile(
185
+ rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
186
+ re.MULTILINE | re.DOTALL,
187
+ )
188
+ cleaned = pattern.sub("", content)
189
+ # Collapse excessive blank lines left behind.
190
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
191
+ return cleaned.lstrip("\n")
192
+
193
+
194
+ def upsert_vault_var(vars_path: str, name: str, vault_block: str) -> None:
195
+ """Insert or replace an ansible-vault variable block in vars.yml."""
196
+ name = validate_var_name(name)
197
+ directory = os.path.dirname(vars_path)
198
+ if directory:
199
+ os.makedirs(directory, mode=0o700, exist_ok=True)
200
+
201
+ existing = ""
202
+ if os.path.exists(vars_path):
203
+ with open(vars_path, "r", encoding="utf-8") as handle:
204
+ existing = handle.read()
205
+
206
+ cleaned = _remove_top_level_key(existing, name).rstrip()
207
+ block = vault_block.strip() + "\n"
208
+ if cleaned:
209
+ new_content = cleaned + "\n" + block
210
+ else:
211
+ new_content = block
212
+
213
+ with open(vars_path, "w", encoding="utf-8") as handle:
214
+ handle.write(new_content)
215
+ try:
216
+ os.chmod(vars_path, 0o600)
217
+ except OSError:
218
+ pass
219
+
220
+
221
+ def _vault_id_for_password_file(vault_password_file: str) -> str:
222
+ if os.path.abspath(vault_password_file) == os.path.abspath(SHARED_DATA_KEY_PATH):
223
+ return "shared"
224
+ return "personal"
225
+
226
+
227
+ def encrypt_named_value(
228
+ plaintext: str,
229
+ name: str,
230
+ vault_password_file: str = DEFAULT_DATA_KEY_PATH,
231
+ vault_id: Optional[str] = None,
232
+ ) -> str:
233
+ """Encrypt plaintext as an Ansible inventory/vars entry named `name`."""
234
+ name = validate_var_name(name)
235
+ if plaintext == "":
236
+ raise ValueError("Value to encrypt is required")
237
+
238
+ label = vault_id or _vault_id_for_password_file(vault_password_file)
239
+ cmd = [
240
+ "ansible-vault",
241
+ "encrypt_string",
242
+ "--vault-id",
243
+ f"{label}@{vault_password_file}",
244
+ "--encrypt-vault-id",
245
+ label,
246
+ "--stdin-name",
247
+ name,
248
+ ]
249
+ try:
250
+ result = subprocess.run(
251
+ cmd,
252
+ input=plaintext,
253
+ capture_output=True,
254
+ text=True,
255
+ timeout=60,
256
+ check=False,
257
+ )
258
+ except FileNotFoundError as exc:
259
+ raise RuntimeError(
260
+ "ansible-vault not found. Install Ansible to use rig vars encrypt."
261
+ ) from exc
262
+
263
+ if result.returncode != 0:
264
+ detail = (result.stderr or result.stdout or "unknown error").strip()
265
+ raise RuntimeError(f"ansible-vault encrypt_string failed: {detail}")
266
+ return result.stdout.rstrip("\n")
267
+
268
+
269
+ def encrypt_stdin(
270
+ vault_password_file: str = DEFAULT_DATA_KEY_PATH,
271
+ stdin_name: str = "",
272
+ stdin: Optional[TextIO] = None,
273
+ vault_id: Optional[str] = None,
274
+ ) -> str:
275
+ """Encrypt stdin until EOF (Ctrl+D) using ansible-vault --stdin-name."""
276
+ name = validate_var_name(stdin_name)
277
+ source = stdin if stdin is not None else sys.stdin
278
+ if source.isatty():
279
+ print(
280
+ f"Reading multiline value for '{name}'. Finish with Ctrl+D (EOF).",
281
+ file=sys.stderr,
282
+ )
283
+ plaintext = source.read()
284
+ if plaintext == "":
285
+ raise ValueError("Value to encrypt is required")
286
+ return encrypt_named_value(
287
+ plaintext, name, vault_password_file, vault_id=vault_id
288
+ )
289
+
290
+
291
+ def read_variable_name(
292
+ default: Optional[str] = None,
293
+ input_fn: Optional[Callable[..., str]] = None,
294
+ ) -> str:
295
+ ask = input_fn or input
296
+ suffix = f" [{default}]" if default else ""
297
+ answer = ask(f"Variable name{suffix}: ").strip()
298
+ if not answer:
299
+ if default:
300
+ return validate_var_name(default)
301
+ raise ValueError("Variable name is required")
302
+ return validate_var_name(answer)
303
+
304
+
305
+ def read_single_line_value(input_fn: Optional[Callable[..., str]] = None) -> str:
306
+ """Read one line to encrypt (default encrypt mode)."""
307
+ ask = input_fn or input
308
+ value = ask("Enter value to encrypt: ")
309
+ if value is None or value == "":
310
+ raise ValueError("Value to encrypt is required")
311
+ return value
312
+
313
+
314
+ def store_encrypted_var(
315
+ name: str,
316
+ plaintext: str,
317
+ project_dir: Optional[str] = None,
318
+ data_key_path: Optional[str] = None,
319
+ *,
320
+ personal: Optional[bool] = None,
321
+ ) -> str:
322
+ """Encrypt plaintext as `name` into shared or personal vars file."""
323
+ if personal is None:
324
+ personal = is_personal_secret_name(name)
325
+ if data_key_path is None:
326
+ data_key_path = PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
327
+ root = resolve_project_dir(project_dir)
328
+ unlock_data_keys(root, require_wraps=False)
329
+ vault_block = encrypt_named_value(
330
+ plaintext,
331
+ name=name,
332
+ vault_password_file=data_key_path,
333
+ )
334
+ vars_path = vars_path_for_project(root, personal=personal)
335
+ upsert_vault_var(vars_path, name, vault_block)
336
+ print(f"Added '{name}' to {vars_path}")
337
+ return vault_block
338
+
339
+
340
+ def _prompt_secret_with_env_fallback(
341
+ label: str,
342
+ env_name: str,
343
+ input_fn: Optional[Callable[..., str]] = None,
344
+ secret_fn: Optional[Callable[..., str]] = None,
345
+ ) -> str:
346
+ ask_secret = secret_fn or getpass.getpass
347
+ ask = input_fn or input
348
+ env_value = os.getenv(env_name, "").strip()
349
+ hint = f" [Enter keeps {env_name} from env]" if env_value else " [Enter skips]"
350
+ try:
351
+ value = ask_secret(f"{label}{hint}: ").strip()
352
+ except Exception:
353
+ value = ask(f"{label}{hint}: ").strip()
354
+ if value:
355
+ return value
356
+ if env_value:
357
+ print(f"Using {env_name} from the environment")
358
+ return env_value
359
+ return ""
360
+
361
+
362
+ PROVIDER_ENV_VARS = {
363
+ "github": "RIG_GITHUB_PAT",
364
+ "gitlab": "RIG_GITLAB_PAT",
365
+ }
366
+
367
+
368
+ def detect_git_provider(url: str) -> Optional[str]:
369
+ """Return 'github' or 'gitlab' when the remote URL is a known host."""
370
+ if not url:
371
+ return None
372
+ lower = url.strip().lower()
373
+ if "github.com" in lower or lower.startswith("git@github.com:"):
374
+ return "github"
375
+ if "gitlab.com" in lower or lower.startswith("git@gitlab.com:"):
376
+ return "gitlab"
377
+ return None
378
+
379
+
380
+ def git_providers_from_repos(repos: Optional[list]) -> list:
381
+ """Unique git providers (github/gitlab) referenced by workspace repos."""
382
+ found: list = []
383
+ for repo in repos or []:
384
+ if not isinstance(repo, dict):
385
+ continue
386
+ url = (repo.get("url") or repo.get("repo") or "").strip()
387
+ provider = detect_git_provider(url)
388
+ if provider and provider not in found:
389
+ found.append(provider)
390
+ return found
391
+
392
+
393
+ def project_pat_var_name(project_name: str) -> str:
394
+ """Repo-specific PAT variable: ``{project_name}_git_pat`` (YAML-safe)."""
395
+ safe = re.sub(r"[^A-Za-z0-9_]", "_", (project_name or "").strip())
396
+ if not safe:
397
+ safe = "project"
398
+ if safe[0].isdigit():
399
+ safe = f"p_{safe}"
400
+ return validate_var_name(f"{safe}_git_pat")
401
+
402
+
403
+ def provider_pat_var_name(provider: str) -> str:
404
+ return validate_var_name(f"{provider}_pat")
405
+
406
+
407
+ def _is_ssh_git_url(url: str) -> bool:
408
+ value = (url or "").strip()
409
+ return value.startswith("git@") or value.startswith("ssh://")
410
+
411
+
412
+ def to_https_git_url(url: str) -> str:
413
+ """
414
+ Convert SSH git remotes to HTTPS.
415
+
416
+ Examples:
417
+ git@github.com:org/repo.git → https://github.com/org/repo.git
418
+ ssh://git@gitlab.com/org/repo.git → https://gitlab.com/org/repo.git
419
+ """
420
+ value = (url or "").strip()
421
+ if not value:
422
+ return value
423
+ if value.startswith("https://") or value.startswith("http://"):
424
+ return value
425
+
426
+ scp = re.match(r"^git@([^:]+):(.+)$", value)
427
+ if scp:
428
+ host, path = scp.group(1), scp.group(2).lstrip("/")
429
+ if not path.endswith(".git"):
430
+ path = f"{path}.git"
431
+ return f"https://{host}/{path}"
432
+
433
+ ssh = re.match(r"^ssh://(?:git@)?([^/]+)/(.+)$", value)
434
+ if ssh:
435
+ host, path = ssh.group(1), ssh.group(2).lstrip("/")
436
+ if not path.endswith(".git"):
437
+ path = f"{path}.git"
438
+ return f"https://{host}/{path}"
439
+
440
+ return value
441
+
442
+
443
+ def _prompt_yes_no(
444
+ message: str,
445
+ default: bool = True,
446
+ input_fn: Optional[Callable[..., str]] = None,
447
+ ) -> bool:
448
+ ask = input_fn or input
449
+ suffix = " [Y/n]" if default else " [y/N]"
450
+ answer = ask(f"{message}{suffix}: ").strip().lower()
451
+ if not answer:
452
+ return default
453
+ return answer in ("y", "yes")
454
+
455
+
456
+ def collect_repo_pats(
457
+ repos: list,
458
+ input_fn: Optional[Callable[..., str]] = None,
459
+ secret_fn: Optional[Callable[..., str]] = None,
460
+ ) -> tuple:
461
+ """
462
+ Ask for a PAT for each GitHub/GitLab repo (HTTPS or SSH).
463
+
464
+ SSH remotes still need a PAT for CLI auth (MR review, etc.); clone URLs
465
+ are converted to HTTPS separately. After the first PAT for a provider,
466
+ asks whether to store it as the default ``github_pat`` / ``gitlab_pat``.
467
+ Later repos on that provider can Enter to reuse the shared value.
468
+
469
+ Returns ``(assignments, default_pats)`` where assignments is
470
+ ``{repo_name: plaintext}`` and default_pats is
471
+ ``{provider: plaintext}`` for PATs the user marked as default.
472
+ """
473
+ ask_secret = secret_fn or getpass.getpass
474
+ ask = input_fn or input
475
+ provider_shared: dict = {}
476
+ default_pats: dict = {}
477
+ assignments: dict = {}
478
+
479
+ for repo in repos:
480
+ if not isinstance(repo, dict):
481
+ continue
482
+ name = (repo.get("name") or "").strip()
483
+ url = (repo.get("url") or "").strip()
484
+ if not name or not url:
485
+ continue
486
+ provider = detect_git_provider(url)
487
+ if not provider:
488
+ print(f"Skipping PAT for '{name}' (unsupported git host; GitHub/GitLab only)")
489
+ continue
490
+ if _is_ssh_git_url(url):
491
+ print(
492
+ f"Note: '{name}' uses SSH remote; "
493
+ f"will clone via {to_https_git_url(url)} and use PAT for {provider} CLI"
494
+ )
495
+
496
+ if provider in provider_shared:
497
+ shared_label = (
498
+ f"default {provider}_pat"
499
+ if provider in default_pats
500
+ else f"shared {provider} PAT"
501
+ )
502
+ label = f"PAT for '{name}' ({provider}) [Enter reuses {shared_label}]"
503
+ try:
504
+ value = ask_secret(f"{label}: ").strip()
505
+ except Exception:
506
+ value = ask(f"{label}: ").strip()
507
+ if not value:
508
+ assignments[name] = provider_shared[provider]
509
+ print(f"Reusing {shared_label} for '{name}'")
510
+ else:
511
+ assignments[name] = value
512
+ continue
513
+
514
+ env_name = PROVIDER_ENV_VARS[provider]
515
+ env_value = os.getenv(env_name, "").strip()
516
+ hint = f" [Enter keeps {env_name} from env]" if env_value else " [Enter skips]"
517
+ label = f"PAT for '{name}' ({provider}){hint}"
518
+ try:
519
+ value = ask_secret(f"{label}: ").strip()
520
+ except Exception:
521
+ value = ask(f"{label}: ").strip()
522
+ if not value and env_value:
523
+ print(f"Using {env_name} from the environment")
524
+ value = env_value
525
+ if not value:
526
+ print(f"Skipping PAT for '{name}'")
527
+ continue
528
+ assignments[name] = value
529
+ provider_shared[provider] = value
530
+
531
+ var_default = provider_pat_var_name(provider)
532
+ if _prompt_yes_no(
533
+ f"Use this as the default {provider} PAT ({var_default})?",
534
+ default=True,
535
+ input_fn=ask,
536
+ ):
537
+ default_pats[provider] = value
538
+ print(f"Will store as {var_default}")
539
+ else:
540
+ print(f"Will store as {project_pat_var_name(name)} (repo-specific)")
541
+
542
+ return assignments, default_pats
543
+
544
+
545
+ def resolve_repo_pat_var_names(
546
+ assignments: dict,
547
+ repos: list,
548
+ default_pats: Optional[dict] = None,
549
+ ) -> tuple:
550
+ """
551
+ Map repo PATs to variable names.
552
+
553
+ - Provider default (user opted in) → ``github_pat`` / ``gitlab_pat``
554
+ - Same PAT shared by multiple repos → provider var
555
+ - Otherwise → ``{project_name}_git_pat``
556
+
557
+ Returns ``(password_vars, to_store)`` where password_vars is
558
+ ``{repo_name: var_name}`` and to_store is ``{var_name: plaintext}``.
559
+ """
560
+ default_pats = dict(default_pats or {})
561
+ providers = {}
562
+ for repo in repos:
563
+ if not isinstance(repo, dict):
564
+ continue
565
+ name = (repo.get("name") or "").strip()
566
+ url = (repo.get("url") or "").strip()
567
+ if name and url:
568
+ providers[name] = detect_git_provider(url)
569
+
570
+ groups: dict = {}
571
+ for name, plaintext in assignments.items():
572
+ provider = providers.get(name)
573
+ if not provider or not plaintext:
574
+ continue
575
+ groups.setdefault((provider, plaintext), []).append(name)
576
+
577
+ password_vars: dict = {}
578
+ to_store: dict = {}
579
+ for (provider, plaintext), names in groups.items():
580
+ is_default = default_pats.get(provider) == plaintext
581
+ if is_default or len(names) > 1:
582
+ var_name = provider_pat_var_name(provider)
583
+ else:
584
+ var_name = project_pat_var_name(names[0])
585
+ to_store[var_name] = plaintext
586
+ for name in names:
587
+ password_vars[name] = var_name
588
+ return password_vars, to_store
589
+
590
+
591
+ def prompt_store_as_personal(
592
+ kind: str,
593
+ identity: Optional[str] = None,
594
+ input_fn: Optional[Callable[..., str]] = None,
595
+ ) -> bool:
596
+ """
597
+ Confirm whether to store a secret in ``vars.{identity}.yml`` (default yes).
598
+
599
+ ``kind`` is a short label like ``git PAT(s)`` or ``coding agent API key``.
600
+ """
601
+ from cli.vault_keys import resolve_identity
602
+
603
+ ident = resolve_identity(identity)
604
+ return _prompt_yes_no(
605
+ f"Store {kind} in .rig/vars.{ident}.yml (personal, not shared vars.yml)?",
606
+ default=True,
607
+ input_fn=input_fn,
608
+ )
609
+
610
+
611
+ def store_repo_pats(
612
+ to_store: dict,
613
+ project_dir: Optional[str] = None,
614
+ data_key_path: Optional[str] = None,
615
+ input_fn: Optional[Callable[..., str]] = None,
616
+ secret_fn: Optional[Callable[..., str]] = None,
617
+ *,
618
+ personal: Optional[bool] = None,
619
+ ) -> bool:
620
+ """Encrypt and upsert each PAT (default: identity/personal vars file)."""
621
+ if not to_store:
622
+ return False
623
+ root = resolve_project_dir(project_dir)
624
+ unlock_data_keys(
625
+ root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
626
+ )
627
+ if personal is None:
628
+ personal = prompt_store_as_personal("git PAT(s)", input_fn=input_fn)
629
+ key_path = data_key_path or (
630
+ PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
631
+ )
632
+ for var_name, plaintext in to_store.items():
633
+ store_encrypted_var(
634
+ var_name,
635
+ plaintext,
636
+ project_dir=root,
637
+ data_key_path=key_path,
638
+ personal=personal,
639
+ )
640
+ scope = "personal" if personal else "shared"
641
+ print(f"Stored git PAT(s) as {scope} secrets")
642
+ return True
643
+
644
+
645
+ def prompt_and_store_git_pat(
646
+ project_dir: Optional[str] = None,
647
+ data_key_path: str = DEFAULT_DATA_KEY_PATH,
648
+ input_fn: Optional[Callable[..., str]] = None,
649
+ secret_fn: Optional[Callable[..., str]] = None,
650
+ repos: Optional[list] = None,
651
+ ) -> dict:
652
+ """
653
+ Prompt for GitHub/GitLab PATs for the given repos and store them encrypted.
654
+
655
+ When ``repos`` is omitted, falls back to a single shared ``github_pat``
656
+ prompt (legacy). Returns ``{repo_name: var_name}`` for assigned PATs.
657
+ """
658
+ root = resolve_project_dir(project_dir)
659
+ unlock_data_keys(
660
+ root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
661
+ )
662
+ key_path = data_key_path or PERSONAL_DATA_KEY_PATH
663
+ if not repos:
664
+ pat = _prompt_secret_with_env_fallback(
665
+ "GitHub PAT for private repo clones",
666
+ "RIG_GITHUB_PAT",
667
+ input_fn=input_fn,
668
+ secret_fn=secret_fn,
669
+ )
670
+ if not pat:
671
+ print("Skipping github_pat (no PAT provided)")
672
+ return {}
673
+ store_encrypted_var(
674
+ "github_pat",
675
+ pat,
676
+ project_dir=root,
677
+ data_key_path=key_path,
678
+ personal=True,
679
+ )
680
+ return {}
681
+
682
+ assignments, default_pats = collect_repo_pats(
683
+ repos, input_fn=input_fn, secret_fn=secret_fn
684
+ )
685
+ password_vars, to_store = resolve_repo_pat_var_names(
686
+ assignments, repos, default_pats=default_pats
687
+ )
688
+ store_repo_pats(
689
+ to_store,
690
+ project_dir=root,
691
+ data_key_path=key_path,
692
+ input_fn=input_fn,
693
+ secret_fn=secret_fn,
694
+ )
695
+ return password_vars
696
+
697
+
698
+ def prompt_and_store_coding_agent_credentials(
699
+ project_dir: Optional[str] = None,
700
+ data_key_path: Optional[str] = None,
701
+ input_fn: Optional[Callable[..., str]] = None,
702
+ secret_fn: Optional[Callable[..., str]] = None,
703
+ agent_type: Optional[str] = None,
704
+ *,
705
+ personal: Optional[bool] = None,
706
+ var_name: Optional[str] = None,
707
+ env_name: Optional[str] = None,
708
+ ) -> bool:
709
+ """
710
+ Ask for one coding agent API key and store it.
711
+
712
+ The default variable is ``coding_agent_api_key`` (single-agent workspaces).
713
+ Pass ``var_name`` / ``env_name`` when each agent has its own key.
714
+ """
715
+ from cli.agents import (
716
+ AGENT_API_KEY_ENVS,
717
+ AGENT_LABELS,
718
+ LEGACY_CODING_AGENT_API_KEY,
719
+ normalize_agent_type,
720
+ )
721
+
722
+ label = "Coding agent API key"
723
+ agent_normalized = None
724
+ if agent_type:
725
+ try:
726
+ agent_normalized = normalize_agent_type(agent_type)
727
+ label = f"{AGENT_LABELS.get(agent_normalized, agent_normalized)} API key"
728
+ except ValueError:
729
+ agent_normalized = None
730
+ stored_name = var_name or LEGACY_CODING_AGENT_API_KEY
731
+ if env_name:
732
+ env_var = env_name
733
+ elif agent_normalized and var_name:
734
+ env_var = AGENT_API_KEY_ENVS.get(agent_normalized, "RIG_CODING_AGENT_API_KEY")
735
+ else:
736
+ env_var = "RIG_CODING_AGENT_API_KEY"
737
+ root = resolve_project_dir(project_dir)
738
+ unlock_data_keys(
739
+ root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
740
+ )
741
+ if agent_normalized == "opencode":
742
+ skip_hint = " [Enter = OpenCode free models]"
743
+ elif agent_normalized == "cursor":
744
+ skip_hint = " [Enter = skip; Cursor CLI needs CURSOR_API_KEY on the VM]"
745
+ elif agent_normalized == "claude":
746
+ skip_hint = " [Enter = skip; Claude Code needs an Anthropic API key]"
747
+ else:
748
+ skip_hint = ""
749
+ api_key = _prompt_secret_with_env_fallback(
750
+ f"{label}{skip_hint}",
751
+ env_var,
752
+ input_fn=input_fn,
753
+ secret_fn=secret_fn,
754
+ )
755
+ if not api_key:
756
+ if agent_normalized == "opencode":
757
+ print(
758
+ f"Skipping {stored_name} (no key provided); "
759
+ "OpenCode will use free models"
760
+ )
761
+ elif agent_normalized == "cursor":
762
+ print(
763
+ f"Skipping {stored_name} (no key provided); "
764
+ "set a Cursor user API key before rig attach"
765
+ )
766
+ else:
767
+ print(f"Skipping {stored_name} (no key provided)")
768
+ return False
769
+
770
+ if personal is None:
771
+ personal = prompt_store_as_personal(
772
+ "coding agent API key", input_fn=input_fn
773
+ )
774
+ key_path = data_key_path or (
775
+ PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
776
+ )
777
+ store_encrypted_var(
778
+ stored_name,
779
+ api_key,
780
+ project_dir=root,
781
+ data_key_path=key_path,
782
+ personal=personal,
783
+ )
784
+ scope = "personal" if personal else "shared"
785
+ print(f"Stored {stored_name} as a {scope} secret")
786
+ return True
787
+
788
+
789
+ def prompt_and_store_coding_agents_credentials(
790
+ agent_types: list,
791
+ project_dir: Optional[str] = None,
792
+ data_key_path: Optional[str] = None,
793
+ input_fn: Optional[Callable[..., str]] = None,
794
+ secret_fn: Optional[Callable[..., str]] = None,
795
+ *,
796
+ personal: Optional[bool] = None,
797
+ ) -> dict:
798
+ """
799
+ Ask for a distinct API key per selected coding agent.
800
+
801
+ Returns ``{agent_type: stored}``. Personal-vs-shared is confirmed once
802
+ after the keys are collected, then every non-empty key is stored.
803
+ """
804
+ from cli.agents import (
805
+ AGENT_API_KEY_ENVS,
806
+ AGENT_API_KEY_VARS,
807
+ AGENT_LABELS,
808
+ coerce_agent_types,
809
+ )
810
+
811
+ selected = coerce_agent_types(agent_types)
812
+ root = resolve_project_dir(project_dir)
813
+ unlock_data_keys(
814
+ root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
815
+ )
816
+ collected: dict = {}
817
+ for agent in selected:
818
+ label = f"{AGENT_LABELS.get(agent, agent)} API key"
819
+ if agent == "opencode":
820
+ skip_hint = " [Enter = OpenCode free models]"
821
+ elif agent == "cursor":
822
+ skip_hint = " [Enter = skip; Cursor CLI needs CURSOR_API_KEY on the VM]"
823
+ else:
824
+ skip_hint = " [Enter = skip; Claude Code needs an Anthropic API key]"
825
+ api_key = _prompt_secret_with_env_fallback(
826
+ f"{label}{skip_hint}",
827
+ AGENT_API_KEY_ENVS[agent],
828
+ input_fn=input_fn,
829
+ secret_fn=secret_fn,
830
+ )
831
+ if not api_key:
832
+ if agent == "opencode":
833
+ print(
834
+ f"Skipping {AGENT_API_KEY_VARS[agent]} (no key provided); "
835
+ "OpenCode will use free models"
836
+ )
837
+ elif agent == "cursor":
838
+ print(
839
+ f"Skipping {AGENT_API_KEY_VARS[agent]} (no key provided); "
840
+ "set a Cursor user API key before rig attach"
841
+ )
842
+ else:
843
+ print(f"Skipping {AGENT_API_KEY_VARS[agent]} (no key provided)")
844
+ continue
845
+ collected[agent] = api_key
846
+
847
+ if collected and personal is None:
848
+ personal = prompt_store_as_personal(
849
+ "coding agent API keys", input_fn=input_fn
850
+ )
851
+ if collected:
852
+ key_path = data_key_path or (
853
+ PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
854
+ )
855
+ scope = "personal" if personal else "shared"
856
+ for agent, api_key in collected.items():
857
+ var_name = AGENT_API_KEY_VARS[agent]
858
+ store_encrypted_var(
859
+ var_name,
860
+ api_key,
861
+ project_dir=root,
862
+ data_key_path=key_path,
863
+ personal=bool(personal),
864
+ )
865
+ print(f"Stored {var_name} as a {scope} secret")
866
+ return {agent: agent in collected for agent in selected}
867
+
868
+
869
+ def read_vscode_browser_password(
870
+ project_dir: Optional[str] = None,
871
+ data_key_path: Optional[str] = None,
872
+ ) -> Optional[str]:
873
+ """Decrypt ``vscode_browser_password`` when present and decryptable."""
874
+ root = resolve_project_dir(project_dir)
875
+ try:
876
+ unlock_data_keys(root, require_wraps=False)
877
+ except ValueError:
878
+ pass
879
+ key_path = data_key_path or SHARED_DATA_KEY_PATH
880
+ vars_path = vars_path_for_project(root, personal=False)
881
+ if VSCODE_BROWSER_PASSWORD_VAR not in list_vault_var_names(vars_path=vars_path):
882
+ return None
883
+ try:
884
+ return decrypt_var(
885
+ VSCODE_BROWSER_PASSWORD_VAR,
886
+ vars_path=vars_path,
887
+ vault_password_file=key_path,
888
+ )
889
+ except (ValueError, RuntimeError):
890
+ return None
891
+
892
+
893
+ def ensure_vscode_browser_password(
894
+ project_dir: Optional[str] = None,
895
+ data_key_path: Optional[str] = None,
896
+ *,
897
+ force: bool = False,
898
+ ) -> bool:
899
+ """
900
+ Ensure ``vscode_browser_password`` exists in ``.rig/vars.yml`` (vaulted).
901
+
902
+ Autogenerates a high-entropy password on first use. Returns ``True`` when a
903
+ new value was written.
904
+ """
905
+ root = resolve_project_dir(project_dir)
906
+ unlock_data_keys(root, require_wraps=False)
907
+ key_path = data_key_path or SHARED_DATA_KEY_PATH
908
+ vars_path = vars_path_for_project(root, personal=False)
909
+ existing = list_vault_var_names(vars_path=vars_path)
910
+ if VSCODE_BROWSER_PASSWORD_VAR in existing and not force:
911
+ return False
912
+
913
+ plaintext = secrets.token_urlsafe(32)
914
+ store_encrypted_var(
915
+ VSCODE_BROWSER_PASSWORD_VAR,
916
+ plaintext,
917
+ project_dir=root,
918
+ data_key_path=key_path,
919
+ personal=False,
920
+ )
921
+ return True
922
+
923
+
924
+ def interactive_encrypt(
925
+ path: Optional[str] = None,
926
+ stdin_name: Optional[str] = None,
927
+ project_dir: Optional[str] = None,
928
+ input_fn: Optional[Callable[..., str]] = None,
929
+ secret_fn: Optional[Callable[..., str]] = None,
930
+ stdin: Optional[TextIO] = None,
931
+ *,
932
+ personal: Optional[bool] = None,
933
+ ) -> str:
934
+ """
935
+ Unlock data keys, encrypt a value, and store it in shared or personal vars.
936
+
937
+ Default: ask for variable name, then a single-line value.
938
+ With stdin_name: use that variable name and read stdin until EOF.
939
+ """
940
+ root = resolve_project_dir(project_dir)
941
+ use_personal_hint = bool(personal) if personal is not None else False
942
+ default_path = PERSONAL_DATA_KEY_PATH if use_personal_hint else SHARED_DATA_KEY_PATH
943
+ target_path = path or default_path
944
+ ensure_data_key(
945
+ path=target_path,
946
+ input_fn=input_fn,
947
+ secret_fn=secret_fn,
948
+ project_dir=root,
949
+ )
950
+ unlock_data_keys(root, require_wraps=False)
951
+
952
+ if stdin_name:
953
+ name = validate_var_name(stdin_name)
954
+ else:
955
+ name = read_variable_name(input_fn=input_fn)
956
+
957
+ use_personal = is_personal_secret_name(name) if personal is None else personal
958
+ key_path = path or (PERSONAL_DATA_KEY_PATH if use_personal else SHARED_DATA_KEY_PATH)
959
+ if not os.path.exists(key_path):
960
+ src = read_data_key(PERSONAL_DATA_KEY_PATH) or read_data_key(SHARED_DATA_KEY_PATH)
961
+ if src:
962
+ write_data_key(src, key_path)
963
+
964
+ if stdin_name:
965
+ vault_block = encrypt_stdin(
966
+ vault_password_file=key_path,
967
+ stdin_name=name,
968
+ stdin=stdin,
969
+ )
970
+ else:
971
+ plaintext = read_single_line_value(input_fn=input_fn)
972
+ vault_block = encrypt_named_value(
973
+ plaintext,
974
+ name=name,
975
+ vault_password_file=key_path,
976
+ )
977
+
978
+ vars_path = vars_path_for_project(root, personal=use_personal)
979
+ upsert_vault_var(vars_path, name, vault_block)
980
+ print(f"Added '{name}' to {vars_path}")
981
+ return vault_block
982
+
983
+
984
+ def require_data_key_file(path: str = DEFAULT_DATA_KEY_PATH) -> str:
985
+ """Return the data key path, or raise if the temporary key is missing."""
986
+ if not os.path.exists(path):
987
+ raise ValueError(
988
+ f"Temporary data key not found at {path}. "
989
+ "Run 'rig vars encrypt' first to create it."
990
+ )
991
+ return path
992
+
993
+
994
+ def extract_vault_ciphertext(block: str) -> str:
995
+ """Normalize an ansible-vault blob from a !vault YAML block."""
996
+ match = re.search(r"(\$ANSIBLE_VAULT;.*)", block, re.DOTALL)
997
+ if not match:
998
+ raise ValueError("No ansible-vault ciphertext found")
999
+ lines = [line.strip() for line in match.group(1).splitlines() if line.strip()]
1000
+ return "\n".join(lines) + "\n"
1001
+
1002
+
1003
+ def extract_var_block(vars_content: str, name: str) -> str:
1004
+ """Return the top-level YAML block for a variable name."""
1005
+ name = validate_var_name(name)
1006
+ pattern = re.compile(
1007
+ rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
1008
+ re.MULTILINE | re.DOTALL,
1009
+ )
1010
+ match = pattern.search(vars_content)
1011
+ if not match:
1012
+ raise ValueError(f"Variable '{name}' not found in vars.yml")
1013
+ return match.group(0).rstrip()
1014
+
1015
+
1016
+ def decrypt_vault_blob(
1017
+ ciphertext: str,
1018
+ vault_password_file: Optional[str] = None,
1019
+ ) -> str:
1020
+ """Decrypt ansible-vault ciphertext using personal and/or shared data keys."""
1021
+ body = (
1022
+ extract_vault_ciphertext(ciphertext)
1023
+ if "$ANSIBLE_VAULT" in ciphertext
1024
+ else ciphertext
1025
+ )
1026
+
1027
+ import tempfile
1028
+
1029
+ vault_ids = []
1030
+ for label, path in (
1031
+ ("personal", PERSONAL_DATA_KEY_PATH),
1032
+ ("shared", SHARED_DATA_KEY_PATH),
1033
+ ("legacy", DEFAULT_DATA_KEY_PATH),
1034
+ ):
1035
+ if os.path.exists(path):
1036
+ vault_ids.extend(["--vault-id", f"{label}@{path}"])
1037
+ if vault_password_file and os.path.exists(vault_password_file):
1038
+ label = _vault_id_for_password_file(vault_password_file)
1039
+ pair = f"{label}@{vault_password_file}"
1040
+ if pair not in " ".join(vault_ids):
1041
+ vault_ids.extend(["--vault-id", pair])
1042
+ if not vault_ids:
1043
+ raise ValueError(
1044
+ "No data keys under /tmp/.rig. Unlock with your personal master key first."
1045
+ )
1046
+
1047
+ fd, temp_path = tempfile.mkstemp(prefix="rig-vault-", suffix=".txt")
1048
+ try:
1049
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1050
+ handle.write(body)
1051
+ cmd = ["ansible-vault", "decrypt", temp_path, *vault_ids, "--output", "-"]
1052
+ try:
1053
+ result = subprocess.run(
1054
+ cmd,
1055
+ capture_output=True,
1056
+ text=True,
1057
+ timeout=60,
1058
+ check=False,
1059
+ )
1060
+ except FileNotFoundError as exc:
1061
+ raise RuntimeError(
1062
+ "ansible-vault not found. Install Ansible to use rig vars decrypt."
1063
+ ) from exc
1064
+ if result.returncode != 0:
1065
+ detail = (result.stderr or result.stdout or "unknown error").strip()
1066
+ raise RuntimeError(f"ansible-vault decrypt failed: {detail}")
1067
+ return result.stdout
1068
+ finally:
1069
+ try:
1070
+ os.unlink(temp_path)
1071
+ except OSError:
1072
+ pass
1073
+
1074
+
1075
+ def decrypt_var(
1076
+ name: str,
1077
+ vars_path: Optional[str] = None,
1078
+ vault_password_file: Optional[str] = None,
1079
+ project_dir: Optional[str] = None,
1080
+ ) -> str:
1081
+ """Decrypt a named variable from shared or personal vars using data keys."""
1082
+ name = validate_var_name(name)
1083
+ root = resolve_project_dir(project_dir)
1084
+ if vars_path:
1085
+ path = vars_path
1086
+ key_file = vault_password_file or (
1087
+ PERSONAL_DATA_KEY_PATH
1088
+ if os.path.basename(path).startswith("vars.")
1089
+ and os.path.basename(path) != "vars.yml"
1090
+ else SHARED_DATA_KEY_PATH
1091
+ )
1092
+ if not os.path.exists(path):
1093
+ raise ValueError(f"vars file not found at {path}")
1094
+ with open(path, "r", encoding="utf-8") as handle:
1095
+ content = handle.read()
1096
+ block = extract_var_block(content, name)
1097
+ return decrypt_vault_blob(block, vault_password_file=key_file)
1098
+
1099
+ unlock_data_keys(root, require_wraps=False)
1100
+ # Prefer personal, then shared.
1101
+ candidates = [
1102
+ (vars_path_for_project(root, personal=True), PERSONAL_DATA_KEY_PATH),
1103
+ (vars_path_for_project(root, personal=False), SHARED_DATA_KEY_PATH),
1104
+ ]
1105
+ last_err: Optional[Exception] = None
1106
+ for path, key_file in candidates:
1107
+ if not os.path.exists(path):
1108
+ continue
1109
+ try:
1110
+ with open(path, "r", encoding="utf-8") as handle:
1111
+ content = handle.read()
1112
+ block = extract_var_block(content, name)
1113
+ key = vault_password_file or key_file
1114
+ return decrypt_vault_blob(block, vault_password_file=key)
1115
+ except ValueError as exc:
1116
+ last_err = exc
1117
+ continue
1118
+ if last_err:
1119
+ raise last_err
1120
+ raise ValueError(f"Variable '{name}' not found in personal or shared vars")
1121
+
1122
+
1123
+ def list_vault_var_names(vars_path: Optional[str] = None, project_dir: Optional[str] = None) -> list[str]:
1124
+ path = vars_path or vars_path_for_project(project_dir)
1125
+ if not os.path.exists(path):
1126
+ return []
1127
+ with open(path, "r", encoding="utf-8") as handle:
1128
+ content = handle.read()
1129
+ names = []
1130
+ for match in re.finditer(r"^([A-Za-z_][A-Za-z0-9_]*):\s*!vault\b", content, re.MULTILINE):
1131
+ names.append(match.group(1))
1132
+ return names
1133
+
1134
+
1135
+ def list_all_vault_vars(project_dir: Optional[str] = None) -> list[tuple[str, str]]:
1136
+ """Return ``(name, scope)`` for vaulted vars (scope: shared|identity)."""
1137
+ root = resolve_project_dir(project_dir)
1138
+ out: list[tuple[str, str]] = []
1139
+ shared = vars_path_for_project(root, personal=False)
1140
+ for name in list_vault_var_names(vars_path=shared):
1141
+ out.append((name, "shared"))
1142
+ personal = vars_path_for_project(root, personal=True)
1143
+ for name in list_vault_var_names(vars_path=personal):
1144
+ if name in WRAP_FIELD_NAMES:
1145
+ continue
1146
+ out.append((name, "identity"))
1147
+ return out
1148
+
1149
+
1150
+ def interactive_decrypt(
1151
+ path: Optional[str] = None,
1152
+ name: Optional[str] = None,
1153
+ project_dir: Optional[str] = None,
1154
+ input_fn: Optional[Callable[..., str]] = None,
1155
+ ) -> str:
1156
+ """Decrypt a variable from personal or shared vars after unlock."""
1157
+ root = resolve_project_dir(project_dir)
1158
+ unlock_data_keys(root, input_fn=input_fn, require_wraps=False)
1159
+ if name:
1160
+ var_name = validate_var_name(name)
1161
+ else:
1162
+ available = [n for n, _ in list_all_vault_vars(root)]
1163
+ default = available[0] if len(available) == 1 else None
1164
+ if available:
1165
+ labeled = ", ".join(f"{n} ({s})" for n, s in list_all_vault_vars(root))
1166
+ print("Encrypted variables: " + labeled)
1167
+ var_name = read_variable_name(default=default, input_fn=input_fn)
1168
+ return decrypt_var(var_name, project_dir=root, vault_password_file=path)
1169
+
1170
+
1171
+ def export_shared_data_key(
1172
+ project_dir: Optional[str] = None,
1173
+ input_fn: Optional[Callable[..., str]] = None,
1174
+ secret_fn: Optional[Callable[..., str]] = None,
1175
+ ) -> str:
1176
+ """Unlock and return the plaintext shared data key (for teammate onboarding)."""
1177
+ root = resolve_project_dir(project_dir)
1178
+ unlock_data_keys(root, input_fn=input_fn, secret_fn=secret_fn)
1179
+ key = read_data_key(SHARED_DATA_KEY_PATH)
1180
+ if not key:
1181
+ raise ValueError("Shared data key not available after unlock")
1182
+ return key