---
id: "0134"
title: "Mesh protocol spec: capability manifests, ed25519 envelopes, enrollment, audit chain"
status: pendiente
type: spec
domain:
  - infra
  - cybersecurity
  - protocols
scope: cross-app
priority: alta
depends: []
blocks:
  - "0135"
  - "0136"
  - "0137"
  - "0138"
  - "0139"
  - "0140"
  - "0141"
  - "0142"
  - "0143"
related:
  - "0069"
related_flows:
  - "0009"
created: 2026-05-24
updated: 2026-05-24
tags: [mesh, wireguard, matrix, e2ee, ed25519, manifest, audit-chain, security, spec, agents, devices]
flow: "0009"
dependencies: []
---

# 0134 — Mesh protocol spec

**Status:** pending

## Por que

Flow 0009 (`agentes-dispositivos-mesh`) introduce un bus de control multi-device sobre WireGuard + Matrix donde cada dispositivo (PC, movil, raspberry, container Docker) ejecuta capabilities firmadas por el operador. Sin un protocolo formal compartido, cada implementacion (device_agent en Go, bot dispatcher en agents_and_robots, panel Mesh en agents_dashboard, hub en wg_hub) va a derivar.

Este issue cierra Fase B del flow: define el contrato exacto que **toda** implementacion debe respetar — wire format, firmas, replay protection, approval flow, audit chain, error model, threat model. Las issues 0135-0143 implementan lo que aqui se define.

Una vez aceptado este spec, ningun cambio en el wire format se acepta sin un nuevo issue + bump de `protocol_version`.

## Anti-scope

- NO define como provision el WG hub (ver 0136).
- NO define UI del panel Mesh (ver 0138).
- NO define implementacion concreta del bot Matrix (ver 0142).
- NO entra en como se persiste `pass operator/ed25519` mas alla de su uso.
- NO define schema de la `operations.db` de cada app — solo el subset estrictamente compartido (`audit_log`, `room_devices`, `seen_nonces`).

## Conventions

- `protocol_version` (string): **"mesh/1"** — incluido en todo envelope.
- Todo timestamp es Unix epoch **segundos** (`int64`).
- Todo `*_id` es `[a-z0-9_-]+` lowercase, 4-64 chars.
- Todo nonce es 16 bytes random (`crypto/rand`), serializado como **base64url sin padding**.
- Todo hash es SHA-256, serializado como **hex lowercase** (64 chars).
- Toda firma ed25519 es 64 bytes, serializada como **base64url sin padding** (86 chars).
- Toda clave publica ed25519 es 32 bytes, serializada como **base64url sin padding** (43 chars).
- Fingerprint de clave publica = primeros 16 bytes hex de `SHA-256(pubkey_raw_32_bytes)`.
- JSON canonical: claves ordenadas alfabeticamente, sin espacios, UTF-8, sin BOM. Para firmas siempre usar la forma canonica.

---

## 1. JSON envelope

Toda invocacion de capability viaja como par request/response, ya sea sobre Matrix (eventos `m.room.message` con `msgtype = m.capability.*`) o sobre HTTP dentro del mesh WG (`POST /capability`).

### 1.1 Request

```json
{
  "protocol_version": "mesh/1",
  "request_id": "req_01J9XYZABCDEF",
  "manifest_id": "manifest_home-wsl_v3",
  "capability": "fs.read",
  "args": {
    "path": "/var/log/syslog",
    "max_bytes": 4096
  },
  "ts": 1748131200,
  "nonce": "Yk9p6Xs_3hZQk4mB7lWcvA",
  "signature": "u2vh...QkA"
}
```

- `request_id`: ULID generado por el caller (agents_and_robots o el operador). Idempotency key — si la misma request llega 2x, device_agent debe devolver el mismo response sin re-ejecutar.
- `manifest_id`: id del capability manifest contra el cual se evalua. El device debe tener este manifest activo o rechazar `manifest_invalid`.
- `capability`: dotted name, ej. `shell.exec`, `fs.read`, `docker.container.list`. Debe estar en `manifest.capabilities[].name`.
- `args`: objeto JSON especifico de la capability. Schema validado por device_agent contra el manifest.
- `ts`: Unix seconds. Edad maxima 60s (ver §5).
- `nonce`: 16 bytes random, base64url. Unico por request (ver §5).
- `signature`: ed25519 sobre canonical bytes (ver 1.3).

### 1.2 Response

```json
{
  "protocol_version": "mesh/1",
  "request_id": "req_01J9XYZABCDEF",
  "ok": true,
  "result": {
    "stdout": "May 24 12:00:00 localhost systemd[1]: Started session-1.scope.\n",
    "stderr": "",
    "exit_code": 0,
    "truncated": false
  },
  "error": null,
  "duration_ms": 42,
  "audit_hash": "a3f5...09bc"
}
```

- `ok`: boolean. Si false, `result` ausente y `error` poblado.
- `error`: objeto `{code, message, details?}` cuando `ok=false`. `code` debe estar en §10.
- `duration_ms`: tiempo de ejecucion en device_agent (no incluye latencia Matrix).
- `audit_hash`: `this_hash` del registro en `audit_log` (ver §7). Permite al caller verificar la cadena.

Response NO va firmado por defecto — viaja sobre canal autenticado (Matrix E2EE o WG mesh). Si en el futuro se requiere firma de response (audit externo), se anade campo opcional `response_signature` con el mismo esquema canonical.

### 1.3 Canonical bytes para firma del request

```
canonical = "mesh/1\n" +
            request_id + "\n" +
            manifest_id + "\n" +
            capability + "\n" +
            sha256_hex(json_canonical(args)) + "\n" +
            int_to_string(ts) + "\n" +
            nonce
```

Bytes UTF-8, separador `\n` (0x0A). No trailing newline. Hash del args para no exponer args grandes a la firma (la firma se valida contra el hash, y `args` se reentrega tal cual; cualquier modificacion rompe la firma).

