diff --git a/plugins/opencode/multicorn-shield.test.ts b/plugins/opencode/multicorn-shield.test.ts new file mode 100644 index 0000000..3e0e8a8 --- /dev/null +++ b/plugins/opencode/multicorn-shield.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + buildOpenCodeParametersMetadata, + buildOpenCodeResultMetadata, +} from "./multicorn-shield.js"; + +describe("OpenCode audit metadata", () => { + it("does not persist gmail_send_email recipient, subject or body in parameters", () => { + const metadata = buildOpenCodeParametersMetadata({ + to: "alice@example.com", + subject: "Project update", + body: "Here are the confidential numbers for Q3.", + }); + + expect(metadata).not.toContain("alice@example.com"); + expect(metadata).not.toContain("Project update"); + expect(metadata).not.toContain("confidential numbers"); + expect(metadata).toContain("[REDACTED]"); + }); + + it("does not persist gmail result text with addresses or subjects", () => { + const metadata = buildOpenCodeResultMetadata( + "Email sent to alice@example.com with subject 'Project update'.", + ); + + expect(metadata).not.toContain("alice@example.com"); + expect(metadata).not.toContain("Project update"); + expect(metadata).toContain("[REDACTED]"); + }); +}); diff --git a/plugins/opencode/multicorn-shield.ts b/plugins/opencode/multicorn-shield.ts index f34e100..ad099ad 100644 --- a/plugins/opencode/multicorn-shield.ts +++ b/plugins/opencode/multicorn-shield.ts @@ -11,6 +11,8 @@ import * as fs from "node:fs"; import { homedir } from "node:os"; import * as path from "node:path"; +import { serializeHookAuditFragment } from "../../src/hooks/codex-cli-hooks-shared.js"; + const MULTICORN_CONFIG = path.join(homedir(), ".multicorn", "config.json"); const HTTP_MS = 10_000; const PLATFORM = "opencode"; @@ -230,37 +232,14 @@ function blockedMessage( return `Shield: Action blocked. Required permission: ${service} (${actionType}). Grant access at ${approvalsUrl}`; } -function scrubMetadataArgs(args: unknown): string { - try { - if (typeof args !== "object" || args === null) return "{}"; - const clone = { ...(args as Record) }; - const contentKey = clone["content"]; - if (typeof contentKey === "string") { - clone["content"] = "[" + contentKey.length.toString() + " chars redacted]"; - } - const cmd = clone["command"]; - if (typeof cmd === "string" && cmd.length > 200) { - clone["command"] = cmd.slice(0, 200) + "... [truncated]"; - } - let out = JSON.stringify(clone); - if (out.length > 4096) out = out.slice(0, 4096); - return out; - } catch { - return "{}"; - } +/** Builds redacted parameters metadata for OpenCode audit rows. */ +export function buildOpenCodeParametersMetadata(args: unknown): string { + return serializeHookAuditFragment(args); } -function scrubResultSnippet(text: unknown): string { - if (typeof text !== "string") return ""; - let s = text; - s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]"); - s = s.replace(/\bmcs_[A-Za-z0-9_-]+\b/g, "[REDACTED]"); - s = s.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]"); - s = s.replace(/Bearer\s+[^\s]+/gi, "[REDACTED]"); - if (s.length > 500) { - return s.slice(0, 500) + "[truncated]"; - } - return s; +/** Builds redacted result metadata for OpenCode audit rows. */ +export function buildOpenCodeResultMetadata(output: unknown): string { + return serializeHookAuditFragment(output); } async function shieldPostActions( @@ -327,7 +306,7 @@ async function shieldBeforeDecision( /** @type {Record} */ const metadata = { tool_name: toolName, - parameters: scrubMetadataArgs(args), + parameters: buildOpenCodeParametersMetadata(args), source: PLATFORM, }; @@ -474,7 +453,7 @@ export const MulticornShieldPlugin: Plugin = (input: PluginInput): Promise)["output"]; if (typeof rawOut === "string") { - snippet = scrubResultSnippet(rawOut); + snippet = buildOpenCodeResultMetadata(rawOut); } } diff --git a/src/hooks/codex-cli-hooks-shared.test.ts b/src/hooks/codex-cli-hooks-shared.test.ts index 9ba6a0d..269c84c 100644 --- a/src/hooks/codex-cli-hooks-shared.test.ts +++ b/src/hooks/codex-cli-hooks-shared.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { redactSecretsForAudit, + redactGoogleUserDataInText, + redactGoogleUserDataValue, serializeHookAuditFragment, truncateForAudit, } from "./codex-cli-hooks-shared.js"; @@ -24,4 +26,37 @@ describe("codex-cli-hooks-shared", () => { it("redactSecretsForAudit masks token= query pairs", () => { expect(redactSecretsForAudit("foo token=abc123secret bar")).toContain("token=[REDACTED]"); }); + + it("redactGoogleUserDataValue redacts gmail send fields", () => { + const out = redactGoogleUserDataValue({ + to: "user@example.com", + subject: "Quarterly report", + body: "Please review the attached figures.", + query: "from:boss", + }) as Record; + + expect(out["to"]).toBe("[REDACTED]"); + expect(out["subject"]).toBe("[REDACTED]"); + expect(out["body"]).toBe("[REDACTED]"); + expect(out["query"]).toBe("[REDACTED]"); + }); + + it("serializeHookAuditFragment redacts gmail send arguments", () => { + const out = serializeHookAuditFragment({ + to: "user@example.com", + subject: "Quarterly report", + body: "Please review the attached figures.", + }); + + expect(out).not.toContain("user@example.com"); + expect(out).not.toContain("Quarterly report"); + expect(out).not.toContain("attached figures"); + expect(out).toContain("[REDACTED]"); + }); + + it("redactGoogleUserDataInText redacts email addresses in tool output", () => { + const out = redactGoogleUserDataInText("Email sent to user@example.com with subject 'Hello'"); + expect(out).not.toContain("user@example.com"); + expect(out).toContain("[REDACTED]"); + }); }); diff --git a/src/hooks/codex-cli-hooks-shared.ts b/src/hooks/codex-cli-hooks-shared.ts index ecdbb8c..76e9da4 100644 --- a/src/hooks/codex-cli-hooks-shared.ts +++ b/src/hooks/codex-cli-hooks-shared.ts @@ -19,6 +19,53 @@ export const AUTH_HEADER = "X-Multicorn-Key"; const AUDIT_METADATA_MAX_CHARS = 10_000; +const GOOGLE_USER_DATA_FIELD_NAMES = new Set([ + "to", + "subject", + "body", + "query", + "name", + "content", + "title", + "description", + "location", + "attendees", + "summary", +]); + +/** + * Redact known Google user-data fields from structured tool arguments before audit storage. + */ +export function redactGoogleUserDataValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactGoogleUserDataValue(item)); + } + if (typeof value === "object" && value !== null) { + const record = value as Record; + const out: Record = {}; + for (const [key, val] of Object.entries(record)) { + out[key] = GOOGLE_USER_DATA_FIELD_NAMES.has(key.toLowerCase()) + ? "[REDACTED]" + : redactGoogleUserDataValue(val); + } + return out; + } + return value; +} + +/** Redact common Google user-data patterns from free-text tool output. */ +export function redactGoogleUserDataInText(text: string): string { + let out = text; + out = out.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[REDACTED]"); + out = out.replace( + /(\b(?:to|from|subject|body|query|preview|attendees):\s*)([^\n]+)/gi, + "$1[REDACTED]", + ); + out = out.replace(/\bwith subject\s+'[^']*'/gi, "with subject '[REDACTED]'"); + out = out.replace(/\bwith subject\s+"[^"]*"/gi, 'with subject "[REDACTED]"'); + return out; +} + /** * Best-effort secret redaction on JSON-ish strings before they are sent as audit metadata. * Heuristic regex pass only (not a structured parser); assumes metadata is non-authoritative. @@ -48,8 +95,14 @@ export function truncateForAudit(serialized: string, maxChars = AUDIT_METADATA_M /** Serialize hook fragments for POST /actions metadata: redact, then cap size. */ export function serializeHookAuditFragment(value: unknown): string { try { + if (typeof value === "string") { + return truncateForAudit(redactGoogleUserDataInText(redactSecretsForAudit(value))); + } + const redacted = redactGoogleUserDataValue(value); const raw = - typeof value === "string" ? value : JSON.stringify(value === undefined ? null : value); + typeof redacted === "string" + ? redacted + : JSON.stringify(redacted === undefined ? null : redacted); return truncateForAudit(redactSecretsForAudit(raw)); } catch { return "[unserializable]"; diff --git a/vitest.config.ts b/vitest.config.ts index 9fdc576..1c099ef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ globals: true, environment: "jsdom", passWithNoTests: true, - include: ["src/**/*.{test,spec}.ts"], + include: ["src/**/*.{test,spec}.ts", "plugins/**/*.{test,spec}.ts"], setupFiles: [], maxWorkers: 2, coverage: {