Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
43 KiB
id, title, status, type, domain, scope, priority, depends, blocks, related, related_flows, created, updated, tags, flow, dependencies
| id | title | status | type | domain | scope | priority | depends | blocks | related | related_flows | created | updated | tags | flow | dependencies | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0134 | Mesh protocol spec: capability manifests, ed25519 envelopes, enrollment, audit chain | pending | spec |
|
cross-app | high |
|
|
|
2026-05-24 | 2026-05-24 |
|
0009 |
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/ed25519mas alla de su uso. - NO define schema de la
operations.dbde 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
*_ides[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
{
"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 rechazarmanifest_invalid.capability: dotted name, ej.shell.exec,fs.read,docker.container.list. Debe estar enmanifest.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
{
"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,resultausente yerrorpoblado.error: objeto{code, message, details?}cuandook=false.codedebe estar en §10.duration_ms: tiempo de ejecucion en device_agent (no incluye latencia Matrix).audit_hash:this_hashdel registro enaudit_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):
- Si
argses null o ausente,json_canonical = "null". - Si
argses objeto, recursivo: ordenar claves alfabeticamente, valores serializados sin espacios, strings con escape JSON estandar. - Si
argses 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
# 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)
{
"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)
{
"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)
- Parsear
manifestysignature. - Computar
manifest_canonical. - Verificar
ed25519.Verify(operator_pubkey, manifest_canonical, signature). - Rechazar si
expires_at < now()→manifest_invalidcondetails.reason = "expired". - Rechazar si
issued_at > now() + 300(clock skew) →manifest_invalidcondetails.reason = "future_issued". - Rechazar si
device_idno coincide con~/.config/device_agent/device_id→manifest_invalid. - Rechazar si
operator_pubkey_fingerprintno 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.pubdurante enrollment.
3.2 Generacion (una sola vez en la vida del operador, idempotente)
# 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
// 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:
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)
// 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)
{
"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.noncepreviene 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)
# 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)
// 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:
{
"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/randpor request. - Server (device_agent O hub) mantiene tabla
seen_nonces:
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
tsdebe estar en[now-60, now+30]. Mas viejo →nonce_replay. Mas futuro →signature_invalidcondetails.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
// 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
{
"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.reactioncon key👍(U+1F44D) sobre el eventoapproval_requesten#operator-approvals, emitida por el matrix_id del operador (configurado enapps/agents_and_robots/config.yaml::operator_matrix_id). - O mensaje
!approve <request_id>en#operator-approvalsdesde 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
// 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)
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
// 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)
// 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)
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 publicaapproval_requestaqui 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:
- Busca
SELECT device_id, manifest_id, role FROM room_devices WHERE room_id=? AND active=1. - Si no existe → ignora (o responde "this room is not bound to a device").
- Si
role='device', dispatcha aldevice_agentcorrespondiente. - Si
role='approval'orole='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.splitPython o equivalente). Quoted strings respetan espacios. - Si parsing falla →
!helpcorto en la misma linea + abort. - Args desconocidos para una capability →
manifest_invalidcondetails.unknown_args: [...].
9.2 Output rendering
El bot formatea responses para Matrix:
ok=true, output corto (<2KB): formatted text con<pre><code>...</code></pre>(Matrixformatted_body).ok=true, output largo: trim a 2KB + link al artifact subido (Matrix media repo si el homeserver lo permite, sino paste apaste.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/ed25519exfiltrado. - Impact: atacante firma manifests + approvals — control total de devices.
- Mitigation:
- GPG-encrypted at rest via
pass(depende de GPG subkey). - Rotacion forzada:
!revoke-allen#operator-broadcast→ todos los devices recibenmanifest_revokedcondevice_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.
- GPG-encrypted at rest via
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.execactivado escapa a host. - Impact: host comprometido, no mas que T2.
- Mitigation:
binaries_whitelistestricta endocker.container.exec(sinbash,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_infraque NO usa--privilegeden 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.MatchGo + canonicalizar path confilepath.Clean+ verificarstrings.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).
- Implementacion glob:
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-approvalscada vez que un device hace POST /enroll exitoso — si no esperabas un enroll,!revokeinmediato.
T8. Matrix homeserver compromise
- Attack: atacante root en
organic-machine.commodifica 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_hubcorre 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
tsfuturo grande, los almacena, los reenvia cuandotscae 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 trackingreportaLeap status: Normal. - Hub alerta a
#operator-approvalssi recibe replicacion de audit contsque difiere >30s delreceived_at.
- Ventana ts:
T10. Denial of service via approval flooding
- Attack: atacante con manifest valido pero capabilities limitadas spam
requires_approval=truerequests 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-approvalsrecibe 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 (
dependenciesapuntando aqui,related_flows: [0009]). - Capabilities groups
wireguard,device-agent,docker-agentcon stubs endocs/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 ainternal). - 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.txtcon 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 funcioneswg_*(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.callsconfunction_id = capability_<name>_<lang>_<domain>cuando la implementacion exista. Si la capability es solo metadata (!help), no se loggea. audit_log(§7) es separado decall_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.