From 3dc23cfc453b69d9e46320db922ac6958bd2989f Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 16 Jun 2026 11:15:28 +0200 Subject: [PATCH 01/13] fix(openclaw): rebuild agent-shield on @palveron/sdk with a tiered fail policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the launch blockers from ANALYSE_AGENT_SHIELD_LAUNCH_READINESS.md. Root cause (B1): agent-shield hand-built the /verify body, drifting from the gateway contract (gateway requires top-level `prompt`, reads tool_name from context and agent_id from metadata). The mismatch made every verify fail server-side and the blanket fail-open swallowed it into a silent ALLOW — the agent ran ungoverned. Fix: delegate verify/health/listPolicies to @palveron/sdk, the single source of truth for the wire contract (+ retries + circuit breaker). One contract, one implementation, no drift. - B1: src/client.mjs is now a thin facade over @palveron/sdk; new contract test asserts the on-the-wire body (prompt top-level, context.tool_name, metadata.agent_id) through the real SDK. - B2: init preflight uses sdk.health() → real GET /health (was /api/v1/health, which 404s in prod and killed init at step 1). - B3: new src/fail-policy.mjs — fail-LOUD on contract/auth errors (never ALLOW); transport failures tiered by risk (HIGH → fail-closed BLOCK, MEDIUM/LOW → fail-open ALLOW); AGENT_SHIELD_FAIL_CLOSED override. MCP catch returns ERROR, never silent ALLOW. - B4: README/SKILL — honest advisory wording (no "blocks before they execute" guarantee); hard in-path hook called out as roadmap. - B5: correct MCP invocation everywhere (npx -y -p @palveron/agent-shield agent-shield-mcp) — agent-shield-mcp is a bin, not a standalone package. - B6: removed dead BYOM LLM-key forwarding; BYOM is configured server-side (dashboard). README/SKILL/help corrected. - B7: package.json publishConfig.access=public. - B8: added MIT LICENSE; README license aligned to MIT. - B10: init reports the real activated+created policy count (no hardcoded "8"). - Dropped the now-redundant client circuit-breaker (SDK owns resilience). - Removed agent-shield.zip artifact; added .gitignore and CI (node 18/20/22). Note: supersedes the local-only WIP commit cfbde5a ("honor body.decision on 4xx") — the SDK handles that contract natively. Tests: 13 passing (node --test). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 26 +++ .gitignore | 4 + LICENSE | 21 +++ README.md | 134 ++++++++++---- SKILL.md | 35 +++- agent-shield.zip | Bin 15682 -> 0 bytes bin/agent-shield.mjs | 50 +++--- package.json | 8 +- src/circuit-breaker.mjs | 81 --------- src/client.mjs | 328 +++++++++++++++++----------------- src/fail-policy.mjs | 89 +++++++++ src/index.mjs | 14 +- src/mcp-server.mjs | 28 +-- test/circuit-breaker.test.mjs | 173 ------------------ test/contract.test.mjs | 96 ++++++++++ test/fail-policy.test.mjs | 162 +++++++++++++++++ 16 files changed, 752 insertions(+), 497 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE delete mode 100644 agent-shield.zip delete mode 100644 src/circuit-breaker.mjs create mode 100644 src/fail-policy.mjs delete mode 100644 test/circuit-breaker.test.mjs create mode 100644 test/contract.test.mjs create mode 100644 test/fail-policy.test.mjs 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..fb72edf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +*.tgz +.DS_Store 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..56770bf 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,43 +98,82 @@ 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 / LOW-risk action | **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 ``` @@ -129,21 +183,27 @@ agent-shield --help # Show all commands 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 +214,10 @@ 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. --- @@ -184,4 +245,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..8e3efa5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,14 +2,18 @@ **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 +- **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 @@ -53,13 +57,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 +86,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 7a2cd91480d5bf5e962d89b204e38b0f0ea983bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15682 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 Q`g|mPcFiWRf4%#E0NPY2l>h($ diff --git a/bin/agent-shield.mjs b/bin/agent-shield.mjs index d4b2a02..989ac78 100644 --- a/bin/agent-shield.mjs +++ b/bin/agent-shield.mjs @@ -21,12 +21,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 +42,6 @@ function createClient(config) { return new ShieldClient({ apiUrl: config.apiUrl, apiKey: config.apiKey, - llmApiKey: config.llmApiKey, }); } @@ -71,11 +69,17 @@ async function cmdInit() { // 2. Setup Shield step('Activating Shield protection rules...'); + let activatedTotal = 0; try { const result = await client.setupShield({ hostname: hostname(), }); - ok(`Shield activated: ${result.policies_activated + result.policies_created} protection rules`); + // Report the REAL number the server activated/created — never a hardcoded + // count. activated = pre-seeded system policies switched on; created = the + // OpenClaw-specific policies this call inserted. + activatedTotal = + (result.policies_activated ?? 0) + (result.policies_created ?? 0); + ok(`Shield activated: ${activatedTotal} protection rule${activatedTotal === 1 ? '' : 's'}`); if (result.agent_name) { ok(`Agent "${result.agent_name}" registered`); } @@ -96,7 +100,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 +120,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(` ${activatedTotal} protection rule${activatedTotal === 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(''); @@ -233,7 +229,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,9 +237,16 @@ 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(''); } @@ -269,7 +272,10 @@ async function updateOpenClawConfig(config) { ocConfig.mcpServers['agent-shield'] = { command: 'npx', - args: ['-y', 'agent-shield-mcp'], + // 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 || '', diff --git a/package.json b/package.json index 07a885a..e67906a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,10 @@ "scripts": { "test": "node --test" }, - "dependencies": {}, - "devDependencies": {} + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@palveron/sdk": "^1.1.0" + } } 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..4325299 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -1,206 +1,212 @@ // 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. - -import { CircuitBreaker } from './circuit-breaker.mjs'; +// 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 { Palveron } from '@palveron/sdk'; +import { classifyRisk } from './risk-classifier.mjs'; +import { isFailLoud, transportFallback } from './fail-policy.mjs'; 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'} + */ +export function normalizeDecision(sdkDecision) { + switch (sdkDecision) { + case 'BLOCKED': + return 'BLOCK'; + case 'MODIFIED': + return 'MODIFY'; + case 'PENDING_APPROVAL': + return 'APPROVAL'; + case 'PASSED': + case 'ALLOWED': + case 'FLAGGED': + case 'POLICY_CHANGE': + return 'ALLOW'; + default: + // Unknown success-class string — default to ALLOW. Failures never reach + // here: they either throw (fail-loud) or are mapped by the fail policy. + return 'ALLOW'; + } +} export class ShieldClient { + #sdk; #apiUrl; #apiKey; - #llmApiKey; - #timeout; - #circuitBreaker; /** * @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_* / pv_test_*). + * @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, - }); + // 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. + * 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 - 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} + * @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 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, - }, - }); + async verify({ agentId, toolName, input, metadata = {} }) { + const riskLevel = toolName ? classifyRisk(toolName) : 'MEDIUM'; + + try { + const res = await this.#sdk.verify({ + prompt: input ?? '', + context: toolName ? { toolName } : undefined, + metadata: { + ...metadata, + ...(agentId ? { agent_id: agentId } : {}), + source: 'agent-shield', + risk_level: riskLevel, + }, + }); + + // 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') { + return transportFallback(riskLevel, { + errorMessage: 'rate_limited', + retryAfterMs: res.retryAfterMs, + }); + } + + return { + decision: normalizeDecision(res.decision), + sdk_decision: res.decision, + reason: res.reason || null, + modified_input: res.decision === 'MODIFIED' ? res.output || null : null, + trace_id: res.traceId || null, + findings: res.findings || [], + }; + } catch (err) { + // 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; + + // Transport failure (timeout / circuit-open / network / 5xx) → tiered. + return transportFallback(riskLevel, { errorMessage: err?.message }); + } } + /** Gateway health (hits the real `/health`, via the SDK). */ + async health() { + return this.#sdk.health(); + } + + /** List active policies (via the SDK). */ + async listPolicies(env) { + return this.#sdk.listPolicies(env); + } + + // ── 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. + /** - * Initialize Shield — activates 8 protection rules for the project. - * Idempotent — safe to call multiple times. - * + * Initialize the OpenClaw Shield for the project (idempotent server-side). * @param {object} params - * @param {string} params.hostname - Machine hostname for default agent registration - * @returns {Promise} + * @param {string} params.hostname + * @returns {Promise} ShieldSetupResponse */ async setupShield({ hostname }) { - return this.#request('POST', '/api/v1/setup/openclaw-shield', { - hostname, - llm_provider: this.#llmApiKey ? 'configured' : null, - }); + return this.#shieldRequest('POST', '/api/v1/setup/openclaw-shield', { hostname }); } /** - * Get current Shield status — active policies, 24h stats. - * @returns {Promise} + * Current Shield status — active policies + 24h stats. + * @returns {Promise} ShieldStatusResponse */ async getShieldStatus() { - return this.#request('GET', '/api/v1/shield/status'); + return this.#shieldRequest('GET', '/api/v1/shield/status'); } - /** - * Health check — verify API is reachable. - * @returns {Promise<{status: string, version: string}>} - */ - async health() { - return this.#request('GET', '/api/v1/health'); - } - - // ─── Internal ────────────────────────────────────────────────────── - - 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, - }; - } - - const url = `${this.#apiUrl}${path}`; - const headers = { - 'Content-Type': 'application/json', - 'X-API-Key': this.#apiKey, - }; - - // Forward LLM key for BYOM 2-pass analysis - if (this.#llmApiKey) { - headers['X-LLM-API-Key'] = this.#llmApiKey; - } - - 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 = { + async #shieldRequest(method, path, body) { + let res; + try { + res = await fetch(`${this.#apiUrl}${path}`, { method, - headers, - signal: AbortSignal.timeout(this.#timeout), - }; - if (serializedBody !== undefined) { - fetchOptions.body = serializedBody; - } + 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}`); + } + if (!res.ok) { + const text = await res.text().catch(() => ''); + let detail = text; try { - const response = await fetch(url, fetchOptions); - - if (response.ok) { - this.#circuitBreaker.recordSuccess(); - return response.json(); - } - - // 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 - ); - } - - // 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 - ); - } - } - - // Exponential backoff before retry - if (attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt))); + detail = JSON.parse(text).error || text; + } catch { + // keep raw text } + throw new Error( + `Shield ${method} ${path} failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`, + ); } - // 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, - }; - } -} - -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/fail-policy.mjs b/src/fail-policy.mjs new file mode 100644 index 0000000..c31bbdd --- /dev/null +++ b/src/fail-policy.mjs @@ -0,0 +1,89 @@ +// 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 {NodeJS.ProcessEnv} [opts.env] - Injectable env (for tests). + * @returns {{decision: 'ALLOW'|'BLOCK', reason: string, _fallback: true, _error?: string, retry_after_ms?: number}} + */ +export function transportFallback(riskLevel, opts = {}) { + const failClosed = failClosedOverride(opts.env) || riskLevel === 'HIGH'; + if (failClosed) { + return { + decision: 'BLOCK', + reason: FAIL_CLOSED_REASON, + _fallback: true, + ...(opts.errorMessage ? { _error: opts.errorMessage } : {}), + ...(opts.retryAfterMs !== undefined ? { retry_after_ms: opts.retryAfterMs } : {}), + }; + } + return { + decision: 'ALLOW', + reason: FAIL_OPEN_REASON, + _fallback: true, + ...(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..dbfbed8 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,7 +1,15 @@ // 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 { ShieldClient, normalizeDecision } from './client.mjs'; export { classifyRisk, shouldVerify, isDestructive } from './risk-classifier.mjs'; export { startMcpServer } from './mcp-server.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..c017052 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -21,10 +21,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,11 +29,13 @@ 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'; @@ -191,12 +189,14 @@ async function handleToolCall(id, params, client, agentId) { } 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, }, }); @@ -206,25 +206,31 @@ async function handleToolCall(id, params, client, agentId) { { 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), + ...(result._fallback ? { _fallback: true } : {}), }), }, ], }); } catch (err) { - // On error, ALLOW — never block the user's workflow due to our failure + // 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(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), }), }, ], 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..7786549 --- /dev/null +++ b/test/contract.test.mjs @@ -0,0 +1,96 @@ +// 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_test_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'); + assert.equal(captured.body.context.tool_name, 'exec', 'tool_name MUST be nested under context'); + assert.equal(captured.body.metadata.agent_id, 'agent-7', 'agent_id MUST be in metadata'); + assert.equal(captured.body.metadata.source, 'agent-shield'); + assert.equal(captured.body.metadata.risk_level, 'HIGH'); + // SDK uses Bearer auth. + assert.match(captured.headers.authorization ?? '', /^Bearer pv_test_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_test_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_test_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'); +}); diff --git a/test/fail-policy.test.mjs b/test/fail-policy.test.mjs new file mode 100644 index 0000000..e0c7fdd --- /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_test_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_test_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_test_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_test_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_test_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_test_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; + } +}); From 680c5bcf99d29034e41a18785f554c81170a7613 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 16 Jun 2026 11:22:12 +0200 Subject: [PATCH 02/13] test(smoke): add live E2E smoke script (test-project only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/smoke-live.mjs drives a real gateway: health preflight, benign→ALLOW, rm -rf→BLOCK, fake AKIA→BLOCK (soft), simulated outage HIGH→fail-closed / MEDIUM→fail-open, invalid key→fail-loud (no silent ALLOW), and init E2E (real count + idempotency + status). Refuses any key not starting with pv_test_ so it can never touch the production project. Not shipped (excluded from package.json "files"). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/smoke-live.mjs | 160 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 scripts/smoke-live.mjs diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs new file mode 100644 index 0000000..a3e6091 --- /dev/null +++ b/scripts/smoke-live.mjs @@ -0,0 +1,160 @@ +#!/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: refuses to run unless PALVERON_API_KEY starts with "pv_test_". +// `init` (setupShield) writes policies + an agent into the project, so this +// must only ever run against a TEST project — never the production core key. +// +// Usage (PowerShell): +// $env:PALVERON_API_KEY="pv_test_xxx" +// $env:PALVERON_API_URL="https://gateway.palveron.com" # optional, this is the default +// node scripts/smoke-live.mjs +// +// Usage (bash): +// PALVERON_API_KEY=pv_test_xxx 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 || ''; + +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 a TEST project key (pv_test_…).'); + process.exit(2); +} +if (!API_KEY.startsWith('pv_test_')) { + console.error( + 'Refusing to run: PALVERON_API_KEY must start with "pv_test_". ' + + 'This smoke calls init (writes policies + an agent), so it must target a TEST project, never production.', + ); + process.exit(2); +} + +line(''); +line(`🛡️ agent-shield live smoke → ${API_URL}`); +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_test_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 ─────────────────────────────── + line('3) Destructive action (exec "rm -rf /") → expect BLOCK'); + { + const r = await client.verify({ agentId: 'smoke', toolName: 'exec', input: 'rm -rf /' }); + check('destructive blocked', r.decision === 'BLOCK', `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 s1 = await client.setupShield({ hostname: hostname() }); + const total1 = (s1.policies_activated ?? 0) + (s1.policies_created ?? 0); + check('setup succeeded', s1.success === true && total1 > 0, `activated=${s1.policies_activated} created=${s1.policies_created} total=${total1} agent="${s1.agent_name}"`); + check('reports 8 rules (B10 truth)', total1 === 8, `total=${total1} (warn if ≠ 8 → system-policy seeding gap)`, { hard: false }); + + const s2 = await client.setupShield({ hostname: hostname() }); + const total2 = (s2.policies_activated ?? 0) + (s2.policies_created ?? 0); + check('idempotent on re-run', s2.success === true, `2nd run total=${total2} (created should drop to ~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); +} From a5255b98eb15b0214a2158bf1b97634735e71222 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 16 Jun 2026 11:42:24 +0200 Subject: [PATCH 03/13] fix(init): read truthful policies_active count from gateway setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinated with the gateway fix (palveron/gateway) that removes the schema-invalid SYSTEM-policy slug activation (setup returned 500 because `UPDATE "Policy" ... WHERE slug = $2` hit a non-existent column) and now returns `policies_active` — the real number of ACTIVE shield/system policies, stable across idempotent re-runs. - init prefers result.policies_active; falls back to the legacy activated+created sum for older gateways. - smoke-live.mjs: assert setup succeeds with active>0 (no 500) and that an idempotent re-run reports the SAME active count (created drops to ~0). Dropped the incorrect hardcoded "== 8" expectation. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/agent-shield.mjs | 18 ++++++++++-------- scripts/smoke-live.mjs | 11 ++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/bin/agent-shield.mjs b/bin/agent-shield.mjs index 989ac78..4fd3d5a 100644 --- a/bin/agent-shield.mjs +++ b/bin/agent-shield.mjs @@ -69,17 +69,19 @@ async function cmdInit() { // 2. Setup Shield step('Activating Shield protection rules...'); - let activatedTotal = 0; + let activeTotal = 0; try { const result = await client.setupShield({ hostname: hostname(), }); - // Report the REAL number the server activated/created — never a hardcoded - // count. activated = pre-seeded system policies switched on; created = the - // OpenClaw-specific policies this call inserted. - activatedTotal = - (result.policies_activated ?? 0) + (result.policies_created ?? 0); - ok(`Shield activated: ${activatedTotal} protection rule${activatedTotal === 1 ? '' : 's'}`); + // 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`); } @@ -120,7 +122,7 @@ async function cmdInit() { log(''); log(' ✅ Shield is active. Your agent is protected.'); log(''); - log(` ${activatedTotal} protection rule${activatedTotal === 1 ? '' : 's'} now enforcing for this project.`); + log(` ${activeTotal} protection rule${activeTotal === 1 ? '' : 's'} now enforcing for this project.`); log(''); log(' Run "agent-shield status" to see the active rules and 24h stats.'); log(''); diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs index a3e6091..41fc142 100644 --- a/scripts/smoke-live.mjs +++ b/scripts/smoke-live.mjs @@ -131,14 +131,15 @@ try { // 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 total1 = (s1.policies_activated ?? 0) + (s1.policies_created ?? 0); - check('setup succeeded', s1.success === true && total1 > 0, `activated=${s1.policies_activated} created=${s1.policies_created} total=${total1} agent="${s1.agent_name}"`); - check('reports 8 rules (B10 truth)', total1 === 8, `total=${total1} (warn if ≠ 8 → system-policy seeding gap)`, { hard: false }); + 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 total2 = (s2.policies_activated ?? 0) + (s2.policies_created ?? 0); - check('idempotent on re-run', s2.success === true, `2nd run total=${total2} (created should drop to ~0)`); + 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}`); From b53da947fa491199ee83563e2878b868693b3e51 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 16 Jun 2026 12:53:26 +0200 Subject: [PATCH 04/13] fix(hardening): close silent-ALLOW paths + truthful risk model (F1,F4,F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up pre-publish hardening on top of the SDK migration. - F1 (security): a governance_check missing tool_name/input no longer returns a silent ALLOW — it returns decision ERROR (missing_required_field). That was the one reachable bypass; without tool_name the risk level isn't even knowable. - F4 (correctness): remove the dead LOW-risk skip branch in mcp-server — every check now goes through verify (evaluated AND traced; a local skip would ALLOW without a gateway call, making the action invisible in the dashboard). Removed the now-trivial `shouldVerify` export; classifyRisk doc made truthful (no LOW tier; unknown → MEDIUM; risk only drives the outage fail policy). SKILL.md + README risk model aligned (HIGH→verify/fail-closed, MEDIUM incl. unknown→ verify/fail-open, no skip). - F5 (hardening): an unrecognized gateway verdict is no longer silently mapped to ALLOW. verify tiers it: HIGH → BLOCK (unknown_decision_failclosed, _anomaly), else ALLOW + _anomaly + console.warn. Added KNOWN_SDK_DECISIONS. - F2 (user value): updateOpenClawConfig now finds ~/.openclaw/openclaw.json (the real OpenClaw global default) first, so the zero-config wiring actually fires. Extracted to src/openclaw-config.mjs with injectable home/cwd for testing; preserves existing mcpServers entries. - Quick wins: bin header comment no longer says "8 rules"; cmdTest no longer defaults a missing decision to ALLOW (shows ERROR). Tests: node --test → 22 passing (+9: F1/F2/F4/F5). New files: test/hardening, test/mcp-server (stdio + mock gateway), test/openclaw-config. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- SKILL.md | 17 ++--- bin/agent-shield.mjs | 54 ++-------------- src/client.mjs | 47 +++++++++++++- src/index.mjs | 5 +- src/mcp-server.mjs | 32 ++++------ src/openclaw-config.mjs | 66 +++++++++++++++++++ src/risk-classifier.mjs | 30 +++------ test/hardening.test.mjs | 65 +++++++++++++++++++ test/mcp-server.test.mjs | 117 ++++++++++++++++++++++++++++++++++ test/openclaw-config.test.mjs | 80 +++++++++++++++++++++++ 11 files changed, 415 insertions(+), 100 deletions(-) create mode 100644 src/openclaw-config.mjs create mode 100644 test/hardening.test.mjs create mode 100644 test/mcp-server.test.mjs create mode 100644 test/openclaw-config.test.mjs diff --git a/README.md b/README.md index 56770bf..4845403 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ governance: | 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 / LOW-risk action | **Fail OPEN** — `ALLOW`, so a transient outage never blocks ordinary work | +| Gateway unreachable, MEDIUM-risk action (incl. any unknown tool) | **Fail OPEN** — `ALLOW`, so a transient outage never blocks ordinary work | Set `AGENT_SHIELD_FAIL_CLOSED=true` to fail closed for **all** risk levels when the gateway is unreachable (maximum safety; availability traded away). diff --git a/SKILL.md b/SKILL.md index 8e3efa5..da54039 100644 --- a/SKILL.md +++ b/SKILL.md @@ -17,9 +17,12 @@ Before executing any HIGH-RISK tool call, call `governance_check`. It returns on ## 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 @@ -28,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 diff --git a/bin/agent-shield.mjs b/bin/agent-shield.mjs index 4fd3d5a..4029224 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 ────────────────────────────────────────────── @@ -210,9 +209,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}`); } @@ -252,49 +255,6 @@ function cmdHelp() { 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', - // 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 || '', - }, - }; - - 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 ────────────────────────────────────────────────── function log(msg) { console.log(msg); } diff --git a/src/client.mjs b/src/client.mjs index 4325299..8693c7d 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -30,6 +30,22 @@ const SHIELD_REQUEST_TIMEOUT_MS = 10000; * @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', + 'BLOCKED', + 'MODIFIED', + 'PENDING_APPROVAL', + 'RATE_LIMITED', +]); + export function normalizeDecision(sdkDecision) { switch (sdkDecision) { case 'BLOCKED': @@ -44,8 +60,9 @@ export function normalizeDecision(sdkDecision) { case 'POLICY_CHANGE': return 'ALLOW'; default: - // Unknown success-class string — default to ALLOW. Failures never reach - // here: they either throw (fail-loud) or are mapped by the fail policy. + // 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'; } } @@ -126,6 +143,32 @@ export class ShieldClient { }); } + // 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, + }; + } + return { decision: normalizeDecision(res.decision), sdk_decision: res.decision, diff --git a/src/index.mjs b/src/index.mjs index dbfbed8..faa4f85 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -3,9 +3,10 @@ // This package is a thin OpenClaw client over @palveron/sdk — NO proprietary // logic, NO duplicated verify contract. -export { ShieldClient, normalizeDecision } 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, diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index c017052..5dc10fb 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -10,7 +10,7 @@ // 3. Returns ALLOW/BLOCK/MODIFY decisions import { ShieldClient } from './client.mjs'; -import { classifyRisk, shouldVerify } from './risk-classifier.mjs'; +import { classifyRisk } from './risk-classifier.mjs'; const PROTOCOL_VERSION = '2024-11-05'; @@ -156,31 +156,19 @@ async function handleToolCall(id, params, client, agentId) { 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, { 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', }), }, ], @@ -188,6 +176,10 @@ 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 → diff --git a/src/openclaw-config.mjs b/src/openclaw-config.mjs new file mode 100644 index 0000000..b5fc33e --- /dev/null +++ b/src/openclaw-config.mjs @@ -0,0 +1,66 @@ +// 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}} 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 || '', + }, + }; + + 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/test/hardening.test.mjs b/test/hardening.test.mjs new file mode 100644 index 0000000..97e54b9 --- /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_test_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_test_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_test_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..838e5bc --- /dev/null +++ b/test/mcp-server.test.mjs @@ -0,0 +1,117 @@ +// 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_test_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)'); + assert.equal(verifyCalls[0].context.tool_name, 'list_directory'); + } 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..622cb59 --- /dev/null +++ b/test/openclaw-config.test.mjs @@ -0,0 +1,80 @@ +// 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_test_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_test_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 }); + } +}); From eccc1721c5c39706589f7f3b3accf7bf8a7c31c7 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 16 Jun 2026 17:23:14 +0200 Subject: [PATCH 05/13] test(smoke): accept APPROVAL for destructive #3 + throwaway pv_live_ override - #3 (rm -rf): the catalog provisions destructive_action as REQUIRE_APPROVAL, so a governed destructive command surfaces as APPROVAL, not BLOCK. Accept BLOCK OR APPROVAL as success (Goal A-2 acceptance #4). - Guard: allow a pv_live_ key only with SMOKE_I_UNDERSTAND=throwaway-project (dedicated throwaway project, never core) + a visible warning; pv_test_ runs as before. Local test helper, not shipped (excluded from package.json files). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/smoke-live.mjs | 43 +++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs index 41fc142..4d85b68 100644 --- a/scripts/smoke-live.mjs +++ b/scripts/smoke-live.mjs @@ -3,9 +3,11 @@ // 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: refuses to run unless PALVERON_API_KEY starts with "pv_test_". -// `init` (setupShield) writes policies + an agent into the project, so this -// must only ever run against a TEST project — never the production core key. +// SAFETY: `init` (setupShield) writes policies + an agent into the project, so +// this must only ever run against a disposable project — never the production +// core key. The default guard requires a TEST key (pv_test_…). A pv_live_ key +// is allowed ONLY with an explicit, conscious override for a dedicated +// throwaway project (SMOKE_I_UNDERSTAND="throwaway-project"). // // Usage (PowerShell): // $env:PALVERON_API_KEY="pv_test_xxx" @@ -15,6 +17,9 @@ // Usage (bash): // PALVERON_API_KEY=pv_test_xxx node scripts/smoke-live.mjs // +// Override for a dedicated throwaway project with a pv_live_ key (NEVER core): +// SMOKE_I_UNDERSTAND=throwaway-project PALVERON_API_KEY=pv_live_xxx node scripts/smoke-live.mjs +// // Exit code 0 = all hard expectations met. Non-zero = a hard check failed. import { hostname } from 'node:os'; @@ -41,12 +46,21 @@ if (!API_KEY) { console.error('Missing PALVERON_API_KEY. Set a TEST project key (pv_test_…).'); process.exit(2); } + +// A pv_test_ key runs as before. Any other key (e.g. pv_live_) runs ONLY with a +// conscious override for a dedicated throwaway project — never the core key. if (!API_KEY.startsWith('pv_test_')) { - console.error( - 'Refusing to run: PALVERON_API_KEY must start with "pv_test_". ' + - 'This smoke calls init (writes policies + an agent), so it must target a TEST project, never production.', - ); - process.exit(2); + if (process.env.SMOKE_I_UNDERSTAND !== 'throwaway-project') { + console.error( + 'Refusing to run: PALVERON_API_KEY does not start with "pv_test_".\n' + + 'This smoke calls init (writes policies + an agent), so a non-test key is only\n' + + 'allowed against a DEDICATED THROWAWAY project — NEVER the production core key.\n' + + 'To override, set SMOKE_I_UNDERSTAND="throwaway-project" (and be certain the key\n' + + 'belongs to a disposable project).', + ); + process.exit(2); + } + console.warn('⚠️ running against a pv_live_ key — throwaway project only'); } line(''); @@ -81,11 +95,18 @@ try { } line(''); - // 3. Destructive action → expect BLOCK ─────────────────────────────── - line('3) Destructive action (exec "rm -rf /") → expect BLOCK'); + // 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 blocked', r.decision === 'BLOCK', `decision=${r.decision} sdk=${r.sdk_decision} reason=${r.reason}`); + check( + 'destructive gated', + r.decision === 'BLOCK' || r.decision === 'APPROVAL', + `decision=${r.decision} sdk=${r.sdk_decision} reason=${r.reason}`, + ); } line(''); From f6c301922736d77b700a910a3b5c7bca22e64461 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Mon, 22 Jun 2026 13:43:22 +0200 Subject: [PATCH 06/13] test(smoke): replace SMOKE_I_UNDERSTAND heuristic with dedicated throwaway project + .env.smoke.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke guard required a pv_test_ key by default and hid a non-test run behind a self-invented SMOKE_I_UNDERSTAND="throwaway-project" sentinel. No pv_test_ key can be minted, so that default path was dead and the override was the only way to run — a hidden crutch. There is no test/sandbox key today; isolation comes from a dedicated disposable project, not a key prefix. - Drop the pv_test_/SMOKE_I_UNDERSTAND heuristic. Require PALVERON_API_KEY + PALVERON_SMOKE_PROJECT (naming the throwaway project is the conscious confirmation that you are pointing at a disposable target). - Add committed .env.smoke.example + gitignore .env.smoke; run via `node --env-file=.env.smoke scripts/smoke-live.mjs` (no hidden env magic). - Document the live-smoke flow in the README. - Fix pv_test_ → pv_live_ across the bad-key fixture, test fixtures and the client.mjs apiKey doc comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.smoke.example | 24 ++++++++++++++ .gitignore | 4 +++ README.md | 30 +++++++++++++++++ scripts/smoke-live.mjs | 62 ++++++++++++++++++----------------- src/client.mjs | 2 +- test/contract.test.mjs | 8 ++--- test/fail-policy.test.mjs | 12 +++---- test/hardening.test.mjs | 6 ++-- test/mcp-server.test.mjs | 2 +- test/openclaw-config.test.mjs | 4 +-- 10 files changed, 107 insertions(+), 47 deletions(-) create mode 100644 .env.smoke.example 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/.gitignore b/.gitignore index fb72edf..e903620 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ package-lock.json *.tgz .DS_Store + +# Live-smoke config — holds a real pv_live_ key. Never commit. +.env.smoke +.env diff --git a/README.md b/README.md index 4845403..5d1477d 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,36 @@ BYOM model keys are configured in the dashboard (**Settings → Neural Gateway** --- +## 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. + +--- + ## Tiers | | Community | Pro | Business | Enterprise | diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs index 4d85b68..40bab3f 100644 --- a/scripts/smoke-live.mjs +++ b/scripts/smoke-live.mjs @@ -3,22 +3,22 @@ // 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 disposable project — never the production -// core key. The default guard requires a TEST key (pv_test_…). A pv_live_ key -// is allowed ONLY with an explicit, conscious override for a dedicated -// throwaway project (SMOKE_I_UNDERSTAND="throwaway-project"). +// 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). // -// Usage (PowerShell): -// $env:PALVERON_API_KEY="pv_test_xxx" -// $env:PALVERON_API_URL="https://gateway.palveron.com" # optional, this is the default -// node scripts/smoke-live.mjs +// 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: // -// Usage (bash): -// PALVERON_API_KEY=pv_test_xxx node scripts/smoke-live.mjs +// node --env-file=.env.smoke scripts/smoke-live.mjs // -// Override for a dedicated throwaway project with a pv_live_ key (NEVER core): -// SMOKE_I_UNDERSTAND=throwaway-project PALVERON_API_KEY=pv_live_xxx node 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. @@ -27,6 +27,7 @@ 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); @@ -43,28 +44,29 @@ function check(name, ok, detail, { hard = true } = {}) { // ── Guards ─────────────────────────────────────────────────────────── if (!API_KEY) { - console.error('Missing PALVERON_API_KEY. Set a TEST project key (pv_test_…).'); + console.error( + 'Missing PALVERON_API_KEY. Set the pv_live_ key of a dedicated throwaway\n' + + 'project (see .env.smoke.example).', + ); process.exit(2); } -// A pv_test_ key runs as before. Any other key (e.g. pv_live_) runs ONLY with a -// conscious override for a dedicated throwaway project — never the core key. -if (!API_KEY.startsWith('pv_test_')) { - if (process.env.SMOKE_I_UNDERSTAND !== 'throwaway-project') { - console.error( - 'Refusing to run: PALVERON_API_KEY does not start with "pv_test_".\n' + - 'This smoke calls init (writes policies + an agent), so a non-test key is only\n' + - 'allowed against a DEDICATED THROWAWAY project — NEVER the production core key.\n' + - 'To override, set SMOKE_I_UNDERSTAND="throwaway-project" (and be certain the key\n' + - 'belongs to a disposable project).', - ); - process.exit(2); - } - console.warn('⚠️ running against a pv_live_ key — throwaway project only'); +// 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}`); +line(`🛡️ agent-shield live smoke → ${API_URL} (throwaway project: ${SMOKE_PROJECT})`); line(''); const client = new ShieldClient({ apiUrl: API_URL, apiKey: API_KEY, maxRetries: 1 }); @@ -73,7 +75,7 @@ const client = new ShieldClient({ apiUrl: API_URL, apiKey: API_KEY, maxRetries: 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_test_definitely_invalid_key', maxRetries: 0 }); +const badKeyClient = new ShieldClient({ apiUrl: API_URL, apiKey: 'pv_live_definitely_invalid_key', maxRetries: 0 }); try { // 1. Health preflight (proves the /health path, B2) ────────────────── diff --git a/src/client.mjs b/src/client.mjs index 8693c7d..b20aaf3 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -75,7 +75,7 @@ export class ShieldClient { /** * @param {object} options * @param {string} options.apiUrl - Base URL of the governance gateway. - * @param {string} options.apiKey - Project API key (pv_live_* / pv_test_*). + * @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 diff --git a/test/contract.test.mjs b/test/contract.test.mjs index 7786549..9f33e06 100644 --- a/test/contract.test.mjs +++ b/test/contract.test.mjs @@ -47,7 +47,7 @@ test('verify body matches the gateway contract: prompt top-level, context.tool_n 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_test_x', maxRetries: 0 }); + 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' }); }, ); @@ -60,7 +60,7 @@ test('verify body matches the gateway contract: prompt top-level, context.tool_n assert.equal(captured.body.metadata.source, 'agent-shield'); assert.equal(captured.body.metadata.risk_level, 'HIGH'); // SDK uses Bearer auth. - assert.match(captured.headers.authorization ?? '', /^Bearer pv_test_x$/); + assert.match(captured.headers.authorization ?? '', /^Bearer pv_live_x$/); // A clean PASSED is normalized to the agent-facing ALLOW. assert.equal(result.decision, 'ALLOW'); @@ -72,7 +72,7 @@ test('a real BLOCKED verdict (HTTP 403 + body) surfaces as BLOCK, not an error o 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_test_x', maxRetries: 0 }); + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); return client.verify({ agentId: 'a', toolName: 'exec', input: 'echo AKIAIOSFODNN7EXAMPLE' }); }, ); @@ -87,7 +87,7 @@ 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_test_x', maxRetries: 0 }); + 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' }); }, ); diff --git a/test/fail-policy.test.mjs b/test/fail-policy.test.mjs index e0c7fdd..6802123 100644 --- a/test/fail-policy.test.mjs +++ b/test/fail-policy.test.mjs @@ -75,7 +75,7 @@ test('failClosedOverride parses truthy/falsey correctly', () => { 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_test_x', maxRetries: 0, timeout: 400 }); + 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'); @@ -85,7 +85,7 @@ test('HIGH-risk transport failure (5xx) → fail-CLOSED BLOCK, never silent ALLO 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_test_x', maxRetries: 0, timeout: 400 }); + 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'); @@ -94,7 +94,7 @@ test('MEDIUM-risk transport failure (5xx) → fail-OPEN ALLOW', async () => { 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_test_x', maxRetries: 0 }); + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); await assert.rejects( () => client.verify({ agentId: 'a', toolName: 'exec', input: 'x' }), (err) => { @@ -108,7 +108,7 @@ test('validation error (HTTP 400) → FAIL-LOUD: throws, does NOT return ALLOW', 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_test_x', maxRetries: 0 }); + const client = new ShieldClient({ apiUrl: baseUrl, apiKey: 'pv_live_x', maxRetries: 0 }); await assert.rejects( () => client.verify({ agentId: 'a', toolName: 'exec', input: 'x' }), (err) => { @@ -132,7 +132,7 @@ test('rate-limit (HTTP 429) on HIGH risk → tiered fail-closed BLOCK with retry try { const client = new ShieldClient({ apiUrl: `http://127.0.0.1:${port}`, - apiKey: 'pv_test_x', + apiKey: 'pv_live_x', maxRetries: 0, timeout: 400, }); @@ -150,7 +150,7 @@ test('override AGENT_SHIELD_FAIL_CLOSED=true: MEDIUM-risk transport failure → 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_test_x', maxRetries: 0, timeout: 400 }); + 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'); diff --git a/test/hardening.test.mjs b/test/hardening.test.mjs index 97e54b9..17f2ef3 100644 --- a/test/hardening.test.mjs +++ b/test/hardening.test.mjs @@ -29,7 +29,7 @@ async function quiet(fn) { } 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_test_x', sdk: fakeSdk('WAT') }); + 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'); @@ -38,7 +38,7 @@ test('F5: unknown decision on a HIGH-risk tool → fail-closed BLOCK, flagged an }); test('F5: unknown decision on a MEDIUM-risk tool → ALLOW but flagged anomaly', async () => { - const client = new ShieldClient({ apiUrl: 'http://x', apiKey: 'pv_test_x', sdk: fakeSdk('WAT') }); + 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'); @@ -47,7 +47,7 @@ test('F5: unknown decision on a MEDIUM-risk tool → ALLOW but flagged anomaly', }); test('F5: known decisions still pass through normally (no anomaly)', async () => { - const client = new ShieldClient({ apiUrl: 'http://x', apiKey: 'pv_test_x', sdk: fakeSdk('PASSED') }); + 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); diff --git a/test/mcp-server.test.mjs b/test/mcp-server.test.mjs index 838e5bc..7750f64 100644 --- a/test/mcp-server.test.mjs +++ b/test/mcp-server.test.mjs @@ -39,7 +39,7 @@ function startMockGateway() { 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_test_x' }, + env: { ...process.env, PALVERON_API_URL: baseUrl, PALVERON_API_KEY: 'pv_live_x' }, stdio: ['pipe', 'pipe', 'pipe'], }); let out = ''; diff --git a/test/openclaw-config.test.mjs b/test/openclaw-config.test.mjs index 622cb59..e52de6b 100644 --- a/test/openclaw-config.test.mjs +++ b/test/openclaw-config.test.mjs @@ -28,7 +28,7 @@ test('writes agent-shield into ~/.openclaw/openclaw.json and preserves foreign e ); const updated = await updateOpenClawConfig( - { apiUrl: 'https://gateway.palveron.com', apiKey: 'pv_test_x' }, + { apiUrl: 'https://gateway.palveron.com', apiKey: 'pv_live_x' }, { home, cwd: emptyCwd }, ); @@ -43,7 +43,7 @@ test('writes agent-shield into ~/.openclaw/openclaw.json and preserves foreign e 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_test_x'); + 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 }); From d3955058769c195e2a9a8cae6aca42ad4c459695 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 23 Jun 2026 11:02:24 +0200 Subject: [PATCH 07/13] fix(mcp): robust JSON-RPC stdio framing (NDJSON default + Content-Length tolerance) --- src/mcp-server.mjs | 83 +++++------------ src/stdio-transport.mjs | 94 ++++++++++++++++++++ test/stdio-transport.test.mjs | 163 ++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 60 deletions(-) create mode 100644 src/stdio-transport.mjs create mode 100644 test/stdio-transport.test.mjs diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index 5dc10fb..100c91d 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -11,6 +11,7 @@ import { ShieldClient } from './client.mjs'; import { classifyRisk } from './risk-classifier.mjs'; +import { createStdioTransport } from './stdio-transport.mjs'; const PROTOCOL_VERSION = '2024-11-05'; @@ -40,50 +41,24 @@ export async function startMcpServer() { 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; - - const body = buffer.slice(bodyStart, bodyStart + contentLength); - buffer = buffer.slice(bodyStart + contentLength); - - try { - const message = JSON.parse(body); - handleMessage(message, client, agentId); - } catch { - writeError('Failed to parse JSON-RPC message'); - } - } - } + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => transport.push(chunk)); } -async function handleMessage(message, client, agentId) { +async function handleMessage(message, client, agentId, transport) { const { id, method, params } = message; switch (method) { case 'initialize': - sendResponse(id, { + sendResponse(transport, id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: { @@ -98,7 +73,7 @@ async function handleMessage(message, client, agentId) { break; case 'tools/list': - sendResponse(id, { + sendResponse(transport, id, { tools: [ { name: 'governance_check', @@ -134,22 +109,22 @@ 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; } @@ -161,7 +136,7 @@ async function handleToolCall(id, params, client, agentId) { // 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', @@ -193,7 +168,7 @@ async function handleToolCall(id, params, client, agentId) { }, }); - sendResponse(id, { + sendResponse(transport, id, { content: [ { type: 'text', @@ -214,7 +189,7 @@ async function handleToolCall(id, params, client, agentId) { // 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(id, { + sendResponse(transport, id, { content: [ { type: 'text', @@ -230,24 +205,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/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/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'); +}); From 94674a56ffdedbca682028c5db4dc664c52ebe11 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 23 Jun 2026 13:02:26 +0200 Subject: [PATCH 08/13] fix(decisions): map ANONYMIZED/REDACTED to MODIFY, preserve nuance in sdk_decision+reason --- SKILL.md | 2 +- src/client.mjs | 44 +++++++++++++++++++++++++++++++++--- test/contract.test.mjs | 51 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/SKILL.md b/SKILL.md index da54039..aba2ec4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -11,7 +11,7 @@ agent-shield records everything your OpenClaw agent does, and asks you — the a 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. diff --git a/src/client.mjs b/src/client.mjs index b20aaf3..871622f 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -40,17 +40,48 @@ export const KNOWN_SDK_DECISIONS = new Set([ 'ALLOWED', 'FLAGGED', 'POLICY_CHANGE', - 'BLOCKED', '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'; @@ -171,9 +202,16 @@ export class ShieldClient { return { 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, - reason: res.reason || null, - modified_input: res.decision === 'MODIFIED' ? res.output || null : null, + // 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 || [], }; diff --git a/test/contract.test.mjs b/test/contract.test.mjs index 9f33e06..2bddec4 100644 --- a/test/contract.test.mjs +++ b/test/contract.test.mjs @@ -94,3 +94,54 @@ test('a MODIFIED verdict surfaces as MODIFY with the sanitized output', async () 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; + } +}); From 03b82b21b9dcc37c79dfa9bef1bba0902befe7ed Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 23 Jun 2026 14:25:15 +0200 Subject: [PATCH 09/13] diag(agent-shield): env-gated file diagnostics for spawn fail-closed (Fund #5) --- .gitignore | 4 + BEFUND_AGENT_SHIELD_SPAWN.md | 183 +++++++++++++++++++++++++++++++++++ README.md | 27 ++++++ bin/agent-shield-mcp.mjs | 29 ++++++ src/client.mjs | 130 ++++++++++++++++++++++++- src/debug-log.mjs | 135 ++++++++++++++++++++++++++ src/mcp-server.mjs | 22 +++++ test/debug-log.test.mjs | 114 ++++++++++++++++++++++ 8 files changed, 641 insertions(+), 3 deletions(-) create mode 100644 BEFUND_AGENT_SHIELD_SPAWN.md create mode 100644 src/debug-log.mjs create mode 100644 test/debug-log.test.mjs diff --git a/.gitignore b/.gitignore index e903620..717a9d6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ package-lock.json # 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..d7f47b0 --- /dev/null +++ b/BEFUND_AGENT_SHIELD_SPAWN.md @@ -0,0 +1,183 @@ +# 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. diff --git a/README.md b/README.md index 5d1477d..e743605 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,33 @@ 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. + +--- + ## Architecture agent-shield is a **thin client**. It contains: diff --git a/bin/agent-shield-mcp.mjs b/bin/agent-shield-mcp.mjs index 33ce2c1..e856168 100644 --- a/bin/agent-shield-mcp.mjs +++ b/bin/agent-shield-mcp.mjs @@ -4,8 +4,37 @@ // This is referenced in openclaw.json: "command": "npx", "args": ["-y", "agent-shield-mcp"] import { startMcpServer } from '../src/mcp-server.mjs'; +import { dlog, keyMeta, collectProxyEnv } 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). Reads the same env aliases +// the client reads (mcp-server.mjs: PALVERON_* preferred, AGENT_SHIELD_* fallback). +// Secret-safe: the key appears only as presence + length. +{ + 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, + nodeVersion: process.version, + 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(), + nodeOptions: process.env.NODE_OPTIONS ?? null, + failClosedOverride: process.env.AGENT_SHIELD_FAIL_CLOSED ?? null, + }); +} 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/src/client.mjs b/src/client.mjs index 871622f..0684100 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -20,6 +20,30 @@ import { Palveron } from '@palveron/sdk'; import { classifyRisk } from './risk-classifier.mjs'; import { isFailLoud, transportFallback } from './fail-policy.mjs'; +import { dlog } from './debug-log.mjs'; + +/** + * 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 SHIELD_REQUEST_TIMEOUT_MS = 10000; @@ -102,6 +126,7 @@ export class ShieldClient { #sdk; #apiUrl; #apiKey; + #timeoutMs; /** * @param {object} options @@ -118,6 +143,7 @@ export class ShieldClient { this.#apiUrl = apiUrl.replace(/\/+$/, ''); this.#apiKey = apiKey; + 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 @@ -137,6 +163,38 @@ export class ShieldClient { return this.#sdk.diagnostics().circuitState; } + /** + * 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} + */ + #circuitStateSafe() { + try { + return this.#sdk.diagnostics().circuitState; + } catch { + return 'unknown'; + } + } + + /** + * 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} + */ + #emitBreakerDelta(before, observed) { + const after = this.#circuitStateSafe(); + if (after !== before) { + dlog('breaker', { from: before, to: after, observed }); + } + return after; + } + /** * Verify a tool call / action before execution. Every HIGH-RISK tool call * goes through here. Applies the tiered fail policy (B3): a genuine block @@ -153,6 +211,16 @@ export class ShieldClient { async verify({ agentId, toolName, input, metadata = {} }) { const riskLevel = toolName ? classifyRisk(toolName) : 'MEDIUM'; + // ── 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 ?? '', @@ -165,13 +233,26 @@ export class ShieldClient { }, }); + 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') { - return transportFallback(riskLevel, { + 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 @@ -216,19 +297,62 @@ export class ShieldClient { 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), + ...(outcome === 'timeout' ? { timeoutMs: this.#timeoutMs } : {}), + elapsedMs: Date.now() - startedAt, + }); + // 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; // Transport failure (timeout / circuit-open / network / 5xx) → tiered. - return transportFallback(riskLevel, { errorMessage: err?.message }); + const fb = transportFallback(riskLevel, { errorMessage: err?.message }); + // failclosed_emit closes the causal chain: this branch is what produced + // gateway_unavailable_failclosed (when fb.decision === 'BLOCK'). `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; } } /** Gateway health (hits the real `/health`, via the SDK). */ async health() { - return this.#sdk.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), + elapsedMs: Date.now() - startedAt, + }); + throw err; + } } /** List active policies (via the SDK). */ diff --git a/src/debug-log.mjs b/src/debug-log.mjs new file mode 100644 index 0000000..01f3de1 --- /dev/null +++ b/src/debug-log.mjs @@ -0,0 +1,135 @@ +// 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'; + +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, + }); + appendFileSync(LOG_PATH, 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; +} diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index 100c91d..c09f46b 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -12,6 +12,7 @@ import { ShieldClient } from './client.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'; @@ -56,6 +57,27 @@ export async function startMcpServer() { 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 { + ev.argBytes = args !== undefined ? Buffer.byteLength(JSON.stringify(args)) : 0; + } catch { + ev.argBytes = -1; + } + } + dlog('mcp_msg', ev); + } + switch (method) { case 'initialize': sendResponse(transport, id, { diff --git a/test/debug-log.test.mjs b/test/debug-log.test.mjs new file mode 100644 index 0000000..3469cca --- /dev/null +++ b/test/debug-log.test.mjs @@ -0,0 +1,114 @@ +// 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 } 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('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 }); + } +}); From 92bd65a19b8520ff4600d570bb7d031bdd667003 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 23 Jun 2026 14:57:30 +0200 Subject: [PATCH 10/13] diag(agent-shield): unmask SDK cause chain + spawn net selftest (Fund #5 deepening) --- BEFUND_AGENT_SHIELD_SPAWN.md | 70 ++++++++++++++++ bin/agent-shield-mcp.mjs | 35 ++++++-- src/client.mjs | 10 ++- src/debug-log.mjs | 152 ++++++++++++++++++++++++++++++++++- test/debug-log.test.mjs | 70 +++++++++++++++- 5 files changed, 329 insertions(+), 8 deletions(-) diff --git a/BEFUND_AGENT_SHIELD_SPAWN.md b/BEFUND_AGENT_SHIELD_SPAWN.md index d7f47b0..bbc8ae1 100644 --- a/BEFUND_AGENT_SHIELD_SPAWN.md +++ b/BEFUND_AGENT_SHIELD_SPAWN.md @@ -181,3 +181,73 @@ Env-Propagation-Fix, Breaker-Reset/Half-Open-Strategie oder Handshake-Ordering `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). diff --git a/bin/agent-shield-mcp.mjs b/bin/agent-shield-mcp.mjs index e856168..79cf0ab 100644 --- a/bin/agent-shield-mcp.mjs +++ b/bin/agent-shield-mcp.mjs @@ -4,21 +4,37 @@ // This is referenced in openclaw.json: "command": "npx", "args": ["-y", "agent-shield-mcp"] import { startMcpServer } from '../src/mcp-server.mjs'; -import { dlog, keyMeta, collectProxyEnv } from '../src/debug-log.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). Reads the same env aliases -// the client reads (mcp-server.mjs: PALVERON_* preferred, AGENT_SHIELD_* fallback). -// Secret-safe: the key appears only as presence + length. +// 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 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, @@ -28,11 +44,20 @@ import { dlog, keyMeta, collectProxyEnv } from '../src/debug-log.mjs'; 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`); diff --git a/src/client.mjs b/src/client.mjs index 0684100..7479abb 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -20,7 +20,7 @@ import { Palveron } from '@palveron/sdk'; import { classifyRisk } from './risk-classifier.mjs'; import { isFailLoud, transportFallback } from './fail-policy.mjs'; -import { dlog } from './debug-log.mjs'; +import { dlog, causeChain } from './debug-log.mjs'; /** * Classify a thrown SDK error into a diagnostic `outcome` for the spawn log. @@ -307,6 +307,12 @@ export class ShieldClient { errName: err?.name ?? null, errCode: err?.code ?? null, errMessage: String(err?.message ?? '').slice(0, 200), + // The SDK masks transport failures as a generic NETWORK_ERROR and DROPS + // the original error (sdk index.mjs:379-387) — so causeChain on the SDK + // error is usually empty here, which itself confirms the mask. The raw + // reason is recovered by net_selftest_fetch (raw fetch outside the SDK). + errStack: String(err?.stack ?? '').slice(0, 600), + causeChain: causeChain(err), ...(outcome === 'timeout' ? { timeoutMs: this.#timeoutMs } : {}), elapsedMs: Date.now() - startedAt, }); @@ -349,6 +355,8 @@ export class ShieldClient { 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; diff --git a/src/debug-log.mjs b/src/debug-log.mjs index 01f3de1..a01f689 100644 --- a/src/debug-log.mjs +++ b/src/debug-log.mjs @@ -32,6 +32,8 @@ import { appendFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; +import { getServers } from 'node:dns'; +import { lookup } from 'node:dns/promises'; const LOG_PATH = (process.env.AGENT_SHIELD_DEBUG_LOG_PATH ?? '').trim(); const ENABLED = LOG_PATH.length > 0; @@ -73,12 +75,20 @@ export function dlog(event, fields = {}) { event, ...fields, }); - appendFileSync(LOG_PATH, line + '\n', 'utf8'); + // 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. } } +/** Truncate a string to `n` chars (secret-safe diagnostics never log full bodies). */ +function truncate(s, n) { + const str = String(s ?? ''); + return str.length > n ? str.slice(0, n) : str; +} + /** * Secret-safe metadata about a credential: presence + length only. Never the * value, never a substring beyond what the caller explicitly chooses. @@ -133,3 +143,143 @@ export function collectProxyEnv(env = process.env) { } return out; } + +/** + * 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, CERT_*) in `err.cause` — the SDK masks the + * surface as a generic NETWORK_ERROR, so this is how the real reason surfaces. + * @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; +} + +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/test/debug-log.test.mjs b/test/debug-log.test.mjs index 3469cca..b22d309 100644 --- a/test/debug-log.test.mjs +++ b/test/debug-log.test.mjs @@ -11,7 +11,16 @@ import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { isDebugEnabled, dlog, keyMeta, sanitizeProxyUrl, collectProxyEnv } from '../src/debug-log.mjs'; +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 @@ -88,6 +97,65 @@ test('enabled: writes valid JSONL with ts/pid/monotonic-seq and no raw secret', } }); +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). From 7abda7d571add91a20770ec306fa81bb855e8f2b Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Tue, 23 Jun 2026 16:32:52 +0200 Subject: [PATCH 11/13] feat(agent-shield): honest gateway_tls_untrusted reason+hint, consume @palveron/sdk ^1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the published @palveron/sdk@1.2.0 cause-preservation fix: a TLS-trust transport failure (HTTPS-inspecting AV/firewall whose root CA Node doesn't trust) now surfaces an honest, actionable BLOCK reason (gateway_tls_untrusted) plus a remediation hint (--use-system-ca / NODE_EXTRA_CA_CERTS) instead of the opaque gateway_unavailable_failclosed — while keeping fail-closed on HIGH risk. Genuine connect/DNS/timeout failures keep the generic reason (no mis-reframe). - error-cause.mjs: dependency-free causeChain + classifyTransportFailure (TLS trust codes), usable on both the diagnostic and live governance paths. - client.mjs: inspect the (now-preserved) SDK cause chain; tiered fail policy. - fail-policy.mjs: optional reason/hint override on the fail-closed branch. - mcp-server.mjs: surface the hint in the governance_check response. - README: AV/firewall TLS-inspection troubleshooting (never disable verification). - package.json: bump @palveron/sdk to ^1.2.0; strip stray UTF-8 BOM + repair mojibaked em-dash in description. - tests: tls-interception suite (45/45 green) against the published SDK. Co-Authored-By: Claude Opus 4.8 (1M context) --- BEFUND_AGENT_SHIELD_SPAWN.md | 62 +++++++++++++++++++++++++ README.md | 32 +++++++++++++ package.json | 8 ++-- src/client.mjs | 27 +++++++---- src/debug-log.mjs | 43 +++--------------- src/error-cause.mjs | 83 ++++++++++++++++++++++++++++++++++ src/fail-policy.mjs | 11 ++++- src/mcp-server.mjs | 3 ++ test/tls-interception.test.mjs | 76 +++++++++++++++++++++++++++++++ 9 files changed, 294 insertions(+), 51 deletions(-) create mode 100644 src/error-cause.mjs create mode 100644 test/tls-interception.test.mjs diff --git a/BEFUND_AGENT_SHIELD_SPAWN.md b/BEFUND_AGENT_SHIELD_SPAWN.md index bbc8ae1..5f5829a 100644 --- a/BEFUND_AGENT_SHIELD_SPAWN.md +++ b/BEFUND_AGENT_SHIELD_SPAWN.md @@ -251,3 +251,65 @@ Hier **nicht** gefixt (read-only, fremde Dependency). - [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/README.md b/README.md index e743605..abb4325 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,38 @@ 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 diff --git a/package.json b/package.json index e67906a..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", @@ -45,6 +45,6 @@ "access": "public" }, "dependencies": { - "@palveron/sdk": "^1.1.0" + "@palveron/sdk": "^1.2.0" } } diff --git a/src/client.mjs b/src/client.mjs index 7479abb..37407dc 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -21,6 +21,7 @@ import { Palveron } from '@palveron/sdk'; import { classifyRisk } from './risk-classifier.mjs'; import { isFailLoud, transportFallback } from './fail-policy.mjs'; import { dlog, causeChain } from './debug-log.mjs'; +import { classifyTransportFailure } from './error-cause.mjs'; /** * Classify a thrown SDK error into a diagnostic `outcome` for the spawn log. @@ -307,10 +308,12 @@ export class ShieldClient { errName: err?.name ?? null, errCode: err?.code ?? null, errMessage: String(err?.message ?? '').slice(0, 200), - // The SDK masks transport failures as a generic NETWORK_ERROR and DROPS - // the original error (sdk index.mjs:379-387) — so causeChain on the SDK - // error is usually empty here, which itself confirms the mask. The raw - // reason is recovered by net_selftest_fetch (raw fetch outside the SDK). + // @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 } : {}), @@ -323,11 +326,19 @@ export class ShieldClient { if (isFailLoud(err)) throw err; // Transport failure (timeout / circuit-open / network / 5xx) → tiered. - const fb = transportFallback(riskLevel, { errorMessage: err?.message }); + // 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 - // gateway_unavailable_failclosed (when fb.decision === 'BLOCK'). `branch` - // names the outcome that caused it — breaker_open_shortcircuit (H1) vs a - // real transport_error/timeout (H2/H3). + // 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; } diff --git a/src/debug-log.mjs b/src/debug-log.mjs index a01f689..94d3a95 100644 --- a/src/debug-log.mjs +++ b/src/debug-log.mjs @@ -34,6 +34,12 @@ 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; @@ -83,12 +89,6 @@ export function dlog(event, fields = {}) { } } -/** Truncate a string to `n` chars (secret-safe diagnostics never log full bodies). */ -function truncate(s, n) { - const str = String(s ?? ''); - return str.length > n ? str.slice(0, n) : str; -} - /** * Secret-safe metadata about a credential: presence + length only. Never the * value, never a substring beyond what the caller explicitly chooses. @@ -144,37 +144,6 @@ export function collectProxyEnv(env = process.env) { return out; } -/** - * 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, CERT_*) in `err.cause` — the SDK masks the - * surface as a generic NETWORK_ERROR, so this is how the real reason surfaces. - * @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; -} - const OS_ENV_VARS = [ 'SystemRoot', 'WINDIR', 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 index c31bbdd..d651114 100644 --- a/src/fail-policy.mjs +++ b/src/fail-policy.mjs @@ -65,16 +65,22 @@ export function isFailLoud(err) { * @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, _error?: string, retry_after_ms?: number}} + * @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: FAIL_CLOSED_REASON, + 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 } : {}), }; @@ -83,6 +89,7 @@ export function transportFallback(riskLevel, opts = {}) { 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/mcp-server.mjs b/src/mcp-server.mjs index c09f46b..660eb90 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -200,6 +200,9 @@ async function handleToolCall(id, params, client, agentId, transport) { 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 } : {}), }), }, 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'); +}); From 3aa40e7bc52fad9bb49a577328eeb5ca27c0a7b6 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Thu, 25 Jun 2026 19:04:39 +0200 Subject: [PATCH 12/13] feat(adapter): normalize tool names for gateway + bind real agent_id via init (Goal 2b) --- bin/agent-shield.mjs | 10 ++++++ src/client.mjs | 7 +++- src/openclaw-config.mjs | 6 +++- src/tool-normalization.mjs | 51 +++++++++++++++++++++++++++++ test/contract.test.mjs | 13 +++++++- test/mcp-server.test.mjs | 3 +- test/openclaw-config.test.mjs | 53 ++++++++++++++++++++++++++++++ test/tool-normalization.test.mjs | 56 ++++++++++++++++++++++++++++++++ 8 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/tool-normalization.mjs create mode 100644 test/tool-normalization.test.mjs diff --git a/bin/agent-shield.mjs b/bin/agent-shield.mjs index 4029224..28320d1 100644 --- a/bin/agent-shield.mjs +++ b/bin/agent-shield.mjs @@ -84,6 +84,16 @@ async function cmdInit() { 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); diff --git a/src/client.mjs b/src/client.mjs index 37407dc..22656b1 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -19,6 +19,7 @@ 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'; @@ -210,7 +211,11 @@ export class ShieldClient { * @returns {Promise<{decision:'ALLOW'|'BLOCK'|'MODIFY'|'APPROVAL', reason:(string|null), modified_input?:(string|null), trace_id?:(string|null), findings?:Array, _fallback?:boolean}>} */ 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; // ── Diagnostics (no-op unless AGENT_SHIELD_DEBUG_LOG_PATH set) ── // `attempt: 0` is the agent-shield-level call; the SDK does its own internal @@ -225,7 +230,7 @@ export class ShieldClient { try { const res = await this.#sdk.verify({ prompt: input ?? '', - context: toolName ? { toolName } : undefined, + context: gatewayToolName ? { toolName: gatewayToolName } : undefined, metadata: { ...metadata, ...(agentId ? { agent_id: agentId } : {}), diff --git a/src/openclaw-config.mjs b/src/openclaw-config.mjs index b5fc33e..bb38c87 100644 --- a/src/openclaw-config.mjs +++ b/src/openclaw-config.mjs @@ -17,7 +17,7 @@ import { homedir } from 'node:os'; * /openclaw.json * /.openclaw/config.json * - * @param {{apiUrl?: string, apiKey?: string}} config + * @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. */ @@ -51,6 +51,10 @@ export async function updateOpenClawConfig(config, opts = {}) { 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 } : {}), }, }; 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/contract.test.mjs b/test/contract.test.mjs index 2bddec4..29f6b43 100644 --- a/test/contract.test.mjs +++ b/test/contract.test.mjs @@ -55,9 +55,20 @@ test('verify body matches the gateway contract: prompt top-level, context.tool_n // 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'); - assert.equal(captured.body.context.tool_name, 'exec', 'tool_name MUST be nested under context'); + // 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$/); diff --git a/test/mcp-server.test.mjs b/test/mcp-server.test.mjs index 7750f64..c5fc0c1 100644 --- a/test/mcp-server.test.mjs +++ b/test/mcp-server.test.mjs @@ -110,7 +110,8 @@ test('F1: governance_check missing input → ERROR (never ALLOW); F4: real tools 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)'); - assert.equal(verifyCalls[0].context.tool_name, 'list_directory'); + // 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 index e52de6b..43fa56b 100644 --- a/test/openclaw-config.test.mjs +++ b/test/openclaw-config.test.mjs @@ -78,3 +78,56 @@ test('returns false when no candidate config file exists', async () => { 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/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); +}); From 2cf7964118b57b11441d011a63b209d57f1fe675 Mon Sep 17 00:00:00 2001 From: Arndt Podzus Date: Thu, 9 Jul 2026 15:24:20 +0200 Subject: [PATCH 13/13] fix(errors): add parseShieldErrorDetail tolerating structured and legacy error shapes (own read, SDK dep unpublished) --- src/client.mjs | 33 ++++++++++++++++++++++----- test/error-tolerance.test.mjs | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 test/error-tolerance.test.mjs diff --git a/src/client.mjs b/src/client.mjs index 22656b1..0a8e4be 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -24,6 +24,30 @@ 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 / @@ -425,12 +449,9 @@ export class ShieldClient { if (!res.ok) { const text = await res.text().catch(() => ''); - let detail = text; - try { - detail = JSON.parse(text).error || text; - } catch { - // keep raw text - } + // 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}` : ''}`, ); 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); +});