mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Implement policy guard engine for agent actions
This commit is contained in:
parent
9d453a09f6
commit
e80b582b18
17 changed files with 2134 additions and 8 deletions
83
server/src/__tests__/services/policy-service.test.ts
Normal file
83
server/src/__tests__/services/policy-service.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PolicyService } from '../../services/policy-service.js';
|
||||
|
||||
describe('PolicyService', () => {
|
||||
let policiesDir: string;
|
||||
let service: PolicyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
policiesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-policy-service-'));
|
||||
service = new PolicyService(policiesDir);
|
||||
await service.waitForInit();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(policiesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('seeds preset policies on first load', async () => {
|
||||
const policies = await service.listPolicies();
|
||||
|
||||
expect(policies).toHaveLength(3);
|
||||
expect(policies.map((policy) => policy.preset).sort()).toEqual([
|
||||
'balanced',
|
||||
'permissive',
|
||||
'strict',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates and evaluates a block-action-type policy', async () => {
|
||||
await service.createPolicy({
|
||||
id: 'block-force-push',
|
||||
name: 'Block Force Push',
|
||||
type: 'block-action-type',
|
||||
enabled: true,
|
||||
scope: {
|
||||
agents: ['codex'],
|
||||
projects: ['core'],
|
||||
actionTypes: [],
|
||||
},
|
||||
responseAction: 'block',
|
||||
config: {
|
||||
actionTypes: ['git.force-push'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.evaluatePolicies({
|
||||
agent: 'codex',
|
||||
project: 'core',
|
||||
actionType: 'git.force-push',
|
||||
});
|
||||
|
||||
expect(result.decision).toBe('block');
|
||||
expect(result.blockedBy).toContain('block-force-push');
|
||||
});
|
||||
|
||||
it('updates and deletes a policy', async () => {
|
||||
const created = await service.createPolicy({
|
||||
id: 'manual-approval',
|
||||
name: 'Manual Approval',
|
||||
type: 'require-approval',
|
||||
enabled: true,
|
||||
scope: {},
|
||||
responseAction: 'require-approval',
|
||||
config: {
|
||||
reason: 'Human sign-off required',
|
||||
approvers: ['bradgroux'],
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await service.updatePolicy(created.id, {
|
||||
...created,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(updated.enabled).toBe(false);
|
||||
|
||||
await service.deletePolicy(created.id);
|
||||
expect(await service.getPolicy(created.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ import { ConfigService } from './services/config-service.js';
|
|||
import { disposeTaskService } from './services/task-service.js';
|
||||
import { initBroadcast } from './services/broadcast-service.js';
|
||||
import { runStartupMigrations } from './services/migration-service.js';
|
||||
import { getPolicyService } from './services/policy-service.js';
|
||||
import { createBackup, runIntegrityChecks } from './services/integrity-service.js';
|
||||
import { errorHandler, AppError } from './middleware/error-handler.js';
|
||||
import { requestIdMiddleware } from './middleware/request-id.js';
|
||||
|
|
@ -566,6 +567,7 @@ let configService: ConfigService | null = null;
|
|||
const featureSettings = await configService.getFeatureSettings();
|
||||
syncSettingsToServices(featureSettings);
|
||||
await getTelemetryService().init();
|
||||
await getPolicyService().waitForInit();
|
||||
} catch (err) {
|
||||
log.error({ err }, 'Failed to initialize services');
|
||||
}
|
||||
|
|
|
|||
59
server/src/routes/policies.ts
Normal file
59
server/src/routes/policies.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Router } from 'express';
|
||||
import type { AgentPolicy, PolicyEvaluationRequest } from '@veritas-kanban/shared';
|
||||
import { getPolicyService } from '../services/policy-service.js';
|
||||
import {
|
||||
policyEvaluationSchema,
|
||||
policyParamsSchema,
|
||||
policySchema,
|
||||
} from '../schemas/policy-schemas.js';
|
||||
import { validate, type ValidatedRequest } from '../middleware/validate.js';
|
||||
|
||||
const router = Router();
|
||||
const policyService = getPolicyService();
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const policies = await policyService.listPolicies();
|
||||
res.json(policies);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
validate({ body: policySchema }),
|
||||
async (req: ValidatedRequest<unknown, unknown, AgentPolicy>, res) => {
|
||||
const policy = await policyService.createPolicy(req.validated.body as AgentPolicy);
|
||||
res.status(201).json(policy);
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
validate({ params: policyParamsSchema, body: policySchema }),
|
||||
async (req: ValidatedRequest<{ id: string }, unknown, AgentPolicy>, res) => {
|
||||
const { id } = req.validated.params as { id: string };
|
||||
const policy = await policyService.updatePolicy(id, req.validated.body as AgentPolicy);
|
||||
res.json(policy);
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
validate({ params: policyParamsSchema }),
|
||||
async (req: ValidatedRequest<{ id: string }>, res) => {
|
||||
const { id } = req.validated.params as { id: string };
|
||||
await policyService.deletePolicy(id);
|
||||
res.json({ deleted: id });
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/evaluate',
|
||||
validate({ body: policyEvaluationSchema }),
|
||||
async (req: ValidatedRequest<unknown, unknown, PolicyEvaluationRequest>, res) => {
|
||||
const result = await policyService.evaluatePolicies(
|
||||
req.validated.body as PolicyEvaluationRequest
|
||||
);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -72,6 +72,7 @@ import lessonsRoutes from '../lessons.js';
|
|||
import delegationRoutes from '../delegation.js';
|
||||
import { workflowRoutes } from '../workflows.js';
|
||||
import toolPolicyRoutes from '../tool-policies.js';
|
||||
import policyRoutes from '../policies.js';
|
||||
import { integrationsRoutes } from '../integrations.js';
|
||||
import { systemHealthRouter } from '../system-health.js';
|
||||
import { transcriptRoutes } from '../transcripts.js';
|
||||
|
|
@ -162,6 +163,7 @@ v1Router.use('/lessons', lessonsRoutes);
|
|||
v1Router.use('/delegation', delegationRoutes);
|
||||
v1Router.use('/workflows', workflowRoutes);
|
||||
v1Router.use('/tool-policies', toolPolicyRoutes);
|
||||
v1Router.use('/policies', policyRoutes);
|
||||
v1Router.use('/integrations', integrationsRoutes);
|
||||
v1Router.use('/transcripts', transcriptRoutes);
|
||||
v1Router.use('/system/health', systemHealthRouter);
|
||||
|
|
|
|||
105
server/src/schemas/policy-schemas.ts
Normal file
105
server/src/schemas/policy-schemas.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const policyIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^[a-z0-9][a-z0-9-_]*$/, 'Policy id must be lowercase kebab/snake case');
|
||||
|
||||
const scopeSchema = z.object({
|
||||
agents: z.array(z.string().min(1)).optional().default([]),
|
||||
projects: z.array(z.string().min(1)).optional().default([]),
|
||||
actionTypes: z.array(z.string().min(1)).optional().default([]),
|
||||
});
|
||||
|
||||
export const policyTypeSchema = z.enum([
|
||||
'risk-threshold',
|
||||
'require-approval',
|
||||
'block-action-type',
|
||||
'rate-limit',
|
||||
'webhook-check',
|
||||
]);
|
||||
|
||||
export const responseActionSchema = z.enum(['block', 'warn', 'require-approval']);
|
||||
|
||||
const riskThresholdConfigSchema = z.object({
|
||||
threshold: z.number().min(0).max(100),
|
||||
comparator: z.enum(['gte', 'gt', 'lte', 'lt']).optional().default('gte'),
|
||||
});
|
||||
|
||||
const requireApprovalConfigSchema = z.object({
|
||||
reason: z.string().max(500).optional(),
|
||||
approvers: z.array(z.string().min(1)).max(20).optional().default([]),
|
||||
});
|
||||
|
||||
const blockActionTypeConfigSchema = z.object({
|
||||
actionTypes: z.array(z.string().min(1)).min(1).max(50),
|
||||
});
|
||||
|
||||
const rateLimitConfigSchema = z.object({
|
||||
maxAttempts: z.number().int().positive().max(10000),
|
||||
windowMs: z.number().int().positive().max(86_400_000),
|
||||
scopeKey: z.enum(['agent', 'project', 'action-type', 'global']).optional().default('global'),
|
||||
});
|
||||
|
||||
const webhookCheckConfigSchema = z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(['GET', 'POST']).optional().default('POST'),
|
||||
timeoutMs: z.number().int().positive().max(60_000).optional().default(5_000),
|
||||
expectedStatus: z.number().int().min(100).max(599).optional().default(200),
|
||||
expectedBodyContains: z.string().optional(),
|
||||
sendContext: z.boolean().optional().default(true),
|
||||
triggerOn: z.enum(['success', 'failure']).optional().default('failure'),
|
||||
});
|
||||
|
||||
const policyBaseSchema = z.object({
|
||||
id: policyIdSchema,
|
||||
name: z.string().min(1).max(120),
|
||||
enabled: z.boolean(),
|
||||
scope: scopeSchema.default({ agents: [], projects: [], actionTypes: [] }),
|
||||
responseAction: responseActionSchema,
|
||||
description: z.string().max(500).optional(),
|
||||
preset: z.enum(['strict', 'balanced', 'permissive']).optional(),
|
||||
createdAt: z.string().datetime().optional(),
|
||||
updatedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const policySchema = z.discriminatedUnion('type', [
|
||||
policyBaseSchema.extend({
|
||||
type: z.literal('risk-threshold'),
|
||||
config: riskThresholdConfigSchema,
|
||||
}),
|
||||
policyBaseSchema.extend({
|
||||
type: z.literal('require-approval'),
|
||||
config: requireApprovalConfigSchema,
|
||||
}),
|
||||
policyBaseSchema.extend({
|
||||
type: z.literal('block-action-type'),
|
||||
config: blockActionTypeConfigSchema,
|
||||
}),
|
||||
policyBaseSchema.extend({
|
||||
type: z.literal('rate-limit'),
|
||||
config: rateLimitConfigSchema,
|
||||
}),
|
||||
policyBaseSchema.extend({
|
||||
type: z.literal('webhook-check'),
|
||||
config: webhookCheckConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const policyParamsSchema = z.object({
|
||||
id: policyIdSchema,
|
||||
});
|
||||
|
||||
export const policyEvaluationSchema = z.object({
|
||||
agent: z.string().min(1).optional(),
|
||||
project: z.string().min(1).optional(),
|
||||
actionType: z.string().min(1),
|
||||
riskScore: z.number().min(0).max(100).optional(),
|
||||
preview: z.boolean().optional().default(false),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type PolicyInput = z.infer<typeof policySchema>;
|
||||
export type PolicyEvaluationInput = z.infer<typeof policyEvaluationSchema>;
|
||||
export type PolicyParams = z.infer<typeof policyParamsSchema>;
|
||||
500
server/src/services/policy-service.ts
Normal file
500
server/src/services/policy-service.ts
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import path from 'path';
|
||||
import type {
|
||||
AgentPolicy,
|
||||
PolicyEvaluationMatch,
|
||||
PolicyEvaluationRequest,
|
||||
PolicyEvaluationResult,
|
||||
PolicyResponseAction,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { fileExists, mkdir, readFile, readdir, unlink, writeFile } from '../storage/fs-helpers.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { policySchema } from '../schemas/policy-schemas.js';
|
||||
import { getPoliciesDir } from '../utils/paths.js';
|
||||
|
||||
const log = createLogger('policy-service');
|
||||
const PRESET_TIMESTAMP = new Date().toISOString();
|
||||
|
||||
const DEFAULT_PRESET_POLICIES: AgentPolicy[] = [
|
||||
{
|
||||
id: 'strict-high-risk-block',
|
||||
name: 'Strict Pack',
|
||||
type: 'risk-threshold',
|
||||
enabled: true,
|
||||
scope: {},
|
||||
responseAction: 'block',
|
||||
description: 'Blocks high-risk actions across all agents and projects.',
|
||||
preset: 'strict',
|
||||
config: {
|
||||
threshold: 85,
|
||||
comparator: 'gte',
|
||||
},
|
||||
createdAt: PRESET_TIMESTAMP,
|
||||
updatedAt: PRESET_TIMESTAMP,
|
||||
},
|
||||
{
|
||||
id: 'balanced-high-risk-approval',
|
||||
name: 'Balanced Pack',
|
||||
type: 'risk-threshold',
|
||||
enabled: true,
|
||||
scope: {},
|
||||
responseAction: 'require-approval',
|
||||
description: 'Requires approval when an action crosses a moderate-to-high risk threshold.',
|
||||
preset: 'balanced',
|
||||
config: {
|
||||
threshold: 65,
|
||||
comparator: 'gte',
|
||||
},
|
||||
createdAt: PRESET_TIMESTAMP,
|
||||
updatedAt: PRESET_TIMESTAMP,
|
||||
},
|
||||
{
|
||||
id: 'permissive-burst-warning',
|
||||
name: 'Permissive Pack',
|
||||
type: 'rate-limit',
|
||||
enabled: true,
|
||||
scope: {},
|
||||
responseAction: 'warn',
|
||||
description: 'Warns when the same actor bursts a large number of actions in a short window.',
|
||||
preset: 'permissive',
|
||||
config: {
|
||||
maxAttempts: 20,
|
||||
windowMs: 60 * 60 * 1000,
|
||||
scopeKey: 'agent',
|
||||
},
|
||||
createdAt: PRESET_TIMESTAMP,
|
||||
updatedAt: PRESET_TIMESTAMP,
|
||||
},
|
||||
];
|
||||
|
||||
const RESPONSE_PRIORITY: Record<PolicyResponseAction, number> = {
|
||||
warn: 1,
|
||||
'require-approval': 2,
|
||||
block: 3,
|
||||
};
|
||||
|
||||
const DECISION_PRIORITY: Record<PolicyEvaluationResult['decision'], number> = {
|
||||
allow: 0,
|
||||
warn: 1,
|
||||
'require-approval': 2,
|
||||
block: 3,
|
||||
};
|
||||
|
||||
export class PolicyService {
|
||||
private readonly policiesDir: string;
|
||||
private readonly cache = new Map<string, AgentPolicy>();
|
||||
private readonly rateLimitState = new Map<string, number[]>();
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(policiesDir = getPoliciesDir()) {
|
||||
this.policiesDir = policiesDir;
|
||||
this.initPromise = this.init();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await mkdir(this.policiesDir, { recursive: true });
|
||||
|
||||
const files = (await readdir(this.policiesDir)).filter((file) => file.endsWith('.json'));
|
||||
if (files.length === 0) {
|
||||
for (const policy of DEFAULT_PRESET_POLICIES) {
|
||||
await this.writePolicyFile(policy);
|
||||
}
|
||||
}
|
||||
|
||||
await this.loadPoliciesFromDisk();
|
||||
}
|
||||
|
||||
async waitForInit(): Promise<void> {
|
||||
if (this.initPromise) {
|
||||
await this.initPromise;
|
||||
}
|
||||
}
|
||||
|
||||
async listPolicies(): Promise<AgentPolicy[]> {
|
||||
await this.waitForInit();
|
||||
return Array.from(this.cache.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async getPolicy(id: string): Promise<AgentPolicy | null> {
|
||||
await this.waitForInit();
|
||||
|
||||
const cached = this.cache.get(id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const filePath = this.getPolicyFilePath(id);
|
||||
if (!(await fileExists(filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = policySchema.parse(JSON.parse(content)) as AgentPolicy;
|
||||
this.cache.set(parsed.id, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async createPolicy(policy: AgentPolicy): Promise<AgentPolicy> {
|
||||
await this.waitForInit();
|
||||
|
||||
const normalized = this.normalizePolicy(policy, true);
|
||||
if (this.cache.has(normalized.id)) {
|
||||
throw new ConflictError(`Policy already exists: ${normalized.id}`);
|
||||
}
|
||||
await this.writePolicyFile(normalized);
|
||||
this.cache.set(normalized.id, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async updatePolicy(id: string, policy: AgentPolicy): Promise<AgentPolicy> {
|
||||
await this.waitForInit();
|
||||
|
||||
if (id !== policy.id) {
|
||||
throw new ValidationError('Policy id in URL must match policy id in request body');
|
||||
}
|
||||
|
||||
if (!this.cache.has(id)) {
|
||||
throw new NotFoundError(`Policy not found: ${id}`);
|
||||
}
|
||||
|
||||
const existing = this.cache.get(id);
|
||||
const normalized = this.normalizePolicy(
|
||||
{
|
||||
...policy,
|
||||
createdAt: existing?.createdAt || policy.createdAt,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await this.writePolicyFile(normalized);
|
||||
this.cache.set(normalized.id, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async deletePolicy(id: string): Promise<void> {
|
||||
await this.waitForInit();
|
||||
|
||||
if (!this.cache.has(id)) {
|
||||
throw new NotFoundError(`Policy not found: ${id}`);
|
||||
}
|
||||
|
||||
const filePath = this.getPolicyFilePath(id);
|
||||
if (await fileExists(filePath)) {
|
||||
await unlink(filePath);
|
||||
}
|
||||
this.cache.delete(id);
|
||||
for (const key of Array.from(this.rateLimitState.keys())) {
|
||||
if (key.startsWith(`${id}:`)) {
|
||||
this.rateLimitState.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluatePolicies(input: PolicyEvaluationRequest): Promise<PolicyEvaluationResult> {
|
||||
await this.waitForInit();
|
||||
|
||||
const policies = await this.listPolicies();
|
||||
const matches: PolicyEvaluationMatch[] = [];
|
||||
let decision: PolicyEvaluationResult['decision'] = 'allow';
|
||||
|
||||
for (const policy of policies) {
|
||||
if (!policy.enabled) continue;
|
||||
if (!this.scopeMatches(policy, input)) continue;
|
||||
|
||||
const evaluation = await this.evaluatePolicy(policy, input);
|
||||
if (!evaluation) continue;
|
||||
|
||||
matches.push(evaluation);
|
||||
if (RESPONSE_PRIORITY[evaluation.responseAction] > DECISION_PRIORITY[decision]) {
|
||||
decision = this.toDecision(evaluation.responseAction);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
matches,
|
||||
warnings: matches
|
||||
.filter((match) => match.responseAction === 'warn')
|
||||
.map((match) => match.message),
|
||||
blockedBy: matches
|
||||
.filter((match) => match.responseAction === 'block')
|
||||
.map((match) => match.policyId),
|
||||
approvalRequiredBy: matches
|
||||
.filter((match) => match.responseAction === 'require-approval')
|
||||
.map((match) => match.policyId),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPoliciesFromDisk(): Promise<void> {
|
||||
const files = (await readdir(this.policiesDir)).filter((file) => file.endsWith('.json'));
|
||||
this.cache.clear();
|
||||
|
||||
for (const fileName of files) {
|
||||
const filePath = path.join(this.policiesDir, fileName);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = policySchema.parse(JSON.parse(content)) as AgentPolicy;
|
||||
this.cache.set(parsed.id, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePolicy(policy: AgentPolicy, isCreate: boolean): AgentPolicy {
|
||||
const now = new Date().toISOString();
|
||||
const normalized = policySchema.parse({
|
||||
...policy,
|
||||
id: policy.id.trim(),
|
||||
name: policy.name.trim(),
|
||||
description: policy.description?.trim() || undefined,
|
||||
scope: {
|
||||
agents: [...new Set(policy.scope.agents ?? [])],
|
||||
projects: [...new Set(policy.scope.projects ?? [])],
|
||||
actionTypes: [...new Set(policy.scope.actionTypes ?? [])],
|
||||
},
|
||||
createdAt: isCreate ? now : policy.createdAt,
|
||||
updatedAt: now,
|
||||
}) as AgentPolicy;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async writePolicyFile(policy: AgentPolicy): Promise<void> {
|
||||
const filePath = this.getPolicyFilePath(policy.id);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(policy, null, 2)}\n`, 'utf8');
|
||||
log.info({ policyId: policy.id, type: policy.type }, 'Policy saved');
|
||||
}
|
||||
|
||||
private getPolicyFilePath(id: string): string {
|
||||
if (!/^[a-z0-9][a-z0-9-_]*$/.test(id)) {
|
||||
throw new ValidationError('Invalid policy id');
|
||||
}
|
||||
return path.join(this.policiesDir, `${id}.json`);
|
||||
}
|
||||
|
||||
private scopeMatches(policy: AgentPolicy, input: PolicyEvaluationRequest): boolean {
|
||||
const { agents = [], projects = [], actionTypes = [] } = policy.scope;
|
||||
|
||||
if (agents.length > 0 && (!input.agent || !agents.includes(input.agent))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (projects.length > 0 && (!input.project || !projects.includes(input.project))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actionTypes.length > 0 && !actionTypes.includes(input.actionType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async evaluatePolicy(
|
||||
policy: AgentPolicy,
|
||||
input: PolicyEvaluationRequest
|
||||
): Promise<PolicyEvaluationMatch | null> {
|
||||
switch (policy.type) {
|
||||
case 'risk-threshold': {
|
||||
if (typeof input.riskScore !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { threshold, comparator = 'gte' } = policy.config;
|
||||
const triggered = this.compareRisk(input.riskScore, threshold, comparator);
|
||||
if (!triggered) return null;
|
||||
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
policyType: policy.type,
|
||||
responseAction: policy.responseAction,
|
||||
message: `${policy.name} matched risk score ${input.riskScore} against threshold ${threshold}.`,
|
||||
details: {
|
||||
threshold,
|
||||
comparator,
|
||||
riskScore: input.riskScore,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'require-approval':
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
policyType: policy.type,
|
||||
responseAction: policy.responseAction,
|
||||
message: policy.config.reason || `${policy.name} requires approval before continuing.`,
|
||||
details: {
|
||||
approvers: policy.config.approvers ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
case 'block-action-type':
|
||||
if (!policy.config.actionTypes.includes(input.actionType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
policyType: policy.type,
|
||||
responseAction: policy.responseAction,
|
||||
message: `${policy.name} matched blocked action type "${input.actionType}".`,
|
||||
details: {
|
||||
actionType: input.actionType,
|
||||
},
|
||||
};
|
||||
|
||||
case 'rate-limit': {
|
||||
const key = this.getRateLimitKey(policy, input);
|
||||
const now = Date.now();
|
||||
const windowStart = now - policy.config.windowMs;
|
||||
const history = (this.rateLimitState.get(key) ?? []).filter(
|
||||
(timestamp) => timestamp >= windowStart
|
||||
);
|
||||
const triggered = history.length >= policy.config.maxAttempts;
|
||||
|
||||
if (!input.preview) {
|
||||
history.push(now);
|
||||
this.rateLimitState.set(key, history);
|
||||
}
|
||||
|
||||
if (!triggered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
policyType: policy.type,
|
||||
responseAction: policy.responseAction,
|
||||
message: `${policy.name} exceeded ${policy.config.maxAttempts} action(s) in ${policy.config.windowMs}ms.`,
|
||||
details: {
|
||||
maxAttempts: policy.config.maxAttempts,
|
||||
windowMs: policy.config.windowMs,
|
||||
key,
|
||||
recentCount: history.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'webhook-check': {
|
||||
const webhookResult = await this.runWebhookCheck(policy, input);
|
||||
if (!webhookResult.triggered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
policyType: policy.type,
|
||||
responseAction: policy.responseAction,
|
||||
message: webhookResult.message,
|
||||
details: webhookResult.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private compareRisk(
|
||||
score: number,
|
||||
threshold: number,
|
||||
comparator: 'gte' | 'gt' | 'lte' | 'lt'
|
||||
): boolean {
|
||||
switch (comparator) {
|
||||
case 'gt':
|
||||
return score > threshold;
|
||||
case 'lte':
|
||||
return score <= threshold;
|
||||
case 'lt':
|
||||
return score < threshold;
|
||||
case 'gte':
|
||||
default:
|
||||
return score >= threshold;
|
||||
}
|
||||
}
|
||||
|
||||
private getRateLimitKey(
|
||||
policy: Extract<AgentPolicy, { type: 'rate-limit' }>,
|
||||
input: PolicyEvaluationRequest
|
||||
): string {
|
||||
const scopeKey = policy.config.scopeKey ?? 'global';
|
||||
let scopeValue = 'global';
|
||||
|
||||
if (scopeKey === 'agent') {
|
||||
scopeValue = input.agent || 'unknown-agent';
|
||||
} else if (scopeKey === 'project') {
|
||||
scopeValue = input.project || 'unknown-project';
|
||||
} else if (scopeKey === 'action-type') {
|
||||
scopeValue = input.actionType;
|
||||
}
|
||||
|
||||
return `${policy.id}:${scopeKey}:${scopeValue}`;
|
||||
}
|
||||
|
||||
private async runWebhookCheck(
|
||||
policy: Extract<AgentPolicy, { type: 'webhook-check' }>,
|
||||
input: PolicyEvaluationRequest
|
||||
): Promise<{ triggered: boolean; message: string; details: Record<string, unknown> }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), policy.config.timeoutMs ?? 5_000);
|
||||
const body = policy.config.sendContext === false ? undefined : JSON.stringify(input);
|
||||
|
||||
try {
|
||||
const response = await fetch(policy.config.url, {
|
||||
method: policy.config.method ?? 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const text = await response.text().catch(() => '');
|
||||
const statusMatches = response.status === (policy.config.expectedStatus ?? 200);
|
||||
const bodyMatches = policy.config.expectedBodyContains
|
||||
? text.includes(policy.config.expectedBodyContains)
|
||||
: true;
|
||||
const success = statusMatches && bodyMatches;
|
||||
const triggerOn = policy.config.triggerOn ?? 'failure';
|
||||
const triggered = triggerOn === 'success' ? success : !success;
|
||||
|
||||
return {
|
||||
triggered,
|
||||
message: triggered
|
||||
? `${policy.name} webhook ${triggerOn === 'success' ? 'succeeded' : 'failed'} with status ${response.status}.`
|
||||
: '',
|
||||
details: {
|
||||
status: response.status,
|
||||
expectedStatus: policy.config.expectedStatus ?? 200,
|
||||
bodySnippet: text.slice(0, 200),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown webhook failure';
|
||||
const triggered = (policy.config.triggerOn ?? 'failure') === 'failure';
|
||||
return {
|
||||
triggered,
|
||||
message: triggered ? `${policy.name} webhook check failed: ${message}.` : '',
|
||||
details: {
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private toDecision(action: PolicyResponseAction): PolicyEvaluationResult['decision'] {
|
||||
if (action === 'block') return 'block';
|
||||
if (action === 'require-approval') return 'require-approval';
|
||||
if (action === 'warn') return 'warn';
|
||||
return 'allow';
|
||||
}
|
||||
}
|
||||
|
||||
let policyService: PolicyService | null = null;
|
||||
|
||||
export function getPolicyService(): PolicyService {
|
||||
if (!policyService) {
|
||||
policyService = new PolicyService();
|
||||
}
|
||||
return policyService;
|
||||
}
|
||||
|
|
@ -12,7 +12,14 @@
|
|||
import fs from 'node:fs';
|
||||
import type { FSWatcher } from 'node:fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { access } from 'node:fs/promises';
|
||||
import {
|
||||
access,
|
||||
mkdir as mkdirAsync,
|
||||
readFile as readFileAsync,
|
||||
readdir as readdirAsync,
|
||||
unlink as unlinkAsync,
|
||||
writeFile as writeFileAsync,
|
||||
} from 'node:fs/promises';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synchronous helpers (used by security config, agent status persistence)
|
||||
|
|
@ -58,6 +65,12 @@ export const createWriteStream = fs.createWriteStream;
|
|||
// Async helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mkdir = mkdirAsync;
|
||||
export const readFile = readFileAsync;
|
||||
export const readdir = readdirAsync;
|
||||
export const unlink = unlinkAsync;
|
||||
export const writeFile = writeFileAsync;
|
||||
|
||||
/**
|
||||
* Async file-existence check.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -200,3 +200,8 @@ export function getWorkflowRunsDir(): string {
|
|||
export function getToolPoliciesDir(): string {
|
||||
return path.join(getRuntimeDir(), 'tool-policies');
|
||||
}
|
||||
|
||||
/** Directory for agent action policies (.veritas-kanban/storage/policies). */
|
||||
export function getPoliciesDir(): string {
|
||||
return path.join(getRuntimeDir(), 'storage', 'policies');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ export * from './broadcast.types.js';
|
|||
export * from './agent-registry.types.js';
|
||||
export * from './shared-resources.types.js';
|
||||
export * from './doc-freshness.types.js';
|
||||
export * from './policy.types.js';
|
||||
|
|
|
|||
101
shared/src/types/policy.types.ts
Normal file
101
shared/src/types/policy.types.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
export type PolicyType =
|
||||
| 'risk-threshold'
|
||||
| 'require-approval'
|
||||
| 'block-action-type'
|
||||
| 'rate-limit'
|
||||
| 'webhook-check';
|
||||
|
||||
export type PolicyResponseAction = 'block' | 'warn' | 'require-approval';
|
||||
|
||||
export type PolicyScopeKey = 'agent' | 'project' | 'action-type' | 'global';
|
||||
|
||||
export interface PolicyScope {
|
||||
agents?: string[];
|
||||
projects?: string[];
|
||||
actionTypes?: string[];
|
||||
}
|
||||
|
||||
export interface RiskThreshold {
|
||||
threshold: number;
|
||||
comparator?: 'gte' | 'gt' | 'lte' | 'lt';
|
||||
}
|
||||
|
||||
export interface RequireApproval {
|
||||
reason?: string;
|
||||
approvers?: string[];
|
||||
}
|
||||
|
||||
export interface BlockActionType {
|
||||
actionTypes: string[];
|
||||
}
|
||||
|
||||
export interface RateLimit {
|
||||
maxAttempts: number;
|
||||
windowMs: number;
|
||||
scopeKey?: PolicyScopeKey;
|
||||
}
|
||||
|
||||
export interface WebhookCheck {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST';
|
||||
timeoutMs?: number;
|
||||
expectedStatus?: number;
|
||||
expectedBodyContains?: string;
|
||||
sendContext?: boolean;
|
||||
triggerOn?: 'success' | 'failure';
|
||||
}
|
||||
|
||||
export interface PolicyConfigMap {
|
||||
'risk-threshold': RiskThreshold;
|
||||
'require-approval': RequireApproval;
|
||||
'block-action-type': BlockActionType;
|
||||
'rate-limit': RateLimit;
|
||||
'webhook-check': WebhookCheck;
|
||||
}
|
||||
|
||||
export interface BasePolicy<TType extends PolicyType = PolicyType> {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TType;
|
||||
enabled: boolean;
|
||||
scope: PolicyScope;
|
||||
responseAction: PolicyResponseAction;
|
||||
config: PolicyConfigMap[TType];
|
||||
description?: string;
|
||||
preset?: 'strict' | 'balanced' | 'permissive';
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type AgentPolicy =
|
||||
| BasePolicy<'risk-threshold'>
|
||||
| BasePolicy<'require-approval'>
|
||||
| BasePolicy<'block-action-type'>
|
||||
| BasePolicy<'rate-limit'>
|
||||
| BasePolicy<'webhook-check'>;
|
||||
|
||||
export interface PolicyEvaluationRequest {
|
||||
agent?: string;
|
||||
project?: string;
|
||||
actionType: string;
|
||||
riskScore?: number;
|
||||
preview?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PolicyEvaluationMatch {
|
||||
policyId: string;
|
||||
policyName: string;
|
||||
policyType: PolicyType;
|
||||
responseAction: PolicyResponseAction;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PolicyEvaluationResult {
|
||||
decision: 'allow' | 'warn' | 'require-approval' | 'block';
|
||||
matches: PolicyEvaluationMatch[];
|
||||
warnings: string[];
|
||||
blockedBy: string[];
|
||||
approvalRequiredBy: string[];
|
||||
}
|
||||
|
|
@ -48,6 +48,12 @@ const WorkflowsPage = lazy(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
const PolicyManager = lazy(() =>
|
||||
import('./components/policies/PolicyManager').then((mod) => ({
|
||||
default: mod.PolicyManager,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Renders the current view (board, activity feed, or backlog). */
|
||||
function MainContent() {
|
||||
const { view, setView, navigateToTask } = useView();
|
||||
|
|
@ -125,6 +131,20 @@ function MainContent() {
|
|||
);
|
||||
}
|
||||
|
||||
if (view === 'policies') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading policies…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PolicyManager onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <KanbanBoard />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
CheckCircle,
|
||||
Archive,
|
||||
ExternalLink,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { BudgetCard } from '@/components/dashboard/BudgetCard';
|
||||
import { MultiAgentPanel } from './MultiAgentPanel';
|
||||
|
|
@ -447,6 +448,24 @@ export function BoardSidebar({ onTaskClick }: BoardSidebarProps) {
|
|||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<button
|
||||
className="flex w-full items-center justify-between rounded-md border px-3 py-3 text-left transition-colors hover:bg-muted/40"
|
||||
onClick={() => setView('policies')}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Policy Engine
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">Manage guard policies</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Review policy packs, approvals, rate limits, and action blocks.
|
||||
</div>
|
||||
</div>
|
||||
<ShieldAlert className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Monthly Budget */}
|
||||
<BudgetCard />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
FileText,
|
||||
Users,
|
||||
Workflow,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
|
||||
|
|
@ -128,6 +129,15 @@ export function Header() {
|
|||
>
|
||||
<Workflow className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'policies' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'policies' ? 'board' : 'policies')}
|
||||
aria-label="Policies"
|
||||
title="Policies"
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
1001
web/src/components/policies/PolicyManager.tsx
Normal file
1001
web/src/components/policies/PolicyManager.tsx
Normal file
File diff suppressed because it is too large
Load diff
69
web/src/components/ui/data-table.tsx
Normal file
69
web/src/components/ui/data-table.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
className?: string;
|
||||
cell: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: DataTableColumn<T>[];
|
||||
data: T[];
|
||||
emptyMessage?: string;
|
||||
rowKey: (row: T) => string;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage = 'No rows found.',
|
||||
rowKey,
|
||||
}: DataTableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-4 py-3 text-left font-medium text-muted-foreground',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={rowKey(row)} className="border-t align-top">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className={cn('px-4 py-3', column.className)}>
|
||||
{column.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,47 @@
|
|||
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type AppView = 'board' | 'activity' | 'backlog' | 'archive' | 'templates' | 'workflows';
|
||||
export type AppView =
|
||||
| 'board'
|
||||
| 'activity'
|
||||
| 'backlog'
|
||||
| 'archive'
|
||||
| 'templates'
|
||||
| 'workflows'
|
||||
| 'policies';
|
||||
|
||||
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
|
||||
const VIEW_PATHS: Record<AppView, string> = {
|
||||
board: '/',
|
||||
activity: '/activity',
|
||||
backlog: '/backlog',
|
||||
archive: '/archive',
|
||||
templates: '/templates',
|
||||
workflows: '/workflows',
|
||||
policies: '/policies',
|
||||
};
|
||||
|
||||
function normalizeAppPath(pathname: string): string {
|
||||
const normalized = pathname.startsWith(basePath)
|
||||
? pathname.slice(basePath.length) || '/'
|
||||
: pathname;
|
||||
return normalized === '' ? '/' : normalized.replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
function getViewFromLocation(): AppView {
|
||||
if (typeof window === 'undefined') return 'board';
|
||||
const path = normalizeAppPath(window.location.pathname);
|
||||
const entry = Object.entries(VIEW_PATHS).find(([, value]) => value === path);
|
||||
return (entry?.[0] as AppView | undefined) || 'board';
|
||||
}
|
||||
|
||||
interface ViewContextValue {
|
||||
view: AppView;
|
||||
|
|
@ -21,23 +62,48 @@ const ViewContext = createContext<ViewContextValue>({
|
|||
});
|
||||
|
||||
export function ViewProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setView] = useState<AppView>('board');
|
||||
const [view, setViewState] = useState<AppView>(() => getViewFromLocation());
|
||||
const [pendingTaskId, setPendingTaskId] = useState<string | null>(null);
|
||||
|
||||
const navigateToTask = useCallback((taskId: string) => {
|
||||
setPendingTaskId(taskId);
|
||||
setView('board');
|
||||
const setView = useCallback((nextView: AppView) => {
|
||||
setViewState(nextView);
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const nextPath = `${basePath}${VIEW_PATHS[nextView]}`.replace(/\/+/g, '/');
|
||||
const nextUrl = nextView === 'board' ? `${nextPath}${window.location.search}` : nextPath;
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
if (currentPath !== nextUrl) {
|
||||
window.history.pushState({}, '', nextUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const navigateToTask = useCallback(
|
||||
(taskId: string) => {
|
||||
setPendingTaskId(taskId);
|
||||
setView('board');
|
||||
},
|
||||
[setView]
|
||||
);
|
||||
|
||||
const clearPendingTask = useCallback(() => {
|
||||
setPendingTaskId(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ view, setView, navigateToTask, pendingTaskId, clearPendingTask }),
|
||||
[view, navigateToTask, pendingTaskId, clearPendingTask]
|
||||
[view, setView, navigateToTask, pendingTaskId, clearPendingTask]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setViewState(getViewFromLocation());
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
return <ViewContext.Provider value={value}>{children}</ViewContext.Provider>;
|
||||
}
|
||||
|
||||
|
|
|
|||
70
web/src/hooks/usePolicies.ts
Normal file
70
web/src/hooks/usePolicies.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
AgentPolicy,
|
||||
PolicyEvaluationRequest,
|
||||
PolicyEvaluationResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { apiFetch } from '@/lib/api/helpers';
|
||||
|
||||
const POLICIES_QUERY_KEY = ['policies'];
|
||||
|
||||
export function usePolicies() {
|
||||
return useQuery<AgentPolicy[]>({
|
||||
queryKey: POLICIES_QUERY_KEY,
|
||||
queryFn: () => apiFetch<AgentPolicy[]>('/api/policies'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePolicy() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (policy: AgentPolicy) =>
|
||||
apiFetch<AgentPolicy>('/api/policies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: POLICIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePolicy() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, policy }: { id: string; policy: AgentPolicy }) =>
|
||||
apiFetch<AgentPolicy>(`/api/policies/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: POLICIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePolicy() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch<{ deleted: string }>(`/api/policies/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: POLICIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvaluatePolicies() {
|
||||
return useMutation({
|
||||
mutationFn: (input: PolicyEvaluationRequest) =>
|
||||
apiFetch<PolicyEvaluationResult>('/api/policies/evaluate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue