Files
aios/plugins/aios-tenant-boundary/policy.js
T

155 lines
6.4 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 only identities populated by the trusted OpenClaw hook boundary. */
export function resolveSenderId(ctx = {}, event = {}) {
return firstString([
event.senderId,
ctx.senderId,
event.sender?.id,
ctx.sender?.id,
event.channelContext?.sender?.id,
ctx.channelContext?.sender?.id,
]);
}
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,
};
}
const singletonTrustedOwner = config.requireExplicitTrustedSenders !== false &&
trusted.size === 1 && trusted.has(senderId);
const ownerVerified = event.senderIsOwner === true || singletonTrustedOwner;
if (config.requireSenderIsOwner !== false && !ownerVerified) {
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
}
return {
trusted: true,
origin: "authenticated_owner",
reason: event.senderIsOwner === true ? "owner_verified" : "owner_verified_by_singleton_trusted_binding",
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) {
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 };
}