`json_canonical(args)`:
1. Si `args` es null o ausente, `json_canonical = "null"`.
2. Si `args` es objeto, recursivo: ordenar claves alfabeticamente, valores serializados sin espacios, strings con escape JSON estandar.
3. Si `args` es array, recursivo sobre cada elemento, sin reordenar.

### 1.4 Transport binding

| Transport | Request encoding | Response encoding |
|---|---|---|
| Matrix room event | `content.body` = JSON string, `msgtype` = `m.capability.request` | `m.capability.response` event en el mismo room |
| HTTP intra-mesh (`https://10.42.0.10:7777/capability`) | `POST` body JSON | response body JSON |
| SSE (streaming logs, `docker.logs --follow`) | request via POST | response inicial JSON `{ok, result: {stream_id}}` + SSE `event: chunk` |

Matrix es default. HTTP solo lo activa el operador con `mesh_http=true` en el manifest para casos de baja latencia (`docker.logs` tail interactivo, transferencias >1MB que Matrix limita).

---

## 2. Capability manifest

Documento firmado por el operador que autoriza a un device a ejecutar un set acotado de capabilities. Sin manifest valido, device_agent rechaza todo.

### 2.1 Schema YAML (legible) — fuente de verdad

```yaml
# manifest_home-wsl_v3.yaml
protocol_version: mesh/1
manifest_id: manifest_home-wsl_v3
device_id: home-wsl
operator: egutierrez@aurgi.com
operator_pubkey_fingerprint: "a1b2c3d4e5f60718"
issued_at: 1748131200
expires_at: 1779667200   # 1 year later
capabilities:
  - name: shell.exec
    requires_approval: false
    constraints:
      binaries_whitelist: [ls, cat, head, tail, grep, ps, df, du, uname, uptime]
      max_duration_s: 10
      max_output_bytes: 65536
      cwd_allowed: ["/home/lucas", "/tmp"]
  - name: fs.read
    requires_approval: false
    constraints:
      paths_allowed: ["/home/lucas/**", "/var/log/syslog", "/etc/os-release"]
      paths_denied: ["/home/lucas/.ssh/**", "/home/lucas/.password-store/**"]
      max_bytes: 1048576
  - name: fs.write
    requires_approval: true
    constraints:
      paths_allowed: ["/home/lucas/inbox/**"]
      max_bytes: 1048576
  - name: docker.container.list
    requires_approval: false
  - name: docker.container.exec
    requires_approval: true
    constraints:
      containers_allowed: ["agents_and_robots", "registry_api"]
      binaries_whitelist: [ls, cat, ps]
      max_duration_s: 30
```

### 2.2 JSON canonical (lo que se firma)

```json
{
  "capabilities": [
    {"constraints": {"binaries_whitelist": ["ls","cat","head","tail","grep","ps","df","du","uname","uptime"], "cwd_allowed":["/home/lucas","/tmp"], "max_duration_s": 10, "max_output_bytes": 65536}, "name": "shell.exec", "requires_approval": false},
    {"constraints": {"max_bytes": 1048576, "paths_allowed": ["/home/lucas/**","/var/log/syslog","/etc/os-release"], "paths_denied": ["/home/lucas/.ssh/**","/home/lucas/.password-store/**"]}, "name": "fs.read", "requires_approval": false},
    {"constraints": {"max_bytes": 1048576, "paths_allowed":["/home/lucas/inbox/**"]}, "name": "fs.write", "requires_approval": true},
    {"name": "docker.container.list", "requires_approval": false},
    {"constraints": {"binaries_whitelist": ["ls","cat","ps"], "containers_allowed": ["agents_and_robots","registry_api"], "max_duration_s": 30}, "name": "docker.container.exec", "requires_approval": true}
  ],
  "device_id": "home-wsl",
  "expires_at": 1779667200,
  "issued_at": 1748131200,
  "manifest_id": "manifest_home-wsl_v3",
  "operator": "egutierrez@aurgi.com",
  "operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
  "protocol_version": "mesh/1"
}
```

Producido por `capability_manifest_canonicalize_go_infra` (function 0135 entrega).

### 2.3 Canonical bytes para firma del manifest

```
manifest_canonical = "mesh/1/manifest\n" + json_canonical(manifest_without_signature)
```

Donde `manifest_without_signature` es el JSON canonical de §2.2. El prefijo `mesh/1/manifest\n` es domain separator — evita que una firma de manifest pueda interpretarse como firma de envelope.

### 2.4 Manifest signed envelope (lo que se entrega al device)

```json
{
  "manifest": { /* §2.2 */ },
  "signature": "k7Yp...QwE"
}
```

Persistido en device como `~/.config/device_agent/manifests/manifest_home-wsl_v3.json`.

### 2.5 Reglas de verificacion (device_agent al arrancar y al recibir request)

1. Parsear `manifest` y `signature`.
2. Computar `manifest_canonical`.
3. Verificar `ed25519.Verify(operator_pubkey, manifest_canonical, signature)`.
4. Rechazar si `expires_at < now()` → `manifest_invalid` con `details.reason = "expired"`.
5. Rechazar si `issued_at > now() + 300` (clock skew) → `manifest_invalid` con `details.reason = "future_issued"`.
6. Rechazar si `device_id` no coincide con `~/.config/device_agent/device_id` → `manifest_invalid`.
7. Rechazar si `operator_pubkey_fingerprint` no coincide con la pubkey conocida → `manifest_invalid`.

### 2.6 Rotacion

Un manifest nuevo coexiste con el anterior hasta su `expires_at`. Para forzar revocacion inmediata: el hub publica un evento `manifest_revoked` (en room `#operator-broadcast`) firmado por el operador con la lista de `manifest_id` revocados. device_agent mantiene `~/.config/device_agent/revoked_manifests.json` y lo consulta antes de aceptar.

---

## 3. ed25519 signing flow

### 3.1 Keypair

- **Privada** del operador: `pass operator/ed25519`. Linea 1 = base64url de los 32 bytes del seed ed25519. Lineas siguientes = metadata (operador email, created_at, fingerprint).
- **Publica** del operador: `~/.fn_operator.pub`. Contenido = base64url de los 32 bytes raw + `\n`. Distribuida a cada device en su `~/.config/device_agent/operator.pub` durante enrollment.

