diff --git a/.env.smoke.example b/.env.smoke.example
new file mode 100644
index 0000000..515dc25
--- /dev/null
+++ b/.env.smoke.example
@@ -0,0 +1,24 @@
+# agent-shield live-smoke configuration — copy to `.env.smoke` and fill in real
+# values. `.env.smoke` is gitignored; NEVER commit a real key.
+#
+# Run the smoke with Node's native env-file loader (no extra deps, no hidden
+# magic):
+#
+# node --env-file=.env.smoke scripts/smoke-live.mjs
+#
+# WHY A DEDICATED THROWAWAY PROJECT:
+# The smoke runs `init` (setupShield), which WRITES policies + an agent into the
+# project. There is no test/sandbox key today — every Palveron key is a live
+# `pv_live_` key, so isolation comes from pointing at a SEPARATE, DISPOSABLE
+# project, not from a key prefix. Create a dedicated throwaway project in the
+# dashboard, use its key here, and never point this at a production project.
+
+# Live API key (pv_live_...) of the dedicated throwaway project.
+PALVERON_API_KEY=pv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+# Human-readable name/id of that throwaway project. Required — it makes the
+# choice of target conscious (you are naming what you are about to write into).
+PALVERON_SMOKE_PROJECT=agent-shield-smoke-throwaway
+
+# Optional — defaults to https://gateway.palveron.com
+# PALVERON_API_URL=https://gateway.palveron.com
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..62d6107
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,26 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ test:
+ name: test (node ${{ matrix.node }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ['18', '20', '22']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ registry-url: 'https://registry.npmjs.org'
+ - name: Install dependencies
+ run: npm install --no-audit --no-fund
+ - name: Run tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..717a9d6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+package-lock.json
+*.tgz
+.DS_Store
+
+# Live-smoke config — holds a real pv_live_ key. Never commit.
+.env.smoke
+.env
+
+# Spawn-diagnostics output (AGENT_SHIELD_DEBUG_LOG_PATH). Sanitized, but may
+# carry gateway URLs / agent ids — never commit.
+.debug/
diff --git a/BEFUND_AGENT_SHIELD_SPAWN.md b/BEFUND_AGENT_SHIELD_SPAWN.md
new file mode 100644
index 0000000..5f5829a
--- /dev/null
+++ b/BEFUND_AGENT_SHIELD_SPAWN.md
@@ -0,0 +1,315 @@
+# BEFUND — agent-shield Spawn-Fail-Closed (Fund #5), Instrumentierungs-Diagnose
+
+**Art:** Diagnose mit Instrumentierung. Eine dauerhafte, env-gated, secret-sichere File-Diagnose
+(`src/debug-log.mjs`) + additive `dlog(...)`-Aufrufe wurden ergänzt. **Keine** Governance-Logik
+geändert (Decision-Mapping, Fail-Closed-Pfad, Circuit-Breaker, Retry, Timeout, Health = byte-identisch).
+Off-by-default (`AGENT_SHIELD_DEBUG_LOG_PATH` ungesetzt → No-op). 37/37 `node --test` grün.
+
+**Branch:** `fix/openclaw-launch-sdk-contract` · **Datum:** 2026-06-23
+
+---
+
+## 1. Symptom (aus Handoff, nicht erneut getestet)
+
+`openclaw chat` → `governance_check` über MCP → **`BLOCK / gateway_unavailable_failclosed`**.
+Derselbe MCP-Server **direkt** (`echo … | node bin/agent-shield-mcp.mjs`) → sauber MODIFY/ANONYMIZED.
+**Im Gateway-Log kein `[agent-shield]`-Eintrag** für den fehlschlagenden Turn → es erreicht **kein
+HTTP-Request** das Gateway → der Fehler entsteht **client-seitig im gespawnten Prozess**.
+
+---
+
+## 2. Read-only Befund: WO `gateway_unavailable_failclosed` herkommt (Datei:Zeile)
+
+Die Fail-Closed-Emission ist **eindeutig lokalisiert** (kein Raten):
+
+- **`src/fail-policy.mjs:34`** — `export const FAIL_CLOSED_REASON = 'gateway_unavailable_failclosed';`
+- **`src/fail-policy.mjs:71-81`** — `transportFallback(riskLevel, opts)` gibt
+ `{ decision: 'BLOCK', reason: FAIL_CLOSED_REASON }` zurück, **wenn** `failClosedOverride(env) ||
+ riskLevel === 'HIGH'`. Sonst `{ decision:'ALLOW', reason: FAIL_OPEN_REASON }`.
+
+`transportFallback` wird aus **`src/client.mjs` an genau zwei Stellen** aufgerufen:
+1. **`client.mjs:250`** — RATE_LIMITED-Zweig (SDK liefert HTTP 429 als `decision:'RATE_LIMITED'`,
+ keine Exception). → Branch `rate_limited`.
+2. **`client.mjs:320`** — `catch (err)`-Zweig (Z. 299), **nachdem** `isFailLoud(err)` false war
+ (also **kein** 400/401, sondern Transport-/Timeout-/Circuit-Open-Fehler). → **Das ist der für
+ Fund #5 relevante Pfad**: `send_email` ist HIGH-Risk → `transportFallback(HIGH)` → BLOCK.
+
+**Schlussfolgerung (belegt):** Der Spawn-Fehler läuft durch **`client.mjs:299 catch` → `:320
+transportFallback`**. Die offene Frage ist **welcher `err`** dort ankommt — und genau das entscheidet
+zwischen H1–H5. Deshalb instrumentiert (nicht geraten).
+
+---
+
+## 3. Read-only Befund: Circuit Breaker ist SDK-INTERN (entscheidend für H1)
+
+Der Breaker lebt **nicht** in agent-shield, sondern in `@palveron/sdk`:
+
+- **`@palveron/sdk/dist/index.mjs`** (gespiegelt in `dist/index.js`):
+ - `index.js:156-158` — `new CircuitBreaker(config.circuitBreakerThreshold ?? 5,
+ config.circuitBreakerCooldown ?? 3e4)` → **Schwelle 5 Failures, Cooldown 30 000 ms**.
+ - `index.js:323-324` — `if (!this.circuit.canRequest()) throw new PalveronCircuitOpenError();`
+ → bei offenem Breaker wird **vor jedem HTTP** geworfen (= „kein Netzwerk-Versuch", konsistent mit
+ „kein `[agent-shield]` im Gateway-Log").
+ - `index.js:298` — `diagnostics().circuitState = this.circuit.getState()` (`closed|open|half-open`).
+- agent-shield kann den Breaker **nur beobachten**: `client.mjs` `get circuitState()` liest
+ `#sdk.diagnostics().circuitState`. Es gibt **keinen** internen Transition-Hook → die `breaker`-
+ Events werden aus **Vorher/Nachher-Deltas** von `circuitState` + dem Fangen von
+ `PalveronCircuitOpenError` abgeleitet (siehe §4 Event `breaker`/`gateway_call_end`).
+
+**Wichtige Konsequenz für H1:** Die Schwelle ist **5**. Ein **einzelner** früher Fehler trippt den
+Breaker **nicht**. H1 würde also ≥5 vorausgehende Fehlversuche **im selben Prozess** verlangen
+(SDK-interne Retries zählen je `onFailure`). Das macht H1 **a priori weniger wahrscheinlich** als
+H2/H3 (geerbtes Proxy-Env / Env-Divergenz, die schon den **ersten** Call als `transport_error` killen)
+— aber der Log entscheidet es faktisch, nicht diese Plausibilität.
+
+---
+
+## 4. Instrumentierungs-Inventar (Event → Datei:Zeile, secret-safe)
+
+Neues Modul **`src/debug-log.mjs`** — `dlog(event, fields)`, append-only JSONL, `seq` monoton pro
+Prozess, `try/catch` (wirft nie), aktiv nur bei gesetztem `AGENT_SHIELD_DEBUG_LOG_PATH`. Secret-Helfer:
+`keyMeta` (nur `{present,len}`), `sanitizeProxyUrl` (→ `host:port`, strippt `user:pass`+Pfad),
+`collectProxyEnv`.
+
+| Event | Datei:Zeile | Entscheidet | Kernfelder |
+|---|---|---|---|
+| `proc_start` | `bin/agent-shield-mcp.mjs:18` | H2/H3/H4 | `cwd`, `argv`, `nodeVersion`, `apiUrl`, `apiUrlEnvSeen` (beide Aliase), `apiKeyPresent`/`apiKeyLen`, `agentId`, `proxyEnv` (sanitisiert), `nodeOptions`, `failClosedOverride` |
+| `mcp_msg` | `src/mcp-server.mjs:78` | H1/H5 (Reihenfolge) | `method`, `id`, bei tools/call: `mcpToolName`, `toolName`, `argKeys`, `argBytes` (**keine** arg-Werte) |
+| `gateway_call_start` | `client.mjs:222` (verify), `:335` (health) | H1 (Breaker-State **vor** Call) | `kind`, `url`, `attempt:0`, `breakerState` |
+| `gateway_call_end` | `client.mjs:237`/`302` (verify), `:339`/`344` (health) | **H1 vs H2/H3** | `outcome` ∈ {`http_ok`(+`httpStatus`,`decision`), `transport_error`(+`errName`,`errCode`,`errMessage`), `breaker_open_shortcircuit`, `timeout`(+`timeoutMs`), `http_error`}, `elapsedMs` |
+| `breaker` | `client.mjs:193` (via `#emitBreakerDelta`, gerufen `:236/301/338/343`) | **H1 direkt** | `from`, `to`, `observed` (Call-Ergebnis als Kontext) |
+| `failclosed_emit` | `client.mjs:254` (rate_limited), `:325` (catch) | Kausalkette schließen | `reason` (gateway_unavailable_failclosed/…failopen), `decision`, `branch` (= outcome), `riskLevel`, `errName` |
+| `proc_fatal` | `bin/agent-shield-mcp.mjs:37` | Start-Crash | `errName`, `errMessage` |
+
+`outcome`-Klassifikation: `client.mjs:34 classifyErrorOutcome(err)` mappt
+`PalveronCircuitOpenError → breaker_open_shortcircuit`, `PalveronTimeoutError → timeout`,
+`PalveronValidation/Authentication → http_error`, sonst `transport_error` (nutzt `err.name`/`err.code`,
+z. B. `ECONNREFUSED`/`ENOTFOUND`/`ETIMEDOUT`/`UND_ERR_CONNECT_TIMEOUT`).
+
+**Korrelation MCP-Call ↔ Gateway-Call:** über `pid` + `seq`-Adjazenz. Node ist single-threaded und die
+Handler awaiten sequenziell (`handleMessage` → `handleToolCall` → `client.verify`), daher folgt der
+`gateway_call_start` eines Turns **unmittelbar** (nächstes `seq`, gleicher `pid`) auf das `mcp_msg`
+mit dem auslösenden `id`. Bewusst **kein** durchgereichter Korrelations-Parameter, um `verify()`
+byte-identisch zu lassen (Wire-Payload unverändert).
+
+---
+
+## 5. Entscheidungs-Matrix (welche Log-Signatur beweist welche Hypothese)
+
+Nach einem Spawn-Lauf + Direkt-Kontroll-Lauf in **derselben** Logdatei (zwei `pid`):
+
+| Beobachtung im Spawn-`pid` | Verdikt |
+|---|---|
+| Turn-`gateway_call_end.outcome = transport_error` mit `errCode` (z. B. `ECONNREFUSED`/`UND_ERR_*`) **schon beim ersten** Call, **und** `proc_start.proxyEnv` nicht leer (Direkt-Lauf leer) | **H2** — geerbtes Proxy-Env bricht den Transport |
+| `proc_start.apiUrl`/`apiKeyLen`/`agentId` weichen vom Direkt-Lauf ab (leer/anders) | **H3** — Env-Sicht ≠ `mcp show` |
+| `proc_start.cwd` ≠ `C:\Projekte\agent-shield` **und** ein Config/`.env`-relativer Read schlägt fehl | **H4** — cwd-Auflösung |
+| Turn-`gateway_call_end.outcome = breaker_open_shortcircuit` **und** ≥1 früherer `gateway_call_end` (gleicher `pid`) = `transport_error`/`timeout` **plus** `breaker closed→open` | **H1** — Breaker durch frühere Fehler getrippt; Ursache ist der **frühere** Fehler (selbst wieder H2/H3) |
+| `mcp_msg`-Reihenfolge zeigt einen Gateway-auslösenden Call **vor** dem Turn (probe/list), der fehlschlug | **H5** — Handshake-/Reihenfolge-Race |
+| Direkt-`pid`: dieselbe `tools/call`-Probe → `gateway_call_end.outcome = http_ok`, `decision = MODIFY/ANONYMIZED` | Kontroll-Beleg: Code/Mapping/Netz ok (deckt sich mit Vorbefund) |
+
+**Erwartung (Plausibilität, nicht Beweis):** H2/H3 am wahrscheinlichsten (erster Call schon
+`transport_error`, kein `[agent-shield]` im Gateway-Log), H1 nachrangig (Schwelle 5). Der Log
+entscheidet.
+
+---
+
+## 6. Beobachtungs-Protokoll (Betreiber = Verifier, nach Commit)
+
+Ziel: **eine** Datei mit Spawn-Lauf UND Direkt-Kontroll-Lauf.
+
+**(a) Spawn-Kontext** — `AGENT_SHIELD_DEBUG_LOG_PATH` in die OpenClaw-MCP-Registrierung aufnehmen:
+```powershell
+openclaw mcp remove agent-shield
+openclaw mcp add agent-shield --command node --arg C:/Projekte/agent-shield/bin/agent-shield-mcp.mjs `
+ --env PALVERON_API_URL= --env PALVERON_API_KEY= --env AGENT_SHIELD_AGENT_ID= `
+ --env AGENT_SHIELD_DEBUG_LOG_PATH=C:/Projekte/agent-shield/.debug/spawn.jsonl
+openclaw mcp show agent-shield # Debug-Env bestätigen
+openclaw chat # einen governance_check-Turn auslösen (send_email/Newsletter)
+```
+
+**(b) Direkter Kontroll-Lauf** — gleiche Env, **gleiches** Logfile, frischer Prozess:
+```powershell
+$env:PALVERON_API_URL=""; $env:PALVERON_API_KEY=""; $env:AGENT_SHIELD_AGENT_ID=""; $env:AGENT_SHIELD_DEBUG_LOG_PATH="C:/Projekte/agent-shield/.debug/spawn.jsonl"
+echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"governance_check","arguments":{"tool_name":"send_email","input":"Newsletter-Entwurf an test@example.com"}}}' | node C:/Projekte/agent-shield/bin/agent-shield-mcp.mjs
+```
+
+**(c) Lesen:** `Get-Content C:/Projekte/agent-shield/.debug/spawn.jsonl`
+(`.debug/` ist gitignored — nie committen.)
+
+---
+
+## 7. Was der Log eindeutig beantworten muss (→ Basis fürs Fix-Goal)
+
+1. **proc_start:** Sieht der Spawn `apiUrl`/`apiKeyLen`/`agentId` korrekt? (H3) Gibt es
+ `proxyEnv`/`nodeOptions`, die der Direkt-Lauf nicht hat? (H2/H4)
+2. **seq/mcp_msg:** Gab es **vor** dem fehlschlagenden `tools/call` einen früheren Gateway-Call? (H1/H5)
+3. **Turn-`gateway_call_end.outcome`:** `breaker_open_shortcircuit` (H1) vs `transport_error`+`errCode`
+ (H2/H3) vs `timeout`?
+4. **`breaker`-Events:** Hat ein früherer Call den Breaker `closed→open` getrippt? Mit welchem `observed`?
+5. **`failclosed_emit.branch`:** Aus welchem outcome kam `gateway_unavailable_failclosed`?
+6. **Direkt vs. Spawn (zwei `pid`):** Worin unterscheiden sie sich konkret?
+
+Der **Fix** (eigenes Folge-Goal) folgt aus diesen Fakten — z. B. Proxy-Bypass für den Gateway-Host,
+Env-Propagation-Fix, Breaker-Reset/Half-Open-Strategie oder Handshake-Ordering — **nicht vorher**.
+
+---
+
+## 8. Akzeptanzgates (erfüllt)
+
+- [x] `AGENT_SHIELD_DEBUG_LOG_PATH` ungesetzt → exakt voriges Verhalten, kein File-IO (Test
+ „no-op when unset"; 31 bestehende Tests unverändert grün).
+- [x] `dlog` wirft nie (Test „unwritable path → SURVIVED"; internes try/catch).
+- [x] Keine Decision-/Breaker-/Retry-/Timeout-Logik verändert — Diff ist rein additiv (`dlog`-Aufrufe +
+ neues Modul + Diagnose-Helfer; `transportFallback`/`isFailLoud`/Mapping unberührt).
+- [x] Kein Secret im Log: Key nur `apiKeyPresent`/`apiKeyLen`; **keine** `arguments`-Werte (nur
+ `argKeys`/`argBytes`); Proxy-Werte auf `host:port` sanitisiert (Test deckt alle drei ab).
+- [x] Zero neue Dependency — nur `node:fs`/`node:os`/`node:path`.
+- [x] `node --test` grün: **37/37** (31 bestehend + 6 neue debug-log-Tests).
+- [x] Emissions-Stellen mit Datei:Zeile dokumentiert (§2/§4).
+
+---
+
+## 9. Nebenfunde (read-only, nicht gefixt)
+
+- **`attempt` ist immer 0** aus agent-shield-Sicht: die SDK macht ihre Retries **intern**
+ (`index.js` retry-Schleife) — agent-shield sieht nur das Endergebnis/den End-Fehler. Der Log
+ vermerkt das bewusst (`attempt:0`), damit niemand fälschlich „nur 1 Versuch" liest.
+- **MCP-Client `maxRetries: 1`** (`mcp-server.mjs:38`) + SDK-Default-Retries: bei wiederholtem
+ Transport-Fehler summieren sich `circuit.onFailure()`-Aufrufe → kann über mehrere Turns die
+ Schwelle 5 erreichen (relevant, falls H1 sich doch zeigt).
+- **Bekannt offen (eigene Goals, hier nicht berührt):** `init` schreibt invalides Top-Level
+ `mcpServers` + nicht-publiziertes `npx` (Fund #2); CLI `status` zeigt 9 statt 15
+ (SYSTEM_SHIELD-Filter); `test`-Erwartung „Dangerous→BLOCK" veraltet (Policy=APPROVAL);
+ `pv_live_`/Resend-Key-Rotation vor Launch.
+
+---
+
+## 10. VERTIEFUNG (Diagnose #2) — SDK-Fehlermaske aufbrechen
+
+### 10.1 Live-Log-Stand (bewiesen, zwei `pid`, identische Config)
+- **Spawn (OpenClaw):** `proc_start` korrekt (`apiUrl=https://gateway.palveron.com`, `apiKeyLen:72`,
+ `agentId:palveron-setup`, `proxyEnv:{}`, `nodeOptions:null`, `cwd=…\.openclaw\workspace`) →
+ `gateway_call_end.outcome="transport_error"`, `errCode:"NETWORK_ERROR"`, `~1025 ms` → BLOCK.
+- **Direkt:** identische Config, `cwd=C:\Projekte\agent-shield` → `http_ok`, `200`,
+ `decision:ANONYMIZED`.
+- **Widerlegt:** H1 (Breaker `closed`, 1 Call), H2 (Proxy `{}` beidseitig), H3 (Spawn-Env korrekt),
+ H9 (`SystemRoot`/`WINDIR`/`SystemDrive` ergänzt, BLOCK blieb). **Belegt:** Egress scheitert nur im
+ Spawn-Kontext, ~1 s, deterministisch — Unterschied liegt im OpenClaw-Spawn, nicht in der Config.
+
+### 10.2 Read-only-Befund: die SDK VERWIRFT die Ursache (SDK-Fix-Kandidat)
+`@palveron/sdk/dist/index.mjs:379-387` — im `catch` der Request-Schleife:
+```js
+if (error instanceof TypeError && error.message.includes("fetch")) {
+ this.circuit.onFailure();
+ lastError = new PalveronError("Network error — could not reach gateway",
+ { code: "NETWORK_ERROR", statusCode: 0, requestId, retryable: true });
+ continue; // ← der ursprüngliche `error` (TypeError mit .cause = echter undici-Grund) wird verworfen
+}
+```
+Der echte Grund (`ENOTFOUND`/`ECONNREFUSED`/`UND_ERR_CONNECT_TIMEOUT`/`EPERM`/`CERT_*`) liegt in
+`error.cause` der `TypeError: fetch failed` — die SDK reicht ihn **nicht** als `cause` an den neuen
+`PalveronError` weiter. **Konsequenz:** `causeChain` auf der **SDK**-Exception ist leer (bestätigt die
+Maske); der rohe Grund ist nur **außerhalb** der SDK sichtbar. → **Eigener SDK-Fix-Kandidat fürs
+Fix-Goal:** `new PalveronError(msg, { code:'NETWORK_ERROR', cause: error, … })` (cause durchreichen).
+Hier **nicht** gefixt (read-only, fremde Dependency).
+
+### 10.3 Neue Instrumente (additiv)
+- **`gateway_call_end`** (`client.mjs` verify+health catch) jetzt zusätzlich `errStack` (≤600) +
+ `causeChain` (rekursiv, Tiefe 5, folgt auch `AggregateError.errors`). Auf der SDK-Maske erwartet
+ leer → beweist die Verwerfung.
+- **`net_selftest_dns`** + **`net_selftest_fetch`** (`debug-log.mjs::netSelftest`, gerufen in
+ `bin` **nur bei aktivem Debug-Pfad**): roher `dns.lookup(host)` + roher `fetch(${apiUrl}/health)`
+ (GET, 3 s Timeout) **außerhalb** der SDK → liefert den **un-maskierten** undici-`causeChain`.
+ Der Client nutzt real `${apiUrl}/health` (`client.mjs:332`, SDK `index.mjs:243 request("GET","/health")`).
+ **Dev-Smoke-Beleg:** der rohe Selbsttest fing in der Entwickler-Umgebung
+ `UNABLE_TO_VERIFY_LEAF_SIGNATURE` (TLS) — genau die Art Grund, die die SDK als `NETWORK_ERROR`
+ verbirgt (Umgebungs-spezifisch; der Betreiber-Spawn-vs-Direkt-Vergleich liefert dessen echten Code).
+- **`proc_start`** erweitert (alle secret-frei): `versions{node,undici,openssl}`, `execPath`,
+ `dnsServers` (`dns.getServers()`), `tlsEnv` (`NODE_TLS_REJECT_UNAUTHORIZED`,
+ `NODE_EXTRA_CA_CERTS` nur Präsenz+Pfad, `UNDICI_*`), `osEnv` (Präsenz-Map kritischer OS-Vars +
+ `PATH`-Länge, **keine** Werte).
+- **UTF-8-Fix:** `dlog` schreibt `Buffer.from(line,'utf8')` → Mojibake (`â€"`) behoben, em-dash
+ round-trip per Test belegt (Bytes `e2 80 94`).
+
+### 10.4 Entscheidungs-Matrix #2 (was der nächste Log beweist)
+| Signal im Spawn-`pid` | Verdikt → Fix-Richtung |
+|---|---|
+| `net_selftest_dns.errCode = ENOTFOUND/EAI_AGAIN` | DNS-Resolver-Sicht im Spawn kaputt → DNS/Resolver-Fix |
+| `net_selftest_fetch.causeChain[].code = ECONNREFUSED/ENETUNREACH/EPERM` | Egress-Block/Firewall im Spawn → Proxy-Bypass/Egress-Allow |
+| `…code = UND_ERR_CONNECT_TIMEOUT` | Connect-Timeout (langsamer/geblockter Pfad) → Timeout/Route |
+| `…code = CERT_*`/`UNABLE_TO_VERIFY_LEAF_SIGNATURE` | TLS-Trust im Spawn (fehlendes CA / MITM) → `NODE_EXTRA_CA_CERTS`/CA-Fix |
+| Selbsttest **ok**, aber SDK-`verify` `transport_error` | Divergenz SDK-`fetch`-Optionen vs. roher fetch → SDK/undici-Config |
+| `osEnv.PATH.len:0` o. `USERPROFILE:false` im Spawn (vs. Direkt) | OpenClaw strippt Kind-Env → Env-Propagation-Fix |
+
+### 10.5 Akzeptanzgates #2 (erfüllt)
+- [x] Off-by-default: kein Netz-Selbsttest, kein File-IO ohne `AGENT_SHIELD_DEBUG_LOG_PATH` (Test
+ „netSelftest no-op when disabled"; 37 Vorgänger-Tests unverändert grün).
+- [x] Selbsttest + `dlog` werfen nie (je Schritt eigenes try/catch; `netSelftest(...).catch(()=>{})`).
+- [x] Diff rein additiv — keine Governance-/Decision-Logik berührt.
+- [x] Kein Secret: Key nur Länge; OS-Env nur Präsenz; CA nur Pfad; Proxy sanitisiert; kein arg-Wert.
+- [x] UTF-8 verifiziert (em-dash Bytes `e2 80 94`).
+- [x] Zero neue Dependency (`node:dns` + `node:fs`/`os`/`path` + global `fetch`).
+- [x] `node --test` grün: **41/41** (+4: causeChain-Unwrap, AggregateError+Tiefe-Cap, osEnvPresence,
+ netSelftest-no-op).
+
+---
+
+## 11. FIX (Fund #5) — TLS-Interception nachhaltig behandelt
+
+**Live-Log #2 bewies die Ursache:** roher `net_selftest_fetch` (außerhalb der SDK) im Spawn schlug mit
+`UNABLE_TO_VERIFY_LEAF_SIGNATURE` fehl, direkt = HTTP 200. Ursache = TLS-interceptende Security-Suite
+(Norton 360): ersetzt das Server-Zertifikat durch eines mit eigenem Root-CA, das im **OS-Trust-Store**
+liegt, aber **nicht** in Nodes eingebautem CA-Bundle → Node lehnt die Kette ab. Der direkte Shell-Lauf
+wird nicht inspiziert, der OpenClaw-Kindprozess schon. Verifizierter Hebel: **`node --use-system-ca`**
+(Node ≥ 22) → Node nutzt den OS-Store → TLS ok → Spawn-Turn liefert MODIFY.
+
+### Strang A — SDK `cause` durchgereicht (SDK-Quelle WAR vorhanden)
+`C:\Projekte\sdk-typescript` ist die Quelle von `@palveron/sdk@1.1.0` (single `src/index.ts`, tsup).
+agent-shield konsumiert sie als **lokales tgz** (`file:../sdk-typescript/palveron-sdk-1.1.0.tgz`). →
+**proper Pfad** (kein node_modules-Hack):
+- `PalveronError`-Konstruktor nimmt jetzt `cause?: unknown` und reicht sie an
+ `super(message, { cause })` (ES2022) durch.
+- Wurf-Stelle `src/index.ts` (vorher `:828`): `new PalveronError('Network error…', { code:'NETWORK_ERROR',
+ …, cause: error })` — das ursprüngliche `TypeError: fetch failed` (mit echtem undici-Grund in dessen
+ `.cause`) bleibt erhalten. **Kein** Decision-/Retry-/Breaker-Verhalten geändert.
+- SDK-Test `src/network-cause.test.ts` (vitest): `PalveronError.cause.cause.code` ===
+ `UNABLE_TO_VERIFY_LEAF_SIGNATURE` bzw. `ECONNREFUSED`. **10/10 vitest grün.**
+- `dist`/`tgz` sind in sdk-typescript **gitignored** (Build-Artefakte) → Operator/CI baut neu
+ (`npm run build && npm pack`); agent-shield reinstalliert das tgz, damit der Fix produktiv greift.
+
+### Strang B — agent-shield: TLS-Interception erkennen + ehrliche Meldung (Defense-in-Depth)
+- Neuer neutraler `src/error-cause.mjs` (`causeChain` aus `debug-log.mjs` extrahiert — kein toter Code,
+ debug-log re-exportiert; lebt jetzt auch auf dem Governance-Pfad, nicht nur im Debug) +
+ `classifyTransportFailure(err)`: TLS-Trust-Codes (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
+ `SELF_SIGNED_CERT_IN_CHAIN`, `DEPTH_ZERO_*`, `CERT_*`, `ERR_TLS_*`) → `{reason:'gateway_tls_untrusted',
+ hint}`; echte Netzfehler → `{reason:null}`.
+- `client.mjs` (catch-Pfad): inspiziert die causeChain; bei TLS bleibt **Fail-Closed BLOCK**, aber
+ `reason='gateway_tls_untrusted'` + umsetzbarer `hint` (--use-system-ca / NODE_EXTRA_CA_CERTS) statt
+ des opaken `gateway_unavailable_failclosed`. `ECONNREFUSED`/`ENOTFOUND`/Timeout behalten den
+ generischen Reason (kein Fehl-Reframe).
+- `fail-policy.mjs::transportFallback`: additive `opts.reason`-Override (nur Fail-Closed-Zweig) +
+ `opts.hint`. `mcp-server.mjs`: `hint` bis in die MCP-Antwort durchgereicht (im OpenClaw-Chat sichtbar).
+- Tests `test/tls-interception.test.mjs` (4): TLS-Code→reason+hint, Nicht-TLS→null, verify HIGH TLS→BLOCK
+ +gateway_tls_untrusted+hint, verify HIGH ECONNREFUSED→gateway_unavailable_failclosed (kein hint).
+- **Greift produktiv, sobald die fixierte SDK (Strang A) installiert ist** (cause vorhanden); mit
+ alter SDK degradiert es sauber (leere causeChain → generischer Reason, keine Fehlklassifikation).
+
+### Strang C — REDUZIERT AUF DOKU-ONLY (args-Fix gehört zu Fund #2)
+`init`/`updateOpenClawConfig` (`openclaw-config.mjs:45-55`) schreibt heute `command:"npx",
+args:["-y","-p","@palveron/agent-shield","agent-shield-mcp"]` (Fund-#2-Pfad: nicht-publiziertes npx +
+Top-Level-`mcpServers`). `--use-system-ca` ist ein **Node**-Flag und lässt sich nicht sauber an die
+**npx**-Invocation hängen — es braucht die `command:"node", args:["--use-system-ca", ""]`-Form,
+also genau die npx→node-Migration, die **Fund #2** besitzt. Pro Goal-Vorgabe daher **kein** Eingriff in
+den kaputten init-Pfad (kein Scope-Mixing); der args-Fix wandert ins Fund-#2-Goal. Geliefert: **nur
+Doku** (README „Antivirus/firewall HTTPS inspection" + `openclaw.mdx` EN+DE „Troubleshooting/
+Fehlerbehebung") mit `--use-system-ca` + `NODE_EXTRA_CA_CERTS`-Fallback. **Tabu eingehalten:** kein
+`NODE_TLS_REJECT_UNAUTHORIZED=0` irgendwo.
+
+### Gates #3 (erfüllt)
+- [x] agent-shield `node --test` **45/45** (+4 TLS); SDK `vitest` **10/10** (+2 cause).
+- [x] Fail-Closed bei HIGH unverändert; Diff additiv; kein Governance-Pfad-Verhalten geändert.
+- [x] Diagnose intakt (off-by-default); `causeChain` extrahiert → von Diagnose **und** Live-Pfad genutzt
+ (kein toter Code).
+- [x] Kein Secret in Logs/Doku; zero neue Runtime-Dependency.
+- [x] Kein node_modules/dist-Hack; SDK in der **Quelle** gefixt; `package.json` der agent-shield
+ unverändert (`^1.1.0`).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6180287
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Palveron A. Podzus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 61aa506..abb4325 100644
--- a/README.md
+++ b/README.md
@@ -12,14 +12,14 @@
-
+
Quick Start ·
Protection ·
MCP Server ·
- Circuit Breaker ·
+ Fail Policy ·
Architecture
@@ -29,7 +29,19 @@
Your agent runs 24/7. Do you know what it's doing right now?
-agent-shield shows you everything your OpenClaw agent does, blocks dangerous actions before they execute, and masks your personal data. **One command. Zero config.**
+agent-shield gives your OpenClaw agent a governance check it calls **before** every
+high-risk action, records every check, and masks personal data on the way out.
+**One command. Zero config.**
+
+> **How enforcement works (the honest version).** agent-shield instructs your agent
+> (via its skill + an MCP `governance_check` tool) to check high-risk actions
+> *before* running them, and records every check for your dashboard. It is
+> **advisory in-path today**: the agent decides to call the check. Hard,
+> unbypassable in-path enforcement via an OpenClaw `before_tool_call` hook is on
+> the roadmap. What is already hard today: when a check *is* called and returns
+> `BLOCK`, the skill tells the agent not to execute — and if our gateway is
+> unreachable during a **high-risk** action, agent-shield fails **closed**
+> (see [Resilience & Fail Policy](#resilience--fail-policy)).
---
@@ -42,19 +54,22 @@ npm install -g @palveron/agent-shield
# 2. Set your keys
export PALVERON_API_KEY="your-key" # from dashboard signup
export PALVERON_API_URL="your-api-url" # API endpoint
-export OPENAI_API_KEY="sk-..." # your own LLM key (BYOM)
# 3. Initialize
npx agent-shield init
```
-That's it. 8 protection rules are now active. Restart your OpenClaw agent.
+That's it. Your OpenClaw Shield rule set is now active. Restart your OpenClaw agent.
+
+`init` prints the exact number of rules it activated for your project (it does not
+assume a fixed count). Run `agent-shield status` to see them live.
---
## What It Protects
-`agent-shield init` activates 8 guardrails automatically — no configuration needed:
+`agent-shield init` activates the OpenClaw Shield rule set automatically — no
+configuration needed:
| Rule | Detects | Action |
|------|---------|--------|
@@ -83,67 +98,171 @@ That's the moment you understand what your agent actually does — not because w
## BYOM — Bring Your Own Model
-Every OpenClaw user already has an LLM API key. agent-shield's 2-pass system (Regex + AI) uses **your** key for the AI pass. Our LLM cost: zero. Your governance cost: zero on the free tier.
+agent-shield's analysis runs **server-side**. Deterministic guardrails (PII, secrets,
+shell/destructive patterns) need no LLM at all. For the optional AI analysis pass, the
+gateway uses **your** model key — but you configure it **in the dashboard**
+(**Settings → Neural Gateway**), where it is stored encrypted and used per project.
+
+> agent-shield does **not** read or forward an LLM API key from your shell. There is
+> no `OPENAI_API_KEY` plumbing in the client — the gateway never received it. Set your
+> BYOM key once in the dashboard and it applies to every check.
---
## MCP Server
-agent-shield includes an MCP (Model Context Protocol) server for integration with coding tools like Cursor and Claude Code:
-
-```bash
-# Start as MCP server
-npx agent-shield-mcp
+agent-shield ships an MCP (Model Context Protocol) server exposing a single
+`governance_check` tool that your agent calls before high-risk operations.
+
+`init` wires this into your `openclaw.json` automatically. To configure it manually
+(OpenClaw, Cursor, Claude Code), use this exact invocation — `agent-shield-mcp` is a
+**bin inside `@palveron/agent-shield`**, not a standalone package:
+
+```json
+{
+ "mcpServers": {
+ "agent-shield": {
+ "command": "npx",
+ "args": ["-y", "-p", "@palveron/agent-shield", "agent-shield-mcp"],
+ "env": {
+ "PALVERON_API_URL": "your-api-url",
+ "PALVERON_API_KEY": "your-key"
+ }
+ }
+ }
+}
```
-The MCP server exposes a `governance_check` tool that your coding agent calls before executing high-risk operations. Configure it in your `.cursor/mcp.json` or Claude Code settings.
+### Which package do I need?
+
+| You want… | Use | Tool(s) |
+|-----------|-----|---------|
+| OpenClaw zero-config governance + CLI setup | **`@palveron/agent-shield`** (this package) | `governance_check` |
+| A generic MCP server for Cursor / Claude Code | [`@palveron/mcp-server`](https://www.npmjs.com/package/@palveron/mcp-server) | `palveron_verify`, `palveron_check_tool_call`, `palveron_list_policies` |
+
+Both talk to the same Palveron Gateway. `agent-shield` is the OpenClaw-focused,
+zero-config path; `@palveron/mcp-server` is the general-purpose coding-assistant path.
---
-## Circuit Breaker
+## Resilience & Fail Policy
+
+agent-shield is built on [`@palveron/sdk`](https://www.npmjs.com/package/@palveron/sdk),
+which provides request retries and a circuit breaker. On top of that, agent-shield
+applies a **tiered fail policy** so an outage on our side never silently disables your
+governance:
-agent-shield implements a 3-state circuit breaker to ensure your agent **never stops** because of a governance outage:
+| Situation | Behavior |
+|-----------|----------|
+| Gateway returns a verdict | The real decision is used (`ALLOW` / `BLOCK` / `MODIFY` / `APPROVAL`) |
+| **Contract / auth error** (bad request, invalid key) | **Fail LOUD** — the error is surfaced; **never** a silent `ALLOW` |
+| Gateway unreachable, **HIGH-risk** action (shell, exec, delete, `git_push`, destructive, secret-exfil) | **Fail CLOSED** — `BLOCK`. We do not let a dangerous action run unchecked during our downtime |
+| Gateway unreachable, MEDIUM-risk action (incl. any unknown tool) | **Fail OPEN** — `ALLOW`, so a transient outage never blocks ordinary work |
-| State | Behavior |
-|-------|----------|
-| **Closed** | Normal operation — every call goes to the Palveron API |
-| **Open** | After 3 consecutive failures — returns `ALLOW` immediately, agent keeps running |
-| **Half-Open** | After 30s — sends one probe request. Success → Closed. Failure → Open again |
+Set `AGENT_SHIELD_FAIL_CLOSED=true` to fail closed for **all** risk levels when the
+gateway is unreachable (maximum safety; availability traded away).
-**Fail-open by design.** If the Palveron gateway is unreachable, your agent continues with `{ decision: "ALLOW", reason: "circuit_open" }`. We never block your agent because of our downtime.
+> This is a deliberate change from a blanket "always fail open" stance. For the
+> dangerous class of actions, security beats availability: if we can't check it, we
+> don't run it.
---
## CLI Commands
```bash
-agent-shield init # Initialize shield, activate 8 rules, register agent
-agent-shield status # Show connection status, active rules, circuit state
-agent-shield test # Send a test prompt through the governance pipeline
+agent-shield init # Initialize shield, activate rules, register agent
+agent-shield status # Show connection status, active rules, 24h stats
+agent-shield test # Send test prompts through the governance pipeline
agent-shield --help # Show all commands
```
---
+## Troubleshooting — spawn diagnostics
+
+When agent-shield runs **as a spawned MCP subprocess** (OpenClaw, Cursor, Claude
+Code, …), the host swallows its stderr, so a normal log is invisible. For cases
+where a governed call behaves differently under the host than when invoked
+directly, set `AGENT_SHIELD_DEBUG_LOG_PATH` to a file path and agent-shield
+appends an ordered, append-only JSONL event log (process start + env snapshot,
+each MCP message, every gateway call with its outcome, circuit-breaker
+transitions, and the fail-closed branch):
+
+```bash
+AGENT_SHIELD_DEBUG_LOG_PATH=./.debug/spawn.jsonl node bin/agent-shield-mcp.mjs
+```
+
+- **Off by default** — unset means zero file IO and no behavioral difference.
+- **Never affects governance** — diagnostics can't throw; on any IO error they
+ go silent. Correctness beats diagnosis.
+- **Secret-safe** — the API key appears only as length/presence, tool-call
+ arguments are never logged (only their key names + byte size), and proxy URLs
+ are reduced to `host:port`.
+
+Add the same env var to the host's MCP registration to capture a real spawn run,
+then compare it against a direct run pointed at the same log file. The `.debug/`
+folder is gitignored.
+
+### Antivirus / firewall HTTPS inspection (TLS-untrusted gateway)
+
+**Symptom:** every `governance_check` returns `BLOCK` with reason
+`gateway_tls_untrusted` (or, with an older SDK, an opaque
+`gateway_unavailable_failclosed`) — but **only** when agent-shield runs as a
+spawned subprocess (e.g. inside OpenClaw), while a direct run works.
+
+**Cause:** a TLS-intercepting security suite (Norton, McAfee, Zscaler, corporate
+proxies, …) re-signs HTTPS traffic with its **own root CA**. That CA lives in the
+Windows/macOS system trust store but **not** in Node's bundled CA set, so Node
+rejects the certificate (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). The direct shell run
+is often not inspected; the spawned child is.
+
+**Fix — trust the OS certificate store (Node ≥ 22):**
+
+```bash
+node --use-system-ca bin/agent-shield-mcp.mjs
+```
+
+In an MCP host registration, use `command: node` with `--use-system-ca` as the
+first argument before the script path.
+
+**Node < 22 fallback:** point Node at the inspecting CA explicitly:
+
+```bash
+NODE_EXTRA_CA_CERTS=/path/to/your-av-or-proxy-root-ca.pem node bin/agent-shield-mcp.mjs
+```
+
+> **Never** set `NODE_TLS_REJECT_UNAUTHORIZED=0`. That disables certificate
+> verification entirely and has no place in a security product. `--use-system-ca`
+> trusts the legitimate OS-store CA **without** weakening verification.
+
+---
+
## Architecture
agent-shield is a **thin client**. It contains:
-- HTTP client with retry logic and circuit breaker
+- A small facade over [`@palveron/sdk`](https://www.npmjs.com/package/@palveron/sdk)
+ (which owns the verify contract, retries, and circuit breaker)
+- The tiered fail policy and OpenClaw Shield setup/status calls
- CLI for initialization and status checks
-- MCP server entry point for coding tool integration
+- MCP server entry point for agent / coding-tool integration
- Local tool-risk classification (trivial mapping, no IP)
-**What it does NOT contain:** No PII patterns, no policy evaluation engine, no guardrail logic. All intelligence lives server-side in the [Palveron Gateway](https://github.com/palveron/gateway). This protects our IP and keeps the client small and dependency-free.
+**What it does NOT contain:** No PII patterns, no policy evaluation engine, no guardrail
+logic. All intelligence lives server-side in the
+[Palveron Gateway](https://github.com/palveron/gateway). This keeps the client small and
+its single dependency (`@palveron/sdk`) is the one source of truth for the API contract —
+no second client implementation to drift from the server.
```
-Your Agent ──→ agent-shield (HTTP client) ──→ Palveron Gateway
- │ │
- │ Circuit Breaker │ 8 Guardrails
- │ Fail-Open on timeout │ PII Detection
- │ │ Blockchain Proof
- ▼ ▼
- Agent continues Trace in Dashboard
+Your Agent ──→ agent-shield ──→ @palveron/sdk ──→ Palveron Gateway
+ │ │
+ │ Tiered fail policy │ Guardrails
+ │ (HIGH → fail-closed) │ PII Detection
+ │ │ Blockchain Proof
+ ▼ ▼
+ Agent decision Trace in Dashboard
```
---
@@ -154,9 +273,40 @@ Your Agent ──→ agent-shield (HTTP client) ──→ Palveron Gateway
|----------|:--------:|-------------|
| `PALVERON_API_KEY` | ✅ | Your project API key (from dashboard) |
| `PALVERON_API_URL` | ✅ | Gateway API endpoint |
-| `OPENAI_API_KEY` | — | Your LLM key for AI-pass (BYOM) |
+| `AGENT_SHIELD_FAIL_CLOSED` | — | `true` forces fail-closed for all risk levels when the gateway is unreachable. Default: tiered |
Legacy fallback: `AGENT_SHIELD_API_KEY` / `AGENT_SHIELD_API_URL` are also accepted.
+BYOM model keys are configured in the dashboard (**Settings → Neural Gateway**), not here.
+
+---
+
+## Live smoke (`scripts/smoke-live.mjs`)
+
+An end-to-end smoke that runs the eight launch checks against a real gateway. It
+is **not** shipped in the npm package.
+
+> ⚠️ **Run it only against a dedicated, disposable project.** Check #8 (`init` /
+> `setupShield`) **writes policies and an agent** into the project. There is no
+> test or sandbox key today — every Palveron key is a live `pv_live_` key, so
+> isolation comes from targeting a **separate throwaway project**, not from a key
+> prefix.
+
+Setup:
+
+1. Create a dedicated throwaway project in the dashboard (e.g. named
+ `agent-shield-smoke-throwaway`).
+2. `cp .env.smoke.example .env.smoke` and fill in that project's `pv_live_` key
+ and name. `.env.smoke` is gitignored.
+3. Run it:
+
+ ```bash
+ node --env-file=.env.smoke scripts/smoke-live.mjs
+ ```
+
+The script refuses to run unless both `PALVERON_API_KEY` and
+`PALVERON_SMOKE_PROJECT` are set — naming the throwaway project is the conscious
+confirmation that you are pointing at a disposable target. Exit code `0` means
+all hard checks passed.
---
@@ -184,4 +334,5 @@ Legacy fallback: `AGENT_SHIELD_API_KEY` / `AGENT_SHIELD_API_URL` are also accept
## License
-Proprietary — © 2026 Palveron A. Podzus. All rights reserved.
+MIT — © 2026 Palveron A. Podzus. This thin client is open source. The governance
+engine (the Palveron Gateway) is proprietary.
diff --git a/SKILL.md b/SKILL.md
index a8e4ca3..aba2ec4 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -2,20 +2,27 @@
**Your agent runs 24/7. Do you know what it's doing right now?**
-agent-shield shows you everything your OpenClaw agent does, blocks dangerous actions before they execute, and masks your personal data. One command. Zero config.
+agent-shield records everything your OpenClaw agent does, and asks you — the agent — to check high-risk actions **before** running them so dangerous ones can be stopped. It masks personal data on the way out. One command. Zero config.
+
+**Your part of the contract:** call `governance_check` before every high-risk tool call (the list below) and honor the decision. This is how "stop it before it runs" actually works — the check only protects an action you route through it first.
## What it does
-Before executing any HIGH-RISK tool call, check with the governance API. The API returns:
+Before executing any HIGH-RISK tool call, call `governance_check`. It returns one of:
- **ALLOW** — proceed normally
- **BLOCK** — do NOT execute. Tell the user why.
-- **MODIFY** — use the sanitized version instead
+- **MODIFY** — use the sanitized version instead. This also covers masked or pseudonymized content (PII anonymized, credentials redacted): the action proceeds, but you MUST use `modified_input` rather than your original input.
+- **APPROVAL** — a human must approve first. Do NOT execute; tell the user approval is required.
+- **ERROR** — the check could not complete (misconfig / contract error). Do NOT silently proceed on HIGH-RISK; warn the user.
## When to check
-ALWAYS call `governance_check` before these tool categories:
+Call `governance_check` before any tool call. Every checked call is verified
+**and** recorded as a trace — there is no "skip" tier. The risk level only
+changes what happens if the gateway is unreachable (see below); it never skips
+the check.
-### 🔴 Must check (HIGH-RISK)
+### 🔴 HIGH-RISK — verified; **fail-closed** (BLOCK) if the gateway is unreachable
- `exec`, `shell`, `bash`, `run_command` — any shell execution
- `delete_file`, `remove_file`, `write_file` — destructive file operations
- `git_push`, `git_push_force`, `git_reset_hard` — irreversible git operations
@@ -24,15 +31,13 @@ ALWAYS call `governance_check` before these tool categories:
- `send_email`, `send_message`, `post_tweet` — outbound communications
- `transfer`, `payment`, `purchase` — financial operations
-### 🟡 Should check (MEDIUM-RISK)
-- `read_file`, `search_files` — file reads (may contain secrets)
+### 🟡 MEDIUM-RISK (incl. any unknown tool) — verified; **fail-open** (ALLOW) if the gateway is unreachable
+- `read_file`, `search_files`, `list_directory` — file reads (may contain secrets)
- `memory_write`, `save_context` — persistent storage
- `navigate`, `fill_form` — browser automation
+- any tool not in the HIGH list — treated as MEDIUM and still verified
-### 🟢 Skip (LOW-RISK)
-- `read_file` on non-sensitive paths
-- `list_directory`
-- Conversation responses (no tool call)
+The only thing you do **not** check is a pure conversational response (no tool call).
## How to check
@@ -53,13 +58,24 @@ If decision == "MODIFY":
- Use modified_input instead of original input
- Tell the user: "🔄 Input was sanitized by Shield"
+If decision == "APPROVAL":
+ - Do NOT execute yet — a human must approve
+ - Tell the user: "⏸ Awaiting approval (Shield): {reason}"
+
+If decision == "ERROR":
+ - The governance check failed to complete (e.g. config/contract error)
+ - Do NOT silently proceed on a HIGH-RISK action
+ - Tell the user: "⚠️ Governance check failed: {error}" and ask how to proceed
+
If decision == "ALLOW":
- Proceed normally
-If decision has _fallback == true:
- - Governance API was unreachable
- - Proceed with caution for MEDIUM-RISK
- - For HIGH-RISK: warn the user that governance is offline
+If decision has _fallback == true (the gateway was unreachable):
+ - HIGH-RISK actions come back as BLOCK (fail-closed) — reason
+ "gateway_unavailable_failclosed". Do NOT execute; tell the user governance
+ is offline and the action was blocked for safety.
+ - MEDIUM/LOW-RISK actions come back as ALLOW (fail-open) — proceed, but note
+ governance was briefly offline.
```
## Setup
@@ -71,15 +87,19 @@ npm install -g @palveron/agent-shield
# Set environment variables
export PALVERON_API_KEY="your-api-key" # From dashboard
export PALVERON_API_URL="your-api-url" # API endpoint
-export OPENAI_API_KEY="sk-..." # Your LLM key (BYOM)
# Initialize
npx agent-shield init
```
+> BYOM (Bring Your Own Model): configure your LLM key in the dashboard
+> (Settings → Neural Gateway). It is used server-side — agent-shield does not
+> read or forward an LLM key from the environment.
+
## What's protected
-After initialization, 8 rules automatically protect your agent:
+After initialization, the OpenClaw Shield rule set protects your agent (`init`
+reports the exact number activated for your project):
1. **Secret-Exfiltration-Shield** — Blocks API keys, private keys, JWTs in output
2. **Shell-Injection-Guard** — Blocks curl|bash, chmod 777, eval()
diff --git a/agent-shield.zip b/agent-shield.zip
deleted file mode 100644
index 7a2cd91..0000000
Binary files a/agent-shield.zip and /dev/null differ
diff --git a/bin/agent-shield-mcp.mjs b/bin/agent-shield-mcp.mjs
index 33ce2c1..79cf0ab 100644
--- a/bin/agent-shield-mcp.mjs
+++ b/bin/agent-shield-mcp.mjs
@@ -4,8 +4,62 @@
// This is referenced in openclaw.json: "command": "npx", "args": ["-y", "agent-shield-mcp"]
import { startMcpServer } from '../src/mcp-server.mjs';
+import {
+ dlog,
+ keyMeta,
+ collectProxyEnv,
+ osEnvPresence,
+ tlsEnvSnapshot,
+ dnsServersSafe,
+ netSelftest,
+} from '../src/debug-log.mjs';
+
+// proc_start — emitted as early as possible so the log captures exactly what the
+// SPAWNED process sees (vs. what `openclaw mcp show` claims). Decides H2 (inherited
+// proxy/NODE_OPTIONS), H3 (env divergence), H4 (cwd), plus the spawn NETWORK
+// context (DNS resolvers, TLS env, undici version, whether OpenClaw stripped the
+// OS env). Reads the same env aliases the client reads (mcp-server.mjs: PALVERON_*
+// preferred, AGENT_SHIELD_* fallback). Secret-safe: key only as presence + length;
+// OS-env values are NEVER logged (presence only); CA cert as path only.
+const apiUrl = process.env.PALVERON_API_URL || process.env.AGENT_SHIELD_API_URL || null;
+{
+ const apiKey = process.env.PALVERON_API_KEY || process.env.AGENT_SHIELD_API_KEY || '';
+ const km = keyMeta(apiKey);
+ dlog('proc_start', {
+ cwd: process.cwd(),
+ argv: process.argv,
+ execPath: process.execPath,
+ nodeVersion: process.version,
+ versions: {
+ node: process.versions.node,
+ undici: process.versions.undici ?? null,
+ openssl: process.versions.openssl ?? null,
+ },
+ apiUrl,
+ apiUrlEnvSeen: {
+ PALVERON_API_URL: process.env.PALVERON_API_URL ?? null,
+ AGENT_SHIELD_API_URL: process.env.AGENT_SHIELD_API_URL ?? null,
+ },
+ apiKeyPresent: km.present,
+ apiKeyLen: km.len,
+ agentId: process.env.AGENT_SHIELD_AGENT_ID ?? null, // not a secret
+ proxyEnv: collectProxyEnv(),
+ tlsEnv: tlsEnvSnapshot(),
+ dnsServers: dnsServersSafe(),
+ osEnv: osEnvPresence(),
+ nodeOptions: process.env.NODE_OPTIONS ?? null,
+ failClosedOverride: process.env.AGENT_SHIELD_FAIL_CLOSED ?? null,
+ });
+}
+
+// Active connectivity self-test — no-op unless diagnostics are enabled. Runs the
+// RAW fetch outside the SDK so the un-masked undici cause chain is captured.
+// Fire-and-forget: a pending fetch keeps the event loop alive long enough for the
+// events to be written even on the short-lived direct-control run; never throws.
+netSelftest(apiUrl).catch(() => {});
startMcpServer().catch((err) => {
+ dlog('proc_fatal', { errName: err?.name, errMessage: String(err?.message ?? err).slice(0, 300) });
process.stderr.write(`[agent-shield-mcp] Fatal: ${err.message}\n`);
process.exit(1);
});
diff --git a/bin/agent-shield.mjs b/bin/agent-shield.mjs
index d4b2a02..28320d1 100644
--- a/bin/agent-shield.mjs
+++ b/bin/agent-shield.mjs
@@ -2,14 +2,13 @@
// bin/agent-shield.mjs
// CLI for agent-shield: init, status, test
// Usage:
-// npx agent-shield init — Setup Shield (8 rules, register agent)
+// npx agent-shield init — Setup Shield (activate rules, register agent)
// npx agent-shield status — Show Shield status + 24h stats
// npx agent-shield test — Run a test governance check
// npx agent-shield help — Show usage
import { ShieldClient } from '../src/client.mjs';
-import { readFile, writeFile, access } from 'fs/promises';
-import { join } from 'path';
+import { updateOpenClawConfig } from '../src/openclaw-config.mjs';
import { hostname } from 'os';
// ─── Config Resolution ──────────────────────────────────────────────
@@ -21,12 +20,11 @@ function resolveConfig() {
const apiKey =
process.env.PALVERON_API_KEY ||
process.env.AGENT_SHIELD_API_KEY;
- const llmApiKey =
- process.env.OPENAI_API_KEY ||
- process.env.ANTHROPIC_API_KEY ||
- process.env.LLM_API_KEY;
- return { apiUrl, apiKey, llmApiKey };
+ // BYOM (Bring Your Own Model) keys are configured in the dashboard
+ // (Settings → Neural Gateway) and used server-side. agent-shield does NOT
+ // read or forward an LLM key.
+ return { apiUrl, apiKey };
}
function createClient(config) {
@@ -43,7 +41,6 @@ function createClient(config) {
return new ShieldClient({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
- llmApiKey: config.llmApiKey,
});
}
@@ -71,14 +68,32 @@ async function cmdInit() {
// 2. Setup Shield
step('Activating Shield protection rules...');
+ let activeTotal = 0;
try {
const result = await client.setupShield({
hostname: hostname(),
});
- ok(`Shield activated: ${result.policies_activated + result.policies_created} protection rules`);
+ // Report the REAL number of active protection rules — never a hardcoded
+ // count. Prefer the gateway's truthful `policies_active` count (stable on
+ // idempotent re-runs); fall back to the legacy created/activated sum for
+ // older gateways that don't return it yet.
+ activeTotal =
+ result.policies_active ??
+ ((result.policies_activated ?? 0) + (result.policies_created ?? 0));
+ ok(`Shield activated: ${activeTotal} protection rule${activeTotal === 1 ? '' : 's'} active`);
if (result.agent_name) {
ok(`Agent "${result.agent_name}" registered`);
}
+ // Goal 2b — bind the REAL agent identity. `setupShield` already returns the
+ // resolved agent_id (Goal 2a: ACTIVE + capabilityModel-seeded). Pass it to
+ // the config writer so the MCP runtime sends it as metadata.agent_id and the
+ // gateway ENFORCES capability instead of falling through to the unevaluated
+ // 'default'. If the gateway omits agent_id, this stays undefined → no env key
+ // → runtime keeps using 'default' (today's behaviour). Graceful.
+ if (result.agent_id) {
+ config.agentId = result.agent_id;
+ ok(`Identity bound: agent ${result.agent_id}`);
+ }
} catch (err) {
fail('Shield setup failed');
error(err.message);
@@ -96,7 +111,7 @@ async function cmdInit() {
log('');
log(` "agent-shield": {`);
log(` "command": "npx",`);
- log(` "args": ["-y", "agent-shield-mcp"],`);
+ log(` "args": ["-y", "-p", "@palveron/agent-shield", "agent-shield-mcp"],`);
log(` "env": {`);
log(` "PALVERON_API_URL": "${config.apiUrl || 'YOUR_API_URL'}",`);
log(` "PALVERON_API_KEY": "${maskKey(config.apiKey)}"`);
@@ -116,17 +131,9 @@ async function cmdInit() {
log('');
log(' ✅ Shield is active. Your agent is protected.');
log('');
- log(' 8 protection rules are now enforcing:');
- log(' • Secret-Exfiltration-Shield → BLOCK leaked keys');
- log(' • Shell-Injection-Guard → BLOCK dangerous commands');
- log(' • Destructive-Actions-Shield → BLOCK rm -rf, DROP TABLE');
- log(' • Package-Install-Watchdog → APPROVAL for installs');
- log(' • Social-Media-Output-Guard → ANONYMIZE PII in posts');
- log(' • GDPR Privacy Shield → ANONYMIZE personal data');
- log(' • Circuit Breaker → BLOCK agent loops');
- log(' • Fiscal Authority Limit → APPROVAL for >€1,000');
+ log(` ${activeTotal} protection rule${activeTotal === 1 ? '' : 's'} now enforcing for this project.`);
log('');
- log(' Run "agent-shield status" to see 24h protection stats.');
+ log(' Run "agent-shield status" to see the active rules and 24h stats.');
log('');
log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
log('');
@@ -212,9 +219,13 @@ async function cmdTest() {
input: tc.input,
});
- const decision = result.decision || 'ALLOW';
+ // Never default a missing decision to ALLOW — a verdict-less response is
+ // an anomaly, not a pass. Surface it as ERROR.
+ const decision = result.decision || 'ERROR';
if (decision === tc.expected) {
ok(`${tc.name}: ${decision} ✓`);
+ } else if (decision === 'ERROR') {
+ warn(`${tc.name}: no decision returned (ERROR), expected ${tc.expected}`);
} else {
warn(`${tc.name}: got ${decision}, expected ${tc.expected}`);
}
@@ -233,7 +244,7 @@ function cmdHelp() {
log('🛡️ Palveron agent-shield — Control Layer for OpenClaw Agents');
log('');
log('Usage:');
- log(' npx agent-shield init Set up Shield (8 rules, register agent)');
+ log(' npx agent-shield init Set up Shield, activate rules, register agent');
log(' npx agent-shield status Show Shield status + 24h stats');
log(' npx agent-shield test Run test governance checks');
log(' npx agent-shield help Show this help');
@@ -241,50 +252,17 @@ function cmdHelp() {
log('Environment Variables:');
log(' PALVERON_API_URL Governance API URL');
log(' PALVERON_API_KEY Your project API key');
- log(' OPENAI_API_KEY Your LLM key (for BYOM 2-pass analysis)');
log('');
- log('See: https://palveron.com/docs/openclaw');
+ log(' AGENT_SHIELD_FAIL_CLOSED Optional. "true" forces fail-closed (BLOCK) for');
+ log(' ALL risk levels if the gateway is unreachable.');
+ log(' Default: tiered (HIGH-risk fails closed,');
+ log(' MEDIUM/LOW fail open).');
+ log('');
+ log(' BYOM (Bring Your Own Model): configure your LLM key in the dashboard');
+ log(' (Settings → Neural Gateway). It is used server-side — not via env here.');
+ log('');
+ log('See: https://docs.palveron.com/en/docs/integrations/openclaw');
log('');
-}
-
-// ─── OpenClaw Config Update ──────────────────────────────────────────
-
-async function updateOpenClawConfig(config) {
- // Look for openclaw.json in current directory and common locations
- const candidates = [
- join(process.cwd(), 'openclaw.json'),
- join(process.cwd(), '.openclaw', 'config.json'),
- ];
-
- for (const configPath of candidates) {
- try {
- await access(configPath);
- const content = await readFile(configPath, 'utf8');
- const ocConfig = JSON.parse(content);
-
- // Add or update MCP server entry
- if (!ocConfig.mcpServers) {
- ocConfig.mcpServers = {};
- }
-
- ocConfig.mcpServers['agent-shield'] = {
- command: 'npx',
- args: ['-y', 'agent-shield-mcp'],
- env: {
- PALVERON_API_URL: config.apiUrl || '',
- PALVERON_API_KEY: config.apiKey || '',
- },
- };
-
- await writeFile(configPath, JSON.stringify(ocConfig, null, 2) + '\n');
- return true;
- } catch {
- // File doesn't exist or can't be read — try next
- continue;
- }
- }
-
- return false;
}
// ─── Output Helpers ──────────────────────────────────────────────────
diff --git a/package.json b/package.json
index 07a885a..418d910 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
- "url": "https://github.com/palveron/agent-shield"
+ "url": "git+https://github.com/palveron/agent-shield.git"
},
"homepage": "https://palveron.com",
"keywords": [
@@ -18,8 +18,8 @@
"mcp"
],
"bin": {
- "agent-shield": "./bin/agent-shield.mjs",
- "agent-shield-mcp": "./bin/agent-shield-mcp.mjs"
+ "agent-shield": "bin/agent-shield.mjs",
+ "agent-shield-mcp": "bin/agent-shield-mcp.mjs"
},
"main": "./src/index.mjs",
"type": "module",
@@ -41,6 +41,10 @@
"scripts": {
"test": "node --test"
},
- "dependencies": {},
- "devDependencies": {}
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@palveron/sdk": "^1.2.0"
+ }
}
diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs
new file mode 100644
index 0000000..40bab3f
--- /dev/null
+++ b/scripts/smoke-live.mjs
@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+// scripts/smoke-live.mjs
+// Live end-to-end smoke for agent-shield against a real Palveron Gateway.
+// NOT shipped in the npm package (not listed in package.json "files").
+//
+// SAFETY: `init` (setupShield) WRITES policies + an agent into the project, so
+// this must only ever run against a DEDICATED, DISPOSABLE project — never a
+// production project. There is no test/sandbox key today: every Palveron key is
+// a live `pv_live_` key, so isolation comes from targeting a separate throwaway
+// project, NOT from a key prefix. The guard below makes that conscious by
+// requiring you to name the throwaway project (PALVERON_SMOKE_PROJECT).
+//
+// Configuration lives in `.env.smoke` (gitignored; copy from
+// `.env.smoke.example`). Run it with Node's native env-file loader — no extra
+// deps, no hidden magic:
+//
+// node --env-file=.env.smoke scripts/smoke-live.mjs
+//
+// Or pass the variables inline (bash):
+// PALVERON_API_KEY=pv_live_xxx PALVERON_SMOKE_PROJECT=my-throwaway \
+// node scripts/smoke-live.mjs
+//
+// Exit code 0 = all hard expectations met. Non-zero = a hard check failed.
+
+import { hostname } from 'node:os';
+import { ShieldClient } from '../src/client.mjs';
+
+const API_URL = process.env.PALVERON_API_URL || process.env.AGENT_SHIELD_API_URL || 'https://gateway.palveron.com';
+const API_KEY = process.env.PALVERON_API_KEY || process.env.AGENT_SHIELD_API_KEY || '';
+const SMOKE_PROJECT = process.env.PALVERON_SMOKE_PROJECT || '';
+
+let hardFailures = 0;
+const line = (s = '') => console.log(s);
+const pass = (s) => line(` ✅ ${s}`);
+const warn = (s) => line(` ⚠️ ${s}`);
+const fail = (s) => { line(` ❌ ${s}`); hardFailures++; };
+
+function check(name, ok, detail, { hard = true } = {}) {
+ const msg = `${name}${detail ? ` — ${detail}` : ''}`;
+ if (ok) pass(msg);
+ else if (hard) fail(msg);
+ else warn(`${msg} (soft — verify against your project's policies)`);
+}
+
+// ── Guards ───────────────────────────────────────────────────────────
+if (!API_KEY) {
+ console.error(
+ 'Missing PALVERON_API_KEY. Set the pv_live_ key of a dedicated throwaway\n' +
+ 'project (see .env.smoke.example).',
+ );
+ process.exit(2);
+}
+
+// No test/sandbox key exists — every Palveron key is a live key. Isolation
+// comes from running against a DEDICATED, DISPOSABLE project, not from a key
+// prefix. Require the runner to name that project so the choice is conscious:
+// this smoke calls init, which WRITES policies + an agent into it.
+if (!SMOKE_PROJECT) {
+ console.error(
+ 'Missing PALVERON_SMOKE_PROJECT. Name the dedicated throwaway project this key\n' +
+ 'belongs to (e.g. "agent-shield-smoke-throwaway"; see .env.smoke.example).\n' +
+ 'This smoke runs init and WRITES policies + an agent — it must target a\n' +
+ 'disposable project, NEVER a production project.',
+ );
+ process.exit(2);
+}
+
+line('');
+line(`🛡️ agent-shield live smoke → ${API_URL} (throwaway project: ${SMOKE_PROJECT})`);
+line('');
+
+const client = new ShieldClient({ apiUrl: API_URL, apiKey: API_KEY, maxRetries: 1 });
+
+// A client pointed at an unreachable address, to exercise the fail policy.
+const downClient = new ShieldClient({ apiUrl: 'http://127.0.0.1:9', apiKey: API_KEY, maxRetries: 0, timeout: 800 });
+
+// A client with a deliberately invalid key, to prove fail-LOUD (no silent ALLOW).
+const badKeyClient = new ShieldClient({ apiUrl: API_URL, apiKey: 'pv_live_definitely_invalid_key', maxRetries: 0 });
+
+try {
+ // 1. Health preflight (proves the /health path, B2) ──────────────────
+ line('1) Health preflight (GET /health)');
+ try {
+ const h = await client.health();
+ check('health reachable', !!h && (h.status === 'healthy' || !!h.version), `status=${h?.status} version=${h?.version}`);
+ } catch (e) {
+ fail(`health threw: ${e?.message}`);
+ }
+ line('');
+
+ // 2. Benign HIGH-risk action → real decision (expect ALLOW/PASSED) ────
+ line('2) Benign action (exec "echo hello") → expect ALLOW');
+ {
+ const r = await client.verify({ agentId: 'smoke', toolName: 'exec', input: 'echo hello world' });
+ check('benign allowed', r.decision === 'ALLOW', `decision=${r.decision} sdk=${r.sdk_decision} reason=${r.reason}`);
+ check('not a fallback', r._fallback === undefined, 'real verdict, not a fail fallback');
+ }
+ line('');
+
+ // 3. Destructive action → expect BLOCK or APPROVAL ────────────────────
+ // The catalog provisions `destructive_action` as REQUIRE_APPROVAL, so a
+ // governed destructive command surfaces as APPROVAL (not BLOCK). Both are a
+ // successful "did not silently allow a destructive action" outcome.
+ line('3) Destructive action (exec "rm -rf /") → expect BLOCK or APPROVAL');
+ {
+ const r = await client.verify({ agentId: 'smoke', toolName: 'exec', input: 'rm -rf /' });
+ check(
+ 'destructive gated',
+ r.decision === 'BLOCK' || r.decision === 'APPROVAL',
+ `decision=${r.decision} sdk=${r.sdk_decision} reason=${r.reason}`,
+ );
+ }
+ line('');
+
+ // 4. Secret in payload → expect BLOCK (soft: depends on policy scope) ─
+ line('4) Fake AWS key (AKIA…) → expect BLOCK');
+ {
+ const r = await client.verify({ agentId: 'smoke', toolName: 'exec', input: 'echo AKIAIOSFODNN7EXAMPLE' });
+ check('secret blocked', r.decision === 'BLOCK', `decision=${r.decision} sdk=${r.sdk_decision} reason=${r.reason}`, { hard: false });
+ }
+ line('');
+
+ // 5. Simulated outage, HIGH-risk → fail-CLOSED BLOCK ─────────────────
+ line('5) Gateway unreachable + HIGH-risk (exec) → expect BLOCK (fail-closed)');
+ {
+ const r = await downClient.verify({ agentId: 'smoke', toolName: 'exec', input: 'rm -rf /' });
+ check('high-risk fails closed', r.decision === 'BLOCK' && r.reason === 'gateway_unavailable_failclosed', `decision=${r.decision} reason=${r.reason}`);
+ }
+ line('');
+
+ // 6. Simulated outage, MEDIUM-risk → fail-OPEN ALLOW ─────────────────
+ line('6) Gateway unreachable + MEDIUM-risk (read_file) → expect ALLOW (fail-open)');
+ {
+ const r = await downClient.verify({ agentId: 'smoke', toolName: 'read_file', input: 'cat config' });
+ check('medium-risk fails open', r.decision === 'ALLOW' && r.reason === 'gateway_unavailable_failopen', `decision=${r.decision} reason=${r.reason}`);
+ }
+ line('');
+
+ // 7. Invalid key → fail-LOUD (throws), never silent ALLOW ────────────
+ line('7) Invalid key + HIGH-risk → expect THROW (fail-loud, no silent ALLOW)');
+ {
+ let threw = false;
+ let result;
+ try {
+ result = await badKeyClient.verify({ agentId: 'smoke', toolName: 'exec', input: 'rm -rf /' });
+ } catch (e) {
+ threw = true;
+ pass(`threw as expected — ${e?.name}: ${e?.message}`);
+ }
+ if (!threw) fail(`did NOT throw — returned decision=${result?.decision} (this would be the old silent-ALLOW bug)`);
+ }
+ line('');
+
+ // 8. init E2E: setup → real count → idempotency → status ─────────────
+ line('8) init E2E (setupShield) → real count, idempotent, status');
+ {
+ const active = (s) => s.policies_active ?? ((s.policies_activated ?? 0) + (s.policies_created ?? 0));
+
+ const s1 = await client.setupShield({ hostname: hostname() });
+ const active1 = active(s1);
+ check('setup succeeded (no 500)', s1.success === true && active1 > 0, `active=${active1} created=${s1.policies_created} agent="${s1.agent_name}"`);
+
+ const s2 = await client.setupShield({ hostname: hostname() });
+ const active2 = active(s2);
+ check('idempotent on re-run', s2.success === true && active2 === active1, `re-run active=${active2} created=${s2.policies_created} (active should match run 1; created ~0)`);
+
+ const st = await client.getShieldStatus();
+ check('status lists active policies', Array.isArray(st.policies) && st.policies.length > 0, `shield_active=${st.shield_active} agents=${st.agent_count} policies=${st.policies?.length}`);
+ line(' active policies:');
+ for (const p of st.policies ?? []) line(` • ${p.name} → ${p.action} (${p.source})`);
+ }
+ line('');
+} catch (e) {
+ fail(`unexpected error: ${e?.stack || e?.message || e}`);
+}
+
+line('────────────────────────────────────────────────────');
+if (hardFailures === 0) {
+ line('✅ Live smoke PASSED (all hard checks).');
+ process.exit(0);
+} else {
+ line(`❌ Live smoke FAILED: ${hardFailures} hard check(s) failed.`);
+ process.exit(1);
+}
diff --git a/src/circuit-breaker.mjs b/src/circuit-breaker.mjs
deleted file mode 100644
index ed42291..0000000
--- a/src/circuit-breaker.mjs
+++ /dev/null
@@ -1,81 +0,0 @@
-// src/circuit-breaker.mjs
-// Client-side fail-open circuit breaker for agent-shield.
-//
-// Three states: CLOSED → OPEN → HALF_OPEN → (CLOSED | OPEN)
-// CLOSED: requests pass through; consecutive failures are counted.
-// OPEN: requests short-circuit and the caller must fail open
-// (return ALLOW). After `resetMs` elapses, the next
-// beforeRequest() probe transitions to HALF_OPEN.
-// HALF_OPEN: exactly one probe request is allowed through. Its
-// outcome decides the next state — success closes the
-// circuit, failure re-opens it immediately.
-//
-// The `clock` constructor option makes the time source injectable so
-// tests don't need fake timers.
-
-export class CircuitBreaker {
- #threshold;
- #resetMs;
- #clock;
- #state;
- #failures;
- #openedAt;
-
- /**
- * @param {object} [options]
- * @param {number} [options.threshold=3] Consecutive failures that open the circuit.
- * @param {number} [options.resetMs=30000] Time the circuit stays OPEN before a probe is allowed.
- * @param {() => number} [options.clock] Monotonic-ish time source (defaults to Date.now).
- */
- constructor({ threshold = 3, resetMs = 30000, clock = Date.now } = {}) {
- this.#threshold = threshold;
- this.#resetMs = resetMs;
- this.#clock = clock;
- this.#state = 'CLOSED';
- this.#failures = 0;
- this.#openedAt = 0;
- }
-
- get state() {
- return this.#state;
- }
-
- /**
- * Ask the breaker whether the next request should be allowed.
- * Side effect: if OPEN and the reset window has elapsed, transitions to HALF_OPEN
- * and returns true (the single probe).
- * @returns {boolean} true → caller proceeds with the real request; false → fail open.
- */
- beforeRequest() {
- if (this.#state === 'OPEN') {
- if (this.#clock() - this.#openedAt >= this.#resetMs) {
- this.#state = 'HALF_OPEN';
- return true;
- }
- return false;
- }
- return true;
- }
-
- recordSuccess() {
- this.#state = 'CLOSED';
- this.#failures = 0;
- }
-
- recordFailure() {
- if (this.#state === 'HALF_OPEN') {
- this.#open();
- return;
- }
- this.#failures++;
- if (this.#failures >= this.#threshold) {
- this.#open();
- }
- }
-
- #open() {
- this.#state = 'OPEN';
- this.#openedAt = this.#clock();
- this.#failures = this.#threshold;
- }
-}
diff --git a/src/client.mjs b/src/client.mjs
index e029129..0a8e4be 100644
--- a/src/client.mjs
+++ b/src/client.mjs
@@ -1,206 +1,462 @@
// src/client.mjs
-// HTTP client for the governance API.
-// This is a thin client — NO PII patterns, NO policy evaluation, NO engine logic.
-// Only HTTP calls to POST /api/v1/verify and setup endpoints.
+// agent-shield HTTP facade over @palveron/sdk.
+//
+// Single Source of Truth for the verify contract (Analysis §1.2 / Block B1):
+// agent-shield no longer hand-builds the /verify request body. @palveron/sdk
+// owns the wire format (`prompt` top-level, `context.tool_name`,
+// `metadata` passthrough), the retry logic, and the circuit breaker. Two
+// implementations of the same contract were the root cause of the
+// launch-blocking silent-ALLOW bug; collapsing them to one removes the drift.
+//
+// agent-shield keeps only the OpenClaw-specific concerns:
+// • the tiered fail policy (Block B3, src/fail-policy.mjs);
+// • the OpenClaw Shield setup/status endpoints the generic SDK does not
+// cover — thin helpers that reuse the SDK auth scheme and THROW on
+// non-2xx (control-plane calls are never silent);
+// • normalization of the SDK's canonical decisions into the
+// ALLOW / BLOCK / MODIFY / APPROVAL vocabulary SKILL.md and the MCP tool
+// speak.
-import { CircuitBreaker } from './circuit-breaker.mjs';
+import { Palveron } from '@palveron/sdk';
+import { classifyRisk } from './risk-classifier.mjs';
+import { normalizeToolName } from './tool-normalization.mjs';
+import { isFailLoud, transportFallback } from './fail-policy.mjs';
+import { dlog, causeChain } from './debug-log.mjs';
+import { classifyTransportFailure } from './error-cause.mjs';
+
+/**
+ * Extract a human-readable detail from a Shield/gateway error body, tolerant of
+ * BOTH error-contract shapes so a thrown Error never contains `[object Object]`:
+ * - NEW (B1a+): `{ error: { code, message, request_id } }` — object.
+ * - LEGACY: `{ error: "" }` — flat string.
+ * - Verdict: `{ reason: "" }` (BLOCKED bodies carry `reason`, not `error`).
+ * Falls back to the raw text when the body has no usable message or is not JSON.
+ * Never throws.
+ * @param {string} text raw response body text
+ * @returns {string}
+ */
+export function parseShieldErrorDetail(text) {
+ try {
+ const parsed = JSON.parse(text);
+ const e = parsed && parsed.error;
+ if (e && typeof e === 'object') return typeof e.message === 'string' ? e.message : text;
+ if (typeof e === 'string') return e;
+ if (parsed && typeof parsed.reason === 'string') return parsed.reason;
+ return text;
+ } catch {
+ return text;
+ }
+}
+
+/**
+ * Classify a thrown SDK error into a diagnostic `outcome` for the spawn log.
+ * Logging-only — does NOT influence the fail policy (which keys off isFailLoud /
+ * the typed errors directly). Distinguishing `breaker_open_shortcircuit` (no
+ * network attempt) from a real `transport_error`/`timeout` is what decides
+ * H1 vs H2/H3 in the Fund-#5 diagnosis.
+ * @param {unknown} err
+ * @returns {'breaker_open_shortcircuit'|'timeout'|'http_error'|'transport_error'}
+ */
+function classifyErrorOutcome(err) {
+ switch (err?.name) {
+ case 'PalveronCircuitOpenError':
+ return 'breaker_open_shortcircuit';
+ case 'PalveronTimeoutError':
+ return 'timeout';
+ case 'PalveronValidationError':
+ case 'PalveronAuthenticationError':
+ return 'http_error';
+ default:
+ return 'transport_error';
+ }
+}
const DEFAULT_TIMEOUT_MS = 5000;
-const MAX_RETRIES = 2;
-const CIRCUIT_BREAKER_THRESHOLD = 3;
-const CIRCUIT_BREAKER_RESET_MS = 30000;
+const SHIELD_REQUEST_TIMEOUT_MS = 10000;
+
+/**
+ * Map a canonical SDK decision (Sprint 87 gateway vocabulary) to the
+ * agent-facing ALLOW / BLOCK / MODIFY / APPROVAL the MCP tool returns.
+ * @param {string} sdkDecision
+ * @returns {'ALLOW'|'BLOCK'|'MODIFY'|'APPROVAL'}
+ */
+/**
+ * The SDK/gateway decisions `verify` explicitly understands. Anything outside
+ * this set is an anomaly (e.g. a future, more restrictive verdict) and is
+ * handled risk-tiered in `verify` — never silently allowed (F5).
+ */
+export const KNOWN_SDK_DECISIONS = new Set([
+ 'PASSED',
+ 'ALLOWED',
+ 'FLAGGED',
+ 'POLICY_CHANGE',
+ 'MODIFIED',
+ 'ANONYMIZED',
+ 'REDACTED',
+ 'BLOCKED',
+ 'PENDING_APPROVAL',
+ 'RATE_LIMITED',
+]);
+
+/**
+ * Decisions where the gateway proceeds but returns a masked/pseudonymized
+ * payload the agent must use instead of its original input. All three map to
+ * agent-facing MODIFY and carry the replacement text in `res.output`:
+ * • MODIFIED — Entity-Gate modification
+ * • ANONYMIZED — PII pseudonymized (reversible, PBEG token vault)
+ * • REDACTED — credential irreversibly removed
+ * The gateway sends exactly ONE of ANONYMIZED/REDACTED (verify_pipeline.rs:391
+ * "credential dominates: REDACTED wins over ANONYMIZED"), so they are distinct
+ * verdicts, not one with a flag.
+ */
+export const MASKING_DECISIONS = new Set(['MODIFIED', 'ANONYMIZED', 'REDACTED']);
+
+/**
+ * Human-readable `reason` fallbacks used ONLY when the gateway supplies none.
+ * A real gateway `reason` always wins — these never overwrite it. They make the
+ * reversible-vs-irreversible compliance nuance legible at the agent/trace level.
+ */
+const MASKING_REASON_DEFAULTS = {
+ ANONYMIZED: 'PII pseudonymized (reversible) — use the masked modified_input',
+ REDACTED: 'Credential removed (irreversible) — use the masked modified_input',
+};
+
+export function normalizeDecision(sdkDecision) {
+ switch (sdkDecision) {
+ case 'BLOCKED':
+ return 'BLOCK';
+ case 'MODIFIED':
+ // ANONYMIZED/REDACTED unify with MODIFIED as agent-facing MODIFY: the agent
+ // proceeds with the masked `modified_input`, identical to the Gateway-Proxy
+ // (proxy.rs:351) and LangChain paths. The compliance nuance lives in
+ // `sdk_decision` + `reason`, not in a fifth agent action.
+ case 'ANONYMIZED':
+ case 'REDACTED':
+ return 'MODIFY';
+ case 'PENDING_APPROVAL':
+ return 'APPROVAL';
+ case 'PASSED':
+ case 'ALLOWED':
+ case 'FLAGGED':
+ case 'POLICY_CHANGE':
+ return 'ALLOW';
+ default:
+ // Only the known success classes above reach ALLOW. Unknown verdicts are
+ // intercepted upstream in `verify` (F5) and never reach this default for
+ // a real call — they are NOT silently allowed.
+ return 'ALLOW';
+ }
+}
export class ShieldClient {
+ #sdk;
#apiUrl;
#apiKey;
- #llmApiKey;
- #timeout;
- #circuitBreaker;
+ #timeoutMs;
/**
* @param {object} options
- * @param {string} options.apiUrl - Base URL of the governance API (e.g. https://api.palveron.com)
- * @param {string} options.apiKey - Project API key (pv_live_xxx or pv_test_xxx)
- * @param {string} [options.llmApiKey] - User's LLM API key for BYOM 2-pass analysis
- * @param {number} [options.timeout] - Request timeout in ms (default: 5000)
- * @param {CircuitBreaker} [options.circuitBreaker] - Inject a pre-built breaker (mainly for tests).
+ * @param {string} options.apiUrl - Base URL of the governance gateway.
+ * @param {string} options.apiKey - Project API key (pv_live_*).
+ * @param {number} [options.timeout] - Per-request timeout in ms (default 5000).
+ * @param {number} [options.maxRetries] - SDK retry attempts (default: SDK default).
+ * @param {import('@palveron/sdk').Palveron} [options.sdk] - Inject a pre-built
+ * SDK client (mainly for tests).
*/
- constructor({ apiUrl, apiKey, llmApiKey, timeout = DEFAULT_TIMEOUT_MS, circuitBreaker } = {}) {
+ constructor({ apiUrl, apiKey, timeout = DEFAULT_TIMEOUT_MS, maxRetries, sdk } = {}) {
if (!apiUrl) throw new Error('apiUrl is required');
if (!apiKey) throw new Error('apiKey is required');
- this.#apiUrl = apiUrl.replace(/\/$/, '');
+ this.#apiUrl = apiUrl.replace(/\/+$/, '');
this.#apiKey = apiKey;
- this.#llmApiKey = llmApiKey || null;
- this.#timeout = timeout;
- this.#circuitBreaker = circuitBreaker || new CircuitBreaker({
- threshold: CIRCUIT_BREAKER_THRESHOLD,
- resetMs: CIRCUIT_BREAKER_RESET_MS,
- });
+ this.#timeoutMs = timeout; // retained for diagnostics only (timeout outcome logging)
+ // Note: there is intentionally NO BYOM LLM-key forwarding. The gateway
+ // reads the BYOM provider key from the project record (Project.openaiKey,
+ // configured in the dashboard → Settings → Neural Gateway). Forwarding a
+ // user LLM key per request was dead weight the gateway never read.
+ this.#sdk =
+ sdk ||
+ new Palveron({
+ apiKey,
+ baseUrl: this.#apiUrl,
+ timeout,
+ ...(maxRetries !== undefined ? { maxRetries } : {}),
+ });
}
- /** @returns {string} Current breaker state — 'CLOSED' | 'OPEN' | 'HALF_OPEN'. */
+ /** Current SDK circuit-breaker state — 'closed' | 'open' | 'half-open'. */
get circuitState() {
- return this.#circuitBreaker.state;
+ return this.#sdk.diagnostics().circuitState;
}
/**
- * Verify a tool call / action before execution.
- * This is the core governance check — every HIGH-RISK tool call goes through here.
- *
- * @param {object} params
- * @param {string} params.agentId - Agent identifier
- * @param {string} params.toolName - Tool being called (e.g. "exec", "delete_file")
- * @param {string} params.input - The input/prompt being sent to the tool
- * @param {string} [params.output] - The output from the tool (for output governance)
- * @param {object} [params.metadata] - Additional context (risk_level, category, etc.)
- * @returns {Promise}
+ * Read the SDK circuit state without ever throwing (diagnostics only).
+ * The breaker is SDK-internal (threshold 5 / cooldown 30s); the only thing
+ * agent-shield can observe is its state via diagnostics() + the
+ * PalveronCircuitOpenError thrown on a short-circuited call.
+ * @returns {string}
*/
- async verify({ agentId, toolName, input, output, metadata = {} }) {
- return this.#request('POST', '/api/v1/verify', {
- agent_id: agentId,
- tool_name: toolName,
- input,
- output: output || null,
- metadata: {
- ...metadata,
- source: 'agent-shield',
- llm_api_key: this.#llmApiKey || undefined,
- },
- });
+ #circuitStateSafe() {
+ try {
+ return this.#sdk.diagnostics().circuitState;
+ } catch {
+ return 'unknown';
+ }
}
/**
- * Initialize Shield — activates 8 protection rules for the project.
- * Idempotent — safe to call multiple times.
- *
- * @param {object} params
- * @param {string} params.hostname - Machine hostname for default agent registration
- * @returns {Promise}
+ * Emit a `breaker` event when the observed SDK circuit state changed across a
+ * call (e.g. closed→open when this call tripped it, or open→half-open after
+ * cooldown). `observed` carries the call result for context. Returns the new
+ * state. Diagnostics only — does not touch breaker behaviour.
+ * @param {string} before
+ * @param {string} observed
+ * @returns {string}
*/
- async setupShield({ hostname }) {
- return this.#request('POST', '/api/v1/setup/openclaw-shield', {
- hostname,
- llm_provider: this.#llmApiKey ? 'configured' : null,
- });
+ #emitBreakerDelta(before, observed) {
+ const after = this.#circuitStateSafe();
+ if (after !== before) {
+ dlog('breaker', { from: before, to: after, observed });
+ }
+ return after;
}
/**
- * Get current Shield status — active policies, 24h stats.
- * @returns {Promise}
+ * Verify a tool call / action before execution. Every HIGH-RISK tool call
+ * goes through here. Applies the tiered fail policy (B3): a genuine block
+ * surfaces as BLOCK; a contract/auth failure THROWS (fail-loud); a transport
+ * failure falls back tiered by risk (HIGH → BLOCK, MEDIUM/LOW → ALLOW).
+ *
+ * @param {object} params
+ * @param {string} [params.agentId]
+ * @param {string} [params.toolName]
+ * @param {string} params.input
+ * @param {object} [params.metadata]
+ * @returns {Promise<{decision:'ALLOW'|'BLOCK'|'MODIFY'|'APPROVAL', reason:(string|null), modified_input?:(string|null), trace_id?:(string|null), findings?:Array, _fallback?:boolean}>}
*/
- async getShieldStatus() {
- return this.#request('GET', '/api/v1/shield/status');
- }
+ async verify({ agentId, toolName, input, metadata = {} }) {
+ // Fail-policy + risk_level + all logs key off the NATIVE tool name — never
+ // overwrite `toolName` (BEFUND Strang B classifyRisk-Trennlinie).
+ const riskLevel = toolName ? classifyRisk(toolName) : 'MEDIUM';
+ // Only the gateway-bound capability key is normalized to `prefix:action`.
+ const gatewayToolName = toolName ? normalizeToolName(toolName) : undefined;
- /**
- * Health check — verify API is reachable.
- * @returns {Promise<{status: string, version: string}>}
- */
- async health() {
- return this.#request('GET', '/api/v1/health');
- }
+ // ── Diagnostics (no-op unless AGENT_SHIELD_DEBUG_LOG_PATH set) ──
+ // `attempt: 0` is the agent-shield-level call; the SDK does its own internal
+ // retries which are not separately visible here. The breaker state captured
+ // BEFORE the call is what distinguishes H1 (open → short-circuit, no network)
+ // from a fresh transport failure.
+ const verifyUrl = `${this.#apiUrl}/api/v1/verify`;
+ const breakerBefore = this.#circuitStateSafe();
+ const startedAt = Date.now();
+ dlog('gateway_call_start', { kind: 'verify', url: verifyUrl, attempt: 0, breakerState: breakerBefore });
+
+ try {
+ const res = await this.#sdk.verify({
+ prompt: input ?? '',
+ context: gatewayToolName ? { toolName: gatewayToolName } : undefined,
+ metadata: {
+ ...metadata,
+ ...(agentId ? { agent_id: agentId } : {}),
+ source: 'agent-shield',
+ risk_level: riskLevel,
+ },
+ });
- // ─── Internal ──────────────────────────────────────────────────────
+ this.#emitBreakerDelta(breakerBefore, 'http_ok');
+ dlog('gateway_call_end', {
+ kind: 'verify',
+ url: verifyUrl,
+ attempt: 0,
+ outcome: 'http_ok',
+ httpStatus: res.decision === 'RATE_LIMITED' ? 429 : 200,
+ decision: res.decision ?? null,
+ elapsedMs: Date.now() - startedAt,
+ });
+
+ // 429 is surfaced by the SDK as decision RATE_LIMITED (not an exception)
+ // on the governed verify path. Treat it as a transport failure, tiered.
+ if (res.decision === 'RATE_LIMITED') {
+ const fb = transportFallback(riskLevel, {
+ errorMessage: 'rate_limited',
+ retryAfterMs: res.retryAfterMs,
+ });
+ dlog('failclosed_emit', { reason: fb.reason, decision: fb.decision, branch: 'rate_limited', riskLevel });
+ return fb;
+ }
+
+ // F5: an unrecognized verdict (e.g. a future, stricter decision) must not
+ // be waved through. For a security tool, "unknown → ALLOW" is the wrong
+ // direction. Tier it: HIGH-risk fails closed; lower risk allows but flags
+ // the anomaly loudly so it surfaces in logs and traces.
+ if (!KNOWN_SDK_DECISIONS.has(res.decision)) {
+ console.warn(
+ `[agent-shield] unrecognized gateway decision: ${JSON.stringify(res.decision)} (riskLevel=${riskLevel})`,
+ );
+ if (riskLevel === 'HIGH') {
+ return {
+ decision: 'BLOCK',
+ reason: 'unknown_decision_failclosed',
+ _anomaly: true,
+ sdk_decision: res.decision,
+ trace_id: res.traceId || null,
+ };
+ }
+ return {
+ decision: 'ALLOW',
+ reason: 'unknown_decision',
+ _anomaly: true,
+ sdk_decision: res.decision,
+ trace_id: res.traceId || null,
+ };
+ }
- async #request(method, path, body = null) {
- // Circuit breaker: fail open instantly while OPEN, never block the agent.
- if (!this.#circuitBreaker.beforeRequest()) {
return {
- decision: 'ALLOW',
- reason: 'circuit_open',
- cached: false,
- _fallback: true,
+ decision: normalizeDecision(res.decision),
+ // Preserve the exact gateway verdict so the dashboard/trace match the
+ // engine decision (ANONYMIZED vs REDACTED stays visible downstream).
+ sdk_decision: res.decision,
+ // Gateway reason wins; fall back to a nuance-preserving default only for
+ // masking verdicts that arrived without one.
+ reason: res.reason || MASKING_REASON_DEFAULTS[res.decision] || null,
+ // Every masking verdict (MODIFIED/ANONYMIZED/REDACTED) carries its
+ // replacement text in `res.output` — pass it through so MODIFY is never
+ // returned without the substitute the agent must use.
+ modified_input: MASKING_DECISIONS.has(res.decision) ? res.output || null : null,
+ trace_id: res.traceId || null,
+ findings: res.findings || [],
};
- }
+ } catch (err) {
+ const outcome = classifyErrorOutcome(err);
+ this.#emitBreakerDelta(breakerBefore, err?.name || 'error');
+ dlog('gateway_call_end', {
+ kind: 'verify',
+ url: verifyUrl,
+ attempt: 0,
+ outcome,
+ errName: err?.name ?? null,
+ errCode: err?.code ?? null,
+ errMessage: String(err?.message ?? '').slice(0, 200),
+ // @palveron/sdk ≥1.2.0 preserves the original transport error on the
+ // PalveronError's `cause` (the NETWORK_ERROR no longer discards it), so
+ // causeChain here surfaces the real undici reason (TLS-trust / connect /
+ // DNS) directly off the SDK error. net_selftest_fetch (raw fetch outside
+ // the SDK) remains as an independent cross-check. Older SDKs dropped the
+ // cause → the chain was simply empty and callers degraded gracefully.
+ errStack: String(err?.stack ?? '').slice(0, 600),
+ causeChain: causeChain(err),
+ ...(outcome === 'timeout' ? { timeoutMs: this.#timeoutMs } : {}),
+ elapsedMs: Date.now() - startedAt,
+ });
- const url = `${this.#apiUrl}${path}`;
- const headers = {
- 'Content-Type': 'application/json',
- 'X-API-Key': this.#apiKey,
- };
+ // FAIL-LOUD: a 400 (broken request body) or 401 (bad key) is our bug or a
+ // misconfiguration. Re-throw — never swallow into ALLOW. This is exactly
+ // the failure class the old silent fail-open hid.
+ if (isFailLoud(err)) throw err;
- // Forward LLM key for BYOM 2-pass analysis
- if (this.#llmApiKey) {
- headers['X-LLM-API-Key'] = this.#llmApiKey;
+ // Transport failure (timeout / circuit-open / network / 5xx) → tiered.
+ // Inspect the cause chain for a TLS-trust failure (HTTPS-inspecting AV /
+ // firewall): keep the fail-CLOSED BLOCK, but replace the opaque
+ // gateway_unavailable_failclosed with an honest, actionable reason+hint so
+ // the user sees WHY and HOW to fix it instead of a bare BLOCK. Genuine
+ // connect/DNS/timeout failures keep the generic reason.
+ const { reason: tlsReason, hint } = classifyTransportFailure(err);
+ const fb = transportFallback(riskLevel, {
+ errorMessage: err?.message,
+ ...(tlsReason ? { reason: tlsReason, hint } : {}),
+ });
+ // failclosed_emit closes the causal chain: this branch is what produced
+ // the fail-closed verdict. `branch` names the outcome that caused it —
+ // breaker_open_shortcircuit (H1) vs a real transport_error/timeout (H2/H3).
+ dlog('failclosed_emit', { reason: fb.reason, decision: fb.decision, branch: outcome, riskLevel, errName: err?.name ?? null });
+ return fb;
}
+ }
- const serializedBody = body && method !== 'GET' ? JSON.stringify(body) : undefined;
-
- let lastError;
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
- // Each attempt gets a fresh timeout signal — reusing one across retries
- // means a single timeout kills every retry.
- const fetchOptions = {
- method,
- headers,
- signal: AbortSignal.timeout(this.#timeout),
- };
- if (serializedBody !== undefined) {
- fetchOptions.body = serializedBody;
- }
+ /** Gateway health (hits the real `/health`, via the SDK). */
+ async health() {
+ const healthUrl = `${this.#apiUrl}/health`;
+ const breakerBefore = this.#circuitStateSafe();
+ const startedAt = Date.now();
+ dlog('gateway_call_start', { kind: 'health', url: healthUrl, attempt: 0, breakerState: breakerBefore });
+ try {
+ const res = await this.#sdk.health();
+ this.#emitBreakerDelta(breakerBefore, 'http_ok');
+ dlog('gateway_call_end', { kind: 'health', url: healthUrl, attempt: 0, outcome: 'http_ok', elapsedMs: Date.now() - startedAt });
+ return res;
+ } catch (err) {
+ const outcome = classifyErrorOutcome(err);
+ this.#emitBreakerDelta(breakerBefore, err?.name || 'error');
+ dlog('gateway_call_end', {
+ kind: 'health',
+ url: healthUrl,
+ attempt: 0,
+ outcome,
+ errName: err?.name ?? null,
+ errCode: err?.code ?? null,
+ errMessage: String(err?.message ?? '').slice(0, 200),
+ errStack: String(err?.stack ?? '').slice(0, 600),
+ causeChain: causeChain(err),
+ elapsedMs: Date.now() - startedAt,
+ });
+ throw err;
+ }
+ }
- try {
- const response = await fetch(url, fetchOptions);
+ /** List active policies (via the SDK). */
+ async listPolicies(env) {
+ return this.#sdk.listPolicies(env);
+ }
- if (response.ok) {
- this.#circuitBreaker.recordSuccess();
- return response.json();
- }
+ // ── OpenClaw Shield endpoints (not part of the generic SDK) ───────────────
+ // Thin control-plane helpers. They reuse the SDK's Bearer auth scheme and
+ // THROW on any non-2xx response — setup and status are never silent.
- // Don't retry 4xx errors (client errors)
- if (response.status >= 400 && response.status < 500) {
- const errorBody = await response.json().catch(() => ({}));
- throw new ShieldApiError(
- errorBody.error || `API error: ${response.status}`,
- response.status,
- errorBody
- );
- }
+ /**
+ * Initialize the OpenClaw Shield for the project (idempotent server-side).
+ * @param {object} params
+ * @param {string} params.hostname
+ * @returns {Promise