<!-- canonical: https://docs.axelabs.ai/architecture/secrets -->
<!-- source: content/architecture/secrets.mdx -->

---
title: 비밀 관리 — vault → 서비스 흐름
description: 모든 서비스 비밀의 SoT 는 Vaultwarden. customers.yaml 매니페스트가 매핑 SSOT. axe ship 가 배포 직전 vault → env_file 동기화 강제.
---

# 비밀 관리 (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[]`:

```yaml
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](/ops/decisions)) |
| 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](/ops/decisions) |
| 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`(vault `gate/axe/esign-signing-key`, RSA-2048 PKCS8 PEM — blueprint OIDC 서명키 패턴과 동형) + 짝 인증서 `GATE_ESIGN_CERT_PEM`(공개 X.509, 비밀 아님이라 env/파일이면 충분) 등재 필요 ([D-gate-5](/ops/decisions) gate-native CAdES-T 서명엔진, [/architecture/governance](/architecture/governance) §6). 키 디렉터리 지정 변형 = `GATE_ESIGN_KEY_DIR`. `GATE_TSA_URL`(RFC3161 TSA, default DigiCert / 한국 prod Koscom·CrossCert) = 비밀 아님(공개 엔드포인트). ⚠️ 서명키 분실 = 전 과거 봉인 검증불가 → **DR/escrow** 필수(하드닝, [/architecture/governance](/architecture/governance) §9). dev = ephemeral key (vault 불요).

## 신규 customer 매니페스트 추가 (realchoice 등)

> **현재 상태 (2026-05-23)**: `customers.yaml` 의 `services:` 섹션은 **`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](/ops/backlog) + [B-onboard-azure-pack](/ops/backlog).

신규 customer 합류 시 `customers.yaml` 의 `services:` 섹션에 customer 별 슬롯을 미리 등재해야 `axe secret push` 가 동작합니다. realchoice 템플릿:

```yaml
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](/ops/backlog)) 가 이 템플릿을 자동 생성하도록 만들면 운영자 손작업 단계 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](/ops/backlog) 의 본질.

## 회전 (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 refresh** — `axe secret pull`
4. **service redeploy** — `axe 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** 가 운영자로 박혀 있어야 함):

```bash
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 탭에서 운영자 (`ai@axellc.com`) 명시 추가 필요. 본 플랫폼 현황:

| App | appId | Owner |
|---|---|---|
| frame_mcp | `137fc0ef-...` | ai@axellc.com (2026-05-21 추가) |
| hive_mcp | `b7ead15d-...` | ai@axellc.com (등록 시 자동) |
| blueprint_mcp | `482598f7-...` | ai@axellc.com (등록 시 자동) |
| Blueprint Graph (better-auth) | `2b222356-...` | ai@axellc.com (2026-05-21 추가) |
| vaultwarden | `9d0dc49b-...` | ai@axellc.com (2026-05-21 추가) |

## 사람에게 전달 — Bitwarden Send

`axe secret get` 의 결과는 vault → 디스크/컨테이너 경로용. **사람 (신규 직원, customer admin) 에게 보낼 때는 평문 절대 금지** — Teams DM, 이메일, 위키 모두 영구 보존되는 채널. Vaultwarden 의 1회용 링크 (Bitwarden Send) 로 전달한다.

### `axe secret send` (권장)

```bash
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 안 박힘).

```bash
# 예: 한진우에게 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개**:

```bash
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 (ai@axellc.com) 이 1:1 DM 으로 직접 발사**. 엔드포인트 [`POST /api/admin/broadcast-dm`](https://blueprint.axellc.com/api/admin/broadcast-dm) 이 each email → AAD lookup → `POST /chats` (oneOnOne, Graph idempotent) → `POST /chats/\{id\}/messages` 수행.

```bash
# 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 taehun.kang@axellc.com --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=ai@axellc.com 본인은 호출 불가) |

상세 코드: `/Users/axe/blueprint/src/app/api/admin/broadcast-dm/route.ts`.

#### 함정

| 함정 | 결과 | 회피 |
|---|---|---|
| 수신자가 메시지 보면 **sender = AI (ai@axellc.com)** 이라 사람-요청 같지 않음 | "왜 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](/ops/decisions) 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](/ops/decisions) + [B-frame-keychain-to-vault](/ops/backlog)) |
| 메모리 즉시 정리 | bash subprocess 종료 시 env var 소멸 + 명시 `unset` |
| idempotent | 같은 name 으로 재실행 시 UPDATE (overwrite), 없으면 CREATE |

### 한 블록 (복붙 → argument-pack 만 수정)

```bash
# === 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

```text
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 axe@100.127.210.30` (Tailscale) 로 접속한다. macOS 는 **SSH 세션이 GUI login keychain 에 쓰는 것을 거부**한다 ("User interaction is not allowed"). 그래서 SSH 컨텍스트에서 vault 에 비밀을 *넣을* 때는 **keychain 을 전혀 안 쓰는 raw-bw 경로**를 쓴다. ([D-ops-44](/ops/decisions), `/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 ai@axellc.com`) → SSH 에선 "vault session not found" |
| osascript `display dialog` | GUI 다이얼로그 = 원격/Windows 운영자에게 안 보임. 운영자-타이핑 비밀은 `read -rs` 로 받을 것 |

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

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

1. **private CA 지정** (필수):

```bash
export NODE_EXTRA_CA_CERTS=/Users/axe/.axe/vault/certs/rootCA.pem
```

2. **unlock — 반드시 자기 단독 줄에서** (master-pw 프롬프트):

```bash
export BW_SESSION="$(bw unlock --raw)"
```

TTY-flaky 환경 폴백 (프롬프트가 안 뜨거나 깨질 때):

```bash
read -rs PW; export BW_SESSION="$(BW_PASSWORD="$PW" bw unlock --passwordenv BW_PASSWORD --raw)"; unset PW
```

3. **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 axe@100.127.210.30` 한 뒤 위 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):

```bash
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 | `axe-cli@axellc.com` 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/cli@2025.7.0`). 서버 측 axe.2 patch ([D-ops-37](/ops/decisions)) 가 영구 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`](/ops/runbook/vault-recovery#bw-cli-data-json-cache-stale-recovery) |

## Bootstrap (현재 상태)

### bw CLI 표준 버전 — `2025.7.0` ([D-ops-28](/ops/decisions))

`axe secret *` + `bw-bootstrap.sh` + customer-side wrapper 가 **모두 bw CLI `2025.7.0`** 가정. 신규 머신 / customer macmini 설치:

```bash
npm install -g @bitwarden/cli@2025.7.0
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](/ops/decisions)) 가 영구 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 들이 아직 만들어지지 않음**. 초기 채우기:

```bash
# vault 잠금 풀기 (필요 시)
bw unlock --raw    # 출력값 복사
security add-generic-password -s axe.vault.session -a ai@axellc.com -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

```bash
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.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](/ops/backlog) 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/secret-rotation) — 회전 실전 절차
- [`/ops/runbook/release-flow`](/ops/runbook/release-flow) — `axe ship` 전체 흐름
- [`/ops/decisions`](/ops/decisions) — D-ops-9 (vault SoT), D-ops-17 (deploy-time pull), D-vault-mcp-catalog (본 catalog)
- [`/services/index`](/services) — Vaultwarden 운영 정보
