Skip to content
Open
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 harnesses/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { readVirtualPathContent } from "../../../src/hooks/virtual-table-query.j
// message_embedding (today's behavior, preserved on every failure mode).
import { tryEmbedStandalone, _setSpawnImpl } from "../../../src/embeddings/standalone-embed-client.js";
import { embeddingSqlLiteral } from "../../../src/embeddings/sql.js";
import { redactSecrets } from "../../../src/hooks/shared/redact.js";
// Resolve sibling skillify-worker.js path at runtime via import.meta.url. The
// openclaw plugin is bundled to harnesses/openclaw/dist/index.js, then installed to
// ~/.openclaw/extensions/hivemind/dist/index.js by install-openclaw.ts. The
Expand Down Expand Up @@ -1521,7 +1522,8 @@ export default definePluginEntry({
content: text,
timestamp: ts,
};
const line = JSON.stringify(entry);
// Mask secrets before the payload is embedded or stored.
const line = redactSecrets(JSON.stringify(entry));
// For JSONB: only escape single quotes, keep JSON structure intact
const jsonForSql = line.replace(/'/g, "''");

Expand Down
6 changes: 5 additions & 1 deletion src/hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { readStdin } from "../utils/stdin.js";
import { type Config } from "../config.js";
import { resolveCaptureConfig } from "./shared/dir-gate.js";
import { redactSecrets } from "./shared/redact.js";
import { DeeplakeApi } from "../deeplake-api.js";
import { sqlStr } from "../utils/sql.js";
import { projectNameFromCwd } from "../utils/project-name.js";
Expand Down Expand Up @@ -145,7 +146,10 @@ async function main(): Promise<void> {
}

const sessionPath = buildSessionPath(config, input.session_id);
const line = JSON.stringify(entry);
// Mask secrets (tokens, passwords, API keys) before the payload is embedded
// or written to the store. Redacting the serialized line covers every field
// (content / tool_input / tool_response) and both egress paths at once.
const line = redactSecrets(JSON.stringify(entry));
log(`writing to ${sessionPath}`);

// Simple INSERT — one row per event, no concat, no race conditions.
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/codex/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { readStdin } from "../../utils/stdin.js";
import { type Config } from "../../config.js";
import { resolveCaptureConfig } from "../shared/dir-gate.js";
import { redactSecrets } from "../shared/redact.js";
import { DeeplakeApi } from "../../deeplake-api.js";
import { sqlStr } from "../../utils/sql.js";
import { projectNameFromCwd } from "../../utils/project-name.js";
Expand Down Expand Up @@ -120,7 +121,7 @@ async function main(): Promise<void> {
}

const sessionPath = buildSessionPath(config, input.session_id);
const line = JSON.stringify(entry);
const line = redactSecrets(JSON.stringify(entry));
log(`writing to ${sessionPath}`);

const projectName = projectNameFromCwd(input.cwd);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/codex/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { finalizeSummary, releaseLock, readState } from "../summary-state.js";
import { capLinesByBytes, stampOffset, WIKI_JSONL_MAX_BYTES } from "../wiki-offset.js";
import { redactSecrets } from "../shared/redact.js";
import { uploadSummary } from "../upload-summary.js";
import { log as _log } from "../../utils/debug.js";
import { EmbedClient } from "../../embeddings/client.js";
Expand Down Expand Up @@ -265,7 +266,7 @@ async function main(): Promise<void> {
if (raw.trim()) {
// Stamp the offset ourselves so the persisted summary is authoritative
// and never depends on the LLM echoing the bookkeeping line.
const text = stampOffset(raw, jsonlLines);
const text = redactSecrets(stampOffset(raw, jsonlLines));
const fname = `${cfg.sessionId}.md`;
const vpath = `/summaries/${cfg.userName}/${fname}`;
// Embed the summary so it ranks in the semantic retrieval branch.
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/cursor/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { readStdin } from "../../utils/stdin.js";
import { resolveCaptureConfig } from "../shared/dir-gate.js";
import { redactSecrets } from "../shared/redact.js";
import { DeeplakeApi } from "../../deeplake-api.js";
import { sqlStr } from "../../utils/sql.js";
import { projectNameFromCwd } from "../../utils/project-name.js";
Expand Down Expand Up @@ -145,7 +146,7 @@ async function main(): Promise<void> {
}

const sessionPath = buildSessionPath(config, sessionId);
const line = JSON.stringify(entry);
const line = redactSecrets(JSON.stringify(entry));
log(`writing to ${sessionPath}`);

const projectName = projectNameFromCwd(cwd);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/cursor/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { finalizeSummary, releaseLock, readState } from "../summary-state.js";
import { capLinesByBytes, stampOffset, WIKI_JSONL_MAX_BYTES } from "../wiki-offset.js";
import { redactSecrets } from "../shared/redact.js";
import { uploadSummary } from "../upload-summary.js";
import { log as _log } from "../../utils/debug.js";
import { EmbedClient } from "../../embeddings/client.js";
Expand Down Expand Up @@ -251,7 +252,7 @@ async function main(): Promise<void> {
if (raw.trim()) {
// Stamp the offset ourselves so the persisted summary is authoritative
// and never depends on the LLM echoing the bookkeeping line.
const text = stampOffset(raw, jsonlLines);
const text = redactSecrets(stampOffset(raw, jsonlLines));
const fname = `${cfg.sessionId}.md`;
const vpath = `/summaries/${cfg.userName}/${fname}`;
// Embed the summary so it ranks in the semantic retrieval branch.
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/hermes/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { readStdin } from "../../utils/stdin.js";
import { resolveCaptureConfig } from "../shared/dir-gate.js";
import { redactSecrets } from "../shared/redact.js";
import { DeeplakeApi } from "../../deeplake-api.js";
import { sqlStr } from "../../utils/sql.js";
import { projectNameFromCwd } from "../../utils/project-name.js";
Expand Down Expand Up @@ -129,7 +130,7 @@ async function main(): Promise<void> {
}

const sessionPath = buildSessionPath(config, sessionId);
const line = JSON.stringify(entry);
const line = redactSecrets(JSON.stringify(entry));
log(`writing to ${sessionPath}`);

const projectName = projectNameFromCwd(cwd);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/hermes/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { finalizeSummary, releaseLock, readState } from "../summary-state.js";
import { capLinesByBytes, stampOffset, WIKI_JSONL_MAX_BYTES } from "../wiki-offset.js";
import { redactSecrets } from "../shared/redact.js";
import { uploadSummary } from "../upload-summary.js";
import { log as _log } from "../../utils/debug.js";
import { EmbedClient } from "../../embeddings/client.js";
Expand Down Expand Up @@ -258,7 +259,7 @@ async function main(): Promise<void> {
if (raw.trim()) {
// Stamp the offset ourselves so the persisted summary is authoritative
// and never depends on the LLM echoing the bookkeeping line.
const text = stampOffset(raw, jsonlLines);
const text = redactSecrets(stampOffset(raw, jsonlLines));
const fname = `${cfg.sessionId}.md`;
const vpath = `/summaries/${cfg.userName}/${fname}`;
// Embed the summary so it ranks in the semantic retrieval branch.
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/pi/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { finalizeSummary, releaseLock, readState } from "../summary-state.js";
import { capLinesByBytes, stampOffset, WIKI_JSONL_MAX_BYTES } from "../wiki-offset.js";
import { redactSecrets } from "../shared/redact.js";
import { uploadSummary } from "../upload-summary.js";
import { log as _log } from "../../utils/debug.js";
import { EmbedClient } from "../../embeddings/client.js";
Expand Down Expand Up @@ -257,7 +258,7 @@ async function main(): Promise<void> {
if (raw.trim()) {
// Stamp the offset ourselves so the persisted summary is authoritative
// and never depends on the LLM echoing the bookkeeping line.
const text = stampOffset(raw, jsonlLines);
const text = redactSecrets(stampOffset(raw, jsonlLines));
const fname = `${cfg.sessionId}.md`;
const vpath = `/summaries/${cfg.userName}/${fname}`;
// Embed the summary so it ranks in the semantic retrieval branch.
Expand Down
210 changes: 210 additions & 0 deletions src/hooks/shared/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Secret redaction for captured session content.
*
* Hivemind captures prompts, tool inputs, tool outputs and assistant messages
* and persists/embeds them. Those payloads routinely contain credentials —
* a Bash `export GITHUB_TOKEN=…`, an `Authorization: Bearer …` header echoed
* by curl, a connection string with an inline password, or an API response
* carrying a fresh access token. We mask those with stars BEFORE the text is
* embedded or written to the store, so a leaked secret never lands in the
* vector or the row.
*
* Coverage is layered, most-specific first:
* 1. Private-key blocks (PEM / OpenSSH).
* 2. ~30 known provider token schemes (keep the scheme prefix as a hint).
* 3. Structured secrets: Authorization headers, bare Bearer, URL basic-auth,
* Sentry DSN, Slack webhooks.
* 4. Generic `KEY=VALUE` / `"key":"value"` where KEY is a secret-ish word.
* 5. High-entropy backstop: a bare, unlabeled, random-looking token with no
* known prefix (e.g. an AWS secret access key, a raw API key in JSON).
*
* Design:
* - Fixed-length mask (`MASK`) — never reveal the secret's length.
* - Keep a NON-secret hint where useful: the scheme prefix of a known token
* (`ghp_********`) or the key name of an assignment (`password=********`).
* - Precision guards on the generic/entropy layers so we don't mask
* look-alikes (`tokenizer`, `max_tokens`), UUIDs, git SHAs, hashes, or
* literal `true/false/null`.
* - Pure, dependency-free, idempotent, and star-safe: replacements introduce
* only `*` and kept literal prefixes, so running this over a serialized
* JSON string keeps the JSON parseable.
*/

const MASK = "********";

interface Rule {
re: RegExp;
replace: string | ((match: string, ...groups: string[]) => string);
}

/** Shannon entropy in bits per character. */
function shannonEntropy(s: string): number {
const freq = new Map<string, number>();
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
let h = 0;
for (const count of freq.values()) {
const p = count / s.length;
h -= p * Math.log2(p);
}
return h;
}

/**
* Does a bare token look like a random secret? Deliberately conservative so
* the entropy backstop doesn't shred ordinary identifiers in a trace.
*/
function looksLikeSecret(tok: string): boolean {
if (tok.length < 24) return false;
if (/^\d+$/.test(tok)) return false; // pure number
if (/^[0-9a-f]+$/i.test(tok)) return false; // hex hash / git SHA / md5
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tok)) return false; // UUID
// Require a mix of character classes — random keys blend cases/digits, while
// English words, dotted versions and snake_case identifiers do not.
const classes =
(/[a-z]/.test(tok) ? 1 : 0) + (/[A-Z]/.test(tok) ? 1 : 0) + (/[0-9]/.test(tok) ? 1 : 0);
if (classes < 2) return false;
return shannonEntropy(tok) >= 3.5;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Secret-ish key names for the generic assignment rule. Ordered longest-first.
const SECRET_KEY_WORDS = [
"aws[_-]?secret[_-]?access[_-]?key",
"secret[_-]?access[_-]?key",
"client[_-]?secret",
"access[_-]?key[_-]?id",
"encryption[_-]?key",
"connection[_-]?string",
"private[_-]?key",
"secret[_-]?key",
"access[_-]?key",
"auth[_-]?token",
"refresh[_-]?token",
"access[_-]?token",
"session[_-]?key",
"account[_-]?key",
"id[_-]?token",
"api[_-]?key",
"app[_-]?key",
"pgpassword",
"passphrase",
"password",
"passwd",
"credentials?",
"signature",
"secret",
"token",
"apikey",
].join("|");

// Values that are never secrets — keep configs like `secret: false` readable.
const NON_SECRET_VALUE = /^(true|false|null|none|undefined|nil|""|''|\{\}|\[\])$/i;

const RULES: Rule[] = [
// ── 1. Private key blocks ────────────────────────────────────────────────
{
re: /(-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z0-9 ]*PRIVATE KEY-----)/g,
replace: `$1${MASK}$2`,
},

// ── 2. Known provider token schemes — keep the scheme prefix ─────────────
// GitHub (classic / oauth / user / server / refresh, fine-grained PAT).
{ re: /(github_pat_)[A-Za-z0-9_]{20,}/g, replace: `$1${MASK}` },
{ re: /(gh[pousr]_)[A-Za-z0-9]{20,}/g, replace: `$1${MASK}` },
// OpenAI / Anthropic (sk-, sk-ant-, sk-proj-).
{ re: /(sk-(?:ant-|proj-)?)[A-Za-z0-9_-]{16,}/g, replace: `$1${MASK}` },
// Stripe secret / restricted / webhook.
{ re: /((?:sk|rk)_(?:live|test)_)[A-Za-z0-9]{16,}/g, replace: `$1${MASK}` },
{ re: /(whsec_)[A-Za-z0-9]{20,}/g, replace: `$1${MASK}` },
// Slack (bot/user/app tokens).
{ re: /(xox[baprse]-)[A-Za-z0-9-]{8,}/g, replace: `$1${MASK}` },
{ re: /(xapp-)[A-Za-z0-9-]{8,}/g, replace: `$1${MASK}` },
// AWS access key ids (the secret access key is caught by the entropy layer).
{ re: /((?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[A-Z0-9]{16})/g, replace: (_m, p1) => `${(p1 as string).slice(0, 4)}${MASK}` },
// Google API key + OAuth access token.
{ re: /(AIza)[A-Za-z0-9_-]{30,}/g, replace: `$1${MASK}` },
{ re: /(ya29\.)[A-Za-z0-9_-]{20,}/g, replace: `$1${MASK}` },
// HuggingFace, GitLab, npm, PyPI.
{ re: /(hf_)[A-Za-z0-9]{20,}/g, replace: `$1${MASK}` },
{ re: /(glpat-)[A-Za-z0-9_-]{18,}/g, replace: `$1${MASK}` },
{ re: /(npm_)[A-Za-z0-9]{30,}/g, replace: `$1${MASK}` },
{ re: /(pypi-)[A-Za-z0-9_-]{40,}/g, replace: `$1${MASK}` },
// Shopify, DigitalOcean, Doppler, Databricks, Linear, Postman.
{ re: /(shp(?:at|ss|ca|pa)_)[a-fA-F0-9]{20,}/g, replace: `$1${MASK}` },
{ re: /((?:dop|doo|dor)_v1_)[a-f0-9]{40,}/g, replace: `$1${MASK}` },
{ re: /(dp\.pt\.)[A-Za-z0-9]{20,}/g, replace: `$1${MASK}` },
{ re: /(dapi)[a-f0-9]{28,}/g, replace: `$1${MASK}` },
{ re: /(lin_api_)[A-Za-z0-9]{20,}/g, replace: `$1${MASK}` },
{ re: /(PMAK-)[a-f0-9]{20,}-[a-f0-9]{20,}/g, replace: `$1${MASK}` },
// SendGrid, Atlassian, Notion, Groq, xAI, Replicate, Mailgun, Telegram bot.
{ re: /(SG\.)[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, replace: `$1${MASK}` },
{ re: /(ATATT)[A-Za-z0-9_\-=]{20,}/g, replace: `$1${MASK}` },
{ re: /((?:secret_|ntn_))[A-Za-z0-9]{40,}/g, replace: `$1${MASK}` },
{ re: /(gsk_)[A-Za-z0-9]{40,}/g, replace: `$1${MASK}` },
{ re: /(xai-)[A-Za-z0-9]{60,}/g, replace: `$1${MASK}` },
{ re: /(r8_)[A-Za-z0-9]{30,}/g, replace: `$1${MASK}` },
{ re: /(key-)[a-f0-9]{32}/g, replace: `$1${MASK}` },
{ re: /\b(\d{8,10}:AA)[A-Za-z0-9_-]{30,}/g, replace: `$1${MASK}` },
// JWTs — three base64url segments; mask entirely.
{ re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, replace: MASK },

// ── 3. Structured secrets ────────────────────────────────────────────────
// DSN-style credential — a long hex key sitting in the userinfo of a URL
// (`https://<hexkey>@host/…`, e.g. a Sentry DSN). Matched structurally (no
// hostname literal) so it stays host-agnostic and doesn't trip URL linters.
{ re: /(https?:\/\/)[a-f0-9]{32,}(@)/gi, replace: `$1${MASK}$2` },
// Slack incoming webhook — match the distinctive `/services/T…/B…/<token>`
// path (the secret) rather than the hostname literal, so it's host-agnostic
// and doesn't trip URL-anchoring linters.
{ re: /(\/services\/)T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g, replace: `$1${MASK}` },
// Authorization / Proxy-Authorization headers.
{
re: /((?:proxy-)?authorization["']?\s*[:=]\s*["']?(?:bearer|basic|token|digest)\s+)([A-Za-z0-9._~+/=-]{8,})/gi,
replace: `$1${MASK}`,
},
// Bare `Bearer <token>`.
{ re: /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{12,})/g, replace: `$1${MASK}` },
Comment on lines +159 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fully redact supported Authorization variants.

A Digest header only has its initial username= fragment replaced, leaving parameters such as a hexadecimal response exposed. Bare bearer is also case-sensitive, so lowercase schemes with hex tokens bypass both this rule and the entropy backstop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/shared/redact.ts` around lines 155 - 161, Update the authorization
redaction rules in the shared redaction pattern set to mask the complete
supported Digest credential, including parameters such as username and response,
rather than only its initial fragment. Make the bare Bearer rule
case-insensitive so lowercase and mixed-case schemes are redacted, while
preserving the existing token character and length constraints.

// Credentials embedded in a URL: `scheme://user:password@host`.
{ re: /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s:/@]+)(@)/gi, replace: `$1${MASK}$3` },
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── 4. Generic labeled assignments ───────────────────────────────────────
{
re: new RegExp(
`((?:${SECRET_KEY_WORDS})(?![A-Za-z0-9])["']?\\s*[:=]\\s*["']?)([^\\s"',;{}()\\[\\]]{1,})`,
"gi",
),
replace: (match, keep: string, value: string) =>
NON_SECRET_VALUE.test(value) ? match : `${keep}${MASK}`,
},
// CLI-flag form: `--password VALUE` / `-p=VALUE`.
{
re: /(--?(?:password|passwd|pwd|token|secret|api[_-]?key)[\s=]+)(["']?)([^\s"']{1,})/gi,
replace: (match, keep: string, quote: string, value: string) =>
NON_SECRET_VALUE.test(value) ? match : `${keep}${quote}${MASK}`,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── 5. High-entropy backstop for bare, unlabeled secrets ─────────────────
// A random-looking token with no known prefix and no labeling key (e.g. an
// AWS secret access key, a raw key echoed in JSON). `/`, `=` and `+` are
// excluded from the candidate charset so file paths, base64 data blobs and
// `key=value` assignments break into short segments — keeping the value
// (a UUID, a decimal, a hash) separate from its label instead of gluing them
// into one high-entropy blob.
{
re: /[A-Za-z0-9_.-]{24,}/g,
replace: (m) => (looksLikeSecret(m) ? MASK : m),
},
];

/**
* Mask tokens, passwords, API keys and other secrets in `text` with stars.
* Returns the input unchanged when it contains no recognized secret. Safe to
* run on a serialized JSON string (introduces only `*` and kept literals).
*/
export function redactSecrets(text: string): string {
if (!text) return text;
let out = text;
for (const rule of RULES) {
out = out.replace(rule.re, rule.replace as string);
}
return out;
}
5 changes: 4 additions & 1 deletion src/hooks/upload-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { randomUUID } from "node:crypto";
import { embeddingSqlLiteral } from "../embeddings/sql.js";
import { redactSecrets } from "./shared/redact.js";

export type QueryFn = (sql: string) => Promise<Array<Record<string, unknown>>>;

Expand Down Expand Up @@ -124,7 +125,9 @@ export function isFinalizedSummaryText(text: unknown): boolean {
* See module docstring for the rationale.
*/
export async function uploadSummary(query: QueryFn, params: UploadParams): Promise<UploadResult> {
const { tableName, vpath, fname, userName, project, agent, text } = params;
const { tableName, vpath, fname, userName, project, agent } = params;
// Mask any secret a summary may have quoted before it's stored/indexed.
const text = redactSecrets(params.text);
const ts = params.ts ?? new Date().toISOString();
const desc = extractDescription(text);
const sizeBytes = Buffer.byteLength(text);
Expand Down
Loading