Skip to Content
플랫폼 아키텍처비밀 관리 (vault)

비밀 관리 (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-9Vaultwarden = canonical secret store기존 결정 — Tier-1 vault
D-ops-17Deploy-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.yamlcustomers.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_fileaxe secret pull 가 작성할 절대 경로. docker-compose.yml 의 env_file: 지시자와 일치
secrets[].env환경변수 이름 (서비스 코드가 os.getenv("...") 로 보는 이름)
secrets[].vaultVaultwarden item 이름. 컨벤션: SERVICE/CUSTOMER/SHORT (예: frame/axe/db_password)
secrets[].rotation_external외부 시스템 식별자 (azure/github/meta/anthropic/naver/slack). 설정 시 axe secret rotate 가 portal URL 안내
bootstrap_onlytrue 면 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 listvault 전체 item 표 (값 출력 X)
axe secret statusbw session 상태 확인

--customer 기본값 axe. realchoice 합류 시 명시.

매니페스트 — 현재 상태 (axe customer)

serviceenv_file비밀 개수
frame/Users/axe/frame/.env.local9 (DB password, JWT, OAuth client_secret, PII passphrase × 2, Claude OAuth, 롯데카드 포털 ID/PW — host-only 스크래퍼 D-frame-lottecard-scraper)
hive/Users/axe/hive/.env.local5 (DB password, JWT, PII passphrase × 2, OAuth client_secret) — frame 동일. confidential client (PKCE + secret, accessTokenVersion=2, isFallbackPublicClient=true)
blueprint/Users/axe/blueprint/.env14 (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/.env2 (DB, Truvia master key)
magnet/Users/axe/magnet/.env10 (DB, HMAC, Meta × 3, Naver × 3, Slack, Threads × 2)
matrix/Users/axe/matrix/.env.local3 (DB password, JWT secret, console API token) — D-matrix-1
vault/Users/axe/.axe/vault/.env1 (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(vault gate/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.yamlservices: 섹션은 axe customer 만 등재. 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.yamlservices: 섹션에 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.

회전 순서가 중요:

  1. 외부 provider 먼저 — Azure 가 새 값 발급 → 운영자가 복사
  2. vault PUT — Bitwarden 에 새 값 저장 (이전 값 덮어쓰기)
  3. env_file refreshaxe secret pull
  4. service redeployaxe ship (blue/green = 무중단)
  5. 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_ID

App Owner 전제조건: portal-등록 app 은 default 로 owner 없음 → az cli 권한 X. 신규 az cli 등록 app 은 호출자가 owner 자동 박힘. 기존 portal-등록 app 들은 portal Owners 탭에서 운영자 ([email protected]) 명시 추가 필요. 본 플랫폼 현황:

AppappIdOwner
frame_mcp137fc0ef-...[email protected] (2026-05-21 추가)
hive_mcpb7ead15d-...[email protected] (등록 시 자동)
blueprint_mcp482598f7-...[email protected] (등록 시 자동)
Blueprint Graph (better-auth)2b222356-...[email protected] (2026-05-21 추가)
vaultwarden9d0dc49b-...[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 NN일 후 자동 삭제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 호출 (위 패턴 반복).

필드의미
EndpointPOST /api/admin/broadcast-dm (Blueprint, host :3100 또는 blueprint.axellc.com)
AuthAuthorization: Bearer $CRON_SECRET (Blueprint .env). NextAuth 세션 불필요
Body\{ emails: string[], text: string, contentType?: "text" | "html" \} 기본 "text" (HTML escape 안전)
AAD lookupUser.aadObjectId (NextAuth 첫 sign-in 시 자동 채워짐). 없으면 skipped
IdempotentGraph 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 비어 있는 신규 직원 → skippedDM 미발사, 운영자 로그 확인 안 하면 모름NextAuth Blueprint 첫 sign-in 1회 선행. 또는 응답 JSON 의 summary.skipped > 0 가드
CRON_SECRET env 미설정503 응답Blueprint .envCRON_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'. --helpecho "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 환경에서도 동작
저장처 단일 SoTAXE 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 단계)

  1. 비밀 발급처 (예: https://github.com/settings/tokens ) 에서 비밀 생성 + 화면에 표시된 값 복사
  2. 위 블록 실행 (운영자 본인 터미널 OR Claude Code 의 Bash tool 둘 다 가능)
  3. macOS native dialog 팝업 → hidden-text 필드에 비밀 붙여넣기 → OK
  4. 결과 = ✓ CREATED <name> (<bw-item-id>) 또는 ✓ UPDATED <name> (<bw-item-id>)
  5. 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 으로 lookupname 검색 실패ID 직접 사용 (COLLECTION_ID=...uuid...)
axe.vault.session Keychain entry 부재NO_SESSION_IN_KEYCHAINbw 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 dialogGUI 다이얼로그 = 원격/Windows 운영자에게 안 보임. 운영자-타이핑 비밀은 read -rs 로 받을 것

정답 = keychain-free raw-bw (운영자가 자기 셸에서 직접)

운영자가 자기 인터랙티브 셸 (Windows ssh [email protected] 또는 Mac 터미널) 에서 직접 실행한다. 에이전트는 스크립트를 ASCII-only · 주석 없음 · bw unlock 을 단독 한 줄로 공급한다 — 한 블록으로 붙여넣으면 unlock 프롬프트가 바로 다음 줄을 비밀번호로 삼켜버리기 때문이다.

  1. private CA 지정 (필수):
export NODE_EXTRA_CA_CERTS=/Users/axe/.axe/vault/certs/rootCA.pem
  1. 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
  1. Login item 생성/갱신 (raw bw create / bw edit), 그다음 bw syncunset BW_SESSION. 항목 매핑:
bw 필드
namecustomers.yamlservices.<svc>.secrets[].vault 경로 (예 gate/axe/jwt-secret)
usernameenv 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 unlockaxe 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 commitsecret 노출.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 abortVaultwarden web UI 에서 Login item 생성 (name = vault path, password 필드 = 값)
bootstrap_only 인 vault 자체 secretpull 실패 (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 회전 권한 XInsufficient privilegesportal 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 userTypeError: toWrappedAccountCryptographicState / “Master password unlock data was not found” crashbw 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 + pull

MCP 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)

  1. https://claude.ai/customize/connectors 접속 + “Custom Connector” → “Add”
  2. Bitwarden 브라우저 확장 아이콘 클릭 — URI 매칭으로 3 개 MCP 항목 자동 suggest
  3. 원하는 항목 클릭 → username (= client_id) / password (= client_secret) 자동 입력
  4. Name + MCP URL 은 custom field 옆 “copy” 한 번씩 → 폼에 붙여넣기
  5. Save → connector 등록 완료

자동 갱신 hook

axe secret rotate <ENV> --service <svc> 가 MCP client_secret 회전 시 axe mcp publish 자동 호출 → catalog 의 password 즉시 갱신. 사용자가 옛 secret 으로 등록하다 OAuth 거부 받는 함정 차단.

신규 MCP 추가 절차 (표준)

  1. Entra app 등록 (또는 az CLI) — application_id_uri = https://axe.axelabs.ai/{svc}/mcp + redirect_uris (claude.ai/.com 양쪽) + mcp.access scope
  2. customers.yaml customers.axe.sso.apps.<svc>_mcp 4-key 추가: client_id, application_id_uri, scopes[], client_secret_env
  3. customers.yaml customers.axe.services.<svc>.secrets[]{env: AZURE_<SVC>_MCP_CLIENT_SECRET, vault: "<svc>/axe/oauth-client-secret", rotation_external: azure} 추가
  4. axe secret push AZURE_<SVC>_MCP_CLIENT_SECRET --service <svc> (interactive)
  5. axe mcp publish → catalog 에 새 item 자동 등재

함정

함정결과회피
customers.yamlclient_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 에 publishsovereignty 위반 (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 변경 빈도 낮아 일상 운영엔 영향 없음

관련

Last updated on