ftC$cgCmOUL?x^0eXZ12NHa!(aF;jbx;lS@qW2^VhZQQH2;g(o2
zmc2L)KC3<0^Vm7g?1}qFducznl@=2Y9hRE(5cb%IIp-zbhAFp8+s2L$DZp=4-S()^
zCJPiVl$}?XfX=#9t$pW&>LDc1wMG!*rrj?Yhbi|l8#A|_pdfJ;q;q173n_0$T5a{I
z?jQ_F`m=1}e*f}h=$xI4EonCdsWGo#i(~8a_5+zD6I$EitOC;}bQdW*Q`8r=$f5qdnEdO9e5V?X56D
zIvv@#{5atNlD0f)iR#FCKAFV+CD`nzG0)fMdFDOUGGWB)w@dd0|@sei4P(A+;b?@&u!Z?<+6QSF@E&6e!^(
z4Nd%I3(vPnNgLkYI32UmBZFHcC<6y`9h$2LQ9oA1K5hs?J!jvGo0K5aPJ%GsSb8ww
zWELV>39ah)=SI`46>Lz}w7h<%@j7U;u3sHSYIU+KsuC{J#*36lJC1l=hyq^h!U1;1
z56|;HIG+!oe_-M%;h3mc=$}^7rJ>gH^A`E!w|0Cl09Gg)!G@n+Q?BuN<!LTY2M8-0H#%kPCT|
z4Zdx+EMA5!%6I1q@q!9VeFvRdZOqH+{!O1sl|B`xNt4LB__SZ9$j79@x7yiol?lF0
z20~F7!;<`>^x*S-gTGJHIXJjD42lH_k9)~)(9QBelnJ)hVJT&&LMKSI^T=dB<)P13<7HckAz4}u
z;ZzRuX*XY$`SlPYF*Z9}!&0$p<5XL(5VKlEr|QSeJRi*D6rxWtW!Zh-OOajdDb+mo
z+u;7EC@KNCg3@Wbca`Gy+!4wMtU&myC*Clx8B
z1I~%%gC|2w=#TK3D-o?X*P7<`=Lt@PKzDzF3uBUNaU&%OF~>bQ(32nlKR*$FokQ^w
z>-jqK>%2Fem^e)ooICR0Uh(*AM^}uh!t{QXhZ0TPg2#^uumoB2)^TueH``IPL}U3c
zbSUjS>?z$4=?^ouvPh;FE}_9}=*;UmIx*idy%xYpEnKrTOQoLBDs-o~{fQ%mPrK2l
z<(+)p`q-c=7;$hAGg%=#eDaIfy{2C!aE5KTdtU}x0E8`#uzGr>);HuZZs@0haEg~Z
z)PwrSen0!0tS!a=p;(k$$>A(gnLwosh7pH5IB6W&cx`nS7s`i7g3X3Z_GST8C5nFP
zJ~IIHyq+xb;>hlh8@_4EnMR9#cD8g08tA4gccc<31Fitw>VF{Y<;z2CBMX_5K)amp
z$3gN+-_4~mr*lh<^BHI`?&sNd1v^e!2@Xr;i>94+O;7Epuqty5Gr@SI5Rah_2_Wd|@0ty~ND(`{{}8QQqJ{Ocwg3^9iBJM*}3
zP@zeQg~~TdK=%`MMHJp{Bd9xayJhPWr)VH=e%E?Xy(J~@#Qh2T!~0Xjw+Kw&5@tr1=d1@T)oV@5HYbzrR=6L;M>N6bRsdY54oZeKq?19rsn5|M$W{@PET)Kmz>t
zLj6zLSAqV&XkTCEpQVBzf1`cyqklF9{KwM6PtsTE;lD^<&cqk#@0D_BeO?f>t;D%5}DPBZ-v_y0x=e8Rs(fSlwv(61GO
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);
+});