177 lines
6.9 KiB
JavaScript
177 lines
6.9 KiB
JavaScript
import { ALLOWED_OPERATIONS, ALLOWED_PATHS } from "./provenance.js";
|
|
|
|
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
|
"memory_search", "sessions_list", "sessions_history", "session_status",
|
|
]);
|
|
const GENERIC_MUTATION_TOOLS = new Set([
|
|
"exec", "shell", "bash", "write", "edit", "apply_patch", "file_write",
|
|
"computer", "process", "terminal",
|
|
]);
|
|
|
|
function strings(value) {
|
|
return Array.isArray(value)
|
|
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
|
: [];
|
|
}
|
|
|
|
function firstString(values) {
|
|
for (const value of values) {
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function hasConversationInput(event = {}, ctx = {}) {
|
|
return Boolean(firstString([
|
|
event.prompt,
|
|
ctx.prompt,
|
|
event.input,
|
|
ctx.input,
|
|
event.text,
|
|
ctx.text,
|
|
event.message?.text,
|
|
ctx.message?.text,
|
|
event.message?.content,
|
|
ctx.message?.content,
|
|
event.channelContext?.text,
|
|
ctx.channelContext?.text,
|
|
event.channelContext?.message?.text,
|
|
ctx.channelContext?.message?.text,
|
|
]));
|
|
}
|
|
|
|
/** Resolve a stable sender ID from generic OpenClaw and WeChat/WeCom shapes. */
|
|
export function resolveSenderId(ctx = {}, event = {}) {
|
|
return firstString([
|
|
event.senderId,
|
|
ctx.senderId,
|
|
event.sender?.id,
|
|
ctx.sender?.id,
|
|
event.message?.senderId,
|
|
ctx.message?.senderId,
|
|
event.channelContext?.sender?.id,
|
|
ctx.channelContext?.sender?.id,
|
|
event.channelContext?.senderId,
|
|
ctx.channelContext?.senderId,
|
|
event.fromUserName,
|
|
ctx.fromUserName,
|
|
event.message?.fromUserName,
|
|
ctx.message?.fromUserName,
|
|
event.channelContext?.fromUserName,
|
|
ctx.channelContext?.fromUserName,
|
|
event.openId,
|
|
event.openid,
|
|
ctx.openId,
|
|
ctx.openid,
|
|
event.channelContext?.openId,
|
|
event.channelContext?.openid,
|
|
ctx.channelContext?.openId,
|
|
ctx.channelContext?.openid,
|
|
event.externalUserId,
|
|
ctx.externalUserId,
|
|
event.channelContext?.externalUserId,
|
|
ctx.channelContext?.externalUserId,
|
|
event.userId,
|
|
ctx.userId,
|
|
event.channelContext?.userId,
|
|
ctx.channelContext?.userId,
|
|
event.from,
|
|
ctx.from,
|
|
event.channelContext?.from,
|
|
ctx.channelContext?.from,
|
|
]);
|
|
}
|
|
|
|
export function classifyRun(event = {}, ctx = {}, config = {}) {
|
|
const senderId = resolveSenderId(ctx, event);
|
|
const trusted = new Set(strings(config.trustedSenderIds));
|
|
const blocked = new Set(strings(config.blockedSenderIds));
|
|
if (typeof ctx.jobId === "string" && ctx.jobId) {
|
|
return { trusted: false, origin: "automation", reason: "job_id_present" };
|
|
}
|
|
if (!senderId) {
|
|
return {
|
|
trusted: false,
|
|
origin: "system_or_unknown",
|
|
reason: "sender_id_missing",
|
|
conversationOnly: hasConversationInput(event, ctx),
|
|
};
|
|
}
|
|
if (blocked.has(senderId)) {
|
|
return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
|
|
}
|
|
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
|
|
return {
|
|
trusted: false,
|
|
origin: "untrusted_sender",
|
|
reason: "sender_not_allowlisted",
|
|
senderId,
|
|
conversationOnly: true,
|
|
};
|
|
}
|
|
if (config.requireSenderIsOwner !== false && event.senderIsOwner !== true) {
|
|
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
|
|
}
|
|
return { trusted: true, origin: "authenticated_owner", reason: "owner_verified", senderId };
|
|
}
|
|
|
|
/**
|
|
* Allow identified/message-bearing runs to enter a conversation-only agent
|
|
* session. Missing non-message system runs and explicitly blocked senders stay
|
|
* fail-closed. Tool access is enforced separately by decideToolCall().
|
|
*/
|
|
export function decideConversationRun(trust, config = {}) {
|
|
if (trust?.reason === "sender_blocked") {
|
|
return { action: "block", code: "sender_blocked" };
|
|
}
|
|
if (trust?.trusted || trust?.reason === "sender_not_owner" || trust?.reason === "job_id_present") {
|
|
return { action: "pass", code: trust?.reason || "trusted" };
|
|
}
|
|
if (trust?.conversationOnly === true) {
|
|
return config.allowUntrustedConversations === false
|
|
? { action: "block", code: trust?.reason || "untrusted_conversation" }
|
|
: { action: "pass", code: "conversation_only" };
|
|
}
|
|
return { action: "block", code: trust?.reason || "run_provenance_missing" };
|
|
}
|
|
|
|
export function runKey(event = {}, ctx = {}) {
|
|
const key = event.runId || ctx.runId;
|
|
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
|
}
|
|
|
|
export function decideToolCall(toolName, runTrust, config = {}) {
|
|
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
|
|
if (!name) return { action: "block", code: "tool_name_missing" };
|
|
if (GENERIC_MUTATION_TOOLS.has(name)) return { action: "block", code: "dedicated_writer_required" };
|
|
if (runTrust?.conversationOnly === true && config.allowUntrustedReadOnlyTools !== true) {
|
|
return { action: "block", code: "conversation_only_no_tools" };
|
|
}
|
|
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
|
|
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
|
|
if (readOnly.has(name)) return { action: "allow", code: "read_only" };
|
|
if (!runTrust?.trusted) return { action: "block", code: runTrust?.reason || "run_provenance_missing" };
|
|
if (name === "aios_evidence_write") return { action: "verify_provenance", code: "dedicated_writer" };
|
|
if (name === "aios_protected_dispatch") return { action: "verify_owner_provenance", code: "protected_dispatch" };
|
|
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
|
|
return { action: "approval", code: "human_side_effect" };
|
|
}
|
|
|
|
export function validateWriterParams(params = {}, payload) {
|
|
const keys = Object.keys(params).sort();
|
|
const expected = ["document", "operation", "path", "provenance_id"];
|
|
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) return { ok: false, code: "tool_params_schema_mismatch" };
|
|
if (params.provenance_id !== payload.provenance_id) return { ok: false, code: "provenance_id_mismatch" };
|
|
if (!ALLOWED_PATHS.includes(params.path)) return { ok: false, code: "path_not_allowed" };
|
|
if (params.operation !== "create" && params.operation !== "update") return { ok: false, code: "operation_not_allowed" };
|
|
if (!payload.allowed_paths.includes(params.path)) return { ok: false, code: "path_not_authorized" };
|
|
for (const operation of [params.operation, "git add", "git commit", "normal push current task branch"]) {
|
|
if (!payload.allowed_operations.includes(operation)) return { ok: false, code: "operation_not_authorized" };
|
|
}
|
|
let serialized;
|
|
try { serialized = JSON.stringify(params.document); } catch { return { ok: false, code: "document_not_json" }; }
|
|
if (serialized === undefined || Buffer.byteLength(serialized) > 256 * 1024) return { ok: false, code: "document_invalid_or_too_large" };
|
|
return { ok: true };
|
|
}
|