diff --git a/node/secret_tripwire.mjs b/node/secret_tripwire.mjs index cdfd6b8..1295044 100644 --- a/node/secret_tripwire.mjs +++ b/node/secret_tripwire.mjs @@ -66,7 +66,9 @@ export async function ensureTripwire({ fetchManifest } = {}) { if (!fetchManifest) return new SecretTripwire(vendored); try { const fresh = await fetchManifest(); - if (fresh && Array.isArray(fresh.rules)) return new SecretTripwire(fresh); + if (fresh && Array.isArray(fresh.rules) && fresh.rules.length > 0) { + return new SecretTripwire(fresh); + } } catch { // offline fallback — vendored baseline } diff --git a/node/test/secret_tripwire.test.mjs b/node/test/secret_tripwire.test.mjs index 9308992..dc56e24 100644 --- a/node/test/secret_tripwire.test.mjs +++ b/node/test/secret_tripwire.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { SecretTripwire, loadVendoredManifest } from "../secret_tripwire.mjs"; +import { SecretTripwire, ensureTripwire, loadVendoredManifest } from "../secret_tripwire.mjs"; import { VeriSwarmClient } from "../veriswarm_client.mjs"; const GH = "ghp_" + "A".repeat(36); @@ -30,6 +30,14 @@ describe("node SecretTripwire", () => { expect(m.version).toMatch(/^sha256:/); expect(m.rules.some((r) => r.name === "github_pat")).toBe(true); }); + + it("falls back to vendored rules when fetched manifest is empty", async () => { + const tw = await ensureTripwire({ + fetchManifest: async () => ({ version: "sha256:empty", rules: [] }), + }); + expect(tw.scan(GH).length).toBeGreaterThan(0); + expect(tw.version).toMatch(/^sha256:/); + }); }); describe("guardOutboundText", () => { @@ -58,4 +66,17 @@ describe("guardOutboundText", () => { const gh = "ghp_" + "A".repeat(36); expect(await c.guardOutboundText(gh)).toBe(gh); }); + + it("redacts when tokenize omits tokenized_text after reporting tokens", async () => { + const c = new VeriSwarmClient({ + baseUrl: "https://example.invalid", + apiKey: "k", + secretsDetection: true, + }); + c.getSecretRules = async () => MANIFEST; + c.tokenizePii = async () => ({ tokens_created: 1 }); + + const out = await c.guardOutboundText(GH); + expect(out).toBe("[VS:GITHUB_TOKEN:offline]"); + }); }); diff --git a/node/veriswarm_client.mjs b/node/veriswarm_client.mjs index cfd5892..8459829 100644 --- a/node/veriswarm_client.mjs +++ b/node/veriswarm_client.mjs @@ -171,7 +171,7 @@ export class VeriSwarmClient { if (this._tripwire.scan(text).length === 0) return text; try { const result = await this.tokenizePii({ text, agentId, sessionId }); - let guarded = result?.tokens_created > 0 ? result.tokenized_text : text; + let guarded = result?.tokens_created > 0 ? result.tokenized_text || text : text; // Belt-and-suspenders: if the API left any known-prefix span intact // (the PII tokenizer is not a secret detector), redact it locally. if (this._tripwire.scan(guarded).length > 0) { diff --git a/openclaw-plugin/src/index.ts b/openclaw-plugin/src/index.ts index 4e44747..7041743 100644 --- a/openclaw-plugin/src/index.ts +++ b/openclaw-plugin/src/index.ts @@ -86,6 +86,108 @@ function piiSessionKey(toolName: string, conversationId?: string | null): string return `${conv}:${toolName}`; } +type SecretGuardMode = "online" | "offline"; + +interface SecretGuardOutcome { + value: unknown; + changed: boolean; + rules: Set; + modes: Set; +} + +function emptySecretGuardOutcome(value: unknown): SecretGuardOutcome { + return { + value, + changed: false, + rules: new Set(), + modes: new Set(), + }; +} + +function mergeSecretGuardOutcome( + target: SecretGuardOutcome, + child: SecretGuardOutcome +): void { + target.changed ||= child.changed; + for (const rule of child.rules) target.rules.add(rule); + for (const mode of child.modes) target.modes.add(mode); +} + +async function guardSecretInput( + input: unknown, + state: PluginState, + toolName: string, + piiKey: string, + seen = new WeakSet() +): Promise { + if (!state.client || !state.tripwire) return emptySecretGuardOutcome(input); + + if (typeof input === "string") { + const hits = state.tripwire.scan(input); + if (hits.length === 0) return emptySecretGuardOutcome(input); + + try { + const result = await state.client.tokenizePii(input); + let guarded = + result.tokens_created > 0 ? result.tokenized_text || input : input; + if (result.tokens_created > 0 && result.session_id) { + state.piiSessions.set(piiKey, result.session_id); + } + // The PII tokenizer is not a secret detector; fail closed if a + // provider-prefix span remains, or if tokenized_text was omitted. + if (state.tripwire.scan(guarded).length > 0) { + guarded = state.tripwire.redactOffline(guarded); + } + return { + value: guarded, + changed: guarded !== input, + rules: new Set(hits.map((h) => h.ruleName)), + modes: new Set(["online"]), + }; + } catch (e) { + console.warn( + `[VeriSwarm] Secret tripwire: API unreachable for tool=${toolName}; ` + + `applying offline redaction. Error: ${(e as Error)?.message ?? e}` + ); + return { + value: state.tripwire.redactOffline(input), + changed: true, + rules: new Set(hits.map((h) => h.ruleName)), + modes: new Set(["offline"]), + }; + } + } + + if (Array.isArray(input)) { + if (seen.has(input)) return emptySecretGuardOutcome(input); + seen.add(input); + const next = [...input]; + const outcome = emptySecretGuardOutcome(next); + for (let i = 0; i < input.length; i++) { + const child = await guardSecretInput(input[i], state, toolName, piiKey, seen); + if (child.changed) next[i] = child.value; + mergeSecretGuardOutcome(outcome, child); + } + return outcome.changed ? outcome : emptySecretGuardOutcome(input); + } + + if (input && typeof input === "object") { + const record = input as Record; + if (seen.has(record)) return emptySecretGuardOutcome(input); + seen.add(record); + const next: Record = { ...record }; + const outcome = emptySecretGuardOutcome(next); + for (const [key, value] of Object.entries(record)) { + const child = await guardSecretInput(value, state, toolName, piiKey, seen); + if (child.changed) next[key] = child.value; + mergeSecretGuardOutcome(outcome, child); + } + return outcome.changed ? outcome : emptySecretGuardOutcome(input); + } + + return emptySecretGuardOutcome(input); +} + // ── Plugin State ──────────────────────────────────────────────────────── interface PluginState { @@ -333,45 +435,19 @@ export const pluginEntry: PluginEntry = { if ( state.secretsDetection && state.tripwire && - event.input && - typeof event.input === "string" + event.input !== undefined && + event.input !== null ) { - const hits = state.tripwire.scan(event.input); - if (hits.length > 0) { - const convId = event.conversation_id || event.conversationId || event.session_id; - const key = piiSessionKey(toolName, convId); - try { - const result = await state.client.tokenizePii(event.input); - let guarded = - result.tokens_created > 0 ? result.tokenized_text : event.input; - if (result.tokens_created > 0) { - state.piiSessions.set(key, result.session_id); - } - // Belt-and-suspenders: if any known-prefix span survived the - // API's tokenization, redact it locally so no recognized - // secret leaves unprotected. - if (state.tripwire.scan(guarded).length > 0) { - guarded = state.tripwire.redactOffline(guarded); - } - await logEvent("secret.tripwire", { - tool_name: toolName, - rules: hits.map((h) => h.ruleName), - mode: "online", - }); - return { input: guarded }; - } catch (e) { - // Offline / API failure → fail closed with local redaction. - console.warn( - `[VeriSwarm] Secret tripwire: API unreachable for tool=${toolName}; ` + - `applying offline redaction. Error: ${(e as Error)?.message ?? e}` - ); - await logEvent("secret.tripwire", { - tool_name: toolName, - rules: hits.map((h) => h.ruleName), - mode: "offline", - }).catch(() => {}); - return { input: state.tripwire.redactOffline(event.input) }; - } + const convId = event.conversation_id || event.conversationId || event.session_id; + const key = piiSessionKey(toolName, convId); + const guarded = await guardSecretInput(event.input, state, toolName, key); + if (guarded.changed) { + await logEvent("secret.tripwire", { + tool_name: toolName, + rules: Array.from(guarded.rules), + mode: guarded.modes.has("offline") ? "offline" : "online", + }).catch(() => {}); + return { input: guarded.value }; } } diff --git a/openclaw-plugin/src/secret_tripwire.ts b/openclaw-plugin/src/secret_tripwire.ts index 94bf383..726547a 100644 --- a/openclaw-plugin/src/secret_tripwire.ts +++ b/openclaw-plugin/src/secret_tripwire.ts @@ -93,7 +93,9 @@ export async function ensureTripwire(opts: EnsureOptions = {}): Promise 0) { + return new SecretTripwire(fresh); + } } catch { // fall through to vendored — offline baseline } diff --git a/openclaw-plugin/test/secret_tripwire_hook.test.ts b/openclaw-plugin/test/secret_tripwire_hook.test.ts index 4652092..2438571 100644 --- a/openclaw-plugin/test/secret_tripwire_hook.test.ts +++ b/openclaw-plugin/test/secret_tripwire_hook.test.ts @@ -56,6 +56,25 @@ describe("before_tool_call secret tripwire", () => { expect(res.input).not.toContain(GH_PAT); }); + it("fails closed for secrets nested in structured tool input", async () => { + const { api, hooks } = makeApi(); + pluginEntry.register(api as any, BASE_CONFIG); + + const res = await hooks["before_tool_call"]({ + name: "http_post", + input: { + url: "https://example.test", + headers: { + Authorization: `Bearer ${GH_PAT}`, + }, + }, + }); + + expect(res.input.headers.Authorization).toContain("[VS:"); + expect(res.input.headers.Authorization).toContain(":offline]"); + expect(JSON.stringify(res.input)).not.toContain(GH_PAT); + }); + it("passes clean input through untouched", async () => { const { api, hooks } = makeApi(); pluginEntry.register(api as any, BASE_CONFIG); diff --git a/openclaw-plugin/test/secret_tripwire_wiring.test.ts b/openclaw-plugin/test/secret_tripwire_wiring.test.ts index 25c40b5..738cfe8 100644 --- a/openclaw-plugin/test/secret_tripwire_wiring.test.ts +++ b/openclaw-plugin/test/secret_tripwire_wiring.test.ts @@ -16,4 +16,11 @@ describe("vendored manifest loader", () => { }); expect(tw.version).toMatch(/^sha256:/); }); + + it("ensureTripwire falls back to vendored copy when fetch returns no rules", async () => { + const tw = await ensureTripwire({ + fetchManifest: async () => ({ version: "sha256:empty", rules: [] }), + }); + expect(tw.version).toMatch(/^sha256:/); + }); }); diff --git a/python/src/veriswarm/secret_tripwire.py b/python/src/veriswarm/secret_tripwire.py index 3445a21..5f2f0b1 100644 --- a/python/src/veriswarm/secret_tripwire.py +++ b/python/src/veriswarm/secret_tripwire.py @@ -78,7 +78,11 @@ def ensure_tripwire( return SecretTripwire(vendored) try: fresh = fetch_manifest() - if isinstance(fresh, dict) and isinstance(fresh.get("rules"), list): + if ( + isinstance(fresh, dict) + and isinstance(fresh.get("rules"), list) + and len(fresh["rules"]) > 0 + ): return SecretTripwire(fresh) except Exception: pass # offline fallback to vendored diff --git a/python/tests/test_secret_tripwire.py b/python/tests/test_secret_tripwire.py index fa9a0bd..fafd1a7 100644 --- a/python/tests/test_secret_tripwire.py +++ b/python/tests/test_secret_tripwire.py @@ -1,6 +1,10 @@ from __future__ import annotations -from veriswarm.secret_tripwire import SecretTripwire, load_vendored_manifest +from veriswarm.secret_tripwire import ( + SecretTripwire, + ensure_tripwire, + load_vendored_manifest, +) GH = "ghp_" + "A" * 36 MANIFEST = { @@ -50,3 +54,10 @@ def test_load_vendored_manifest(): m = load_vendored_manifest() assert m["version"].startswith("sha256:") assert any(r["name"] == "github_pat" for r in m["rules"]) + + +def test_ensure_tripwire_falls_back_when_fetched_manifest_has_no_rules(): + tw = ensure_tripwire(fetch_manifest=lambda: {"version": "sha256:empty", "rules": []}) + + assert tw.version.startswith("sha256:") + assert tw.scan(GH)