mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps give those agents governed access to external tools. > - Remote MCP setup needs secure endpoint validation and durable credentials. > - PostHog needs both browser sign-in and personal API key setup paths. > - This pull request adds the shared remote MCP foundation and the PostHog definition. > - The benefit is a secure and reusable base for later app connection work. ## Linked Issues or Issue Description Refs #11965 This is stack 1 of 11. It replaces the first reviewable part of #11965. ## What Changed - Add guarded remote MCP setup and credential handling. - Add PostHog OAuth and API key connection methods. - Add focused server, shared contract, and UI coverage. - Keep the migration replay-safe and idempotent. - Give the late-close security regression the same 10-second CI headroom as the adjacent real-timer handshake test. - Synchronize fake-timer handshake tests at the exact ensure-session boundary so real filesystem setup cannot race the fake deadline. - Drive PTY overflow coverage only after listener registration so scheduling cannot reorder the test fixture. ## Verification - pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts server/src/__tests__/plugin-worker-manager.test.ts (220 passed; affected cases also passed five focused stress repetitions) - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never leaks a sandbox-provided value from a late close rejection into logs or the result"` (1 passed) - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never promotes a late ensureSession resolution|closes a late-resolving real handle exactly once"` (2 passed) - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - `pnpm --filter @paperclipai/db check:migrations` - `pnpm build` ## Risks - Remote endpoint validation can reject configurations that previously passed without checks. - OAuth configuration errors can block setup until the operator corrects the provider settings. - The migration uses guarded statements so repeated execution is safe. - The test-only synchronization changes do not affect runtime behavior; they remove filesystem/fake-clock and listener-registration races observed under parallel CI load. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1843 lines
77 KiB
TypeScript
1843 lines
77 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
heartbeatRuns,
|
|
issues,
|
|
principalPermissionGrants,
|
|
projects,
|
|
routines,
|
|
toolAccessAuditEvents,
|
|
toolActionRequests,
|
|
toolApplications,
|
|
toolCatalogEntries,
|
|
toolConnections,
|
|
toolCallEvents,
|
|
toolInvocations,
|
|
toolPolicies,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolRateLimitCounters,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
ToolAccessDecision,
|
|
ToolAccessDecisionInput,
|
|
ToolAccessReasonCode,
|
|
ToolAccessSelector,
|
|
ToolAuditEventType,
|
|
CreateToolPolicy,
|
|
DuplicateToolPolicy,
|
|
CreateToolTrustRuleFromActionRequest,
|
|
ReorderToolPolicies,
|
|
RevokeToolTrustRule,
|
|
ToolPolicyDecision,
|
|
ToolPolicyConditions,
|
|
UpdateToolPolicy,
|
|
ToolRateLimitRule,
|
|
ToolRedactedValueSummary,
|
|
ToolTrustRuleArgumentFilters,
|
|
ToolRiskLevel,
|
|
} from "@paperclipai/shared";
|
|
import { toolPolicyConditionsSchema } from "@paperclipai/shared";
|
|
import { badRequest, conflict, notFound, unprocessable } from "../errors.js";
|
|
import {
|
|
effectiveToolProfileBindings,
|
|
profileIdsInBindingOrder,
|
|
} from "./tool-profile-binding-precedence.js";
|
|
import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
|
|
|
|
type ToolAccessContext = {
|
|
companyId: string;
|
|
actorType: "agent" | "user" | "system" | "plugin";
|
|
actorId: string;
|
|
agentId: string | null;
|
|
heartbeatRunId: string | null;
|
|
issueId: string | null;
|
|
projectId: string | null;
|
|
routineId: string | null;
|
|
gatewayId: string | null;
|
|
applicationId: string | null;
|
|
connectionId: string | null;
|
|
catalogEntryId: string | null;
|
|
catalogStatus: string | null;
|
|
catalogVersionHash: string | null;
|
|
catalogSchemaHash: string | null;
|
|
providerType: string | null;
|
|
applicationKey: string | null;
|
|
upstreamToolName: string | null;
|
|
toolName: string;
|
|
riskLevel: ToolRiskLevel | null;
|
|
argumentsHash: string;
|
|
arguments: unknown;
|
|
};
|
|
|
|
type RedactionResult = {
|
|
summary: ToolRedactedValueSummary;
|
|
redactionPlan: { redactedFieldCount: number; redactedFields: string[] };
|
|
};
|
|
|
|
type PolicyConditionEvaluation = {
|
|
matched: boolean;
|
|
matchedGroups: string[];
|
|
failedGroup?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
type TrustRuleConfig = {
|
|
trustRule?: {
|
|
sourceActionRequestId?: string | null;
|
|
sourceInvocationId?: string | null;
|
|
sourceApprovalCount?: number;
|
|
approvalThreshold?: number;
|
|
argumentFilters?: ToolTrustRuleArgumentFilters | null;
|
|
expiresAt?: string | null;
|
|
revokedAt?: string | null;
|
|
revokedByAgentId?: string | null;
|
|
revokedByUserId?: string | null;
|
|
revocationReason?: string | null;
|
|
hitCount?: number;
|
|
lastHitAt?: string | null;
|
|
catalogVersionHash?: string | null;
|
|
schemaHash?: string | null;
|
|
batchApproval?: Record<string, unknown> | null;
|
|
};
|
|
} & Record<string, unknown>;
|
|
|
|
const SENSITIVE_KEY_RE =
|
|
/(^|[_-])(api[_-]?key|authorization|bearer|client[_-]?secret|cookie|credential|jwt|password|private[_-]?key|refresh[_-]?token|secret|session[_-]?token|token)($|[_-])/i;
|
|
const SECRET_VALUE_RE = /\b(sk-[a-z0-9_-]{12,}|ghp_[a-z0-9_]{12,}|xox[baprs]-[a-z0-9-]{12,}|bearer\s+[a-z0-9._-]{12,})\b/i;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function snapshotString(snapshot: Record<string, unknown>, key: string): string | null {
|
|
const value = snapshot[key];
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (!isRecord(value) && !Array.isArray(value)) return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
}
|
|
|
|
function sha256(value: unknown): string {
|
|
return createHash("sha256").update(stableStringify(value)).digest("hex");
|
|
}
|
|
|
|
function isoDateOrNull(value: unknown): string | null {
|
|
if (!value) return null;
|
|
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
if (typeof value !== "string") return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function policyConfig(policy: typeof toolPolicies.$inferSelect): TrustRuleConfig {
|
|
return isRecord(policy.config) ? policy.config as TrustRuleConfig : {};
|
|
}
|
|
|
|
function trustRuleConfig(policy: typeof toolPolicies.$inferSelect) {
|
|
const config = policyConfig(policy).trustRule;
|
|
return isRecord(config) ? config : null;
|
|
}
|
|
|
|
function readPath(value: unknown, path: string): unknown {
|
|
if (!path) return undefined;
|
|
return path.split(".").reduce<unknown>((current, segment) => {
|
|
if (!isRecord(current) && !Array.isArray(current)) return undefined;
|
|
if (Array.isArray(current)) {
|
|
const index = Number(segment);
|
|
return Number.isInteger(index) ? current[index] : undefined;
|
|
}
|
|
return current[segment];
|
|
}, value);
|
|
}
|
|
|
|
function argumentFiltersMatch(filters: ToolTrustRuleArgumentFilters | null | undefined, ctx: ToolAccessContext): boolean {
|
|
if (!filters || filters.allowAny === true) return true;
|
|
if (filters.exactHash && filters.exactHash !== ctx.argumentsHash) return false;
|
|
if (filters.allowedHashes?.length && !filters.allowedHashes.includes(ctx.argumentsHash)) return false;
|
|
if (filters.fieldEquals) {
|
|
for (const [path, expected] of Object.entries(filters.fieldEquals)) {
|
|
if (stableStringify(readPath(ctx.arguments, path)) !== stableStringify(expected)) return false;
|
|
}
|
|
}
|
|
if (filters.fieldNotEquals) {
|
|
for (const [path, expected] of Object.entries(filters.fieldNotEquals)) {
|
|
if (stableStringify(readPath(ctx.arguments, path)) === stableStringify(expected)) return false;
|
|
}
|
|
}
|
|
if (filters.fieldIn) {
|
|
for (const [path, allowedValues] of Object.entries(filters.fieldIn)) {
|
|
const actual = stableStringify(readPath(ctx.arguments, path));
|
|
if (!allowedValues.some((expected) => stableStringify(expected) === actual)) return false;
|
|
}
|
|
}
|
|
if (filters.fieldMatches) {
|
|
for (const [path, pattern] of Object.entries(filters.fieldMatches)) {
|
|
const actual = readPath(ctx.arguments, path);
|
|
if (typeof actual !== "string") return false;
|
|
let re: RegExp;
|
|
try {
|
|
re = new RegExp(pattern);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!re.test(actual)) return false;
|
|
}
|
|
}
|
|
if (filters.fieldExists?.some((path) => readPath(ctx.arguments, path) === undefined)) return false;
|
|
if (filters.fieldAbsent?.some((path) => readPath(ctx.arguments, path) !== undefined)) return false;
|
|
return Boolean(
|
|
filters.exactHash
|
|
|| filters.allowedHashes?.length
|
|
|| filters.fieldEquals
|
|
|| filters.fieldNotEquals
|
|
|| filters.fieldIn
|
|
|| filters.fieldMatches
|
|
|| filters.fieldExists?.length
|
|
|| filters.fieldAbsent?.length,
|
|
);
|
|
}
|
|
|
|
function trustRuleNeedsReview(policy: typeof toolPolicies.$inferSelect, ctx: ToolAccessContext): boolean {
|
|
const rule = trustRuleConfig(policy);
|
|
if (!rule || !ctx.catalogEntryId) return false;
|
|
const catalogVersionHash = typeof rule.catalogVersionHash === "string" ? rule.catalogVersionHash : null;
|
|
const schemaHash = typeof rule.schemaHash === "string" ? rule.schemaHash : null;
|
|
return Boolean(
|
|
ctx.catalogStatus === "quarantined"
|
|
|| ctx.catalogStatus === "removed"
|
|
|| (catalogVersionHash && ctx.catalogVersionHash && catalogVersionHash !== ctx.catalogVersionHash)
|
|
|| (schemaHash && ctx.catalogSchemaHash && schemaHash !== ctx.catalogSchemaHash),
|
|
);
|
|
}
|
|
|
|
function trustRuleIsActive(policy: typeof toolPolicies.$inferSelect, now = new Date()): boolean {
|
|
const rule = trustRuleConfig(policy);
|
|
if (!rule) return false;
|
|
if (rule.revokedAt) return false;
|
|
if (rule.expiresAt) {
|
|
const expiresAt = new Date(rule.expiresAt);
|
|
if (!Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() <= now.getTime()) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function sideEffectIdempotencyKey(ctx: ToolAccessContext, argumentsHash: string): string {
|
|
return `side_effect:${sha256({
|
|
companyId: ctx.companyId,
|
|
runId: ctx.heartbeatRunId,
|
|
issueId: ctx.issueId,
|
|
applicationId: ctx.applicationId,
|
|
connectionId: ctx.connectionId,
|
|
catalogEntryId: ctx.catalogEntryId,
|
|
toolName: ctx.toolName,
|
|
argumentsHash,
|
|
})}`;
|
|
}
|
|
|
|
function auditOutcome(accessDecision: ToolAccessDecision): "pending" | "success" | "denied" | "timeout" {
|
|
if (accessDecision.decision === "allow") return "success";
|
|
if (accessDecision.decision === "require_approval") return "pending";
|
|
if (accessDecision.decision === "defer_runtime") return "timeout";
|
|
return "denied";
|
|
}
|
|
|
|
function summarizeAndRedact(value: unknown): RedactionResult {
|
|
const redactedFields: string[] = [];
|
|
const visit = (current: unknown, path: string): unknown => {
|
|
if (typeof current === "string") {
|
|
if (SECRET_VALUE_RE.test(current)) {
|
|
redactedFields.push(path || "$");
|
|
return "[REDACTED]";
|
|
}
|
|
return current.length > 500 ? `${current.slice(0, 500)}...[truncated]` : current;
|
|
}
|
|
if (Array.isArray(current)) return current.slice(0, 50).map((entry, index) => visit(entry, `${path}[${index}]`));
|
|
if (!isRecord(current)) return current;
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, nested] of Object.entries(current)) {
|
|
const nestedPath = path ? `${path}.${key}` : key;
|
|
if (SENSITIVE_KEY_RE.test(key)) {
|
|
redactedFields.push(nestedPath);
|
|
out[key] = "[REDACTED]";
|
|
} else {
|
|
out[key] = visit(nested, nestedPath);
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
const redacted = visit(value ?? {}, "");
|
|
const text = stableStringify(redacted);
|
|
return {
|
|
summary: {
|
|
summary: text.length > 4000 ? `${text.slice(0, 4000)}...[truncated]` : text,
|
|
sizeBytes: Buffer.byteLength(text),
|
|
sha256: sha256(redacted),
|
|
redactedFields,
|
|
},
|
|
redactionPlan: {
|
|
redactedFieldCount: redactedFields.length,
|
|
redactedFields,
|
|
},
|
|
};
|
|
}
|
|
|
|
function decision(
|
|
kind: ToolPolicyDecision,
|
|
reasonCode: ToolAccessReasonCode,
|
|
explanation: string,
|
|
effectiveProfileIds: string[],
|
|
matchedPolicyIds: string[],
|
|
extra: Partial<ToolAccessDecision> = {},
|
|
): ToolAccessDecision {
|
|
return {
|
|
decision: kind,
|
|
allowed: kind === "allow",
|
|
reasonCode,
|
|
explanation,
|
|
effectiveProfileIds,
|
|
matchedPolicyIds,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function listValues(value: unknown): string[] {
|
|
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
}
|
|
|
|
function asToolRiskLevel(value: unknown): ToolRiskLevel | null {
|
|
if (
|
|
value === "low"
|
|
|| value === "medium"
|
|
|| value === "high"
|
|
|| value === "critical"
|
|
|| value === "read"
|
|
|| value === "write"
|
|
|| value === "destructive"
|
|
) {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function selectorMatches(selector: ToolAccessSelector | Record<string, unknown> | null | undefined, ctx: ToolAccessContext): boolean {
|
|
if (!selector || Object.keys(selector).length === 0) return true;
|
|
const s = selector as Record<string, unknown>;
|
|
// Tool/action names are exact selectors. Glob-looking values are treated as literal names.
|
|
const match = (singleKey: string, pluralKey: string, actual: string | null) => {
|
|
const single = typeof s[singleKey] === "string" ? String(s[singleKey]) : null;
|
|
const many = listValues(s[pluralKey]);
|
|
return (!single || actual === single) && (many.length === 0 || Boolean(actual && many.includes(actual)));
|
|
};
|
|
const matchAny = (singleKey: string, pluralKey: string, actuals: Array<string | null>) => {
|
|
const values = actuals.filter((value): value is string => Boolean(value));
|
|
const single = typeof s[singleKey] === "string" ? String(s[singleKey]) : null;
|
|
const many = listValues(s[pluralKey]);
|
|
return (!single || values.includes(single)) && (many.length === 0 || many.some((value) => values.includes(value)));
|
|
};
|
|
return (
|
|
match("actorType", "actorTypes", ctx.actorType) &&
|
|
match("agentId", "agentIds", ctx.agentId) &&
|
|
match("projectId", "projectIds", ctx.projectId) &&
|
|
match("routineId", "routineIds", ctx.routineId) &&
|
|
match("issueId", "issueIds", ctx.issueId) &&
|
|
match("gatewayId", "gatewayIds", ctx.gatewayId) &&
|
|
match("applicationId", "applicationIds", ctx.applicationId) &&
|
|
match("connectionId", "connectionIds", ctx.connectionId) &&
|
|
match("catalogEntryId", "catalogEntryIds", ctx.catalogEntryId) &&
|
|
match("applicationKey", "applicationKeys", ctx.applicationKey) &&
|
|
match("providerType", "providerTypes", ctx.providerType) &&
|
|
matchAny("toolName", "toolNames", [ctx.toolName, ctx.upstreamToolName]) &&
|
|
match("riskLevel", "riskLevels", ctx.riskLevel)
|
|
);
|
|
}
|
|
|
|
function conditionRecord(value: unknown): Record<string, unknown> | null {
|
|
return isRecord(value) && Object.keys(value).length > 0 ? value : null;
|
|
}
|
|
|
|
function argumentConditionMatches(condition: Record<string, unknown>, ctx: ToolAccessContext): boolean {
|
|
const filters: ToolTrustRuleArgumentFilters = {
|
|
fieldEquals: conditionRecord(condition.fieldEquals) ?? undefined,
|
|
fieldNotEquals: conditionRecord(condition.fieldNotEquals) ?? undefined,
|
|
fieldIn: conditionRecord(condition.fieldIn) as Record<string, unknown[]> | undefined,
|
|
fieldMatches: conditionRecord(condition.fieldMatches) as Record<string, string> | undefined,
|
|
fieldExists: listValues(condition.fieldExists),
|
|
fieldAbsent: listValues(condition.fieldAbsent),
|
|
};
|
|
return argumentFiltersMatch(filters, ctx);
|
|
}
|
|
|
|
function riskRank(value: ToolRiskLevel | null): number {
|
|
if (value === "read" || value === "low") return 1;
|
|
if (value === "write" || value === "medium") return 2;
|
|
if (value === "destructive" || value === "high") return 3;
|
|
if (value === "critical") return 4;
|
|
return 0;
|
|
}
|
|
|
|
function boolCondition(value: unknown): boolean | null {
|
|
return typeof value === "boolean" ? value : null;
|
|
}
|
|
|
|
function timeWindowMatches(condition: Record<string, unknown>, now: Date): boolean {
|
|
const startAt = isoDateOrNull(condition.startAt);
|
|
const endAt = isoDateOrNull(condition.endAt);
|
|
if (startAt && now.getTime() < new Date(startAt).getTime()) return false;
|
|
if (endAt && now.getTime() > new Date(endAt).getTime()) return false;
|
|
|
|
const days = Array.isArray(condition.daysOfWeekUtc)
|
|
? condition.daysOfWeekUtc.filter((day): day is number => Number.isInteger(day) && day >= 0 && day <= 6)
|
|
: [];
|
|
if (days.length > 0 && !days.includes(now.getUTCDay())) return false;
|
|
|
|
const startHour = typeof condition.startHourUtc === "number" ? condition.startHourUtc : null;
|
|
const endHour = typeof condition.endHourUtc === "number" ? condition.endHourUtc : null;
|
|
if (startHour !== null || endHour !== null) {
|
|
const hour = now.getUTCHours();
|
|
const start = startHour ?? 0;
|
|
const end = endHour ?? 24;
|
|
if (start === end) return false;
|
|
if (start < end) {
|
|
if (hour < start || hour >= end) return false;
|
|
} else if (hour < start && hour >= end) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function policyConditions(policy: typeof toolPolicies.$inferSelect): ToolPolicyConditions | null {
|
|
return isRecord(policy.conditions) && Object.keys(policy.conditions).length > 0
|
|
? policy.conditions as ToolPolicyConditions
|
|
: null;
|
|
}
|
|
|
|
function conditionGroupFail(group: string, reason: string): PolicyConditionEvaluation {
|
|
return { matched: false, matchedGroups: [], failedGroup: group, reason };
|
|
}
|
|
|
|
function evaluatePolicyConditions(
|
|
conditions: ToolPolicyConditions | null | undefined,
|
|
ctx: ToolAccessContext,
|
|
now = new Date(),
|
|
): PolicyConditionEvaluation {
|
|
if (!conditions || Object.keys(conditions).length === 0) return { matched: true, matchedGroups: [] };
|
|
const matchedGroups: string[] = [];
|
|
|
|
const argumentCondition = conditionRecord(conditions.arguments ?? conditions.args);
|
|
if (argumentCondition) {
|
|
if (!argumentConditionMatches(argumentCondition, ctx)) {
|
|
return conditionGroupFail("arguments", "Tool arguments did not satisfy the policy condition.");
|
|
}
|
|
matchedGroups.push("arguments");
|
|
}
|
|
|
|
if (conditions.actor) {
|
|
if (!selectorMatches(conditions.actor, ctx)) {
|
|
return conditionGroupFail("actor", "Actor did not satisfy the policy condition.");
|
|
}
|
|
matchedGroups.push("actor");
|
|
}
|
|
|
|
if (conditions.context) {
|
|
const context = conditions.context as Record<string, unknown>;
|
|
if (boolCondition(context.requireIssue) === true && !ctx.issueId) {
|
|
return conditionGroupFail("context", "Policy condition requires issue context.");
|
|
}
|
|
if (boolCondition(context.requireProject) === true && !ctx.projectId) {
|
|
return conditionGroupFail("context", "Policy condition requires project context.");
|
|
}
|
|
if (boolCondition(context.requireRoutine) === true && !ctx.routineId) {
|
|
return conditionGroupFail("context", "Policy condition requires routine context.");
|
|
}
|
|
if (!selectorMatches(context, ctx)) {
|
|
return conditionGroupFail("context", "Issue, project, or routine context did not satisfy the policy condition.");
|
|
}
|
|
matchedGroups.push("context");
|
|
}
|
|
|
|
if (conditions.risk) {
|
|
const risk = conditions.risk as Record<string, unknown>;
|
|
const levels = listValues(risk.levels).map(asToolRiskLevel).filter((level): level is ToolRiskLevel => Boolean(level));
|
|
const max = asToolRiskLevel(risk.max);
|
|
const isWrite = boolCondition(risk.isWrite);
|
|
const isDestructive = boolCondition(risk.isDestructive);
|
|
if (levels.length > 0 && (!ctx.riskLevel || !levels.includes(ctx.riskLevel))) {
|
|
return conditionGroupFail("risk", "Tool risk level did not satisfy the policy condition.");
|
|
}
|
|
if (max && riskRank(ctx.riskLevel) > riskRank(max)) {
|
|
return conditionGroupFail("risk", "Tool risk level is above the policy condition limit.");
|
|
}
|
|
if (isWrite !== null && ((ctx.riskLevel === "write" || ctx.riskLevel === "destructive" || ctx.riskLevel === "medium" || ctx.riskLevel === "high" || ctx.riskLevel === "critical") !== isWrite)) {
|
|
return conditionGroupFail("risk", "Tool write capability did not satisfy the policy condition.");
|
|
}
|
|
if (isDestructive !== null && ((ctx.riskLevel === "destructive" || ctx.riskLevel === "high" || ctx.riskLevel === "critical") !== isDestructive)) {
|
|
return conditionGroupFail("risk", "Tool destructive capability did not satisfy the policy condition.");
|
|
}
|
|
matchedGroups.push("risk");
|
|
}
|
|
|
|
if (conditions.credentialScope) {
|
|
const scope = conditions.credentialScope as Record<string, unknown>;
|
|
if (!selectorMatches(scope, ctx)) {
|
|
return conditionGroupFail("credentialScope", "Credential scope did not satisfy the policy condition.");
|
|
}
|
|
if (!selectorMatches({
|
|
applicationKey: scope.applicationKey,
|
|
applicationKeys: scope.applicationKeys,
|
|
providerType: scope.providerType,
|
|
providerTypes: scope.providerTypes,
|
|
}, ctx)) {
|
|
return conditionGroupFail("credentialScope", "Credential provider did not satisfy the policy condition.");
|
|
}
|
|
matchedGroups.push("credentialScope");
|
|
}
|
|
|
|
if (conditions.trustBoundary) {
|
|
const boundary = conditions.trustBoundary as Record<string, unknown>;
|
|
if (!selectorMatches({
|
|
applicationKey: boundary.applicationKey,
|
|
applicationKeys: boundary.applicationKeys,
|
|
providerType: boundary.providerType,
|
|
providerTypes: boundary.providerTypes,
|
|
}, ctx)) {
|
|
return conditionGroupFail("trustBoundary", "Trust boundary did not satisfy the policy condition.");
|
|
}
|
|
if (boolCondition(boundary.remoteHttpOnly) === true && ctx.providerType !== "mcp_remote_http") {
|
|
return conditionGroupFail("trustBoundary", "Policy condition requires a remote HTTP MCP tool.");
|
|
}
|
|
if (boolCondition(boundary.paperclipSelfOnly) === true && ctx.providerType !== "paperclip_self") {
|
|
return conditionGroupFail("trustBoundary", "Policy condition requires a Paperclip self tool.");
|
|
}
|
|
matchedGroups.push("trustBoundary");
|
|
}
|
|
|
|
if (conditions.timeWindow) {
|
|
if (!timeWindowMatches(conditions.timeWindow as Record<string, unknown>, now)) {
|
|
return conditionGroupFail("timeWindow", "Current UTC time is outside the policy condition window.");
|
|
}
|
|
matchedGroups.push("timeWindow");
|
|
}
|
|
|
|
return { matched: true, matchedGroups };
|
|
}
|
|
|
|
function profileEntryMatches(entry: typeof toolProfileEntries.$inferSelect, ctx: ToolAccessContext): boolean {
|
|
const conditions = isRecord(entry.conditions) ? entry.conditions as ToolPolicyConditions : null;
|
|
if (!evaluatePolicyConditions(conditions, ctx).matched) return false;
|
|
if (entry.selectorType === "application") return entry.applicationId === ctx.applicationId;
|
|
if (entry.selectorType === "connection") return entry.connectionId === ctx.connectionId;
|
|
if (entry.selectorType === "catalog_entry") return entry.catalogEntryId === ctx.catalogEntryId;
|
|
if (entry.selectorType === "tool_name") return entry.toolName === ctx.toolName;
|
|
if (entry.selectorType === "risk_level") return entry.riskLevel === ctx.riskLevel;
|
|
return false;
|
|
}
|
|
|
|
function targetMatches(binding: typeof toolProfileBindings.$inferSelect, ctx: ToolAccessContext): boolean {
|
|
if (binding.targetType === "company") return binding.targetId === ctx.companyId;
|
|
if (binding.targetType === "agent") return binding.targetId === ctx.agentId;
|
|
if (binding.targetType === "project") return binding.targetId === ctx.projectId;
|
|
if (binding.targetType === "routine") return binding.targetId === ctx.routineId;
|
|
if (binding.targetType === "issue") return binding.targetId === ctx.issueId;
|
|
if (binding.targetType === "gateway") return binding.targetId === ctx.gatewayId;
|
|
return false;
|
|
}
|
|
|
|
function rateLimitRule(policy: typeof toolPolicies.$inferSelect): ToolRateLimitRule | null {
|
|
const config = isRecord(policy.config) ? policy.config : {};
|
|
const raw = isRecord(config.rateLimit) ? config.rateLimit : config;
|
|
const limit = typeof raw.limit === "number" ? raw.limit : null;
|
|
const windowSeconds = typeof raw.windowSeconds === "number" ? raw.windowSeconds : null;
|
|
if (!limit || !windowSeconds || limit <= 0 || windowSeconds <= 0) return null;
|
|
return {
|
|
limit: Math.floor(limit),
|
|
windowSeconds: Math.floor(windowSeconds),
|
|
keyBy: Array.isArray(raw.keyBy)
|
|
? raw.keyBy.filter((item): item is NonNullable<ToolRateLimitRule["keyBy"]>[number] => typeof item === "string")
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function assertGenericPolicyType(policyType: string) {
|
|
if (policyType === "trust_rule") {
|
|
throw unprocessable("Trust rules are managed through the trust-rule promotion and revoke endpoints");
|
|
}
|
|
if (policyType === "redact" || policyType === "validate") {
|
|
throw unprocessable(`Tool policy type '${policyType}' is not supported at runtime`);
|
|
}
|
|
}
|
|
|
|
function isSafePolicyRegex(pattern: string): boolean {
|
|
if (pattern.length > 256) return false;
|
|
if (/\\[1-9]/.test(pattern)) return false;
|
|
if (/(^|[^\\])\|/.test(pattern)) return false;
|
|
if (/\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)[+*{]/.test(pattern)) return false;
|
|
return true;
|
|
}
|
|
|
|
function assertSupportedPolicyConditions(conditions: Record<string, unknown> | null | undefined) {
|
|
if (!conditions || Object.keys(conditions).length === 0) return;
|
|
const parsed = toolPolicyConditionsSchema.safeParse(conditions);
|
|
if (!parsed.success) {
|
|
throw unprocessable("Tool policy conditions include unsupported runtime semantics", {
|
|
issues: parsed.error.issues.map((issue) => ({
|
|
path: issue.path.join("."),
|
|
message: issue.message,
|
|
})),
|
|
});
|
|
}
|
|
const fieldMatches = parsed.data.arguments?.fieldMatches;
|
|
if (fieldMatches) {
|
|
for (const pattern of Object.values(fieldMatches)) {
|
|
if (!isSafePolicyRegex(pattern)) {
|
|
throw unprocessable("Tool policy fieldMatches includes an unsafe regular expression");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function unsupportedRuntimePolicyType(policyType: string) {
|
|
return policyType === "redact" || policyType === "validate";
|
|
}
|
|
|
|
function hasConfig(config: Record<string, unknown> | null | undefined) {
|
|
return Boolean(config && Object.keys(config).length > 0);
|
|
}
|
|
|
|
function assertSupportedGenericPolicyShape(
|
|
policyType: string,
|
|
conditions: Record<string, unknown> | null | undefined,
|
|
config: Record<string, unknown> | null | undefined,
|
|
) {
|
|
assertGenericPolicyType(policyType);
|
|
assertSupportedPolicyConditions(conditions);
|
|
if (policyType !== "rate_limit" && hasConfig(config)) {
|
|
throw unprocessable(`Tool policy type '${policyType}' does not support config`);
|
|
}
|
|
if (policyType === "rate_limit") {
|
|
const raw = isRecord(config?.rateLimit) ? config.rateLimit : config;
|
|
const limit = isRecord(raw) && typeof raw.limit === "number" ? raw.limit : null;
|
|
const windowSeconds = isRecord(raw) && typeof raw.windowSeconds === "number" ? raw.windowSeconds : null;
|
|
if (!limit || !windowSeconds || limit <= 0 || windowSeconds <= 0) {
|
|
throw unprocessable("Rate-limit policy config requires positive numeric limit and windowSeconds");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getGenericPolicyRow(db: Db, companyId: string, policyId: string) {
|
|
const [policy] = await db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(and(
|
|
eq(toolPolicies.id, policyId),
|
|
eq(toolPolicies.companyId, companyId),
|
|
ne(toolPolicies.policyType, "trust_rule"),
|
|
))
|
|
.limit(1);
|
|
if (!policy) throw notFound("Tool policy not found");
|
|
return policy;
|
|
}
|
|
|
|
function windowKind(windowSeconds: number): "minute" | "hour" | "day" | "month" {
|
|
if (windowSeconds <= 60) return "minute";
|
|
if (windowSeconds <= 3600) return "hour";
|
|
if (windowSeconds <= 86400) return "day";
|
|
return "month";
|
|
}
|
|
|
|
function windowStart(now: Date, windowSeconds: number): Date {
|
|
return new Date(Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000);
|
|
}
|
|
|
|
function rateBucket(rule: ToolRateLimitRule, ctx: ToolAccessContext): string {
|
|
const parts = rule.keyBy?.length ? rule.keyBy : ["company", "agent", "connection", "tool"] as const;
|
|
return parts.map((part) => {
|
|
if (part === "company") return `company:${ctx.companyId}`;
|
|
if (part === "agent") return `agent:${ctx.agentId ?? "none"}`;
|
|
if (part === "application") return `application:${ctx.applicationId ?? "none"}`;
|
|
if (part === "connection") return `connection:${ctx.connectionId ?? "none"}`;
|
|
return `tool:${ctx.toolName}`;
|
|
}).join("|");
|
|
}
|
|
|
|
function scopeAllowsTool(scope: Record<string, unknown> | null, ctx: ToolAccessContext) {
|
|
if (!scope || Object.keys(scope).length === 0) return true;
|
|
const allowed = listValues(scope.allow);
|
|
if (allowed.includes(`tool:${ctx.toolName}`)) return true;
|
|
if (ctx.connectionId && allowed.includes(`connection:${ctx.connectionId}`)) return true;
|
|
if (ctx.applicationId && allowed.includes(`application:${ctx.applicationId}`)) return true;
|
|
return selectorMatches(scope, ctx);
|
|
}
|
|
|
|
export function toolAccessPolicyService(db: Db) {
|
|
async function listPolicies(companyId: string) {
|
|
return db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(and(eq(toolPolicies.companyId, companyId), ne(toolPolicies.policyType, "trust_rule")))
|
|
.orderBy(asc(toolPolicies.priority), desc(toolPolicies.updatedAt));
|
|
}
|
|
|
|
async function reorderPolicies(companyId: string, body: ReorderToolPolicies) {
|
|
const uniquePolicyIds = [...new Set(body.policyIds)];
|
|
if (uniquePolicyIds.length !== body.policyIds.length) {
|
|
throw badRequest("policyIds must not contain duplicates");
|
|
}
|
|
|
|
return db.transaction(async (tx) => {
|
|
const rows = await tx
|
|
.update(toolPolicies)
|
|
.set({ updatedAt: sql`${toolPolicies.updatedAt}` })
|
|
.where(and(
|
|
eq(toolPolicies.companyId, companyId),
|
|
ne(toolPolicies.policyType, "trust_rule"),
|
|
))
|
|
.returning();
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
const missingIds = uniquePolicyIds.filter((id) => !byId.has(id));
|
|
if (missingIds.length > 0) {
|
|
throw unprocessable("All reordered policies must belong to the company", { missingPolicyIds: missingIds });
|
|
}
|
|
if (uniquePolicyIds.length !== rows.length) {
|
|
throw unprocessable("Reorder must include every non-trust policy for the company", {
|
|
expectedPolicyCount: rows.length,
|
|
receivedPolicyCount: uniquePolicyIds.length,
|
|
});
|
|
}
|
|
|
|
const now = new Date();
|
|
for (const [index, policyId] of uniquePolicyIds.entries()) {
|
|
await tx
|
|
.update(toolPolicies)
|
|
.set({ priority: (index + 1) * 100, updatedAt: now })
|
|
.where(eq(toolPolicies.id, policyId));
|
|
}
|
|
return tx
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(and(eq(toolPolicies.companyId, companyId), ne(toolPolicies.policyType, "trust_rule")))
|
|
.orderBy(asc(toolPolicies.priority), desc(toolPolicies.updatedAt));
|
|
});
|
|
}
|
|
|
|
async function duplicatePolicy(input: {
|
|
companyId: string;
|
|
policyId: string;
|
|
body: DuplicateToolPolicy;
|
|
actor?: { agentId?: string | null; userId?: string | null };
|
|
}) {
|
|
const existing = await getGenericPolicyRow(db, input.companyId, input.policyId);
|
|
assertSupportedGenericPolicyShape(existing.policyType, existing.conditions ?? null, existing.config ?? null);
|
|
const rows = await db
|
|
.select({ name: toolPolicies.name })
|
|
.from(toolPolicies)
|
|
.where(eq(toolPolicies.companyId, input.companyId));
|
|
const names = new Set(rows.map((row) => row.name));
|
|
let name = input.body.name?.trim() || `${existing.name} copy`;
|
|
if (names.has(name)) {
|
|
const baseName = name;
|
|
let suffix = 2;
|
|
while (names.has(`${baseName} ${suffix}`)) suffix += 1;
|
|
name = `${baseName} ${suffix}`;
|
|
}
|
|
const now = new Date();
|
|
const [policy] = await db
|
|
.insert(toolPolicies)
|
|
.values({
|
|
companyId: existing.companyId,
|
|
name,
|
|
description: existing.description,
|
|
policyType: existing.policyType,
|
|
priority: existing.priority + 1,
|
|
enabled: false,
|
|
selectors: existing.selectors ?? {},
|
|
conditions: existing.conditions ?? null,
|
|
config: existing.config ?? null,
|
|
createdByAgentId: input.actor?.agentId ?? null,
|
|
createdByUserId: input.actor?.userId ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
return policy;
|
|
}
|
|
|
|
async function createPolicy(
|
|
companyId: string,
|
|
body: CreateToolPolicy,
|
|
actor?: { agentId?: string | null; userId?: string | null },
|
|
) {
|
|
assertSupportedGenericPolicyShape(body.policyType, body.conditions ?? null, body.config ?? null);
|
|
const now = new Date();
|
|
const [policy] = await db
|
|
.insert(toolPolicies)
|
|
.values({
|
|
companyId,
|
|
name: body.name,
|
|
description: body.description ?? null,
|
|
policyType: body.policyType,
|
|
priority: body.priority ?? 100,
|
|
enabled: body.enabled ?? true,
|
|
selectors: body.selectors ?? {},
|
|
conditions: body.conditions ?? null,
|
|
config: body.config ?? null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
return policy;
|
|
}
|
|
|
|
async function updatePolicy(input: {
|
|
companyId: string;
|
|
policyId: string;
|
|
body: UpdateToolPolicy;
|
|
}) {
|
|
const existing = await getGenericPolicyRow(db, input.companyId, input.policyId);
|
|
const nextPolicyType = input.body.policyType ?? existing.policyType;
|
|
const nextConditions = input.body.conditions !== undefined ? input.body.conditions ?? null : existing.conditions ?? null;
|
|
const nextConfig = input.body.config !== undefined ? input.body.config ?? null : existing.config ?? null;
|
|
assertSupportedGenericPolicyShape(nextPolicyType, nextConditions, nextConfig);
|
|
const now = new Date();
|
|
const [policy] = await db
|
|
.update(toolPolicies)
|
|
.set({
|
|
...(input.body.name !== undefined ? { name: input.body.name } : {}),
|
|
...(input.body.description !== undefined ? { description: input.body.description ?? null } : {}),
|
|
...(input.body.policyType !== undefined ? { policyType: input.body.policyType } : {}),
|
|
...(input.body.priority !== undefined ? { priority: input.body.priority } : {}),
|
|
...(input.body.enabled !== undefined ? { enabled: input.body.enabled } : {}),
|
|
...(input.body.selectors !== undefined ? { selectors: input.body.selectors ?? {} } : {}),
|
|
...(input.body.conditions !== undefined ? { conditions: input.body.conditions ?? null } : {}),
|
|
...(input.body.config !== undefined ? { config: input.body.config ?? null } : {}),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolPolicies.id, existing.id))
|
|
.returning();
|
|
return policy;
|
|
}
|
|
|
|
async function deletePolicy(input: { companyId: string; policyId: string }) {
|
|
const existing = await getGenericPolicyRow(db, input.companyId, input.policyId);
|
|
const [deleted] = await db
|
|
.delete(toolPolicies)
|
|
.where(eq(toolPolicies.id, existing.id))
|
|
.returning();
|
|
return deleted;
|
|
}
|
|
|
|
async function loadContext(input: ToolAccessDecisionInput): Promise<
|
|
| { ok: true; ctx: ToolAccessContext; redaction: RedactionResult }
|
|
| { ok: false; decision: ToolAccessDecision; redaction: RedactionResult }
|
|
> {
|
|
const redaction = summarizeAndRedact(input.request.arguments ?? {});
|
|
let agentId = input.actor.agentId ?? (input.actor.actorType === "agent" ? input.actor.actorId : null);
|
|
let heartbeatRunId = input.runContext?.heartbeatRunId ?? null;
|
|
let issueId = input.runContext?.issueId ?? null;
|
|
let projectId = input.runContext?.projectId ?? null;
|
|
let routineId = input.runContext?.routineId ?? null;
|
|
const gatewayId = input.runContext?.gatewayId ?? null;
|
|
|
|
if (input.actor.actorType === "agent") {
|
|
const [agent] = await db.select().from(agents).where(and(eq(agents.id, agentId ?? ""), eq(agents.companyId, input.companyId)));
|
|
if (!agent) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_missing_agent", "Authenticated agent was not found in the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
agentId = agent.id;
|
|
}
|
|
|
|
if (heartbeatRunId) {
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, heartbeatRunId));
|
|
if (!run || run.companyId !== input.companyId || (input.actor.actorType === "agent" && run.agentId !== agentId)) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Supplied run context does not match the authenticated actor.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
agentId = run.agentId;
|
|
const snapshot = isRecord(run.contextSnapshot) ? run.contextSnapshot : {};
|
|
const runIssueId = snapshotString(snapshot, "issueId");
|
|
const runProjectId = snapshotString(snapshot, "projectId");
|
|
const runRoutineId = snapshotString(snapshot, "routineId");
|
|
if ((issueId && runIssueId && issueId !== runIssueId)
|
|
|| (projectId && runProjectId && projectId !== runProjectId)
|
|
|| (routineId && runRoutineId && routineId !== runRoutineId)) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Supplied run context does not match the stored heartbeat context.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
issueId = runIssueId ?? issueId;
|
|
projectId = runProjectId ?? projectId;
|
|
routineId = runRoutineId ?? routineId;
|
|
}
|
|
|
|
if (issueId) {
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
if (!issue || issue.companyId !== input.companyId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_company_boundary", "Issue context is outside the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
if (projectId && projectId !== issue.projectId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Project context does not match the issue context.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
projectId = projectId ?? issue.projectId;
|
|
}
|
|
if (projectId) {
|
|
const [project] = await db.select().from(projects).where(eq(projects.id, projectId));
|
|
if (!project || project.companyId !== input.companyId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_company_boundary", "Project context is outside the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
}
|
|
if (routineId) {
|
|
const [routine] = await db.select().from(routines).where(eq(routines.id, routineId));
|
|
if (!routine || routine.companyId !== input.companyId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_company_boundary", "Routine context is outside the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
}
|
|
|
|
let applicationId = input.request.applicationId ?? null;
|
|
let connectionId = input.request.connectionId ?? null;
|
|
let catalogEntryId = input.request.catalogEntryId ?? null;
|
|
let catalogStatus: string | null = null;
|
|
let catalogVersionHash: string | null = null;
|
|
let catalogSchemaHash: string | null = null;
|
|
let providerType = typeof input.request.providerType === "string" ? input.request.providerType : null;
|
|
let applicationKey = typeof input.request.applicationKey === "string" ? input.request.applicationKey : null;
|
|
let upstreamToolName = typeof input.request.upstreamToolName === "string" ? input.request.upstreamToolName : null;
|
|
let riskLevel = asToolRiskLevel(input.request.riskLevel);
|
|
let connectionTransport: string | null = null;
|
|
let applicationType: string | null = null;
|
|
|
|
if (catalogEntryId) {
|
|
const [entry] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, catalogEntryId));
|
|
if (!entry || entry.companyId !== input.companyId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_missing_tool", "Requested tool is not in the company catalog.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
connectionId = entry.connectionId;
|
|
applicationId = entry.applicationId ?? applicationId;
|
|
riskLevel = entry.riskLevel;
|
|
upstreamToolName = upstreamToolName ?? entry.toolName;
|
|
catalogStatus = entry.status;
|
|
catalogVersionHash = entry.versionHash;
|
|
catalogSchemaHash = entry.schemaHash;
|
|
} else if (connectionId) {
|
|
const [entry] = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(and(eq(toolCatalogEntries.companyId, input.companyId), eq(toolCatalogEntries.connectionId, connectionId), eq(toolCatalogEntries.name, input.request.toolName)));
|
|
if (entry) {
|
|
catalogEntryId = entry.id;
|
|
applicationId = entry.applicationId ?? applicationId;
|
|
riskLevel = entry.riskLevel;
|
|
upstreamToolName = upstreamToolName ?? entry.toolName;
|
|
catalogStatus = entry.status;
|
|
catalogVersionHash = entry.versionHash;
|
|
catalogSchemaHash = entry.schemaHash;
|
|
}
|
|
}
|
|
|
|
if (connectionId) {
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
|
if (!connection || connection.companyId !== input.companyId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_company_boundary", "Connection is outside the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
if (!connection.enabled || connection.status === "disabled" || connection.status === "archived") {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_disabled_connection", "Connection is disabled.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
applicationId = connection.applicationId;
|
|
connectionTransport = connection.transport;
|
|
}
|
|
if (applicationId) {
|
|
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, applicationId));
|
|
if (!application || application.companyId !== input.companyId) {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_company_boundary", "Application is outside the company.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
if (application.status === "disabled") {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_disabled_application", "Application is disabled.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
if (application.status === "archived") {
|
|
return { ok: false, redaction, decision: decision("deny", "deny_archived_application", "Application is archived.", [], [], { redactionPlan: redaction.redactionPlan }) };
|
|
}
|
|
applicationKey = applicationKey ?? application.applicationKey;
|
|
applicationType = application.type;
|
|
}
|
|
providerType = providerType
|
|
?? (applicationType === "mcp_http" && connectionTransport === "mcp_remote"
|
|
? "mcp_remote_http"
|
|
: applicationType === "mcp_stdio" && connectionTransport === "local_stdio"
|
|
? "mcp_local_stdio"
|
|
: null);
|
|
|
|
return {
|
|
ok: true,
|
|
redaction,
|
|
ctx: {
|
|
companyId: input.companyId,
|
|
actorType: input.actor.actorType,
|
|
actorId: input.actor.actorId,
|
|
agentId,
|
|
heartbeatRunId,
|
|
issueId,
|
|
projectId,
|
|
routineId,
|
|
gatewayId,
|
|
applicationId,
|
|
connectionId,
|
|
catalogEntryId,
|
|
catalogStatus,
|
|
catalogVersionHash,
|
|
catalogSchemaHash,
|
|
providerType,
|
|
applicationKey,
|
|
upstreamToolName,
|
|
toolName: input.request.toolName,
|
|
riskLevel,
|
|
argumentsHash: redaction.summary.sha256 ?? sha256(input.request.arguments ?? {}),
|
|
arguments: input.request.arguments ?? {},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function effectiveProfiles(ctx: ToolAccessContext) {
|
|
const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, ctx.companyId));
|
|
const matchingBindings = bindings.filter((binding) => targetMatches(binding, ctx));
|
|
if (matchingBindings.length === 0) return { profiles: [], entries: [] as Array<typeof toolProfileEntries.$inferSelect> };
|
|
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
|
|
const candidateProfiles = await db.select().from(toolProfiles).where(and(
|
|
eq(toolProfiles.companyId, ctx.companyId),
|
|
inArray(toolProfiles.id, candidateProfileIds),
|
|
));
|
|
const activeBindings = effectiveToolProfileBindings(matchingBindings, candidateProfiles, ctx.connectionId);
|
|
const profileIds = profileIdsInBindingOrder(activeBindings);
|
|
const profilesById = new Map(candidateProfiles.map((profile) => [profile.id, profile]));
|
|
const activeProfiles = profileIds
|
|
.map((profileId) => profilesById.get(profileId) ?? null)
|
|
.filter((profile): profile is typeof toolProfiles.$inferSelect => Boolean(profile && profile.status === "active"));
|
|
const activeProfileIds = activeProfiles.map((profile) => profile.id);
|
|
const entries = activeProfileIds.length > 0
|
|
? await db.select().from(toolProfileEntries).where(and(eq(toolProfileEntries.companyId, ctx.companyId), inArray(toolProfileEntries.profileId, activeProfileIds)))
|
|
: [];
|
|
return { profiles: activeProfiles, entries };
|
|
}
|
|
|
|
async function explicitGrant(ctx: ToolAccessContext): Promise<boolean> {
|
|
const principalType = ctx.actorType === "agent" ? "agent" : ctx.actorType === "user" ? "user" : null;
|
|
const principalId = ctx.actorType === "agent" ? ctx.agentId : ctx.actorId;
|
|
if (!principalType || !principalId) return false;
|
|
const grants = await db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(and(
|
|
eq(principalPermissionGrants.companyId, ctx.companyId),
|
|
eq(principalPermissionGrants.principalType, principalType),
|
|
eq(principalPermissionGrants.principalId, principalId),
|
|
eq(principalPermissionGrants.permissionKey, "tools:use"),
|
|
));
|
|
return grants.some((grant) => scopeAllowsTool(grant.scope, ctx));
|
|
}
|
|
|
|
async function enforceRateLimit(policy: typeof toolPolicies.$inferSelect, ctx: ToolAccessContext, consume: boolean) {
|
|
const rule = rateLimitRule(policy);
|
|
if (!rule) return null;
|
|
const now = new Date();
|
|
const start = windowStart(now, rule.windowSeconds);
|
|
const kind = windowKind(rule.windowSeconds);
|
|
const resetAt = new Date(start.getTime() + rule.windowSeconds * 1000);
|
|
const bucketKey = `${policy.id}:${rateBucket(rule, ctx)}`;
|
|
const counterWhere = and(
|
|
eq(toolRateLimitCounters.companyId, ctx.companyId),
|
|
eq(toolRateLimitCounters.policyId, policy.id),
|
|
eq(toolRateLimitCounters.counterKey, bucketKey),
|
|
eq(toolRateLimitCounters.windowKind, kind),
|
|
eq(toolRateLimitCounters.windowStartAt, start),
|
|
);
|
|
if (!consume) {
|
|
const [existing] = await db.select().from(toolRateLimitCounters).where(counterWhere);
|
|
const count = existing ? Math.max(0, existing.limit - existing.remaining) : 0;
|
|
return { limited: count >= rule.limit, count, limit: rule.limit, windowSeconds: rule.windowSeconds, bucketKey };
|
|
}
|
|
|
|
const [counter] = await db
|
|
.insert(toolRateLimitCounters)
|
|
.values({
|
|
companyId: ctx.companyId,
|
|
policyId: policy.id,
|
|
counterKey: bucketKey,
|
|
scopeType: "policy",
|
|
scopeId: policy.id,
|
|
windowKind: kind,
|
|
windowStartAt: start,
|
|
limit: rule.limit,
|
|
remaining: Math.max(0, rule.limit - 1),
|
|
resetAt,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
toolRateLimitCounters.companyId,
|
|
toolRateLimitCounters.policyId,
|
|
toolRateLimitCounters.counterKey,
|
|
toolRateLimitCounters.windowKind,
|
|
toolRateLimitCounters.windowStartAt,
|
|
],
|
|
set: {
|
|
limit: rule.limit,
|
|
remaining: sql`greatest(0, least(${toolRateLimitCounters.remaining}, ${rule.limit}) - 1)`,
|
|
resetAt,
|
|
updatedAt: now,
|
|
},
|
|
setWhere: gt(toolRateLimitCounters.remaining, 0),
|
|
})
|
|
.returning({ remaining: toolRateLimitCounters.remaining });
|
|
if (!counter) {
|
|
return { limited: true, count: rule.limit, limit: rule.limit, windowSeconds: rule.windowSeconds, bucketKey };
|
|
}
|
|
return {
|
|
limited: false,
|
|
count: Math.max(0, rule.limit - counter.remaining),
|
|
limit: rule.limit,
|
|
windowSeconds: rule.windowSeconds,
|
|
bucketKey,
|
|
};
|
|
}
|
|
|
|
async function recordTrustRuleHit(policy: typeof toolPolicies.$inferSelect, ctx: ToolAccessContext, redaction: RedactionResult) {
|
|
const now = new Date();
|
|
const config = policyConfig(policy);
|
|
const rule = trustRuleConfig(policy) ?? {};
|
|
const nextRule = {
|
|
...rule,
|
|
hitCount: Math.max(0, Number(rule.hitCount ?? 0)) + 1,
|
|
lastHitAt: now.toISOString(),
|
|
};
|
|
await db
|
|
.update(toolPolicies)
|
|
.set({ config: { ...config, trustRule: nextRule }, updatedAt: now })
|
|
.where(eq(toolPolicies.id, policy.id));
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: ctx.companyId,
|
|
connectionId: ctx.connectionId,
|
|
catalogEntryId: ctx.catalogEntryId,
|
|
actorType: ctx.actorType,
|
|
actorId: ctx.actorId,
|
|
action: "tool_access.trust_rule_used",
|
|
outcome: "success",
|
|
reasonCode: "allow_trust_rule",
|
|
details: {
|
|
policyId: policy.id,
|
|
agentId: ctx.agentId,
|
|
issueId: ctx.issueId,
|
|
runId: ctx.heartbeatRunId,
|
|
toolName: ctx.toolName,
|
|
hitCount: nextRule.hitCount,
|
|
argumentsSummary: redaction.summary,
|
|
},
|
|
});
|
|
await db.insert(toolCallEvents).values({
|
|
companyId: ctx.companyId,
|
|
eventType: "trust_rule_used",
|
|
actorType: ctx.actorType,
|
|
actorId: ctx.actorId,
|
|
agentId: ctx.agentId,
|
|
runId: ctx.heartbeatRunId,
|
|
issueId: ctx.issueId,
|
|
applicationId: ctx.applicationId,
|
|
connectionId: ctx.connectionId,
|
|
catalogEntryId: ctx.catalogEntryId,
|
|
toolName: ctx.toolName,
|
|
decision: "allow",
|
|
matchedPolicyIds: [policy.id],
|
|
reasonCode: "allow_trust_rule",
|
|
outcome: "success",
|
|
argumentsSummary: redaction.summary,
|
|
requestHash: redaction.summary.sha256 ?? null,
|
|
requestSummary: redaction.summary,
|
|
redactionPlan: redaction.redactionPlan,
|
|
metadata: { hitCount: nextRule.hitCount },
|
|
});
|
|
}
|
|
|
|
async function decide(input: ToolAccessDecisionInput): Promise<ToolAccessDecision> {
|
|
const loaded = await loadContext(input);
|
|
if (!loaded.ok) return loaded.decision;
|
|
const { ctx, redaction } = loaded;
|
|
const profileState = await effectiveProfiles(ctx);
|
|
const effectiveProfileIds = profileState.profiles.map((profile) => profile.id);
|
|
const policies = await db.select().from(toolPolicies).where(and(eq(toolPolicies.companyId, ctx.companyId), eq(toolPolicies.enabled, true))).orderBy(asc(toolPolicies.priority), asc(toolPolicies.createdAt));
|
|
for (const policy of policies) {
|
|
const conditions = policyConditions(policy);
|
|
if (conditions && selectorMatches(policy.selectors, ctx)) {
|
|
const parsed = toolPolicyConditionsSchema.safeParse(conditions);
|
|
if (!parsed.success) {
|
|
return decision(
|
|
"deny",
|
|
"deny_policy_block",
|
|
"Tool access denied because a matching policy uses unsupported runtime conditions.",
|
|
effectiveProfileIds,
|
|
[policy.id],
|
|
{
|
|
redactionPlan: redaction.redactionPlan,
|
|
policyExplanation: {
|
|
policyId: policy.id,
|
|
policyType: policy.policyType,
|
|
selectorMatched: true,
|
|
conditionsError: parsed.error.issues.map((issue) => ({
|
|
path: issue.path.join("."),
|
|
message: issue.message,
|
|
})),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const matchingPolicies = policies
|
|
.map((policy) => ({ policy, conditionEvaluation: evaluatePolicyConditions(policyConditions(policy), ctx) }))
|
|
.filter(({ policy, conditionEvaluation }) => selectorMatches(policy.selectors, ctx) && conditionEvaluation.matched);
|
|
for (const { policy, conditionEvaluation } of matchingPolicies) {
|
|
const policyExplanation = {
|
|
policyId: policy.id,
|
|
policyType: policy.policyType,
|
|
selectorMatched: true,
|
|
conditionsMatched: conditionEvaluation.matchedGroups,
|
|
};
|
|
if (unsupportedRuntimePolicyType(policy.policyType)) {
|
|
return decision(
|
|
"deny",
|
|
"deny_policy_block",
|
|
"Tool access denied because a matching policy uses unsupported runtime semantics.",
|
|
effectiveProfileIds,
|
|
[policy.id],
|
|
{ redactionPlan: redaction.redactionPlan, policyExplanation },
|
|
);
|
|
}
|
|
if (policy.policyType === "block") {
|
|
return decision("deny", "deny_policy_block", policy.description ?? "Tool access is blocked by policy.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation });
|
|
}
|
|
if (policy.policyType === "rate_limit") {
|
|
if (!rateLimitRule(policy)) {
|
|
return decision(
|
|
"deny",
|
|
"deny_policy_block",
|
|
"Tool access denied because a matching rate-limit policy has invalid runtime config.",
|
|
effectiveProfileIds,
|
|
[policy.id],
|
|
{ redactionPlan: redaction.redactionPlan, policyExplanation },
|
|
);
|
|
}
|
|
const state = await enforceRateLimit(policy, ctx, input.consumeRateLimit === true);
|
|
if (state?.limited) {
|
|
return decision("rate_limited", "rate_limited", "Tool access rate limit exceeded.", effectiveProfileIds, [policy.id], { rateLimitState: state, redactionPlan: redaction.redactionPlan, policyExplanation });
|
|
}
|
|
continue;
|
|
}
|
|
if (policy.policyType === "trust_rule") {
|
|
const rule = trustRuleConfig(policy);
|
|
if (!rule || !trustRuleIsActive(policy)) continue;
|
|
if (!argumentFiltersMatch(rule.argumentFilters, ctx)) continue;
|
|
if (trustRuleNeedsReview(policy, ctx)) {
|
|
return decision(
|
|
"require_approval",
|
|
"requires_review_changed_tool",
|
|
"Tool definition changed or was quarantined after this trust rule was created; review is required.",
|
|
effectiveProfileIds,
|
|
[policy.id],
|
|
{ redactionPlan: redaction.redactionPlan, policyExplanation },
|
|
);
|
|
}
|
|
if (input.consumeRateLimit === true) {
|
|
await recordTrustRuleHit(policy, ctx, redaction);
|
|
}
|
|
return decision("allow", "allow_trust_rule", policy.description ?? "Tool access allowed by trust rule.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation });
|
|
}
|
|
if (policy.policyType === "require_approval") {
|
|
return decision("require_approval", "requires_approval_policy", policy.description ?? "Tool access requires approval.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation });
|
|
}
|
|
if (policy.policyType === "allow") {
|
|
return decision("allow", "allow_policy", "Tool access allowed by policy.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation });
|
|
}
|
|
}
|
|
if (await explicitGrant(ctx)) {
|
|
return decision("allow", "allow_explicit_grant", "Tool access allowed by explicit grant.", effectiveProfileIds, [], { redactionPlan: redaction.redactionPlan });
|
|
}
|
|
|
|
const entriesByProfile = new Map<string, Array<typeof toolProfileEntries.$inferSelect>>();
|
|
for (const entry of profileState.entries) {
|
|
const list = entriesByProfile.get(entry.profileId) ?? [];
|
|
list.push(entry);
|
|
entriesByProfile.set(entry.profileId, list);
|
|
}
|
|
for (const profile of profileState.profiles) {
|
|
const entries = entriesByProfile.get(profile.id) ?? [];
|
|
const matchingEntries = entries.filter((entry) => profileEntryMatches(entry, ctx));
|
|
if (matchingEntries.some((entry) => entry.effect === "exclude")) continue;
|
|
if (profile.defaultAction === "allow" || matchingEntries.some((entry) => entry.effect === "include")) {
|
|
return decision("allow", "allow_profile", "Tool access allowed by effective profile.", effectiveProfileIds, [], { redactionPlan: redaction.redactionPlan });
|
|
}
|
|
}
|
|
|
|
return decision("deny", "deny_default", "No effective tool profile, grant, or allow policy permits this call.", effectiveProfileIds, [], { redactionPlan: redaction.redactionPlan });
|
|
}
|
|
|
|
async function writeAudit(
|
|
input: ToolAccessDecisionInput,
|
|
accessDecision: ToolAccessDecision,
|
|
eventType: ToolAuditEventType = "policy_decision",
|
|
) {
|
|
const loaded = await loadContext(input);
|
|
const redaction = loaded.redaction;
|
|
const ctx = loaded.ok ? loaded.ctx : {
|
|
companyId: input.companyId,
|
|
actorType: input.actor.actorType,
|
|
actorId: input.actor.actorId,
|
|
agentId: input.actor.agentId ?? null,
|
|
issueId: input.runContext?.issueId ?? null,
|
|
gatewayId: input.runContext?.gatewayId ?? null,
|
|
runId: input.runContext?.heartbeatRunId ?? null,
|
|
connectionId: input.request.connectionId ?? null,
|
|
catalogEntryId: input.request.catalogEntryId ?? null,
|
|
applicationId: input.request.applicationId ?? null,
|
|
providerType: input.request.providerType ?? null,
|
|
applicationKey: input.request.applicationKey ?? null,
|
|
upstreamToolName: input.request.upstreamToolName ?? null,
|
|
toolName: input.request.toolName,
|
|
riskLevel: input.request.riskLevel ?? null,
|
|
};
|
|
const runId = "runId" in ctx ? ctx.runId : ctx.heartbeatRunId;
|
|
try {
|
|
const [legacyAuditEvent] = await db.insert(toolAccessAuditEvents).values({
|
|
companyId: input.companyId,
|
|
connectionId: ctx.connectionId,
|
|
catalogEntryId: ctx.catalogEntryId,
|
|
actorType: ctx.actorType,
|
|
actorId: ctx.actorId,
|
|
action: `tool_access.${eventType}`,
|
|
outcome: accessDecision.allowed ? "success" : "denied",
|
|
reasonCode: accessDecision.reasonCode,
|
|
details: {
|
|
decision: accessDecision.decision,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
effectiveProfileIds: accessDecision.effectiveProfileIds,
|
|
applicationId: ctx.applicationId,
|
|
applicationKey: ctx.applicationKey,
|
|
providerType: ctx.providerType,
|
|
upstreamToolName: ctx.upstreamToolName,
|
|
agentId: ctx.agentId,
|
|
issueId: ctx.issueId,
|
|
runId,
|
|
toolName: ctx.toolName,
|
|
riskLevel: ctx.riskLevel,
|
|
argumentsSummary: redaction.summary,
|
|
redactionPlan: redaction.redactionPlan,
|
|
policyExplanation: accessDecision.policyExplanation ?? null,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
},
|
|
}).returning();
|
|
const [toolCallEvent] = await db.insert(toolCallEvents).values({
|
|
companyId: input.companyId,
|
|
eventType: eventType as typeof toolCallEvents.$inferInsert["eventType"],
|
|
actorType: ctx.actorType,
|
|
actorId: ctx.actorId,
|
|
agentId: ctx.agentId,
|
|
runId,
|
|
issueId: ctx.issueId,
|
|
applicationId: ctx.applicationId,
|
|
connectionId: ctx.connectionId,
|
|
catalogEntryId: ctx.catalogEntryId,
|
|
toolName: ctx.toolName,
|
|
decision: accessDecision.decision,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
reasonCode: accessDecision.reasonCode,
|
|
outcome: auditOutcome(accessDecision),
|
|
argumentsSummary: redaction.summary,
|
|
requestHash: redaction.summary.sha256 ?? null,
|
|
requestSummary: redaction.summary,
|
|
redactionPlan: redaction.redactionPlan,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
metadata: {
|
|
legacyAuditEventId: legacyAuditEvent.id,
|
|
effectiveProfileIds: accessDecision.effectiveProfileIds,
|
|
explanation: accessDecision.explanation,
|
|
policyExplanation: accessDecision.policyExplanation ?? null,
|
|
providerType: ctx.providerType,
|
|
applicationKey: ctx.applicationKey,
|
|
upstreamToolName: ctx.upstreamToolName,
|
|
riskLevel: ctx.riskLevel,
|
|
},
|
|
}).returning();
|
|
return { legacyAuditEvent, toolCallEvent };
|
|
} catch (error) {
|
|
await recordToolRuntimeAuditWriteFailure(db, input.companyId);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function recordInvocation(input: ToolAccessDecisionInput, accessDecision: ToolAccessDecision) {
|
|
const loaded = await loadContext(input);
|
|
if (!loaded.ok) throw new Error("Cannot record invocation for invalid tool access context");
|
|
const { ctx, redaction } = loaded;
|
|
const argumentsHash = redaction.summary.sha256 ?? sha256(input.request.arguments ?? {});
|
|
const idempotencyKey = input.request.idempotencyKey
|
|
?? (input.request.sideEffecting ? sideEffectIdempotencyKey(ctx, argumentsHash) : null);
|
|
if (idempotencyKey) {
|
|
const [existing] = await db.select().from(toolInvocations).where(and(
|
|
eq(toolInvocations.companyId, input.companyId),
|
|
eq(toolInvocations.idempotencyKey, idempotencyKey),
|
|
));
|
|
if (existing) return { invocation: existing, replayed: true, actionRequest: null };
|
|
}
|
|
const status = accessDecision.decision === "allow"
|
|
? "authorized"
|
|
: accessDecision.decision === "require_approval"
|
|
? "awaiting_approval"
|
|
: accessDecision.decision === "rate_limited"
|
|
? "rate_limited"
|
|
: "denied";
|
|
const [invocation] = await db.insert(toolInvocations).values({
|
|
companyId: ctx.companyId,
|
|
idempotencyKey,
|
|
actorType: ctx.actorType,
|
|
actorId: ctx.actorId,
|
|
agentId: ctx.agentId,
|
|
issueId: ctx.issueId,
|
|
runId: ctx.heartbeatRunId,
|
|
applicationId: ctx.applicationId,
|
|
connectionId: ctx.connectionId,
|
|
catalogEntryId: ctx.catalogEntryId,
|
|
catalogVersionHash: ctx.catalogVersionHash,
|
|
catalogSchemaHash: ctx.catalogSchemaHash,
|
|
providerType: ctx.providerType,
|
|
applicationKey: ctx.applicationKey,
|
|
upstreamToolName: ctx.upstreamToolName,
|
|
riskLevel: ctx.riskLevel,
|
|
toolName: ctx.toolName,
|
|
argumentsHash,
|
|
argumentsSummary: redaction.summary,
|
|
policyDecision: accessDecision.decision,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
approvalState: accessDecision.decision === "require_approval" ? "pending" : "not_required",
|
|
status,
|
|
errorCode: accessDecision.allowed || accessDecision.decision === "require_approval" ? null : accessDecision.reasonCode,
|
|
errorMessage: accessDecision.allowed || accessDecision.decision === "require_approval" ? null : accessDecision.explanation,
|
|
completedAt: accessDecision.allowed || accessDecision.decision === "require_approval" ? null : new Date(),
|
|
}).returning();
|
|
let actionRequest = null;
|
|
if (accessDecision.decision === "require_approval") {
|
|
[actionRequest] = await db.insert(toolActionRequests).values({
|
|
companyId: ctx.companyId,
|
|
invocationId: invocation.id,
|
|
issueId: ctx.issueId,
|
|
status: "pending",
|
|
canonicalArgumentsHash: invocation.argumentsHash ?? argumentsHash,
|
|
canonicalArgumentsSummary: redaction.summary,
|
|
requestedByAgentId: ctx.actorType === "agent" ? ctx.agentId : null,
|
|
requestedByUserId: ctx.actorType === "user" ? ctx.actorId : null,
|
|
}).returning();
|
|
}
|
|
return { invocation, replayed: false, actionRequest };
|
|
}
|
|
|
|
async function matchingApprovedActionRequestCount(input: {
|
|
companyId: string;
|
|
invocation: typeof toolInvocations.$inferSelect;
|
|
filters: ToolTrustRuleArgumentFilters;
|
|
selectors: Record<string, unknown>;
|
|
}) {
|
|
const rows = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(and(eq(toolActionRequests.companyId, input.companyId), inArray(toolActionRequests.status, ["approved", "executed"])));
|
|
if (rows.length === 0) return 0;
|
|
const invocationIds = rows.map((row) => row.invocationId);
|
|
const invocations = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(and(eq(toolInvocations.companyId, input.companyId), inArray(toolInvocations.id, invocationIds)));
|
|
const byId = new Map(invocations.map((row) => [row.id, row]));
|
|
const issueIds = [...new Set(invocations.map((row) => row.issueId).filter((id): id is string => Boolean(id)))];
|
|
const issueRows = issueIds.length > 0
|
|
? await db
|
|
.select({ id: issues.id, projectId: issues.projectId })
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, input.companyId), inArray(issues.id, issueIds)))
|
|
: [];
|
|
const issueProjectById = new Map(issueRows.map((row) => [row.id, row.projectId]));
|
|
const allowedHashes = new Set([
|
|
...(input.filters.allowedHashes ?? []),
|
|
...(input.filters.exactHash ? [input.filters.exactHash] : []),
|
|
...(input.invocation.argumentsHash ? [input.invocation.argumentsHash] : []),
|
|
]);
|
|
return rows.filter((row) => {
|
|
const invocation = byId.get(row.invocationId);
|
|
if (!invocation) return false;
|
|
if (invocation.toolName !== input.invocation.toolName) return false;
|
|
if (invocation.applicationId !== input.invocation.applicationId) return false;
|
|
if (invocation.connectionId !== input.invocation.connectionId) return false;
|
|
if (invocation.catalogEntryId !== input.invocation.catalogEntryId) return false;
|
|
if (invocation.catalogVersionHash !== input.invocation.catalogVersionHash) return false;
|
|
if (invocation.catalogSchemaHash !== input.invocation.catalogSchemaHash) return false;
|
|
if (!selectorMatches(input.selectors, {
|
|
companyId: input.companyId,
|
|
actorType: invocation.actorType as ToolAccessContext["actorType"],
|
|
actorId: invocation.actorId ?? invocation.agentId ?? "system",
|
|
agentId: invocation.agentId,
|
|
heartbeatRunId: invocation.runId,
|
|
issueId: invocation.issueId,
|
|
projectId: invocation.issueId ? issueProjectById.get(invocation.issueId) ?? null : null,
|
|
routineId: null,
|
|
gatewayId: null,
|
|
applicationId: invocation.applicationId,
|
|
connectionId: invocation.connectionId,
|
|
catalogEntryId: invocation.catalogEntryId,
|
|
catalogStatus: null,
|
|
catalogVersionHash: invocation.catalogVersionHash,
|
|
catalogSchemaHash: invocation.catalogSchemaHash,
|
|
providerType: invocation.providerType,
|
|
applicationKey: invocation.applicationKey,
|
|
upstreamToolName: invocation.upstreamToolName,
|
|
toolName: invocation.toolName,
|
|
riskLevel: invocation.riskLevel,
|
|
argumentsHash: invocation.argumentsHash ?? "",
|
|
arguments: {},
|
|
})) return false;
|
|
if (input.filters.allowAny === true) return true;
|
|
return Boolean(invocation.argumentsHash && allowedHashes.has(invocation.argumentsHash));
|
|
}).length;
|
|
}
|
|
|
|
function trustRuleSelectors(input: {
|
|
invocation: typeof toolInvocations.$inferSelect;
|
|
issueProjectId: string | null;
|
|
selectors?: ToolAccessSelector;
|
|
scope?: CreateToolTrustRuleFromActionRequest["scope"];
|
|
}): Record<string, unknown> {
|
|
const selectors: Record<string, unknown> = { ...(input.selectors ?? {}) };
|
|
const scope = input.scope ?? {};
|
|
const apply = (key: string, value: string | null | undefined, enabled: boolean) => {
|
|
if (!enabled || !value || selectors[key] || selectors[`${key}s`]) return;
|
|
selectors[key] = value;
|
|
};
|
|
apply("agentId", input.invocation.agentId, scope.includeAgent ?? true);
|
|
apply("projectId", input.issueProjectId, scope.includeProject ?? true);
|
|
apply("issueId", input.invocation.issueId, scope.includeIssue === true);
|
|
apply("applicationId", input.invocation.applicationId, scope.includeApplication ?? true);
|
|
apply("connectionId", input.invocation.connectionId, scope.includeConnection ?? true);
|
|
apply("catalogEntryId", input.invocation.catalogEntryId, scope.includeCatalogEntry === true);
|
|
apply("toolName", input.invocation.toolName, scope.includeTool ?? true);
|
|
return selectors;
|
|
}
|
|
|
|
const REQUIRED_REVIEWED_TRUST_RULE_SELECTOR_KEYS = [
|
|
"agentId",
|
|
"projectId",
|
|
"applicationId",
|
|
"connectionId",
|
|
"toolName",
|
|
] as const;
|
|
|
|
const OPTIONAL_REVIEWED_TRUST_RULE_SELECTOR_KEYS = ["issueId", "catalogEntryId"] as const;
|
|
|
|
function reviewedTrustRuleSelectorValues(input: {
|
|
invocation: typeof toolInvocations.$inferSelect;
|
|
issueProjectId: string | null;
|
|
}): Record<string, string> {
|
|
const reviewed: Record<string, string> = {};
|
|
if (input.invocation.agentId) reviewed.agentId = input.invocation.agentId;
|
|
if (input.issueProjectId) reviewed.projectId = input.issueProjectId;
|
|
if (input.invocation.applicationId) reviewed.applicationId = input.invocation.applicationId;
|
|
if (input.invocation.connectionId) reviewed.connectionId = input.invocation.connectionId;
|
|
if (input.invocation.toolName) reviewed.toolName = input.invocation.toolName;
|
|
if (input.invocation.issueId) reviewed.issueId = input.invocation.issueId;
|
|
if (input.invocation.catalogEntryId) reviewed.catalogEntryId = input.invocation.catalogEntryId;
|
|
return reviewed;
|
|
}
|
|
|
|
function exactReviewedTrustRuleFilters(invocation: typeof toolInvocations.$inferSelect): ToolTrustRuleArgumentFilters {
|
|
if (!invocation.argumentsHash) {
|
|
throw unprocessable("Trust rule promotion requires an exact reviewed argument hash on the source action request");
|
|
}
|
|
return { exactHash: invocation.argumentsHash };
|
|
}
|
|
|
|
function assertReviewedTrustRuleSelectors(selectors: Record<string, unknown>, reviewed: Record<string, string>) {
|
|
const allowedKeys = new Set<string>([
|
|
...REQUIRED_REVIEWED_TRUST_RULE_SELECTOR_KEYS,
|
|
...OPTIONAL_REVIEWED_TRUST_RULE_SELECTOR_KEYS,
|
|
]);
|
|
for (const [key, value] of Object.entries(selectors)) {
|
|
if (!allowedKeys.has(key)) {
|
|
throw unprocessable(
|
|
`Trust rule promotion only supports the reviewed actor/tool scope. Unsupported selector '${key}'.`,
|
|
);
|
|
}
|
|
const expected = reviewed[key];
|
|
if (!expected || value !== expected) {
|
|
throw unprocessable(
|
|
`Trust rule promotion selector '${key}' must exactly match the reviewed scope.`,
|
|
);
|
|
}
|
|
}
|
|
for (const key of REQUIRED_REVIEWED_TRUST_RULE_SELECTOR_KEYS) {
|
|
const expected = reviewed[key];
|
|
if (!expected) continue;
|
|
if (selectors[key] !== expected) {
|
|
throw unprocessable(
|
|
`Trust rule promotion must keep the reviewed actor/tool scope. Missing exact selector '${key}'.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertReviewedTrustRuleArgumentFilters(
|
|
invocation: typeof toolInvocations.$inferSelect,
|
|
filters: ToolTrustRuleArgumentFilters | null | undefined,
|
|
): ToolTrustRuleArgumentFilters {
|
|
const exact = exactReviewedTrustRuleFilters(invocation);
|
|
if (!filters) return exact;
|
|
if (
|
|
filters.allowAny === true
|
|
|| filters.allowedHashes?.length
|
|
|| filters.fieldEquals
|
|
|| filters.fieldNotEquals
|
|
|| filters.fieldIn
|
|
|| filters.fieldMatches
|
|
|| filters.fieldExists?.length
|
|
|| filters.fieldAbsent?.length
|
|
) {
|
|
throw unprocessable("Trust rule promotion only supports the exact reviewed argument hash.");
|
|
}
|
|
if (filters.exactHash !== exact.exactHash) {
|
|
throw unprocessable("Trust rule promotion exactHash must match the reviewed argument hash.");
|
|
}
|
|
return exact;
|
|
}
|
|
|
|
async function createTrustRuleFromActionRequest(input: {
|
|
companyId: string;
|
|
actionRequestId: string;
|
|
body: CreateToolTrustRuleFromActionRequest;
|
|
actor?: { agentId?: string | null; userId?: string | null };
|
|
}) {
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(and(eq(toolActionRequests.id, input.actionRequestId), eq(toolActionRequests.companyId, input.companyId)))
|
|
.limit(1);
|
|
if (!actionRequest) throw notFound("Tool action request not found");
|
|
if (actionRequest.status !== "approved" && actionRequest.status !== "executed") {
|
|
throw unprocessable("Trust rules can only be created from approved or executed action requests");
|
|
}
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(and(eq(toolInvocations.id, actionRequest.invocationId), eq(toolInvocations.companyId, input.companyId)))
|
|
.limit(1);
|
|
if (!invocation) throw notFound("Tool invocation not found");
|
|
if (invocation.policyDecision !== "require_approval") {
|
|
throw unprocessable("Trust rules must be promoted from approval-required tool actions");
|
|
}
|
|
if (invocation.catalogEntryId && !invocation.catalogVersionHash) {
|
|
throw unprocessable("Trust rule promotion requires a reviewed tool catalog version on the source action request");
|
|
}
|
|
|
|
const [issue] = invocation.issueId
|
|
? await db
|
|
.select({ projectId: issues.projectId })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, invocation.issueId), eq(issues.companyId, input.companyId)))
|
|
.limit(1)
|
|
: [null];
|
|
const reviewedSelectors = reviewedTrustRuleSelectorValues({
|
|
invocation,
|
|
issueProjectId: issue?.projectId ?? null,
|
|
});
|
|
const selectors = trustRuleSelectors({
|
|
invocation,
|
|
issueProjectId: issue?.projectId ?? null,
|
|
selectors: input.body.selectors,
|
|
scope: input.body.scope,
|
|
});
|
|
assertReviewedTrustRuleSelectors(selectors, reviewedSelectors);
|
|
const filters = assertReviewedTrustRuleArgumentFilters(invocation, input.body.argumentFilters);
|
|
const approvalThreshold = input.body.approvalThreshold ?? 2;
|
|
const approvedCount = await matchingApprovedActionRequestCount({
|
|
companyId: input.companyId,
|
|
invocation,
|
|
filters,
|
|
selectors,
|
|
});
|
|
if (approvedCount < approvalThreshold) {
|
|
throw unprocessable(`Trust rule requires ${approvalThreshold} matching approved actions in the final rule scope; found ${approvedCount}`);
|
|
}
|
|
const now = new Date();
|
|
const expiresAt = isoDateOrNull(input.body.expiresAt);
|
|
const name = input.body.name ?? `Trust ${invocation.toolName} ${actionRequest.id.slice(0, 8)}`;
|
|
const description = input.body.description
|
|
?? `Progressive-autonomy trust rule promoted from action request ${actionRequest.id}.`;
|
|
const [policy] = await db.insert(toolPolicies).values({
|
|
companyId: input.companyId,
|
|
name,
|
|
description,
|
|
policyType: "trust_rule",
|
|
priority: input.body.priority ?? 40,
|
|
enabled: true,
|
|
selectors,
|
|
config: {
|
|
trustRule: {
|
|
sourceActionRequestId: actionRequest.id,
|
|
sourceInvocationId: invocation.id,
|
|
sourceApprovalCount: approvedCount,
|
|
approvalThreshold,
|
|
argumentFilters: filters,
|
|
expiresAt,
|
|
revokedAt: null,
|
|
hitCount: 0,
|
|
lastHitAt: null,
|
|
catalogVersionHash: invocation.catalogVersionHash ?? null,
|
|
schemaHash: invocation.catalogSchemaHash ?? null,
|
|
batchApproval: input.body.batchApproval ?? null,
|
|
},
|
|
},
|
|
createdByAgentId: input.actor?.agentId ?? null,
|
|
createdByUserId: input.actor?.userId ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).returning();
|
|
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: input.companyId,
|
|
connectionId: invocation.connectionId,
|
|
catalogEntryId: invocation.catalogEntryId,
|
|
actorType: input.actor?.agentId ? "agent" : input.actor?.userId ? "user" : "system",
|
|
actorId: input.actor?.agentId ?? input.actor?.userId ?? null,
|
|
action: "tool_access.trust_rule_created",
|
|
outcome: "success",
|
|
reasonCode: "trust_rule_promoted_from_approval",
|
|
details: {
|
|
policyId: policy.id,
|
|
actionRequestId: actionRequest.id,
|
|
invocationId: invocation.id,
|
|
approvalThreshold,
|
|
approvedCount,
|
|
selectors,
|
|
argumentFilters: filters,
|
|
expiresAt,
|
|
},
|
|
});
|
|
await db.insert(toolCallEvents).values({
|
|
companyId: input.companyId,
|
|
eventType: "trust_rule_created",
|
|
actorType: input.actor?.agentId ? "agent" : input.actor?.userId ? "user" : "system",
|
|
actorId: input.actor?.agentId ?? input.actor?.userId ?? null,
|
|
agentId: invocation.agentId,
|
|
runId: invocation.runId,
|
|
issueId: invocation.issueId,
|
|
applicationId: invocation.applicationId,
|
|
connectionId: invocation.connectionId,
|
|
catalogEntryId: invocation.catalogEntryId,
|
|
invocationId: invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
toolName: invocation.toolName,
|
|
decision: "allow",
|
|
matchedPolicyIds: [policy.id],
|
|
reasonCode: "trust_rule_promoted_from_approval",
|
|
outcome: "success",
|
|
argumentsSummary: invocation.argumentsSummary,
|
|
requestHash: invocation.argumentsHash,
|
|
requestSummary: invocation.argumentsSummary,
|
|
metadata: { approvalThreshold, approvedCount, selectors, expiresAt },
|
|
});
|
|
return policy;
|
|
}
|
|
|
|
async function listTrustRules(companyId: string) {
|
|
return db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(and(eq(toolPolicies.companyId, companyId), eq(toolPolicies.policyType, "trust_rule")))
|
|
.orderBy(desc(toolPolicies.updatedAt));
|
|
}
|
|
|
|
async function revokeTrustRule(input: {
|
|
companyId: string;
|
|
policyId: string;
|
|
body: RevokeToolTrustRule;
|
|
actor?: { agentId?: string | null; userId?: string | null };
|
|
}) {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(and(eq(toolPolicies.id, input.policyId), eq(toolPolicies.companyId, input.companyId)))
|
|
.limit(1);
|
|
if (!existing || existing.policyType !== "trust_rule") throw notFound("Tool trust rule not found");
|
|
const now = new Date();
|
|
const config = policyConfig(existing);
|
|
const rule = trustRuleConfig(existing) ?? {};
|
|
const [updated] = await db
|
|
.update(toolPolicies)
|
|
.set({
|
|
enabled: false,
|
|
config: {
|
|
...config,
|
|
trustRule: {
|
|
...rule,
|
|
revokedAt: rule.revokedAt ?? now.toISOString(),
|
|
revokedByAgentId: input.actor?.agentId ?? null,
|
|
revokedByUserId: input.actor?.userId ?? null,
|
|
revocationReason: input.body.reason ?? null,
|
|
},
|
|
},
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolPolicies.id, existing.id))
|
|
.returning();
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: input.companyId,
|
|
actorType: input.actor?.agentId ? "agent" : input.actor?.userId ? "user" : "system",
|
|
actorId: input.actor?.agentId ?? input.actor?.userId ?? null,
|
|
action: "tool_access.trust_rule_revoked",
|
|
outcome: "success",
|
|
reasonCode: "trust_rule_revoked",
|
|
details: { policyId: existing.id, reason: input.body.reason ?? null },
|
|
});
|
|
await db.insert(toolCallEvents).values({
|
|
companyId: input.companyId,
|
|
eventType: "trust_rule_revoked",
|
|
actorType: input.actor?.agentId ? "agent" : input.actor?.userId ? "user" : "system",
|
|
actorId: input.actor?.agentId ?? input.actor?.userId ?? null,
|
|
decision: "deny",
|
|
matchedPolicyIds: [existing.id],
|
|
reasonCode: "trust_rule_revoked",
|
|
outcome: "success",
|
|
metadata: { reason: input.body.reason ?? null },
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
return {
|
|
decide,
|
|
writeAudit,
|
|
recordInvocation,
|
|
summarizeAndRedact,
|
|
listPolicies,
|
|
reorderPolicies,
|
|
createPolicy,
|
|
duplicatePolicy,
|
|
updatePolicy,
|
|
deletePolicy,
|
|
createTrustRuleFromActionRequest,
|
|
listTrustRules,
|
|
revokeTrustRule,
|
|
ensureNoDuplicatePolicyNameError: (error: unknown) => {
|
|
if (error instanceof Error && /duplicate key value/.test(error.message)) {
|
|
throw conflict("A tool policy with that name already exists");
|
|
}
|
|
throw error;
|
|
},
|
|
};
|
|
}
|