비밀 관리 (D-ops-17)
AXE 플랫폼의 모든 서비스 비밀 (DB password, OAuth client_secret, JWT signing key, 외부 API token) 은 Vaultwarden 이 canonical store. 서비스 컨테이너는 vault 를 직접 모르고,
axe ship가 배포 직전 vault → env_file 로 동기화 한 뒤 docker compose 가 env_file 을 읽음.
핵심 결정
| # | 결정 | 이유 |
|---|---|---|
| D-ops-9 | Vaultwarden = canonical secret store | 기존 결정 — Tier-1 vault |
| D-ops-17 | Deploy-time pull (runtime X) | runtime vault 호출은 blast radius + bootstrap 문제. deploy-time 만 의존성 발생, 서비스 코드는 vault 모름 |
데이터 흐름
┌──────────────┐ ┌─────────────────────────────────────────┐
│ Vaultwarden │ ← canonical SoT │ /Users/axe/.axe/customers.yaml │
│ (axe.axelabs │ │ services.<svc>.secrets[]: │
│ .ai/vault) │ │ - env: FRAME_DB_PASSWORD │
└──────┬───────┘ │ vault: "frame/axe/db-password" │
│ │ - env: AZURE_FRAME_MCP_CLIENT_SECRET│
│ bw CLI │ vault: "frame/axe/oauth-..." │
│ (BW_SESSION │ rotation_external: azure │
│ from Keychain) └─────────────────────┬───────────────────┘
│ │ (manifest read)
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ axe ship <service> (release-gate) │
│ step 1: docs-check │
│ step 2: branch + clean │
│ step 3: commits to push │
│ step 4: confirm │
│ step 5: git push │
│ step 6: deploy │
│ ├─ pre-deploy [a]: axe secret check <service> ← abort 시 stop│
│ ├─ pre-deploy [b]: axe secret pull <service> ← env_file write│
│ └─ docker compose up -d ... (env_file = pulled content) │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ /Users/axe/<repo>/.env(.local) │ ← AUTO-GENERATED, mode 600,
│ FRAME_DB_PASSWORD="..." │ git-ignored
│ AZURE_FRAME_MCP_CLIENT_SECRET="..." │
│ ... │
└────────────────────────────────────────┘
│
▼
docker container env (frame-mcp-blue, etc.)매니페스트 스키마
customers.yaml 의 customers.CUSTOMER.services.SERVICE.secrets[]:
services:
frame:
env_file: "/Users/axe/frame/.env.local" # axe secret pull 의 write target
secrets:
- env: FRAME_DB_PASSWORD # 환경변수 이름 (docker-compose 가 보는)
vault: "frame/axe/db-password" # Bitwarden item name (axe secret get 가 보는)
- env: AZURE_FRAME_MCP_CLIENT_SECRET
vault: "frame/axe/oauth-client-secret"
rotation_external: azure # 회전 시 Azure portal 갱신 필요
# ...| 필드 | 필수 | 의미 |
|---|---|---|
env_file | ✓ | axe secret pull 가 작성할 절대 경로. docker-compose.yml 의 env_file: 지시자와 일치 |
secrets[].env | ✓ | 환경변수 이름 (서비스 코드가 os.getenv("...") 로 보는 이름) |
secrets[].vault | ✓ | Vaultwarden item 이름. 컨벤션: SERVICE/CUSTOMER/SHORT (예: frame/axe/db_password) |
secrets[].rotation_external | ✗ | 외부 시스템 식별자 (azure/github/meta/anthropic/naver/slack). 설정 시 axe secret rotate 가 portal URL 안내 |
bootstrap_only | ✗ | true 면 service 자체가 vault 인 경우 (vault 가 자기 자신 못 읽음 — chicken-and-egg). check/pull skip |
CLI 명령
| 명령 | 동작 |
|---|---|
axe secret check SERVICE | 매니페스트 vs vault 보유 비교 → 누락 출력. 누락 시 exit 1 |
axe secret pull SERVICE | 매니페스트 순회 → bw 로 fetch → env_file atomic write (mode 600) |
axe secret push ENV_NAME --service SVC --value VALUE | 매니페스트로 vault 경로 lookup → 신규 생성 or password 필드 갱신. 대형/PEM/파일-소스 비밀은 --value-stdin 으로 stdin 주입 (값이 argv/ps 에 안 뜸): grep ^KEY= .env | cut -d= -f2- | axe secret push KEY --service SVC --value-stdin |
axe secret send ENV_NAME --service SVC [--to RECIP] | 매니페스트 lookup → vault GET → bw send 로 1회용 링크 생성 → URL stdout. 기본 -d 1 -a 1 --hidden. 사람에게 전달 전용 |
axe secret rotate ENV_NAME --service SVC | 새 값 (외부 provider 입력 or 자동 생성) → vault PUT → pull → ship 트리거 |
axe secret get VAULT_NAME | 단건 조회 (저레벨, 기존 명령) |
axe secret list | vault 전체 item 표 (값 출력 X) |
axe secret status | bw session 상태 확인 |
--customer 기본값 axe. realchoice 합류 시 명시.
매니페스트 — 현재 상태 (axe customer)
| service | env_file | 비밀 개수 |
|---|---|---|
| frame | /Users/axe/frame/.env.local | 9 (DB password, JWT, OAuth client_secret, PII passphrase × 2, Claude OAuth, 롯데카드 포털 ID/PW — host-only 스크래퍼 D-frame-lottecard-scraper) |
| hive | /Users/axe/hive/.env.local | 5 (DB password, JWT, PII passphrase × 2, OAuth client_secret) — frame 동일. confidential client (PKCE + secret, accessTokenVersion=2, isFallbackPublicClient=true) |
| blueprint | /Users/axe/blueprint/.env | 14 (DB, agent secret, Anthropic admin, OAuth, NextAuth, GH token, cron, frame token, OIDC 서명키 blueprint/axe/oidc-signing-key — D-axe-idp-1 RS256 base64 PKCS8, …) |
| stream | /Users/axe/stream/.env | 2 (DB, Truvia master key) |
| magnet | /Users/axe/magnet/.env | 10 (DB, HMAC, Meta × 3, Naver × 3, Slack, Threads × 2) |
| matrix | /Users/axe/matrix/.env.local | 3 (DB password, JWT secret, console API token) — D-matrix-1 |
| vault | /Users/axe/.axe/vault/.env | 1 (Vaultwarden 자기 자신 OAuth client_secret) — bootstrap_only: true |
합계 42 개 비밀 매니페스트 등재. (현재 vault 안 실제 item 수와 매니페스트 양은 별개 — axe secret check SVC 로 갭 확인.)
gate (착수 예정, 아직 미배포 — 위 표 미포함): gate 가 배포되면
services.gate.secrets[]에 e-sign 서버 서명키GATE_ESIGN_KEY_PEM(vaultgate/axe/esign-signing-key, RSA-2048 PKCS8 PEM — blueprint OIDC 서명키 패턴과 동형) + 짝 인증서GATE_ESIGN_CERT_PEM(공개 X.509, 비밀 아님이라 env/파일이면 충분) 등재 필요 (D-gate-5 gate-native CAdES-T 서명엔진, /architecture/governance §6). 키 디렉터리 지정 변형 =GATE_ESIGN_KEY_DIR.GATE_TSA_URL(RFC3161 TSA, default DigiCert / 한국 prod Koscom·CrossCert) = 비밀 아님(공개 엔드포인트). ⚠️ 서명키 분실 = 전 과거 봉인 검증불가 → DR/escrow 필수(하드닝, /architecture/governance §9). dev = ephemeral key (vault 불요).
신규 customer 매니페스트 추가 (realchoice 등)
현재 상태 (2026-05-23):
customers.yaml의services:섹션은axecustomer 만 등재. realchoice 는 customer 메타블록 (legal_name, tailscale_host, sso.apps 등) 만 있고 secrets 매니페스트 부재. 즉axe secret push --customer realchoice가 manifest lookup 실패. 이 갭이 D-day “1-shot onboard” 의 가장 큰 막힘 → B-onboard-customers-add + B-onboard-azure-pack.
신규 customer 합류 시 customers.yaml 의 services: 섹션에 customer 별 슬롯을 미리 등재해야 axe secret push 가 동작합니다. realchoice 템플릿:
services:
frame:
realchoice:
env_file: "/Users/realchoice/frame/.env.local"
secrets:
- env: FRAME_DB_PASSWORD
vault: "frame/realchoice/db-password"
- env: FRAME_JWT_SECRET
vault: "frame/realchoice/jwt-secret"
- env: AZURE_FRAME_MCP_CLIENT_SECRET
vault: "frame/realchoice/oauth-client-secret"
rotation_external: azure
- env: FRAME_PII_PASSPHRASE_REALCHOICE
vault: "frame/realchoice/pii-passphrase-realchoice"
- env: CLAUDE_CODE_OAUTH_TOKEN
vault: "frame/realchoice/claude-oauth"
blueprint:
realchoice:
env_file: "/Users/realchoice/blueprint/.env"
secrets:
- env: AZURE_AD_CLIENT_SECRET
vault: "blueprint/realchoice/azure-client-secret"
rotation_external: azure
- env: NEXTAUTH_SECRET
vault: "blueprint/realchoice/nextauth-secret"
# ... (axe customer 의 12 개 패턴 동일 복제)
vaultwarden:
realchoice:
env_file: "/Users/realchoice/.axe/vault/.env"
bootstrap_only: true
secrets:
- env: AZURE_VAULTWARDEN_CLIENT_SECRET
vault: "vaultwarden/realchoice/oauth-client-secret"
rotation_external: azure
hive:
realchoice:
env_file: "/Users/realchoice/hive/.env.local"
secrets:
- env: HIVE_DB_PASSWORD
vault: "hive/realchoice/db-password"
# ... axe 동일 패턴
axe customers add {customer}명령 (B-onboard-customers-add) 가 이 템플릿을 자동 생성하도록 만들면 운영자 손작업 단계 2 제거 가능. customer IT 회신 (8 개 값) JSON 한 덩어리를 받아axe customer ingest {customer} azure-pack.json한 줄에 (a) customers.yaml 슬롯 작성 + (b) 3 개 client_secret 을 vault 로 push 까지 묶는 게 B-onboard-azure-pack 의 본질.
회전 (rotation) 흐름
axe secret rotate AZURE_FRAME_MCP_CLIENT_SECRET --service frame
[1/4] external rotation required (provider: azure)
portal: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/...
action: rotate secret in the provider, then paste the NEW value below.
the OLD value will remain valid until step 4 (rotation overlap window).
new value: ********
[2/4] writing new value to vault item 'frame/axe/oauth-client-secret'
✓ updated existing vault item
[3/4] axe secret pull frame
✓ frame: pulled 7 secrets → /Users/axe/frame/.env.local (mode 600)
[4/4] deploy with new secret
run `axe ship frame` now? (yes/no): yes
→ frame blue/green swap with new secret
Note: in the azure portal, REVOKE the OLD secret value now that the new
one is verified working in production. Both are valid until you delete the old.회전 순서가 중요:
- 외부 provider 먼저 — Azure 가 새 값 발급 → 운영자가 복사
- vault PUT — Bitwarden 에 새 값 저장 (이전 값 덮어쓰기)
- env_file refresh —
axe secret pull - service redeploy —
axe ship(blue/green = 무중단) - OLD secret revoke — Azure portal 에서 이전 값 삭제 (overlap window 닫기)
Pull 동작 — merge-mode (D-ops-18 의 결과)
axe secret pull SERVICE 는 env_file 을 머지 합니다:
- 매니페스트의 키 → vault 값으로 in-place update
- 매니페스트 외 키 / 주석 / 빈 줄 → 그대로 보존
- 매니페스트엔 있는데 파일엔 없는 키 → 파일 끝에 append
→ 비밀 외 config (예: AZURE_AD_CLIENT_ID, NEXTAUTH_URL, DATABASE_URL 등 운영자가 hand-maintain 하는 항목) 가 pull 호출로 사라지지 않음. 초기 구현은 전체 덮어쓰기였고 2026-05-21 첫 blueprint pull 에서 9개 config 가 wipe 되는 사고 발생 → 코드 패치 + D-ops-18 등재.
출력 예:
✓ blueprint: merged 13 secrets into /Users/axe/blueprint/.env (13 updated, 0 added, non-managed lines preserved)Rotation — az CLI 기반 (D-ops-19)
Azure 의 client_secret 회전은 6 단계 az cli 자동화. Portal UI 0 회 (단, app 의 Owner 가 운영자로 박혀 있어야 함):
APP_ID=<app's appId> # e.g. Frame MCP: 137fc0ef-...
OLD_KEY_ID=$(az ad app credential list --id $APP_ID --query "[?starts_with(hint,'<old hint>')].keyId | [0]" -o tsv)
# 1. Azure 새 secret 추가 (--append, OLD 유지 = overlap window)
NEW=$(az ad app credential reset --id $APP_ID \
--display-name "rotated <date>" --years 2 --append \
--query password -o tsv)
# 2. vault PUT
axe secret push <ENV_NAME> --service <svc> --value "$NEW"
# 3. pull (merge — config 보존)
axe secret pull <svc>
# 4. 무중단 swap
axe deploy <svc> axe --apply # frame / hive
# 또는
axe blueprint upgrade axe --apply # blueprint
# 5. 검증 — 컨테이너 env 의 새 secret prefix 가 OLD 와 다른지
docker exec <active-container> env | grep <ENV_NAME>
# 6. 운영 안정 확인 후 OLD revoke
az ad app credential delete --id $APP_ID --key-id $OLD_KEY_IDApp Owner 전제조건: portal-등록 app 은 default 로 owner 없음 → az cli 권한 X. 신규 az cli 등록 app 은 호출자가 owner 자동 박힘. 기존 portal-등록 app 들은 portal Owners 탭에서 운영자 ([email protected]) 명시 추가 필요. 본 플랫폼 현황:
| App | appId | Owner |
|---|---|---|
| frame_mcp | 137fc0ef-... | [email protected] (2026-05-21 추가) |
| hive_mcp | b7ead15d-... | [email protected] (등록 시 자동) |
| blueprint_mcp | 482598f7-... | [email protected] (등록 시 자동) |
| Blueprint Graph (better-auth) | 2b222356-... | [email protected] (2026-05-21 추가) |
| vaultwarden | 9d0dc49b-... | [email protected] (2026-05-21 추가) |
사람에게 전달 — Bitwarden Send
axe secret get 의 결과는 vault → 디스크/컨테이너 경로용. 사람 (신규 직원, customer admin) 에게 보낼 때는 평문 절대 금지 — Teams DM, 이메일, 위키 모두 영구 보존되는 채널. Vaultwarden 의 1회용 링크 (Bitwarden Send) 로 전달한다.
axe secret send (권장)
axe secret send <ENV_NAME> --service <svc> [--to <recipient>]매니페스트 lookup (customers.yaml services.<svc>.secrets[].env → .vault) → bw GET → bw send 파이프. stdout 에 Send URL 만 (상태 메시지는 stderr) — axe secret send ... | pbcopy 로 클립보드 직행. 기본값:
| 플래그 | 의미 | 기본값 |
|---|---|---|
-d N / --days N | N일 후 자동 삭제 | 1 |
-a N / --access N | 최대 N회 열람 후 무효 | 1 |
--to <라벨> | Send name 에 (라벨) 추가 (운영자 추적용, 실 ACL 아님) | 없음 |
--password <p> | 추가 비밀번호 잠금 | 없음 — 외부 customer 대상 시 권장 |
--customer <c> | customer 식별 (기본 axe) | axe |
stdin 으로 값 주입 → argv 에 secret 노출 0 (ps, shell history 안 박힘).
# 예: 한진우에게 Blueprint MCP secret 전달
axe secret send AZURE_BLUEPRINT_MCP_CLIENT_SECRET --service blueprint --to jinwoo
# ✓ Send created: blueprint AZURE_BLUEPRINT_MCP_CLIENT_SECRET (jinwoo)
# expires in 1d / max 1 access(es)
# https://vault.axelabs.ai/#/send/abc123.../def456...다인 × 다 secret 배치
-a 1 이면 한 사람이 열면 끝나므로 (수신자, secret) 1쌍당 링크 1개:
for who in taehun jinwoo soohun; do
for env in AZURE_BLUEPRINT_MCP_CLIENT_SECRET AZURE_HIVE_MCP_CLIENT_SECRET; do
svc=$(echo $env | awk -F_ '{print tolower($2)}')
echo "=== $who / $env ==="
axe secret send $env --service $svc --to $who
done
done위 6 링크가 stdout 에, 각 헤더는 stderr 로 분리 출력. 운영자 본인 (soohun) 도 동일하게 새 Custom Connector 등록 흐름이 필요하면 포함.
자동 발사 — /api/admin/broadcast-dm
axe secret send 출력 URL 을 운영자가 Teams 에 손으로 paste 하지 말고 Blueprint bot ([email protected]) 이 1:1 DM 으로 직접 발사. 엔드포인트 POST /api/admin/broadcast-dm 이 each email → AAD lookup → POST /chats (oneOnOne, Graph idempotent) → POST /chats/\{id\}/messages 수행.
# 1. URL 발급 (이미 `/tmp/sends.txt` 로 캐시했다고 가정)
# 2. 수신자별 메시지 합성 + DM 발사 (예: 강태훈 한 명)
CRON=$(grep -E "^CRON_SECRET=" /Users/axe/blueprint/.env | cut -d= -f2-)
URL_BP=$(grep "^taehun|blueprint|" /tmp/sends.txt | cut -d'|' -f3)
URL_HV=$(grep "^taehun|hive|" /tmp/sends.txt | cut -d'|' -f3)
TEXT="태훈님, Blueprint + Hive Custom Connector 등록 부탁드립니다 ...
Blueprint Secret: $URL_BP
Hive Secret: $URL_HV"
curl -s -X POST http://localhost:3100/api/admin/broadcast-dm \
-H "Authorization: Bearer $CRON" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg e [email protected] --arg t "$TEXT" \
'{emails:[$e], text:$t, contentType:"text"}')"
# → {"summary":{"total":1,"sent":1,...}, "results":[{...,"chatId":"19:...","messageId":"..."}]}같은 텍스트를 N명에게 broadcast: emails:[a,b,c] 한 호출. 다른 텍스트면 N 호출 (위 패턴 반복).
| 필드 | 의미 |
|---|---|
| Endpoint | POST /api/admin/broadcast-dm (Blueprint, host :3100 또는 blueprint.axellc.com) |
| Auth | Authorization: Bearer $CRON_SECRET (Blueprint .env). NextAuth 세션 불필요 |
| Body | \{ emails: string[], text: string, contentType?: "text" | "html" \} 기본 "text" (HTML escape 안전) |
| AAD lookup | User.aadObjectId (NextAuth 첫 sign-in 시 자동 채워짐). 없으면 skipped |
| Idempotent | Graph POST /chats 가 같은 (bot, target) 쌍이면 같은 chat id 반환 → 재호출해도 새 채팅방 안 생김 |
| Self-DM 차단 | target AAD = bot AAD 면 skipped (bot=[email protected] 본인은 호출 불가) |
상세 코드: /Users/axe/blueprint/src/app/api/admin/broadcast-dm/route.ts.
함정
| 함정 | 결과 | 회피 |
|---|---|---|
| 수신자가 메시지 보면 sender = AI ([email protected]) 이라 사람-요청 같지 않음 | ”왜 AI 가 connector 등록 시킴” 혼동 | 메시지 본문 첫 줄에 운영자명 명시 (“수훈 대표 요청으로 …”) |
User.aadObjectId 비어 있는 신규 직원 → skipped | DM 미발사, 운영자 로그 확인 안 하면 모름 | NextAuth Blueprint 첫 sign-in 1회 선행. 또는 응답 JSON 의 summary.skipped > 0 가드 |
CRON_SECRET env 미설정 | 503 응답 | Blueprint .env 의 CRON_SECRET 확인. Phase 2 deploy 에서 자동 생성됨 |
웹 UI 대안
https://vault.axelabs.ai 로그인 → 좌측 Send → + New Send → Type: Text, Text: secret 붙여넣기, Deletion: 1 day, Maximum Access Count: 1, Hide text by default ✓ → Save → Copy link. CLI 가 막혔거나 매니페스트 외 임시 비밀에만 사용.
함정
| 함정 | 결과 | 회피 |
|---|---|---|
bw send 를 raw 로 호출하면서 axe secret get 에 -n 누락 | trailing \n 이 base64/URL 인코딩 시 secret 변형 → OAuth 거부 | axe secret send 사용 (내부에서 stdin 으로 처리) |
| Send URL 자체를 다시 위키/메일에 영구 보존 | 1회 사용 후 URL 무효지만 메타데이터 (라벨) 노출 | URL 은 1회용 채널 (DM, SMS) 만 사용 |
-d 너무 길게 (예: -d 30) | 수신자가 잊고 안 받으면 secret 이 30일간 노출면 유지 | -d 1 기본, 수신 확인 후 bw send delete <id> |
| 수신자가 받기 전에 운영자 본인이 열어버림 | -a 1 소진 → 수신자 재발급 필요 | URL 검증 X — 발급 후 바로 송부, 수신자 동의 확인 후 만 |
Send 만든 직후 bw sync 안 한 두 번째 macmini 에서 안 보임 | revoke 가 한쪽에만 적용 | 운영자는 한 기기에서만 Send 관리, 또는 매 작업 후 bw sync |
| vault locked 상태 (timeout) | axe secret send 가 시작 시 status 검사 → “vault is locked” 명시 + unlock 명령 안내 | axe vault unlock → Keychain 갱신. TTY 없는 에이전트/cron 컨텍스트면 osascript 다이얼로그 자동 팝업 (운영자가 master password 입력) — 더 이상 “운영자가 터미널에서 직접” punting 불필요 (2026-06-04 root-cause fix). --gui 강제 / --tty 레거시 프롬프트 |
bw locked 상태에서 raw bw get password X 직접 호출 | interactive prompt 시도 → ERR_USE_AFTER_CLOSE crash + exit 0 + 빈 stdout (misleading) | axe secret send/get 만 사용 — pre-check 가 status 가드 |
bw 2025.7.0 (D-ops-28 다운그레이드 line) 의 bw send 가 옵션 (-d / -a / -n / --hidden) 함께 주면 stdin pipe 거부 — echo X | bw send -d 1 -a 1 -n "..." → error: missing required argument 'data'. --help 의 echo "text" | bw send 예시는 옵션 0 인 호출에만 동작 | raw bw send 사용한 1세대 axe secret send 가 exit 1 로 죽음 (2026-05-23 발견, 1세대 라이브 첫 호출 시) | 2세대 구현 = bw send create 서브커맨드 + base64-encoded JSON template 을 stdin 으로 (\{type:0, name, deletionDate, maxAccessCount, text:\{text, hidden\}, password\}) — encoded blob 도 stdin 이라 argv 노출 0. accessUrl 필드 parse 해서 반환 |
운영자 → vault 비밀 입력 패턴 (osascript hidden-dialog)
운영자가 외부에서 발급받은 비밀 (PAT, API key, OAuth client_secret, SaaS token) 을 vault 에 한 번에 영구 저장해야 할 때의 표준 패턴. D-ops-40 axe.3 release (2026-05-26) GHCR PAT 보관 시 정착.
같은 osascript 다이얼로그 기법이 vault unlock 에도 적용됨 (2026-06-04):
axe vault unlock은 TTY 가 없으면 (Claude Code / cron / CI) 자동으로 hidden-text 다이얼로그를 띄워 master password 를 받아bw unlock --passwordenv로 unlock → BW_SESSION 을 Keychain 에 저장. 덕분에 어느 에이전트 세션이든 한 명령으로 vault unlock 을 트리거할 수 있고 운영자는 팝업에만 답하면 됨 (master password 는 argv/ps/history 어디에도 안 남음). 근인: 기존bw unlock --raw는 프롬프트에 TTY 가 필요해 non-TTY 호출자가 unlock 불가 → 매번 운영자에게 punting 했음.파일/대형/PEM 비밀(예: 플랫폼 OIDC RS256 서명키)은 다이얼로그(single-line) 대상이 아니므로
... | axe secret push <ENV> --service <svc> --value-stdin으로 stdin 주입 (값이 argv/ps 에 안 뜸).
원칙
| 원칙 | 구현 |
|---|---|
| 비밀 값 = AI context 미진입 | bash subprocess 안에서만 env var, AI 가 받는 output 은 ✓ saved/✗ failed 만 |
| shell history 미기록 | osascript display dialog (zsh history 우회) |
| 화면 / 스크롤백 미노출 | hidden answer 옵션 |
| clipboard 미사용 | dialog → 직접 env var (clipboard hop 없음) |
| TTY 불필요 | macOS Apple Events 로 GUI dialog — forkpty: Device not configured 환경에서도 동작 |
| 저장처 단일 SoT | AXE Vaultwarden organization collection (keychain 금지 — D-ops-ops-17 + B-frame-keychain-to-vault) |
| 메모리 즉시 정리 | bash subprocess 종료 시 env var 소멸 + 명시 unset |
| idempotent | 같은 name 으로 재실행 시 UPDATE (overwrite), 없으면 CREATE |
한 블록 (복붙 → argument-pack 만 수정)
# === argument-pack: 이 부분만 task 별로 갱신 ===
ITEM_NAME='<bw-item-name>' # 예: ghcr-axelabs-ai-pull-pat
COLLECTION_ID='<organization-collection-uuid>' # bw list collections --organizationid <ORG> | jq 로 확인
ORG_ID='<organization-uuid>' # 예: 0c5d8bbd-ad85-42b4-8b8a-2849031981b1 (AXE)
LOGIN_USERNAME='<username-or-empty>' # 예: axe-labs-ai
LOGIN_URI='<service-url-or-empty>' # 예: https://ghcr.io
DIALOG_PROMPT='<운영자에게 보여줄 한 줄 설명>'
DIALOG_TITLE='<dialog 타이틀 — AXE Vault 일관성>'
NOTES_HINT='<짧은 용도 + rotation 주기>'
# ============================================
SECRET=$(osascript \
-e "display dialog \"${DIALOG_PROMPT}\" default answer \"\" with hidden answer with title \"${DIALOG_TITLE}\" buttons {\"Cancel\",\"OK\"} default button \"OK\"" \
-e 'text returned of result' 2>&1)
RC=$?
if [ $RC -ne 0 ] || [ -z "$SECRET" ]; then echo "DIALOG_CANCELLED_OR_EMPTY rc=$RC"; exit 1; fi
export BW_SESSION="$(security find-generic-password -s 'axe.vault.session' -w 2>/dev/null)"
if [ -z "$BW_SESSION" ]; then echo "NO_SESSION_IN_KEYCHAIN"; exit 1; fi
STATUS=$(bw status --session "$BW_SESSION" --raw 2>&1 | jq -r '.status' 2>/dev/null)
if [ "$STATUS" != "unlocked" ]; then echo "SESSION_NOT_UNLOCKED (run: bw unlock --raw)"; exit 1; fi
bw sync --session "$BW_SESSION" >/dev/null 2>&1
EXISTING=$(bw list items --organizationid "$ORG_ID" --session "$BW_SESSION" 2>/dev/null \
| jq -r --arg n "$ITEM_NAME" '.[] | select(.name==$n) | .id' | head -1)
if [ -n "$EXISTING" ]; then
bw get item "$EXISTING" --session "$BW_SESSION" 2>/dev/null | jq \
--arg secret "$SECRET" --arg today "$(date +%Y-%m-%d)" --arg hint "$NOTES_HINT" \
'.notes="\($hint) Updated: \($today)." | .login.password=$secret' \
| bw encode | bw edit item "$EXISTING" --session "$BW_SESSION" >/dev/null \
&& echo "✓ UPDATED $ITEM_NAME ($EXISTING)"
else
bw get template item | jq \
--arg name "$ITEM_NAME" --arg org "$ORG_ID" --arg col "$COLLECTION_ID" \
--arg secret "$SECRET" --arg today "$(date +%Y-%m-%d)" --arg hint "$NOTES_HINT" \
--arg user "$LOGIN_USERNAME" --arg uri "$LOGIN_URI" \
'.name=$name | .notes="\($hint) Issued: \($today)." | .organizationId=$org | .collectionIds=[$col]
| .login.username=$user | .login.password=$secret
| (if $uri == "" then . else .login.uris=[{"uri":$uri}] end)' \
| bw encode | bw create item --session "$BW_SESSION" 2>/dev/null | jq -r '.id' \
| xargs -I{} echo "✓ CREATED $ITEM_NAME ({})"
fi
unset SECRET호출 시 운영자 액션 (5 단계)
- 비밀 발급처 (예: https://github.com/settings/tokens ) 에서 비밀 생성 + 화면에 표시된 값 복사
- 위 블록 실행 (운영자 본인 터미널 OR Claude Code 의 Bash tool 둘 다 가능)
- macOS native dialog 팝업 → hidden-text 필드에 비밀 붙여넣기 → OK
- 결과 =
✓ CREATED <name> (<bw-item-id>)또는✓ UPDATED <name> (<bw-item-id>) pbcopy < /dev/null로 clipboard 클리어
이후 자동화는 bw get password <name> --session "$BW_SESSION" 한 줄로 꺼내씀.
AI agent 호출
Claude Code 세션은 vault-secret-capture skill 로 같은 패턴 자동 적용 (description 매칭 기반 자동 발견). 운영자가 “vault 에 PAT 저장해줘” 만 해도 위 블록을 argument-pack 채워서 실행 — 비밀 값은 dialog 통해서만 받음 (AI context 미통과).
실 적용 예 — D-ops-40 GHCR PAT
ITEM_NAME = ghcr-axelabs-ai-pull-pat
COLLECTION_ID = 1d794d29-f127-4602-b8cb-5ce8a731cf7f (Platform — Service Secrets)
ORG_ID = 0c5d8bbd-ad85-42b4-8b8a-2849031981b1 (AXE)
LOGIN_USERNAME = axe-labs-ai
LOGIN_URI = https://ghcr.io
NOTES_HINT = GHCR push/pull. axe-macmini + customer macmini docker login. Rotate 90d.이후 사용: bw get password ghcr-axelabs-ai-pull-pat | docker login ghcr.io -u axe-labs-ai --password-stdin
함정 (이 패턴 한정)
| 함정 | 결과 | 회피 |
|---|---|---|
collection name 의 em-dash (Platform — Service Secrets) 잘못 hyphen 으로 lookup | name 검색 실패 | ID 직접 사용 (COLLECTION_ID=...uuid...) |
axe.vault.session Keychain entry 부재 | NO_SESSION_IN_KEYCHAIN | bw unlock --raw | security add-generic-password -s 'axe.vault.session' -a "$(whoami)" -w "$(cat)" -U |
| 줄바꿈 포함 secret (PEM, RSA private key) | dialog 가 single-line | 다른 패턴 필요 (file path → vault attachment, 별 skill) |
dialog OK 가 아닌 Cancel / 빈 입력 | DIALOG_CANCELLED_OR_EMPTY | 정상 처리, exit 1 로 정지 (재실행 가능) |
| customer-side 비밀을 AXE vault 에 저장 | sovereignty 위반 | customer 측이 자기 vault 에 자기가 저장 (같은 패턴 customer macmini 에서 실행) |
향후 확장
axe secret prompt-store <name> --collection X --uri ... --username ...CLI subcommand 로 한 줄 명령화 (backlog: B-axe-secret-prompt-store-cli)- 다중 secret 동시 입력 (예: client_id + client_secret 쌍) — 현재는 별 호출 2회
- 줄바꿈 포함 secret — file path 받아서 vault attachment 으로 저장하는 별 skill
SSH 환경에서 vault 비밀 주입 (keychain-free raw-bw)
위 osascript hidden-dialog 패턴은 GUI 세션(콘솔/원격 데스크톱)을 전제로 한다. 그러나 이 머신 = AXE 의 Mac mini 이고 Claude Code 세션은 SSH(loopback) 위에서 돈다 — 운영자는 Windows 에서
ssh [email protected](Tailscale) 로 접속한다. macOS 는 SSH 세션이 GUI login keychain 에 쓰는 것을 거부한다 (“User interaction is not allowed”). 그래서 SSH 컨텍스트에서 vault 에 비밀을 넣을 때는 keychain 을 전혀 안 쓰는 raw-bw 경로를 쓴다. (D-ops-44,/Users/axe/CLAUDE.md강제 규칙.)
금지 (SSH 에서 전부 실패)
| 금지 | 이유 (SSH 에서 깨지는 메커니즘) |
|---|---|
운영자에게 axe vault unlock 시키기 | unlock 의 keychain-cache 단계가 SSH 세션에서 crash |
axe secret push / pull / check 로 비밀 PUT | 내부 _vault_env 가 keychain 세션만 읽음 (security find-generic-password -s axe.vault.session -a [email protected]) → SSH 에선 “vault session not found” |
osascript display dialog | GUI 다이얼로그 = 원격/Windows 운영자에게 안 보임. 운영자-타이핑 비밀은 read -rs 로 받을 것 |
정답 = keychain-free raw-bw (운영자가 자기 셸에서 직접)
운영자가 자기 인터랙티브 셸 (Windows ssh [email protected] 또는 Mac 터미널) 에서 직접 실행한다. 에이전트는 스크립트를 ASCII-only · 주석 없음 · bw unlock 을 단독 한 줄로 공급한다 — 한 블록으로 붙여넣으면 unlock 프롬프트가 바로 다음 줄을 비밀번호로 삼켜버리기 때문이다.
- private CA 지정 (필수):
export NODE_EXTRA_CA_CERTS=/Users/axe/.axe/vault/certs/rootCA.pem- unlock — 반드시 자기 단독 줄에서 (master-pw 프롬프트):
export BW_SESSION="$(bw unlock --raw)"TTY-flaky 환경 폴백 (프롬프트가 안 뜨거나 깨질 때):
read -rs PW; export BW_SESSION="$(BW_PASSWORD="$PW" bw unlock --passwordenv BW_PASSWORD --raw)"; unset PW- Login item 생성/갱신 (raw
bw create/bw edit), 그다음bw sync→unset BW_SESSION. 항목 매핑:
| bw 필드 | 값 |
|---|---|
| name | customers.yaml 의 services.<svc>.secrets[].vault 경로 (예 gate/axe/jwt-secret) |
| username | env var 이름 (예 GATE_JWT_SECRET) |
| password | 스크립트 안에서 생성/읽은 값 — $(openssl rand -hex 32) / $(cat key.pem) / 상수. echo·shell history·argv·agent-context 어디에도 안 남김 |
Windows 운영자 접속
운영자(Windows)는 Tailscale 로 ssh [email protected] 한 뒤 위 3 단계를 자기 SSH 셸 안에서 실행한다. BW_SESSION 은 그 셸에만 존재한다.
검증 caveat — 에이전트는 검증 불가
BW_SESSION 이 운영자 셸 안에만 살아 있으므로 에이전트(Claude)는 결과를 직접 확인할 수 없다. 운영자의 created/updated + synced 출력이 곧 확인이다. axe secret check 호출 금지 (keychain 필요 → SSH 에서 실패).
keychain 세션이 필요한 곳 = deploy/ship 시점 한정
비밀을 넣는 작업은 위 raw-bw 로 keychain 없이 끝난다. 단 axe ship / axe secret pull 은 배포 시점에 keychain 세션을 읽는다. SSH 에서 GUI 없이 그 세션을 채우는 법 (headless unlock):
read -rs KCPW; security unlock-keychain -p "$KCPW" ~/Library/Keychains/login.keychain-db; unset KCPW그다음 axe vault unlock → axe ship. 이 단계는 배포/ship 할 때만 필요하고, 비밀 주입 자체에는 불요하다.
함정
| 함정 | 결과 | 회피 |
|---|---|---|
| 서비스 코드에서 vault REST 직접 호출 | vault 다운 = 전 서비스 startup fail | 금지. 매니페스트 등록 후 deploy-time pull 만 |
git push origin main 직접 호출 (axe ship 우회) | docs drift + secret 미동기화 | release-worthy push 는 axe ship 만 (D-ops-16) |
.env(.local) git commit | secret 노출 | .gitignore 에 .env* (각 repo 확인 필요) |
| vault item name 오타 | check 가 missing 으로 표시 | 매니페스트 yaml lint + vault item 정확 복사 |
| bw session 만료 (Keychain) | axe secret get/check/pull 무응답 | bw unlock --raw 재실행 + Keychain 업데이트 |
| 매니페스트에는 있는데 vault item 누락 | check 실패 → ship abort | Vaultwarden web UI 에서 Login item 생성 (name = vault path, password 필드 = 값) |
bootstrap_only 인 vault 자체 secret | pull 실패 (rightly) | /Users/axe/.axe/vault/.env 수동 유지 + 종이 메모 fallback |
| 운영자 1명 → service account 부재 | 모든 bw 호출이 운영자 개인 token | [email protected] service account 분리 (Phase 6) |
compose environment: 블록의 default-empty substitution ($VAR 변수 치환 형식) 이 env_file 값을 shadow | 컨테이너에 secret 빈 값 도달 (frame 2026-05-21 dogfood 시 발견) | compose environment: 블록에 비밀 vars 중복 금지 — env_file 만 사용 (D-ops-18) |
compose 파일이 subdir 에 있고 Docker auto-load .env 가 project_dir 의 .env 만 찾음 | $VAR 변수 substitution 실패 → 컨테이너 미작동 (blueprint /docker/ subdir 시 발견) | ln -sf ../.env SUBDIR/.env symlink (D-ops-18) |
| portal-등록 app 에 owner 없음 → az cli 회전 권한 X | Insufficient privileges | portal Owners 탭에서 운영자 명시 추가 (D-ops-19) |
az 의 credential reset (no --append) 가 기존 secrets 전체 삭제 | overlap window 없이 즉시 cutover → in-flight 인증 실패 | 항상 --append (D-ops-19) |
매니페스트 env_file 이 compose 가 실제 읽는 파일과 불일치 (frame .env vs .env.local) | pull 가 .env.local 에 써도 compose 가 .env 에서 substitution → 새 값 안 닿음 | compose 의 모든 비밀 var 가 env_file 단일 경로에서 읽혀야 함. environment block 에서 비밀 substitution 제거 (D-ops-18) |
bw CLI 2026.4+ 또는 Bitwarden Chrome extension >= 2026.4 + Vaultwarden Timshel fork SSO user | TypeError: toWrappedAccountCryptographicState / “Master password unlock data was not found” crash | bw 2025.7.0 pin (npm install -g @bitwarden/[email protected]). 서버 측 axe.2 patch (D-ops-37) 가 영구 fix — patch 적용된 image (axe.3 release) 사용 시 bw 2026.x 도 호환되지만 운영 표준은 2025.7.0 통일 |
bw data.json cache stale (server patch deploy 후) — axe.2 / axe.3 등 server patch deploy 후 local cache 가 옛 schema 의 wrapped key 보유 → bw 가 decrypt 시도 시 MAC mismatch. patch deploy 가 client-side 무효화 신호 안 보냄 = 운영자 본인이 logout/login 1 회 필수 | 매 bw unlock 또는 bw get ... 시 [Encrypt service] MAC comparison failed → bw 명령 전체 실패 | (1) bw logout (2) bw config server https://axe.axelabs.ai/vault (3) bw login interactive (4) bw unlock 재시도. frequency 추적: 2026-05-22, 2026-05-26 — server patch deploy 후 매번 1회. 상세 = /ops/runbook/vault-recovery#bw-cli-data-json-cache-stale-recovery |
Bootstrap (현재 상태)
bw CLI 표준 버전 — 2025.7.0 (D-ops-28)
axe secret * + bw-bootstrap.sh + customer-side wrapper 가 모두 bw CLI 2025.7.0 가정. 신규 머신 / customer macmini 설치:
npm install -g @bitwarden/[email protected]
bw --version # 2025.7.0 확인다운그레이드 사유: bw 2026.4+ 와 Bitwarden Chrome extension >= 2026.4 가 Vaultwarden Timshel fork 의 SSO user 와 호환 깨짐 — TypeError: toWrappedAccountCryptographicState / “Master password unlock data was not found” crash. 서버 측 axe.2 patch (D-ops-37) 가 영구 fix 라 본 patch 이미지 사용 시 bw 2026.x 도 호환되지만, 운영 표준은 2025.7.0 로 통일 (회귀 차단 + customer macmini 일관성).
axe CLI 의 _svc_step_customer_vault_check 가 version mismatch 발견 시 warning. bw-bootstrap.sh 가 install hint + warning gate.
초기 채우기
axe secret push/pull/rotate 코드는 동작 — bw CLI 가 설치돼 있고 BW_SESSION 이 Keychain 에 있으면 즉시 사용 가능. 다만 vault 안에 실제 item 들이 아직 만들어지지 않음. 초기 채우기:
# vault 잠금 풀기 (필요 시)
bw unlock --raw # 출력값 복사
security add-generic-password -s axe.vault.session -a [email protected] -w "<token>" -U
# 누락 확인
axe secret check frame
# 각 누락 item 에 대해, 현재 디스크의 값을 vault 로 push
axe secret push FRAME_DB_PASSWORD --service frame
# (interactive: value 입력)
# 또는 --value '<v>' 로 한 줄에
# 모두 채운 후 재확인
axe secret check frame # exit 0 = ✓
# 이후로는 axe ship 가 자동으로 check + pullMCP Connectors catalog (D-vault-mcp-catalog, 2026-05-26)
운영자/직원/customer 가 claude.ai/customize/connectors (또는 다른 MCP host) 에 axelabs 자체 MCP 를 custom connector 로 등록할 때, 4 조각 (이름, MCP URL, client_id, client_secret) 을 매번 4 곳에서 찾는 비효율 해소. Vaultwarden 의 MCP Connectors org collection 이 catalog view — Bitwarden 브라우저 확장이 URI 매칭으로 자동 suggest.
구조 (데이터 중복 0)
SoT (변경 금지) View (자동 생성)
───────────────── ─────────────────────────────────
customers.yaml ─┐
.sso.apps.frame_mcp │
.client_id ├──→ axe mcp publish ──→ Vaultwarden
.application_id_uri │ (idempotent upsert) "MCP Connectors" collection
.client_secret_env │ ├─ "Frame MCP (axe)"
│ │ URIs: claude.ai/.com/customize/connectors
Vaultwarden ─┤ │ + https://axe.axelabs.ai/frame/mcp
frame/axe/ │ │ username: 137fc0ef-... (client_id)
oauth-client-secret ─┘ │ password: <client_secret 40 chars>
│ field "MCP URL": application_id_uri
│ field "Tenant ID" / "Scopes" / "Vault secret path"
├─ "Hive MCP (axe)"
└─ "Blueprint MCP (axe)"CLI
axe mcp list # 미리보기 — vault 미접근
axe mcp publish # 실 publish — 매니페스트 순회 + vault fetch + upsert
axe mcp publish --dry-run # plan only
axe mcp publish --allow-missing # public client (secret 없는 항목) 도 publish매 호출 idempotent — item 이름 ({Svc} MCP ({cust})) 으로 lookup 하여 존재 시 edit, 없으면 create.
사용자 흐름 (target audience)
https://claude.ai/customize/connectors접속 + “Custom Connector” → “Add”- Bitwarden 브라우저 확장 아이콘 클릭 — URI 매칭으로 3 개 MCP 항목 자동 suggest
- 원하는 항목 클릭 → username (= client_id) / password (= client_secret) 자동 입력
- Name + MCP URL 은 custom field 옆 “copy” 한 번씩 → 폼에 붙여넣기
- Save → connector 등록 완료
자동 갱신 hook
axe secret rotate <ENV> --service <svc> 가 MCP client_secret 회전 시 axe mcp publish 자동 호출 → catalog 의 password 즉시 갱신. 사용자가 옛 secret 으로 등록하다 OAuth 거부 받는 함정 차단.
신규 MCP 추가 절차 (표준)
- Entra app 등록 (또는 az CLI) — application_id_uri =
https://axe.axelabs.ai/{svc}/mcp+ redirect_uris (claude.ai/.com 양쪽) + mcp.access scope customers.yaml customers.axe.sso.apps.<svc>_mcp4-key 추가:client_id,application_id_uri,scopes[],client_secret_envcustomers.yaml customers.axe.services.<svc>.secrets[]에{env: AZURE_<SVC>_MCP_CLIENT_SECRET, vault: "<svc>/axe/oauth-client-secret", rotation_external: azure}추가axe secret push AZURE_<SVC>_MCP_CLIENT_SECRET --service <svc>(interactive)axe mcp publish→ catalog 에 새 item 자동 등재
함정
| 함정 | 결과 | 회피 |
|---|---|---|
customers.yaml 의 client_secret_env line 누락 (예: 5/21 hive 등재 후에도 frame_mcp 의 본 line 이 “proxy-only” 주석 형태로 stale, 5/26 정정) | catalog 의 frame_mcp 가 (public) 으로 잘못 publish — claude.ai 등록 시 secret 칸 비어 OAuth 거부 | axe mcp list 출력의 “vault item” 칸이 (public) 인 항목 점검. claude.ai 가 secret 요구하는 MCP 면 customers.yaml 의 client_secret_env line 확인 |
| MCP URL (custom field) 의 Bitwarden 확장 자동입력 불가 | claude.ai 폼의 “Server URL” 필드는 표준 username/password 가 아니라 자동입력 안 됨 — 사용자가 “copy” → “paste” 한 번 필요 | 표준 한계. 4 곳 찾는 것보다 4 클릭 이 빠름 |
| customer (realchoice 등) 의 MCP secret 을 operator vault 에 publish | sovereignty 위반 (B-customer-sovereignty-architecture Q3) | axe mcp publish 는 --customer 명시 + 명시되지 않으면 axe 만 publish. customer 측 운영자가 customer macmini 에서 customer 자체 vault 에 publish |
| Vaultwarden org timeout 으로 catalog 가 stale 보일 가능성 | Bitwarden 확장에 새 item 안 보임 | 클라이언트 측 bw sync 또는 web UI 새로고침. catalog 변경 빈도 낮아 일상 운영엔 영향 없음 |
관련
/ops/runbook/secret-rotation— 회전 실전 절차/ops/runbook/release-flow—axe ship전체 흐름/ops/decisions— D-ops-9 (vault SoT), D-ops-17 (deploy-time pull), D-vault-mcp-catalog (본 catalog)/services/index— Vaultwarden 운영 정보