mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Add governance decision traces
This commit is contained in:
parent
62f258052c
commit
abfac7c446
40 changed files with 2292 additions and 201 deletions
|
|
@ -42,12 +42,13 @@
|
|||
29. [Error Learning](#error-learning)
|
||||
30. [Tool Policies](#tool-policies)
|
||||
31. [Traces](#traces)
|
||||
32. [Audit](#audit)
|
||||
33. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
34. [Common Workflows](#common-workflows)
|
||||
35. [Versioning & Deprecation](#versioning--deprecation)
|
||||
36. [Rate Limits](#rate-limits)
|
||||
37. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
32. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
33. [Audit](#audit)
|
||||
34. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
35. [Common Workflows](#common-workflows)
|
||||
36. [Versioning & Deprecation](#versioning--deprecation)
|
||||
37. [Rate Limits](#rate-limits)
|
||||
38. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1249,8 +1250,8 @@ POST /api/agents/permissions/check
|
|||
```json
|
||||
{
|
||||
"allowed": true,
|
||||
"level": "specialist",
|
||||
"requiresApproval": false
|
||||
"requiresApproval": false,
|
||||
"traceId": "govtrace_1760000000000_ab12cd"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1336,7 +1337,8 @@ Accepts either a task ID or ad-hoc metadata:
|
|||
"agent": "codex-1",
|
||||
"model": "claude-sonnet-4.5",
|
||||
"rule": "high-priority-bugs",
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"traceId": "govtrace_1760000000000_ab12cd"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1758,7 +1760,8 @@ POST /api/tool-policies/:role/validate
|
|||
{
|
||||
"role": "intern",
|
||||
"tool": "deploy",
|
||||
"allowed": false
|
||||
"allowed": false,
|
||||
"traceId": "govtrace_1760000000000_ab12cd"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -2185,6 +2188,66 @@ Update a specific assumption by its zero-based index.
|
|||
|
||||
---
|
||||
|
||||
### Governance Decision Traces (`/api/governance/traces`)
|
||||
|
||||
Inspect policy, tool-policy, agent-permission, routing, and workflow-gate decisions with evaluated rules, matched rules, remediation, and redacted raw detail.
|
||||
|
||||
#### List Governance Traces
|
||||
|
||||
```
|
||||
GET /api/governance/traces
|
||||
```
|
||||
|
||||
Query params: `kind`, `outcome`, `agent`, `taskId`, `actionType`, `startTime`, `endTime`, `limit`.
|
||||
|
||||
`kind` values: `policy`, `tool-policy`, `agent-permission`, `routing`, `workflow-gate`.
|
||||
|
||||
`outcome` values: `allowed`, `warned`, `blocked`, `approval-required`, `routed`, `fallback`, `skipped`.
|
||||
|
||||
**Response:** Array of trace records.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "govtrace_1760000000000_ab12cd",
|
||||
"kind": "policy",
|
||||
"outcome": "blocked",
|
||||
"title": "Policy evaluation: git.push",
|
||||
"summary": "Production deploy requires approval.",
|
||||
"remediation": "Request approval from a lead agent.",
|
||||
"subject": {
|
||||
"agentId": "codex",
|
||||
"taskId": "task_123",
|
||||
"actionType": "git.push"
|
||||
},
|
||||
"evaluatedRules": [
|
||||
{
|
||||
"id": "policy:prod-risk",
|
||||
"label": "Production risk gate",
|
||||
"type": "policy",
|
||||
"status": "matched",
|
||||
"outcome": "blocked",
|
||||
"message": "Risk score exceeded the blocking threshold."
|
||||
}
|
||||
],
|
||||
"matchedRules": [],
|
||||
"steps": [],
|
||||
"redacted": true,
|
||||
"createdAt": "2026-06-01T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Get Governance Trace
|
||||
|
||||
```
|
||||
GET /api/governance/traces/:id
|
||||
```
|
||||
|
||||
Returns one trace record including `raw` detail when present. All persisted trace values are redacted before write.
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Drift Detection (`/api/drift`)
|
||||
|
||||
Track agent metric baselines and detect behavioral deviations.
|
||||
|
|
@ -2355,14 +2418,15 @@ DELETE /api/policies/:id
|
|||
#### Evaluate Policy
|
||||
|
||||
```
|
||||
POST /api/policies/:id/evaluate
|
||||
POST /api/policies/evaluate
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "TARS",
|
||||
"tool": "browser",
|
||||
"action": "navigate",
|
||||
"project": "core",
|
||||
"actionType": "tool.browser.navigate",
|
||||
"riskScore": 72,
|
||||
"metadata": { "url": "https://example.com" }
|
||||
}
|
||||
```
|
||||
|
|
@ -2371,11 +2435,20 @@ POST /api/policies/:id/evaluate
|
|||
|
||||
```json
|
||||
{
|
||||
"allowed": false,
|
||||
"effect": "deny",
|
||||
"matchedRule": { "tool": "browser", "action": "*", "effect": "deny" },
|
||||
"decision": "require-approval",
|
||||
"matches": [
|
||||
{
|
||||
"policyId": "pol_abc123",
|
||||
"auditId": "audit_xyz789"
|
||||
"policyName": "Production risk gate",
|
||||
"policyType": "risk-threshold",
|
||||
"responseAction": "require-approval",
|
||||
"message": "Risk score requires approval."
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"blockedBy": [],
|
||||
"approvalRequiredBy": ["pol_abc123"],
|
||||
"traceId": "govtrace_1760000000000_ab12cd"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ support bundles must follow.
|
|||
## Data Classes
|
||||
|
||||
| Data class | Primary tables | Default retention | Export/delete policy |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Workspace identity and membership | `workspaces`, `users`, `workspace_memberships`, `workspace_invitations` | Until admin removal or workspace archival | Scoped exports include selected workspace, memberships, invitations, and member users only. |
|
||||
| Tasks and task metadata | `tasks` | Active/backlog until archived or deleted; archived until cleanup | Preview linked artifacts before deletion. Included in full and scoped exports. |
|
||||
| Task comments and discussion | `tasks` JSON | Follows parent task | Included with task export. Cleanup follows parent task unless comment-level delete is used. |
|
||||
|
|
@ -35,7 +35,7 @@ support bundles must follow.
|
|||
| Workflow definitions, runs, and scheduled snapshots | `workflow_definitions`, `workflow_acls`, `workflow_audit_events`, `workflow_runs`, `scheduled_deliverables`, `scheduled_deliverable_runs` | Definitions until deleted; runs/snapshots by admin retention | Never delete active runs or current scheduled state silently. |
|
||||
| Notifications and subscriptions | `notifications`, `thread_subscriptions` | Until read/dismissed history cleanup | Preview delivered/read state, target category, source task, and age. |
|
||||
| Chat and squad messages | `chat_sessions`, `chat_messages`, `squad_messages` | Until session/workspace cleanup | Preview session, task link, message count, agents, and age. |
|
||||
| Audit, governance, and policy records | `activity_events`, `status_history`, `decision_records`, `feedback_records`, `scoring_profiles`, `scoring_evaluations`, `drift_alerts`, `drift_baselines`, `audit_entries`, `agent_policies`, `tool_policies` | Longer-lived audit evidence | Do not silently delete through operational cleanup. |
|
||||
| Audit, governance, and policy records | `activity_events`, `status_history`, `decision_records`, `governance_decision_traces`, `feedback_records`, `scoring_profiles`, `scoring_evaluations`, `drift_alerts`, `drift_baselines`, `audit_entries`, `agent_policies`, `tool_policies` | Longer-lived audit evidence | Do not silently delete through operational cleanup. |
|
||||
| Device sessions and API tokens | excluded from backup table exports | Until expiration or revocation | Revoke before deletion. Never print secret values or hashes. |
|
||||
| Configuration and registries | `app_config_documents`, `managed_list_items`, `task_templates`, `prompt_templates`, `prompt_versions`, `prompt_usage` | Until changed or deleted | Full exports include them. Scoped exports exclude global app config. |
|
||||
| Backups, imports, and exports | filesystem bundles and manifests | Until admin removes files | Every export includes a manifest with data classes, row counts, and redaction state. |
|
||||
|
|
|
|||
|
|
@ -68,7 +68,26 @@ curl "http://localhost:3001/api/decisions?taskId=task_20260321_abc"
|
|||
curl http://localhost:3001/api/decisions/dec_abc123
|
||||
```
|
||||
|
||||
### 4. Update an Assumption
|
||||
### 4. Review Governance Decision Traces
|
||||
|
||||
Policy evaluation, tool-policy validation, agent permission checks, agent
|
||||
routing, and workflow gates record redacted decision traces alongside agent
|
||||
decision records.
|
||||
|
||||
```bash
|
||||
# List blocked governance traces
|
||||
curl "http://localhost:3001/api/governance/traces?outcome=blocked"
|
||||
|
||||
# Inspect one trace
|
||||
curl http://localhost:3001/api/governance/traces/govtrace_1760000000000_ab12cd
|
||||
```
|
||||
|
||||
Use these traces when a policy blocks an action, a tool is denied, an agent lacks
|
||||
permission, routing falls back to the default agent, or a workflow gate stops a
|
||||
run. The Decision Audit Trail UI has a Governance Traces mode for the same
|
||||
records.
|
||||
|
||||
### 5. Update an Assumption
|
||||
|
||||
After the outcome is known, mark individual assumptions as held or not held:
|
||||
|
||||
|
|
@ -95,18 +114,20 @@ curl -X PATCH http://localhost:3001/api/decisions/dec_abc123/assumptions/1 \
|
|||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------- | ------------------------------------------ | ------------------------------------- |
|
||||
| ------- | ------------------------------------- | ------------------------------------- |
|
||||
| `GET` | `/api/decisions` | List decisions (filterable) |
|
||||
| `POST` | `/api/decisions` | Log a new decision |
|
||||
| `GET` | `/api/decisions/:id` | Get a single decision |
|
||||
| `PATCH` | `/api/decisions/:id/assumptions/:idx` | Update a specific assumption by index |
|
||||
| `GET` | `/api/governance/traces` | List governance decision traces |
|
||||
| `GET` | `/api/governance/traces/:id` | Get one governance decision trace |
|
||||
|
||||
---
|
||||
|
||||
## Decision Object Schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | -------- | -------- | --------------------------------------------------- |
|
||||
| ------------- | -------- | -------- | ----------------------------------- |
|
||||
| `decision` | string | ✅ | The decision made |
|
||||
| `confidence` | number | ✅ | 0–1 confidence score |
|
||||
| `reasoning` | string | ❌ | Why this decision was made |
|
||||
|
|
@ -120,7 +141,7 @@ curl -X PATCH http://localhost:3001/api/decisions/dec_abc123/assumptions/1 \
|
|||
## Query Parameters (List)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --------------- | ------ | -------------------------------------------- |
|
||||
| --------------- | ------ | ----------------------------------------- |
|
||||
| `agent` | string | Filter by agent name |
|
||||
| `taskId` | string | Filter by task ID |
|
||||
| `minConfidence` | number | Minimum confidence score (0–1) |
|
||||
|
|
|
|||
|
|
@ -1011,14 +1011,16 @@ telemetry NDJSON files.
|
|||
|
||||
## Governance Repository Implementation
|
||||
|
||||
The first governance repository pass moves JSON-backed decisions, feedback,
|
||||
scoring, and drift data into SQLite document tables with indexed query columns.
|
||||
The domain services keep their existing APIs, validation, and analytics logic;
|
||||
only the persistence backend switches when `VERITAS_STORAGE=sqlite`.
|
||||
The first governance repository pass moves JSON-backed decisions, governance
|
||||
decision traces, feedback, scoring, and drift data into SQLite document tables
|
||||
with indexed query columns. The domain services keep their existing APIs,
|
||||
validation, and analytics logic; only the persistence backend switches when
|
||||
`VERITAS_STORAGE=sqlite`.
|
||||
|
||||
| Runtime table | Stored data |
|
||||
| --------------------- | -------------------------------------------------------------------------------------- |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `decision_records` | Complete `DecisionRecord` JSON plus agent, task, parent, confidence, and risk columns. |
|
||||
| `governance_decision_traces` | Complete redacted governance trace JSON plus kind, outcome, agent, task, and action columns. |
|
||||
| `feedback_records` | Complete `Feedback` JSON plus task, agent, rating, sentiment, and resolved columns. |
|
||||
| `scoring_profiles` | Complete `ScoringProfile` JSON plus name and built-in metadata. |
|
||||
| `scoring_evaluations` | Complete `EvaluationResult` JSON plus profile, agent, task, and score columns. |
|
||||
|
|
@ -1244,6 +1246,7 @@ Manual recovery is intentionally boring:
|
|||
| `.veritas-kanban/workflow-runs/*/progress.md` | `workflow_run_outputs` or retained file path | Store text content when reasonably sized; otherwise keep file path. |
|
||||
| `.veritas-kanban/workflow-runs/*/step-outputs/*` | `workflow_run_outputs` | Preserve output filename and content type. |
|
||||
| `.veritas-kanban/tool-policies/*.json` | `tool_policies` | Role remains the natural unique key per workspace. |
|
||||
| `.veritas-kanban/governance-traces/*.json` | `governance_decision_traces` | Preserve redacted policy, permission, routing, and workflow gate explanations. |
|
||||
| `.veritas-kanban/storage/policies/*.json` | `policy_profiles` | Keep full rules JSON until policy schema is formalized. |
|
||||
| `.veritas-kanban/storage/drift/alerts/*` | `drift_alerts` | Preserve raw payload. |
|
||||
| `.veritas-kanban/storage/drift/baselines/*` | `drift_baselines` | Preserve raw payload. |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ operator checklist for final release verification.
|
|||
password sessions must become persisted per-user sessions before
|
||||
multi-user/server-mode GA, or the release must explicitly limit password
|
||||
sessions to single-owner local deployments.
|
||||
- [ ] Governance decision traces verify policy, tool-policy, agent-permission,
|
||||
routing, and workflow-gate decisions include matched rules, remediation,
|
||||
redacted raw detail, API access through `/api/governance/traces`, and UI
|
||||
drilldown from the Decision Audit Trail.
|
||||
- [ ] Performance/load review covers SQLite read/write paths, dashboard queries,
|
||||
WebSocket fan-out, workflow run updates, and remote/mobile clients. Track
|
||||
evidence and limits in
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentRoutingService } from '../services/agent-routing-service';
|
||||
import type { AgentRoutingConfig, AppConfig, Task } from '@veritas-kanban/shared';
|
||||
import type { AgentRoutingConfig, AppConfig } from '@veritas-kanban/shared';
|
||||
|
||||
// Mock ConfigService
|
||||
const mockGetConfig = vi.fn();
|
||||
|
|
@ -91,6 +91,31 @@ describe('AgentRoutingService', () => {
|
|||
expect(result.reason).toContain('High-priority code');
|
||||
});
|
||||
|
||||
it('returns a governance trace with evaluated and matched routing rules', async () => {
|
||||
const result = await service.resolveAgentWithTrace(
|
||||
{
|
||||
type: 'code',
|
||||
priority: 'high',
|
||||
project: 'core',
|
||||
},
|
||||
{ taskId: 'task_1' }
|
||||
);
|
||||
|
||||
expect(result.result.rule).toBe('code-high');
|
||||
expect(result.trace).toMatchObject({
|
||||
kind: 'routing',
|
||||
outcome: 'routed',
|
||||
subject: {
|
||||
agentId: 'claude-code',
|
||||
taskId: 'task_1',
|
||||
actionType: 'agent.route',
|
||||
project: 'core',
|
||||
},
|
||||
});
|
||||
expect(result.trace.evaluatedRules?.map((rule) => rule.id)).toContain('routing:code-high');
|
||||
expect(result.trace.matchedRules?.map((rule) => rule.id)).toEqual(['routing:code-high']);
|
||||
});
|
||||
|
||||
it('matches medium-priority code task to second rule', async () => {
|
||||
const result = await service.resolveAgent({
|
||||
type: 'code',
|
||||
|
|
|
|||
66
server/src/__tests__/governance-trace-service.test.ts
Normal file
66
server/src/__tests__/governance-trace-service.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { GovernanceTraceService } from '../services/governance-trace-service.js';
|
||||
|
||||
describe('GovernanceTraceService', () => {
|
||||
let testRoot: string;
|
||||
let tracesDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-governance-traces-'));
|
||||
tracesDir = path.join(testRoot, 'traces');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('records redacted file-backed traces and filters the list', async () => {
|
||||
const service = new GovernanceTraceService({ tracesDir, storageType: 'file' });
|
||||
const secret = 'sk_live_1234567890abcdef';
|
||||
const localPath = '/Users/bradgroux/Projects/veritas-kanban/.env';
|
||||
|
||||
const blocked = await service.record({
|
||||
kind: 'policy',
|
||||
outcome: 'blocked',
|
||||
title: 'Policy decision',
|
||||
summary: `Blocked ${secret} from ${localPath}`,
|
||||
subject: { agentId: 'codex', taskId: 'task-1', actionType: 'git.push' },
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: 'policy:risk',
|
||||
label: 'Risk gate',
|
||||
type: 'policy',
|
||||
status: 'matched',
|
||||
outcome: 'blocked',
|
||||
message: `Matched token ${secret}`,
|
||||
},
|
||||
],
|
||||
matchedRules: [],
|
||||
steps: [],
|
||||
raw: { secret, localPath },
|
||||
createdAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
await service.record({
|
||||
kind: 'routing',
|
||||
outcome: 'routed',
|
||||
title: 'Routing decision',
|
||||
summary: 'Selected codex.',
|
||||
subject: { agentId: 'codex', actionType: 'agent.route' },
|
||||
createdAt: '2026-06-01T13:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(JSON.stringify(blocked)).not.toContain(secret);
|
||||
expect(JSON.stringify(blocked)).not.toContain(localPath);
|
||||
expect(blocked.redacted).toBe(true);
|
||||
|
||||
const policyTraces = await service.list({ kind: 'policy', agent: 'codex' });
|
||||
expect(policyTraces.map((trace) => trace.id)).toEqual([blocked.id]);
|
||||
await expect(service.get(blocked.id)).resolves.toMatchObject({
|
||||
id: blocked.id,
|
||||
outcome: 'blocked',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ const {
|
|||
mockToolPolicyService,
|
||||
mockAgentPermissionService,
|
||||
mockAgentRoutingService,
|
||||
mockGovernanceTraceService,
|
||||
mockTaskService,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPolicyService: {
|
||||
|
|
@ -15,6 +16,7 @@ const {
|
|||
updatePolicy: vi.fn(),
|
||||
deletePolicy: vi.fn(),
|
||||
evaluatePolicies: vi.fn(),
|
||||
evaluatePoliciesWithTrace: vi.fn(),
|
||||
},
|
||||
mockToolPolicyService: {
|
||||
listPolicies: vi.fn(),
|
||||
|
|
@ -22,6 +24,7 @@ const {
|
|||
savePolicy: vi.fn(),
|
||||
deletePolicy: vi.fn(),
|
||||
validateToolAccess: vi.fn(),
|
||||
validateToolAccessWithTrace: vi.fn(),
|
||||
},
|
||||
mockAgentPermissionService: {
|
||||
listPermissions: vi.fn(),
|
||||
|
|
@ -30,14 +33,19 @@ const {
|
|||
setLevel: vi.fn(),
|
||||
updatePermissions: vi.fn(),
|
||||
checkPermission: vi.fn(),
|
||||
checkPermissionWithTrace: vi.fn(),
|
||||
requestApproval: vi.fn(),
|
||||
reviewApproval: vi.fn(),
|
||||
},
|
||||
mockAgentRoutingService: {
|
||||
resolveAgent: vi.fn(),
|
||||
resolveAgentWithTrace: vi.fn(),
|
||||
getRoutingConfig: vi.fn(),
|
||||
updateRoutingConfig: vi.fn(),
|
||||
},
|
||||
mockGovernanceTraceService: {
|
||||
record: vi.fn(),
|
||||
},
|
||||
mockTaskService: {
|
||||
getTask: vi.fn(),
|
||||
},
|
||||
|
|
@ -69,6 +77,10 @@ vi.mock('../../services/agent-routing-service.js', () => ({
|
|||
getAgentRoutingService: () => mockAgentRoutingService,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/governance-trace-service.js', () => ({
|
||||
getGovernanceTraceService: () => mockGovernanceTraceService,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/task-service.js', () => ({
|
||||
getTaskService: () => mockTaskService,
|
||||
}));
|
||||
|
|
@ -146,12 +158,30 @@ describe('admin-only governance routes', () => {
|
|||
mockPolicyService.updatePolicy.mockResolvedValue(policyBody);
|
||||
mockPolicyService.deletePolicy.mockResolvedValue(undefined);
|
||||
mockPolicyService.evaluatePolicies.mockResolvedValue({ decision: 'allow', matches: [] });
|
||||
mockPolicyService.evaluatePoliciesWithTrace.mockResolvedValue({
|
||||
result: { decision: 'allow', matches: [] },
|
||||
trace: {
|
||||
kind: 'policy',
|
||||
outcome: 'allowed',
|
||||
title: 'Policy evaluation',
|
||||
summary: 'Allowed.',
|
||||
},
|
||||
});
|
||||
|
||||
mockToolPolicyService.listPolicies.mockResolvedValue([toolPolicyBody]);
|
||||
mockToolPolicyService.getToolPolicy.mockResolvedValue(toolPolicyBody);
|
||||
mockToolPolicyService.savePolicy.mockResolvedValue(undefined);
|
||||
mockToolPolicyService.deletePolicy.mockResolvedValue(undefined);
|
||||
mockToolPolicyService.validateToolAccess.mockResolvedValue(true);
|
||||
mockToolPolicyService.validateToolAccessWithTrace.mockResolvedValue({
|
||||
allowed: true,
|
||||
trace: {
|
||||
kind: 'tool-policy',
|
||||
outcome: 'allowed',
|
||||
title: 'Tool policy',
|
||||
summary: 'Allowed.',
|
||||
},
|
||||
});
|
||||
|
||||
mockAgentPermissionService.setLevel.mockResolvedValue({ agentId: 'a1', level: 'lead' });
|
||||
mockAgentPermissionService.updatePermissions.mockResolvedValue({
|
||||
|
|
@ -172,7 +202,17 @@ describe('admin-only governance routes', () => {
|
|||
});
|
||||
|
||||
mockAgentRoutingService.resolveAgent.mockResolvedValue({ agent: 'codex' });
|
||||
mockAgentRoutingService.resolveAgentWithTrace.mockResolvedValue({
|
||||
result: { agent: 'codex' },
|
||||
trace: {
|
||||
kind: 'routing',
|
||||
outcome: 'routed',
|
||||
title: 'Agent routing',
|
||||
summary: 'Routed.',
|
||||
},
|
||||
});
|
||||
mockAgentRoutingService.updateRoutingConfig.mockResolvedValue(routingConfigBody);
|
||||
mockGovernanceTraceService.record.mockResolvedValue({ id: 'govtrace_1' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -215,7 +255,7 @@ describe('admin-only governance routes', () => {
|
|||
.send({ actionType: 'create_task', riskScore: 10 })
|
||||
.expect(200);
|
||||
|
||||
expect(mockPolicyService.evaluatePolicies).toHaveBeenCalledWith({
|
||||
expect(mockPolicyService.evaluatePoliciesWithTrace).toHaveBeenCalledWith({
|
||||
actionType: 'create_task',
|
||||
riskScore: 10,
|
||||
preview: false,
|
||||
|
|
@ -249,6 +289,7 @@ describe('admin-only governance routes', () => {
|
|||
expect(mockToolPolicyService.savePolicy).not.toHaveBeenCalled();
|
||||
expect(mockToolPolicyService.deletePolicy).not.toHaveBeenCalled();
|
||||
expect(mockToolPolicyService.validateToolAccess).not.toHaveBeenCalled();
|
||||
expect(mockToolPolicyService.validateToolAccessWithTrace).not.toHaveBeenCalled();
|
||||
|
||||
await request(app)
|
||||
.put('/api/tool-policies/intern')
|
||||
|
|
@ -335,7 +376,7 @@ describe('admin-only governance routes', () => {
|
|||
.send({ type: 'feature', priority: 'medium' })
|
||||
.expect(200);
|
||||
|
||||
expect(mockAgentRoutingService.resolveAgent).toHaveBeenCalledWith({
|
||||
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith({
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
project: undefined,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,44 @@ describe('PolicyService', () => {
|
|||
expect(result.blockedBy).toContain('block-force-push');
|
||||
});
|
||||
|
||||
it('explains policy evaluations with a redacted governance trace payload', async () => {
|
||||
await service.createPolicy({
|
||||
id: 'block-deploy',
|
||||
name: 'Block Deploy',
|
||||
type: 'block-action-type',
|
||||
enabled: true,
|
||||
scope: {
|
||||
agents: ['codex'],
|
||||
projects: ['core'],
|
||||
actionTypes: [],
|
||||
},
|
||||
responseAction: 'block',
|
||||
config: {
|
||||
actionTypes: ['deploy.production'],
|
||||
},
|
||||
});
|
||||
|
||||
const evaluation = await service.evaluatePoliciesWithTrace({
|
||||
agent: 'codex',
|
||||
project: 'core',
|
||||
actionType: 'deploy.production',
|
||||
metadata: { path: '/Users/bradgroux/Projects/veritas-kanban/.env' },
|
||||
});
|
||||
|
||||
expect(evaluation.result.decision).toBe('block');
|
||||
expect(evaluation.trace).toMatchObject({
|
||||
kind: 'policy',
|
||||
outcome: 'blocked',
|
||||
subject: {
|
||||
agentId: 'codex',
|
||||
project: 'core',
|
||||
actionType: 'deploy.production',
|
||||
},
|
||||
});
|
||||
expect(evaluation.trace.matchedRules?.map((rule) => rule.id)).toContain('block-deploy');
|
||||
expect(evaluation.trace.remediation).toContain('Change the action');
|
||||
});
|
||||
|
||||
it('updates and deletes a policy', async () => {
|
||||
const created = await service.createPolicy({
|
||||
id: 'manual-approval',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { AnyTelemetryEvent } from '@veritas-kanban/shared';
|
|||
import { DecisionService } from '../../services/decision-service.js';
|
||||
import { DriftService } from '../../services/drift-service.js';
|
||||
import { FeedbackService } from '../../services/feedback-service.js';
|
||||
import { GovernanceTraceService } from '../../services/governance-trace-service.js';
|
||||
import { ScoringService } from '../../services/scoring-service.js';
|
||||
import {
|
||||
createTestSqliteDatabase,
|
||||
|
|
@ -308,4 +309,42 @@ describe('SQLite governance repositories', () => {
|
|||
await expect(fs.access(alertsDir)).rejects.toThrow();
|
||||
await expect(fs.access(baselinesDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stores governance decision traces in SQLite', async () => {
|
||||
const tracesDir = path.join(testRoot, 'storage', 'governance-traces');
|
||||
const service = new GovernanceTraceService({
|
||||
tracesDir,
|
||||
storageType: 'sqlite',
|
||||
sqliteDatabase: fixture.database,
|
||||
});
|
||||
|
||||
const allowed = await service.record({
|
||||
kind: 'agent-permission',
|
||||
outcome: 'allowed',
|
||||
title: 'Agent permission',
|
||||
summary: 'Specialist can complete task.',
|
||||
subject: { agentId: 'codex', taskId: 'task_1', actionType: 'complete_task' },
|
||||
createdAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
const blocked = await service.record({
|
||||
kind: 'policy',
|
||||
outcome: 'blocked',
|
||||
title: 'Policy block',
|
||||
summary: 'Production deploy requires approval.',
|
||||
subject: { agentId: 'codex', taskId: 'task_2', actionType: 'git.push' },
|
||||
createdAt: '2026-06-01T13:00:00.000Z',
|
||||
});
|
||||
|
||||
expect((await service.list({ agent: 'codex' })).map((trace) => trace.id)).toEqual([
|
||||
blocked.id,
|
||||
allowed.id,
|
||||
]);
|
||||
expect((await service.list({ kind: 'policy' })).map((trace) => trace.id)).toEqual([blocked.id]);
|
||||
await expect(service.get(blocked.id)).resolves.toMatchObject({
|
||||
id: blocked.id,
|
||||
outcome: 'blocked',
|
||||
subject: { taskId: 'task_2' },
|
||||
});
|
||||
await expect(fs.access(tracesDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import path from 'path';
|
|||
const mockRunStreamed = vi.fn();
|
||||
const mockStartThread = vi.fn();
|
||||
const mockResumeThread = vi.fn();
|
||||
const mockRecordGovernanceTrace = vi.fn();
|
||||
|
||||
vi.mock('@openai/codex-sdk', () => ({
|
||||
Codex: class {
|
||||
|
|
@ -20,6 +21,12 @@ vi.mock('../services/tool-policy-service.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../services/governance-trace-service.js', () => ({
|
||||
getGovernanceTraceService: () => ({
|
||||
record: mockRecordGovernanceTrace,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { WorkflowStepExecutor } from '../services/workflow-step-executor.js';
|
||||
import type { WorkflowRun, WorkflowStep } from '../types/workflow.js';
|
||||
|
||||
|
|
@ -49,6 +56,7 @@ describe('WorkflowStepExecutor Codex integration', () => {
|
|||
mockRunStreamed.mockResolvedValue({ events: codexEvents() });
|
||||
mockStartThread.mockReturnValue({ runStreamed: mockRunStreamed });
|
||||
mockResumeThread.mockReturnValue({ runStreamed: mockRunStreamed });
|
||||
mockRecordGovernanceTrace.mockResolvedValue({ id: 'govtrace_1' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -109,4 +117,45 @@ describe('WorkflowStepExecutor Codex integration', () => {
|
|||
expect(result.output).toContain('Implemented workflow Codex step.');
|
||||
expect(result.outputPath).toContain('implement.md');
|
||||
});
|
||||
|
||||
it('records governance traces for workflow gate decisions', async () => {
|
||||
const executor = new WorkflowStepExecutor(tmpDir);
|
||||
const step: WorkflowStep = {
|
||||
id: 'approval-gate',
|
||||
name: 'Approval Gate',
|
||||
type: 'gate',
|
||||
condition: '{{review.decision == "approved"}}',
|
||||
on_false: {
|
||||
escalate_to: 'human',
|
||||
escalate_message: 'Review approval is required',
|
||||
},
|
||||
};
|
||||
const run: WorkflowRun = {
|
||||
id: 'run_1234567890_gate',
|
||||
workflowId: 'wf-gates',
|
||||
workflowVersion: 1,
|
||||
taskId: 'task_1',
|
||||
status: 'running',
|
||||
context: {
|
||||
review: { decision: 'pending' },
|
||||
},
|
||||
startedAt: new Date().toISOString(),
|
||||
steps: [{ stepId: 'approval-gate', status: 'running', retries: 0 }],
|
||||
};
|
||||
|
||||
await expect(executor.executeStep(step, run)).rejects.toThrow('Review approval is required');
|
||||
expect(mockRecordGovernanceTrace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'workflow-gate',
|
||||
outcome: 'approval-required',
|
||||
subject: expect.objectContaining({
|
||||
workflowId: 'wf-gates',
|
||||
runId: 'run_1234567890_gate',
|
||||
taskId: 'task_1',
|
||||
stepId: 'approval-gate',
|
||||
actionType: 'workflow.gate',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getAgentPermissionService } from '../services/agent-permission-service.js';
|
||||
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError } from '../middleware/error-handler.js';
|
||||
import { authorize } from '../middleware/auth.js';
|
||||
|
|
@ -109,8 +110,9 @@ router.post(
|
|||
});
|
||||
const { agentId, action } = schema.parse(req.body);
|
||||
const service = getAgentPermissionService();
|
||||
const result = await service.checkPermission(agentId, action);
|
||||
res.json(result);
|
||||
const result = await service.checkPermissionWithTrace(agentId, action);
|
||||
const trace = await getGovernanceTraceService().record(result.trace);
|
||||
res.json({ ...result.result, traceId: trace.id });
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getAgentRoutingService } from '../services/agent-routing-service.js';
|
||||
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
|
||||
import { getTaskService } from '../services/task-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
|
|
@ -79,15 +80,16 @@ router.post(
|
|||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
const result = await routing.resolveAgent(task);
|
||||
return res.json(result);
|
||||
const result = await routing.resolveAgentWithTrace(task, { taskId: taskIdParse.data.taskId });
|
||||
const trace = await getGovernanceTraceService().record(result.trace);
|
||||
return res.json({ ...result.result, traceId: trace.id });
|
||||
}
|
||||
|
||||
// Fall back to metadata
|
||||
const metaParse = routeByMetadataSchema.safeParse(req.body);
|
||||
if (metaParse.success) {
|
||||
const { type, priority, project, subtaskCount } = metaParse.data;
|
||||
const result = await routing.resolveAgent({
|
||||
const result = await routing.resolveAgentWithTrace({
|
||||
type: type || 'feature',
|
||||
priority: priority || 'medium',
|
||||
project,
|
||||
|
|
@ -100,7 +102,8 @@ router.post(
|
|||
}))
|
||||
: undefined,
|
||||
});
|
||||
return res.json(result);
|
||||
const trace = await getGovernanceTraceService().record(result.trace);
|
||||
return res.json({ ...result.result, traceId: trace.id });
|
||||
}
|
||||
|
||||
throw new ValidationError('Provide either { taskId } or { type, priority, ... }');
|
||||
|
|
|
|||
51
server/src/routes/governance-traces.ts
Normal file
51
server/src/routes/governance-traces.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { validate, type ValidatedRequest } from '../middleware/validate.js';
|
||||
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
const traceListQuerySchema = z.object({
|
||||
kind: z
|
||||
.enum(['policy', 'tool-policy', 'agent-permission', 'routing', 'workflow-gate'])
|
||||
.optional(),
|
||||
outcome: z
|
||||
.enum(['allowed', 'warned', 'blocked', 'approval-required', 'routed', 'fallback', 'skipped'])
|
||||
.optional(),
|
||||
agent: z.string().optional(),
|
||||
taskId: z.string().optional(),
|
||||
actionType: z.string().optional(),
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
const traceIdParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
type TraceListQuery = z.infer<typeof traceListQuerySchema>;
|
||||
type TraceIdParams = z.infer<typeof traceIdParamsSchema>;
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
validate({ query: traceListQuerySchema }),
|
||||
asyncHandler(async (req: ValidatedRequest<unknown, TraceListQuery>, res) => {
|
||||
const query = req.validated.query as TraceListQuery | undefined;
|
||||
const traces = await getGovernanceTraceService().list(query);
|
||||
res.json(traces);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
validate({ params: traceIdParamsSchema }),
|
||||
asyncHandler(async (req: ValidatedRequest<TraceIdParams>, res) => {
|
||||
const params = req.validated.params as TraceIdParams;
|
||||
const trace = await getGovernanceTraceService().get(params.id);
|
||||
res.json(trace);
|
||||
})
|
||||
);
|
||||
|
||||
export { router as governanceTraceRoutes };
|
||||
|
|
@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||
import type { AgentPolicy, PolicyEvaluationRequest } from '@veritas-kanban/shared';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { getPolicyService } from '../services/policy-service.js';
|
||||
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
|
||||
import {
|
||||
policyEvaluationSchema,
|
||||
policyParamsSchema,
|
||||
|
|
@ -57,10 +58,11 @@ router.post(
|
|||
'/evaluate',
|
||||
validate({ body: policyEvaluationSchema }),
|
||||
asyncHandler(async (req: ValidatedRequest<unknown, unknown, PolicyEvaluationRequest>, res) => {
|
||||
const result = await policyService.evaluatePolicies(
|
||||
const evaluation = await policyService.evaluatePoliciesWithTrace(
|
||||
req.validated.body as PolicyEvaluationRequest
|
||||
);
|
||||
res.json(result);
|
||||
const trace = await getGovernanceTraceService().record(evaluation.trace);
|
||||
res.json({ ...evaluation.result, traceId: trace.id });
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getToolPolicyService } from '../services/tool-policy-service.js';
|
||||
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { authorize } from '../middleware/auth.js';
|
||||
|
||||
|
|
@ -143,8 +144,9 @@ router.post(
|
|||
asyncHandler(async (req, res) => {
|
||||
const { role } = RoleParamSchema.parse(req.params);
|
||||
const { tool } = z.object({ tool: z.string().min(1) }).parse(req.body);
|
||||
const allowed = await toolPolicyService.validateToolAccess(role, tool);
|
||||
res.json({ role, tool, allowed });
|
||||
const result = await toolPolicyService.validateToolAccessWithTrace(role, tool);
|
||||
const trace = await getGovernanceTraceService().record(result.trace);
|
||||
res.json({ role, tool, allowed: result.allowed, traceId: trace.id });
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ import { integrationsRoutes } from '../integrations.js';
|
|||
import { systemHealthRouter } from '../system-health.js';
|
||||
import { transcriptRoutes } from '../transcripts.js';
|
||||
import { decisionRoutes } from '../decisions.js';
|
||||
import { governanceTraceRoutes } from '../governance-traces.js';
|
||||
import { scoringRoutes } from '../scoring.js';
|
||||
import { feedbackRoutes } from '../feedback.js';
|
||||
import promptRegistryRoutes from '../prompt-registry.js';
|
||||
|
|
@ -217,6 +218,7 @@ v1Router.use('/transcripts', transcriptAccess, transcriptRoutes);
|
|||
v1Router.use('/scoring', scoringAccess, scoringRoutes);
|
||||
v1Router.use('/system/health', workspaceAccess, systemHealthRouter);
|
||||
v1Router.use('/decisions', taskAccess, decisionRoutes);
|
||||
v1Router.use('/governance/traces', policyAccess, governanceTraceRoutes);
|
||||
v1Router.use('/feedback', feedbackAccess, feedbackRoutes);
|
||||
v1Router.use('/prompt-registry', promptRegistryAccess, promptRegistryRoutes);
|
||||
v1Router.use('/sqlite', backupAccess, sqlitePortabilityRoutes);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { createLogger } from '../lib/logger.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { CreateGovernanceTraceInput } from '@veritas-kanban/shared';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import { migrateLegacyFiles } from '../utils/migrate-legacy-files.js';
|
||||
const DATA_DIR = getRuntimeDir();
|
||||
|
|
@ -230,47 +231,83 @@ class AgentPermissionService {
|
|||
allowed: boolean;
|
||||
reason?: string;
|
||||
requiresApproval?: boolean;
|
||||
}> {
|
||||
return (await this.checkPermissionWithTrace(agentId, action)).result;
|
||||
}
|
||||
|
||||
async checkPermissionWithTrace(
|
||||
agentId: string,
|
||||
action: string
|
||||
): Promise<{
|
||||
result: {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
requiresApproval?: boolean;
|
||||
};
|
||||
trace: CreateGovernanceTraceInput;
|
||||
}> {
|
||||
const config = await this.getPermissions(agentId);
|
||||
let result: {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
requiresApproval?: boolean;
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'create_task':
|
||||
return config.canCreateTasks
|
||||
result = config.canCreateTasks
|
||||
? { allowed: true }
|
||||
: { allowed: false, reason: 'Intern agents cannot create tasks', requiresApproval: true };
|
||||
break;
|
||||
|
||||
case 'delegate':
|
||||
return config.canDelegate
|
||||
result = config.canDelegate
|
||||
? { allowed: true }
|
||||
: { allowed: false, reason: 'Only lead agents can delegate', requiresApproval: true };
|
||||
break;
|
||||
|
||||
case 'approve':
|
||||
return config.canApprove
|
||||
result = config.canApprove
|
||||
? { allowed: true }
|
||||
: { allowed: false, reason: 'Only lead agents can approve work' };
|
||||
break;
|
||||
|
||||
case 'complete_task':
|
||||
if (config.autoComplete) {
|
||||
return { allowed: true };
|
||||
}
|
||||
return {
|
||||
result = { allowed: true };
|
||||
} else {
|
||||
result = {
|
||||
allowed: true,
|
||||
reason: 'Task will go to review instead of done',
|
||||
requiresApproval: false,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete_task':
|
||||
return config.level === 'lead'
|
||||
result =
|
||||
config.level === 'lead'
|
||||
? { allowed: true }
|
||||
: { allowed: false, reason: 'Only lead agents can delete tasks', requiresApproval: true };
|
||||
: {
|
||||
allowed: false,
|
||||
reason: 'Only lead agents can delete tasks',
|
||||
requiresApproval: true,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
// Check custom restrictions
|
||||
if (config.restrictions?.some((r) => action.includes(r))) {
|
||||
return { allowed: false, reason: `Action restricted for ${config.level} agents` };
|
||||
result = { allowed: false, reason: `Action restricted for ${config.level} agents` };
|
||||
} else {
|
||||
result = { allowed: true };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
result,
|
||||
trace: this.buildPermissionTrace(config, action, result),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -340,6 +377,78 @@ class AgentPermissionService {
|
|||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
private buildPermissionTrace(
|
||||
config: AgentPermissionConfig,
|
||||
action: string,
|
||||
result: { allowed: boolean; reason?: string; requiresApproval?: boolean }
|
||||
): CreateGovernanceTraceInput {
|
||||
const outcome: CreateGovernanceTraceInput['outcome'] = result.allowed
|
||||
? result.requiresApproval
|
||||
? 'approval-required'
|
||||
: 'allowed'
|
||||
: result.requiresApproval
|
||||
? 'approval-required'
|
||||
: 'blocked';
|
||||
|
||||
return {
|
||||
kind: 'agent-permission',
|
||||
outcome,
|
||||
title: `Agent permission: ${config.agentId} -> ${action}`,
|
||||
summary: result.allowed
|
||||
? result.reason || `${config.agentId} can perform ${action}.`
|
||||
: result.reason || `${config.agentId} cannot perform ${action}.`,
|
||||
remediation: result.allowed
|
||||
? undefined
|
||||
: result.requiresApproval
|
||||
? 'Request approval from a lead agent or promote the agent permission level.'
|
||||
: 'Change the agent level, remove a restriction, or delegate the action to an authorized agent.',
|
||||
subject: { agentId: config.agentId, actionType: action },
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: `agent-permission:${config.level}`,
|
||||
label: `${config.level} permissions`,
|
||||
type: 'agent-permission',
|
||||
status: 'matched',
|
||||
outcome,
|
||||
message: result.reason || `${config.level} permissions evaluated for ${action}.`,
|
||||
details: {
|
||||
level: config.level,
|
||||
canCreateTasks: config.canCreateTasks,
|
||||
canDelegate: config.canDelegate,
|
||||
canApprove: config.canApprove,
|
||||
autoComplete: config.autoComplete,
|
||||
restrictions: config.restrictions ?? [],
|
||||
},
|
||||
},
|
||||
],
|
||||
matchedRules: [
|
||||
{
|
||||
id: `agent-permission:${config.level}`,
|
||||
label: `${config.level} permissions`,
|
||||
type: 'agent-permission',
|
||||
status: 'matched',
|
||||
outcome,
|
||||
message: result.reason || `${config.level} permissions evaluated for ${action}.`,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'level',
|
||||
label: 'Permission level',
|
||||
status: 'info',
|
||||
message: `${config.agentId} is configured as ${config.level}.`,
|
||||
},
|
||||
{
|
||||
id: 'outcome',
|
||||
label: 'Outcome',
|
||||
status: result.allowed ? 'matched' : 'not-matched',
|
||||
message: result.reason || (result.allowed ? 'Action allowed.' : 'Action blocked.'),
|
||||
},
|
||||
],
|
||||
raw: { config, action, result },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
import { ConfigService } from './config-service.js';
|
||||
import {
|
||||
DEFAULT_ROUTING_CONFIG,
|
||||
type CreateGovernanceTraceInput,
|
||||
type GovernanceTraceRule,
|
||||
type GovernanceTraceStep,
|
||||
type AgentRoutingConfig,
|
||||
type RoutingRule,
|
||||
type RoutingResult,
|
||||
|
|
@ -21,6 +24,12 @@ import { createLogger } from '../lib/logger.js';
|
|||
|
||||
const log = createLogger('agent-routing');
|
||||
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
|
||||
|
||||
interface RoutingTraceContext {
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export class AgentRoutingService {
|
||||
private configService: ConfigService;
|
||||
|
||||
|
|
@ -34,24 +43,51 @@ export class AgentRoutingService {
|
|||
* @param task - Full task object (or partial with type/priority/project/subtasks)
|
||||
* @returns RoutingResult with the selected agent, optional model, fallback, and reasoning
|
||||
*/
|
||||
async resolveAgent(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>
|
||||
): Promise<RoutingResult> {
|
||||
async resolveAgent(task: RoutableTask): Promise<RoutingResult> {
|
||||
return (await this.resolveAgentWithTrace(task)).result;
|
||||
}
|
||||
|
||||
async resolveAgentWithTrace(
|
||||
task: RoutableTask,
|
||||
context: RoutingTraceContext = {}
|
||||
): Promise<{ result: RoutingResult; trace: CreateGovernanceTraceInput }> {
|
||||
const config = await this.configService.getConfig();
|
||||
const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG;
|
||||
const evaluatedRules: GovernanceTraceRule[] = [];
|
||||
const steps: GovernanceTraceStep[] = [];
|
||||
|
||||
// If routing is disabled, return the global default
|
||||
if (!routing.enabled) {
|
||||
return {
|
||||
const result: RoutingResult = {
|
||||
agent: routing.defaultAgent || config.defaultAgent,
|
||||
model: routing.defaultModel,
|
||||
reason: 'Routing disabled — using default agent',
|
||||
reason: 'Routing disabled, using default agent',
|
||||
};
|
||||
return {
|
||||
result,
|
||||
trace: this.buildRoutingTrace(task, context, result, {
|
||||
outcome: 'skipped',
|
||||
evaluatedRules,
|
||||
matchedRules: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'routing-disabled',
|
||||
label: 'Routing disabled',
|
||||
status: 'skipped',
|
||||
message: 'Agent routing is disabled in configuration.',
|
||||
},
|
||||
],
|
||||
routing,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate rules in order (first match wins)
|
||||
for (const rule of routing.rules) {
|
||||
if (!rule.enabled) continue;
|
||||
if (!rule.enabled) {
|
||||
evaluatedRules.push(this.routingRuleTrace(rule, 'skipped', 'Rule is disabled.'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.matchesRule(task, rule.match)) {
|
||||
// Verify the agent is actually configured and enabled
|
||||
|
|
@ -60,31 +96,87 @@ export class AgentRoutingService {
|
|||
a.type === rule.agent
|
||||
);
|
||||
if (!agentConfig?.enabled) {
|
||||
log.warn(`Rule "${rule.name}" matched but agent "${rule.agent}" is disabled — skipping`);
|
||||
const message = `Rule "${rule.name}" matched but agent "${rule.agent}" is disabled, skipping.`;
|
||||
log.warn(message);
|
||||
const skippedRule = this.routingRuleTrace(rule, 'matched', message, 'skipped');
|
||||
evaluatedRules.push(skippedRule);
|
||||
steps.push({
|
||||
id: `rule:${rule.id}`,
|
||||
label: rule.name,
|
||||
status: 'skipped',
|
||||
message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Task [type=${task.type}, priority=${task.priority}] matched rule "${rule.name}" → ${rule.agent}${rule.model ? ` (${rule.model})` : ''}`
|
||||
);
|
||||
return {
|
||||
const matchedRule = this.routingRuleTrace(
|
||||
rule,
|
||||
'matched',
|
||||
`Matched rule: ${rule.name}`,
|
||||
'routed'
|
||||
);
|
||||
evaluatedRules.push(matchedRule);
|
||||
const result: RoutingResult = {
|
||||
agent: rule.agent,
|
||||
model: rule.model,
|
||||
fallback: rule.fallback,
|
||||
rule: rule.id,
|
||||
reason: `Matched rule: ${rule.name}`,
|
||||
};
|
||||
return {
|
||||
result,
|
||||
trace: this.buildRoutingTrace(task, context, result, {
|
||||
outcome: 'routed',
|
||||
evaluatedRules,
|
||||
matchedRules: [matchedRule],
|
||||
steps: [
|
||||
...steps,
|
||||
{
|
||||
id: `rule:${rule.id}`,
|
||||
label: rule.name,
|
||||
status: 'matched',
|
||||
message: `Selected ${rule.agent}.`,
|
||||
},
|
||||
],
|
||||
routing,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
evaluatedRules.push(
|
||||
this.routingRuleTrace(rule, 'not-matched', 'Rule criteria did not match.')
|
||||
);
|
||||
}
|
||||
|
||||
// No rule matched — use defaults
|
||||
log.info(
|
||||
`Task [type=${task.type}, priority=${task.priority}] — no rules matched, using default: ${routing.defaultAgent}`
|
||||
);
|
||||
return {
|
||||
const result: RoutingResult = {
|
||||
agent: routing.defaultAgent || config.defaultAgent,
|
||||
model: routing.defaultModel,
|
||||
reason: 'No routing rules matched — using default agent',
|
||||
reason: 'No routing rules matched, using default agent',
|
||||
};
|
||||
return {
|
||||
result,
|
||||
trace: this.buildRoutingTrace(task, context, result, {
|
||||
outcome: 'fallback',
|
||||
evaluatedRules,
|
||||
matchedRules: [],
|
||||
steps: [
|
||||
...steps,
|
||||
{
|
||||
id: 'default-agent',
|
||||
label: 'Default agent',
|
||||
status: 'info',
|
||||
message: `Selected default agent ${result.agent}.`,
|
||||
},
|
||||
],
|
||||
routing,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +314,65 @@ export class AgentRoutingService {
|
|||
}
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
private routingRuleTrace(
|
||||
rule: RoutingRule,
|
||||
status: GovernanceTraceRule['status'],
|
||||
message: string,
|
||||
outcome?: CreateGovernanceTraceInput['outcome']
|
||||
): GovernanceTraceRule {
|
||||
return {
|
||||
id: `routing:${rule.id}`,
|
||||
label: rule.name,
|
||||
type: 'routing',
|
||||
status,
|
||||
outcome,
|
||||
message,
|
||||
details: {
|
||||
match: rule.match,
|
||||
agent: rule.agent,
|
||||
model: rule.model,
|
||||
fallback: rule.fallback,
|
||||
enabled: rule.enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildRoutingTrace(
|
||||
task: RoutableTask,
|
||||
context: RoutingTraceContext,
|
||||
result: RoutingResult,
|
||||
input: {
|
||||
outcome: CreateGovernanceTraceInput['outcome'];
|
||||
evaluatedRules: GovernanceTraceRule[];
|
||||
matchedRules: GovernanceTraceRule[];
|
||||
steps: GovernanceTraceStep[];
|
||||
routing: AgentRoutingConfig;
|
||||
}
|
||||
): CreateGovernanceTraceInput {
|
||||
return {
|
||||
kind: 'routing',
|
||||
outcome: input.outcome,
|
||||
title: `Agent routing: ${result.agent}`,
|
||||
summary: result.reason,
|
||||
remediation:
|
||||
input.outcome === 'fallback'
|
||||
? 'Add or reorder routing rules if the default agent should not handle this task.'
|
||||
: input.outcome === 'skipped'
|
||||
? 'Enable agent routing to evaluate task-aware routing rules.'
|
||||
: undefined,
|
||||
subject: {
|
||||
agentId: result.agent,
|
||||
taskId: context.taskId,
|
||||
actionType: 'agent.route',
|
||||
project: task.project,
|
||||
},
|
||||
evaluatedRules: input.evaluatedRules,
|
||||
matchedRules: input.matchedRules,
|
||||
steps: input.steps,
|
||||
raw: { task, routing: input.routing, result },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ export const DATA_LIFECYCLE_POLICIES: readonly DataLifecyclePolicy[] = [
|
|||
'activity_events',
|
||||
'status_history',
|
||||
'decision_records',
|
||||
'governance_decision_traces',
|
||||
'feedback_records',
|
||||
'scoring_profiles',
|
||||
'scoring_evaluations',
|
||||
|
|
|
|||
161
server/src/services/governance-trace-service.ts
Normal file
161
server/src/services/governance-trace-service.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
CreateGovernanceTraceInput,
|
||||
GovernanceTraceListFilters,
|
||||
GovernanceTraceRecord,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { mkdir, readFile, readdir, writeFile } from '../storage/fs-helpers.js';
|
||||
import { NotFoundError } from '../middleware/error-handler.js';
|
||||
import { redactString } from '../lib/redact.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
|
||||
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
|
||||
import { SqliteGovernanceTraceRepository } from '../storage/sqlite/governance-repositories.js';
|
||||
|
||||
export interface GovernanceTraceServiceOptions {
|
||||
tracesDir?: string;
|
||||
storageType?: 'file' | 'sqlite';
|
||||
sqliteDatabase?: SqliteDatabase;
|
||||
sqliteConnectionOptions?: SqliteConnectionOptions;
|
||||
}
|
||||
|
||||
const MAX_LIST_LIMIT = 500;
|
||||
|
||||
export class GovernanceTraceService {
|
||||
private readonly tracesDir: string;
|
||||
private readonly repository: SqliteGovernanceTraceRepository | null = null;
|
||||
private readonly sqliteDatabase: SqliteDatabase | null = null;
|
||||
private readonly ownsSqliteDatabase: boolean = false;
|
||||
|
||||
constructor(options: GovernanceTraceServiceOptions = {}) {
|
||||
this.tracesDir = options.tracesDir ?? path.join(getRuntimeDir(), 'governance-traces');
|
||||
const storageType =
|
||||
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
|
||||
|
||||
if (storageType === 'sqlite') {
|
||||
this.sqliteDatabase =
|
||||
options.sqliteDatabase ?? new SqliteDatabase(options.sqliteConnectionOptions);
|
||||
this.ownsSqliteDatabase = !options.sqliteDatabase;
|
||||
this.sqliteDatabase.open();
|
||||
this.repository = new SqliteGovernanceTraceRepository(this.sqliteDatabase);
|
||||
}
|
||||
}
|
||||
|
||||
async record(input: CreateGovernanceTraceInput): Promise<GovernanceTraceRecord> {
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
const trace: GovernanceTraceRecord = this.redactValue({
|
||||
id: `govtrace_${Date.now()}_${nanoid(6)}`,
|
||||
kind: input.kind,
|
||||
outcome: input.outcome,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
remediation: input.remediation,
|
||||
subject: input.subject ?? {},
|
||||
evaluatedRules: input.evaluatedRules ?? [],
|
||||
matchedRules: input.matchedRules ?? [],
|
||||
steps: input.steps ?? [],
|
||||
raw: input.raw,
|
||||
redacted: true,
|
||||
createdAt,
|
||||
}) as GovernanceTraceRecord;
|
||||
|
||||
if (this.repository) {
|
||||
this.repository.save(trace);
|
||||
return trace;
|
||||
}
|
||||
|
||||
await mkdir(this.tracesDir, { recursive: true });
|
||||
await writeFile(this.getTracePath(trace.id), JSON.stringify(trace, null, 2), 'utf8');
|
||||
return trace;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<GovernanceTraceRecord> {
|
||||
if (this.repository) {
|
||||
const trace = this.repository.get(id);
|
||||
if (!trace) throw new NotFoundError('Governance trace not found');
|
||||
return trace;
|
||||
}
|
||||
|
||||
const tracePath = this.getTracePath(id);
|
||||
const trace = JSON.parse(await readFile(tracePath, 'utf8')) as GovernanceTraceRecord;
|
||||
return trace;
|
||||
}
|
||||
|
||||
async list(filters: GovernanceTraceListFilters = {}): Promise<GovernanceTraceRecord[]> {
|
||||
const normalized = {
|
||||
...filters,
|
||||
limit: Math.min(Math.max(filters.limit ?? 100, 1), MAX_LIST_LIMIT),
|
||||
};
|
||||
|
||||
if (this.repository) {
|
||||
return this.repository.list(normalized);
|
||||
}
|
||||
|
||||
await mkdir(this.tracesDir, { recursive: true });
|
||||
const files = await readdir(this.tracesDir);
|
||||
const traces = await Promise.all(
|
||||
files
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map(async (file) => JSON.parse(await readFile(path.join(this.tracesDir, file), 'utf8')))
|
||||
);
|
||||
|
||||
return (traces as GovernanceTraceRecord[])
|
||||
.filter((trace) => {
|
||||
const created = Date.parse(trace.createdAt);
|
||||
const start = normalized.startTime ? Date.parse(normalized.startTime) : undefined;
|
||||
const end = normalized.endTime ? Date.parse(normalized.endTime) : undefined;
|
||||
|
||||
if (normalized.kind && trace.kind !== normalized.kind) return false;
|
||||
if (normalized.outcome && trace.outcome !== normalized.outcome) return false;
|
||||
if (normalized.agent && trace.subject.agentId !== normalized.agent) return false;
|
||||
if (normalized.taskId && trace.subject.taskId !== normalized.taskId) return false;
|
||||
if (normalized.actionType && trace.subject.actionType !== normalized.actionType) {
|
||||
return false;
|
||||
}
|
||||
if (start !== undefined && created < start) return false;
|
||||
if (end !== undefined && created > end) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.id.localeCompare(a.id))
|
||||
.slice(0, normalized.limit);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.ownsSqliteDatabase) {
|
||||
this.sqliteDatabase?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private getTracePath(id: string): string {
|
||||
validatePathSegment(id);
|
||||
return ensureWithinBase(this.tracesDir, path.join(this.tracesDir, `${id}.json`));
|
||||
}
|
||||
|
||||
private redactValue(value: unknown): unknown {
|
||||
if (typeof value === 'string') return this.redactText(value);
|
||||
if (Array.isArray(value)) return value.map((entry) => this.redactValue(entry));
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||
key,
|
||||
this.redactValue(entry),
|
||||
])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private redactText(value: string): string {
|
||||
return redactString(value)
|
||||
.replace(/\/Users\/[^/\s]+\/[^\s)]+/g, '[redacted-local-path]')
|
||||
.replace(/[A-Z]:\\Users\\[^\\\s]+\\[^\s)]+/g, '[redacted-local-path]');
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: GovernanceTraceService | null = null;
|
||||
|
||||
export function getGovernanceTraceService(): GovernanceTraceService {
|
||||
singleton ??= new GovernanceTraceService();
|
||||
return singleton;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import path from 'path';
|
||||
import type {
|
||||
AgentPolicy,
|
||||
CreateGovernanceTraceInput,
|
||||
GovernanceTraceRule,
|
||||
PolicyEvaluationMatch,
|
||||
PolicyEvaluationRequest,
|
||||
PolicyEvaluationResult,
|
||||
|
|
@ -234,26 +236,82 @@ export class PolicyService {
|
|||
}
|
||||
|
||||
async evaluatePolicies(input: PolicyEvaluationRequest): Promise<PolicyEvaluationResult> {
|
||||
return (await this.evaluatePoliciesWithTrace(input)).result;
|
||||
}
|
||||
|
||||
async evaluatePoliciesWithTrace(input: PolicyEvaluationRequest): Promise<{
|
||||
result: PolicyEvaluationResult;
|
||||
trace: CreateGovernanceTraceInput;
|
||||
}> {
|
||||
await this.waitForInit();
|
||||
|
||||
const policies = await this.listPolicies();
|
||||
const matches: PolicyEvaluationMatch[] = [];
|
||||
const evaluatedRules: GovernanceTraceRule[] = [];
|
||||
let decision: PolicyEvaluationResult['decision'] = 'allow';
|
||||
|
||||
for (const policy of policies) {
|
||||
if (!policy.enabled) continue;
|
||||
if (!this.scopeMatches(policy, input)) continue;
|
||||
if (!policy.enabled) {
|
||||
evaluatedRules.push({
|
||||
id: policy.id,
|
||||
label: policy.name,
|
||||
type: policy.type,
|
||||
status: 'skipped',
|
||||
message: `${policy.name} is disabled.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.scopeMatches(policy, input)) {
|
||||
evaluatedRules.push({
|
||||
id: policy.id,
|
||||
label: policy.name,
|
||||
type: policy.type,
|
||||
status: 'not-matched',
|
||||
message: `${policy.name} did not match the actor, project, or action scope.`,
|
||||
details: {
|
||||
scope: policy.scope,
|
||||
agent: input.agent,
|
||||
project: input.project,
|
||||
actionType: input.actionType,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const evaluation = await this.evaluatePolicy(policy, input);
|
||||
if (!evaluation) continue;
|
||||
if (!evaluation) {
|
||||
evaluatedRules.push({
|
||||
id: policy.id,
|
||||
label: policy.name,
|
||||
type: policy.type,
|
||||
status: 'not-matched',
|
||||
message: `${policy.name} was in scope but its condition did not trigger.`,
|
||||
details: {
|
||||
policyType: policy.type,
|
||||
actionType: input.actionType,
|
||||
riskScore: input.riskScore,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push(evaluation);
|
||||
evaluatedRules.push({
|
||||
id: policy.id,
|
||||
label: policy.name,
|
||||
type: policy.type,
|
||||
status: 'matched',
|
||||
outcome: this.traceOutcomeForDecision(this.toDecision(evaluation.responseAction)),
|
||||
message: evaluation.message,
|
||||
details: evaluation.details,
|
||||
});
|
||||
if (RESPONSE_PRIORITY[evaluation.responseAction] > DECISION_PRIORITY[decision]) {
|
||||
decision = this.toDecision(evaluation.responseAction);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const result: PolicyEvaluationResult = {
|
||||
decision,
|
||||
matches,
|
||||
warnings: matches
|
||||
|
|
@ -266,6 +324,44 @@ export class PolicyService {
|
|||
.filter((match) => match.responseAction === 'require-approval')
|
||||
.map((match) => match.policyId),
|
||||
};
|
||||
const outcome = this.traceOutcomeForDecision(decision);
|
||||
|
||||
return {
|
||||
result,
|
||||
trace: {
|
||||
kind: 'policy',
|
||||
outcome,
|
||||
title: `Policy evaluation: ${input.actionType}`,
|
||||
summary: this.policyTraceSummary(result),
|
||||
remediation: this.policyTraceRemediation(result),
|
||||
subject: {
|
||||
agentId: input.agent,
|
||||
actorId: input.agent,
|
||||
project: input.project,
|
||||
actionType: input.actionType,
|
||||
},
|
||||
evaluatedRules,
|
||||
matchedRules: evaluatedRules.filter((rule) => rule.status === 'matched'),
|
||||
steps: [
|
||||
{
|
||||
id: 'scope',
|
||||
label: 'Scope evaluation',
|
||||
status: evaluatedRules.some((rule) => rule.status === 'matched') ? 'matched' : 'info',
|
||||
message: `${evaluatedRules.length} policy rule(s) evaluated for ${input.actionType}.`,
|
||||
},
|
||||
{
|
||||
id: 'outcome',
|
||||
label: 'Outcome',
|
||||
status: matches.length > 0 ? 'matched' : 'info',
|
||||
message: this.policyTraceSummary(result),
|
||||
},
|
||||
],
|
||||
raw: {
|
||||
input,
|
||||
result,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPoliciesFromDisk(): Promise<void> {
|
||||
|
|
@ -553,6 +649,35 @@ export class PolicyService {
|
|||
return 'allow';
|
||||
}
|
||||
|
||||
private traceOutcomeForDecision(
|
||||
decision: PolicyEvaluationResult['decision']
|
||||
): CreateGovernanceTraceInput['outcome'] {
|
||||
if (decision === 'block') return 'blocked';
|
||||
if (decision === 'require-approval') return 'approval-required';
|
||||
if (decision === 'warn') return 'warned';
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
private policyTraceSummary(result: PolicyEvaluationResult): string {
|
||||
if (result.decision === 'allow') return 'No enabled policy blocked or warned on this action.';
|
||||
if (result.decision === 'warn') return result.warnings.join(' ') || 'Policy warning matched.';
|
||||
if (result.decision === 'require-approval') {
|
||||
return `Approval is required by ${result.approvalRequiredBy.join(', ')}.`;
|
||||
}
|
||||
return `Action is blocked by ${result.blockedBy.join(', ')}.`;
|
||||
}
|
||||
|
||||
private policyTraceRemediation(result: PolicyEvaluationResult): string | undefined {
|
||||
if (result.decision === 'allow') return undefined;
|
||||
if (result.decision === 'warn') {
|
||||
return 'Review the matched warning before continuing or adjust the policy scope.';
|
||||
}
|
||||
if (result.decision === 'require-approval') {
|
||||
return 'Request approval from an authorized reviewer or change the matched policy.';
|
||||
}
|
||||
return 'Change the action, lower the risk, or update the matched blocking policy.';
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.ownsSqliteDatabase) {
|
||||
this.sqliteDatabase?.close();
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const SQLITE_BACKUP_TABLES = [
|
|||
'status_history',
|
||||
'telemetry_events',
|
||||
'decision_records',
|
||||
'governance_decision_traces',
|
||||
'feedback_records',
|
||||
'scoring_profiles',
|
||||
'scoring_evaluations',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { CreateGovernanceTraceInput } from '@veritas-kanban/shared';
|
||||
import type { ToolPolicy } from '../types/workflow.js';
|
||||
import { ValidationError } from '../types/workflow.js';
|
||||
import { getToolPoliciesDir } from '../utils/paths.js';
|
||||
|
|
@ -365,7 +366,15 @@ export class ToolPolicyService {
|
|||
* 3. Monitor logs for "No policy found" warnings
|
||||
*/
|
||||
async validateToolAccess(role: string, tool: string): Promise<boolean> {
|
||||
return (await this.validateToolAccessWithTrace(role, tool)).allowed;
|
||||
}
|
||||
|
||||
async validateToolAccessWithTrace(
|
||||
role: string,
|
||||
tool: string
|
||||
): Promise<{ allowed: boolean; trace: CreateGovernanceTraceInput }> {
|
||||
const policy = await this.getToolPolicy(role);
|
||||
const normalizedRole = role.trim().toLowerCase();
|
||||
|
||||
if (!policy) {
|
||||
// No policy defined for this role - allow all tools (fail-open pattern)
|
||||
|
|
@ -374,19 +383,56 @@ export class ToolPolicyService {
|
|||
{ role, tool },
|
||||
'No policy found for role - allowing all tools (fail-open). Define a policy for this role to enforce restrictions.'
|
||||
);
|
||||
return true;
|
||||
return {
|
||||
allowed: true,
|
||||
trace: {
|
||||
kind: 'tool-policy',
|
||||
outcome: 'allowed',
|
||||
title: `Tool policy: ${normalizedRole} -> ${tool}`,
|
||||
summary: `No tool policy exists for ${normalizedRole}; access allowed by compatibility fallback.`,
|
||||
remediation:
|
||||
'Define a tool policy for this role to enforce explicit allow and deny lists.',
|
||||
subject: { role: normalizedRole, tool, actionType: 'tool.validate' },
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: `tool-policy:${normalizedRole}`,
|
||||
label: normalizedRole,
|
||||
type: 'tool-policy',
|
||||
status: 'skipped',
|
||||
outcome: 'allowed',
|
||||
message: 'No policy found for this role.',
|
||||
},
|
||||
],
|
||||
matchedRules: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'fallback',
|
||||
label: 'Compatibility fallback',
|
||||
status: 'info',
|
||||
message: 'Missing role policy allows the tool for backward compatibility.',
|
||||
},
|
||||
],
|
||||
raw: { role: normalizedRole, tool, policy: null, allowed: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Denied list takes precedence over allowed list
|
||||
if (policy.denied.includes(tool)) {
|
||||
log.debug({ role, tool }, 'Tool access denied by policy');
|
||||
return false;
|
||||
return {
|
||||
allowed: false,
|
||||
trace: this.buildToolTrace(policy, tool, false, 'Tool matched the denied list.'),
|
||||
};
|
||||
}
|
||||
|
||||
// Check allowed list
|
||||
// '*' means all tools allowed
|
||||
if (policy.allowed.includes('*')) {
|
||||
return true;
|
||||
return {
|
||||
allowed: true,
|
||||
trace: this.buildToolTrace(policy, tool, true, 'Role policy allows all tools.'),
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit allow
|
||||
|
|
@ -394,7 +440,15 @@ export class ToolPolicyService {
|
|||
if (!allowed) {
|
||||
log.debug({ role, tool, allowedTools: policy.allowed }, 'Tool not in allowed list');
|
||||
}
|
||||
return allowed;
|
||||
return {
|
||||
allowed,
|
||||
trace: this.buildToolTrace(
|
||||
policy,
|
||||
tool,
|
||||
allowed,
|
||||
allowed ? 'Tool matched the allowed list.' : 'Tool is not present in the allowed list.'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -476,6 +530,75 @@ export class ToolPolicyService {
|
|||
}
|
||||
}
|
||||
|
||||
private buildToolTrace(
|
||||
policy: ToolPolicy,
|
||||
tool: string,
|
||||
allowed: boolean,
|
||||
reason: string
|
||||
): CreateGovernanceTraceInput {
|
||||
const role = policy.role.trim().toLowerCase();
|
||||
const denied = policy.denied.includes(tool);
|
||||
const outcome: CreateGovernanceTraceInput['outcome'] = allowed ? 'allowed' : 'blocked';
|
||||
const matchedRules =
|
||||
allowed || denied
|
||||
? [
|
||||
{
|
||||
id: `tool-policy:${role}`,
|
||||
label: role,
|
||||
type: 'tool-policy',
|
||||
status: 'matched' as const,
|
||||
outcome,
|
||||
message: reason,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
kind: 'tool-policy',
|
||||
outcome,
|
||||
title: `Tool policy: ${role} -> ${tool}`,
|
||||
summary: allowed
|
||||
? `${tool} is allowed for role ${role}.`
|
||||
: `${tool} is denied for role ${role}.`,
|
||||
remediation: allowed
|
||||
? undefined
|
||||
: 'Choose an allowed tool, change the agent role, or update the role tool policy.',
|
||||
subject: { role, tool, actionType: 'tool.validate' },
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: `tool-policy:${role}`,
|
||||
label: role,
|
||||
type: 'tool-policy',
|
||||
status: allowed || denied ? 'matched' : 'not-matched',
|
||||
outcome,
|
||||
message: reason,
|
||||
details: {
|
||||
allowed: policy.allowed,
|
||||
denied: policy.denied,
|
||||
},
|
||||
},
|
||||
],
|
||||
matchedRules,
|
||||
steps: [
|
||||
{
|
||||
id: 'deny-precedence',
|
||||
label: 'Deny precedence',
|
||||
status: denied ? 'matched' : 'not-matched',
|
||||
message: denied
|
||||
? 'Denied tools take precedence over allowed tools.'
|
||||
: 'Tool did not match the denied list.',
|
||||
},
|
||||
{
|
||||
id: 'allow-list',
|
||||
label: 'Allow list',
|
||||
status: allowed ? 'matched' : 'not-matched',
|
||||
message: reason,
|
||||
},
|
||||
],
|
||||
raw: { role, tool, policy, allowed },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache and reload defaults (useful for tests)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from '../types/workflow.js';
|
||||
import { getWorkflowRunsDir } from '../utils/paths.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { getGovernanceTraceService } from './governance-trace-service.js';
|
||||
import { getToolPolicyService } from './tool-policy-service.js';
|
||||
|
||||
const log = createLogger('workflow-step-executor');
|
||||
|
|
@ -693,6 +694,7 @@ export class WorkflowStepExecutor {
|
|||
if (!conditionResult) {
|
||||
// Condition not met — handle on_false policy
|
||||
const policy = step.on_false;
|
||||
await this.recordWorkflowGateTrace(step, run, false, policy);
|
||||
|
||||
if (policy?.escalate_to === 'human') {
|
||||
// Block the workflow (will be handled by workflow-run-service)
|
||||
|
|
@ -703,6 +705,7 @@ export class WorkflowStepExecutor {
|
|||
}
|
||||
|
||||
// Gate passed
|
||||
await this.recordWorkflowGateTrace(step, run, true, step.on_false);
|
||||
const output = `Gate ${step.id} passed: ${step.condition}`;
|
||||
const outputPath = await this.saveStepOutput(run.id, step.id, output);
|
||||
|
||||
|
|
@ -911,6 +914,98 @@ export class WorkflowStepExecutor {
|
|||
return this.getNestedValue(context, cleaned);
|
||||
}
|
||||
|
||||
private async recordWorkflowGateTrace(
|
||||
step: WorkflowStep,
|
||||
run: WorkflowRun,
|
||||
conditionResult: boolean,
|
||||
policy?: WorkflowStep['on_false']
|
||||
): Promise<void> {
|
||||
const outcome = conditionResult
|
||||
? 'allowed'
|
||||
: policy?.escalate_to === 'human'
|
||||
? 'approval-required'
|
||||
: 'blocked';
|
||||
const message = conditionResult
|
||||
? `Gate condition passed: ${step.condition}`
|
||||
: `Gate condition failed: ${step.condition}`;
|
||||
|
||||
await getGovernanceTraceService().record({
|
||||
kind: 'workflow-gate',
|
||||
outcome,
|
||||
title: `Workflow gate: ${step.name || step.id}`,
|
||||
summary: conditionResult
|
||||
? `Workflow run ${run.id} can continue past gate ${step.id}.`
|
||||
: policy?.escalate_message || `Workflow run ${run.id} is blocked at gate ${step.id}.`,
|
||||
remediation: conditionResult
|
||||
? undefined
|
||||
: policy?.escalate_to === 'human'
|
||||
? 'Review the gate output and resume the workflow after human approval.'
|
||||
: 'Update upstream step output or revise the gate condition before retrying the workflow.',
|
||||
subject: {
|
||||
workflowId: run.workflowId,
|
||||
runId: run.id,
|
||||
taskId: run.taskId,
|
||||
stepId: step.id,
|
||||
actionType: 'workflow.gate',
|
||||
},
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: `workflow-gate:${step.id}`,
|
||||
label: step.name || step.id,
|
||||
type: 'workflow-gate',
|
||||
status: conditionResult ? 'matched' : 'not-matched',
|
||||
outcome,
|
||||
message,
|
||||
details: {
|
||||
condition: step.condition,
|
||||
onFalse: policy,
|
||||
},
|
||||
},
|
||||
],
|
||||
matchedRules: conditionResult
|
||||
? [
|
||||
{
|
||||
id: `workflow-gate:${step.id}`,
|
||||
label: step.name || step.id,
|
||||
type: 'workflow-gate',
|
||||
status: 'matched',
|
||||
outcome,
|
||||
message,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'condition',
|
||||
label: 'Condition',
|
||||
status: conditionResult ? 'matched' : 'not-matched',
|
||||
message,
|
||||
details: { expression: step.condition },
|
||||
},
|
||||
{
|
||||
id: 'on-false',
|
||||
label: 'On false policy',
|
||||
status: policy ? 'info' : 'skipped',
|
||||
message: policy
|
||||
? `Gate failure policy escalates to ${policy.escalate_to}.`
|
||||
: 'No gate failure policy configured.',
|
||||
},
|
||||
],
|
||||
raw: {
|
||||
step,
|
||||
run: {
|
||||
id: run.id,
|
||||
workflowId: run.workflowId,
|
||||
workflowVersion: run.workflowVersion,
|
||||
taskId: run.taskId,
|
||||
status: run.status,
|
||||
currentStep: run.currentStep,
|
||||
},
|
||||
result: conditionResult,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup OpenClaw session (Phase 2 tracked in #110)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type {
|
|||
EvaluationResult,
|
||||
Feedback,
|
||||
FeedbackQuery,
|
||||
GovernanceTraceListFilters,
|
||||
GovernanceTraceRecord,
|
||||
ScoringProfile,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type { SqliteDatabase } from './database.js';
|
||||
|
|
@ -18,6 +20,10 @@ interface DecisionRow {
|
|||
decision_json: string;
|
||||
}
|
||||
|
||||
interface GovernanceTraceRow {
|
||||
trace_json: string;
|
||||
}
|
||||
|
||||
interface FeedbackRow {
|
||||
feedback_json: string;
|
||||
}
|
||||
|
|
@ -145,6 +151,115 @@ export class SqliteDecisionRepository {
|
|||
}
|
||||
}
|
||||
|
||||
export class SqliteGovernanceTraceRepository {
|
||||
constructor(private readonly database: SqliteDatabase) {}
|
||||
|
||||
save(trace: GovernanceTraceRecord): void {
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO governance_decision_traces (
|
||||
id,
|
||||
workspace_id,
|
||||
kind,
|
||||
outcome,
|
||||
agent_id,
|
||||
task_id,
|
||||
action_type,
|
||||
trace_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, 'local', ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
outcome = excluded.outcome,
|
||||
agent_id = excluded.agent_id,
|
||||
task_id = excluded.task_id,
|
||||
action_type = excluded.action_type,
|
||||
trace_json = excluded.trace_json,
|
||||
created_at = excluded.created_at
|
||||
`
|
||||
)
|
||||
.run(
|
||||
trace.id,
|
||||
trace.kind,
|
||||
trace.outcome,
|
||||
trace.subject.agentId ?? trace.subject.actorId ?? trace.subject.role ?? null,
|
||||
trace.subject.taskId ?? null,
|
||||
trace.subject.actionType ?? null,
|
||||
JSON.stringify(trace),
|
||||
trace.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
get(id: string): GovernanceTraceRecord | null {
|
||||
const row = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
SELECT trace_json
|
||||
FROM governance_decision_traces
|
||||
WHERE workspace_id = 'local'
|
||||
AND id = ?
|
||||
`
|
||||
)
|
||||
.get(id) as GovernanceTraceRow | undefined;
|
||||
|
||||
return row ? (JSON.parse(row.trace_json) as GovernanceTraceRecord) : null;
|
||||
}
|
||||
|
||||
list(filters: GovernanceTraceListFilters = {}): GovernanceTraceRecord[] {
|
||||
const clauses = ["workspace_id = 'local'"];
|
||||
const params: SQLInputValue[] = [];
|
||||
|
||||
if (filters.kind) {
|
||||
clauses.push('kind = ?');
|
||||
params.push(filters.kind);
|
||||
}
|
||||
if (filters.outcome) {
|
||||
clauses.push('outcome = ?');
|
||||
params.push(filters.outcome);
|
||||
}
|
||||
if (filters.agent) {
|
||||
clauses.push('agent_id = ?');
|
||||
params.push(filters.agent);
|
||||
}
|
||||
if (filters.taskId) {
|
||||
clauses.push('task_id = ?');
|
||||
params.push(filters.taskId);
|
||||
}
|
||||
if (filters.actionType) {
|
||||
clauses.push('action_type = ?');
|
||||
params.push(filters.actionType);
|
||||
}
|
||||
if (filters.startTime) {
|
||||
clauses.push('created_at >= ?');
|
||||
params.push(filters.startTime);
|
||||
}
|
||||
if (filters.endTime) {
|
||||
clauses.push('created_at <= ?');
|
||||
params.push(filters.endTime);
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(filters.limit ?? 100, 1), 500);
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
SELECT trace_json
|
||||
FROM governance_decision_traces
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY datetime(created_at) DESC, id DESC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(...params, limit) as unknown as GovernanceTraceRow[];
|
||||
|
||||
return rows.map((row) => JSON.parse(row.trace_json) as GovernanceTraceRecord);
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteFeedbackRepository {
|
||||
constructor(private readonly database: SqliteDatabase) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,38 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [
|
|||
ON device_sessions(workspace_id, revoked_at, expires_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 17,
|
||||
name: '0017_governance_decision_traces',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS governance_decision_traces (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
agent_id TEXT,
|
||||
task_id TEXT,
|
||||
action_type TEXT,
|
||||
trace_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_traces_workspace_created
|
||||
ON governance_decision_traces(workspace_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_traces_kind_created
|
||||
ON governance_decision_traces(kind, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_traces_outcome_created
|
||||
ON governance_decision_traces(outcome, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_traces_agent_created
|
||||
ON governance_decision_traces(agent_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_traces_task_created
|
||||
ON governance_decision_traces(task_id, created_at DESC);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] {
|
||||
|
|
|
|||
89
shared/src/types/governance-trace.types.ts
Normal file
89
shared/src/types/governance-trace.types.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
export type GovernanceTraceKind =
|
||||
| 'policy'
|
||||
| 'tool-policy'
|
||||
| 'agent-permission'
|
||||
| 'routing'
|
||||
| 'workflow-gate';
|
||||
|
||||
export type GovernanceTraceOutcome =
|
||||
| 'allowed'
|
||||
| 'warned'
|
||||
| 'blocked'
|
||||
| 'approval-required'
|
||||
| 'routed'
|
||||
| 'fallback'
|
||||
| 'skipped';
|
||||
|
||||
export type GovernanceTraceStepStatus = 'matched' | 'not-matched' | 'skipped' | 'info';
|
||||
|
||||
export interface GovernanceTraceSubject {
|
||||
actorId?: string;
|
||||
agentId?: string;
|
||||
role?: string;
|
||||
taskId?: string;
|
||||
workflowId?: string;
|
||||
runId?: string;
|
||||
stepId?: string;
|
||||
actionType?: string;
|
||||
tool?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export interface GovernanceTraceRule {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
status: GovernanceTraceStepStatus;
|
||||
outcome?: GovernanceTraceOutcome;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GovernanceTraceStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: GovernanceTraceStepStatus;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GovernanceTraceRecord {
|
||||
id: string;
|
||||
kind: GovernanceTraceKind;
|
||||
outcome: GovernanceTraceOutcome;
|
||||
title: string;
|
||||
summary: string;
|
||||
remediation?: string;
|
||||
subject: GovernanceTraceSubject;
|
||||
evaluatedRules: GovernanceTraceRule[];
|
||||
matchedRules: GovernanceTraceRule[];
|
||||
steps: GovernanceTraceStep[];
|
||||
raw?: Record<string, unknown>;
|
||||
redacted: true;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateGovernanceTraceInput {
|
||||
kind: GovernanceTraceKind;
|
||||
outcome: GovernanceTraceOutcome;
|
||||
title: string;
|
||||
summary: string;
|
||||
remediation?: string;
|
||||
subject?: GovernanceTraceSubject;
|
||||
evaluatedRules?: GovernanceTraceRule[];
|
||||
matchedRules?: GovernanceTraceRule[];
|
||||
steps?: GovernanceTraceStep[];
|
||||
raw?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface GovernanceTraceListFilters {
|
||||
kind?: GovernanceTraceKind;
|
||||
outcome?: GovernanceTraceOutcome;
|
||||
agent?: string;
|
||||
taskId?: string;
|
||||
actionType?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
|
@ -24,3 +24,4 @@ export * from './feedback.types.js';
|
|||
export * from './workflow.js';
|
||||
export * from './work-product.types.js';
|
||||
export * from './maintenance.types.js';
|
||||
export * from './governance-trace.types.js';
|
||||
|
|
|
|||
|
|
@ -98,4 +98,5 @@ export interface PolicyEvaluationResult {
|
|||
warnings: string[];
|
||||
blockedBy: string[];
|
||||
approvalRequiredBy: string[];
|
||||
traceId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
|
|||
},
|
||||
{ prefix: '/api/system/health', read: 'workspace:read', write: 'admin:manage' },
|
||||
{ prefix: '/api/decisions', read: 'task:read', write: 'task:write' },
|
||||
{ prefix: '/api/governance/traces', read: 'policy:read' },
|
||||
{ prefix: '/api/feedback', read: 'report:read', write: 'comment:write' },
|
||||
{
|
||||
prefix: '/api/prompt-registry',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => ({
|
|||
updateScoringProfile: vi.fn(),
|
||||
useDecision: vi.fn(),
|
||||
useDecisions: vi.fn(),
|
||||
useGovernanceTrace: vi.fn(),
|
||||
useGovernanceTraces: vi.fn(),
|
||||
useDriftAlerts: vi.fn(),
|
||||
useDriftBaselines: vi.fn(),
|
||||
useFeedbackAnalytics: vi.fn(),
|
||||
|
|
@ -120,6 +122,11 @@ vi.mock('@/hooks/useDecisions', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useGovernanceTraces', () => ({
|
||||
useGovernanceTrace: mocks.useGovernanceTrace,
|
||||
useGovernanceTraces: mocks.useGovernanceTraces,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useFeedback', () => ({
|
||||
useCreateFeedback: () => ({ mutateAsync: mocks.createFeedback, isPending: false }),
|
||||
useDeleteFeedback: () => ({ mutateAsync: mocks.deleteFeedback, isPending: false }),
|
||||
|
|
@ -296,6 +303,102 @@ describe('governance surfaces Mantine migration', () => {
|
|||
},
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useGovernanceTraces.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'govtrace-1',
|
||||
kind: 'policy',
|
||||
outcome: 'blocked',
|
||||
title: 'Production policy block',
|
||||
summary: 'Production deploy requires approval.',
|
||||
remediation: 'Request approval from a lead agent.',
|
||||
subject: {
|
||||
agentId: 'codex',
|
||||
taskId: 'task-1',
|
||||
actionType: 'git.push',
|
||||
},
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: 'policy:risk-prod',
|
||||
label: 'Production risk gate',
|
||||
type: 'policy',
|
||||
status: 'matched',
|
||||
outcome: 'blocked',
|
||||
message: 'Risk gate matched.',
|
||||
},
|
||||
],
|
||||
matchedRules: [
|
||||
{
|
||||
id: 'policy:risk-prod',
|
||||
label: 'Production risk gate',
|
||||
type: 'policy',
|
||||
status: 'matched',
|
||||
outcome: 'blocked',
|
||||
message: 'Risk gate matched.',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'condition',
|
||||
label: 'Condition',
|
||||
status: 'matched',
|
||||
message: 'Action matched git.push.',
|
||||
},
|
||||
],
|
||||
raw: { actionType: 'git.push' },
|
||||
redacted: true,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useGovernanceTrace.mockReturnValue({
|
||||
data: {
|
||||
id: 'govtrace-1',
|
||||
kind: 'policy',
|
||||
outcome: 'blocked',
|
||||
title: 'Production policy block',
|
||||
summary: 'Production deploy requires approval.',
|
||||
remediation: 'Request approval from a lead agent.',
|
||||
subject: {
|
||||
agentId: 'codex',
|
||||
taskId: 'task-1',
|
||||
actionType: 'git.push',
|
||||
},
|
||||
evaluatedRules: [
|
||||
{
|
||||
id: 'policy:risk-prod',
|
||||
label: 'Production risk gate',
|
||||
type: 'policy',
|
||||
status: 'matched',
|
||||
outcome: 'blocked',
|
||||
message: 'Risk gate matched.',
|
||||
},
|
||||
],
|
||||
matchedRules: [
|
||||
{
|
||||
id: 'policy:risk-prod',
|
||||
label: 'Production risk gate',
|
||||
type: 'policy',
|
||||
status: 'matched',
|
||||
outcome: 'blocked',
|
||||
message: 'Risk gate matched.',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'condition',
|
||||
label: 'Condition',
|
||||
status: 'matched',
|
||||
message: 'Action matched git.push.',
|
||||
},
|
||||
],
|
||||
raw: { actionType: 'git.push' },
|
||||
redacted: true,
|
||||
createdAt: now,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useFeedbackList.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
|
|
@ -372,7 +475,8 @@ describe('governance surfaces Mantine migration', () => {
|
|||
expectNoLegacySlots(baseElement);
|
||||
});
|
||||
|
||||
it('renders drift and decision audit surfaces with direct Mantine primitives', () => {
|
||||
it('renders drift and decision audit surfaces with direct Mantine primitives', async () => {
|
||||
const user = userEvent.setup();
|
||||
const drift = renderWithProviders(<DriftMonitor onBack={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Behavioral Drift Monitor')).toBeDefined();
|
||||
|
|
@ -389,6 +493,12 @@ describe('governance surfaces Mantine migration', () => {
|
|||
1
|
||||
);
|
||||
expect(decisions.baseElement.querySelector('.mantine-TextInput-root')).toBeDefined();
|
||||
expect(decisions.baseElement.querySelector('.mantine-SegmentedControl-root')).toBeDefined();
|
||||
await user.click(screen.getByText('Governance Traces'));
|
||||
expect(screen.getByText('Production policy block')).toBeDefined();
|
||||
await user.click(screen.getByText('Production policy block'));
|
||||
expect(screen.getByText('Evaluated Rules')).toBeDefined();
|
||||
expect(screen.getByText('Raw Detail')).toBeDefined();
|
||||
expectNoLegacySlots(decisions.baseElement);
|
||||
cleanup();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Search, ShieldAlert, BrainCircuit } from 'lucide-react';
|
||||
import { Badge, Button, Select, TextInput } from '@mantine/core';
|
||||
import { ArrowLeft, Search, ShieldAlert, BrainCircuit, Route, ShieldCheck } from 'lucide-react';
|
||||
import { Badge, Button, SegmentedControl, Select, TextInput } from '@mantine/core';
|
||||
import { useDecisions } from '@/hooks/useDecisions';
|
||||
import type { DecisionListFilters, DecisionRecord } from '@veritas-kanban/shared';
|
||||
import { useGovernanceTraces } from '@/hooks/useGovernanceTraces';
|
||||
import type {
|
||||
DecisionListFilters,
|
||||
DecisionRecord,
|
||||
GovernanceTraceKind,
|
||||
GovernanceTraceListFilters,
|
||||
GovernanceTraceOutcome,
|
||||
GovernanceTraceRecord,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { DecisionDetail } from './DecisionDetail';
|
||||
import { GovernanceTraceDetail } from './GovernanceTraceDetail';
|
||||
|
||||
interface DecisionExplorerProps {
|
||||
onBack: () => void;
|
||||
|
|
@ -11,23 +20,59 @@ interface DecisionExplorerProps {
|
|||
|
||||
const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
|
||||
type ExplorerMode = 'decisions' | 'governance';
|
||||
|
||||
const traceKindOptions: Array<{ value: 'all' | GovernanceTraceKind; label: string }> = [
|
||||
{ value: 'all', label: 'All trace types' },
|
||||
{ value: 'policy', label: 'Policies' },
|
||||
{ value: 'tool-policy', label: 'Tool policies' },
|
||||
{ value: 'agent-permission', label: 'Agent permissions' },
|
||||
{ value: 'routing', label: 'Routing' },
|
||||
{ value: 'workflow-gate', label: 'Workflow gates' },
|
||||
];
|
||||
|
||||
const traceOutcomeOptions: Array<{ value: 'all' | GovernanceTraceOutcome; label: string }> = [
|
||||
{ value: 'all', label: 'All outcomes' },
|
||||
{ value: 'allowed', label: 'Allowed' },
|
||||
{ value: 'warned', label: 'Warned' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'approval-required', label: 'Approval required' },
|
||||
{ value: 'routed', label: 'Routed' },
|
||||
{ value: 'fallback', label: 'Fallback' },
|
||||
{ value: 'skipped', label: 'Skipped' },
|
||||
];
|
||||
|
||||
function riskColor(riskScore: number): string {
|
||||
if (riskScore >= 75) return 'red';
|
||||
if (riskScore >= 40) return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
||||
const initialSelectedId =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('decision')
|
||||
: null;
|
||||
function traceOutcomeColor(outcome: GovernanceTraceOutcome): string {
|
||||
if (outcome === 'blocked') return 'red';
|
||||
if (outcome === 'approval-required') return 'orange';
|
||||
if (outcome === 'warned' || outcome === 'fallback') return 'yellow';
|
||||
if (outcome === 'allowed' || outcome === 'routed') return 'green';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
||||
const initialParams =
|
||||
typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const initialSelectedId = initialParams?.get('decision') ?? null;
|
||||
const initialTraceId = initialParams?.get('trace') ?? null;
|
||||
const initialMode = initialParams?.get('mode') === 'governance' ? 'governance' : 'decisions';
|
||||
const initialSearch = initialParams?.get('q') ?? '';
|
||||
|
||||
const [mode, setMode] = useState<ExplorerMode>(initialTraceId ? 'governance' : initialMode);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
|
||||
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(initialTraceId);
|
||||
const [agent, setAgent] = useState('all');
|
||||
const [confidenceFilter, setConfidenceFilter] = useState('all');
|
||||
const [riskFilter, setRiskFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [traceKind, setTraceKind] = useState<'all' | GovernanceTraceKind>('all');
|
||||
const [traceOutcome, setTraceOutcome] = useState<'all' | GovernanceTraceOutcome>('all');
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
|
||||
const filters = useMemo<DecisionListFilters>(() => {
|
||||
const next: DecisionListFilters = {};
|
||||
|
|
@ -47,7 +92,15 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
return next;
|
||||
}, [agent, confidenceFilter, riskFilter]);
|
||||
|
||||
const traceFilters = useMemo<GovernanceTraceListFilters>(() => {
|
||||
const next: GovernanceTraceListFilters = { limit: 200 };
|
||||
if (traceKind !== 'all') next.kind = traceKind;
|
||||
if (traceOutcome !== 'all') next.outcome = traceOutcome;
|
||||
return next;
|
||||
}, [traceKind, traceOutcome]);
|
||||
|
||||
const { data: decisions = [], isLoading } = useDecisions(filters);
|
||||
const { data: traces = [], isLoading: tracesLoading } = useGovernanceTraces(traceFilters);
|
||||
|
||||
const filteredDecisions = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
|
|
@ -60,6 +113,34 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
);
|
||||
}, [decisions, search]);
|
||||
|
||||
const filteredTraces = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return traces;
|
||||
return traces.filter((trace) =>
|
||||
[
|
||||
trace.id,
|
||||
trace.kind,
|
||||
trace.outcome,
|
||||
trace.title,
|
||||
trace.summary,
|
||||
trace.subject.agentId,
|
||||
trace.subject.actorId,
|
||||
trace.subject.role,
|
||||
trace.subject.taskId,
|
||||
trace.subject.workflowId,
|
||||
trace.subject.runId,
|
||||
trace.subject.stepId,
|
||||
trace.subject.actionType,
|
||||
trace.subject.tool,
|
||||
trace.subject.project,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [traces, search]);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
() => Array.from(new Set(decisions.map((decision) => decision.agentId))).sort(),
|
||||
[decisions]
|
||||
|
|
@ -70,17 +151,50 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
if (selectedId) {
|
||||
params.set('decision', selectedId);
|
||||
params.delete('trace');
|
||||
} else {
|
||||
params.delete('decision');
|
||||
}
|
||||
if (selectedTraceId) {
|
||||
params.set('trace', selectedTraceId);
|
||||
params.delete('decision');
|
||||
} else {
|
||||
params.delete('trace');
|
||||
}
|
||||
if (mode === 'governance') {
|
||||
params.set('mode', 'governance');
|
||||
} else {
|
||||
params.delete('mode');
|
||||
}
|
||||
if (search.trim()) {
|
||||
params.set('q', search.trim());
|
||||
} else {
|
||||
params.delete('q');
|
||||
}
|
||||
const query = params.toString();
|
||||
window.history.replaceState({}, '', `${BASE_PATH}/decisions${query ? `?${query}` : ''}`);
|
||||
}, [selectedId]);
|
||||
}, [mode, search, selectedId, selectedTraceId]);
|
||||
|
||||
if (selectedId) {
|
||||
const handleModeChange = (value: string) => {
|
||||
setMode(value as ExplorerMode);
|
||||
setSelectedId(null);
|
||||
setSelectedTraceId(null);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
if (selectedId && mode === 'decisions') {
|
||||
return <DecisionDetail decisionId={selectedId} onBack={() => setSelectedId(null)} />;
|
||||
}
|
||||
|
||||
if (selectedTraceId && mode === 'governance') {
|
||||
return (
|
||||
<GovernanceTraceDetail traceId={selectedTraceId} onBack={() => setSelectedTraceId(null)} />
|
||||
);
|
||||
}
|
||||
|
||||
const activeCount = mode === 'decisions' ? filteredDecisions.length : filteredTraces.length;
|
||||
const activeLabel = mode === 'decisions' ? 'decisions' : 'traces';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
|
@ -92,25 +206,46 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
<div>
|
||||
<h1 className="text-2xl font-bold">Decision Audit Trail</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review agent reasoning, assumptions, and parent decision lineage.
|
||||
Review agent reasoning, policy decisions, and workflow gate outcomes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="light" tt="none">
|
||||
{filteredDecisions.length} decisions
|
||||
{activeCount} {activeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_220px_220px_220px]">
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
data={[
|
||||
{ value: 'decisions', label: 'Agent Decisions' },
|
||||
{ value: 'governance', label: 'Governance Traces' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
mode === 'decisions'
|
||||
? 'grid gap-4 lg:grid-cols-[1.2fr_220px_220px_220px]'
|
||||
: 'grid gap-4 lg:grid-cols-[1.2fr_220px_220px]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search decision id, task, context..."
|
||||
placeholder={
|
||||
mode === 'decisions'
|
||||
? 'Search decision id, task, context...'
|
||||
: 'Search trace id, rule, action, subject...'
|
||||
}
|
||||
leftSection={<Search className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'decisions' ? (
|
||||
<>
|
||||
<Select
|
||||
value={agent}
|
||||
onChange={(value) => setAgent(value ?? 'all')}
|
||||
|
|
@ -147,8 +282,31 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
placeholder="Risk"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={traceKind}
|
||||
onChange={(value) => setTraceKind((value ?? 'all') as 'all' | GovernanceTraceKind)}
|
||||
data={traceKindOptions}
|
||||
placeholder="Trace type"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={traceOutcome}
|
||||
onChange={(value) =>
|
||||
setTraceOutcome((value ?? 'all') as 'all' | GovernanceTraceOutcome)
|
||||
}
|
||||
data={traceOutcomeOptions}
|
||||
placeholder="Outcome"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'decisions' ? (
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[1.5fr_1.1fr_120px_120px_150px_180px] gap-3 border-b bg-muted/40 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Action</span>
|
||||
|
|
@ -177,7 +335,88 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[1.5fr_150px_150px_170px_180px] gap-3 border-b bg-muted/40 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Trace</span>
|
||||
<span>Type</span>
|
||||
<span>Outcome</span>
|
||||
<span>Subject</span>
|
||||
<span>Timestamp</span>
|
||||
</div>
|
||||
|
||||
{tracesLoading ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
Loading governance traces...
|
||||
</div>
|
||||
) : filteredTraces.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No governance traces match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
filteredTraces.map((trace) => (
|
||||
<GovernanceTraceRow
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
onOpen={() => setSelectedTraceId(trace.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GovernanceTraceRow({
|
||||
trace,
|
||||
onOpen,
|
||||
}: {
|
||||
trace: GovernanceTraceRecord;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const subject =
|
||||
trace.subject.agentId ||
|
||||
trace.subject.actorId ||
|
||||
trace.subject.role ||
|
||||
trace.subject.taskId ||
|
||||
trace.subject.workflowId ||
|
||||
trace.subject.runId ||
|
||||
trace.subject.actionType ||
|
||||
'local';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="grid w-full grid-cols-[1.5fr_150px_150px_170px_180px] gap-3 border-b px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-accent/40"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{trace.title}</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{trace.summary}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Route className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{trace.kind}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Badge
|
||||
color={traceOutcomeColor(trace.outcome)}
|
||||
variant="light"
|
||||
tt="none"
|
||||
leftSection={<ShieldCheck className="h-3 w-3" />}
|
||||
>
|
||||
{trace.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="font-medium">{subject}</div>
|
||||
<div className="text-muted-foreground">{trace.subject.actionType}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(trace.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
207
web/src/components/decisions/GovernanceTraceDetail.tsx
Normal file
207
web/src/components/decisions/GovernanceTraceDetail.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { ArrowLeft, CheckCircle2, ShieldAlert, Route, FileJson } from 'lucide-react';
|
||||
import { Badge, Button, Code, ScrollArea } from '@mantine/core';
|
||||
import { useGovernanceTrace } from '@/hooks/useGovernanceTraces';
|
||||
import type {
|
||||
GovernanceTraceRecord,
|
||||
GovernanceTraceRule,
|
||||
GovernanceTraceStep,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
interface GovernanceTraceDetailProps {
|
||||
traceId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const outcomeColor: Record<GovernanceTraceRecord['outcome'], string> = {
|
||||
allowed: 'green',
|
||||
warned: 'yellow',
|
||||
blocked: 'red',
|
||||
'approval-required': 'orange',
|
||||
routed: 'blue',
|
||||
fallback: 'yellow',
|
||||
skipped: 'gray',
|
||||
};
|
||||
|
||||
const statusColor: Record<GovernanceTraceRule['status'], string> = {
|
||||
matched: 'green',
|
||||
'not-matched': 'gray',
|
||||
skipped: 'gray',
|
||||
info: 'blue',
|
||||
};
|
||||
|
||||
export function GovernanceTraceDetail({ traceId, onBack }: GovernanceTraceDetailProps) {
|
||||
const { data: trace, isLoading } = useGovernanceTrace(traceId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground">
|
||||
Loading governance trace...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trace) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground">
|
||||
Governance trace not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subjectEntries = Object.entries(trace.subject).filter(
|
||||
([, value]) => value !== undefined && value !== ''
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Traces
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge color="blue" variant="light" tt="none">
|
||||
{trace.kind}
|
||||
</Badge>
|
||||
<Badge color={outcomeColor[trace.outcome]} variant="light" tt="none">
|
||||
{trace.outcome}
|
||||
</Badge>
|
||||
{trace.redacted && (
|
||||
<Badge color="gray" variant="outline" tt="none">
|
||||
redacted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-card p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md border bg-muted/50 p-2">
|
||||
<ShieldAlert className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl font-semibold">{trace.title}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{trace.summary}</p>
|
||||
{trace.remediation && (
|
||||
<div className="mt-4 rounded-md border border-dashed p-3 text-sm">
|
||||
<div className="font-medium">Remediation</div>
|
||||
<p className="mt-1 text-muted-foreground">{trace.remediation}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
|
||||
<section className="rounded-lg border bg-card p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Route className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Subject</h2>
|
||||
</div>
|
||||
{subjectEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No subject metadata recorded.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{subjectEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="max-w-[65%] break-words text-right font-medium">
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 text-xs text-muted-foreground">
|
||||
{new Date(trace.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-card p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Evaluated Rules</h2>
|
||||
<Badge variant="light" tt="none">
|
||||
{trace.matchedRules.length} matched
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{trace.evaluatedRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No rules evaluated.</p>
|
||||
) : (
|
||||
trace.evaluatedRules.map((rule) => <RuleRow key={rule.id} rule={rule} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">Trace Steps</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{trace.steps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No steps recorded.</p>
|
||||
) : (
|
||||
trace.steps.map((step) => <StepRow key={step.id} step={step} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-card p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<FileJson className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Raw Detail</h2>
|
||||
</div>
|
||||
<ScrollArea h={280} type="auto">
|
||||
<Code block className="text-xs">
|
||||
{JSON.stringify(trace.raw ?? trace, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({ rule }: { rule: GovernanceTraceRule }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{rule.label}</span>
|
||||
<Badge color={statusColor[rule.status]} variant="light" tt="none">
|
||||
{rule.status}
|
||||
</Badge>
|
||||
{rule.outcome && (
|
||||
<Badge color={outcomeColor[rule.outcome]} variant="outline" tt="none">
|
||||
{rule.outcome}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{rule.message}</p>
|
||||
{rule.details && (
|
||||
<Code block className="mt-3 text-xs">
|
||||
{JSON.stringify(rule.details, null, 2)}
|
||||
</Code>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({ step }: { step: GovernanceTraceStep }) {
|
||||
return (
|
||||
<div className="flex gap-3 rounded-md border p-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{step.label}</span>
|
||||
<Badge color={statusColor[step.status]} variant="light" tt="none">
|
||||
{step.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{step.message}</p>
|
||||
{step.details && (
|
||||
<Code block className="mt-3 text-xs">
|
||||
{JSON.stringify(step.details, null, 2)}
|
||||
</Code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,15 @@ import type {
|
|||
PolicyType,
|
||||
PolicyResponseAction,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { ArrowLeft, Edit, FlaskConical, Plus, ShieldAlert, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
|
|
@ -31,6 +39,8 @@ interface PolicyManagerProps {
|
|||
onBack: () => void;
|
||||
}
|
||||
|
||||
const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
|
||||
type PolicyFormState = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -996,6 +1006,18 @@ export function PolicyManager({ onBack }: PolicyManagerProps) {
|
|||
<span className="text-sm text-muted-foreground">
|
||||
{evaluatePolicies.data.matches.length} matching policies
|
||||
</span>
|
||||
{evaluatePolicies.data.traceId && (
|
||||
<Button
|
||||
component="a"
|
||||
href={`${BASE_PATH}/decisions?trace=${encodeURIComponent(evaluatePolicies.data.traceId)}`}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
target="_self"
|
||||
>
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Open Trace
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{evaluatePolicies.data.matches.map((match) => (
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import { sanitizeText } from '@/lib/sanitize';
|
|||
|
||||
type TimelineTabTarget = 'agent' | 'changes' | 'details' | 'review' | 'work-products';
|
||||
|
||||
const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
|
||||
interface AgentRunTimelinePanelProps {
|
||||
task: Task;
|
||||
initialAttemptId?: string | null;
|
||||
|
|
@ -897,6 +899,18 @@ function typeFilterLabel(type: AgentRunTimelineEventType): string {
|
|||
return EVENT_LABELS[type];
|
||||
}
|
||||
|
||||
function governanceTraceHrefForEvent(event: AgentRunTimelineEvent): string | null {
|
||||
if (event.type !== 'policy' && event.type !== 'approval') return null;
|
||||
|
||||
const query =
|
||||
safeString(event.metadata?.taskId) ??
|
||||
safeString(event.metadata?.task_id) ??
|
||||
safeString(event.metadata?.actionType) ??
|
||||
event.title;
|
||||
|
||||
return `${BASE_PATH}/decisions?mode=governance&q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
onOpenTab,
|
||||
|
|
@ -913,6 +927,7 @@ function EventRow({
|
|||
linkTarget && linkTarget !== 'external' && linkTarget !== 'workflow' && onOpenTab;
|
||||
const canOpenWorkflow = linkTarget === 'workflow' && onOpenWorkflow;
|
||||
const canOpenExternal = linkTarget === 'external' && event.link?.href;
|
||||
const governanceTraceHref = governanceTraceHrefForEvent(event);
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
|
|
@ -954,9 +969,9 @@ function EventRow({
|
|||
{metadata}
|
||||
</Code>
|
||||
)}
|
||||
{(canOpenInternal || canOpenWorkflow || canOpenExternal) && event.link && (
|
||||
{(canOpenInternal || canOpenWorkflow || canOpenExternal || governanceTraceHref) && (
|
||||
<Group gap="xs">
|
||||
{canOpenInternal && linkTarget && (
|
||||
{canOpenInternal && linkTarget && event.link && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
|
|
@ -965,7 +980,7 @@ function EventRow({
|
|||
{event.link.label}
|
||||
</Button>
|
||||
)}
|
||||
{canOpenWorkflow && (
|
||||
{canOpenWorkflow && event.link && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
|
|
@ -974,7 +989,7 @@ function EventRow({
|
|||
{event.link.label}
|
||||
</Button>
|
||||
)}
|
||||
{canOpenExternal && (
|
||||
{canOpenExternal && event.link && (
|
||||
<Button
|
||||
component="a"
|
||||
href={event.link.href}
|
||||
|
|
@ -986,6 +1001,18 @@ function EventRow({
|
|||
{event.link.label}
|
||||
</Button>
|
||||
)}
|
||||
{governanceTraceHref && (
|
||||
<Button
|
||||
component="a"
|
||||
href={governanceTraceHref}
|
||||
rel="noopener noreferrer"
|
||||
size="compact-xs"
|
||||
target="_self"
|
||||
variant="subtle"
|
||||
>
|
||||
Governance Traces
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export * from './useTasks';
|
|||
export * from './useTemplateForm';
|
||||
export * from './useTemplates';
|
||||
export * from './useDecisions';
|
||||
export * from './useGovernanceTraces';
|
||||
// Note: useTimeTracking also exports formatDuration - import directly to avoid conflict with useMetrics
|
||||
export {
|
||||
useTimeSummary,
|
||||
|
|
|
|||
23
web/src/hooks/useGovernanceTraces.ts
Normal file
23
web/src/hooks/useGovernanceTraces.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { GovernanceTraceListFilters } from '@veritas-kanban/shared';
|
||||
|
||||
export function useGovernanceTraces(filters: GovernanceTraceListFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['governance-traces', filters],
|
||||
queryFn: () => api.governanceTraces.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGovernanceTrace(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['governance-traces', id],
|
||||
queryFn: () => {
|
||||
if (!id) {
|
||||
throw new Error('Governance trace id is required');
|
||||
}
|
||||
return api.governanceTraces.get(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
34
web/src/lib/api/governance-traces.ts
Normal file
34
web/src/lib/api/governance-traces.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { GovernanceTraceListFilters, GovernanceTraceRecord } from '@veritas-kanban/shared';
|
||||
import { API_BASE, handleResponse } from './helpers';
|
||||
|
||||
function toQuery(filters: GovernanceTraceListFilters = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.kind) params.set('kind', filters.kind);
|
||||
if (filters.outcome) params.set('outcome', filters.outcome);
|
||||
if (filters.agent) params.set('agent', filters.agent);
|
||||
if (filters.taskId) params.set('taskId', filters.taskId);
|
||||
if (filters.actionType) params.set('actionType', filters.actionType);
|
||||
if (filters.startTime) params.set('startTime', filters.startTime);
|
||||
if (filters.endTime) params.set('endTime', filters.endTime);
|
||||
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
export const governanceTracesApi = {
|
||||
list: async (filters: GovernanceTraceListFilters = {}): Promise<GovernanceTraceRecord[]> => {
|
||||
const response = await fetch(`${API_BASE}/governance/traces${toQuery(filters)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<GovernanceTraceRecord[]>(response);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<GovernanceTraceRecord> => {
|
||||
const response = await fetch(`${API_BASE}/governance/traces/${encodeURIComponent(id)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<GovernanceTraceRecord>(response);
|
||||
},
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import { templatesApi, taskTypesApi, sprintsApi, activityApi, attachmentsApi } f
|
|||
import { timeApi, statusHistoryApi } from './time';
|
||||
import { chatApi } from './chat';
|
||||
import { decisionsApi } from './decisions';
|
||||
import { governanceTracesApi } from './governance-traces';
|
||||
import { scoringApi } from './scoring';
|
||||
import { searchApi } from './search';
|
||||
import { identityApi } from './identity';
|
||||
|
|
@ -42,6 +43,7 @@ export const api = {
|
|||
statusHistory: statusHistoryApi,
|
||||
chat: chatApi,
|
||||
decisions: decisionsApi,
|
||||
governanceTraces: governanceTracesApi,
|
||||
scoring: scoringApi,
|
||||
search: searchApi,
|
||||
identity: identityApi,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue