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

179 lines
8.3 KiB
JavaScript

import crypto from "node:crypto";
import path from "node:path";
import { canonicalize, loadStoredRecord, readPublicKey, validatePayload, verifyRecord } from "./provenance.js";
import { classifyRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
import { executeEvidenceWrite } from "./writer.js";
import { createRuntimeApi } from "./runtime.js";
const activeRuns = new Map();
const permits = new Map();
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
function pluginConfig(event, api) {
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
}
function prune(now = Date.now()) {
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
}
function toolCallId(event) {
return typeof event?.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId.trim() : undefined;
}
function eventParams(event) {
return event?.params ?? event?.parameters;
}
function requireAbsoluteConfig(config, key) {
const value = config[key];
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${key}_missing`);
return value;
}
export function authorizeEvidenceCall({ event, trust, config, now = Date.now() }) {
const callId = toolCallId(event);
if (!callId) return { ok: false, code: "tool_call_id_missing" };
if (!trust?.trusted) return { ok: false, code: trust?.reason || "run_provenance_missing" };
const params = eventParams(event);
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "tool_params_missing" };
let record;
try { record = loadStoredRecord(requireAbsoluteConfig(config, "provenanceStoreDir"), params.provenance_id); }
catch (error) {
const code = error instanceof Error && ["unsafe_provenance_file", "invalid_provenance_location", "provenance_record_too_large"].includes(error.message)
? error.message : "provenance_store_rejected";
return { ok: false, code };
}
const validation = validatePayload(record.payload, now);
if (!validation.ok) return validation;
let publicKey;
try { publicKey = readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")); }
catch { return { ok: false, code: "public_key_rejected" }; }
const signature = verifyRecord(record, publicKey, config.provenanceKeyId);
if (!signature.ok) return signature;
const payload = record.payload;
const runId = runKey(event, {});
if (!runId || payload.run_id !== runId) return { ok: false, code: "run_id_mismatch" };
if (payload.sender_id !== trust.senderId) return { ok: false, code: "sender_id_mismatch" };
if (config.writerTaskBranch && payload.task_branch !== config.writerTaskBranch) return { ok: false, code: "task_branch_mismatch" };
if (config.writerTaskId && payload.task_id !== config.writerTaskId) return { ok: false, code: "task_id_mismatch" };
if (config.provenanceIssuer && payload.issued_by !== config.provenanceIssuer) return { ok: false, code: "issuer_mismatch" };
if (config.executor && payload.executor !== config.executor) return { ok: false, code: "executor_mismatch" };
if (config.executorInstance && payload.executor_instance !== config.executorInstance) return { ok: false, code: "executor_instance_mismatch" };
const paramsValidation = validateWriterParams(params, payload);
if (!paramsValidation.ok) return paramsValidation;
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
if (permits.has(callId)) return { ok: false, code: "permit_replay" };
const ttlMs = Number.isSafeInteger(config.permitTtlMs) && config.permitTtlMs > 0
? Math.min(config.permitTtlMs, DEFAULT_PERMIT_TTL_MS)
: DEFAULT_PERMIT_TTL_MS;
permits.set(callId, { payload, paramsDigest: digest, recordedAt: now, ttlMs });
return { ok: true };
}
export function consumePermit(toolCallIdValue, params, now = Date.now()) {
const permit = permits.get(toolCallIdValue);
permits.delete(toolCallIdValue);
if (!permit) throw new Error("permit_missing");
if (now - permit.recordedAt > permit.ttlMs) throw new Error("permit_expired");
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("permit_params_mismatch");
return permit;
}
function toolResult(details) {
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
}
function registerWriterTool(api) {
api.registerTool((toolContext) => ({
name: "aios_evidence_write",
label: "AIOS Evidence Writer",
description: "Write one Owner-authorized JSON evidence file and normally push the current task branch.",
parameters: {
type: "object",
additionalProperties: false,
required: ["provenance_id", "path", "operation", "document"],
properties: {
provenance_id: { type: "string" },
path: { type: "string" },
operation: { type: "string", enum: ["create", "update"] },
document: { type: "object" },
},
},
executionMode: "sequential",
async execute(callId, params) {
const permit = consumePermit(callId, params);
const config = toolContext?.getRuntimeConfig?.()?.plugins?.entries?.["aios-tenant-boundary"]?.config
?? api.pluginConfig ?? {};
const result = await executeEvidenceWrite({
repoRoot: requireAbsoluteConfig(config, "writerRepoRoot"),
evidencePath: params.path,
operation: params.operation,
document: params.document,
payload: permit.payload,
pushRemote: config.writerPushRemote ?? "gitea-http",
askPassPath: config.writerAskPassPath ?? "",
auditLogPath: requireAbsoluteConfig(config, "writerAuditLogPath"),
gitBinary: config.gitBinary ?? "/usr/bin/git",
});
return toolResult(result);
},
}), { name: "aios_evidence_write" });
}
export default {
id: "aios-tenant-boundary",
name: "AIOS Tenant Boundary",
description: "Fail-closed Owner provenance and dedicated evidence-writer boundary.",
register(api) {
const runtimeApi = createRuntimeApi(api);
api.registerHttpRoute({ path: "/v1/health", auth: "plugin", match: "exact", handler: runtimeApi.handleHealth });
api.registerHttpRoute({ path: "/v1/execute", auth: "plugin", match: "exact", handler: runtimeApi.handleExecute });
api.registerHttpRoute({ path: "/v1/commands/", auth: "plugin", match: "prefix", handler: runtimeApi.commandRequest });
registerWriterTool(api);
api.on("before_agent_run", async (event, ctx) => {
prune();
const config = pluginConfig(event, api);
const trust = classifyRun(event, ctx, config);
const key = runKey(event, ctx);
if (key) activeRuns.set(key, { ...trust, recordedAt: Date.now() });
if (config.blockUnknownAgentRuns === true && !trust.trusted && trust.reason !== "job_id_present") {
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
}
return { outcome: "pass" };
}, { priority: 100 });
api.on("before_tool_call", async (event, ctx) => {
prune();
const config = pluginConfig(event, api);
const trust = activeRuns.get(runKey(event, ctx));
const decision = decideToolCall(event.toolName, trust, config);
if (decision.action === "block") return { block: true, blockReason: `AIOS V2R1 policy denied: ${decision.code}` };
if (decision.action === "verify_provenance") {
const authorized = authorizeEvidenceCall({ event, trust, config });
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
return undefined;
}
if (decision.action === "approval") {
return { requireApproval: {
title: `Approve ${String(event.toolName)}`,
description: "Authenticated Owner requested a non-evidence side effect.",
severity: "warning", timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
allowedDecisions: ["allow-once", "deny"], pluginId: "aios-tenant-boundary",
} };
}
return undefined;
}, { priority: 100 });
api.on("agent_end", async (event, ctx) => {
const key = runKey(event, ctx);
if (key) activeRuns.delete(key);
});
},
};
export const __state = { activeRuns, permits };