### 3.2 Generacion (una sola vez en la vida del operador, idempotente)

```bash
# Funcion del registry: operator_keygen_bash_infra (0135 entrega)
operator_keygen() {
  if pass show operator/ed25519 >/dev/null 2>&1; then
    echo "operator key already exists; skipping"
    return 0
  fi
  local seed pub fp
  seed=$(openssl rand 32 | base64 -w0 | tr '+/' '-_' | tr -d '=')
  pub=$(echo "$seed" | go_ed25519_derive_pub)  # helper Go: seed → pub
  fp=$(echo -n "$(echo "$pub" | base64url -d)" | sha256sum | cut -c1-32)
  pass insert -m operator/ed25519 <<EOF
$seed
operator: $(git config user.email)
created_at: $(date +%s)
fingerprint: $fp
pubkey: $pub
EOF
  echo "$pub" > ~/.fn_operator.pub
  chmod 600 ~/.fn_operator.pub
}
```

### 3.3 Sign

```go
// capability_manifest_sign_go_infra (issue 0135)
func SignManifest(m Manifest, seed []byte) ([]byte, error) {
    if len(seed) != ed25519.SeedSize {
        return nil, ErrInvalidSeed
    }
    canonical, err := canonicalizeManifest(m)
    if err != nil { return nil, err }
    msg := append([]byte("mesh/1/manifest\n"), canonical...)
    priv := ed25519.NewKeyFromSeed(seed)
    return ed25519.Sign(priv, msg), nil
}
```

Para envelopes:

```go
func SignRequest(r Request, seed []byte) ([]byte, error) {
    canonical, err := canonicalRequestBytes(r)
    if err != nil { return nil, err }
    priv := ed25519.NewKeyFromSeed(seed)
    return ed25519.Sign(priv, canonical), nil
}
```

`canonicalRequestBytes` implementa §1.3.

### 3.4 Verify (device_agent)

```go
// capability_manifest_verify_go_infra (issue 0135)
func VerifyManifest(signed SignedManifest, pubkey []byte) error {
    if len(pubkey) != ed25519.PublicKeySize {
        return ErrInvalidPubkey
    }
    canonical, err := canonicalizeManifest(signed.Manifest)
    if err != nil { return err }
    msg := append([]byte("mesh/1/manifest\n"), canonical...)
    if !ed25519.Verify(pubkey, msg, signed.Signature) {
        return ErrSignatureInvalid
    }
    return nil
}
```

### 3.5 Domain separators (criticos)

Cada tipo de firma usa prefix unico para evitar cross-protocol attacks:

| Tipo | Prefix |
|---|---|
| Manifest | `"mesh/1/manifest\n"` |
| Request envelope | `"mesh/1/request\n"` (implicito en §1.3 — usa el `\n` join) |
| Enrollment token | `"mesh/1/enroll\n"` |
| Approval token | `"mesh/1/approval\n"` |
| Manifest revocation | `"mesh/1/revoke\n"` |

---

## 4. Enrollment token

Token corto firmado por el operador que un device usa **una sola vez** para registrarse contra `wg_hub` y obtener su config WireGuard + manifest inicial.

### 4.1 Payload JSON (canonical)

```json
{
  "allowed_capabilities": ["shell.exec", "fs.read", "docker.container.list"],
  "device_id": "home-wsl",
  "expires_at": 1748131800,
  "issued_at": 1748131200,
  "nonce": "Tk9NbjVxV3JLcF9j",
  "operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
  "protocol_version": "mesh/1",
  "purpose": "enroll"
}
```

- `expires_at - issued_at` <= 600s (10 min). Hub rechaza si excede.
- `allowed_capabilities`: subset que el operador autoriza a este enrollment. El manifest final puede ser mas restrictivo pero no mas amplio.
- `nonce` previene replay incluso si el operador reusa el token por error.
- `purpose: "enroll"` es discriminator interno; los otros valores reservados son `"approval"` (§6) y `"revoke"`.

### 4.2 Wire format

```
base64url(json_canonical(payload)) + "." + base64url(ed25519_signature)
```

Ejemplo (truncado):

```
eyJhbGxvd2VkX2NhcGFiaWxpdGllcyI6WyJzaGVsbC5leGVjIiwiZnMucmVhZCJdLCJkZXZpY2VfaWQiOiJob21lLXdzbCIsLi4u.k7YpQwE9Vh...
```

Aproximadamente 280-320 bytes. Cabe en un QR Code v6 (error correction M).

### 4.3 Generacion (operador)

```bash
# enroll_device pipeline (issue 0139) llama:
./fn run enrollment_token_create home-wsl \
    --capabilities "shell.exec,fs.read,docker.container.list" \
    --ttl 600
# stdout: token base64url
```

### 4.4 Verificacion (wg_hub `POST /enroll`)

```go
// enrollment_token_verify_go_infra (issue 0135)
func VerifyEnrollToken(raw string, pubkey []byte) (*EnrollPayload, error) {
    parts := strings.Split(raw, ".")
    if len(parts) != 2 { return nil, ErrTokenMalformed }
    payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
    if err != nil { return nil, ErrTokenMalformed }
    sig, err := base64.RawURLEncoding.DecodeString(parts[1])
    if err != nil { return nil, ErrTokenMalformed }
    msg := append([]byte("mesh/1/enroll\n"), payloadBytes...)
    if !ed25519.Verify(pubkey, msg, sig) {
        return nil, ErrSignatureInvalid
    }
    var p EnrollPayload
    if err := json.Unmarshal(payloadBytes, &p); err != nil {
        return nil, ErrTokenMalformed
    }
    now := time.Now().Unix()
    if p.ExpiresAt < now { return nil, ErrTokenExpired }
    if p.IssuedAt > now + 300 { return nil, ErrTokenFutureIssued }
    if p.Purpose != "enroll" { return nil, ErrTokenWrongPurpose }
    return &p, nil
}
```

### 4.5 POST /enroll (wg_hub)

