Skip to content
This repository was archived by the owner on Jul 19, 2026. It is now read-only.
Merged
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
31 changes: 31 additions & 0 deletions plugins/opencode/multicorn-shield.test.ts
Original file line number Diff line number Diff line change
@@ -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]");
});
});
41 changes: 10 additions & 31 deletions plugins/opencode/multicorn-shield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>) };
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(
Expand Down Expand Up @@ -327,7 +306,7 @@ async function shieldBeforeDecision(
/** @type {Record<string, unknown>} */
const metadata = {
tool_name: toolName,
parameters: scrubMetadataArgs(args),
parameters: buildOpenCodeParametersMetadata(args),
source: PLATFORM,
};

Expand Down Expand Up @@ -474,7 +453,7 @@ export const MulticornShieldPlugin: Plugin = (input: PluginInput): Promise<Hooks
if (typeof output === "object" && "output" in output) {
const rawOut = (output as Record<string, unknown>)["output"];
if (typeof rawOut === "string") {
snippet = scrubResultSnippet(rawOut);
snippet = buildOpenCodeResultMetadata(rawOut);
}
}

Expand Down
35 changes: 35 additions & 0 deletions src/hooks/codex-cli-hooks-shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
redactSecretsForAudit,
redactGoogleUserDataInText,
redactGoogleUserDataValue,
serializeHookAuditFragment,
truncateForAudit,
} from "./codex-cli-hooks-shared.js";
Expand All @@ -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<string, unknown>;

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]");
});
});
55 changes: 54 additions & 1 deletion src/hooks/codex-cli-hooks-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const out: Record<string, unknown> = {};
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.
Expand Down Expand Up @@ -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]";
Expand Down
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading