feat: infrastructure-control network-verification + IP-first doctrine, plus 3 supporting contracts

- infrastructure-control: add Section 5.3 (Network Verification & Routing) with
  dual-path checks, NetBird ingress health, DNS drift detection, and routing
  regression alerts. Add Section 7 (Configuration Doctrine — IP-First) mandating
  LAN IPs for all configs/agents, URLs reserved for human browser access only.
  Also enrich access matrix (Mumuni, Koonimo, LiteLLM Admin, Grafana entries),
  reachability matrix, and CT 116 nginx routing + Prometheus targets.
- build-zulip-plugin: fold Success Criteria into Maintains postconditions.
- pi-approval-architecture: new reference doc — pi approval model vs Hermes,
  architectural limits, /approve and /deny stub commands.
- zulip-approval-fix: new responsibility — documents the Zulip HTML/prefix bug
  that broke /approve and /deny, and the one-line fix applied.
This commit is contained in:
Abiba
2026-07-02 20:39:40 +00:00
parent c1d993fe01
commit b0c0331727
4 changed files with 433 additions and 61 deletions
+26 -28
View File
@@ -15,6 +15,30 @@ description: >
- last_generation: timestamp — When the last generation was created
- known_issues: array — Issues discovered in the current version
**Postconditions** (the plugin is GOOD when ALL pass):
*Connectivity*
- connected == true — Establishes and maintains Zulip event queue
- reconnect_on_failure == true — Reconnects after queue expiry
- recovers_from_network_loss == true — Handles temporary network drops
*Message Flow*
- dm_response_time_ms < 10000 — DMs get a response within 10 seconds
- stream_mention_detection == true — @mentions in streams are detected and routed
- all_bots_detection == true — @all-bots mentions are detected
- echo_loop_prevention == true — Never replies to its own messages
*Code Quality*
- syntax_check == "pass" — All Python files are valid
- has_register_function == true — Exposes `register(ctx)` entry point
- plugin_yaml_valid == true — plugin.yaml parses correctly
- follows_base_adapter_pattern == true — Extends BasePlatformAdapter
*Reliability*
- graceful_disconnect == true — shutdown doesn't crash
- handles_malformed_messages == true — Bad JSON doesn't kill the poll loop
- typing_indicators_work == true — Sends typing notifications
## Parameters
- zulip_site: string — Zulip server URL (default: "https://chat.sysloggh.net")
@@ -26,45 +50,19 @@ description: >
The improvement cycle is **event-driven**, not just cron-based:
- **On check failure**: If any Success Criteria fails, wake immediately to fix
- **On check failure**: If any postcondition fails, wake immediately to fix
- **On user request**: `prose run build-zulip-plugin` — explicit upgrade
- **On new pattern**: If another plugin or agent reports a better pattern, wake to assimilate
- **Retrospective**: Every 24 hours if no other trigger fired — scan for subtle degradation
- **On first deploy**: Always run Generation 1 when no plugin exists yet
## Success Criteria (What Makes a Plugin "Good")
The plugin is considered GOOD when ALL of these pass:
### Connectivity
- plugin.connected == true — Establishes and maintains Zulip event queue
- plugin.reconnect_on_failure == true — Reconnects after queue expiry
- plugin.recovers_from_network_loss == true — Handles temporary network drops
### Message Flow
- dm_response_time_ms < 10000 — DMs get a response within 10 seconds
- stream_mention_detection == true — @mentions in streams are detected and routed
- all_bots_detection == true — @all-bots mentions are detected
- echo_loop_prevention == true — Never replies to its own messages
### Code Quality
- syntax_check == "pass" — All Python files are valid
- has_register_function == true — Exposes `register(ctx)` entry point
- plugin_yaml_valid == true — plugin.yaml parses correctly
- follows_base_adapter_pattern == true — Extends BasePlatformAdapter
### Reliability
- graceful_disconnect == true — shutdown doesn't crash
- handles_malformed_messages == true — Bad JSON doesn't kill the poll loop
- typing_indicators_work == true — Sends typing notifications
## Remembers
Each generation stores:
- What worked well (success patterns)
- What broke (failure modes)
- What the user complained about (pain points)
- Which Success Criteria passed and failed
- Which postconditions passed and failed
## Generation Rules
+226 -33
View File
@@ -60,19 +60,23 @@ description: >
| Proxmox Root | Password via API ticket | `root@pam:kakashi19` | ✅ |
| docker-vm (.7) | SSH root | SSH key | ✅ |
| CT 116 (syslog-api) | SSH root | SSH key | ✅ |
| Tanko CT (.122) | SSH jerome | SSH key | ✅ |
| Tanko CT (.122) | SSH jerome | id_ed25519 | ✅ |
| Mumuni CT (.123) | SSH root | id_ed25519 | ✅ |
| Koonimo CT (.114) | SSH jerome | ❌ no key access |
| Netbird (.17) | SSH root | SSH key | ✅ |
| Gitea | API token | abiba-bot token | ✅ |
| Zulip | Bot API key | per-bot tokens | ✅ |
| Zulip | Bot API key | abiba-bot@chat.sysloggh.net | ✅ |
| RA-H OS | MCP bridge | port 3100 | ✅ |
| LiteLLM Admin | master key | `sk-litellm-7f96...` | ✅ |
| Grafana | admin password | `syslog-grafana-2026` | ✅ |
### Reachability Matrix
| From / To | PVE API | docker-vm (.7) | CT 116 | Tanko (.122) | Netbird (.17) |
|-----------|---------|----------------|--------|-------------|---------------|
| **Abiba** (CT 100) | ✅ :443 | ✅ SSH | ✅ SSH | ✅ SSH | ❌ no Netbird |
| **Tanko** (CT 122) | ❌ | ❌ | ❌ | ✅ | ❌ |
| **docker-vm** (.7) | ❌ | | ❌ | ❌ | ❌ |
| From / To | PVE API | docker-vm (.7) | CT 116 | Tanko (.122) | Mumuni (.123) | Koonimo (.114) |
|-----------|---------|----------------|--------|-------------|---------------|----------------|
| **Abiba** (.24) | ✅ :443 | ✅ SSH | ✅ SSH | ✅ SSH jerome | ✅ SSH root | ❌ SSH |
| **Tanko** (.122) | ❌ | ❌ | ❌ via NetBird | ✅ | ❌ | ❌ |
| **Mumuni** (.123) | ❌ | | ❌ | ❌ | ✅ | ❌ |
**Conclusion:** Only Abiba has cross-infrastructure access. All monitoring contracts run from Abiba.
@@ -138,14 +142,34 @@ description: >
### Ecosystem B: CT 116 syslog-api (192.168.68.116)
| Container | Image | Port |
|-----------|-------|------|
| harness-litellm | berriai/litellm:1.90.0-rc.1 | :4001 |
| harness-nginx | nginx:alpine | :80 |
| harness-router | inference-harness-router | :9000 |
| harness-postgres | postgres:16-alpine | :5432 |
| harness-redis | redis:7-alpine | :6379 |
| harness-dashboard | inference-harness-dashboard | :3000 |
8 containers in inference-harness stack:
| Container | Image | Port | Role |
|-----------|-------|------|------|
| harness-litellm | berriai/litellm:1.90.0-rc.1 | :4000→:4001 | API proxy, key mgmt, fallbacks |
| harness-router | inference-harness-router | :9000 (127.0.0.1) | GPU routing, slot booking, CB |
| harness-nginx | nginx:alpine | :80 | Entrypoint, /v1→LiteLLM, /dashboard/, /grafana/ |
| harness-postgres | postgres:16-alpine | :5432 | LiteLLM DB (keys, spend, config) |
| harness-redis | redis:7-alpine | :6379 | Router slots, circuit breakers |
| harness-dashboard | inference-harness-dashboard | :3000 | SyslogAI Harness UI |
| harness-grafana | grafana/grafana | :3000→:3001 | GPU dashboards via Prometheus |
| harness-prometheus | prom/prometheus | :9090 | Metrics scraper, 5 targets |
**Nginx routing**:
- `/v1/*` → harness-litellm:4000 (API)
- `/admin/*` → harness-litellm:4000
- `/dashboard/` → harness-dashboard:3000
- `/litellm/*` → harness-litellm:4000
- `/grafana/` → harness-grafana:3000
- `/health/*` → harness-litellm:4000/health/liveliness
- `/gpu/` → 192.168.68.24:9100 (fleet monitor)
**Prometheus targets**:
- 192.168.68.8:9400 (RTX 3090 — qwen)
- 192.168.68.110:9400 (RTX 5070 — gemma)
- 192.168.68.15:9400 (Strix Halo — ornith)
- 192.168.68.24:9401 (Router metrics exporter)
- harness-litellm:4000 (LiteLLM health)
### Ecosystem C: Netbird (72.61.0.17)
@@ -157,6 +181,18 @@ description: >
| netbird-crowdsec | crowdsecurity/crowdsec:v1.7.7 | WAF |
| netbird-traefik | traefik | Reverse proxy |
> **Dependency note:** NetBird is an **access layer only** — it exists so a
> human on a PC or away from the LAN can reach services by URL. It is **not**
> a dependency of any service backend. Per Section 7, every config and agent
> must reach backends by LAN IP; the `*.sysloggh.net` URLs are reserved for
> human browsers. The 2026-07-02 outage (NetBird VPS hung) took out every
> CNAME'd URL while every LAN-IP backend stayed up — proof the doctrine is
> necessary.
>
> **Recovery:** a hung NetBird VPS (TCP accepts, SSH banner exchange times
> out, TLS hangs) cannot be self-remediated over the network. It requires a
> hard reboot from the hosting provider's console. See Section 5.3.
### Checks (every 60s)
```
@@ -226,25 +262,103 @@ For docker-vm specifically:
## Section 5: Network Services — Monitoring
| Service | Domain | Status |
|---------|--------|--------|
| Proxmox API | minipve.sysloggh.net:443 | ✅ |
| Authentik | auth.sysloggh.net:443 | ✅ |
| Gitea | git.sysloggh.net:443 | ✅ |
| Zulip | chat.sysloggh.net:443 | ✅ |
| LiteLLM | litellm.sysloggh.net:443 | ✅ |
| Pulse | pulse.sysloggh.net:443 | ✅ |
| SearXNG | searxng.sysloggh.net:8888 | ✅ |
| Firecrawl | firecrawl.sysloggh.net:3002 | ✅ |
### 5.1 Service Inventory
### Checks (every 2 min)
The `Resolves To` column is the load-bearing one. Services that CNAME to
`netbird.sysloggh.net` (72.61.0.17) are routed through the NetBird reverse
proxy and **fail whenever NetBird blips**, even though their LAN backend is
fine. Services that resolve directly to a LAN IP are NetBird-independent.
| Service | Domain | LAN Backend | Resolves To | NetBird-dep | Status |
|---------|--------|-------------|-------------|-------------|--------|
| Proxmox API | minipve.sysloggh.net:8006 | 192.168.68.12 | LAN IP | No | ✅ |
| LiteLLM | litellm.sysloggh.net | 192.168.68.116 | LAN IP | No | ✅ |
| Authentik | auth.sysloggh.net:443 | 192.168.68.11 | CNAME → netbird | **Yes** | ⚠️ |
| Gitea | git.sysloggh.net:443 | 192.168.68.110 | CNAME → netbird | **Yes** | ⚠️ |
| Zulip | chat.sysloggh.net:443 | 192.168.68.117 | CNAME → netbird | **Yes** | ⚠️ |
| Pulse | pulse.sysloggh.net:443 | 192.168.68.7 | CNAME → netbird | **Yes** | ⚠️ |
| DNS UI | dns.sysloggh.net:443 | 192.168.68.102 | CNAME → netbird | **Yes** | ⚠️ |
| SearXNG | searxng.sysloggh.net:8888 | 192.168.68.7:8888 | LAN IP | No | ✅ |
| Firecrawl | firecrawl.sysloggh.net:3002 | 192.168.68.7:3002 | LAN IP | No | ✅ |
**Verified 2026-07-02:** NetBird VPS rebooted after a hang; all CNAME'd
services recovered. LAN-IP-direct paths stayed up throughout the outage.
### 5.2 Checks (every 2 min)
```
## Maintains
- dns-resolution: { services: [{name, resolves}] }
- dns-resolution: { services: [{name, resolves_to, is_lan_ip}] }
- ssl-expiry: { services: [{name, days_remaining}] }
- endpoint-reachability: { services: [{name, http_code}] }
- netbird-dependency: { services: [{name, depends_on_netbird: bool}] }
```
### 5.3 Network Verification & Routing (every 5 min)
This block is what catches a NetBird blip before it becomes an outage. It
proves two independent paths for every service: the **URL path** (what a
browser uses, may cross NetBird) and the **LAN-IP path** (what configs and
agents must use, never crosses NetBird).
```
## Maintains
- dual-path-reachability: {
services: [{
name: string,
url_path: { http_code, tls_ok }, # the *.sysloggh.net path
lan_path: { ip, port, http_code }, # the IP:port path
lan_independent: bool # lan_path works when url_path fails
}]
}
- netbird-ingress-health: {
vps_up: bool, # TCP 22/80/443 accept on 72.61.0.17
ssh_banner_ok: bool, # banner exchange completes (catches hung box)
containers_up: int, # all 5 netbird-* containers running
proxy_routes_ok: int # traefik routers resolving (chat/git/auth return 2xx/3xx)
}
- routing-regression: {
new_netbird_cnames: [domain], # domains that newly CNAME to netbird.sysloggh.net
# → alert: a service just became NetBird-dependent
config_url_violations: [file] # configs/agents found referencing a *.sysloggh.net
# URL where a LAN IP is required (see Section 7)
}
## Checks
- For each service in 5.1:
- curl https://<domain>/ → record url_path (code, tls_ok)
- curl http://<lan_ip>:<port>/ → record lan_path
- If url_path fails AND lan_path succeeds → netbird-degraded, NOT service-down
- If BOTH fail → service-down (real outage)
- NetBird ingress (72.61.0.17):
- TCP probe 22, 80, 443 → all accept
- SSH banner exchange completes within 10s (catches the hung-box symptom
where TCP accepts but sshd never sends its banner)
- ssh root@72.61.0.17 'docker ps' → 5/5 netbird-* containers Up
- curl https://chat.sysloggh.net/ → 2xx/3xx (proxy route alive)
- DNS dependency drift:
- For each *.sysloggh.net, dig +short → flag any new CNAME → netbird.sysloggh.net
- A service moving FROM LAN-IP TO netbird CNAME is a regression (alert)
- A service moving FROM netbird CNAME TO LAN-IP is an improvement (log)
## Remediations
- NetBird VPS hung (TCP up, SSH banner timeout, TLS hang):
→ This is a host-level hang, not a service fault. Cannot self-remediate via SSH.
→ Alert crit immediately: "NetBird VPS hung — needs hard reboot from provider console"
→ Do NOT declare backend services down; their LAN-IP paths are still up.
- Single proxy route missing (e.g. git returns 000 but chat ok):
→ ssh root@72.61.0.17 'docker restart netbird-traefik'
→ re-check after 30s
- New NetBird CNAME detected:
→ Alert: "<domain> became NetBird-dependent — violates IP-first doctrine (Section 7)"
- Config URL violation detected:
→ Alert with file + line; do not auto-edit configs
```
## Section 6: Alert Routing & Escalation
@@ -281,6 +395,63 @@ ZFS pool degraded
→ Cannot auto-fix → Escalate immediately
```
## Section 7: Configuration Doctrine — IP-First, URLs for Browsers
This doctrine is the structural fix for the NetBird-dependency outage. It is
enforced by the `routing-regression.config_url_violations` check in Section
5.3.
### Rule
1. **Service configs use LAN IP addresses, always.** Any file that wires a
service, agent, monitor, or integration to another internal service must
reference the backend by its `192.168.68.x` LAN IP and port — never by a
`*.sysloggh.net` URL. NetBird may blip at any time; LAN IPs do not.
2. **`*.sysloggh.net` URLs are reserved for human browser access.** They are
a convenience layer (split-horizon DNS on the LAN, NetBird reverse proxy
off the LAN) for a person at a keyboard. They must never be a load-bearing
dependency in code or config.
3. **Agents and monitors reach backends by LAN IP.** Abiba, Tanko, and Mumuni
all run on the LAN; there is no reason for them to hairpin through NetBird.
The daily infra report and all `ssh`/`curl` checks already follow this.
4. **DNS records should prefer LAN IPs over NetBird CNAMEs.** A service that
can resolve directly to its LAN IP (like `litellm``192.168.68.116` and
`minipve``192.168.68.12`) is NetBird-independent. Moving a record from
a NetBird CNAME to a LAN IP is an improvement; the reverse is a regression.
5. **NetBird is for remote/PC access only.** When you are out or on a PC,
NetBird carries you in. When you are on the LAN, NetBird is not in the
path. Configs must reflect this — they always assume LAN.
### Canonical IP map (configs MUST use these)
| Service | Use in configs | URL (browsers only) |
|---------|----------------|--------------------|
| Proxmox API | `https://192.168.68.12:8006` | `https://minipve.sysloggh.net` |
| LiteLLM API | `http://192.168.68.116:4000` | `https://litellm.sysloggh.net` |
| LiteLLM (nginx) | `http://192.168.68.116` | — |
| Grafana | `http://192.168.68.116:3001` | — |
| Authentik | `https://192.168.68.11` | `https://auth.sysloggh.net` |
| Gitea | `http://192.168.68.110:3000` | `https://git.sysloggh.net` |
| Zulip API | `http://192.168.68.117` | `https://chat.sysloggh.net` |
| SearXNG | `http://192.168.68.7:8888` | — |
| Firecrawl | `http://192.168.68.7:3002` | — |
| RA-H OS bridge | `http://192.168.68.65:3100` | — |
### Violation examples (to be flagged by Section 5.3)
- ❌ An agent config setting `LITELLM_BASE_URL=https://litellm.sysloggh.net`
- ✅ Same config setting `LITELLM_BASE_URL=http://192.168.68.116:4000`
- ❌ A monitor curling `https://chat.sysloggh.net/api/v1/server_settings`
- ✅ A monitor curling `http://192.168.68.117/api/v1/server_settings` (and
optionally the URL as a separate browser-path check)
### Goal state
Every `*.sysloggh.net` record resolves to a LAN IP (split-horizon DNS on LAN,
NetBird reverse proxy off LAN). NetBird then becomes purely the off-LAN
ingress — if it blips, only remote browser users notice, and no agent,
monitor, or integration breaks.
## Appendix A: Quick Health Commands
```bash
@@ -293,8 +464,30 @@ curl -sfk "$PVE/api2/json/cluster/resources" -H "$AUTH"
ssh root@192.168.68.7 "docker ps --format '{{.Names}} {{.Status}}'"
ssh root@192.168.68.116 "docker ps --format '{{.Names}} {{.Status}}'"
# GPU fleet quick check
curl -s http://192.168.68.116/health/unified | jq .status
curl -s http://192.168.68.24:9100/gpu-data | jq .summary
# Grafana status
curl -s http://admin:syslog-grafana-2026@192.168.68.116:3001/api/health
# Prometheus targets
curl -s http://192.168.68.116:9090/api/v1/targets | jq '.data.activeTargets[] | {job: .labels.job, health: .health}'
# LiteLLM key check
curl -s -H "Authorization: Bearer sk-litellm-7f96080dd99b15c36bd4b333b58a6796" \
http://192.168.68.116/litellm/key/list | jq '.keys[] | {alias: .key_alias, models: .models}'
# Storage check
ssh root@192.168.68.7 "df -h /media/storage /media/mediastore"
# Router roster reload (if needed)
curl -s -X POST http://192.168.68.116:9000/admin/roster/reload \
-H "Authorization: Bearer sk-admin-ee09fffd04978b61a1569ac670c68814"
# Restart stuck GPU (saturation watchdog alternative)
ssh root@192.168.68.8 "systemctl restart llama-server"
ssh root@192.168.68.110 "systemctl restart llama-server"
```
## Appendix B: CT Inventory
@@ -302,9 +495,9 @@ ssh root@192.168.68.7 "df -h /media/storage /media/mediastore"
| CT | Name | Node | IP | Role | Agent |
|----|------|------|----|------|-------|
| 100 | abiba | amdpve | .24 | Pi agent (this host) | ✅ pi |
| 101 | llm-gpu | acerpve | | GPU VM | ❌ |
| 101 | llm-gpu | acerpve | .8 | GPU RTX 3090 | ❌ |
| 102 | adguard | acerpve | — | DNS | ❌ |
| 103 | ocu-llm | ocupve | | GPU VLM | ❌ |
| 103 | ocu-llm | ocupve | .110 | GPU RTX 5070 | ❌ |
| 104 | authentik | minipve | .11 | OIDC | ❌ |
| 105 | kagentz | amdpve | — | Agent Zero | ✅ |
| 106 | ra-h-os | storepve | .65 | KG bridge | ✅ MCP |
@@ -314,10 +507,10 @@ ssh root@192.168.68.7 "df -h /media/storage /media/mediastore"
| 110 | gitea | minipve | — | Git | ❌ |
| 111 | tdunna | amdpve | — | ? | ❌ |
| 112 | tanko | amdpve | .122 | Hermes agent | ✅ |
| 113 | baggy | amdpve | — | ? | |
| 114 | mumuni | minipve | | Hermes agent | ✅ |
| 113 | baggy/koonimo | amdpve | .114 | Hermes agent | |
| 114 | mumuni | minipve | .123 | Hermes agent | ✅ |
| 115 | scottdenya | amdpve | — | ? | ❌ |
| 116 | syslog-api | minipve | .116 | LiteLLM stack | ❌ |
| 116 | syslog-api | minipve | .116 | LiteLLM + Grafana | ❌ |
| 117 | zulip | storepve | — | Chat | ❌ |
| 118 | jitsi | minipve | — | Video | ❌ |
@@ -329,5 +522,5 @@ ssh root@192.168.68.7 "df -h /media/storage /media/mediastore"
| docker-vm (.7) | SearXNG | `/opt/search-stack/searxng/docker-compose.yml` |
| docker-vm (.7) | Home stack | `/opt/home_stack/docker-compose.yml` |
| docker-vm (.7) | Audiobookshelf | `/opt/audiobookshelf/docker-compose.yml` |
| CT 116 (.116) | LiteLLM | `/root/docker-compose-litellm.yml` |
| CT 116 (.116) | Inference Harness | `/opt/inference-harness/docker-compose.yml` |
| Netbird (.17) | Netbird | Docker run (not compose) |
+107
View File
@@ -0,0 +1,107 @@
---
kind: pattern
name: pi-approval-architecture
description: >
Documents pi's approval model vs Hermes, what commands are available,
and the architectural constraints that prevent mid-turn tool approval in pi.
Updated with /approve and /deny stub commands for cross-agent UX consistency.
agent: abiba
version: 1.0.0
---
# Pi Approval Architecture
## TL;DR
| Feature | Hermes | Pi (Abiba) |
|---------|--------|------------|
| `/approve` command | ✅ Full — unblocks agent | ✅ Stub — acknowledges, no blocking |
| `/deny` command | ✅ Full — blocks agent | ✅ Stub — acknowledges, no blocking |
| Dangerous command detection | ✅ 40+ patterns + Tirith | ❌ None (LLM judgment only) |
| Mid-turn tool approval | ✅ Agent thread blocks on Event | ❌ No hook in agent loop |
| Smart approval (aux LLM) | ✅ `approvals.mode: smart` | ❌ N/A |
| Yolo mode | ✅ `/yolo`, `approvals.mode: off` | ❌ N/A |
| Permanent allowlist | ✅ `command_allowlist` + `shell-hooks` | ❌ N/A |
| Session-scoped approval | ✅ `approve_session()` | ❌ N/A |
| Approval timeout | ✅ 60s default, configurable | ❌ N/A |
## Why Pi Can't Do Mid-Turn Approval
Pi's agent loop is **418 lines** of TypeScript. It's deliberately minimal:
```
LLM decides → calls tool → tool executes immediately → output goes to LLM → repeats
```
There is no middleware between "LLM decides to call bash" and "bash executes."
The extension system (`injectIntoSession`) feeds messages IN but doesn't intercept
tool calls mid-turn.
Hermes has a full approval pipeline:
```
LLM decides → check_all_command_guards() → if dangerous → notify_cb → block thread
→ user responds /approve → resolve_gateway_approval() → Event.set() → unblock
```
This pipeline is deeply integrated into Hermes' gateway, tool executor, and
session management. Replicating it in pi would require rewriting the core agent
loop — violating pi's design constraint of minimal core.
## What Pi HAS: `/approve` and `/deny` Stubs
Added to prevent `/approve` and `/deny` from being forwarded to the LLM as
garbled input. When a user types these commands to Abiba:
- **`/approve`** → "Pi doesn't have pending approvals. Commands execute immediately."
- **`/approve session`** → Same response
- **`/deny`** → Same response + "For Hermes agents (Tanko, Mumuni), these work with their built-in approval system."
This keeps the UX consistent across agents — users can type `/approve` anywhere
without getting confused by LLM responses.
## Pi's Full Command Set (Hermes Parity)
| Command | Handler | Description | Hermes Equivalent |
|---------|---------|-------------|-------------------|
| `/status` | System | PM2 processes, containers, session info | `/status` |
| `/retry` | Session | Re-injects last user message | `/retry` |
| `/continue` | Session | Continues truncated response | `/continue` |
| `/new [topic]` | Session | Clears conversation context | `/new` |
| `/stop` | Control | Interrupts current LLM processing | `/stop` |
| `/model [name]` | Info | Show current model, list available | `/model` |
| `/compact` | Session | Requests context compaction | `/compact` |
| `/yolo` | Stub | Pi has no approval system — accepted for UX | `/yolo` |
| `/verbose` | Stub | Verbosity controlled by prompt, not toggle | `/verbose` |
| `/approve` | Stub | Pi executes immediately — no approvals | `/approve` |
| `/deny` | Stub | Pi executes immediately — no approvals | `/deny` |
| `/help [cmd]` | Info | Shows command help | `/help` |
## What Pi COULD Add (Without Core Changes)
These would work within pi's current architecture:
1. **Pre-message dangerous command scan**: Before forwarding an agent response to
Zulip, scan the text for dangerous bash commands and add a warning banner.
Doesn't prevent execution but alerts user.
2. **User-facing approval prompt injection**: When user asks pi to do something
dangerous (detected via regex on user input), pi could respond with a
confirmation request before proceeding. This works because it's at the
message level, not mid-turn.
3. **Response rate-limiting**: If pi generates many bash commands rapidly,
throttle and ask for confirmation. Works within the extension's send path.
## Files
| File | Purpose |
|------|---------|
| `/root/.pi/agent/extensions/zulip/index.js` | Pi Zulip extension with command system |
| `/root/prose-contracts/zulip-approval-fix.prose.md` | Hermes Zulip approval fix |
| `/root/prose-contracts/pi-approval-architecture.prose.md` | This document |
## Related
- Hermes `tools/approval.py` — Full approval pipeline (dangerous patterns, Tirith, smart mode)
- Hermes `gateway/slash_commands.py:_handle_approve_command` — Slash command handler
- Hermes Zulip adapter fix — HTML stripping for command detection
+74
View File
@@ -0,0 +1,74 @@
---
kind: responsibility
name: zulip-approval-fix
description: >
Fixes broken /approve and /deny slash commands for Hermes agents in Zulip.
Zulip delivers messages as HTML (<p>/approve</p>), but the gateway's slash
command parser expects plain text. HTML tags prevent command matching.
agent: abiba
version: 2.0.0
status: deployed
---
## Root Cause
The Hermes gateway ALREADY has native `/approve` and `/deny` handling via
`slash_commands.py:_handle_approve_command()`. No custom approval interception
is needed in the platform adapter.
The bug: Zulip's API returns message content as **rendered HTML**
(`<p>/approve session</p>`), but the gateway's slash command prefix matcher
checks `text.startswith("/approve")`. The `<p>` tag prevents matching.
## Fix (Applied)
One line added to `~/.hermes/plugins/platforms/zulip/adapter.py`:
```python
content = msg.get("content", "")
content = _strip_html(content) # Strip Zulip HTML for /commands
```
Plus a small `_strip_html()` helper that strips HTML tags and decodes entities.
## How the Full Flow Works
1. Agent tries dangerous command → `check_all_command_guards()`
2. Gateway's `_approval_notify_sync` sends approval prompt via `send()`
3. User sees: "⚠️ Dangerous command requires approval — Reply /approve or /deny"
4. User types `/approve session` in Zulip
5. Zulip API returns `<p>/approve session</p>`
6. Adapter strips HTML → `/approve session`
7. Gateway's slash command handler matches `/approve`
8. `_handle_approve_command` calls `resolve_gateway_approval(session_key, "session")`
9. Agent thread unblocks, command executes
## Gateway Slash Commands Supported
| Command | Effect |
|---------|--------|
| `/approve` | Approve once |
| `/approve session` | Approve for this session |
| `/approve always` | Permanently approve this pattern |
| `/approve all` | Approve ALL pending commands |
| `/approve all session` | Approve all + remember session |
| `/deny` | Deny oldest pending |
| `/deny all` | Deny all pending |
## Files Changed
| File | Host | Change |
|------|------|--------|
| `~/.hermes/plugins/platforms/zulip/adapter.py` | .122, .123 | +_strip_html() helper + HTML stripping in message handler |
## Deployed
- ✅ Mumuni (.123) — patched + gateway restarted
- ✅ Tanko (.122) — patched (jerome user), gateway restart pending
## Verification
1. Send DM: "run rm /tmp/test" to Mumuni/Tanko in Zulip
2. Agent should respond with approval prompt: "⚠️ Dangerous command requires approval"
3. Reply `/approve` — agent should execute and respond
4. Reply `/deny` — agent should block and explain why