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 @@ Website Docs Node.js - License + License

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} ShieldSetupResponse + */ + async setupShield({ hostname }) { + return this.#shieldRequest('POST', '/api/v1/setup/openclaw-shield', { hostname }); + } - // 5xx — retry - lastError = new ShieldApiError( - `Server error: ${response.status}`, - response.status - ); - } catch (err) { - if (err instanceof ShieldApiError) throw err; - if (err.name === 'TimeoutError' || err.name === 'AbortError') { - lastError = new ShieldApiError('Request timed out', 0); - } else { - lastError = new ShieldApiError( - err.message || 'Network error', - 0 - ); - } - } + /** + * Current Shield status — active policies + 24h stats. + * @returns {Promise} ShieldStatusResponse + */ + async getShieldStatus() { + return this.#shieldRequest('GET', '/api/v1/shield/status'); + } - // Exponential backoff before retry - if (attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt))); - } + async #shieldRequest(method, path, body) { + let res; + try { + res = await fetch(`${this.#apiUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.#apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(SHIELD_REQUEST_TIMEOUT_MS), + }); + } catch (err) { + throw new Error(`Shield ${method} ${path} failed: ${err?.message || err}`); } - // All retries exhausted — record one failure against the breaker. - this.#circuitBreaker.recordFailure(); - - // CRITICAL: On failure, ALLOW the action to proceed. - // Agent-shield must never block the user's workflow due to our own outage. - return { - decision: 'ALLOW', - reason: 'api_unreachable', - cached: false, - _fallback: true, - _error: lastError?.message, - }; - } -} + if (!res.ok) { + const text = await res.text().catch(() => ''); + // B1e: tolerate BOTH gateway error-contract shapes (structured object + + // legacy flat string) and BLOCKED-verdict `reason` — never `[object Object]`. + const detail = parseShieldErrorDetail(text); + throw new Error( + `Shield ${method} ${path} failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`, + ); + } -export class ShieldApiError extends Error { - constructor(message, statusCode, body = null) { - super(message); - this.name = 'ShieldApiError'; - this.statusCode = statusCode; - this.body = body; + return res.json(); } } diff --git a/src/debug-log.mjs b/src/debug-log.mjs new file mode 100644 index 0000000..94d3a95 --- /dev/null +++ b/src/debug-log.mjs @@ -0,0 +1,254 @@ +// src/debug-log.mjs +// ───────────────────────────────────────────────────────────────────────────── +// Env-gated, zero-dependency, secret-safe JSONL spawn diagnostics. +// +// WHY THIS EXISTS +// OpenClaw (and other agent hosts) spawn the MCP server as a subprocess and +// swallow its stderr — a `console.error` is invisible AND throwaway. This is a +// permanent, reusable facility: when a spawned governance process misbehaves +// in a way that only reproduces under the host (inherited proxy env, circuit +// breaker state, env divergence from `mcp show`, handshake races), set one env +// var and get an ordered, append-only event log. Every future spawn adapter +// (Cursor, Windsurf, Claude Code, n8n, CrewAI) hits the same class of bug, so +// the diagnostic lives in the repo rather than being re-improvised each time. +// +// CONTRACT +// • OFF BY DEFAULT. Enabled only when AGENT_SHIELD_DEBUG_LOG_PATH is a non-empty +// path, read ONCE at module load. Unset → `dlog` is a pure no-op: no fs +// access, no per-call try/overhead beyond a single boolean check. +// • `dlog(event, fields)` NEVER throws (internal try/catch). Diagnostics must +// never influence the governance decision — Correctness > Diagnosis. +// • Synchronous append (`appendFileSync`): the host can kill the process at any +// moment; a buffered async write would lose the very events we need. +// • One valid JSON object per line (JSONL). `seq` is a per-process monotonic +// counter — it is the ordering proof (call #1 vs #2 in the same pid). +// • Zero dependencies — only node:fs / node:os / node:path builtins. +// +// SECRET HYGIENE (hard rule) +// Never log the API key, tokens, header values, or request/response bodies. +// Keys appear only as { present, len }. Proxy URLs are reduced to host:port +// (embedded user:pass and path stripped). URLs and env-var *names* are safe. +// ───────────────────────────────────────────────────────────────────────────── + +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { getServers } from 'node:dns'; +import { lookup } from 'node:dns/promises'; +import { truncate, causeChain } from './error-cause.mjs'; + +// Re-exported so existing importers (and the diagnostic path) keep one source of +// truth for cause resolution; the implementation lives in error-cause.mjs so it +// is usable on the live governance path too (not tied to the debug facility). +export { causeChain }; + +const LOG_PATH = (process.env.AGENT_SHIELD_DEBUG_LOG_PATH ?? '').trim(); +const ENABLED = LOG_PATH.length > 0; + +let seq = 0; + +// Best-effort: ensure the parent directory exists so the very first append +// doesn't silently no-op into a missing folder. Never throws (off-path stays +// off; a bad path simply yields no log rather than a crash). +if (ENABLED) { + try { + mkdirSync(dirname(LOG_PATH), { recursive: true }); + } catch { + /* best-effort only */ + } +} + +/** Is file diagnostics active for this process? */ +export function isDebugEnabled() { + return ENABLED; +} + +/** + * Append one diagnostic event as a JSON line. No-op (and zero fs cost) when + * AGENT_SHIELD_DEBUG_LOG_PATH is unset. Never throws. + * + * @param {string} event - Event name (see GOAL §5: proc_start, mcp_msg, + * breaker, gateway_call_start, gateway_call_end, + * failclosed_emit). + * @param {object} [fields] - Event-specific, ALREADY secret-safe fields. + */ +export function dlog(event, fields = {}) { + if (!ENABLED) return; + try { + const line = JSON.stringify({ + ts: new Date().toISOString(), + pid: process.pid, + seq: ++seq, + event, + ...fields, + }); + // Explicit UTF-8 bytes so non-ASCII in error messages (em dash, etc.) is + // not mangled by a platform default code page. + appendFileSync(LOG_PATH, Buffer.from(line + '\n', 'utf8')); + } catch { + // Swallow: a diagnostics IO error must never reach the governance path. + } +} + +/** + * Secret-safe metadata about a credential: presence + length only. Never the + * value, never a substring beyond what the caller explicitly chooses. + * @param {unknown} key + * @returns {{ present: boolean, len: number }} + */ +export function keyMeta(key) { + if (typeof key !== 'string' || key.length === 0) return { present: false, len: 0 }; + return { present: true, len: key.length }; +} + +const PROXY_VARS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', +]; + +/** + * Reduce a proxy URL to `protocol//host:port`, dropping any embedded + * `user:pass@` credentials and path. NO_PROXY-style host lists (not URLs) are + * returned unchanged (they carry no secret). + * @param {string} val + * @returns {string} + */ +export function sanitizeProxyUrl(val) { + if (typeof val !== 'string' || val === '') return val; + try { + const u = new URL(val); + return `${u.protocol}//${u.host}`; // host includes :port; user:pass + path dropped + } catch { + return val; // not a URL (e.g. NO_PROXY list) — no credentials to strip + } +} + +/** + * Snapshot the proxy-related env vars a spawned process inherited, sanitized. + * Only present, non-empty vars appear. Decides H2 (inherited proxy) / H4. + * @param {NodeJS.ProcessEnv} [env] + * @returns {Record} + */ +export function collectProxyEnv(env = process.env) { + const out = {}; + for (const name of PROXY_VARS) { + const v = env[name]; + if (v !== undefined && v !== '') { + out[name] = name.toLowerCase().includes('no_proxy') ? v : sanitizeProxyUrl(v); + } + } + return out; +} + +const OS_ENV_VARS = [ + 'SystemRoot', + 'WINDIR', + 'SystemDrive', + 'USERPROFILE', + 'TEMP', + 'APPDATA', + 'LOCALAPPDATA', + 'ProgramFiles', + 'ProgramData', +]; + +/** + * Presence-only map of the OS env vars OpenClaw might strip from a child. Values + * are NEVER logged (paths can be sensitive); only `true/false`, plus PATH length + * so an empty/stripped PATH is visible. Detects an over-stripped spawn env. + * @param {NodeJS.ProcessEnv} [env] + */ +export function osEnvPresence(env = process.env) { + const out = {}; + for (const k of OS_ENV_VARS) out[k] = env[k] !== undefined && env[k] !== ''; + out.PATH = { present: typeof env.PATH === 'string' && env.PATH.length > 0, len: env.PATH ? env.PATH.length : 0 }; + return out; +} + +/** + * TLS / undici env relevant to fetch behaviour. NODE_EXTRA_CA_CERTS is logged as + * presence + PATH only (never the cert content). UNDICI_* config values are not + * secrets. Decides TLS-related spawn divergence. + * @param {NodeJS.ProcessEnv} [env] + */ +export function tlsEnvSnapshot(env = process.env) { + const undici = {}; + for (const k of Object.keys(env)) { + if (k.startsWith('UNDICI_')) undici[k] = env[k]; + } + return { + NODE_TLS_REJECT_UNAUTHORIZED: env.NODE_TLS_REJECT_UNAUTHORIZED ?? null, + NODE_EXTRA_CA_CERTS_present: !!env.NODE_EXTRA_CA_CERTS, + NODE_EXTRA_CA_CERTS_path: env.NODE_EXTRA_CA_CERTS ?? null, // path only, no content + ...(Object.keys(undici).length ? { undici } : {}), + }; +} + +/** The system DNS resolvers, read-only. Never throws. Decides resolver divergence. */ +export function dnsServersSafe() { + try { + return getServers(); + } catch { + return null; + } +} + +/** + * Active, low-level connectivity self-test — ONLY runs when diagnostics are + * enabled (otherwise a pure no-op: no DNS, no fetch, no network). Each step is + * isolated in its own try/catch and emits its own event; it can never throw into + * or delay the governance path beyond its own short timeout. Runs the raw + * `fetch` OUTSIDE the SDK so the un-masked undici cause chain is visible. + * + * @param {string} apiUrl - Gateway base URL (no secret). + * @returns {Promise} + */ +export async function netSelftest(apiUrl) { + if (!ENABLED || !apiUrl) return; + + let host; + try { + host = new URL(apiUrl).hostname; + } catch { + return; // unparseable URL — nothing to probe + } + + // 1) DNS lookup — does name resolution itself fail in this spawn context? + const t0 = Date.now(); + try { + const r = await lookup(host); + dlog('net_selftest_dns', { host, address: r.address, family: r.family, elapsedMs: Date.now() - t0 }); + } catch (e) { + dlog('net_selftest_dns', { + host, + errName: e?.name ?? null, + errCode: e?.code ?? null, + errno: e?.errno ?? null, + syscall: e?.syscall ?? null, + elapsedMs: Date.now() - t0, + }); + } + + // 2) Raw GET /health (idempotent, no mutation) — the un-SDK-masked transport + // result. On failure the full cause chain reveals DNS vs connect vs TLS vs + // EPERM/egress-block. + const healthUrl = `${apiUrl.replace(/\/+$/, '')}/health`; + const t1 = Date.now(); + try { + const res = await fetch(healthUrl, { method: 'GET', signal: AbortSignal.timeout(3000) }); + dlog('net_selftest_fetch', { url: healthUrl, httpStatus: res.status, elapsedMs: Date.now() - t1 }); + } catch (e) { + dlog('net_selftest_fetch', { + url: healthUrl, + errName: e?.name ?? null, + errCode: e?.code ?? null, + errMessage: truncate(e?.message, 200), + causeChain: causeChain(e), + elapsedMs: Date.now() - t1, + }); + } +} diff --git a/src/error-cause.mjs b/src/error-cause.mjs new file mode 100644 index 0000000..3662a25 --- /dev/null +++ b/src/error-cause.mjs @@ -0,0 +1,83 @@ +// src/error-cause.mjs +// Neutral, dependency-free error-cause utilities used on BOTH the diagnostic +// path (debug-log.mjs) and the live governance path (client.mjs fail policy). +// Kept out of debug-log.mjs so the cause inspection isn't tied to the +// debug-only facility — it must work even when diagnostics are off. + +/** Truncate a string to `n` chars (diagnostics/logs never carry full bodies). */ +export function truncate(s, n) { + const str = String(s ?? ''); + return str.length > n ? str.slice(0, n) : str; +} + +/** + * Resolve a thrown error's `cause` chain into a flat, secret-safe array. Node / + * undici stash the REAL transport reason (ENOTFOUND, ECONNREFUSED, + * UND_ERR_CONNECT_TIMEOUT, EPERM, UNABLE_TO_VERIFY_LEAF_SIGNATURE, …) in + * `err.cause`. @palveron/sdk ≥ the Fund-#5 fix preserves it on the + * PalveronError; older builds dropped it (then the chain is simply empty — + * callers degrade gracefully, never misclassify). + * @param {unknown} err + * @param {number} [maxDepth] + * @returns {Array<{name:?string,code:?string,errno:?number,syscall:?string,address:?string,port:?(number|string),message:string}>} + */ +export function causeChain(err, maxDepth = 5) { + const chain = []; + let cur = err?.cause; + let depth = 0; + while (cur && depth < maxDepth) { + chain.push({ + name: cur.name ?? null, + code: cur.code ?? null, + errno: cur.errno ?? null, + syscall: cur.syscall ?? null, + address: cur.address ?? null, + port: cur.port ?? null, + message: truncate(cur.message, 200), + }); + // AggregateError (undici connect) hides sub-errors in `.errors` — surface + // the first so DNS/connect failures aren't swallowed. + cur = cur.cause ?? (Array.isArray(cur.errors) ? cur.errors[0] : undefined); + depth++; + } + return chain; +} + +// TLS-trust failure codes — the gateway cert chain could not be verified. The +// dominant real-world cause is an antivirus/firewall performing HTTPS +// inspection with a corporate/AV root CA that lives in the OS trust store but +// not in Node's bundled CA set. +const TLS_TRUST_CODES = new Set([ + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'CERT_UNTRUSTED', + 'CERT_HAS_EXPIRED', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); + +export const GATEWAY_TLS_UNTRUSTED_REASON = 'gateway_tls_untrusted'; +const TLS_HINT = + 'TLS certificate could not be verified — most likely an antivirus or firewall ' + + 'performing HTTPS inspection with a root CA that Node does not trust by default. ' + + 'Start Node with --use-system-ca (Node ≥22) so it trusts the OS certificate ' + + 'store, or set NODE_EXTRA_CA_CERTS to the inspecting CA. This does NOT disable ' + + 'certificate verification.'; + +/** + * Inspect a transport error (and its full cause chain) for a TLS-trust failure. + * Returns an honest, actionable reason+hint for that class, or `{reason:null}` + * for genuine network errors (ECONNREFUSED/ENOTFOUND/timeout) so they keep the + * generic gateway-unavailable label. Never throws. + * @param {unknown} err + * @returns {{reason: (string|null), hint: (string|null)}} + */ +export function classifyTransportFailure(err) { + const codes = [err?.code, ...causeChain(err).map((c) => c.code)].filter(Boolean); + const isTls = codes.some( + (c) => TLS_TRUST_CODES.has(c) || c.startsWith('CERT_') || c.startsWith('ERR_TLS_'), + ); + if (isTls) return { reason: GATEWAY_TLS_UNTRUSTED_REASON, hint: TLS_HINT }; + return { reason: null, hint: null }; +} diff --git a/src/fail-policy.mjs b/src/fail-policy.mjs new file mode 100644 index 0000000..d651114 --- /dev/null +++ b/src/fail-policy.mjs @@ -0,0 +1,96 @@ +// src/fail-policy.mjs +// Tiered fail policy for agent-shield governance calls (Analysis Block G / B3). +// +// The OLD client failed OPEN on every error: a malformed request or a gateway +// outage silently returned `ALLOW`, leaving the agent ungoverned exactly when +// governance mattered most. That was the launch-blocking bug — a contract drift +// hid behind a blanket fail-open. This module replaces it with an honest, +// tiered policy built on the typed errors of @palveron/sdk: +// +// • Contract / auth failures (400 validation, 401 auth) → FAIL-LOUD. +// These are our own bug or a misconfiguration, never a reason to wave a +// dangerous action through. The caller re-throws so the CLI surfaces the +// error and the MCP server reports an explicit ERROR — never ALLOW. +// +// • Transport failures (timeout, circuit-open, network, 5xx, 429 rate-limit) +// → TIERED by the local risk classifier: +// - HIGH risk (shell / exec / delete / git_push / destructive / secret +// exfiltration) → FAIL-CLOSED: decision BLOCK. If our gateway is down we +// do not let a dangerous action run unchecked. +// - MEDIUM / LOW risk → FAIL-OPEN: decision ALLOW, so a transient outage +// never blocks ordinary work. +// +// • Override: AGENT_SHIELD_FAIL_CLOSED=true forces fail-closed for ALL risk +// levels on transport failure (maximum safety; availability traded away). +// +// Decision vocabulary here is the agent-facing one (ALLOW / BLOCK), matching +// SKILL.md and the MCP `governance_check` contract. + +import { + PalveronValidationError, + PalveronAuthenticationError, +} from '@palveron/sdk'; + +export const FAIL_CLOSED_REASON = 'gateway_unavailable_failclosed'; +export const FAIL_OPEN_REASON = 'gateway_unavailable_failopen'; + +/** + * Is the global fail-closed override active? + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function failClosedOverride(env = process.env) { + return String(env.AGENT_SHIELD_FAIL_CLOSED ?? '').trim().toLowerCase() === 'true'; +} + +/** + * Is this a FAIL-LOUD error (contract / auth) that must never be swallowed + * into an ALLOW? These indicate a broken request or bad credentials, not a + * transient outage. + * @param {unknown} err + * @returns {boolean} + */ +export function isFailLoud(err) { + return ( + err instanceof PalveronValidationError || + err instanceof PalveronAuthenticationError + ); +} + +/** + * Decide the fallback verdict for a TRANSPORT failure, tiered by risk. + * Returns a synthetic verify-result; never throws. + * + * @param {'HIGH'|'MEDIUM'|'LOW'} riskLevel + * @param {object} [opts] + * @param {string} [opts.errorMessage] - Diagnostic message (attached as `_error`). + * @param {number} [opts.retryAfterMs] - Rate-limit hint to surface to the caller. + * @param {string} [opts.reason] - Specific fail-CLOSED reason override (e.g. + * `gateway_tls_untrusted`). Only used on the BLOCK branch; the generic + * gateway-unavailable reason stays for ordinary connect/DNS/timeout failures. + * @param {string} [opts.hint] - Actionable, user-facing remediation hint + * (surfaced to the agent/MCP response). Attached when present. + * @param {NodeJS.ProcessEnv} [opts.env] - Injectable env (for tests). + * @returns {{decision: 'ALLOW'|'BLOCK', reason: string, _fallback: true, hint?: string, _error?: string, retry_after_ms?: number}} + */ +export function transportFallback(riskLevel, opts = {}) { + const failClosed = failClosedOverride(opts.env) || riskLevel === 'HIGH'; + if (failClosed) { + return { + decision: 'BLOCK', + reason: opts.reason || FAIL_CLOSED_REASON, + _fallback: true, + ...(opts.hint ? { hint: opts.hint } : {}), + ...(opts.errorMessage ? { _error: opts.errorMessage } : {}), + ...(opts.retryAfterMs !== undefined ? { retry_after_ms: opts.retryAfterMs } : {}), + }; + } + return { + decision: 'ALLOW', + reason: FAIL_OPEN_REASON, + _fallback: true, + ...(opts.hint ? { hint: opts.hint } : {}), + ...(opts.errorMessage ? { _error: opts.errorMessage } : {}), + ...(opts.retryAfterMs !== undefined ? { retry_after_ms: opts.retryAfterMs } : {}), + }; +} diff --git a/src/index.mjs b/src/index.mjs index 52a469f..faa4f85 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,7 +1,16 @@ // src/index.mjs -// Public API for @palveron/agent-shield -// This package is a thin client — NO proprietary logic. +// Public API for @palveron/agent-shield. +// This package is a thin OpenClaw client over @palveron/sdk — NO proprietary +// logic, NO duplicated verify contract. -export { ShieldClient, ShieldApiError } from './client.mjs'; -export { classifyRisk, shouldVerify, isDestructive } from './risk-classifier.mjs'; +export { ShieldClient, normalizeDecision, KNOWN_SDK_DECISIONS } from './client.mjs'; +export { classifyRisk, isDestructive } from './risk-classifier.mjs'; export { startMcpServer } from './mcp-server.mjs'; +export { updateOpenClawConfig } from './openclaw-config.mjs'; +export { + transportFallback, + isFailLoud, + failClosedOverride, + FAIL_CLOSED_REASON, + FAIL_OPEN_REASON, +} from './fail-policy.mjs'; diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index a5ac891..660eb90 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -10,7 +10,9 @@ // 3. Returns ALLOW/BLOCK/MODIFY decisions import { ShieldClient } from './client.mjs'; -import { classifyRisk, shouldVerify } from './risk-classifier.mjs'; +import { classifyRisk } from './risk-classifier.mjs'; +import { createStdioTransport } from './stdio-transport.mjs'; +import { dlog } from './debug-log.mjs'; const PROTOCOL_VERSION = '2024-11-05'; @@ -21,10 +23,6 @@ const PROTOCOL_VERSION = '2024-11-05'; export async function startMcpServer() { const apiUrl = process.env.PALVERON_API_URL || process.env.AGENT_SHIELD_API_URL; 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; if (!apiUrl || !apiKey) { writeError( @@ -33,59 +31,56 @@ export async function startMcpServer() { process.exit(1); } + // BYOM note: the gateway uses the project's server-side LLM key + // (dashboard → Settings → Neural Gateway). No LLM key is read or forwarded here. const client = new ShieldClient({ apiUrl, apiKey, - llmApiKey, timeout: 3000, // MCP needs to be fast + maxRetries: 1, // fail fast on the hot path; the fail policy tiers the outcome }); const agentId = process.env.AGENT_SHIELD_AGENT_ID || 'default'; - // JSON-RPC over stdio - let buffer = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk) => { - buffer += chunk; - processBuffer(); + // JSON-RPC over stdio — NDJSON (MCP standard) with Content-Length tolerance. + // The transport detects the input framing and mirrors it on responses. + const transport = createStdioTransport({ + onMessage: (msg) => handleMessage(msg, client, agentId, transport), + write: (s) => process.stdout.write(s), + onParseError: () => writeError('Failed to parse JSON-RPC message'), }); - function processBuffer() { - // MCP uses Content-Length framing - while (true) { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) break; - - const header = buffer.slice(0, headerEnd); - const match = header.match(/Content-Length:\s*(\d+)/i); - if (!match) { - buffer = buffer.slice(headerEnd + 4); - continue; - } - - const contentLength = parseInt(match[1], 10); - const bodyStart = headerEnd + 4; - if (buffer.length < bodyStart + contentLength) break; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => transport.push(chunk)); +} - const body = buffer.slice(bodyStart, bodyStart + contentLength); - buffer = buffer.slice(bodyStart + contentLength); +async function handleMessage(message, client, agentId, transport) { + const { id, method, params } = message; + // mcp_msg — proves the in-process call ORDER (probe/list before the failing + // tools/call?) and, via pid+seq adjacency, correlates each gateway call to the + // MCP message that triggered it. Decides H1/H5. Secret-safe: for tools/call we + // log only the tool name + argument KEY NAMES + byte size, never the argument + // values (PII risk). + { + const ev = { method, ...(id !== undefined ? { id } : {}) }; + if (method === 'tools/call') { + const args = params?.arguments; + ev.mcpToolName = params?.name ?? null; + ev.toolName = typeof args?.tool_name === 'string' ? args.tool_name : null; + ev.argKeys = args && typeof args === 'object' ? Object.keys(args) : []; try { - const message = JSON.parse(body); - handleMessage(message, client, agentId); + ev.argBytes = args !== undefined ? Buffer.byteLength(JSON.stringify(args)) : 0; } catch { - writeError('Failed to parse JSON-RPC message'); + ev.argBytes = -1; } } + dlog('mcp_msg', ev); } -} - -async function handleMessage(message, client, agentId) { - const { id, method, params } = message; switch (method) { case 'initialize': - sendResponse(id, { + sendResponse(transport, id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: { @@ -100,7 +95,7 @@ async function handleMessage(message, client, agentId) { break; case 'tools/list': - sendResponse(id, { + sendResponse(transport, id, { tools: [ { name: 'governance_check', @@ -136,53 +131,41 @@ async function handleMessage(message, client, agentId) { break; case 'tools/call': - await handleToolCall(id, params, client, agentId); + await handleToolCall(id, params, client, agentId, transport); break; default: // Unknown method — respond with method not found if (id !== undefined) { - sendError(id, -32601, `Method not found: ${method}`); + sendError(transport, id, -32601, `Method not found: ${method}`); } } } -async function handleToolCall(id, params, client, agentId) { +async function handleToolCall(id, params, client, agentId, transport) { const { name, arguments: args } = params || {}; if (name !== 'governance_check') { - sendError(id, -32602, `Unknown tool: ${name}`); + sendError(transport, id, -32602, `Unknown tool: ${name}`); return; } const toolName = args?.tool_name; const input = args?.input; + // F1: a malformed call (missing tool_name or input) must NEVER be a silent + // ALLOW — that is the one path that lets a broken/tampered governance_check + // bypass the gateway, and without tool_name the risk level isn't even + // knowable. Surface ERROR (SKILL.md handles it without proceeding on HIGH-RISK). if (!toolName || !input) { - sendResponse(id, { + sendResponse(transport, id, { content: [ { type: 'text', text: JSON.stringify({ - decision: 'ALLOW', - reason: 'Missing tool_name or input — allowing by default', - }), - }, - ], - }); - return; - } - - // Quick risk check — skip API call for truly LOW-RISK operations - if (!shouldVerify(toolName)) { - sendResponse(id, { - content: [ - { - type: 'text', - text: JSON.stringify({ - decision: 'ALLOW', - reason: 'low_risk_tool', - risk_level: classifyRisk(toolName), + decision: 'ERROR', + reason: 'missing_required_field', + error: 'governance_check requires tool_name and input', }), }, ], @@ -190,41 +173,56 @@ async function handleToolCall(id, params, client, agentId) { return; } + // F4: there is no LOW skip tier. Every governance_check goes through + // client.verify so it is both evaluated AND recorded as a trace — a local + // skip would return ALLOW without a gateway call, making the action invisible + // in the dashboard (breaking the "see everything" promise). try { + // client.verify applies the tiered fail policy (B3): a real verdict, or a + // risk-tiered fallback on transport failure (HIGH → BLOCK, MEDIUM/LOW → + // ALLOW). It only THROWS for fail-loud contract/auth failures (next catch). const result = await client.verify({ agentId, toolName, input, metadata: { - risk_level: classifyRisk(toolName), context: args?.context || null, }, }); - sendResponse(id, { + sendResponse(transport, id, { content: [ { type: 'text', text: JSON.stringify({ - decision: result.decision || 'ALLOW', + decision: result.decision, reason: result.reason || null, modified_input: result.modified_input || null, trace_id: result.trace_id || null, risk_level: classifyRisk(toolName), + // Actionable remediation (e.g. TLS-interception → --use-system-ca) so + // the user sees HOW to fix a fail-closed BLOCK, not just that it blocked. + ...(result.hint ? { hint: result.hint } : {}), + ...(result._fallback ? { _fallback: true } : {}), }), }, ], }); } catch (err) { - // On error, ALLOW — never block the user's workflow due to our failure - sendResponse(id, { + // FAIL-LOUD: a contract (400) or auth (401) failure is our bug or a + // misconfiguration. NEVER report ALLOW — that is exactly what hid the + // launch-blocking governance gap. Surface an explicit ERROR so the agent + // (per SKILL.md) treats high-risk actions with caution instead of + // proceeding silently. + sendResponse(transport, id, { content: [ { type: 'text', text: JSON.stringify({ - decision: 'ALLOW', - reason: 'governance_api_error', - error: err.message, + decision: 'ERROR', + reason: 'governance_check_failed', + error: err?.message || String(err), + risk_level: classifyRisk(toolName), }), }, ], @@ -232,24 +230,12 @@ async function handleToolCall(id, params, client, agentId) { } } -function sendResponse(id, result) { - const response = JSON.stringify({ - jsonrpc: '2.0', - id, - result, - }); - const header = `Content-Length: ${Buffer.byteLength(response)}\r\n\r\n`; - process.stdout.write(header + response); +function sendResponse(transport, id, result) { + transport.send({ jsonrpc: '2.0', id, result }); } -function sendError(id, code, message) { - const response = JSON.stringify({ - jsonrpc: '2.0', - id, - error: { code, message }, - }); - const header = `Content-Length: ${Buffer.byteLength(response)}\r\n\r\n`; - process.stdout.write(header + response); +function sendError(transport, id, code, message) { + transport.send({ jsonrpc: '2.0', id, error: { code, message } }); } function writeError(msg) { diff --git a/src/openclaw-config.mjs b/src/openclaw-config.mjs new file mode 100644 index 0000000..bb38c87 --- /dev/null +++ b/src/openclaw-config.mjs @@ -0,0 +1,70 @@ +// src/openclaw-config.mjs +// Locate and update the OpenClaw MCP config so `init` can wire agent-shield in +// automatically. Extracted from the CLI so the path resolution + merge behavior +// is unit-testable with injectable `home`/`cwd` (no global mocking). + +import { readFile, writeFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** + * Add (or update) the `agent-shield` MCP server entry in the user's OpenClaw + * config. Writes only the first existing candidate file and preserves any + * other `mcpServers` entries already present. + * + * Candidate order — the real OpenClaw global default first, then project-local: + * ~/.openclaw/openclaw.json (OpenClaw default, global) + * /openclaw.json + * /.openclaw/config.json + * + * @param {{apiUrl?: string, apiKey?: string, agentId?: string}} config + * @param {{home?: string, cwd?: string}} [opts] - injectable for tests. + * @returns {Promise} true if a config file was found and updated. + */ +export async function updateOpenClawConfig(config, opts = {}) { + const home = opts.home ?? homedir(); + const cwd = opts.cwd ?? process.cwd(); + + const candidates = [ + join(home, '.openclaw', 'openclaw.json'), + join(cwd, 'openclaw.json'), + join(cwd, '.openclaw', 'config.json'), + ]; + + for (const configPath of candidates) { + try { + await access(configPath); + const content = await readFile(configPath, 'utf8'); + const ocConfig = JSON.parse(content); + + // Preserve any existing mcpServers; only set our own entry. + if (!ocConfig.mcpServers) { + ocConfig.mcpServers = {}; + } + + ocConfig.mcpServers['agent-shield'] = { + command: 'npx', + // agent-shield-mcp is a bin INSIDE @palveron/agent-shield, not a + // standalone package. `-p` points npx at the right package so the + // invocation resolves whether or not the package is installed globally. + args: ['-y', '-p', '@palveron/agent-shield', 'agent-shield-mcp'], + env: { + PALVERON_API_URL: config.apiUrl || '', + PALVERON_API_KEY: config.apiKey || '', + // Goal 2b — bind the real agent identity ONLY when init resolved one. + // Absent (not empty-string) when unknown so the MCP runtime falls back + // to 'default' (mcp-server.mjs:43) exactly as before. + ...(config.agentId ? { AGENT_SHIELD_AGENT_ID: config.agentId } : {}), + }, + }; + + await writeFile(configPath, JSON.stringify(ocConfig, null, 2) + '\n'); + return true; + } catch { + // File doesn't exist or can't be read — try the next candidate. + continue; + } + } + + return false; +} diff --git a/src/risk-classifier.mjs b/src/risk-classifier.mjs index 6e3efdc..2b86974 100644 --- a/src/risk-classifier.mjs +++ b/src/risk-classifier.mjs @@ -1,8 +1,11 @@ // src/risk-classifier.mjs // Local tool-risk classification for OpenClaw tool calls. // This is intentionally simple — the real intelligence lives server-side. -// Only HIGH-RISK tools trigger a governance check (verify call). -// LOW-RISK tools are logged as traces but not blocked. +// There is no LOW skip tier: every governance_check is verified AND traced. +// The classifier only decides the FAIL POLICY on a gateway outage: +// HIGH → fail-closed (BLOCK) +// MEDIUM → fail-open (ALLOW) +// Unknown tools default to MEDIUM (never skipped). const HIGH_RISK_TOOLS = new Set([ // Shell/System @@ -40,10 +43,11 @@ const MEDIUM_RISK_TOOLS = new Set([ ]); /** - * Classify a tool call's risk level. + * Classify a tool call's risk level. Drives only the outage fail policy + * (HIGH → fail-closed, MEDIUM → fail-open). There is no LOW tier. * * @param {string} toolName - The tool being called - * @returns {'HIGH' | 'MEDIUM' | 'LOW'} + * @returns {'HIGH' | 'MEDIUM'} */ export function classifyRisk(toolName) { const normalized = toolName.toLowerCase().replace(/[-\s]/g, '_'); @@ -51,25 +55,11 @@ export function classifyRisk(toolName) { if (HIGH_RISK_TOOLS.has(normalized)) return 'HIGH'; if (MEDIUM_RISK_TOOLS.has(normalized)) return 'MEDIUM'; - // Unknown tools default to MEDIUM — better safe than sorry - // but not aggressive enough to block everything + // Unknown tools default to MEDIUM — verified like everything else, and + // fail-open on a gateway outage (only known-dangerous tools fail closed). return 'MEDIUM'; } -/** - * Should this tool call be sent to the governance API? - * HIGH → always verify - * MEDIUM → verify (but don't block workflow if API is down) - * LOW → skip (only log as trace) - * - * @param {string} toolName - * @returns {boolean} - */ -export function shouldVerify(toolName) { - const risk = classifyRisk(toolName); - return risk === 'HIGH' || risk === 'MEDIUM'; -} - /** * Is this a tool call that could be destructive? * Used for CLI status output. diff --git a/src/stdio-transport.mjs b/src/stdio-transport.mjs new file mode 100644 index 0000000..0574067 --- /dev/null +++ b/src/stdio-transport.mjs @@ -0,0 +1,94 @@ +// src/stdio-transport.mjs +// Robust JSON-RPC-over-stdio framing for MCP. Pure + injectable (no global +// mocking) — same pattern as openclaw-config.mjs: the caller passes `onMessage` +// + `write` in, tests feed strings and capture writes. +// +// MCP stdio is newline-delimited JSON (NDJSON): one JSON object per line, no +// embedded newlines. Some legacy clients use LSP-style Content-Length framing. +// We detect the framing from the first non-blank bytes and MIRROR it on every +// response, so we interoperate with either client without configuration. + +/** + * Create a stdio transport that frames JSON-RPC messages in both NDJSON + * (default) and Content-Length (legacy tolerance) and mirrors the detected + * input framing on outgoing messages. + * + * @param {object} deps + * @param {(msg: any) => void} deps.onMessage Called with each parsed message. + * @param {(chunk: string) => void} deps.write Sinks a fully-framed output chunk. + * @param {(err: Error, raw: string) => void} [deps.onParseError] Invalid JSON — + * the read loop reports it and keeps running (never crashes). + * @returns {{ push(chunk: string): void, send(payload: any): void, readonly framing: ('ndjson'|'content-length'|null) }} + */ +export function createStdioTransport({ onMessage, write, onParseError }) { + let buffer = ''; + let framing = null; // 'ndjson' | 'content-length' — locked in on first message + + function detect(buf) { + return /^Content-Length:/i.test(buf.replace(/^\s*/, '')) ? 'content-length' : 'ndjson'; + } + + function push(chunk) { + buffer += chunk; + if (framing === null && buffer.trim().length > 0) framing = detect(buffer); + if (framing === 'content-length') drainContentLength(); + else if (framing === 'ndjson') drainNdjson(); + } + + function drainNdjson() { + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + let line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.endsWith('\r')) line = line.slice(0, -1); // tolerate CRLF + if (line.trim()) deliver(line); + } + } + + function drainContentLength() { + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) break; + const m = buffer.slice(0, headerEnd).match(/Content-Length:\s*(\d+)/i); + if (!m) { + buffer = buffer.slice(headerEnd + 4); // skip a header block we can't size + continue; + } + const len = parseInt(m[1], 10); + const start = headerEnd + 4; + if (buffer.length < start + len) break; // body not fully arrived yet + deliver(buffer.slice(start, start + len)); + buffer = buffer.slice(start + len); + } + } + + function deliver(raw) { + let msg; + try { + msg = JSON.parse(raw); + } catch (e) { + onParseError?.(e, raw); // never crash the loop on malformed input + return; + } + onMessage(msg); + } + + function send(payload) { + // JSON.stringify never emits raw newlines, so the NDJSON invariant (one + // object per line) always holds. + const json = JSON.stringify(payload); + if (framing === 'content-length') { + write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n` + json); + } else { + write(json + '\n'); // NDJSON default — also used before the first message + } + } + + return { + push, + send, + get framing() { + return framing; + }, + }; +} diff --git a/src/tool-normalization.mjs b/src/tool-normalization.mjs new file mode 100644 index 0000000..ef6f278 --- /dev/null +++ b/src/tool-normalization.mjs @@ -0,0 +1,51 @@ +// src/tool-normalization.mjs +// Übersetzt native OpenClaw-Tool-Namen in die Capability-Taxonomie des Gateways +// (prefix:action). NUR für den an den Gateway gesendeten context.tool_name — +// classifyRisk + Logs bleiben nativ (siehe client.mjs). Der Gateway bleibt +// framework-agnostisch; diese OpenClaw-Übersetzung gehört in den Adapter. +// +// Map = BEFUND_AGENT_ID_ACTIVATION.md Strang B (verbatim). Unter dem Goal-1- +// Preset landen die Ziel-Keys auf: ai/communication/data → ALLOW, +// external → REQUIRE_APPROVAL (mit api:get-Override → ALLOW), infrastructure → +// DENY. Dadurch: web_search/send_email → ALLOW (unbeaufsichtigter Lauf +// überlebt), exec/install_package → DENY (Code-Exec hart blocken). +const TOOL_MAP = Object.freeze({ + exec: 'infra:code_exec', shell: 'infra:code_exec', bash: 'infra:code_exec', + run_command: 'infra:code_exec', terminal: 'infra:code_exec', subprocess: 'infra:code_exec', + install_package: 'infra:code_exec', npm_install: 'infra:code_exec', pip_install: 'infra:code_exec', + apt_install: 'infra:code_exec', brew_install: 'infra:code_exec', + read_file: 'files:read', list_directory: 'files:read', search_files: 'files:read', + glob: 'files:read', find_files: 'files:read', + write_file: 'files:write', move_file: 'files:write', rename_file: 'files:write', + delete_file: 'files:delete', remove_file: 'files:delete', + sql_query: 'db:query', db_execute: 'db:mutate', drop_table: 'db:mutate', + send_email: 'email:send', send_message: 'slack:send', send_slack: 'slack:send', + web_search: 'api:get', fetch_url: 'api:get', download: 'api:get', + git_status: 'api:get', git_log: 'api:get', git_diff: 'api:get', + navigate: 'api:get', browser_navigate: 'api:get', + http_request: 'api:post', curl: 'api:post', + git_push: 'api:post', git_push_force: 'api:post', git_reset_hard: 'api:post', + fill_form: 'api:post', click: 'api:post', post_tweet: 'api:post', publish: 'api:post', + transfer: 'payment:execute', payment: 'payment:execute', purchase: 'payment:execute', + buy: 'payment:execute', sell: 'payment:execute', +}); + +/** + * Translate a native OpenClaw tool name to the gateway capability key + * (`prefix:action`). For the gateway `context.tool_name` ONLY — never for + * `classifyRisk`/logs, which must stay native (the outage fail-policy keys off + * the native name). + * + * @param {string} native - The native tool name (e.g. "send_email", "exec"). + * @returns {string} The mapped `prefix:action` key, or — for an unknown tool — + * the normalized native key WITHOUT a ':', which the gateway's + * `resolve_category` `_`-arm routes to `external` → REQUIRE_APPROVAL (the safe + * default catch, BEFUND Strang B). + */ +export function normalizeToolName(native) { + if (!native) return native; + // Same normalization as classifyRisk (risk-classifier.mjs:53): lower + [-\s]→_, + // so "send-email" / "Send Email" / "send_email" all collapse to one key. + const key = String(native).toLowerCase().replace(/[-\s]/g, '_'); + return TOOL_MAP[key] ?? key; +} diff --git a/test/circuit-breaker.test.mjs b/test/circuit-breaker.test.mjs deleted file mode 100644 index 131587b..0000000 --- a/test/circuit-breaker.test.mjs +++ /dev/null @@ -1,173 +0,0 @@ -// test/circuit-breaker.test.mjs -// Verifies the client-side fail-open behavior of agent-shield. -// Runs with the built-in Node test runner: `node --test test/` or -// directly as `node test/circuit-breaker.test.mjs`. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; -import { CircuitBreaker } from '../src/circuit-breaker.mjs'; -import { ShieldClient } from '../src/client.mjs'; - -// ─── Unit tests: CircuitBreaker in isolation ───────────────────────── - -test('CircuitBreaker starts CLOSED and lets requests through', () => { - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000 }); - assert.equal(cb.state, 'CLOSED'); - assert.equal(cb.beforeRequest(), true); -}); - -test('CircuitBreaker opens after 3 consecutive failures', () => { - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000 }); - cb.recordFailure(); - assert.equal(cb.state, 'CLOSED'); - cb.recordFailure(); - assert.equal(cb.state, 'CLOSED'); - cb.recordFailure(); - assert.equal(cb.state, 'OPEN'); - assert.equal(cb.beforeRequest(), false, 'OPEN breaker must short-circuit'); -}); - -test('CircuitBreaker: a success before the 3rd failure resets the counter', () => { - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000 }); - cb.recordFailure(); - cb.recordFailure(); - cb.recordSuccess(); - cb.recordFailure(); - cb.recordFailure(); - assert.equal(cb.state, 'CLOSED', 'success must reset the failure counter'); -}); - -test('CircuitBreaker stays OPEN inside the reset window', () => { - let now = 1_000; - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000, clock: () => now }); - cb.recordFailure(); cb.recordFailure(); cb.recordFailure(); - assert.equal(cb.state, 'OPEN'); - - now = 1_000 + 29_999; - assert.equal(cb.beforeRequest(), false); - assert.equal(cb.state, 'OPEN'); -}); - -test('CircuitBreaker transitions OPEN → HALF_OPEN after the reset window, closes on probe success', () => { - let now = 0; - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000, clock: () => now }); - cb.recordFailure(); cb.recordFailure(); cb.recordFailure(); - assert.equal(cb.state, 'OPEN'); - - now = 30_000; - assert.equal(cb.beforeRequest(), true, 'a single probe is allowed once the window elapses'); - assert.equal(cb.state, 'HALF_OPEN'); - - cb.recordSuccess(); - assert.equal(cb.state, 'CLOSED', 'probe success closes the circuit'); -}); - -test('CircuitBreaker: probe failure re-opens the circuit immediately', () => { - let now = 0; - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000, clock: () => now }); - cb.recordFailure(); cb.recordFailure(); cb.recordFailure(); - now = 30_000; - cb.beforeRequest(); - assert.equal(cb.state, 'HALF_OPEN'); - - cb.recordFailure(); - assert.equal(cb.state, 'OPEN', 'one failed probe re-opens; no second probe yet'); - assert.equal(cb.beforeRequest(), false); -}); - -// ─── Integration tests: ShieldClient response shape ────────────────── - -test('ShieldClient returns { decision: ALLOW, reason: circuit_open, cached: false } when breaker is OPEN', async () => { - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000 }); - cb.recordFailure(); cb.recordFailure(); cb.recordFailure(); - assert.equal(cb.state, 'OPEN'); - - // apiUrl is fine because the OPEN breaker short-circuits before any fetch is attempted. - const client = new ShieldClient({ - apiUrl: 'http://127.0.0.1:1', - apiKey: 'pv_test_x', - circuitBreaker: cb, - }); - - const result = await client.verify({ agentId: 't', toolName: 'exec', input: 'rm -rf /' }); - assert.equal(result.decision, 'ALLOW'); - assert.equal(result.reason, 'circuit_open'); - assert.equal(result.cached, false); - assert.equal(result._fallback, true); -}); - -test('ShieldClient: 3 consecutive request failures open the circuit; the 4th call short-circuits to ALLOW', async () => { - // Listening server that returns 500 for every request — exhausts retries and - // counts as one breaker failure per verify() call. - const server = createServer((_req, res) => { - res.statusCode = 500; - res.end('boom'); - }); - await new Promise((r) => server.listen(0, '127.0.0.1', r)); - const { port } = server.address(); - - try { - const client = new ShieldClient({ - apiUrl: `http://127.0.0.1:${port}`, - apiKey: 'pv_test_x', - timeout: 250, - }); - - for (let i = 0; i < 3; i++) { - const r = await client.verify({ agentId: 't', toolName: 'exec', input: 'x' }); - assert.equal(r.decision, 'ALLOW', `attempt ${i + 1} must fail open with ALLOW`); - assert.equal(r.reason, 'api_unreachable', `attempt ${i + 1} reason should be api_unreachable, got ${r.reason}`); - } - - assert.equal(client.circuitState, 'OPEN', 'breaker should be OPEN after 3 logical failures'); - - const short = await client.verify({ agentId: 't', toolName: 'exec', input: 'x' }); - assert.equal(short.decision, 'ALLOW'); - assert.equal(short.reason, 'circuit_open'); - assert.equal(short.cached, false); - assert.equal(short._fallback, true); - } finally { - await new Promise((r) => server.close(r)); - } -}); - -test('ShieldClient: HALF_OPEN probe success after 30 s closes the circuit (simulated clock)', async () => { - // Use a successful server and an injected breaker with a controllable clock - // so we don't actually wait 30 seconds. - const server = createServer((_req, res) => { - res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ decision: 'ALLOW', reason: 'ok' })); - }); - await new Promise((r) => server.listen(0, '127.0.0.1', r)); - const { port } = server.address(); - - try { - let now = 0; - const cb = new CircuitBreaker({ threshold: 3, resetMs: 30_000, clock: () => now }); - cb.recordFailure(); cb.recordFailure(); cb.recordFailure(); - assert.equal(cb.state, 'OPEN'); - - const client = new ShieldClient({ - apiUrl: `http://127.0.0.1:${port}`, - apiKey: 'pv_test_x', - circuitBreaker: cb, - timeout: 500, - }); - - // Inside the window — still short-circuits. - now = 29_999; - const blocked = await client.verify({ agentId: 't', toolName: 'exec', input: 'x' }); - assert.equal(blocked.reason, 'circuit_open'); - - // Cross the window — next call is the half-open probe; server is healthy → success → CLOSED. - now = 30_000; - const probed = await client.verify({ agentId: 't', toolName: 'exec', input: 'x' }); - assert.equal(probed.decision, 'ALLOW'); - assert.equal(probed.reason, 'ok', 'probe succeeded — response should be the server payload'); - assert.equal(client.circuitState, 'CLOSED'); - } finally { - await new Promise((r) => server.close(r)); - } -}); diff --git a/test/contract.test.mjs b/test/contract.test.mjs new file mode 100644 index 0000000..29f6b43 --- /dev/null +++ b/test/contract.test.mjs @@ -0,0 +1,158 @@ +// test/contract.test.mjs +// Proves the verify wire-contract end-to-end through the REAL @palveron/sdk: +// the body agent-shield sends must carry the fields the gateway requires +// (`prompt` top-level, `context.tool_name`, `metadata.agent_id`). A drift here +// was the launch-blocking bug (the gateway requires `prompt: String` and reads +// tool_name/agent_id from nested fields). We assert against the bytes actually +// put on the wire by the SDK, not a mock. +// +// Runs with the built-in Node test runner: `node --test`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { ShieldClient } from '../src/client.mjs'; + +/** Spin a one-shot HTTP server that captures the first request body + replies. */ +async function withCapturingServer(reply, run) { + let captured = null; + const server = createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: raw ? JSON.parse(raw) : null, + }; + const { status, json } = reply(captured); + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(json)); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + try { + const result = await run(`http://127.0.0.1:${port}`); + return { captured, result }; + } finally { + await new Promise((r) => server.close(r)); + } +} + +test('verify body matches the gateway contract: prompt top-level, context.tool_name, metadata.agent_id', async () => { + const { captured, result } = await withCapturingServer( + () => ({ status: 200, json: { decision: 'PASSED', trace_id: 't_ok', reason: 'clean' } }), + async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + return client.verify({ agentId: 'agent-7', toolName: 'exec', input: 'rm -rf /tmp/build' }); + }, + ); + + // The bytes on the wire — the whole point of the SDK migration. + assert.equal(captured.url, '/api/v1/verify'); + assert.equal(captured.body.prompt, 'rm -rf /tmp/build', 'prompt MUST be top-level'); + // Goal 2b — the gateway tool_name is NORMALIZED to the capability taxonomy + // (`exec` → `infra:code_exec` → DENY under the Goal-1 preset). This is the + // ONLY place the native name is translated. + assert.equal( + captured.body.context.tool_name, + 'infra:code_exec', + 'tool_name MUST be nested under context AND normalized to the capability key', + ); + assert.equal(captured.body.metadata.agent_id, 'agent-7', 'agent_id MUST be in metadata'); + assert.equal(captured.body.metadata.source, 'agent-shield'); + // classifyRisk-Trennlinie: risk_level is computed from the NATIVE name. `exec` + // is HIGH natively; classifyRisk('infra:code_exec') would be MEDIUM, so a HIGH + // here proves the fail-policy still keys off the native tool, not the + // normalized gateway key. + assert.equal(captured.body.metadata.risk_level, 'HIGH'); + // SDK uses Bearer auth. + assert.match(captured.headers.authorization ?? '', /^Bearer pv_live_x$/); + + // A clean PASSED is normalized to the agent-facing ALLOW. + assert.equal(result.decision, 'ALLOW'); + assert.equal(result.sdk_decision, 'PASSED'); + assert.equal(result._fallback, undefined, 'a real verdict must not look like a fallback'); +}); + +test('a real BLOCKED verdict (HTTP 403 + body) surfaces as BLOCK, not an error or ALLOW', async () => { + const { result } = await withCapturingServer( + () => ({ status: 403, json: { decision: 'BLOCKED', reason: 'secret_exfiltration', trace_id: 't_blk' } }), + async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + return client.verify({ agentId: 'a', toolName: 'exec', input: 'echo AKIAIOSFODNN7EXAMPLE' }); + }, + ); + assert.equal(result.decision, 'BLOCK'); + assert.equal(result.sdk_decision, 'BLOCKED'); + assert.equal(result.reason, 'secret_exfiltration'); + assert.equal(result.trace_id, 't_blk'); + assert.equal(result._fallback, undefined); +}); + +test('a MODIFIED verdict surfaces as MODIFY with the sanitized output', async () => { + const { result } = await withCapturingServer( + () => ({ status: 200, json: { decision: 'MODIFIED', output: 'masked', reason: 'pii_masked' } }), + async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + return client.verify({ agentId: 'a', toolName: 'send_message', input: 'email me@x.com' }); + }, + ); + assert.equal(result.decision, 'MODIFY'); + assert.equal(result.modified_input, 'masked'); +}); + +test('an ANONYMIZED verdict (HTTP 200 + output) → MODIFY, masked output, sdk_decision preserved, gateway reason wins, no anomaly', async () => { + const { result } = await withCapturingServer( + () => ({ status: 200, json: { decision: 'ANONYMIZED', output: 'Newsletter draft to [EMAIL]', reason: 'pii_tokenized', trace_id: 't_anon' } }), + async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + return client.verify({ agentId: 'a', toolName: 'send_email', input: 'Newsletter draft to test@example.com' }); + }, + ); + assert.equal(result.decision, 'MODIFY', 'ANONYMIZED proceeds with the masked text, not BLOCK'); + assert.equal(result.modified_input, 'Newsletter draft to [EMAIL]', 'masked output must reach the agent'); + assert.equal(result.sdk_decision, 'ANONYMIZED', 'the engine verdict stays visible for the trace'); + assert.equal(result.reason, 'pii_tokenized', 'a real gateway reason must not be overwritten'); + assert.equal(result._anomaly, undefined, 'a known verdict must not be flagged as an anomaly'); + assert.equal(result.trace_id, 't_anon'); +}); + +test('a REDACTED verdict (HTTP 200 + output, no reason) → MODIFY, masked output, sdk_decision preserved, irreversible-nuance reason', async () => { + const { result } = await withCapturingServer( + () => ({ status: 200, json: { decision: 'REDACTED', output: 'curl -H "Authorization: Bearer [REDACTED]"' } }), + async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + return client.verify({ agentId: 'a', toolName: 'exec', input: 'curl -H "Authorization: Bearer sk-secret"' }); + }, + ); + assert.equal(result.decision, 'MODIFY', 'REDACTED proceeds with the masked text, not BLOCK'); + assert.equal(result.modified_input, 'curl -H "Authorization: Bearer [REDACTED]"'); + assert.equal(result.sdk_decision, 'REDACTED'); + assert.match(result.reason, /irreversible/i, 'fallback reason must convey credential removal is irreversible'); + assert.equal(result._anomaly, undefined); +}); + +test('F5 intact: a genuinely unknown future verdict on a HIGH-risk tool still fails closed (BLOCK + anomaly)', async () => { + const origWarn = console.warn; + console.warn = () => {}; // F5 warns to stderr by design + try { + const { result } = await withCapturingServer( + () => ({ status: 200, json: { decision: 'FUTURE_VERDICT', trace_id: 't_future' } }), + async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + return client.verify({ agentId: 'a', toolName: 'exec', input: 'rm -rf /' }); + }, + ); + assert.equal(result.decision, 'BLOCK', 'unknown HIGH-risk verdict must fail closed'); + assert.equal(result.reason, 'unknown_decision_failclosed'); + assert.equal(result._anomaly, true); + assert.equal(result.sdk_decision, 'FUTURE_VERDICT'); + } finally { + console.warn = origWarn; + } +}); diff --git a/test/debug-log.test.mjs b/test/debug-log.test.mjs new file mode 100644 index 0000000..b22d309 --- /dev/null +++ b/test/debug-log.test.mjs @@ -0,0 +1,182 @@ +// test/debug-log.test.mjs +// The spawn-diagnostics facility must be (1) a true no-op when unset, (2) never +// throw, (3) secret-safe, (4) valid JSONL when enabled. These guard the +// "Correctness > Diagnosis" contract: the log can never affect governance. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + isDebugEnabled, + dlog, + keyMeta, + sanitizeProxyUrl, + collectProxyEnv, + causeChain, + osEnvPresence, + netSelftest, +} from '../src/debug-log.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// file:// URL so the subprocess `import()` works on Windows (drive paths aren't +// valid ESM specifiers). +const MODULE_URL = pathToFileURL(join(__dirname, '..', 'src', 'debug-log.mjs')).href; + +// The test runner is launched WITHOUT AGENT_SHIELD_DEBUG_LOG_PATH. +test('no-op when AGENT_SHIELD_DEBUG_LOG_PATH is unset — disabled and never throws', () => { + assert.equal(process.env.AGENT_SHIELD_DEBUG_LOG_PATH, undefined, 'precondition: env unset in runner'); + assert.equal(isDebugEnabled(), false); + assert.doesNotThrow(() => dlog('should_be_dropped', { a: 1, secretish: 'pv_live_xxx' })); +}); + +test('keyMeta exposes only presence + length, never the value', () => { + assert.deepEqual(keyMeta('pv_live_abcdef0123456789'), { present: true, len: 24 }); + assert.deepEqual(keyMeta(''), { present: false, len: 0 }); + assert.deepEqual(keyMeta(undefined), { present: false, len: 0 }); +}); + +test('sanitizeProxyUrl strips embedded credentials and path, keeps host:port', () => { + assert.equal(sanitizeProxyUrl('http://user:pass@proxy.corp:8080/path'), 'http://proxy.corp:8080'); + assert.equal(sanitizeProxyUrl('https://proxy.corp:3128'), 'https://proxy.corp:3128'); + // NO_PROXY-style host lists are not URLs → returned unchanged (no secret). + assert.equal(sanitizeProxyUrl('localhost,127.0.0.1,.internal'), 'localhost,127.0.0.1,.internal'); +}); + +test('collectProxyEnv only includes set vars and sanitizes proxy values', () => { + const env = { + HTTP_PROXY: 'http://user:secret@gw:8080', + NO_PROXY: 'localhost,127.0.0.1', + HTTPS_PROXY: '', // empty → excluded + }; + const out = collectProxyEnv(env); + assert.equal(out.HTTP_PROXY, 'http://gw:8080', 'credentials stripped'); + assert.equal(out.NO_PROXY, 'localhost,127.0.0.1'); + assert.equal('HTTPS_PROXY' in out, false, 'empty var excluded'); +}); + +test('enabled: writes valid JSONL with ts/pid/monotonic-seq and no raw secret', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-shield-dlog-')); + const logPath = join(dir, 'spawn.jsonl'); + try { + // Module reads the env once at load → drive the enabled path in a subprocess. + const script = ` + import('${MODULE_URL}').then((m) => { + m.dlog('first', { keyMeta: m.keyMeta('pv_live_SUPERSECRETVALUE') }); + m.dlog('second', { n: 2 }); + }); + `; + const r = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + env: { ...process.env, AGENT_SHIELD_DEBUG_LOG_PATH: logPath }, + encoding: 'utf8', + }); + assert.equal(r.status, 0, `subprocess failed: ${r.stderr}`); + + const lines = readFileSync(logPath, 'utf8').trim().split('\n'); + assert.equal(lines.length, 2, 'one JSON object per dlog call'); + + const a = JSON.parse(lines[0]); + const b = JSON.parse(lines[1]); + assert.equal(a.event, 'first'); + assert.equal(b.event, 'second'); + assert.equal(a.seq, 1, 'seq starts at 1'); + assert.equal(b.seq, 2, 'seq is monotonic per process'); + assert.equal(a.pid, b.pid, 'same pid within one process'); + assert.match(a.ts, /^\d{4}-\d{2}-\d{2}T.*Z$/, 'ISO ms timestamp'); + assert.deepEqual(a.keyMeta, { present: true, len: 24 }); + + // Hard secret-hygiene assertion: the raw key value never appears anywhere. + const raw = readFileSync(logPath, 'utf8'); + assert.equal(raw.includes('SUPERSECRETVALUE'), false, 'raw secret must never be logged'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('causeChain unwraps the nested transport reason (the SDK masks it at the surface)', () => { + // Mimic `TypeError: fetch failed` → cause: real undici/Node error. + const inner = Object.assign(new Error('connect ECONNREFUSED 1.2.3.4:443'), { + code: 'ECONNREFUSED', + errno: -4078, + syscall: 'connect', + address: '1.2.3.4', + port: 443, + }); + const top = new TypeError('fetch failed'); + top.cause = inner; + + const chain = causeChain(top); + assert.equal(chain.length, 1, 'one cause level'); + assert.equal(chain[0].code, 'ECONNREFUSED'); + assert.equal(chain[0].syscall, 'connect'); + assert.equal(chain[0].address, '1.2.3.4'); + assert.equal(chain[0].port, 443); +}); + +test('causeChain follows AggregateError .errors and caps depth', () => { + // undici connect failures surface sub-errors in `.errors`. + const agg = new Error('all attempts failed'); + agg.errors = [Object.assign(new Error('getaddrinfo ENOTFOUND gw'), { code: 'ENOTFOUND', syscall: 'getaddrinfo' })]; + const top = new Error('fetch failed'); + top.cause = agg; + const chain = causeChain(top); + assert.equal(chain[0].message.includes('all attempts failed'), true); + assert.equal(chain[1].code, 'ENOTFOUND', 'sub-error via .errors is surfaced'); + + // Depth cap: a chain longer than 5 is truncated, never infinite. + let head = new Error('L0'); + let node = head; + for (let i = 1; i < 10; i++) { + node.cause = new Error(`L${i}`); + node = node.cause; + } + assert.equal(causeChain(head).length, 5, 'depth capped at 5'); + assert.deepEqual(causeChain({}), [], 'no cause → empty chain'); +}); + +test('osEnvPresence reports presence only (never values) + PATH length', () => { + const env = { SystemRoot: 'C:\\Windows', PATH: 'a;b;c' }; // WINDIR/TEMP/etc. absent + const m = osEnvPresence(env); + assert.equal(m.SystemRoot, true); + assert.equal(m.WINDIR, false); + assert.equal(m.TEMP, false); + assert.deepEqual(m.PATH, { present: true, len: 5 }); + // Hard secret-hygiene: the actual SystemRoot path value must not leak. + assert.equal(JSON.stringify(m).includes('Windows'), false, 'env values must never appear'); +}); + +test('netSelftest is a no-op when diagnostics are disabled (no DNS, no fetch, no throw)', async () => { + assert.equal(isDebugEnabled(), false, 'precondition: disabled in runner'); + // Resolves to undefined without touching the network or throwing. An IP that + // would hang if probed proves it short-circuits before any fetch. + await assert.doesNotReject(() => netSelftest('http://10.255.255.1:81')); +}); + +test('enabled but unwritable path → still never throws (Correctness > Diagnosis)', () => { + // Deterministically unwritable on every platform: put the log "under" an + // existing regular file, so both mkdir(parent) and append fail (ENOTDIR). + const dir = mkdtempSync(join(tmpdir(), 'agent-shield-dlog-bad-')); + const blocker = join(dir, 'iam-a-file'); + writeFileSync(blocker, 'x'); + const badPath = join(blocker, 'x.jsonl'); // a file used as a directory → ENOTDIR + try { + const script = ` + import('${MODULE_URL}').then((m) => { + m.dlog('x', { a: 1 }); // must not throw despite an unwritable path + process.stdout.write('SURVIVED'); + }); + `; + const r = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + env: { ...process.env, AGENT_SHIELD_DEBUG_LOG_PATH: badPath }, + encoding: 'utf8', + }); + assert.equal(r.status, 0, `subprocess crashed: ${r.stderr}`); + assert.match(r.stdout, /SURVIVED/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/error-tolerance.test.mjs b/test/error-tolerance.test.mjs new file mode 100644 index 0000000..994eb41 --- /dev/null +++ b/test/error-tolerance.test.mjs @@ -0,0 +1,43 @@ +// B1e — agent-shield tolerates BOTH gateway error-body shapes. +// +// The gateway error contract moved from a flat `{ error: "" }` string to a +// structured `{ error: { code, message, request_id } }` object. The Shield's own +// error-body read (`client.mjs`) must surface a real string detail either way — +// never `[object Object]`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseShieldErrorDetail } from '../src/client.mjs'; + +test('NEW structured object-shape → error.message', () => { + const detail = parseShieldErrorDetail( + JSON.stringify({ error: { code: 'validation_error', message: 'Prompt is required', request_id: 'r1' } }), + ); + assert.equal(detail, 'Prompt is required'); +}); + +test('LEGACY flat-string error → the string', () => { + const detail = parseShieldErrorDetail(JSON.stringify({ error: 'Old flat message' })); + assert.equal(detail, 'Old flat message'); +}); + +test('BLOCKED verdict body → reason', () => { + const detail = parseShieldErrorDetail(JSON.stringify({ reason: 'Denied by capability model', decision: 'BLOCKED' })); + assert.equal(detail, 'Denied by capability model'); +}); + +test('object-shape result is ALWAYS a string, never [object Object]', () => { + const detail = parseShieldErrorDetail(JSON.stringify({ error: { code: 'forbidden', message: 'Nope' } })); + assert.equal(typeof detail, 'string'); + assert.notEqual(detail, '[object Object]'); +}); + +test('non-JSON body → raw text passthrough', () => { + assert.equal(parseShieldErrorDetail('plain text 502'), 'plain text 502'); + assert.equal(parseShieldErrorDetail(''), ''); +}); + +test('object error without a usable message → raw text fallback', () => { + const raw = JSON.stringify({ error: { code: 123 } }); + assert.equal(parseShieldErrorDetail(raw), raw); +}); diff --git a/test/fail-policy.test.mjs b/test/fail-policy.test.mjs new file mode 100644 index 0000000..6802123 --- /dev/null +++ b/test/fail-policy.test.mjs @@ -0,0 +1,162 @@ +// test/fail-policy.test.mjs +// Proves the tiered fail policy (Block B3): the launch-blocking silent-ALLOW is +// gone. Contract/auth failures fail LOUD (throw); transport failures are tiered +// by risk (HIGH → fail-closed BLOCK, MEDIUM/LOW → fail-open ALLOW); the +// AGENT_SHIELD_FAIL_CLOSED override forces fail-closed for all levels. +// +// Runs with the built-in Node test runner: `node --test`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { ShieldClient } from '../src/client.mjs'; +import { + transportFallback, + isFailLoud, + failClosedOverride, + FAIL_CLOSED_REASON, + FAIL_OPEN_REASON, +} from '../src/fail-policy.mjs'; +import { + PalveronValidationError, + PalveronAuthenticationError, + PalveronTimeoutError, +} from '@palveron/sdk'; + +/** Server that always replies with the given status/body. */ +async function withServer(status, json, run) { + const server = createServer((_req, res) => { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(json)); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + try { + return await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((r) => server.close(r)); + } +} + +// ─── Unit: pure fail-policy ────────────────────────────────────────── + +test('isFailLoud: validation + auth errors are fail-loud; transport errors are not', () => { + assert.equal(isFailLoud(new PalveronValidationError('bad body')), true); + assert.equal(isFailLoud(new PalveronAuthenticationError('bad key')), true); + assert.equal(isFailLoud(new PalveronTimeoutError(1000)), false); + assert.equal(isFailLoud(new Error('network')), false); +}); + +test('transportFallback: HIGH risk fails CLOSED, MEDIUM/LOW fail OPEN', () => { + assert.deepEqual( + { d: transportFallback('HIGH', { env: {} }).decision, r: transportFallback('HIGH', { env: {} }).reason }, + { d: 'BLOCK', r: FAIL_CLOSED_REASON }, + ); + assert.equal(transportFallback('MEDIUM', { env: {} }).decision, 'ALLOW'); + assert.equal(transportFallback('MEDIUM', { env: {} }).reason, FAIL_OPEN_REASON); + assert.equal(transportFallback('LOW', { env: {} }).decision, 'ALLOW'); +}); + +test('transportFallback: AGENT_SHIELD_FAIL_CLOSED=true forces BLOCK for all levels', () => { + const env = { AGENT_SHIELD_FAIL_CLOSED: 'true' }; + assert.equal(transportFallback('MEDIUM', { env }).decision, 'BLOCK'); + assert.equal(transportFallback('LOW', { env }).decision, 'BLOCK'); +}); + +test('failClosedOverride parses truthy/falsey correctly', () => { + assert.equal(failClosedOverride({ AGENT_SHIELD_FAIL_CLOSED: 'true' }), true); + assert.equal(failClosedOverride({ AGENT_SHIELD_FAIL_CLOSED: 'TRUE' }), true); + assert.equal(failClosedOverride({ AGENT_SHIELD_FAIL_CLOSED: 'false' }), false); + assert.equal(failClosedOverride({}), false); +}); + +// ─── Integration: ShieldClient honors the policy ───────────────────── + +test('HIGH-risk transport failure (5xx) → fail-CLOSED BLOCK, never silent ALLOW', async () => { + const result = await withServer(500, { error: 'boom' }, async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0, timeout: 400 }); + return client.verify({ agentId: 'a', toolName: 'exec', input: 'rm -rf /' }); + }); + assert.equal(result.decision, 'BLOCK', 'a dangerous tool during our outage MUST be blocked'); + assert.equal(result.reason, FAIL_CLOSED_REASON); + assert.equal(result._fallback, true); +}); + +test('MEDIUM-risk transport failure (5xx) → fail-OPEN ALLOW', async () => { + const result = await withServer(500, { error: 'boom' }, async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0, timeout: 400 }); + return client.verify({ agentId: 'a', toolName: 'read_file', input: 'cat config' }); + }); + assert.equal(result.decision, 'ALLOW'); + assert.equal(result.reason, FAIL_OPEN_REASON); +}); + +test('validation error (HTTP 400) → FAIL-LOUD: throws, does NOT return ALLOW', async () => { + await withServer(400, { error: 'missing field prompt', field: 'prompt' }, async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + await assert.rejects( + () => client.verify({ agentId: 'a', toolName: 'exec', input: 'x' }), + (err) => { + assert.ok(err instanceof PalveronValidationError, 'must be a PalveronValidationError'); + assert.equal(err.statusCode, 400); + return true; + }, + ); + }); +}); + +test('auth error (HTTP 401) → FAIL-LOUD: throws, does NOT return ALLOW', async () => { + await withServer(401, { error: 'bad key' }, async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); + await assert.rejects( + () => client.verify({ agentId: 'a', toolName: 'exec', input: 'x' }), + (err) => { + assert.ok(err instanceof PalveronAuthenticationError); + assert.equal(err.statusCode, 401); + return true; + }, + ); + }); +}); + +test('rate-limit (HTTP 429) on HIGH risk → tiered fail-closed BLOCK with retry hint', async () => { + const server = createServer((_req, res) => { + res.statusCode = 429; + res.setHeader('content-type', 'application/json'); + res.setHeader('retry-after', '2'); + res.end(JSON.stringify({ error: 'rate limited' })); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + try { + const client = new ShieldClient({ + apiUrl: `http://127.0.0.1:${port}`, + apiKey: 'pv_live_x', + maxRetries: 0, + timeout: 400, + }); + const result = await client.verify({ agentId: 'a', toolName: 'exec', input: 'rm -rf /' }); + assert.equal(result.decision, 'BLOCK'); + assert.equal(result.reason, FAIL_CLOSED_REASON); + assert.equal(result.retry_after_ms, 2000); + } finally { + await new Promise((r) => server.close(r)); + } +}); + +test('override AGENT_SHIELD_FAIL_CLOSED=true: MEDIUM-risk transport failure → BLOCK', async () => { + const prev = process.env.AGENT_SHIELD_FAIL_CLOSED; + process.env.AGENT_SHIELD_FAIL_CLOSED = 'true'; + try { + const result = await withServer(500, { error: 'boom' }, async (baseUrl) => { + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0, timeout: 400 }); + return client.verify({ agentId: 'a', toolName: 'read_file', input: 'x' }); + }); + assert.equal(result.decision, 'BLOCK'); + assert.equal(result.reason, FAIL_CLOSED_REASON); + } finally { + if (prev === undefined) delete process.env.AGENT_SHIELD_FAIL_CLOSED; + else process.env.AGENT_SHIELD_FAIL_CLOSED = prev; + } +}); diff --git a/test/hardening.test.mjs b/test/hardening.test.mjs new file mode 100644 index 0000000..17f2ef3 --- /dev/null +++ b/test/hardening.test.mjs @@ -0,0 +1,65 @@ +// test/hardening.test.mjs +// F5: an unrecognized gateway verdict must NOT be silently allowed — tiered by +// risk (HIGH → fail-closed BLOCK, MEDIUM → ALLOW but flagged as an anomaly). +// F4 (unit): there is no LOW tier and `shouldVerify` is gone (no dead export). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShieldClient } from '../src/client.mjs'; +import * as api from '../src/index.mjs'; +import { classifyRisk } from '../src/risk-classifier.mjs'; + +/** Minimal fake SDK that always returns the given verify response. */ +function fakeSdk(decision) { + return { + verify: async () => ({ decision, traceId: 't_fake', reason: 'fabricated' }), + diagnostics: () => ({ circuitState: 'closed' }), + }; +} + +/** Run with console.warn suppressed (F5 warns to stderr by design). */ +async function quiet(fn) { + const orig = console.warn; + console.warn = () => {}; + try { + return await fn(); + } finally { + console.warn = orig; + } +} + +test('F5: unknown decision on a HIGH-risk tool → fail-closed BLOCK, flagged anomaly (never ALLOW)', async () => { + const client = new ShieldClient({ apiUrl: 'http://x', apiKey: 'pv_live_x', sdk: fakeSdk('WAT') }); + const r = await quiet(() => client.verify({ agentId: 'a', toolName: 'exec', input: 'rm -rf /' })); + assert.equal(r.decision, 'BLOCK'); + assert.equal(r.reason, 'unknown_decision_failclosed'); + assert.equal(r._anomaly, true); + assert.equal(r.sdk_decision, 'WAT'); +}); + +test('F5: unknown decision on a MEDIUM-risk tool → ALLOW but flagged anomaly', async () => { + const client = new ShieldClient({ apiUrl: 'http://x', apiKey: 'pv_live_x', sdk: fakeSdk('WAT') }); + const r = await quiet(() => client.verify({ agentId: 'a', toolName: 'read_file', input: 'x' })); + assert.equal(r.decision, 'ALLOW'); + assert.equal(r.reason, 'unknown_decision'); + assert.equal(r._anomaly, true); + assert.equal(r.sdk_decision, 'WAT'); +}); + +test('F5: known decisions still pass through normally (no anomaly)', async () => { + const client = new ShieldClient({ apiUrl: 'http://x', apiKey: 'pv_live_x', sdk: fakeSdk('PASSED') }); + const r = await client.verify({ agentId: 'a', toolName: 'exec', input: 'echo hi' }); + assert.equal(r.decision, 'ALLOW'); + assert.equal(r._anomaly, undefined); +}); + +test('F4: classifyRisk has no LOW tier — known MEDIUM tools and unknown tools both classify MEDIUM', () => { + assert.equal(classifyRisk('list_directory'), 'MEDIUM'); + assert.equal(classifyRisk('search_files'), 'MEDIUM'); + assert.equal(classifyRisk('some_unknown_tool'), 'MEDIUM'); + assert.equal(classifyRisk('exec'), 'HIGH'); +}); + +test('F4: shouldVerify is removed (no dead export)', () => { + assert.equal(api.shouldVerify, undefined, 'shouldVerify must no longer be exported'); +}); diff --git a/test/mcp-server.test.mjs b/test/mcp-server.test.mjs new file mode 100644 index 0000000..c5fc0c1 --- /dev/null +++ b/test/mcp-server.test.mjs @@ -0,0 +1,118 @@ +// test/mcp-server.test.mjs +// Drives the real MCP server over stdio against a mock gateway. +// F1: governance_check with a missing field → decision ERROR (never ALLOW). +// F4: a previously "low-risk" tool now goes through verify (hits the gateway, +// gets a real decision) — there is no local skip that returns ALLOW. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BIN = join(__dirname, '..', 'bin', 'agent-shield-mcp.mjs'); + +/** Mock gateway that records /verify calls and always returns PASSED. */ +function startMockGateway() { + const verifyCalls = []; + const server = createServer((req, res) => { + let buf = ''; + req.on('data', (c) => (buf += c)); + req.on('end', () => { + if (req.url === '/api/v1/verify') { + verifyCalls.push(buf ? JSON.parse(buf) : null); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ decision: 'PASSED', reason: 'clean', trace_id: 't1' })); + return; + } + res.statusCode = 404; + res.end('{}'); + }); + }); + return { server, verifyCalls }; +} + +/** Spawn the MCP server, send framed requests, collect framed responses by id. */ +function driveMcp(baseUrl, requests) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN], { + env: { ...process.env, PALVERON_API_URL: baseUrl, PALVERON_API_KEY: 'pv_live_x' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let out = ''; + const responses = {}; + child.stdout.on('data', (d) => { + out += d.toString(); + // Parse all complete Content-Length frames currently buffered. + while (true) { + const m = out.match(/Content-Length:\s*(\d+)\r\n\r\n/i); + if (!m) break; + const headerEnd = m.index + m[0].length; + const len = parseInt(m[1], 10); + if (out.length < headerEnd + len) break; + const body = out.slice(headerEnd, headerEnd + len); + out = out.slice(headerEnd + len); + try { + const msg = JSON.parse(body); + if (msg.id !== undefined) responses[msg.id] = msg; + } catch { + /* ignore */ + } + } + }); + child.on('error', reject); + + for (const r of requests) { + const body = JSON.stringify(r); + child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); + } + + setTimeout(() => { + child.kill(); + resolve(responses); + }, 1000); + }); +} + +/** Pull the decision JSON out of an MCP tools/call result. */ +function decisionOf(resp) { + return JSON.parse(resp.result.content[0].text); +} + +test('F1: governance_check missing input → ERROR (never ALLOW); F4: real tools hit verify', async () => { + const { server, verifyCalls } = startMockGateway(); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + const responses = await driveMcp(baseUrl, [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + // missing input + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'governance_check', arguments: { tool_name: 'exec' } } }, + // missing tool_name + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'governance_check', arguments: { input: 'rm -rf /' } } }, + // a previously "low-risk" tool — must still go through verify (no skip) + { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'governance_check', arguments: { tool_name: 'list_directory', input: 'ls /' } } }, + ]); + + const d2 = decisionOf(responses[2]); + assert.equal(d2.decision, 'ERROR', 'missing input must be ERROR, never ALLOW'); + assert.equal(d2.reason, 'missing_required_field'); + + const d3 = decisionOf(responses[3]); + assert.equal(d3.decision, 'ERROR', 'missing tool_name must be ERROR, never ALLOW'); + + const d4 = decisionOf(responses[4]); + assert.equal(d4.decision, 'ALLOW', 'list_directory resolves to the gateway PASSED verdict'); + assert.notEqual(d4.reason, 'low_risk_tool', 'there must be no local skip path'); + assert.equal(verifyCalls.length, 1, 'the "low-risk" tool must have hit /verify (no skip)'); + // Goal 2b — the wire tool_name is normalized: list_directory → files:read. + assert.equal(verifyCalls[0].context.tool_name, 'files:read'); + } finally { + await new Promise((r) => server.close(r)); + } +}); diff --git a/test/openclaw-config.test.mjs b/test/openclaw-config.test.mjs new file mode 100644 index 0000000..43fa56b --- /dev/null +++ b/test/openclaw-config.test.mjs @@ -0,0 +1,133 @@ +// test/openclaw-config.test.mjs +// F2: updateOpenClawConfig finds ~/.openclaw/openclaw.json (global default), +// writes the agent-shield MCP entry with the correct invocation, and preserves +// any pre-existing foreign mcpServers entries. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { updateOpenClawConfig } from '../src/openclaw-config.mjs'; + +const EXPECTED_ARGS = ['-y', '-p', '@palveron/agent-shield', 'agent-shield-mcp']; + +async function tmpRoot() { + return mkdtemp(join(tmpdir(), 'agent-shield-test-')); +} + +test('writes agent-shield into ~/.openclaw/openclaw.json and preserves foreign entries', async () => { + const home = await tmpRoot(); + const emptyCwd = await tmpRoot(); + try { + const cfgPath = join(home, '.openclaw', 'openclaw.json'); + await mkdir(join(home, '.openclaw'), { recursive: true }); + await writeFile( + cfgPath, + JSON.stringify({ mcpServers: { 'some-other-server': { command: 'foo', args: ['bar'] } } }, null, 2), + ); + + const updated = await updateOpenClawConfig( + { apiUrl: 'https://gateway.palveron.com', apiKey: 'pv_live_x' }, + { home, cwd: emptyCwd }, + ); + + assert.equal(updated, true, 'should report it updated a file'); + const parsed = JSON.parse(await readFile(cfgPath, 'utf8')); + + // Foreign entry preserved. + assert.deepEqual(parsed.mcpServers['some-other-server'], { command: 'foo', args: ['bar'] }); + + // Our entry, with the correct (package-scoped) invocation. + const entry = parsed.mcpServers['agent-shield']; + assert.equal(entry.command, 'npx'); + assert.deepEqual(entry.args, EXPECTED_ARGS); + assert.equal(entry.env.PALVERON_API_URL, 'https://gateway.palveron.com'); + assert.equal(entry.env.PALVERON_API_KEY, 'pv_live_x'); + } finally { + await rm(home, { recursive: true, force: true }); + await rm(emptyCwd, { recursive: true, force: true }); + } +}); + +test('falls back to /openclaw.json when no home config exists', async () => { + const emptyHome = await tmpRoot(); + const cwd = await tmpRoot(); + try { + const cfgPath = join(cwd, 'openclaw.json'); + await writeFile(cfgPath, JSON.stringify({}, null, 2)); + + const updated = await updateOpenClawConfig({ apiUrl: 'u', apiKey: 'k' }, { home: emptyHome, cwd }); + assert.equal(updated, true); + const parsed = JSON.parse(await readFile(cfgPath, 'utf8')); + assert.deepEqual(parsed.mcpServers['agent-shield'].args, EXPECTED_ARGS); + } finally { + await rm(emptyHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('returns false when no candidate config file exists', async () => { + const emptyHome = await tmpRoot(); + const emptyCwd = await tmpRoot(); + try { + const updated = await updateOpenClawConfig({ apiUrl: 'u', apiKey: 'k' }, { home: emptyHome, cwd: emptyCwd }); + assert.equal(updated, false); + } finally { + await rm(emptyHome, { recursive: true, force: true }); + await rm(emptyCwd, { recursive: true, force: true }); + } +}); + +// Goal 2b — identity binding: when init resolved an agent_id, it is written into +// the MCP env so the runtime sends the REAL identity (mcp-server.mjs:43). +test('writes AGENT_SHIELD_AGENT_ID into the env when config.agentId is set', async () => { + const home = await tmpRoot(); + const emptyCwd = await tmpRoot(); + try { + const cfgPath = join(home, '.openclaw', 'openclaw.json'); + await mkdir(join(home, '.openclaw'), { recursive: true }); + await writeFile(cfgPath, JSON.stringify({ mcpServers: { keepme: { command: 'foo' } } }, null, 2)); + + const updated = await updateOpenClawConfig( + { apiUrl: 'https://gw', apiKey: 'pv_live_x', agentId: 'agent_real_123' }, + { home, cwd: emptyCwd }, + ); + + assert.equal(updated, true); + const env = JSON.parse(await readFile(cfgPath, 'utf8')).mcpServers['agent-shield'].env; + assert.equal(env.AGENT_SHIELD_AGENT_ID, 'agent_real_123', 'real id must be bound into the env'); + assert.equal(env.PALVERON_API_URL, 'https://gw'); + assert.equal(env.PALVERON_API_KEY, 'pv_live_x'); + // Foreign entry untouched. + assert.deepEqual( + JSON.parse(await readFile(cfgPath, 'utf8')).mcpServers.keepme, + { command: 'foo' }, + ); + } finally { + await rm(home, { recursive: true, force: true }); + await rm(emptyCwd, { recursive: true, force: true }); + } +}); + +// Without an agentId the env key must be ABSENT (not empty-string) so the MCP +// runtime falls back to 'default' exactly as before — graceful, inert. +test('omits AGENT_SHIELD_AGENT_ID entirely when config.agentId is unset', async () => { + const home = await tmpRoot(); + const emptyCwd = await tmpRoot(); + try { + const cfgPath = join(home, '.openclaw', 'openclaw.json'); + await mkdir(join(home, '.openclaw'), { recursive: true }); + await writeFile(cfgPath, JSON.stringify({}, null, 2)); + + await updateOpenClawConfig({ apiUrl: 'u', apiKey: 'k' }, { home, cwd: emptyCwd }); + + const env = JSON.parse(await readFile(cfgPath, 'utf8')).mcpServers['agent-shield'].env; + assert.ok(!('AGENT_SHIELD_AGENT_ID' in env), 'env key must be absent, not empty-string'); + assert.equal(env.PALVERON_API_URL, 'u'); + assert.equal(env.PALVERON_API_KEY, 'k'); + } finally { + await rm(home, { recursive: true, force: true }); + await rm(emptyCwd, { recursive: true, force: true }); + } +}); diff --git a/test/stdio-transport.test.mjs b/test/stdio-transport.test.mjs new file mode 100644 index 0000000..c6e8c77 --- /dev/null +++ b/test/stdio-transport.test.mjs @@ -0,0 +1,163 @@ +// test/stdio-transport.test.mjs +// Pure transport-level coverage for the JSON-RPC stdio framing fix: +// - NDJSON (MCP standard) is the default framing. +// - Content-Length (legacy LSP-style) is tolerated. +// - Responses MIRROR the detected input framing. +// - Malformed JSON never crashes the read loop. +// - Messages split across chunks are delivered only once complete. +// +// We drive the REAL message handlers by wiring the transport to a tiny in-test +// harness that mirrors src/mcp-server.mjs's dispatch (initialize / tools/list / +// notifications/initialized). No process, no stdin — strings in, writes out. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createStdioTransport } from '../src/stdio-transport.mjs'; + +const PROTOCOL_VERSION = '2024-11-05'; + +/** + * Build a transport whose onMessage applies the same dispatch shape the MCP + * server uses for the methods under test. Returns the transport plus the array + * of raw output chunks written by the transport. + */ +function harness() { + const writes = []; + const transport = createStdioTransport({ + onMessage: (msg) => { + const { id, method } = msg; + switch (method) { + case 'initialize': + transport.send({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'palveron-governance', version: '0.1.0' }, + }, + }); + break; + case 'notifications/initialized': + break; // notification → no response + case 'tools/list': + transport.send({ + jsonrpc: '2.0', + id, + result: { tools: [{ name: 'governance_check' }] }, + }); + break; + default: + if (id !== undefined) { + transport.send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }); + } + } + }, + write: (s) => writes.push(s), + onParseError: () => writes.push('__PARSE_ERROR__'), + }); + return { transport, writes }; +} + +function ndjson(obj) { + return JSON.stringify(obj) + '\n'; +} + +function contentLength(obj) { + const json = JSON.stringify(obj); + return `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`; +} + +/** Parse a single NDJSON output chunk (must end with exactly one newline). */ +function parseNdjson(chunk) { + assert.ok(chunk.endsWith('\n'), 'NDJSON response must end with a newline'); + assert.equal(chunk.indexOf('\n'), chunk.length - 1, 'exactly one line per NDJSON response'); + return JSON.parse(chunk.slice(0, -1)); +} + +/** Parse a single Content-Length output chunk. */ +function parseContentLength(chunk) { + const m = chunk.match(/^Content-Length:\s*(\d+)\r\n\r\n/i); + assert.ok(m, 'response must carry a Content-Length header'); + const body = chunk.slice(m[0].length); + assert.equal(Buffer.byteLength(body), parseInt(m[1], 10), 'Content-Length must match body byte length'); + return JSON.parse(body); +} + +test('NDJSON initialize → one NDJSON response line with serverInfo + protocolVersion', () => { + const { transport, writes } = harness(); + transport.push( + ndjson({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'probe', version: '0' } }, + }), + ); + + assert.equal(transport.framing, 'ndjson'); + assert.equal(writes.length, 1, 'exactly one response'); + const resp = parseNdjson(writes[0]); + assert.equal(resp.id, 1); + assert.equal(resp.result.serverInfo.name, 'palveron-governance'); + assert.equal(resp.result.protocolVersion, PROTOCOL_VERSION); +}); + +test('NDJSON tools/list → response lists governance_check', () => { + const { transport, writes } = harness(); + transport.push(ndjson({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })); + + assert.equal(writes.length, 1); + const resp = parseNdjson(writes[0]); + assert.deepEqual( + resp.result.tools.map((t) => t.name), + ['governance_check'], + ); +}); + +test('Content-Length initialize → correct response, mirrored in Content-Length framing', () => { + const { transport, writes } = harness(); + transport.push( + contentLength({ + jsonrpc: '2.0', + id: 7, + method: 'initialize', + params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'probe', version: '0' } }, + }), + ); + + assert.equal(transport.framing, 'content-length'); + assert.equal(writes.length, 1); + const resp = parseContentLength(writes[0]); + assert.equal(resp.id, 7); + assert.equal(resp.result.serverInfo.name, 'palveron-governance'); +}); + +test('notifications/initialized (no id) → no response', () => { + const { transport, writes } = harness(); + transport.push(ndjson({ jsonrpc: '2.0', method: 'notifications/initialized' })); + assert.equal(writes.length, 0, 'a notification must never get a response'); +}); + +test('chunk-splitting: a message split across two push() calls is delivered once complete', () => { + const { transport, writes } = harness(); + const line = ndjson({ jsonrpc: '2.0', id: 9, method: 'initialize', params: {} }); + const half = Math.floor(line.length / 2); + + transport.push(line.slice(0, half)); // partial line — nothing yet + assert.equal(writes.length, 0, 'no delivery until the newline arrives'); + + transport.push(line.slice(half)); // completes the line + assert.equal(writes.length, 1, 'delivered exactly once when complete'); + assert.equal(parseNdjson(writes[0]).id, 9); +}); + +test('malformed JSON does not crash the loop and a following valid message still works', () => { + const { transport, writes } = harness(); + transport.push('{ this is not json }\n'); + assert.deepEqual(writes, ['__PARSE_ERROR__'], 'parse error reported, loop alive'); + + transport.push(ndjson({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} })); + assert.equal(writes.length, 2, 'the next valid message is still processed'); + assert.equal(parseNdjson(writes[1]).result.tools[0].name, 'governance_check'); +}); diff --git a/test/tls-interception.test.mjs b/test/tls-interception.test.mjs new file mode 100644 index 0000000..3aa8433 --- /dev/null +++ b/test/tls-interception.test.mjs @@ -0,0 +1,76 @@ +// test/tls-interception.test.mjs +// Fund #5 fix: a TLS-trust transport failure (HTTPS-inspecting AV/firewall whose +// root CA Node doesn't trust) must STILL fail closed on HIGH risk, but surface an +// honest, actionable reason (`gateway_tls_untrusted`) + hint instead of the +// opaque `gateway_unavailable_failclosed`. Genuine connect failures keep the +// generic reason (no mis-reframe). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShieldClient } from '../src/client.mjs'; +import { classifyTransportFailure, causeChain } from '../src/error-cause.mjs'; + +/** Build a post-fix-SDK-shaped error: PalveronError(NETWORK_ERROR) whose cause + * is the `TypeError: fetch failed`, whose own cause is the real undici reason. */ +function networkError(causeCode) { + const undici = Object.assign(new Error(`tls/${causeCode}`), { code: causeCode }); + const fetchTypeError = new TypeError('fetch failed', { cause: undici }); + const e = new Error('Network error — could not reach gateway'); + e.name = 'PalveronError'; + e.code = 'NETWORK_ERROR'; + e.cause = fetchTypeError; + return e; +} + +function throwingSdk(err) { + return { + verify: async () => { + throw err; + }, + diagnostics: () => ({ circuitState: 'closed' }), + }; +} + +test('classifyTransportFailure detects a TLS-trust code anywhere in the cause chain', () => { + const r = classifyTransportFailure(networkError('UNABLE_TO_VERIFY_LEAF_SIGNATURE')); + assert.equal(r.reason, 'gateway_tls_untrusted'); + assert.match(r.hint, /--use-system-ca/); + assert.match(r.hint, /HTTPS inspection|antivirus|firewall/i); + + // Direct code on the error (no cause) is also caught. + const direct = classifyTransportFailure(Object.assign(new Error('x'), { code: 'CERT_HAS_EXPIRED' })); + assert.equal(direct.reason, 'gateway_tls_untrusted'); +}); + +test('classifyTransportFailure does NOT reframe genuine connect/DNS failures', () => { + assert.equal(classifyTransportFailure(networkError('ECONNREFUSED')).reason, null); + assert.equal(classifyTransportFailure(networkError('ENOTFOUND')).reason, null); + assert.equal(classifyTransportFailure(networkError('UND_ERR_CONNECT_TIMEOUT')).reason, null); + // causeChain still surfaces the real code for the diagnostic log. + assert.equal(causeChain(networkError('ECONNREFUSED')).at(-1).code, 'ECONNREFUSED'); +}); + +test('verify: HIGH-risk TLS-interception failure → BLOCK with gateway_tls_untrusted + hint', async () => { + const client = new ShieldClient({ + apiUrl: 'http://x', + apiKey: 'pv_live_x', + sdk: throwingSdk(networkError('UNABLE_TO_VERIFY_LEAF_SIGNATURE')), + }); + const r = await client.verify({ agentId: 'a', toolName: 'exec', input: 'rm -rf /tmp/x' }); + assert.equal(r.decision, 'BLOCK', 'fail-closed is preserved'); + assert.equal(r.reason, 'gateway_tls_untrusted', 'honest TLS reason, not the opaque generic one'); + assert.match(r.hint, /--use-system-ca/, 'actionable hint surfaced'); + assert.equal(r._fallback, true); +}); + +test('verify: HIGH-risk genuine connect failure → BLOCK keeps gateway_unavailable_failclosed (no hint)', async () => { + const client = new ShieldClient({ + apiUrl: 'http://x', + apiKey: 'pv_live_x', + sdk: throwingSdk(networkError('ECONNREFUSED')), + }); + const r = await client.verify({ agentId: 'a', toolName: 'exec', input: 'rm -rf /tmp/x' }); + assert.equal(r.decision, 'BLOCK'); + assert.equal(r.reason, 'gateway_unavailable_failclosed', 'real network errors keep the generic reason'); + assert.equal(r.hint, undefined, 'no TLS hint for a non-TLS failure'); +}); diff --git a/test/tool-normalization.test.mjs b/test/tool-normalization.test.mjs new file mode 100644 index 0000000..9d61312 --- /dev/null +++ b/test/tool-normalization.test.mjs @@ -0,0 +1,56 @@ +// test/tool-normalization.test.mjs +// Goal 2b — the native→capability map (BEFUND Strang B). Proves the dogfood- +// critical tools land on the right gateway key (and therefore the right +// enforcement mode under the Goal-1 preset), case/separator variants collapse, +// and unknown tools stay key-without-':' (gateway external → REQUIRE_APPROVAL). +// +// Runs with the built-in Node test runner: `node --test`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeToolName } from '../src/tool-normalization.mjs'; + +test('maps the dogfood-critical + representative tools to their capability keys', () => { + // communication → ALLOW + assert.equal(normalizeToolName('send_email'), 'email:send'); + assert.equal(normalizeToolName('send_message'), 'slack:send'); + // external read via api:get override → ALLOW (web_search keeps the unattended run alive) + assert.equal(normalizeToolName('web_search'), 'api:get'); + assert.equal(normalizeToolName('fetch_url'), 'api:get'); + assert.equal(normalizeToolName('git_status'), 'api:get'); + // external write → REQUIRE_APPROVAL + assert.equal(normalizeToolName('http_request'), 'api:post'); + assert.equal(normalizeToolName('git_push'), 'api:post'); + // infrastructure → DENY + assert.equal(normalizeToolName('exec'), 'infra:code_exec'); + assert.equal(normalizeToolName('install_package'), 'infra:code_exec'); + // data + assert.equal(normalizeToolName('read_file'), 'files:read'); + assert.equal(normalizeToolName('write_file'), 'files:write'); + assert.equal(normalizeToolName('delete_file'), 'files:delete'); + assert.equal(normalizeToolName('sql_query'), 'db:query'); + assert.equal(normalizeToolName('drop_table'), 'db:mutate'); + // payments + assert.equal(normalizeToolName('payment'), 'payment:execute'); +}); + +test('collapses case + separator variants to one key (same as classifyRisk)', () => { + assert.equal(normalizeToolName('send-email'), 'email:send'); + assert.equal(normalizeToolName('Send Email'), 'email:send'); + assert.equal(normalizeToolName('SEND_EMAIL'), 'email:send'); + assert.equal(normalizeToolName('Web Search'), 'api:get'); +}); + +test('unknown tool → normalized key WITHOUT a ":" (gateway external → REQUIRE_APPROVAL)', () => { + const out = normalizeToolName('foobar_tool'); + assert.equal(out, 'foobar_tool'); + assert.ok(!out.includes(':'), 'unknown tool must not synthesize a capability prefix'); + // separator-normalized but still unmapped + assert.equal(normalizeToolName('Some New Tool'), 'some_new_tool'); +}); + +test('falsy input is passed through unchanged (no throw)', () => { + assert.equal(normalizeToolName(''), ''); + assert.equal(normalizeToolName(undefined), undefined); + assert.equal(normalizeToolName(null), null); +});