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/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
cli/vault_keys.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Identity + master-key wrapping for rig data keys.
|
|
3
|
+
|
|
4
|
+
Personal master key is never stored. It unwraps:
|
|
5
|
+
- rig_identity_data_key → /tmp/.rig/.personal-data-key
|
|
6
|
+
- rig_shared_data_key → /tmp/.rig/.shared-data-key
|
|
7
|
+
|
|
8
|
+
Both wraps live in ``.rig/vars.{identity}.yml``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import getpass
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
from typing import Callable, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
22
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
23
|
+
|
|
24
|
+
# Ephemeral Ansible vault passwords (tmpfs; cleared on reboot).
|
|
25
|
+
TMP_RIG_DIR = "/tmp/.rig"
|
|
26
|
+
PERSONAL_DATA_KEY_PATH = f"{TMP_RIG_DIR}/.personal-data-key"
|
|
27
|
+
SHARED_DATA_KEY_PATH = f"{TMP_RIG_DIR}/.shared-data-key"
|
|
28
|
+
# Legacy alias used by older code paths.
|
|
29
|
+
DEFAULT_DATA_KEY_PATH = f"{TMP_RIG_DIR}/.data-key"
|
|
30
|
+
|
|
31
|
+
IDENTITY_DATA_KEY_VAR = "rig_identity_data_key"
|
|
32
|
+
SHARED_DATA_KEY_VAR = "rig_shared_data_key"
|
|
33
|
+
WRAP_FIELD_NAMES = frozenset({IDENTITY_DATA_KEY_VAR, SHARED_DATA_KEY_VAR})
|
|
34
|
+
|
|
35
|
+
_IDENTITY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
|
36
|
+
_WRAP_PREFIX = "rig1"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_identity(raw: str) -> str:
|
|
40
|
+
"""Normalize an identity to a safe filename fragment."""
|
|
41
|
+
value = (raw or "").strip().lower()
|
|
42
|
+
value = re.sub(r"[^a-z0-9_-]+", "-", value)
|
|
43
|
+
value = value.strip("-_")
|
|
44
|
+
if not value:
|
|
45
|
+
raise ValueError("Identity is empty after sanitization")
|
|
46
|
+
if value[0].isdigit():
|
|
47
|
+
value = f"u-{value}"
|
|
48
|
+
if not _IDENTITY_RE.match(value):
|
|
49
|
+
raise ValueError(f"Invalid identity {raw!r}")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_os_identity() -> str:
|
|
54
|
+
try:
|
|
55
|
+
return sanitize_identity(getpass.getuser())
|
|
56
|
+
except Exception:
|
|
57
|
+
return "user"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_identity(
|
|
61
|
+
explicit: Optional[str] = None,
|
|
62
|
+
config=None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Resolve identity: explicit → RIG_IDENTITY → config → OS user."""
|
|
65
|
+
if explicit:
|
|
66
|
+
return sanitize_identity(explicit)
|
|
67
|
+
env = os.getenv("RIG_IDENTITY", "").strip()
|
|
68
|
+
if env:
|
|
69
|
+
return sanitize_identity(env)
|
|
70
|
+
if config is None:
|
|
71
|
+
try:
|
|
72
|
+
from cli.config import RigConfig
|
|
73
|
+
config = RigConfig()
|
|
74
|
+
except Exception:
|
|
75
|
+
config = None
|
|
76
|
+
if config is not None:
|
|
77
|
+
stored = getattr(config, "get_identity", lambda: None)()
|
|
78
|
+
if stored:
|
|
79
|
+
return sanitize_identity(stored)
|
|
80
|
+
return default_os_identity()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def personal_vars_filename(identity: str) -> str:
|
|
84
|
+
return f"vars.{sanitize_identity(identity)}.yml"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def personal_vars_path(project_dir: str, identity: Optional[str] = None) -> str:
|
|
88
|
+
ident = resolve_identity(identity)
|
|
89
|
+
return os.path.join(os.path.abspath(project_dir), ".rig", personal_vars_filename(ident))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def shared_vars_path(project_dir: str) -> str:
|
|
93
|
+
return os.path.join(os.path.abspath(project_dir), ".rig", "vars.yml")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def generate_data_key() -> str:
|
|
97
|
+
return secrets.token_urlsafe(32)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _derive_key(master: str, salt: bytes) -> bytes:
|
|
101
|
+
kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1)
|
|
102
|
+
return kdf.derive(master.encode("utf-8"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def wrap_with_master_key(plaintext: str, master_key: str) -> str:
|
|
106
|
+
"""Return an opaque ``rig1$…`` blob decryptable only with master_key."""
|
|
107
|
+
if not plaintext:
|
|
108
|
+
raise ValueError("Data key plaintext is required")
|
|
109
|
+
if not master_key:
|
|
110
|
+
raise ValueError("Master key is required")
|
|
111
|
+
salt = os.urandom(16)
|
|
112
|
+
nonce = os.urandom(12)
|
|
113
|
+
key = _derive_key(master_key, salt)
|
|
114
|
+
aes = AESGCM(key)
|
|
115
|
+
ciphertext = aes.encrypt(nonce, plaintext.encode("utf-8"), None)
|
|
116
|
+
parts = [
|
|
117
|
+
_WRAP_PREFIX,
|
|
118
|
+
base64.urlsafe_b64encode(salt).decode("ascii").rstrip("="),
|
|
119
|
+
base64.urlsafe_b64encode(nonce).decode("ascii").rstrip("="),
|
|
120
|
+
base64.urlsafe_b64encode(ciphertext).decode("ascii").rstrip("="),
|
|
121
|
+
]
|
|
122
|
+
return "$".join(parts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unwrap_with_master_key(blob: str, master_key: str) -> str:
|
|
126
|
+
if not blob or not master_key:
|
|
127
|
+
raise ValueError("Wrap blob and master key are required")
|
|
128
|
+
parts = blob.strip().split("$")
|
|
129
|
+
if len(parts) != 4 or parts[0] != _WRAP_PREFIX:
|
|
130
|
+
raise ValueError("Unrecognized key wrap format")
|
|
131
|
+
|
|
132
|
+
def _pad(s: str) -> str:
|
|
133
|
+
return s + "=" * (-len(s) % 4)
|
|
134
|
+
|
|
135
|
+
salt = base64.urlsafe_b64decode(_pad(parts[1]))
|
|
136
|
+
nonce = base64.urlsafe_b64decode(_pad(parts[2]))
|
|
137
|
+
ciphertext = base64.urlsafe_b64decode(_pad(parts[3]))
|
|
138
|
+
key = _derive_key(master_key, salt)
|
|
139
|
+
aes = AESGCM(key)
|
|
140
|
+
try:
|
|
141
|
+
plaintext = aes.decrypt(nonce, ciphertext, None)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
raise ValueError("Incorrect master key or corrupt wrap") from exc
|
|
144
|
+
return plaintext.decode("utf-8")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_data_key_file(path: str) -> Optional[str]:
|
|
148
|
+
if not os.path.exists(path):
|
|
149
|
+
return None
|
|
150
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
151
|
+
key = handle.read()
|
|
152
|
+
if key.endswith("\n"):
|
|
153
|
+
key = key[:-1]
|
|
154
|
+
return key or None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_data_key_file(data_key: str, path: str) -> None:
|
|
158
|
+
directory = os.path.dirname(path)
|
|
159
|
+
if directory:
|
|
160
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
161
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
162
|
+
handle.write(data_key)
|
|
163
|
+
handle.write("\n")
|
|
164
|
+
try:
|
|
165
|
+
os.chmod(path, 0o600)
|
|
166
|
+
if directory:
|
|
167
|
+
os.chmod(directory, 0o700)
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def materialize_data_keys(identity_key: str, shared_key: str) -> Tuple[str, str]:
|
|
173
|
+
"""Write both data keys under /tmp/.rig and return their paths."""
|
|
174
|
+
write_data_key_file(identity_key, PERSONAL_DATA_KEY_PATH)
|
|
175
|
+
write_data_key_file(shared_key, SHARED_DATA_KEY_PATH)
|
|
176
|
+
# Keep legacy path as a copy of the identity key for older call sites.
|
|
177
|
+
write_data_key_file(identity_key, DEFAULT_DATA_KEY_PATH)
|
|
178
|
+
return PERSONAL_DATA_KEY_PATH, SHARED_DATA_KEY_PATH
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def tmp_data_keys_present() -> bool:
|
|
182
|
+
return bool(
|
|
183
|
+
read_data_key_file(PERSONAL_DATA_KEY_PATH)
|
|
184
|
+
and read_data_key_file(SHARED_DATA_KEY_PATH)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_wrap_fields(identity_vars_path: str) -> dict:
|
|
189
|
+
"""Parse wrap fields from an identity vars file without decrypting vaults."""
|
|
190
|
+
if not os.path.exists(identity_vars_path):
|
|
191
|
+
return {}
|
|
192
|
+
with open(identity_vars_path, "r", encoding="utf-8") as handle:
|
|
193
|
+
content = handle.read()
|
|
194
|
+
found = {}
|
|
195
|
+
for name in WRAP_FIELD_NAMES:
|
|
196
|
+
match = re.search(
|
|
197
|
+
rf"^{re.escape(name)}:\s*[\"']?({re.escape(_WRAP_PREFIX)}\$[^\s\"']+)[\"']?\s*$",
|
|
198
|
+
content,
|
|
199
|
+
re.MULTILINE,
|
|
200
|
+
)
|
|
201
|
+
if match:
|
|
202
|
+
found[name] = match.group(1)
|
|
203
|
+
return found
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def upsert_plain_yaml_string(path: str, name: str, value: str) -> None:
|
|
207
|
+
"""Insert or replace a top-level scalar string in a YAML vars file."""
|
|
208
|
+
directory = os.path.dirname(path)
|
|
209
|
+
if directory:
|
|
210
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
211
|
+
existing = ""
|
|
212
|
+
if os.path.exists(path):
|
|
213
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
214
|
+
existing = handle.read()
|
|
215
|
+
pattern = re.compile(
|
|
216
|
+
rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
|
|
217
|
+
re.MULTILINE | re.DOTALL,
|
|
218
|
+
)
|
|
219
|
+
cleaned = pattern.sub("", existing)
|
|
220
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).rstrip()
|
|
221
|
+
line = f'{name}: "{value}"\n'
|
|
222
|
+
new_content = (cleaned + "\n" + line) if cleaned else line
|
|
223
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
224
|
+
handle.write(new_content if new_content.endswith("\n") else new_content + "\n")
|
|
225
|
+
try:
|
|
226
|
+
os.chmod(path, 0o600)
|
|
227
|
+
except OSError:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def write_key_wraps(
|
|
232
|
+
identity_vars_path: str,
|
|
233
|
+
*,
|
|
234
|
+
identity_data_key: str,
|
|
235
|
+
shared_data_key: str,
|
|
236
|
+
master_key: str,
|
|
237
|
+
) -> None:
|
|
238
|
+
upsert_plain_yaml_string(
|
|
239
|
+
identity_vars_path,
|
|
240
|
+
IDENTITY_DATA_KEY_VAR,
|
|
241
|
+
wrap_with_master_key(identity_data_key, master_key),
|
|
242
|
+
)
|
|
243
|
+
upsert_plain_yaml_string(
|
|
244
|
+
identity_vars_path,
|
|
245
|
+
SHARED_DATA_KEY_VAR,
|
|
246
|
+
wrap_with_master_key(shared_data_key, master_key),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def prompt_master_key(
|
|
251
|
+
*,
|
|
252
|
+
confirm: bool = False,
|
|
253
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
254
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
255
|
+
label: str = "Personal master key",
|
|
256
|
+
) -> str:
|
|
257
|
+
ask_secret = secret_fn or getpass.getpass
|
|
258
|
+
ask = input_fn or input
|
|
259
|
+
try:
|
|
260
|
+
value = ask_secret(f"{label}: ").strip()
|
|
261
|
+
except Exception:
|
|
262
|
+
value = ask(f"{label}: ").strip()
|
|
263
|
+
if not value:
|
|
264
|
+
raise ValueError("Personal master key is required")
|
|
265
|
+
if confirm:
|
|
266
|
+
try:
|
|
267
|
+
again = ask_secret(f"Confirm {label.lower()}: ").strip()
|
|
268
|
+
except Exception:
|
|
269
|
+
again = ask(f"Confirm {label.lower()}: ").strip()
|
|
270
|
+
if again != value:
|
|
271
|
+
raise ValueError("Master keys do not match")
|
|
272
|
+
return value
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def unlock_data_keys(
|
|
276
|
+
project_dir: str,
|
|
277
|
+
*,
|
|
278
|
+
identity: Optional[str] = None,
|
|
279
|
+
master_key: Optional[str] = None,
|
|
280
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
281
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
282
|
+
require_wraps: bool = True,
|
|
283
|
+
) -> Tuple[str, str]:
|
|
284
|
+
"""
|
|
285
|
+
Ensure personal + shared data keys exist under /tmp/.rig.
|
|
286
|
+
|
|
287
|
+
Returns ``(personal_key_path, shared_key_path)``.
|
|
288
|
+
"""
|
|
289
|
+
if tmp_data_keys_present():
|
|
290
|
+
return PERSONAL_DATA_KEY_PATH, SHARED_DATA_KEY_PATH
|
|
291
|
+
|
|
292
|
+
ident = resolve_identity(identity)
|
|
293
|
+
id_path = personal_vars_path(project_dir, ident)
|
|
294
|
+
wraps = read_wrap_fields(id_path)
|
|
295
|
+
if IDENTITY_DATA_KEY_VAR not in wraps or SHARED_DATA_KEY_VAR not in wraps:
|
|
296
|
+
if require_wraps:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"Missing key wraps in {id_path}. Run 'rig init' to create "
|
|
299
|
+
f"{IDENTITY_DATA_KEY_VAR} / {SHARED_DATA_KEY_VAR}."
|
|
300
|
+
)
|
|
301
|
+
# Legacy / test fallback: one ephemeral key for both vaults.
|
|
302
|
+
legacy = read_data_key_file(DEFAULT_DATA_KEY_PATH) or generate_data_key()
|
|
303
|
+
return materialize_data_keys(legacy, legacy)
|
|
304
|
+
|
|
305
|
+
key = master_key or prompt_master_key(
|
|
306
|
+
confirm=False,
|
|
307
|
+
input_fn=input_fn,
|
|
308
|
+
secret_fn=secret_fn,
|
|
309
|
+
label="Personal master key (unlock)",
|
|
310
|
+
)
|
|
311
|
+
identity_dk = unwrap_with_master_key(wraps[IDENTITY_DATA_KEY_VAR], key)
|
|
312
|
+
shared_dk = unwrap_with_master_key(wraps[SHARED_DATA_KEY_VAR], key)
|
|
313
|
+
return materialize_data_keys(identity_dk, shared_dk)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def shared_vault_exists(project_dir: str) -> bool:
|
|
317
|
+
path = shared_vars_path(project_dir)
|
|
318
|
+
if not os.path.exists(path):
|
|
319
|
+
return False
|
|
320
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
321
|
+
content = handle.read()
|
|
322
|
+
return "!vault" in content or "$ANSIBLE_VAULT" in content
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def bootstrap_identity_keys(
|
|
326
|
+
project_dir: str,
|
|
327
|
+
*,
|
|
328
|
+
identity: Optional[str] = None,
|
|
329
|
+
master_key: Optional[str] = None,
|
|
330
|
+
shared_data_key: Optional[str] = None,
|
|
331
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
332
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
333
|
+
config=None,
|
|
334
|
+
) -> Tuple[str, str, str]:
|
|
335
|
+
"""
|
|
336
|
+
Create wraps for a new identity and materialize keys.
|
|
337
|
+
|
|
338
|
+
Returns ``(identity, personal_key_path, shared_key_path)``.
|
|
339
|
+
"""
|
|
340
|
+
from cli.config import RigConfig
|
|
341
|
+
|
|
342
|
+
cfg = config or RigConfig()
|
|
343
|
+
ask = input_fn or input
|
|
344
|
+
default_id = resolve_identity(identity, config=cfg)
|
|
345
|
+
answer = ask(f"Identity [{default_id}]: ").strip()
|
|
346
|
+
ident = sanitize_identity(answer or default_id)
|
|
347
|
+
cfg.set_identity(ident)
|
|
348
|
+
|
|
349
|
+
key = master_key or prompt_master_key(
|
|
350
|
+
confirm=True,
|
|
351
|
+
input_fn=input_fn,
|
|
352
|
+
secret_fn=secret_fn,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
identity_dk = generate_data_key()
|
|
356
|
+
if shared_data_key:
|
|
357
|
+
shared_dk = shared_data_key
|
|
358
|
+
elif tmp_data_keys_present():
|
|
359
|
+
shared_dk = read_data_key_file(SHARED_DATA_KEY_PATH) or generate_data_key()
|
|
360
|
+
elif shared_vault_exists(project_dir):
|
|
361
|
+
ask_secret = secret_fn or getpass.getpass
|
|
362
|
+
print(
|
|
363
|
+
"Shared secrets already exist. Paste the shared data key "
|
|
364
|
+
"(from a teammate: rig vars export-shared-key)."
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
pasted = ask_secret("Shared data key: ").strip()
|
|
368
|
+
except Exception:
|
|
369
|
+
pasted = ask("Shared data key: ").strip()
|
|
370
|
+
if not pasted:
|
|
371
|
+
raise ValueError("Shared data key is required to join this workspace")
|
|
372
|
+
shared_dk = pasted
|
|
373
|
+
else:
|
|
374
|
+
shared_dk = generate_data_key()
|
|
375
|
+
print("Generated new shared data key for this workspace")
|
|
376
|
+
|
|
377
|
+
id_path = personal_vars_path(project_dir, ident)
|
|
378
|
+
write_key_wraps(
|
|
379
|
+
id_path,
|
|
380
|
+
identity_data_key=identity_dk,
|
|
381
|
+
shared_data_key=shared_dk,
|
|
382
|
+
master_key=key,
|
|
383
|
+
)
|
|
384
|
+
personal_path, shared_path = materialize_data_keys(identity_dk, shared_dk)
|
|
385
|
+
print(f"Wrote key wraps to {id_path}")
|
|
386
|
+
print(f"Materialized data keys under {TMP_RIG_DIR}")
|
|
387
|
+
return ident, personal_path, shared_path
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def filter_wrap_fields_from_yaml_text(content: str) -> str:
|
|
391
|
+
"""Remove rig_*_data_key wrap lines/blocks from vars YAML text."""
|
|
392
|
+
cleaned = content
|
|
393
|
+
for name in WRAP_FIELD_NAMES:
|
|
394
|
+
cleaned = re.sub(
|
|
395
|
+
rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
|
|
396
|
+
"",
|
|
397
|
+
cleaned,
|
|
398
|
+
flags=re.MULTILINE | re.DOTALL,
|
|
399
|
+
)
|
|
400
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
401
|
+
return cleaned.lstrip("\n")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def write_filtered_vars_copy(src_path: str, dest_path: str) -> Optional[str]:
|
|
405
|
+
"""
|
|
406
|
+
Copy vars file without wrap fields for ansible ``-e @file``.
|
|
407
|
+
|
|
408
|
+
Returns ``None`` when the filtered file would be empty (wraps-only identity
|
|
409
|
+
file). Ansible rejects an empty ``-e @file`` with "could not be made into
|
|
410
|
+
a dictionary".
|
|
411
|
+
"""
|
|
412
|
+
if not os.path.exists(src_path):
|
|
413
|
+
return None
|
|
414
|
+
with open(src_path, "r", encoding="utf-8") as handle:
|
|
415
|
+
content = handle.read()
|
|
416
|
+
filtered = filter_wrap_fields_from_yaml_text(content).strip()
|
|
417
|
+
if not filtered:
|
|
418
|
+
return None
|
|
419
|
+
if not filtered.endswith("\n"):
|
|
420
|
+
filtered = filtered + "\n"
|
|
421
|
+
directory = os.path.dirname(dest_path)
|
|
422
|
+
if directory:
|
|
423
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
424
|
+
with open(dest_path, "w", encoding="utf-8") as handle:
|
|
425
|
+
handle.write(filtered)
|
|
426
|
+
try:
|
|
427
|
+
os.chmod(dest_path, 0o600)
|
|
428
|
+
except OSError:
|
|
429
|
+
pass
|
|
430
|
+
return dest_path
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def is_personal_secret_name(name: str) -> bool:
|
|
434
|
+
"""Heuristic: PATs and coding agent key are personal."""
|
|
435
|
+
n = (name or "").strip()
|
|
436
|
+
if n in {
|
|
437
|
+
"coding_agent_api_key",
|
|
438
|
+
"opencode_api_key",
|
|
439
|
+
"claude_api_key",
|
|
440
|
+
"cursor_api_key",
|
|
441
|
+
"github_pat",
|
|
442
|
+
"gitlab_pat",
|
|
443
|
+
}:
|
|
444
|
+
return True
|
|
445
|
+
if n.endswith("_git_pat"):
|
|
446
|
+
return True
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def fingerprint_key(data_key: str) -> str:
|
|
451
|
+
return hashlib.sha256(data_key.encode("utf-8")).hexdigest()[:12]
|