← back to AbramsEgo
memos/a2a-rails.md
76 lines
# A2A Client Rails & Egress-Sentinel Alignment (TK-10381, Phase C)
## Security rails enforced in lib/a2a-client.js
1. **HTTPS/443 only** — `assertSafeUrl()` throws on http:// or non-443 port.
Egress-sentinel reports CRITICAL on ESTABLISHED to non-web port (the 144.172.92.199:8080 C2 shape).
Our constraint is STRICTER: we only open TLS/443, which egress-sentinel classifies as benign.
2. **Payload linter** — `lintPayload()` blocks outbound text containing:
- `SHOPIFY_ADMIN_TOKEN`, `DATABASE_URL`, `sk_live_`, `pk_live_`, `ghp_…` (GitHub PATs)
- `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `REPLICATE_API_TOKEN` (AI keys)
- No fleet-snapshot fields ever leave the box.
3. **15 s timeout + no redirects** — `fetchSafe()` aborts after 15 s and sets `redirect: 'error'`
so a response redirect can't move the connection to an attacker host.
4. **Empty allowlist at ship** — `data/a2a-agents.json` is `[]`. Adding any entry requires
Steve's explicit approval. The `/api/a2a/consult` route refuses agents not in the list.
5. **Results tagged UNTRUSTED** — the `/api/a2a/consult` response carries
`warning: "UNTRUSTED · EXTERNAL"`. Results are never fed to `/api/chat` or any agent prompt.
6. **Activation gate** — even with the code shipped and the route wired, no external connection
is ever made until at least one allowlist entry exists AND a caller sends a valid request.
The current state (empty allowlist) is functionally equivalent to the route not existing.
## egress-sentinel cross-reference
The sentinel (com.steve.egress-sentinel, every 5 min) watches for:
- ESTABLISHED outbound to non-web port on public IP → CRITICAL
- Executable dot-file droppers in /tmp → CRITICAL
- node process that reaches curl/wget/nc or base64-decodes a /tmp path → CRITICAL
Our A2A egress:
- HTTPS/443 only → classified BENIGN by the sentinel (web-port, TLS)
- No shell exec, no subprocess, no /tmp writes
- fetch() is built-in Node — no curl/wget/nc involvement
## Smoke test (run after pm2 restart abramsego)
```sh
# 1. Phase A — allowlist is empty; consult is refused
curl -sf -u admin:DW2024! -X POST http://localhost:9773/api/a2a/consult \
-H 'Content-Type: application/json' -d '{"agent":"nobody","q":"hello"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); assert 'not in allowlist' in d.get('error',''), d; print('PASS: empty allowlist refuses unknown agent')"
# 2. Phase A — linter blocks outbound secrets
curl -sf -u admin:DW2024! -X POST http://localhost:9773/api/a2a/consult \
-H 'Content-Type: application/json' -d '{"agent":"nobody","q":"my SHOPIFY_ADMIN_TOKEN=secret123 is this ok?"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); assert 'blocked' in d.get('error',''), d; print('PASS: payload linter blocks secret in question')"
# 3. Phase B — agents list returns empty with gate message
curl -sf -u admin:DW2024! http://localhost:9773/api/a2a/agents \
| python3 -c "import sys,json; d=json.load(sys.stdin); assert d['agents']==[], d; assert 'empty' in d['activation_gate'].lower(), d; print('PASS: agents list empty with gate message')"
# 4. healthz still responds
curl -sf http://localhost:9773/api/healthz | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('ok'), d; print('PASS: healthz ok')"
```
## To add a peer agent (Steve-gated)
Edit `data/a2a-agents.json`:
```json
[
{
"name": "example-advisor",
"url": "https://advisor.example.com",
"purpose": "Strategy advice on X (approved by Steve YYYY-MM-DD)",
"auth_ref": "example_advisor_api_key"
}
]
```
- `auth_ref` must reference a key in secrets-manager; never put the actual key in the file.
- Add the host to the egress-allowlist doc (this file).
- Draft to pending-approval and wait for Steve's go before editing the file.