Implement policy guard engine for agent actions

This commit is contained in:
bradgroux 2026-03-21 02:54:33 -05:00
parent 9d453a09f6
commit e80b582b18
17 changed files with 2134 additions and 8 deletions

View 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();
});
});

View file

@ -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');
}

View 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;

View file

@ -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);

View 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>;

View 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;
}

View file

@ -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.
*

View file

@ -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');
}

View file

@ -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';

View 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[];
}

View file

@ -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 />;
}

View file

@ -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>

View file

@ -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"

File diff suppressed because it is too large Load diff

View 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>
);
}

View file

@ -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>;
}

View 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),
}),
});
}