```
POST https://organic-machine.com/enroll
Content-Type: application/json

{
  "enrollment_token": "eyJhbGxv...QwE",
  "device_pubkey_wg": "K3v8...j0c=",       // WG public key del device, generada por wg_keygen
  "device_hostname": "home-wsl",
  "device_os": "linux-wsl2",
  "device_arch": "x86_64"
}
```

Response:

```json
{
  "ok": true,
  "wg_config": "[Interface]\nPrivateKey = <keep on device>\nAddress = 10.42.0.10/24\n[Peer]\nPublicKey = ...\nEndpoint = organic-machine.com:51820\nAllowedIPs = 10.42.0.0/24\nPersistentKeepalive = 25\n",
  "manifest": { /* signed manifest */ },
  "matrix_room": "!abc123:organic-machine.com",
  "matrix_invite_url": "https://matrix.to/#/!abc123:organic-machine.com?via=organic-machine.com"
}
```

Hub marca el token como `consumed` en `wg_enrollment_tokens` (token_nonce as PK) — segundo uso rechazado con `nonce_replay`.

---

## 5. Replay protection

### 5.1 Nonces

- 16 bytes `crypto/rand` por request.
- Server (device_agent O hub) mantiene tabla `seen_nonces`:

```sql
CREATE TABLE IF NOT EXISTS seen_nonces (
    nonce TEXT PRIMARY KEY,           -- base64url
    seen_at INTEGER NOT NULL,         -- unix seconds
    request_id TEXT NOT NULL,
    expires_at INTEGER NOT NULL       -- seen_at + 300
);
CREATE INDEX IF NOT EXISTS idx_seen_nonces_expires ON seen_nonces(expires_at);
```

- TTL = 300s. Job periodico (cada 60s) borra entradas con `expires_at < now()`.

### 5.2 Timestamp

- `ts` debe estar en `[now-60, now+30]`. Mas viejo → `nonce_replay`. Mas futuro → `signature_invalid` con `details.reason="clock_skew"`.
- Asume devices con NTP sync (`chrony`/`systemd-timesyncd`). Si un device tiene clock drift >30s, el operador recibe alerta en `#operator-approvals`.

### 5.3 Algoritmo

```go
// Pseudo
func AcceptNonce(db *sql.DB, nonce string, ts int64, requestID string) error {
    now := time.Now().Unix()
    if ts < now - 60 { return ErrNonceReplay }
    if ts > now + 30 { return ErrSignatureInvalid /* clock_skew */ }
    _, err := db.Exec(
        `INSERT INTO seen_nonces(nonce, seen_at, request_id, expires_at) VALUES(?,?,?,?)`,
        nonce, now, requestID, now+300,
    )
    if isUniqueViolation(err) { return ErrNonceReplay }
    return err
}
```

`AcceptNonce` se llama **antes** de ejecutar la capability, despues de verificar la firma. Si la firma es invalida pero el nonce es nuevo, NO se inserta (evita amplificar log spam).

---

## 6. Approval flow

Para capabilities con `requires_approval: true`, device_agent NO ejecuta sin recibir un approval token firmado por el operador.

### 6.1 Secuencia

```
[operator]      [agents_and_robots]      [device_agent]      [#operator-approvals]
   |                    |                       |                    |
   | !exec rm -rf /tmp/x in #dev-home-wsl       |                    |
   |------------------->|                       |                    |
   |                    |--- request envelope ->|                    |
   |                    |                       |--- decide: approval needed
   |                    |                       |                    |
   |                    |<--- approval_request -|                    |
   |                    |--- post to #op-approvals ----------------->|
   |                    |                       |                    |
   |<--- notification --|                       |                    |
   |                                                                 |
   |--- reacts 👍 OR posts !approve req_01J9... ------------------->|
   |                                                                 |
   |                    |<--- captures reaction/cmd -----------------|
   |                    |--- signs approval_token (via operator key)
   |                    |--- posts approval_token to #dev-home-wsl
   |                    |                       |                    |
   |                    |--- approval_token --->|                    |
   |                    |                       |--- verifies token  |
   |                    |                       |--- executes        |
   |                    |<--- response ---------|                    |
   |<-- output in #dev-home-wsl                                      |
```

### 6.2 Approval token payload

```json
{
  "protocol_version": "mesh/1",
  "purpose": "approval",
  "request_id": "req_01J9XYZABCDEF",
  "manifest_id": "manifest_home-wsl_v3",
  "capability": "shell.exec",
  "args_hash": "a3f5...09bc",
  "approver": "egutierrez@aurgi.com",
  "approved_at": 1748131245,
  "expires_at": 1748131305,
  "nonce": "Yk9p6Xs_3hZQk4mB7lWcvA"
}
```

`args_hash` debe coincidir con `sha256_hex(json_canonical(args))` del request original — evita que el operador apruebe `ls /tmp` y el bot reemplace por `rm -rf /tmp`.

### 6.3 Wire format

Igual que enrollment token: `base64url(payload) + "." + base64url(signature)`, domain separator `"mesh/1/approval\n"`.

### 6.4 Captura por agents_and_robots

`agents_and_robots` corre como bot Matrix con la operator key cargada (a traves de `pass operator/ed25519` montado en `/etc/agents_and_robots/operator.key` con permisos 400, owned by service user). Cuando detecta:

- Reaccion `m.reaction` con key `👍` (U+1F44D) sobre el evento `approval_request` en `#operator-approvals`, **emitida por el matrix_id del operador** (configurado en `apps/agents_and_robots/config.yaml::operator_matrix_id`).
- O mensaje `!approve <request_id>` en `#operator-approvals` desde el mismo matrix_id.

Entonces firma el approval token y lo envia a device_agent (via Matrix event `m.capability.approval` en el room del device).

### 6.5 Timeout

Si device_agent no recibe approval token en 60s tras enviar approval_request, responde al room con error `approval_timeout`. El operador puede re-emitir el comando original (genera nuevo `request_id`, nuevo nonce, nueva approval).

### 6.6 Approval denegada

