openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
377 lines (376 loc) • 20.2 kB
JavaScript
import { i as registerSecretValueForRedaction } from "./secret-redaction-registry-BOLC6DkF.js";
import { a as getNodeSqliteKysely, i as executeSqliteQueryTakeFirstSync, r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js";
import { E as tableExists } from "./openclaw-state-db-cache-C7ljO0xP.js";
import { r as withExistingOpenClawStateDatabaseReadOnly } from "./openclaw-state-db-readonly-BRgmrGHt.js";
import { i as openOpenClawStateDatabase, s as runOpenClawStateWriteTransaction, vt as normalizeSqliteNumber } from "./openclaw-state-db-BRTnL-D8.js";
import { yt as validateDecisionReceiptV1 } from "./src-BiL5aQto.js";
import { createHmac, randomBytes } from "node:crypto";
import { sql } from "kysely";
//#region src/audit/audit-identity.ts
/** Stable installation-local pseudonyms for sensitive audit identifiers. */
const AUDIT_IDENTITY_SINGLETON_ID = 1;
const AUDIT_IDENTITY_KEY_BYTES = 32;
const AUDIT_IDENTITY_KEY_ID_BYTES = 16;
const AUDIT_IDENTITY_KEY_ID_RE = /^[a-f0-9]{32}$/u;
const AUDIT_IDENTITY_DOMAIN = "openclaw.audit.identity.v1";
const identityByDatabase = /* @__PURE__ */ new WeakMap();
function registerAuditIdentityKeyForRedaction(key) {
const bytes = Buffer.from(key);
registerSecretValueForRedaction(bytes.toString("hex"));
registerSecretValueForRedaction(bytes.toString("base64url"));
}
function parseAuditIdentityKey(row) {
if (typeof row.key_id !== "string" || !AUDIT_IDENTITY_KEY_ID_RE.test(row.key_id) || !(row.key instanceof Uint8Array) || row.key.byteLength !== AUDIT_IDENTITY_KEY_BYTES) throw new Error("audit identity key is corrupt");
const key = Buffer.from(row.key);
registerAuditIdentityKeyForRedaction(key);
return {
keyId: row.key_id,
key
};
}
/** Load the stable audit identity key or create it transactionally on first use. */
function loadOrCreateAuditIdentityKey(db) {
const cached = identityByDatabase.get(db);
if (cached) return cached;
const kysely = getNodeSqliteKysely(db);
const existing = executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("audit_identity_keys").select(["key_id", "key"]).where("id", "=", AUDIT_IDENTITY_SINGLETON_ID));
if (existing) {
const identity = parseAuditIdentityKey(existing);
identityByDatabase.set(db, identity);
return identity;
}
const retainedMessage = executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("audit_events").select("sequence").where("kind", "=", "message").limit(1));
const retainedExecutionContext = Boolean(db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get("execution_identity_contexts")) ? executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("execution_identity_contexts").select("context_id").limit(1)) : void 0;
if (retainedMessage || retainedExecutionContext) throw new Error("audit identity key is missing");
const candidate = {
id: AUDIT_IDENTITY_SINGLETON_ID,
key_id: randomBytes(AUDIT_IDENTITY_KEY_ID_BYTES).toString("hex"),
key: randomBytes(AUDIT_IDENTITY_KEY_BYTES),
created_at: Date.now()
};
executeSqliteQuerySync(db, kysely.insertInto("audit_identity_keys").values(candidate).onConflict((conflict) => conflict.column("id").doNothing()));
const stored = executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("audit_identity_keys").select(["key_id", "key"]).where("id", "=", AUDIT_IDENTITY_SINGLETON_ID));
if (!stored) throw new Error("audit identity key could not be created");
const identity = parseAuditIdentityKey(stored);
identityByDatabase.set(db, identity);
return identity;
}
/** Forget transaction-local key state after a failed or rolled-back write. */
function clearAuditIdentityKeyCacheForDatabase(db) {
identityByDatabase.delete(db);
}
/** Produce a stable, domain-separated pseudonym without retaining raw identity bytes. */
function pseudonymizeAuditIdentity(params) {
if (params.value === void 0 || params.value.length === 0) return;
const digest = createHmac("sha256", params.identity.key).update(JSON.stringify([
AUDIT_IDENTITY_DOMAIN,
params.kind,
params.channel,
params.accountId ?? null,
params.kind === "message" ? params.conversationId ?? null : null,
params.value
]), "utf8").digest("hex");
return `hmac-sha256:v1:${params.identity.keyId}:${digest}`;
}
/** Project one execution-identity ref without retaining its raw owner value. */
function pseudonymizeExecutionIdentityRef(params) {
if (!params.scope || !params.value) throw new Error("execution identity HMAC scope and value must be non-empty");
const identity = loadOrCreateAuditIdentityKey(params.db);
const digest = createHmac("sha256", identity.key).update(JSON.stringify([
"openclaw.audit.execution-identity.v1",
params.kind,
params.scope,
params.value
]), "utf8").digest("hex");
return `hmac-sha256:v1:${identity.keyId}:${digest}`;
}
//#endregion
//#region src/audit/execution-decision-facts.ts
const EXECUTION_DECISION_FACT_MAX_BYTES = 16384;
const EXECUTION_DECISION_FACT_RETENTION_MS = 2592e6;
const EXECUTION_DECISION_FACT_MAX_ROWS = 25e4;
const EXECUTION_DECISION_FACT_PRUNE_BATCH_ROWS = 1024;
const EXECUTION_DECISION_FACT_SUMMARY_MAX_ROWS = 128;
const EXECUTION_DECISION_SELECTOR_PREFIX = "decision-fact:";
const ensuredDatabases = /* @__PURE__ */ new WeakSet();
const EXECUTION_DECISION_FACT_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS execution_decision_facts (
receipt_id TEXT NOT NULL PRIMARY KEY CHECK (length(receipt_id) BETWEEN 1 AND 256),
context_id TEXT NOT NULL CHECK (length(context_id) BETWEEN 1 AND 256),
execution_id TEXT NOT NULL CHECK (length(execution_id) BETWEEN 1 AND 256),
run_id TEXT NOT NULL CHECK (length(run_id) BETWEEN 1 AND 256),
action_id TEXT CHECK (action_id IS NULL OR length(action_id) BETWEEN 1 AND 256),
action_family TEXT NOT NULL CHECK (length(action_family) BETWEEN 1 AND 256),
decision_outcome TEXT NOT NULL CHECK (
decision_outcome IN ('allowed', 'denied', 'not-applicable', 'unknown')
),
coverage_state TEXT NOT NULL CHECK (
coverage_state IN ('enforced', 'attribution-only', 'unattributed', 'unknown', 'unsupported')
),
reason_code TEXT NOT NULL CHECK (length(reason_code) BETWEEN 1 AND 256),
owner TEXT NOT NULL CHECK (length(owner) BETWEEN 1 AND 256),
source_ref TEXT NOT NULL CHECK (length(source_ref) BETWEEN 1 AND 256),
occurred_at INTEGER NOT NULL CHECK (occurred_at >= 0),
receipt_bytes INTEGER NOT NULL CHECK (receipt_bytes BETWEEN 1 AND 16384),
receipt_json TEXT NOT NULL CHECK (length(receipt_json) > 0),
UNIQUE (occurred_at, receipt_id)
) STRICT;
CREATE INDEX IF NOT EXISTS execution_decision_facts_context_occurred_idx
ON execution_decision_facts (context_id, occurred_at, receipt_id);
CREATE INDEX IF NOT EXISTS execution_decision_facts_run_occurred_idx
ON execution_decision_facts (run_id, occurred_at, receipt_id);
`;
function decisionDb(db) {
return getNodeSqliteKysely(db);
}
function ensureExecutionDecisionFactSchema(options = {}) {
const database = openOpenClawStateDatabase(options);
if (ensuredDatabases.has(database.db)) return;
runOpenClawStateWriteTransaction(({ db }) => {
db.exec(EXECUTION_DECISION_FACT_SCHEMA_SQL);
}, options, { operationLabel: "audit.execution-decision.schema.ensure" });
ensuredDatabases.add(database.db);
}
function parseDecisionRow(row) {
const bytes = normalizeSqliteNumber(row.receipt_bytes);
const occurredAt = normalizeSqliteNumber(row.occurred_at);
if (typeof row.receipt_json !== "string" || bytes === void 0 || Buffer.byteLength(row.receipt_json, "utf8") !== bytes || bytes > EXECUTION_DECISION_FACT_MAX_BYTES || occurredAt === void 0) throw new Error("invalid decision fact payload bounds");
const parsed = JSON.parse(row.receipt_json);
if (!validateDecisionReceiptV1(parsed)) throw new Error("invalid decision fact payload schema");
if (parsed.receiptId !== row.receipt_id || parsed.contextId !== row.context_id || parsed.executionId !== row.execution_id || parsed.runId !== row.run_id || (parsed.actionId ?? null) !== row.action_id || parsed.action.family !== row.action_family || parsed.decision.outcome !== row.decision_outcome || parsed.decision.reasonCode !== row.reason_code || parsed.enforcement.coverageState !== row.coverage_state || parsed.source.owner !== row.owner || parsed.source.recordRef !== row.source_ref || parsed.occurredAt !== occurredAt || JSON.stringify(parsed) !== row.receipt_json) throw new Error("decision fact payload disagrees with indexed columns");
return parsed;
}
function unknownDecisionReceipt(row, reasonCode, missingEvidence) {
return {
schemaVersion: 1,
receiptId: row.receipt_id,
contextId: row.context_id,
executionId: row.execution_id,
runId: row.run_id,
...row.action_id ? { actionId: row.action_id } : {},
occurredAt: normalizeSqliteNumber(row.occurred_at) ?? 0,
action: {
family: row.action_family,
operation: "decision"
},
decision: {
outcome: "unknown",
reasonCode
},
enforcement: {
coverageState: "unknown",
policyRefs: [],
grantRefs: [],
contextFieldsUsed: []
},
source: {
owner: row.owner,
recordRef: row.source_ref,
decisionBoundary: "execution-decision-facts"
},
missingEvidence: [missingEvidence],
remediation: [{
code: "inspect_state_integrity",
text: "Run openclaw doctor and inspect the shared state database before trusting this decision."
}]
};
}
function hasExactExecutionContext(db, context) {
if (!tableExists(db, "execution_identity_contexts")) return false;
return Boolean(executeSqliteQueryTakeFirstSync(db, decisionDb(db).selectFrom("execution_identity_contexts").select("context_id").where("context_id", "=", context.contextId).where("execution_id", "=", context.executionId).where("run_id", "=", context.runId)));
}
function deleteExpiredDecisionFacts(db, now, limit) {
const kysely = decisionDb(db);
const expiredIds = kysely.selectFrom("execution_decision_facts").select("receipt_id").where("occurred_at", "<", now - EXECUTION_DECISION_FACT_RETENTION_MS).orderBy("occurred_at", "asc").orderBy("receipt_id", "asc").limit(limit);
return executeSqliteQuerySync(db, kysely.deleteFrom("execution_decision_facts").where("receipt_id", "in", expiredIds));
}
function pruneDecisionFactsAfterInsert(db, now, limits) {
const kysely = decisionDb(db);
const expired = deleteExpiredDecisionFacts(db, now, limits.pruneBatchRows);
const remaining = Math.max(0, limits.pruneBatchRows - Number(expired.numAffectedRows ?? 0n));
if (remaining === 0) return;
const retainedIds = kysely.selectFrom("execution_decision_facts").select("receipt_id").orderBy("occurred_at", "desc").orderBy("receipt_id", "desc").limit(limits.maxRows);
const overflowIds = kysely.selectFrom("execution_decision_facts").select("receipt_id").where("receipt_id", "not in", retainedIds).orderBy("occurred_at", "asc").orderBy("receipt_id", "asc").limit(remaining);
executeSqliteQuerySync(db, kysely.deleteFrom("execution_decision_facts").where("receipt_id", "in", overflowIds));
}
/** Record one immutable fact only when its action owner has no native durable record. */
function recordExecutionDecisionFact(receipt, options = {}) {
if (!validateDecisionReceiptV1(receipt)) throw new Error("execution decision fact must match DecisionReceiptV1");
if (receipt.source.owner === "operator_approvals") throw new Error("operator approvals must be read from their owner-native table");
if (!hasExactExecutionContext(openOpenClawStateDatabase(options).db, receipt)) throw new Error("execution decision fact requires an exact retained execution context");
const receiptJson = JSON.stringify(receipt);
const receiptBytes = Buffer.byteLength(receiptJson, "utf8");
if (receiptBytes > EXECUTION_DECISION_FACT_MAX_BYTES) throw new Error("execution decision fact exceeds 16 KiB");
ensureExecutionDecisionFactSchema(options);
return runOpenClawStateWriteTransaction(({ db }) => {
const kysely = decisionDb(db);
if (!hasExactExecutionContext(db, receipt)) throw new Error("execution decision fact requires an exact retained execution context");
const existing = executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("execution_decision_facts").select(["receipt_json"]).where("receipt_id", "=", receipt.receiptId));
if (existing) {
if (existing.receipt_json !== receiptJson) throw new Error("execution decision fact id conflicts with retained state");
return "existing";
}
executeSqliteQuerySync(db, kysely.insertInto("execution_decision_facts").values({
receipt_id: receipt.receiptId,
context_id: receipt.contextId,
execution_id: receipt.executionId,
run_id: receipt.runId,
action_id: receipt.actionId ?? null,
action_family: receipt.action.family,
decision_outcome: receipt.decision.outcome,
coverage_state: receipt.enforcement.coverageState,
reason_code: receipt.decision.reasonCode,
owner: receipt.source.owner,
source_ref: receipt.source.recordRef,
occurred_at: receipt.occurredAt,
receipt_bytes: receiptBytes,
receipt_json: receiptJson
}));
pruneDecisionFactsAfterInsert(db, options.now ?? Date.now(), options.limits ?? {
maxRows: EXECUTION_DECISION_FACT_MAX_ROWS,
pruneBatchRows: EXECUTION_DECISION_FACT_PRUNE_BATCH_ROWS
});
return "inserted";
}, options, { operationLabel: "audit.execution-decision.record" });
}
function retainedDecisionFactsForContextQuery(db, contextId, now) {
return decisionDb(db).selectFrom("execution_decision_facts").where("context_id", "=", contextId).where("occurred_at", ">=", now - EXECUTION_DECISION_FACT_RETENTION_MS);
}
function executionDecisionRowId() {
return sql`execution_decision_facts.rowid`;
}
function executionDecisionPayloadBytes() {
return sql`length(CAST(execution_decision_facts.receipt_json AS BLOB))`;
}
function executionDecisionBoundedPayload() {
return sql`CASE WHEN length(CAST(execution_decision_facts.receipt_json AS BLOB)) <= ${EXECUTION_DECISION_FACT_MAX_BYTES} THEN execution_decision_facts.receipt_json ELSE NULL END`;
}
function executionDecisionSelectorId(row) {
const rowId = normalizeSqliteNumber(row.receipt_rowid);
if (rowId === void 0 || rowId < 1) throw new Error("invalid execution decision fact rowid");
return `${EXECUTION_DECISION_SELECTOR_PREFIX}${rowId}`;
}
function retainedDecisionFactMetadata(params) {
const boundary = params.after ? executeSqliteQueryTakeFirstSync(params.db, decisionDb(params.db).selectFrom("execution_decision_facts").select(["receipt_id", "occurred_at"]).where(executionDecisionRowId(), "=", params.after.rowId).where("context_id", "=", params.contextId).where("occurred_at", "=", params.after.occurredAt)) : void 0;
if (params.after && !boundary) throw new Error("execution decision cursor is no longer retained");
return executeSqliteQuerySync(params.db, retainedDecisionFactsForContextQuery(params.db, params.contextId, params.now).$if(boundary !== void 0, (query) => query.where((eb) => eb.or([eb("occurred_at", ">", boundary.occurred_at), eb.and([eb("occurred_at", "=", boundary.occurred_at), eb("receipt_id", ">", boundary.receipt_id)])]))).select([
"receipt_id",
"context_id",
"execution_id",
"run_id",
"action_id",
"action_family",
"decision_outcome",
"coverage_state",
"reason_code",
"owner",
"source_ref",
"occurred_at",
"receipt_bytes"
]).select([
executionDecisionRowId().as("receipt_rowid"),
executionDecisionPayloadBytes().as("payload_bytes"),
executionDecisionBoundedPayload().as("bounded_receipt_json")
]).orderBy("occurred_at", "asc").orderBy("receipt_id", "asc").$if(params.offset !== void 0, (query) => query.offset(params.offset)).limit(params.limit)).rows;
}
function projectDecisionRow(row, context) {
try {
const receipt = parseDecisionRow(row);
return receipt.contextId === context.contextId && receipt.executionId === context.executionId && receipt.runId === context.runId ? receipt : unknownDecisionReceipt(row, "decision_fact_execution_link_mismatch", "decision.execution_link");
} catch {
return unknownDecisionReceipt(row, "decision_fact_record_corrupt", "decision.fact.valid");
}
}
function projectDecisionMetadata(metadata, context) {
if (metadata.payload_bytes > EXECUTION_DECISION_FACT_MAX_BYTES) return unknownDecisionReceipt(metadata, "decision_fact_payload_bounded", "decision.fact.payload_bounded");
return typeof metadata.bounded_receipt_json === "string" ? projectDecisionRow({
...metadata,
receipt_json: metadata.bounded_receipt_json
}, context) : unknownDecisionReceipt(metadata, "decision_fact_record_corrupt", "decision.fact.valid");
}
/** Summarize at most 128 owner rows; the 129th makes coverage explicitly unknown. */
function summarizeExecutionDecisionFactsForContext(params) {
return withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
if (!tableExists(db, "execution_decision_facts")) return {
count: 0,
missingEvidence: []
};
const metadataRows = retainedDecisionFactMetadata({
db,
contextId: params.context.contextId,
now: params.now ?? Date.now(),
limit: 129
});
const count = metadataRows.length;
if (count === 0) return {
count: 0,
missingEvidence: []
};
if (count > EXECUTION_DECISION_FACT_SUMMARY_MAX_ROWS) return {
count,
coverageState: "unknown",
missingEvidence: ["decision.fact.summary_bounded"]
};
const receipts = metadataRows.map((metadata) => projectDecisionMetadata(metadata, params.context));
const coverage = new Set(receipts.map((receipt) => receipt.enforcement.coverageState));
return {
count,
...coverage.has("unsupported") ? { coverageState: "unsupported" } : coverage.has("unknown") ? { coverageState: "unknown" } : coverage.has("enforced") ? { coverageState: "enforced" } : {},
missingEvidence: [...new Set(receipts.flatMap((receipt) => receipt.missingEvidence))].toSorted()
};
}, params.database) ?? {
count: 0,
missingEvidence: []
};
}
function hasExecutionDecisionFactsForRun(params) {
return withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
if (!tableExists(db, "execution_decision_facts")) return false;
return Boolean(executeSqliteQueryTakeFirstSync(db, decisionDb(db).selectFrom("execution_decision_facts").select("receipt_id").where("run_id", "=", params.runId).where("occurred_at", ">=", (params.now ?? Date.now()) - EXECUTION_DECISION_FACT_RETENTION_MS).limit(1)));
}, params.database) ?? false;
}
function pageExecutionDecisionFactsForContext(params) {
return withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
if (!tableExists(db, "execution_decision_facts")) return {
entries: [],
receipts: []
};
const metadataRows = retainedDecisionFactMetadata({
db,
contextId: params.context.contextId,
now: params.now ?? Date.now(),
after: params.after,
offset: params.offset,
limit: params.limit + 1
});
const pageMetadata = metadataRows.slice(0, params.limit);
const entries = pageMetadata.map((metadata) => ({
receipt: projectDecisionMetadata(metadata, params.context),
selectorId: executionDecisionSelectorId(metadata)
}));
const last = pageMetadata.at(-1);
return {
entries,
receipts: entries.map((entry) => entry.receipt),
...metadataRows.length > params.limit && last ? { nextCursor: {
occurredAt: normalizeSqliteNumber(last.occurred_at) ?? 0,
rowId: last.receipt_rowid
} } : {}
};
}, params.database) ?? {
entries: [],
receipts: []
};
}
/** Delete one bounded batch without creating the optional table. */
function pruneExpiredExecutionDecisionFacts(params = {}) {
const databaseOptions = params.database ?? {};
const database = openOpenClawStateDatabase(databaseOptions);
if (!tableExists(database.db, "execution_decision_facts")) return 0;
return runOpenClawStateWriteTransaction(({ db }) => Number(deleteExpiredDecisionFacts(db, params.now ?? Date.now(), EXECUTION_DECISION_FACT_PRUNE_BATCH_ROWS).numAffectedRows ?? 0n), {
...databaseOptions,
database
}, { operationLabel: "audit.execution-decision.maintenance" });
}
//#endregion
export { summarizeExecutionDecisionFactsForContext as a, pseudonymizeAuditIdentity as c, recordExecutionDecisionFact as i, pseudonymizeExecutionIdentityRef as l, pageExecutionDecisionFactsForContext as n, clearAuditIdentityKeyCacheForDatabase as o, pruneExpiredExecutionDecisionFacts as r, loadOrCreateAuditIdentityKey as s, hasExecutionDecisionFactsForRun as t };