Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/corsair/core/endpoints/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function bindEndpointsRecursively({
permissionsConfig?: {
mode: PermissionMode;
overrides?: Record<string, PermissionPolicy>;
limits?: import('../plugins').UsageLimit[];
};
/** Risk level metadata per dot-notation endpoint path. Defaults riskLevel to 'write' when missing. */
endpointMeta?: Record<string, EndpointMetaEntry>;
Expand Down Expand Up @@ -122,7 +123,7 @@ export function bindEndpointsRecursively({

// ── Permission guard ────────────────────────────────────────────────────────────────
let onPermissionComplete: (() => Promise<void>) | undefined;
if (permissionsConfig) {
if (permissionsConfig || permissionsOptions?.limits?.length) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const meta = endpointMetaEntry;
const {
result: permResult,
Expand All @@ -135,8 +136,8 @@ export function bindEndpointsRecursively({
pluginId,
endpointPath: operationPath,
args,
mode: permissionsConfig.mode,
override: permissionsConfig.overrides?.[operationPath],
mode: permissionsConfig?.mode ?? 'open',
override: permissionsConfig?.overrides?.[operationPath],
// Default to 'write' when no meta declared — conservative fallback
riskLevel: meta?.riskLevel ?? 'write',
meta,
Expand All @@ -146,13 +147,19 @@ export function bindEndpointsRecursively({
: undefined,
tenantId,
approvalMode: permissionsOptions?.mode,
globalLimits: permissionsOptions?.limits,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
pluginLimits: permissionsConfig?.limits,
});
if (permResult === 'blocked') {
let msg: string;
if (permReason === 'denied') {
msg = `Action '${operationPath}' was denied by the user. Await further instructions before proceeding.`;
} else if (permReason === 'policy') {
msg = `Action '${operationPath}' is blocked by the permission policy. Update the corsair config to allow it.`;
} else if (permReason === 'rate_limit_exceeded') {
msg = `Action '${operationPath}' is currently rate limited. Please wait before trying again.`;
} else if (permReason === 'budget_exhausted') {
msg = `Action '${operationPath}' has exhausted its configured budget quota.`;
} else if (permReason === 'timeout') {
msg = `Action '${operationPath}' timed out waiting for approval.`;
} else if (permToken && permId) {
Expand Down
70 changes: 67 additions & 3 deletions packages/corsair/core/permissions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
EndpointRiskLevel,
PermissionMode,
PermissionPolicy,
UsageLimit,
} from '../plugins';

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -236,12 +237,20 @@ export type EnforcePermissionOptions = {
| 'synchronous'
| 'asynchronous'
| (() => 'synchronous' | 'asynchronous');
globalLimits?: (UsageLimit & { scope?: 'global' | 'tenant' })[];
pluginLimits?: UsageLimit[];
};

export type EnforcePermissionResult = {
result: 'allow' | 'blocked';
/** Why the call was blocked. Only present when result === 'blocked'. */
reason?: 'denied' | 'policy' | 'timeout' | 'pending';
reason?:
| 'denied'
| 'policy'
| 'timeout'
| 'pending'
| 'rate_limit_exceeded'
| 'budget_exhausted';
/** Permission record ID. Present when a pending approval record exists. */
id?: string;
/** Permission token (the value embedded in review URLs). Present when a pending approval record exists. */
Expand Down Expand Up @@ -322,7 +331,6 @@ export async function enforcePermission(
opts: EnforcePermissionOptions,
): Promise<EnforcePermissionResult> {
const policy = evaluatePermission(opts.riskLevel, opts.mode, opts.override);
if (policy === 'allow') return { result: 'allow' };

const irreversibleNote = opts.meta?.irreversible ? ' (irreversible)' : '';
const description = opts.meta?.description
Expand All @@ -338,9 +346,10 @@ export async function enforcePermission(
return { result: 'blocked', reason: 'policy' };
}

const tenantId = opts.tenantId ?? 'default';

const argsJson = JSON.stringify(opts.args);
const now = new Date().toISOString();
const tenantId = opts.tenantId ?? 'default';

// Check for an existing, non-expired permission record for this plugin + endpoint + args + tenant
const existing = await opts.db.db
Expand Down Expand Up @@ -406,6 +415,61 @@ export async function enforcePermission(
};
}

// Evaluate limits
const applicableLimits = [
...(opts.globalLimits || []).map((l) => ({
...l,
// global configs default to 'global' scope
resolvedScope: l.scope === 'tenant' ? `tenant:${tenantId}` : `global`,
})),
...(opts.pluginLimits || []).map((l) => ({
...l,
// plugin configs natively apply to the plugin
resolvedScope: `plugin:${opts.pluginId}`,
})),
].filter((l) => !l.riskLevel || l.riskLevel === opts.riskLevel);

if (applicableLimits.length > 0) {
const { sql } = await import('kysely');
const nowTs = Date.now();
for (const limit of applicableLimits) {
const windowMs = parseDurationMs(limit.window);
const epoch = Math.floor(nowTs / windowMs);
// Hash properties to create a stable limit bucket
const limitFingerprint = `${limit.type}_${limit.max}_${limit.window}`;
const key = `usage:${limit.resolvedScope}:${limitFingerprint}:${epoch}`;
const expiresAt = new Date(nowTs + windowMs).toISOString();

const res = await opts.db.db
.insertInto('corsair_usage_counters')
.values({ key, count: 1, expires_at: expiresAt })
.onConflict((oc) =>
oc
.column('key')
.doUpdateSet({ count: sql`corsair_usage_counters.count + 1` }),
)
.returning('count')
.executeTakeFirst();

if (res && res.count > limit.max) {
console.log(
`[corsair/${opts.pluginId}] '${opts.endpointPath}' blocked — ${limit.type} exceeded.`,
`\n Action: ${description}`,
`\n Limit: ${limit.max} per ${limit.window}`,
);
return {
result: 'blocked',
reason:
limit.type === 'budget'
? 'budget_exhausted'
: 'rate_limit_exceeded',
};
}
}
}

if (policy === 'allow') return { result: 'allow' };

// No existing actionable record — create a new pending approval request
const id = uuidv4();
const token = randomBytes(32).toString('hex');
Expand Down
22 changes: 22 additions & 0 deletions packages/corsair/core/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ import type {
*/
export type EndpointRiskLevel = 'read' | 'write' | 'destructive';

/**
* Usage limit configuration for rate limiting and budget quotas.
*/
export type UsageLimit = {
/** The maximum number of tool calls allowed within the time window. */
max: number;
/** The time window for the limit, e.g., '1m' for a minute, '1d' for a day. */
window: string;
/** Dictates the 'blocked' reason returned when the limit is exceeded. */
type: 'rate_limit' | 'budget';
/** Optional filter to restrict the limit to a specific risk level. */
riskLevel?: EndpointRiskLevel;
};

/**
* Permission mode controlling what the AI agent is allowed to do by default.
*
Expand Down Expand Up @@ -158,6 +172,10 @@ export type PluginPermissionsConfig<T extends EndpointTree = EndpointTree> = {
* Only valid paths for this specific plugin compile — typos are type errors.
*/
overrides?: Partial<Record<EndpointPathsOf<T>, PermissionPolicy>>;
/**
* Limits applied to calls made to this plugin.
*/
limits?: UsageLimit[];
};

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -737,6 +755,10 @@ export type CorsairPermissionsOptions = {
| 'synchronous'
| 'asynchronous'
| (() => 'synchronous' | 'asynchronous');
/**
* Global usage limits applied to all calls across all plugins.
*/
limits?: (UsageLimit & { scope?: 'global' | 'tenant' })[];
/**
* @deprecated Use `manual.onApprovalRequired` instead. TODO: delete ~April 2026.
*/
Expand Down
25 changes: 25 additions & 0 deletions packages/corsair/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,27 @@ export type CorsairPermissionInsert = {
error?: string | null;
};

// ─────────────────────────────────────────────────────────────────────────────
// Corsair Usage Counters
// ─────────────────────────────────────────────────────────────────────────────

export const CorsairUsageSchema = z.object({
/** Composite key identifying the limit scope and time window epoch */
key: z.string(),
/** Number of logical tool attempts within this window */
count: z.number().int().default(1),
/** ISO8601 timestamp — when this counter can be safely pruned */
expires_at: z.string(),
});

export type CorsairUsage = z.infer<typeof CorsairUsageSchema>;

export type CorsairUsageInsert = {
key: string;
count?: number;
expires_at: string;
};

// ─────────────────────────────────────────────────────────────────────────────
// Table Names
// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -167,6 +188,7 @@ export type CorsairTableName =
| 'corsair_entities'
| 'corsair_events'
| 'corsair_permissions'
| 'corsair_usage_counters'
| (string & {});

// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -178,6 +200,7 @@ export type CorsairTableRow = {
corsair_accounts: CorsairAccount;
corsair_entities: CorsairEntity;
corsair_events: CorsairEvent;
corsair_usage_counters: CorsairUsage;
};

export type TableRowType<T extends CorsairTableName> =
Expand Down Expand Up @@ -234,6 +257,7 @@ export type CorsairTableInsert = {
corsair_accounts: CorsairAccountInsert;
corsair_entities: CorsairEntityInsert;
corsair_events: CorsairEventInsert;
corsair_usage_counters: CorsairUsageInsert;
};

export type TableInsertType<T extends CorsairTableName> =
Expand Down Expand Up @@ -266,6 +290,7 @@ export type CorsairTableUpdate = {
corsair_accounts: CorsairAccountUpdate;
corsair_entities: CorsairEntityUpdate;
corsair_events: CorsairEventUpdate;
corsair_usage_counters: Partial<CorsairUsage>;
};

export type TableUpdateType<T extends CorsairTableName> =
Expand Down
2 changes: 2 additions & 0 deletions packages/corsair/db/kysely/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CorsairEvent,
CorsairIntegration,
CorsairPermission,
CorsairUsage,
} from '../index';
import { SqliteDatePlugin } from './sqlite-date-plugin.js';

Expand All @@ -18,6 +19,7 @@ export type CorsairKyselyDatabase = {
corsair_entities: CorsairEntity;
corsair_events: CorsairEvent;
corsair_permissions: CorsairPermission;
corsair_usage_counters: CorsairUsage;
};

export type CorsairDatabase = {
Expand Down
55 changes: 55 additions & 0 deletions packages/corsair/tests/bind-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
import { bindEndpointsRecursively } from '../core/endpoints/bind';
import { createTestDatabase } from './setup-db';

describe('bindEndpointsRecursively - Global Limits Bypass', () => {
let testDb: ReturnType<typeof createTestDatabase>;

beforeEach(() => {
testDb = createTestDatabase();
});

afterEach(() => {
testDb.cleanup();
});

it('enforces global limits on a plugin that does NOT define options.permissions', async () => {
const endpoints = {
testMethod: () => 'success',
};

const tree: Record<string, unknown> = {};

bindEndpointsRecursively({
endpoints,
hooks: undefined,
ctx: {},
tree,
pluginId: 'test-plugin',
errorHandlers: {
handle: (e: any) => {
throw e;
},
wrap: (e: any) => {
throw e;
},
},
currentPath: [],
// NOTE: permissionsConfig is intentionally undefined!
permissionsConfig: undefined,
permissionsOptions: {
limits: [{ max: 1, window: '1m', type: 'rate_limit' }],
},
database: testDb.database,
});

const boundMethod = tree.testMethod as () => Promise<string>;

// First call should succeed and consume the 1 quota
const result1 = await boundMethod();
expect(result1).toBe('success');

// Second call should fail with rate limit error
await expect(boundMethod()).rejects.toThrow('rate limited');
});
});
Loading
Loading