Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion node/secret_tripwire.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
23 changes: 22 additions & 1 deletion node/test/secret_tripwire.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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]");
});
});
2 changes: 1 addition & 1 deletion node/veriswarm_client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
152 changes: 114 additions & 38 deletions openclaw-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
modes: Set<SecretGuardMode>;
}

function emptySecretGuardOutcome(value: unknown): SecretGuardOutcome {
return {
value,
changed: false,
rules: new Set<string>(),
modes: new Set<SecretGuardMode>(),
};
}

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<object>()
): Promise<SecretGuardOutcome> {
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<SecretGuardMode>(["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<SecretGuardMode>(["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<string, unknown>;
if (seen.has(record)) return emptySecretGuardOutcome(input);
seen.add(record);
const next: Record<string, unknown> = { ...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 {
Expand Down Expand Up @@ -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 };
}
}

Expand Down
4 changes: 3 additions & 1 deletion openclaw-plugin/src/secret_tripwire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ export async function ensureTripwire(opts: EnsureOptions = {}): Promise<SecretTr
if (!opts.fetchManifest) return new SecretTripwire(vendored);
try {
const fresh = await opts.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 {
// fall through to vendored — offline baseline
}
Expand Down
19 changes: 19 additions & 0 deletions openclaw-plugin/test/secret_tripwire_hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions openclaw-plugin/test/secret_tripwire_wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:/);
});
});
6 changes: 5 additions & 1 deletion python/src/veriswarm/secret_tripwire.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion python/tests/test_secret_tripwire.py
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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)