Reaccion `👎` (U+1F44E) o comando `!deny <request_id>` → bot firma un `approval_denied_token` (mismo formato + `denied=true`). device_agent responde `approval_denied`.

### 6.7 Verificacion en device_agent

```go
// Pseudo
func VerifyApproval(token string, req Request, pubkey []byte) error {
    payload, err := decodeApprovalToken(token, pubkey)
    if err != nil { return err }
    if payload.RequestID != req.RequestID { return ErrApprovalMismatch }
    if payload.Capability != req.Capability { return ErrApprovalMismatch }
    if payload.ArgsHash != sha256Hex(canonicalJSON(req.Args)) { return ErrApprovalMismatch }
    if payload.ExpiresAt < time.Now().Unix() { return ErrApprovalExpired }
    if payload.Denied { return ErrApprovalDenied }
    return nil
}
```

---

## 7. Audit log hash chain

Append-only log local a cada device_agent con hash chain que detecta tampering. Replicado periodicamente al hub para archivo tamper-evident off-device.

### 7.1 Schema (`apps/device_agent/audit.db`)

```sql
CREATE TABLE IF NOT EXISTS audit_log (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    ts           INTEGER NOT NULL,
    request_id   TEXT NOT NULL,
    manifest_id  TEXT NOT NULL,
    capability   TEXT NOT NULL,
    args_hash    TEXT NOT NULL,
    approval_id  TEXT,              -- nullable, request_id del approval token usado
    exit_code    INTEGER,           -- nullable mientras no haya respuesta
    ok           INTEGER NOT NULL,  -- 0/1
    error_code   TEXT,              -- nullable
    duration_ms  INTEGER NOT NULL,
    prev_hash    TEXT NOT NULL,     -- hex 64 chars
    this_hash    TEXT NOT NULL,     -- hex 64 chars
    UNIQUE(request_id)
);
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
```

Migracion vive en `apps/device_agent/migrations/001_init.sql`. Regla `db_migrations.md`.

### 7.2 Hash chain

```
record_canonical = ts + "|" + request_id + "|" + manifest_id + "|" +
                   capability + "|" + args_hash + "|" + approval_id_or_empty + "|" +
                   exit_code_str + "|" + ok_str + "|" + error_code_or_empty + "|" +
                   duration_ms_str

this_hash = sha256_hex(prev_hash + "\n" + record_canonical)
```

Para el primer registro `prev_hash = "0000...0000"` (64 zeros).

`wg_peer_revoke_go_infra` (ya existente) hace algo similar para revocations; este spec usa el mismo patron para todas las invocaciones.

### 7.3 Append helper

```go
// device_audit_append_go_infra (issue 0135)
func AppendAudit(db *sql.DB, rec Record) (string, error) {
    var prev string
    err := db.QueryRow(`SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prev)
    if err == sql.ErrNoRows {
        prev = strings.Repeat("0", 64)
    } else if err != nil {
        return "", err
    }
    canonical := canonicalRecord(rec)
    h := sha256.Sum256([]byte(prev + "\n" + canonical))
    this := hex.EncodeToString(h[:])
    _, err = db.Exec(`INSERT INTO audit_log(...) VALUES(...)`, /* fields, prev, this */)
    if err != nil { return "", err }
    return this, nil
}
```

Transaccion con `BEGIN IMMEDIATE` para evitar carrera entre prev_hash select y insert.

### 7.4 Verificacion (cualquiera con copia del db)

```go
// device_audit_verify_go_infra (issue 0135)
func VerifyChain(db *sql.DB) error {
    rows, _ := db.Query(`SELECT id, prev_hash, this_hash, /* fields */ FROM audit_log ORDER BY id`)
    expected := strings.Repeat("0", 64)
    for rows.Next() {
        var rec Record
        rows.Scan(&rec.ID, &rec.PrevHash, &rec.ThisHash /* ... */)
        if rec.PrevHash != expected { return fmt.Errorf("chain broken at id %d", rec.ID) }
        canonical := canonicalRecord(rec)
        h := sha256.Sum256([]byte(rec.PrevHash + "\n" + canonical))
        if hex.EncodeToString(h[:]) != rec.ThisHash {
            return fmt.Errorf("hash mismatch at id %d", rec.ID)
        }
        expected = rec.ThisHash
    }
    return nil
}
```

### 7.5 Replicacion al hub

Cada 60s device_agent hace `POST /audit/replicate` al hub con el bloque de registros nuevos (delta sobre el ultimo replicado). El hub valida la cadena, anade su propio `replicated_at`, y almacena en `apps/wg_hub/operations.db::device_audit` (tabla espejo + meta `last_replicated_id` por device).

Si el hub detecta `chain_broken` o `hash_mismatch`, emite evento a `#operator-approvals` con severity=critical y marca device como `status='compromised'` en `wg_peers`.

---

## 8. Room ↔ device mapping

### 8.1 Schema (`apps/agents_and_robots/operations.db`)

```sql
CREATE TABLE IF NOT EXISTS room_devices (
    room_id      TEXT PRIMARY KEY,            -- !abc123:organic-machine.com
    device_id    TEXT NOT NULL,
    manifest_id  TEXT NOT NULL,
    role         TEXT NOT NULL,               -- 'device' | 'container' | 'approval' | 'broadcast'
    created_at   INTEGER NOT NULL,
    updated_at   INTEGER NOT NULL,
    active       INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_room_devices_device_id ON room_devices(device_id);
CREATE INDEX IF NOT EXISTS idx_room_devices_role ON room_devices(role);
```

Migracion: `apps/agents_and_robots/migrations/NNN_room_devices.sql`.

### 8.2 Roles especiales

- `role='approval'`: hay exactamente UN room con este rol, default alias `#operator-approvals:organic-machine.com`. Bot publica `approval_request` aqui y escucha reacciones del operador.
- `role='broadcast'`: alias `#operator-broadcast:organic-machine.com`. Bot publica eventos de control firmados (revocations, manifest rotations).
- `role='device'`: room 1:1 por device. Alias por convencion `#dev-<device_id>:organic-machine.com`.
- `role='container'`: para modo "deep" docker (containers como peers WG). Alias `#cont-<container_id>:organic-machine.com`.

