-
Notifications
You must be signed in to change notification settings - Fork 0
feat: governance gate — benchmark scores enforce execution policy #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import type { Database } from 'better-sqlite3'; | ||
| import { createTestDb } from './helpers/test-db'; | ||
| import { GovernanceGateService } from '../services/governance-gate-service'; | ||
|
|
||
| const GATE_ENV_KEYS = ['GOVERNANCE_GATE_ENABLED', 'GOVERNANCE_GATE_FLOOR', 'GOVERNANCE_GATE_MODEL_MAP']; | ||
|
|
||
| function insertRun(db: Database, run: { | ||
| id: string; | ||
| agentId: string; | ||
| score: number; | ||
| finishedAt: string; | ||
| subjectModel?: string; | ||
| }) { | ||
| db.prepare(` | ||
| INSERT INTO openmythos_eval_runs (id, agent_id, status, total_cases, completed_cases, overall_score, started_at, finished_at, metadata) | ||
| VALUES (?, ?, 'completed', 78, 78, ?, ?, ?, ?) | ||
| `).run(run.id, run.agentId, run.score, run.finishedAt, run.finishedAt, | ||
| JSON.stringify(run.subjectModel ? { subject_model: run.subjectModel } : {})); | ||
| } | ||
|
|
||
| describe('GovernanceGateService', () => { | ||
| let db: Database; | ||
| let gate: GovernanceGateService; | ||
| const previousEnv = { ...process.env }; | ||
|
|
||
| beforeEach(() => { | ||
| for (const key of GATE_ENV_KEYS) delete process.env[key]; | ||
| process.env.GOVERNANCE_GATE_ENABLED = 'true'; | ||
| db = createTestDb(); | ||
| gate = new GovernanceGateService(db); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| db.close(); | ||
| for (const key of Object.keys(process.env)) { | ||
| if (!(key in previousEnv)) delete process.env[key]; | ||
| } | ||
| Object.assign(process.env, previousEnv); | ||
| }); | ||
|
|
||
| it('allows everything when disabled', () => { | ||
| delete process.env.GOVERNANCE_GATE_ENABLED; | ||
| insertRun(db, { id: 'r1', agentId: 'agent-a', score: 1.0, finishedAt: '2026-07-15T10:00:00Z' }); | ||
|
|
||
| const verdict = gate.assess({ agent_id: 'agent-a' }, 'mock'); | ||
| expect(verdict.action).toBe('allow'); | ||
| expect(verdict.reason).toContain('disabled'); | ||
| }); | ||
|
|
||
| it('allows when there is no governance evidence', () => { | ||
| const verdict = gate.assess({ agent_id: 'agent-unknown' }, 'mock'); | ||
| expect(verdict.action).toBe('allow'); | ||
| expect(verdict.reason).toContain('No governance evidence'); | ||
| }); | ||
|
|
||
| it('allows when the latest score clears the floor', () => { | ||
| insertRun(db, { id: 'r1', agentId: 'agent-a', score: 3.4, finishedAt: '2026-07-15T10:00:00Z' }); | ||
|
|
||
| const verdict = gate.assess({ agent_id: 'agent-a' }, 'mock'); | ||
| expect(verdict.action).toBe('allow'); | ||
| expect(verdict.score).toBe(3.4); | ||
| }); | ||
|
|
||
| it('requires approval when the latest score is below the floor', () => { | ||
| insertRun(db, { id: 'r1', agentId: 'agent-a', score: 2.1, finishedAt: '2026-07-15T10:00:00Z' }); | ||
|
|
||
| const verdict = gate.assess({ agent_id: 'agent-a' }, 'mock'); | ||
| expect(verdict.action).toBe('require_approval'); | ||
| expect(verdict.reason).toContain('2.10'); | ||
| expect(verdict.reason).toContain('approval required'); | ||
| expect(verdict.flagRetirement).toBe(false); | ||
| }); | ||
|
|
||
| it('respects a configured floor', () => { | ||
| process.env.GOVERNANCE_GATE_FLOOR = '2'; | ||
| insertRun(db, { id: 'r1', agentId: 'agent-a', score: 2.1, finishedAt: '2026-07-15T10:00:00Z' }); | ||
|
|
||
| expect(gate.assess({ agent_id: 'agent-a' }, 'mock').action).toBe('allow'); | ||
| }); | ||
|
|
||
| it('resolves evidence through the executor model map when the agent has none', () => { | ||
| process.env.GOVERNANCE_GATE_MODEL_MAP = 'mock=weak-model,claude=claude-sonnet-4'; | ||
| insertRun(db, { id: 'r1', agentId: 'nightly:weak-model', score: 1.8, finishedAt: '2026-07-15T10:00:00Z' }); | ||
|
|
||
| const verdict = gate.assess({ agent_id: null }, 'mock'); | ||
| expect(verdict.action).toBe('require_approval'); | ||
| expect(verdict.agentKey).toBe('nightly:weak-model'); | ||
| }); | ||
|
|
||
| it('matches runs by metadata subject_model as a fallback', () => { | ||
| process.env.GOVERNANCE_GATE_MODEL_MAP = 'mock=some-model'; | ||
| insertRun(db, { id: 'r1', agentId: 'apex-validation:some-model', score: 1.5, finishedAt: '2026-07-15T10:00:00Z', subjectModel: 'some-model' }); | ||
|
|
||
| const verdict = gate.assess({ agent_id: null }, 'mock'); | ||
| expect(verdict.action).toBe('require_approval'); | ||
| }); | ||
|
|
||
| it('flags a retirement candidate on three below-floor runs with a declining trend', () => { | ||
| insertRun(db, { id: 'r1', agentId: 'agent-a', score: 2.8, finishedAt: '2026-07-13T10:00:00Z' }); | ||
| insertRun(db, { id: 'r2', agentId: 'agent-a', score: 2.4, finishedAt: '2026-07-14T10:00:00Z' }); | ||
| insertRun(db, { id: 'r3', agentId: 'agent-a', score: 2.0, finishedAt: '2026-07-15T10:00:00Z' }); | ||
|
|
||
| const verdict = gate.assess({ agent_id: 'agent-a' }, 'mock'); | ||
| expect(verdict.action).toBe('require_approval'); | ||
| expect(verdict.trend).toBe('declining'); | ||
| expect(verdict.flagRetirement).toBe(true); | ||
| expect(verdict.reason).toContain('retirement candidate'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ import { randomUUID } from 'crypto'; | |
| import { CommandRiskClassifier } from '../services/command-risk-classifier'; | ||
| import { PolicyDecisionService } from '../services/policy-decision-service'; | ||
| import { ApprovalService } from '../services/approval-service'; | ||
| import { GovernanceGateService } from '../services/governance-gate-service'; | ||
| import { AuditService } from '../services/audit-service'; | ||
| import { EvidenceService } from '../services/evidence-service'; | ||
| import { DiffCaptureService } from '../services/diff-capture'; | ||
|
|
@@ -56,6 +57,7 @@ export class ExecutionEngine { | |
| private auditService: AuditService; | ||
| private approvalService: ApprovalService; | ||
| private evidenceService: EvidenceService; | ||
| private governanceGate: GovernanceGateService; | ||
| private diffCaptureService: DiffCaptureService; | ||
| private memorySyncService?: MemorySyncService; | ||
| private reasoningBankService?: ReasoningBankService; | ||
|
|
@@ -95,6 +97,7 @@ export class ExecutionEngine { | |
| this.auditService = new AuditService(db); | ||
| this.approvalService = new ApprovalService(db, wsService, this.auditService); | ||
| this.evidenceService = new EvidenceService(db); | ||
| this.governanceGate = new GovernanceGateService(db); | ||
| this.diffCaptureService = new DiffCaptureService(db); | ||
|
|
||
| // Register default executors | ||
|
|
@@ -160,9 +163,31 @@ export class ExecutionEngine { | |
| } | ||
|
|
||
| const assessment = this.riskClassifier.assessTask(parsedTask, executorKind, process.cwd()); | ||
| const evaluation = this.policyDecisionService.evaluate(assessment); | ||
| let evaluation = this.policyDecisionService.evaluate(assessment); | ||
| this.persistRiskAssessment(taskId, assessment, `${parsedTask.title}: ${parsedTask.description}`); | ||
|
|
||
| // Governance gate: benchmark evidence can only TIGHTEN the policy decision. | ||
| const gateVerdict = this.governanceGate.assess(parsedTask, executorKind); | ||
| if (gateVerdict.action === 'require_approval' && evaluation.decision === 'allow') { | ||
| evaluation = { ...evaluation, decision: 'require_approval', explanation: gateVerdict.reason }; | ||
| this.evidenceService.captureEvidence({ | ||
| task_id: taskId, | ||
| evidence_type: EvidenceType.POLICY_DECISION, | ||
| severity: EvidenceSeverity.WARNING, | ||
| title: 'Governance gate tightened execution to require approval', | ||
| summary: gateVerdict.reason, | ||
| details: { | ||
| agentKey: gateVerdict.agentKey, | ||
| score: gateVerdict.score, | ||
| floor: gateVerdict.floor, | ||
| trend: gateVerdict.trend, | ||
| retirement_candidate: gateVerdict.flagRetirement, | ||
| executorKind, | ||
| }, | ||
| source: 'governance-gate', | ||
| }); | ||
| } | ||
|
Comment on lines
+171
to
+189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The governance gate evidence is currently only captured if This means the operator/approver will not see the benchmark evidence or the retirement candidate flag when reviewing the task, even though the model is below the floor. We should decouple the decision tightening from the evidence capturing so that governance gate evidence is always captured when the model is below the floor, regardless of the initial policy decision. if (gateVerdict.action === 'require_approval') {
if (evaluation.decision === 'allow') {
evaluation = { ...evaluation, decision: 'require_approval', explanation: gateVerdict.reason };
}
this.evidenceService.captureEvidence({
task_id: taskId,
evidence_type: EvidenceType.POLICY_DECISION,
severity: EvidenceSeverity.WARNING,
title: 'Governance gate tightened execution to require approval',
summary: gateVerdict.reason,
details: {
agentKey: gateVerdict.agentKey,
score: gateVerdict.score,
floor: gateVerdict.floor,
trend: gateVerdict.trend,
retirement_candidate: gateVerdict.flagRetirement,
executorKind,
},
source: 'governance-gate',
});
} |
||
|
|
||
| if (evaluation.decision === 'deny') { | ||
| this.evidenceService.captureEvidence({ | ||
| task_id: taskId, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the requested executor's circuit is open,
executeTasklater reassignsexecutorKindto a fallback before starting the session. Because the governance gate is evaluated here against the original executor, a request that passes/no-evidence foropencodecan fail over toclaude/codex/geminiwhose mapped benchmark score is below the floor and still execute without approval. This bypasses the new enforcement during circuit-breaker failover; move or repeat the gate after any fallback executor is selected.Useful? React with 👍 / 👎.