-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcredential-server
More file actions
executable file
·1975 lines (1754 loc) · 74.9 KB
/
Copy pathcredential-server
File metadata and controls
executable file
·1975 lines (1754 loc) · 74.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Credential server for CLI tools inside safehouse sandbox.
Listens on a Unix socket and serves credentials on-demand with approval.
Supports GitHub CLI (gh), AWS CLI, Azure CLI (az), SSH agent gating, Docker, and netrc.
Run this OUTSIDE the sandbox.
Protocol (JSON over Unix socket):
Request: {"type": "gh"}
Request: {"type": "aws", "profile": "my-profile"}
Request: {"type": "az"}
Request: {"type": "ssh"}
Request: {"type": "docker"}
Request: {"type": "netrc", "machine": "pypi.fury.io"}
Request: {"type": "open", "url": "https://..."}
Response: {"ok": true, ...credentials...}
Response: {"ok": false}
Remote mode (aws and open): a request with "origin": "remote" arrives over an
ssh -R socket forward. It carries self-reported "host"/"cmd"/"cwd" and is
scoped to a synthetic "remote:<host>" identity. See AGENTS.md "Remote mode".
Approval form (single screen, hotkeys toggle each row, Enter confirms):
scope: [o] once [r] reads [p] pattern [a] all
duration: [1] 1min [5] 5min [s] session (shown for r/p/a; default 5min)
pattern: [g] cycles granularity (shown for p with >1 option:
e.g. "aws s3 cp" vs "aws s3" vs exact)
[t] include sensitive (shown for r/p/a; default on when the
displayed command is itself sensitive)
grants: live summary of the pending approval
[Enter] confirm [d/Esc] deny [?] info
Defaults to once, so a bare Enter allows the single request.
Sensitive escalation (secretsmanager/kms/keyvault except list-*/describe-* metadata
ops; ssm only with --with-decryption):
A persistent approval without "include sensitive" still gates sensitive commands
later, once each, even when otherwise auto-approved:
[Enter] once - approve this one request
[d/Esc] deny - reject
[r] remember - auto-approve sensitive for remaining approval duration
[s] session - auto-approve sensitive for this credential until the
scope ends (sandbox exit / server restart), surviving
later re-approvals
"""
import sys
if "--help" in sys.argv or "-h" in sys.argv:
print("Usage: credential-server [options]")
print()
print("Options:")
print(" --no-auto-git-reads Prompt for git/gh read commands")
print(" By default git/gh reads are auto-approved")
print(" --no-auto-git-push Prompt for git push/gh pr write commands")
print(" By default non-protected-branch pushes are auto-approved")
print(" --no-auto-docker-reads")
print(" Prompt for docker read commands")
print(" By default docker reads are auto-approved")
print(" --no-auto-netrc Prompt for netrc credential requests")
print(" By default netrc requests are auto-approved")
sys.exit(0)
auto_approve_git_reads = "--no-auto-git-reads" not in sys.argv
auto_approve_git_push = "--no-auto-git-push" not in sys.argv
auto_approve_docker_reads = "--no-auto-docker-reads" not in sys.argv
auto_approve_netrc = "--no-auto-netrc" not in sys.argv
import ctypes
import ctypes.util
import fnmatch
import json
import netrc as netrc_mod
import os
import re
import select
import shlex
import shutil
import signal
import socket
import struct
import subprocess
import sys
import termios
import threading
import time
import tty
import urllib.parse
from datetime import datetime, timedelta
try:
import tomllib
except ModuleNotFoundError:
tomllib = None
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SOCKET_PATH = os.path.join(SCRIPT_DIR, ".credential-server.sock")
AUDIT_LOG = os.path.join(SCRIPT_DIR, "credential-audit.log")
CONFIG_PATH = os.path.join(SCRIPT_DIR, "config.toml")
APPROVALS_PATH = os.path.join(SCRIPT_DIR, ".approvals.toml")
DOCKER_PROXY_PATH = os.path.join(SCRIPT_DIR, ".docker-proxy.sock")
# How long an OAuth loopback tunnel for a remote `open` stays up.
LOOPBACK_TUNNEL_TTL = 300
# Click-to-focus notifications via alerter (brew install vjeantet/tap/alerter).
# --sender activates terminal app on click; wezterm cli or AXRaise focuses the pane/window.
WINDOW_TITLE = "credential-server"
TERMINAL_BUNDLE_IDS = {
"Apple_Terminal": "com.apple.Terminal",
"iTerm.app": "com.googlecode.iterm2",
"ghostty": "com.mitchellh.ghostty",
"WezTerm": "com.github.wez.wezterm",
"Alacritty": "org.alacritty",
"kitty": "net.kovidgoyal.kitty",
}
TERMINAL_BUNDLE_ID = TERMINAL_BUNDLE_IDS.get(os.environ.get("TERM_PROGRAM", ""))
HAS_ALERTER = shutil.which("alerter") is not None
WEZTERM_PANE = os.environ.get("WEZTERM_PANE")
# Approval memory: {(safehouse_pid, cred_key): [grant, ...]}. A credential holds
# several grants so approving one pattern doesn't evict another.
# grant: {"mode": ..., "expires": ..., "pattern": ..., "sensitive": ...}
# cred_key examples: "gh", "aws:dev", "ssh:root@host"
# mode: "read" = reads only, "similar" = matching pattern only, "all" = everything
# pattern: pattern string from pattern_options (only for "similar" mode), e.g. "aws s3 cp"
# expires: datetime | None (None = until safehouse process exits)
approvals = {}
# Sensitive escalations remembered for the whole scope, outliving the approval
# entry they were granted under: {(safehouse_pid, cred_key)}
sensitive_scopes = set()
# PIDs of approved commands — children inherit approval
approved_pids = set()
def _pid_alive(pid):
try:
os.kill(pid, 0)
return True
except OSError:
return False
def audit_log(cmd, cred_key, approval, cwd=None, origin=None):
"""Append approved credential request to audit log.
origin is set for remote requests ("remote:<host>") so self-reported
remote commands are distinguishable from verified local ones."""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cwd_tag = f" cwd={cwd}" if cwd else ""
origin_tag = f" origin={origin}" if origin else ""
with open(AUDIT_LOG, "a") as f:
f.write(f"{ts} {approval} {cred_key} {cmd}{cwd_tag}{origin_tag}\n")
SOL_LOCAL = 0
LOCAL_PEERPID = 2
DIM = "\033[2m"
BOLD = "\033[1m"
GREEN = "\033[32m"
RED = "\033[31m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
RESET = "\033[0m"
SENSITIVE_SERVICES = {"secretsmanager", "kms", "keyvault", "secret"}
PROMPT_TIMEOUT = 60
REMINDER_INTERVAL = 15
def load_config():
"""Load config from config.toml. Returns dict with defaults for missing keys."""
defaults = {"auto-approve": {"aws-profiles": []}}
if tomllib is None:
if os.path.exists(CONFIG_PATH):
print(f" {RED}warning: config.toml exists but tomllib unavailable (needs Python 3.11+){RESET}")
return defaults
if not os.path.exists(CONFIG_PATH):
return defaults
try:
with open(CONFIG_PATH, "rb") as f:
return tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError) as e:
print(f" {RED}warning: failed to load config.toml: {e}{RESET}")
return defaults
_config = load_config()
_ap = _config.get("auto-approve", {}).get("aws-profiles", [])
auto_approve_aws_profiles = {p for p in _ap if isinstance(p, str)} if isinstance(_ap, list) else set()
_mp = _config.get("auto-approve", {}).get("main-push-repos", [])
auto_approve_main_push_repos = {r.lower() for r in _mp if isinstance(r, str)} if isinstance(_mp, list) else set()
def is_auto_approved_profile(profile):
return any(fnmatch.fnmatchcase(profile, pat) for pat in auto_approve_aws_profiles)
def is_sensitive(parts):
"""Check if command accesses sensitive services (secrets, keys, etc.).
Metadata-only operations (list-*, describe-*) are exempt; get-* is not,
since get-secret-value reveals secret material. SSM only gates
--with-decryption reads (the only path exposing SecureString values)."""
parts = parts or ()
if "ssm" in parts and "--with-decryption" in parts:
return True
for i, part in enumerate(parts):
if part in SENSITIVE_SERVICES:
nxt = parts[i + 1] if i + 1 < len(parts) else ""
if not nxt.startswith(("list-", "describe-")):
return True
return False
def _is_s3_download(parts):
"""True if an `aws s3 cp/sync` only reads from S3: its first non-flag operand
is an s3:// URI and no later token is one, so an s3:// destination (or a flag
value hiding one) always stays a write. `-` counts as an operand, keeping
`cp - s3://...` an upload. `mv` is excluded: it deletes the source object.
Local filesystem writes (including `sync --delete`) are out of scope."""
if "s3" not in parts:
return False
i = parts.index("s3") + 1
if i >= len(parts) or parts[i] not in ("cp", "sync"):
return False
operands = [p for p in parts[i + 1:] if p == "-" or not p.startswith("-")]
if not operands or not operands[0].startswith("s3://"):
return False
return not any(p.startswith("s3://") for p in operands[1:])
def is_read_only(parts):
"""Classify if command is read-only (safe to auto-approve in read mode).
`parts` is a tokenized argv list (use get_argv() output, not cmd.split())."""
if not parts:
return False
aws_read_prefixes = ("describe-", "list-", "get-", "head-", "batch-get-", "filter-", "lookup-", "search-")
aws_read_exact = {"ls", "tail", "scan", "query", "search", "start-query", "start-live-tail",
"validate-template"}
if parts[0] == "aws":
if is_sensitive(parts):
return False
if _is_s3_download(parts):
return True
for prefix in aws_read_prefixes:
if any(part.startswith(prefix) for part in parts[1:]):
return True
if any(part in aws_read_exact for part in parts[1:]):
return True
return False
gh_read_verbs = {"list", "view", "status", "diff", "checks", "search", "clone", "watch"}
gh_help_flags = {"--version", "--help", "-v", "-h"}
if parts[0] == "gh":
if len(parts) > 1 and parts[1] in gh_help_flags:
return True
if len(parts) > 1 and parts[1] in gh_read_verbs:
return True
if len(parts) > 2 and parts[2] in gh_read_verbs:
return True
if len(parts) > 1 and parts[1] == "api":
mutating = {"-X", "--method"}
for i, p in enumerate(parts[2:], 2):
if p in mutating and i + 1 < len(parts) and parts[i + 1].upper() != "GET":
return False
return True
return False
az_read_verbs = {"list", "show", "get"}
if parts[0] == "az":
if is_sensitive(parts):
return False
return any(part in az_read_verbs for part in parts[1:])
docker_read_cmds = {"ps", "images", "logs", "inspect", "stats", "top", "port",
"version", "info", "diff", "history", "pull"}
docker_read_mgmt = {"ls", "list", "inspect", "logs"}
if parts[0] == "docker":
# docker compose reads
if len(parts) >= 3 and parts[1] == "compose":
return parts[2] in ("ps", "logs", "config", "ls", "images", "version", "pull")
# docker <management> <read-verb> (e.g. docker container ls)
if len(parts) >= 3 and parts[1] in ("container", "image", "network", "volume", "buildx"):
return parts[2] in docker_read_mgmt
# docker <subcommand>
if len(parts) >= 2:
return parts[1] in docker_read_cmds
return False
git_read_subcommands = {"fetch", "pull", "clone", "ls-remote", "remote"}
if parts[0] == "git" or parts[0].endswith("/git"):
i = _git_subcommand_index(parts)
return i is not None and parts[i] in git_read_subcommands
return False
def rw_tag(parts):
"""'read'/'write' for tools is_read_only classifies, None for the rest."""
if not parts or os.path.basename(parts[0]) not in ("aws", "gh", "az", "docker", "git"):
return None
return "read" if is_read_only(parts) else "write"
def approved_line(tags, cmd_parts, cmd, actual_cmd=None):
rw = rw_tag(cmd_parts)
if rw:
tags = tags + [rw]
head = actual_cmd or cmd
if rw == "write":
head = f"{RED}{head}{RESET}"
body = f"{head}\n {DIM}via: {cmd}{RESET}" if actual_cmd else head
return f"\n {GREEN}✓{RESET} {DIM}({', '.join(tags)}){RESET}\n {body}"
def _git_subcommand_index(parts):
"""Index of the git subcommand in parts, skipping global `-c key=val` /
`-C path` flags. Returns None if no subcommand found."""
i = 1
while i < len(parts) - 1 and parts[i] in ("-c", "-C"):
i += 2
return i if i < len(parts) else None
def is_safe_docker_write(parts):
"""Check if docker write command is safe to auto-approve."""
if not parts or len(parts) < 2 or parts[0] != "docker":
return False
# docker exec is always safe (existing container's security context)
if parts[1] == "exec":
return True
if parts[1] != "run":
return False
unsafe_flags = {"--privileged", "--cap-add", "--device", "--security-opt"}
volume_flags = {"-v", "--volume", "--mount"}
namespace_flags = {"--net", "--network", "--pid", "--ipc", "--uts", "--userns"}
for i, part in enumerate(parts[2:], 2):
flag = part.split("=")[0]
if flag in unsafe_flags or flag in volume_flags:
return False
if flag in namespace_flags:
val = part.split("=", 1)[1] if "=" in part else (parts[i + 1] if i + 1 < len(parts) else "")
if val == "host":
return False
return True
def _ssh_targets_git_host(parts):
"""Check if SSH command targets a known git host."""
git_hosts = {"github.com", "gitlab.com", "bitbucket.org"}
return any(a in git_hosts or a.split("@")[-1] in git_hosts for a in parts[1:])
def is_git_gh_read(parts):
"""Check if command is a git, gh, or git-related SSH read operation."""
if not parts:
return False
if parts[0] == "gh" or parts[0] == "git" or parts[0].endswith("/git"):
return is_read_only(parts)
# ssh -G only prints config (no connection) - safe regardless of caller
if parts[0] == "ssh" and "-G" in parts and _ssh_targets_git_host(parts):
return True
return False
def is_claude_code(chain):
"""Check if process chain originates from Claude Code."""
return any(name == "claude" for _, name, _, _ in chain)
def get_git_branch(cwd):
"""Get current git branch name from working directory. Returns branch name or None."""
if not cwd:
return None
try:
return subprocess.check_output(
["git", "-C", cwd, "branch", "--show-current"],
stderr=subprocess.DEVNULL,
).decode().strip() or None
except (subprocess.CalledProcessError, OSError):
return None
def git_target_cwd(parts, safehouse_cwd):
"""Dir where a git command actually operates: `-C <path>` if present (resolved
against safehouse_cwd when relative), else safehouse_cwd. Returns safehouse_cwd
for non-git commands."""
if not parts or not (parts[0] == "git" or parts[0].endswith("/git")):
return safehouse_cwd
target = None
i = 1
while i < len(parts) - 1 and parts[i] in ("-c", "-C"):
if parts[i] == "-C":
target = parts[i + 1]
i += 2
if target is None:
return safehouse_cwd
if not os.path.isabs(target):
if not safehouse_cwd:
return safehouse_cwd
target = os.path.normpath(os.path.join(safehouse_cwd, target))
return target
_OWNER_REPO_ISSUE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+#\d+$")
def get_gh_target_repo(parts):
"""Extract target repo from gh command. Checks -R/--repo flag, URLs, and owner/repo#N args."""
for i, part in enumerate(parts):
if part in ("-R", "--repo") and i + 1 < len(parts):
return parts[i + 1]
if part.startswith("--repo="):
return part.split("=", 1)[1]
# Check positional args for URL or owner/repo#N patterns
for part in parts:
if part.startswith("-"):
continue
if part.startswith("https://github.com/"):
segments = part[len("https://github.com/"):].split("/")
if len(segments) >= 2:
return "/".join(segments[:2])
if _OWNER_REPO_ISSUE_RE.match(part):
return part.split("#")[0]
return None
def get_current_repo(cwd):
"""Get owner/repo from git remote origin. Returns None on failure."""
if not cwd:
return None
try:
url = subprocess.check_output(
["git", "-C", cwd, "remote", "get-url", "origin"],
stderr=subprocess.DEVNULL,
).decode().strip()
for prefix in ("https://github.com/", "git@github.com:"):
if url.startswith(prefix):
return url[len(prefix):].removesuffix(".git")
return None
except (subprocess.CalledProcessError, OSError):
return None
def is_same_repo(parts, safehouse_cwd):
"""Check if gh command targets the checked-out repo. False if targeting a different repo."""
target = get_gh_target_repo(parts)
if target is None:
return True
current = get_current_repo(safehouse_cwd)
if current is None:
return False
return target.lower().rstrip("/") == current.lower().rstrip("/")
def is_safe_gh_api_write(parts, safehouse_cwd):
"""Check if gh api write targets a safe endpoint (comments/reviews) on the same repo."""
# Find API path (first non-flag arg after 'api')
api_path = None
for part in parts[2:]:
if not part.startswith("-"):
api_path = part
break
if not api_path:
return False
segments = api_path.split("/")
# repos/owner/repo/.../comments or .../reviews
if len(segments) < 4 or segments[0] != "repos":
return False
if segments[-1] not in ("comments", "reviews"):
return False
api_repo = "/".join(segments[1:3])
current = get_current_repo(safehouse_cwd)
if current is None:
return False
return api_repo.lower() == current.lower()
def is_safe_git_push(parts, safehouse_cwd):
"""Check if command is a safe push/PR op (not targeting main/master,
unless the repo is listed in main-push-repos). Best-effort."""
if not parts:
return False
protected = {"main", "master"}
# gh pr/issue writes and run rerun (not merge)
if parts[0] == "gh":
if not is_same_repo(parts, safehouse_cwd):
return False
if len(parts) >= 3 and parts[1] == "pr" and parts[2] in ("create", "edit", "close", "comment"):
return True
if len(parts) >= 3 and parts[1] == "issue" and parts[2] == "comment":
return True
if len(parts) >= 3 and parts[1] == "run" and parts[2] == "rerun":
return True
if len(parts) >= 3 and parts[1] == "api":
return is_safe_gh_api_write(parts, safehouse_cwd)
return False
# git push only
if not (parts[0] == "git" or parts[0].endswith("/git")):
return False
ci = _git_subcommand_index(parts)
if ci is None or parts[ci] != "push":
return False
# Don't auto-approve --all or --mirror (pushes all branches including protected)
rest = parts[ci + 1:]
if any(p in ("--all", "--mirror") for p in rest):
return False
# Parse: git push [flags...] [remote] [refspec...]
args = [p for p in rest if not p.startswith("-")]
# Exemption is keyed to the origin URL, so only lift protection for pushes to origin
if auto_approve_main_push_repos and (not args or args[0] == "origin"):
repo = get_current_repo(git_target_cwd(parts, safehouse_cwd))
if repo and repo.lower() in auto_approve_main_push_repos:
protected = set()
# Check explicit refspecs for protected branch targets
if len(args) >= 2:
for refspec in args[1:]:
if ":" in refspec:
target = refspec.split(":")[-1].removeprefix("refs/heads/")
else:
target = refspec.removeprefix("refs/heads/")
if target in protected:
return False
if target.upper() == "HEAD":
break # HEAD resolves to current branch — fall through
else:
return True # All explicit refspecs are non-protected
# No explicit branch or HEAD used - determine current branch from target cwd
branch = get_git_branch(git_target_cwd(parts, safehouse_cwd))
if branch:
return branch not in protected
return False
def notify(message):
"""Send macOS notification with sound. Click focuses terminal if alerter available."""
def _alerter():
"""Run alerter, focus terminal on click."""
try:
result = subprocess.run(
["alerter", "--title", "Credential Request",
"--message", message, "--group", "credential-server",
"--sender", TERMINAL_BUNDLE_ID, "--timeout", "30"],
capture_output=True, text=True,
)
if result.stdout.strip() in ("@CONTENTCLICKED", "@ACTIONCLICKED"):
if WEZTERM_PANE:
# Switch tab and tag window title for AXRaise
subprocess.run(
["wezterm", "cli", "activate-pane", "--pane-id", WEZTERM_PANE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
subprocess.run(
["wezterm", "cli", "set-window-title", "--pane-id", WEZTERM_PANE, WINDOW_TITLE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
# Raise specific window by title
if TERMINAL_BUNDLE_ID:
subprocess.Popen(
["osascript", "-e",
'tell application "System Events"'
f' to tell (first process whose bundle identifier is "{TERMINAL_BUNDLE_ID}")'
f' to perform action "AXRaise" of (first window whose name contains "{WINDOW_TITLE}")'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except OSError:
pass
try:
subprocess.Popen(
["afplay", "/System/Library/Sounds/Funk.aiff"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if HAS_ALERTER and TERMINAL_BUNDLE_ID:
threading.Thread(target=_alerter, daemon=True).start()
else:
subprocess.Popen(
["osascript",
"-e", f'display notification "{message}" with title "Credential Request"'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except OSError:
pass
def cleanup(*_):
try:
os.unlink(SOCKET_PATH)
except OSError:
pass
try:
os.unlink(DOCKER_PROXY_PATH)
except OSError:
pass
sys.exit(0)
signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
# macOS sysctl mib: CTL_KERN=1, KERN_PROCARGS2=49. Returns int32 argc, exec_path,
# optional alignment padding, then argv[0..argc-1], then envp, then applep.
_KERN_PROCARGS2 = 49
_CTL_KERN = 1
def get_argv(pid):
"""Get real argv via KERN_PROCARGS2. Returns list[str] or None if unavailable.
Unlike `ps -o args=`, this preserves the original token boundaries: a
`-c key="val with spaces"` flag stays a single argv element instead of being
re-split on whitespace.
"""
try:
mib = (ctypes.c_int * 3)(_CTL_KERN, _KERN_PROCARGS2, pid)
size = ctypes.c_size_t(0)
if _libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0:
return None
buf = (ctypes.c_char * size.value)()
if _libc.sysctl(mib, 3, buf, ctypes.byref(size), None, 0) != 0:
return None
data = buf.raw[:size.value]
if len(data) < 4:
return None
argc = int.from_bytes(data[:4], "little")
# Skip exec_path (null-terminated) and any zero-padding before argv[0].
nul = data.find(b"\x00", 4)
if nul == -1:
return None
offset = nul + 1
while offset < len(data) and data[offset] == 0:
offset += 1
argv = []
for _ in range(argc):
nul = data.find(b"\x00", offset)
if nul == -1:
return None
argv.append(data[offset:nul].decode("utf-8", "replace"))
offset = nul + 1
return argv
except OSError:
return None
def get_process_chain(pid, max_depth=16):
"""Walk the process tree up from pid.
Each entry: (pid, name, args, argv). `args` is the ps display string (lossy
for quoted values with spaces). `argv` is the real argv list; use it for
parsing decisions, and `args` for display only. Falls back to args.split()
when argv is unavailable (process exited, etc.)."""
chain = []
current = pid
for _ in range(max_depth):
if current <= 1:
break
try:
out = subprocess.check_output(
["ps", "-p", str(current), "-o", "pid=,ppid=,args="],
stderr=subprocess.DEVNULL,
).decode().strip()
parts = out.split(None, 2)
if len(parts) < 3:
break
args = parts[2]
name = os.path.basename(args.split()[0]) if args else "?"
argv = get_argv(int(parts[0]))
if argv is None:
argv = args.split()
chain.append((int(parts[0]), name, args, argv))
current = int(parts[1])
except (subprocess.CalledProcessError, ValueError):
break
return chain
def get_cwd(pid):
"""Get working directory of a process."""
try:
out = subprocess.check_output(
["lsof", "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
stderr=subprocess.DEVNULL,
).decode()
return next((l[1:] for l in out.splitlines() if l.startswith("n/")), None)
except subprocess.CalledProcessError:
return None
def _find_wrapper(argv, wrapper):
"""Return index of an argv token whose path ends with `wrapper` (e.g. 'bin/gh')."""
for i, tok in enumerate(argv or ()):
if tok.endswith("/" + wrapper) or tok == wrapper:
return i
return None
def parse_chain(chain):
"""Extract command and safehouse info from process chain.
Returns (cmd, cmd_argv, cmd_pid, safehouse_pid, safehouse_cwd, actual_cmd).
`cmd`/`actual_cmd` are display strings; `cmd_argv` is the canonical
tokenized argv. Pass it to classifiers, not cmd.split().
"""
cmd = None
cmd_argv = None
cmd_pid = None
ssh_fallback = None
ssh_fallback_argv = None
safehouse_pid = None
safehouse_cwd = None
# Outermost ansible ancestor (workers may match too via fork-inherited argv;
# we want the long-lived top-level so approval inheritance survives worker churn).
ansible_match = None
for pid, name, args, argv in chain:
if ".credential-server.sock" in args:
continue
if "git-credential-helper" in args:
continue
if "libexec/git-core/" in args:
continue
if "aws-credential-process" in args:
continue
if "safehouse" in args:
safehouse_pid = pid
safehouse_cwd = get_cwd(pid)
break
# ansible/ansible-playbook is a python script, so argv[0] may be the
# interpreter and argv[1] the script path. Track outermost match.
if ssh_fallback is not None and argv:
for i, token in enumerate(argv[:2]):
base = os.path.basename(token)
if base in ("ansible-playbook", "ansible"):
new_argv = [base] + argv[i + 1:]
ansible_match = (shlex.join(new_argv), new_argv, pid)
break
# First non-skipped entry is the actual command
if cmd is None:
for wrapper in ("bin/gh", "bin/aws", "bin/ssh", "bin/docker"):
idx = _find_wrapper(argv, wrapper)
if idx is None:
continue
tool = wrapper.split("/")[1]
new_argv = [tool] + argv[idx + 1:]
extracted = shlex.join(new_argv)
if wrapper == "bin/ssh":
# Don't set cmd yet, look for parent (e.g. git)
ssh_fallback = extracted
ssh_fallback_argv = new_argv
else:
cmd = extracted
cmd_argv = new_argv
cmd_pid = pid
break
else:
if ssh_fallback is not None:
# ssh under git: attribute to the parent git command.
# Otherwise keep walking past intermediates (git-lfs, hooks, sh -c, etc.)
is_git = argv and (argv[0] == "git" or argv[0].endswith("/git"))
if is_git:
gi = _git_subcommand_index(argv)
if gi is not None and argv[gi] in (
"push", "fetch", "pull", "clone", "ls-remote",
):
cmd = shlex.join(argv)
cmd_argv = list(argv)
cmd_pid = pid
else:
cmd_argv = list(argv) if argv else args.split()
cmd = shlex.join(cmd_argv)
cmd_pid = pid
if cmd is None and ansible_match is not None:
cmd, cmd_argv, cmd_pid = ansible_match
actual_cmd = ssh_fallback if ssh_fallback and cmd else None
if cmd is None and ssh_fallback is not None:
cmd = ssh_fallback
cmd_argv = ssh_fallback_argv
return cmd, cmd_argv, cmd_pid, safehouse_pid, safehouse_cwd, actual_cmd
def _sanitize_host(host):
"""Clamp a self-reported remote host label to a safe, short token.
Remote requests supply their own host name; restrict it so it can't
inject ANSI codes or newlines into the scope key, prompt, or audit log."""
cleaned = re.sub(r"[^A-Za-z0-9._-]", "", host)[:64]
return cleaned or "unknown"
def loopback_callback_port(url):
"""Port of a loopback redirect_uri in an OAuth authorize URL, else None."""
try:
params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
for value in params.get("redirect_uri", []):
parsed = urllib.parse.urlparse(value)
if parsed.hostname in ("127.0.0.1", "localhost", "::1") and parsed.port:
return parsed.port
except ValueError:
pass
return None
def _cancel_forward(host, spec):
"""Tear down a forward set up by forward_loopback."""
try:
subprocess.run(["ssh", "-O", "cancel", "-L", spec, host],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=10)
except (subprocess.TimeoutExpired, OSError):
pass
def forward_loopback(host, port):
"""Tunnel localhost:<port> to the same port on host, cancelled after a TTL.
An OAuth authorize URL from a remote host redirects to a loopback listener
that lives on that host, but we open the URL in the Mac's browser — without
this forward the callback lands on the Mac's own (empty) port. Needs the
shared ssh ControlMaster to be up; if it isn't, print the manual command."""
spec = f"{port}:127.0.0.1:{port}"
try:
result = subprocess.run(["ssh", "-O", "forward", "-L", spec, host],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, timeout=10)
except (subprocess.TimeoutExpired, OSError) as e:
result = None
error = str(e)
else:
error = result.stderr.decode(errors="replace").strip()
if result is None or result.returncode != 0:
print(f" {RED}callback tunnel failed:{RESET} {error}")
print(f" {DIM}run manually: ssh -L {spec} {host}{RESET}")
return
mins = LOOPBACK_TUNNEL_TTL // 60
print(f" {GREEN}callback tunnel{RESET} {DIM}localhost:{port} → {host} "
f"(cancels in {mins}min){RESET}")
timer = threading.Timer(LOOPBACK_TUNNEL_TTL, _cancel_forward, args=(host, spec))
timer.daemon = True
timer.start()
# ssh flags that take a separate value argument (vs boolean or attached -pVAL form)
SSH_ARG_FLAGS = {"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L",
"-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w"}
def _ssh_host(parts):
"""Find destination host token in an ssh argv (skipping flag/value pairs)."""
i = 1 # skip "ssh"
while i < len(parts):
tok = parts[i]
if not tok.startswith("-"):
return tok
if tok in SSH_ARG_FLAGS:
i += 2 # bare arg-flag consumes next token as its value
else:
i += 1 # boolean flag or attached form (e.g. -p22, -oKey=val)
return None
def pattern_options(parts):
"""Pattern granularities for parts, recommended-first. Empty if too short."""
if not parts:
return []
program = parts[0]
options = []
if program == "ssh":
host = _ssh_host(parts)
if host:
options.append(("host", f"ssh {host}"))
elif program in ("aws", "gh") and len(parts) >= 3:
options.append(("service+verb", " ".join(parts[:3])))
options.append(("service", " ".join(parts[:2])))
elif len(parts) >= 2:
options.append(("subcommand", " ".join(parts[:2])))
exact = " ".join(parts)
if not any(p == exact for _, p in options):
options.append(("exact", exact))
return options
def pattern_matches(parts, stored):
"""True if any of parts' available patterns equals the stored pattern."""
return any(p == stored for _, p in pattern_options(parts))
def grant_time_left(safehouse_pid, grant):
"""Remaining-time label for a grant, or None once it is dead."""
if grant["expires"] is not None:
remaining = (grant["expires"] - datetime.now()).total_seconds()
if remaining <= 0:
return None
mins, secs = divmod(int(remaining), 60)
return f"{mins}m{secs:02d}s"
# Remote scope (string identity): no live PID to probe. A session approval
# lasts until this server process restarts.
if isinstance(safehouse_pid, int) and not _pid_alive(safehouse_pid):
return None
return "sess"
def check_approval(safehouse_pid, cred_key, parts):
"""Find a live grant covering this command. Returns (grant, reason).
`parts` is the tokenized argv list (used for read/pattern classification)."""
key = (safehouse_pid, cred_key)
if safehouse_pid is None or key not in approvals:
return None, None
live = [g for g in approvals[key] if grant_time_left(safehouse_pid, g) is not None]
if len(live) != len(approvals[key]):
if live:
approvals[key] = live
else:
del approvals[key]
save_approvals()
for grant in live:
time_left = grant_time_left(safehouse_pid, grant)
mode = grant.get("mode", "all")
if mode == "all":
return grant, time_left
if mode == "read" and is_read_only(parts):
return grant, f"read {time_left}"
if mode == "similar":
stored = grant.get("pattern")
if stored and pattern_matches(parts, stored):
return grant, f"{stored} {time_left}"
return None, None
def store_approval(safehouse_pid, cred_key, mode, duration, pattern=None, sensitive=False):
"""Add a grant to a credential. mode: read/similar/all, duration: 1/5/session.
Grants accumulate: approving a second pattern keeps the first, so alternating
commands under one credential don't evict each other. Re-approving the same
(mode, pattern) replaces it.
safehouse_pid is an int (local sandbox) or a "remote:<host>" string
(remote mode) — both are valid scope identities. duration "session"
leaves expires=None; for an int that means until the process exits, for
a remote string until the server restarts (see check_approval)."""
if safehouse_pid is None:
return
key = (safehouse_pid, cred_key)
durations = {"1": timedelta(minutes=1), "5": timedelta(minutes=5)}
expires = datetime.now() + durations[duration] if duration in durations else None
grant = {"mode": mode, "expires": expires}
if mode == "similar" and pattern:
grant["pattern"] = pattern
if sensitive:
grant["sensitive"] = True
kept = [g for g in approvals.get(key, [])
if (g["mode"], g.get("pattern")) != (mode, grant.get("pattern"))
and grant_time_left(safehouse_pid, g) is not None]
approvals[key] = kept + [grant]
save_approvals()
def save_approvals():
"""Persist current approvals to .approvals.toml."""
entries = []
now = datetime.now()
for (pid, cred_key), grants in list(approvals.items()):
# Remote scopes (string identity) are session-only — never persisted.
if not isinstance(pid, int):
continue
for grant in grants:
# Skip already-expired grants
if grant["expires"] is not None and grant["expires"] <= now:
continue
rec = {"safehouse_pid": pid, "cred_key": cred_key, "mode": grant["mode"]}
if grant["expires"] is not None:
rec["expires"] = grant["expires"].strftime("%Y-%m-%dT%H:%M:%S")
if "pattern" in grant:
rec["pattern"] = grant["pattern"]
if grant.get("sensitive"):
rec["sensitive"] = True
entries.append(rec)
lines = ["# Auto-managed by credential-server. Edit at your own risk.", ""]
for rec in entries:
lines.append("[[approvals]]")
for k, v in rec.items():
if isinstance(v, bool):
lines.append(f"{k} = {str(v).lower()}")
elif isinstance(v, int):
lines.append(f"{k} = {v}")
else:
lines.append(f'{k} = "{v}"')
lines.append("")
for pid, cred_key in sorted(k for k in sensitive_scopes if isinstance(k[0], int)):