### 8.3 Resolucion al dispatchar

Cuando el bot recibe un `!cmd` en cualquier room:

1. Busca `SELECT device_id, manifest_id, role FROM room_devices WHERE room_id=? AND active=1`.
2. Si no existe → ignora (o responde "this room is not bound to a device").
3. Si `role='device'`, dispatcha al `device_agent` correspondiente.
4. Si `role='approval'` o `role='broadcast'`, NO acepta `!exec`/`!fs.*`/`!docker.*` — solo `!approve`, `!deny`, `!revoke`, `!help`.

---

## 9. Element commands

Comandos que el bot de `agents_and_robots` parsea y traduce a envelopes. Estos viven en rooms `role='device'` o `role='container'` (excepto `!approve`/`!deny` que viven en `role='approval'`).

| Command | Capability | Args | Notas |
|---|---|---|---|
| `!help` | (meta) | none | Bot responde con capability matrix del manifest del device del room |
| `!exec <argv...>` | `shell.exec` | `{argv: [...], cwd?: string}` | argv splits por shlex, sin shell wrapping |
| `!fs.read <path> [bytes]` | `fs.read` | `{path, max_bytes?}` | default max_bytes = manifest.max_bytes |
| `!fs.write <path> <<<content` | `fs.write` | `{path, content_base64}` | content viene en heredoc o quoted; bot codifica base64 |
| `!fs.ls <path>` | `fs.list` | `{path}` | output: array de {name,type,size} |
| `!docker exec <container> <argv...>` | `docker.container.exec` | `{container, argv}` | container debe estar en `containers_allowed` |
| `!docker logs <container> [tail]` | `docker.container.logs` | `{container, tail?, follow?}` | `--follow` activa SSE |
| `!docker ps` | `docker.container.list` | `{}` | output: tabla containers vivos |
| `!approve <req_id>` | (meta) | `{request_id}` | solo en `role='approval'`, solo del operator_matrix_id |
| `!deny <req_id>` | (meta) | `{request_id, reason?}` | idem |
| `!revoke <device_id>` | (meta) | `{device_id, reason?}` | solo del operator_matrix_id; emite `manifest_revoked` + `wg_peer_revoke` |
| `!status` | (meta) | none | bot responde: device IP WG, last_handshake, manifest_id activo, capabilities count |

### 9.1 Parsing rules

- Lexer shlex-style (`shlex.split` Python o equivalente). Quoted strings respetan espacios.
- Si parsing falla → `!help` corto en la misma linea + abort.
- Args desconocidos para una capability → `manifest_invalid` con `details.unknown_args: [...]`.

### 9.2 Output rendering

El bot formatea responses para Matrix:

- `ok=true`, output corto (<2KB): formatted text con `<pre><code>...</code></pre>` (Matrix `formatted_body`).
- `ok=true`, output largo: trim a 2KB + link al artifact subido (Matrix media repo si el homeserver lo permite, sino paste a `paste.organic-machine.com`).
- `ok=false`: render como `[ERROR error_code] message\n<details>` con codigo de color rojo en clientes que soportan colored text.

---

## 10. Error model

Todos los `error.code` son strings snake_case. El cliente NO debe parsear `message` — solo `code`. `details` es objeto libre por code.

| code | meaning | details fields | retry? |
|---|---|---|---|
| `manifest_invalid` | Manifest no firma, expirado, device_id mismatch, o no tiene la capability | `reason`, `manifest_id`, `expires_at?` | no — pedir manifest nuevo |
| `capability_denied` | Capability esta en manifest pero los args violan constraints | `constraint_violated`, `value` | no — ajustar args |
| `binary_not_whitelisted` | shell.exec con binario fuera de `binaries_whitelist` | `binary`, `whitelist` | no |
| `path_not_allowed` | fs.* con path fuera de `paths_allowed` o en `paths_denied` | `path`, `allowed_globs`, `denied_globs` | no |
| `container_not_allowed` | docker.* sobre container fuera de `containers_allowed` | `container`, `allowed_list` | no |
| `approval_timeout` | requires_approval=true y no llego token en 60s | `waited_s` | si — re-enviar |
| `approval_denied` | operador denego | `approver`, `reason?` | no |
| `approval_mismatch` | approval token args_hash != request args_hash | `expected_hash`, `got_hash` | no — posible MITM |
| `nonce_replay` | nonce ya visto en ventana TTL=300s | `nonce`, `first_seen_at` | no — generar nonce nuevo |
| `signature_invalid` | firma ed25519 no verifica | `reason` (e.g. `clock_skew`, `bad_pubkey`, `corrupted`) | no |
| `token_expired` | enrollment o approval token expirado | `expires_at`, `now` | no |
| `token_consumed` | enrollment token ya usado (nonce en `wg_enrollment_tokens`) | `first_use_at` | no |
| `device_revoked` | device esta en revocation list | `revoked_at`, `reason` | no |
| `capability_not_found` | capability name no existe en device_agent | `name`, `available` | no |
| `execution_failed` | la capability ejecuto y devolvio exit != 0 | `exit_code`, `stderr` (trimmed) | depende — semantica de la capability |
| `output_too_large` | output > `max_output_bytes` | `bytes`, `limit` | no — pedir con tail/head |
| `duration_exceeded` | timeout `max_duration_s` excedido | `limit_s`, `killed_signal` | no |
| `transport_error` | error de Matrix/HTTP transport debajo del envelope | `transport`, `inner_error` | si con backoff |
| `internal` | bug en device_agent/hub/bot; NO leakear stack al room | `incident_id` | no — operator debe ver logs |

### 10.1 Mapping a HTTP status (transport HTTP intra-mesh)

| code | HTTP |
|---|---|
| `manifest_invalid`, `signature_invalid`, `token_*` | 401 |
| `capability_denied`, `binary_not_whitelisted`, `path_not_allowed`, `container_not_allowed`, `device_revoked` | 403 |
| `capability_not_found` | 404 |
| `nonce_replay`, `approval_mismatch` | 409 |
| `approval_timeout`, `duration_exceeded` | 408 |
| `output_too_large` | 413 |
| `approval_denied` | 403 |
| `execution_failed` | 200 (con ok=false, exit_code en body) |
| `transport_error`, `internal` | 500 |

Matrix transport ignora HTTP status — el `ok=false` y `error.code` son suficientes.

---

## 11. Security threat model

Top 10 ataques + mitigaciones. Listadas por probabilidad x impacto. Cada item refiere al control que lo mitiga.

### T1. Operator ed25519 key leak

- **Attack**: laptop comprometida, `pass operator/ed25519` exfiltrado.
- **Impact**: atacante firma manifests + approvals — control total de devices.
- **Mitigation**:
  - GPG-encrypted at rest via `pass` (depende de GPG subkey).
  - Rotacion forzada: `!revoke-all` en `#operator-broadcast` → todos los devices reciben `manifest_revoked` con `device_id="*"` → entran en modo enroll.
  - Hardware-backed key (YubiKey, ssh-agent con touch policy) — out of scope v1, candidato a issue futuro.
  - Detection: hub registra todas las firmas (mensaje `signed_by_operator_at`); operador revisa diariamente.

### T2. Compromised device executes unauthorized capabilities

- **Attack**: device_agent comprometido, atacante quiere ejecutar capabilities fuera del manifest.
- **Impact**: limitado a las capabilities del manifest (asumiendo verify es correcto).
- **Mitigation**:
  - Manifest verify obligatorio antes de cada request (§2.5).
  - Sandbox: `firejail` (Linux) o equivalente — ver issue 0140.
  - Whitelist binarios + paths + containers (§2).
  - Audit chain replicado a hub (§7.5) — atacante no puede borrar audit.

### T3. Replay attack

- **Attack**: atacante captura request firmado valido (de un log, de Matrix federation leak), lo reenvia.
- **Impact**: capability ejecutada 2x.
- **Mitigation**: §5. Nonce TTL=300s + ts window 60s. SQLite UNIQUE constraint.

### T4. MITM despite WireGuard

- **Attack**: alguien dentro del mesh WG (otro device comprometido) intercepta requests entre bot y device.
- **Impact**: leer args/output; modificar args si firma se ignora.
- **Mitigation**:
  - HTTP intra-mesh sobre TLS (cert auto-firmado mesh-only, pinned).
  - Matrix transport: E2EE via Olm/Megolm — bot debe verificar device keys del room antes de aceptar.
  - Firma del envelope (§1.3) — args modificados → `signature_invalid`.

### T5. Container escape

- **Attack**: container con `docker.container.exec` activado escapa a host.
- **Impact**: host comprometido, no mas que T2.
- **Mitigation**:
  - `binaries_whitelist` estricta en `docker.container.exec` (sin `bash`, `sh`, `nsenter`, `unshare`).
  - Modo "deep" (container con WG-peer propio) solo para containers de propia infra (`agents_and_robots`, `registry_api`).
  - Docker socket NUNCA expuesto via capability (capability solo via `docker_container_exec_go_infra` que NO usa `--privileged` en exec).
  - Detection: container con syscalls anomalas → logged por seccomp profile (out of scope v1).

### T6. Malicious manifest with crafted globs

- **Attack**: operador firma manifest con `paths_allowed: ["/home/lucas/**"]` pero device_agent tiene bug en glob matcher que permite `..` traversal.
- **Impact**: fs.read fuera del directorio.
- **Mitigation**:
  - Implementacion glob: `filepath.Match` Go + canonicalizar path con `filepath.Clean` + verificar `strings.HasPrefix(cleaned, allowed_prefix)`.
  - Reject paths con `..` antes de glob match.
  - Test suite con corpus de path traversal (`../../etc/passwd`, `/home/lucas/../etc/passwd`, symlinks).

### T7. Enrollment token theft

- **Attack**: token QR fotografiado por tercero.
- **Impact**: tercero hace POST /enroll con su propia WG pubkey → device fantasma en la mesh.
- **Mitigation**:
  - TTL=600s.
  - Single-use (nonce consumed en hub).
  - Operador recibe alerta en `#operator-approvals` cada vez que un device hace POST /enroll exitoso — si no esperabas un enroll, `!revoke` inmediato.

### T8. Matrix homeserver compromise

- **Attack**: atacante root en `organic-machine.com` modifica eventos Matrix.
- **Impact**: si E2EE roto, todo el contenido leak. Si E2EE OK, solo metadata.
- **Mitigation**:
  - Megolm E2EE entre operador y bot — keys nunca en disco del homeserver.
  - Envelope firmado (§1.3) — atacante no puede inyectar requests sin operator key.
  - Hub WG segregado: `wg_hub` corre en mismo VPS pero NO confia en Matrix para autorizacion (solo para transport).

### T9. Clock skew abuse

- **Attack**: device con clock muy adelantado firma requests con `ts` futuro grande, los almacena, los reenvia cuando `ts` cae en ventana.
- **Impact**: replay extendido mas alla de TTL.
- **Mitigation**:
  - Ventana ts: `[now-60, now+30]` — clock skew tolerado pequeño.
  - Devices forzados a sync NTP (chrony) — el provision check verifica `chronyc tracking` reporta `Leap status: Normal`.
  - Hub alerta a `#operator-approvals` si recibe replicacion de audit con `ts` que difiere >30s del `received_at`.

### T10. Denial of service via approval flooding

- **Attack**: atacante con manifest valido pero capabilities limitadas spam `requires_approval=true` requests para inundar `#operator-approvals`.
- **Impact**: operador pierde signal en noise; legitimo approval enterrado.
- **Mitigation**:
  - Rate limit en agents_and_robots: por device_id, max 10 approval_requests / 5min.
  - Excedente → silently dropped + audit entry + `#operator-approvals` recibe resumen agregado (`device home-wsl: 47 approval requests in 5min, throttled`).
  - Si pattern repetido, operador `!revoke home-wsl`.

---

## 12. Implementation order

Las issues 0135-0143 implementan lo definido en este spec. Dependencias y orden:

| # | Issue | Que entrega | Depende de | Bloquea |
|---|---|---|---|---|
| 0135 | capability manifest sign/verify funcs | `capability_manifest_sign_go_infra`, `capability_manifest_verify_go_infra`, `enrollment_token_create_go_infra`, `enrollment_token_verify_go_infra`, `device_audit_append_go_infra`, `device_audit_verify_go_infra`, `operator_keygen_bash_infra` | 0134 (spec) | 0136, 0137, 0140, 0142 |
| 0136 | provision_wg_hub pipeline | `provision_wg_hub_bash_pipelines` que compone las 9 funciones `wg_*` (ver flow 0009 Fase C) | 0135 (audit funcs solamente; resto independiente) | 0137 |
| 0137 | wg_hub Go service | `apps/wg_hub/` con endpoints `POST /enroll`, `GET /peers`, `POST /peers/:id/revoke`, `POST /audit/replicate`, SSE `/events` | 0135, 0136 | 0138, 0139 |
| 0138 | agents_dashboard Mesh panel | Panel ImGui en `apps/agents_dashboard/` con lista de peers, last_handshake, bytes rx/tx, approval queue, boton revoke | 0137 | — |
| 0139 | enroll_device pipeline | `enroll_device_bash_pipelines` que el operador corre en su laptop para enroll un device nuevo (genera token, lo muestra como QR, hace POST /enroll en nombre del device si tiene SSH) | 0135, 0137 | 0140, 0141 |
| 0140 | device_agent Go binary | `apps/device_agent/` — Matrix client + capability dispatcher + sandbox firejail + audit chain. Cross-compile linux/amd64, linux/arm64, windows/amd64 | 0135, 0139 | 0142 |
| 0141 | Android Termux variant | `apps/device_agent_android/` — variante para Termux con WG via wg-go (userspace) y capabilities limitadas (no firejail) | 0140 | — |
| 0142 | Matrix bot dispatcher routes | Extender `apps/agents_and_robots/` con dispatcher `m.capability.*` → device, parse de comandos §9, room_devices table | 0135, 0137, 0140 | 0143 |
| 0143 | Operator approval flow | Capturar reactions en `#operator-approvals`, firmar approval tokens, enviar a device, registrar timeout. En `apps/agents_and_robots/` | 0142 | — |

### 12.1 Paralelismo

- 0135 secuencial (todo lo demas depende).
- 0136 + 0140 paralelos tras 0135.
- 0137 espera 0136 (necesita las funciones `wg_*`).
- 0138 + 0139 + 0140 paralelos tras 0137.
- 0141 tras 0140.
- 0142 + 0143 ultimo bloque.

Skill `parallel-fix-issues` puede orquestar 0136/0140 y 0138/0139 en worktrees aislados (ojo: 0140 crea sub-repo `apps/device_agent/`, requiere `git init` dentro como dice `apps_subrepo.md`).

### 12.2 Acceptance gate para cerrar 0134

- [ ] Este documento mergeado en `dev/issues/`.
- [ ] Issues 0135-0143 creados con frontmatter coherente (`dependencies` apuntando aqui, `related_flows: [0009]`).
- [ ] Capabilities groups `wireguard`, `device-agent`, `docker-agent` con stubs en `docs/capabilities/` referenciando este spec.
- [ ] No changes en wire format hasta que todos los issues 0135-0143 cierren — cambios posteriores requieren nuevo issue + bump `protocol_version`.

---

## Notas

### Wire format evolution policy

`protocol_version: mesh/1` es immutable durante el ciclo de vida de las issues 0135-0143. Cualquier cambio breaking (renombrar campo, cambiar canonical bytes, anadir campo obligatorio) requiere bump a `mesh/2` con un issue nuevo que documente migracion y compat layer.

Cambios non-breaking aceptados sin bump:
- Anadir nuevos `error.code` (clientes los manejan via fallback a `internal`).
- Anadir nuevas capabilities (devices viejos las rechazan con `capability_not_found`).
- Anadir campos opcionales con default backwards-compatible.

### Test corpus

Cada funcion de §3 y §4 entrega test fixtures en `cpp/functions/*/testdata/` o equivalente:

- `manifest_valid.json` + `manifest_valid.sig` — par validable.
- `manifest_expired.json` — para test de §2.5 paso 4.
- `manifest_tampered.json` + sig original — para test de signature_invalid.
- `enroll_token_valid.txt`, `enroll_token_expired.txt`, `enroll_token_wrong_purpose.txt`.
- `path_traversal_corpus.txt` con 50+ paths maliciosos para test T6.

### Capability groups stubs

Como parte de este issue se crean stubs minimos en:

- `docs/capabilities/wireguard.md` — lista de las 9 funciones `wg_*` (referenciadas desde flow 0009).
- `docs/capabilities/device-agent.md` — capability dispatcher + sandbox + audit chain.
- `docs/capabilities/docker-agent.md` — capabilities sobre containers.

Cada uno con seccion `## Ejemplo canonico` + `## Fronteras` segun regla `capability_groups.md`. Los stubs se llenan a medida que las funciones se crean en 0135-0142.

### Observabilidad

- Cada envelope request/response loggea en `call_monitor.calls` con `function_id = capability_<name>_<lang>_<domain>` cuando la implementacion exista. Si la capability es solo metadata (`!help`), no se loggea.
- `audit_log` (§7) es separado de `call_monitor` — el primero es tamper-evident del operator, el segundo es telemetria del agente Claude.
- Panel "Mesh" de `agents_dashboard` (issue 0138) consume:
  - `wg_hub::wg_peers` (peers vivos + last_handshake + tx/rx).
  - `wg_hub::device_audit` (replica de audit chains — para verificacion offline).
  - `agents_and_robots::room_devices` (mapping rooms ↔ devices).
  - `agents_and_robots::approvals_pending` (queue de approvals pendientes).

### Capability growth log

`v0.1.0 (2026-05-24)` — initial spec mesh/1.
