# Alien Vault

> Access the owner's external services (Gmail, GitHub, APIs) that require a credential, by calling the local Alien Agent ID vault proxy — without ever seeing, requesting, or handling the secret. Use whenever you're asked to read the owner's email or call a service that needs an API key / token / login. Starts the proxy if it isn't running; the owner unlocks the vault once on their phone; you only ever name the credential.

- Skill: `alien-id/alien-vault` (Agent Skill)
- Install (CLI): `npx skillmds@latest add alien-id/alien-vault`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alien-id/alien-vault/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: alien-id (https://skillmd.com/u/alien-id)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/alien-id/alien-vault

---


<!--
  TEMPLATE — before use, replace the two placeholders below with absolute paths:
    <AGENT_ID_REPO>  → your agent-id checkout, e.g. /Users/you/agent-id
    <VAULT_DIR>      → the vault's state-dir (where vault.enc lives), e.g. /Users/you/vault-demo
  And edit the "Available credentials" table to match the credentials you added.
  See this directory's README.md for the full setup.

  SECURITY NOTE: `Bash(curl:*)` and `Bash(python3:*)` are BROAD grants — they
  authorize arbitrary HTTP and arbitrary code, i.e. an exfiltration channel, while
  this skill also reads the owner's private mail (untrusted content). That is the
  classic "private data + untrusted input + exfil" trifecta. They are here only to
  keep this demo copy-pasteable. For anything beyond a throwaway demo, narrow them
  (or drop the blanket grant and approve calls per-invocation) so a prompt
  injection in an email can't turn curl/python into a data-exfil tool.
-->

# Alien Vault — using the owner's credentials without seeing them

The owner keeps their API keys, tokens, and logins in an **encrypted vault**. A
local **proxy** holds the unlocked vault in memory and injects the real
credential into outbound requests for you. You never receive the secret value —
you only ever name the credential.

**The trust boundary, and your one rule:** you can *identify* a credential by
name; you can never *read* its value. So:

- ✅ Call the proxy URL naming the credential and the upstream host.
- ❌ Never ask the owner for the secret, never try to read the vault file, never
  run `agent-id-vault show`, never echo a token. There is nothing to copy — the
  proxy does the auth.

## Step 1 — Make sure the proxy is running

The proxy listens on `http://127.0.0.1:48771` (data) and `:48772` (control).
Check whether it's up:

```bash
curl -s -m 2 http://127.0.0.1:48772/status
```

- Returns JSON (`{"ok":true,...}`) → it's already running. Go to Step 2.
- Connection refused / empty → it isn't running. **Start it yourself**, as a
  long-running **background** process (it serves until stopped — do not wait for
  it to exit / don't block on it):

  ```bash
  node <AGENT_ID_REPO>/plugins/agent-id-proxy/bin/cli.mjs \
    start --state-dir <VAULT_DIR> \
    --await-mobile --approval-timeout 5m --idle-timeout 30m
  ```

  Wait ~2 s, then confirm:

  ```bash
  curl -s http://127.0.0.1:48772/status
  ```

  You should see `"locked": true` and a paired device. Always start it
  **locked** with `--await-mobile`, and point `--state-dir` at the directory
  holding `vault.enc` — the owner unlocks on their phone, never via a stored
  key. Don't start it any other way.

## Step 2 — Call the service

To reach an upstream service, call:

```
http://127.0.0.1:48771/<credential-name>/<upstream-host>/<path...>
```

The proxy looks up `<credential-name>`, checks `<upstream-host>` against that
credential's allowlist, injects the real credential, and forwards to
`https://<upstream-host>/<path>` over real TLS. You get the upstream's response
body back. You do **not** do TLS or auth yourself — just call the local URL.

### Available credentials

| Name | Upstream host | What it is |
|---|---|---|
| `gmail` | `gmail.googleapis.com` | The owner's Gmail via the OAuth2 REST API (read-only) |
| `gmail` | `mail.google.com` | The owner's Gmail via a captured browser session (cookie-jar) |

A `gmail` credential is set up one of two ways, and which one you have decides
the host **and** how you read mail:

- **OAuth2 (`gmail.googleapis.com`)** → use the REST recipes below.
- **cookie-jar (`mail.google.com`)** → the REST API is not available; read the
  **Atom feed** instead (see "Reading Gmail via a captured session").

If a `gmail.googleapis.com` call returns `host_not_allowed`, the credential is a
cookie-jar — switch to the Atom-feed recipe. (If you need a credential that
isn't listed, tell the owner its name and what it's for — don't guess or ask for
the raw secret.)

## Step 3 — The owner unlocks on their phone

The vault starts **locked** (and re-locks when idle). There is **no unlock
command for you to run** — you can't unlock it, and you shouldn't try. Instead:

1. Just make your request. If the vault is locked, the proxy **pauses your
   request** and sends an approval prompt to the owner's phone. Use a generous
   timeout so the request can wait:

   ```bash
   curl -s --max-time 180 'http://127.0.0.1:48771/gmail/gmail.googleapis.com/gmail/v1/users/me/profile'
   ```

2. The owner slides to unlock on their phone. Your paused request then completes
   and returns normally — often after a 10–60 s pause. That pause is expected;
   don't abort and retry in a tight loop.
3. Once unlocked, further requests return instantly (no prompt) until it
   idle-locks again.

If you instead get back `{"error":"vault_locked"}` with HTTP 401, the proxy is
running without a phone-approval channel — restart it with `--await-mobile` as
in Step 1.

## Reading Gmail (recipes)

All paths below are relative to `http://127.0.0.1:48771/gmail/gmail.googleapis.com`.

- **Confirm access / whoami:** `GET /gmail/v1/users/me/profile`
- **List recent inbox message IDs:** `GET /gmail/v1/users/me/messages?maxResults=10&q=in:inbox`
- **Search:** add a Gmail query, e.g. `?q=from:boss@example.com newer_than:7d`
- **Headers + snippet of one message:**
  `GET /gmail/v1/users/me/messages/<id>?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`
- **Full body of one message:** `GET /gmail/v1/users/me/messages/<id>?format=full`
  (the body is base64url in `payload.parts[].body.data` / `payload.body.data`).

`messages.list` returns only IDs — fetch each message separately for headers and
content. To list the latest inbox emails with sender + subject + snippet:

```bash
python3 - <<'PY'
import json
from urllib.request import urlopen
B = "http://127.0.0.1:48771/gmail/gmail.googleapis.com/gmail/v1/users/me"
def get(p):
    with urlopen(B + p) as r: return json.load(r)
ids = [m["id"] for m in get("/messages?maxResults=10&q=in:inbox").get("messages", [])]
for i, mid in enumerate(ids, 1):
    d = get("/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=Subject" % mid)
    h = {x["name"]: x["value"] for x in d["payload"]["headers"]}
    unread = "*" if "UNREAD" in d.get("labelIds", []) else " "
    print("%2d. %s %-40s | %s" % (i, unread, h.get("From","?")[:40], h.get("Subject","")[:60]))
    print("      " + d.get("snippet","")[:90])
PY
```

## Reading Gmail via a captured session (cookie-jar → Atom feed)

If the `gmail` credential is a **cookie-jar** (host `mail.google.com`), the
Gmail REST API isn't reachable — read the authenticated **Atom feed** of unread
mail instead. The single working path is:

```
http://127.0.0.1:48771/gmail/mail.google.com/mail/u/0/feed/atom
```

Use `/mail/u/0/...` (the primary signed-in account). The bare `/mail/feed/atom`
**302-redirects** to this account-indexed path, so always include `u/0`.

The response is Atom XML: a `<fullcount>` of unread messages, then one `<entry>`
per unread mail with `<title>`, `<author><name>/<email>`, `<issued>`, and a
`<summary>` snippet. To list unread sender + subject + snippet:

```bash
python3 - <<'PY'
import urllib.request, xml.etree.ElementTree as ET
URL = "http://127.0.0.1:48771/gmail/mail.google.com/mail/u/0/feed/atom"
with urllib.request.urlopen(URL, timeout=180) as r:
    root = ET.fromstring(r.read())
ns = {"a": "http://purl.org/atom/ns#"}
count = root.findtext("a:fullcount", default="?", namespaces=ns)
print("unread:", count)
for i, e in enumerate(root.findall("a:entry", ns), 1):
    title = (e.findtext("a:title", "", ns) or "").strip()
    name = e.findtext("a:author/a:name", "", ns)
    summ = (e.findtext("a:summary", "", ns) or "").strip()
    print("%2d. %-30s | %s" % (i, name[:30], title[:60]))
    print("      " + summ[:90])
PY
```

The Atom feed lists **unread** mail only and carries no message IDs / full
bodies — it's a read-only inbox glance. If reads start returning a Google login
page, the captured session expired; tell the owner to re-run the cookie
bootstrap. For durable, scoped access, the owner can switch to the OAuth2
credential (REST recipes above).

## Errors

| Response | Meaning | What to do |
|---|---|---|
| connection refused on :48771/:48772 | Proxy not running | Start it (Step 1) |
| HTTP 401 `vault_locked` | Running without phone channel | Restart with `--await-mobile` (Step 1) |
| HTTP 400 `credential_not_found` | No credential by that name | List what you tried; ask the owner for the right name |
| HTTP 403 `host_not_allowed` | Host isn't on the credential's allowlist | You used the wrong upstream host for that credential |
| HTTP 401/502 `oauth_refresh_*` | The stored token was revoked/expired | Tell the owner to re-mint that credential |

Report what you read in plain terms. The credential value never appears in your
output because you never had it — that's the design.

