Skip to content

Commit 6f1992d

Browse files
committed
feat(sdk): expose custom conditions on createGovernance (0.17.0)
Add conditions?: RegisteredConditionType[] to GovernanceConfig and mirror the engine's condition-registry surface (registerCondition / unregisterCondition / getRegisteredCondition / getRegisteredConditions / clearConditionRegistry) on GovernanceInstance, so callers using the documented createGovernance flow no longer have to drop down to createPolicyEngine to register custom evaluators. Widen GovernanceConfig.defaultOutcome to the full PolicyOutcome union to match PolicyEngineConfig.defaultOutcome. Update the README's Quick Start with a Custom Conditions section.
1 parent 2e94d68 commit 6f1992d

6 files changed

Lines changed: 312 additions & 3 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,57 @@ if (decision.outcome === 'require_approval') {
189189
}
190190
```
191191

192+
### Custom Conditions
193+
194+
When the built-in condition types aren't enough, register your own evaluators directly on the governance instance — no need to drop down to `createPolicyEngine` for this. Pass them at construction or register them at runtime:
195+
196+
```typescript
197+
import { createGovernance } from 'governance-sdk';
198+
199+
// Option A — register at construction time
200+
const gov = createGovernance({
201+
conditions: [
202+
{
203+
name: 'geo_fence',
204+
description: 'Block actions outside allowed regions',
205+
evaluator: (ctx, params) => {
206+
const region = (ctx.metadata?.region as string | undefined) ?? '';
207+
const allowed = params.allowedRegions as string[];
208+
return region.length > 0 && !allowed.includes(region);
209+
},
210+
},
211+
],
212+
rules: [{
213+
id: 'geo-rule',
214+
name: 'Geo fence',
215+
condition: { type: 'geo_fence', params: { allowedRegions: ['us', 'eu'] } },
216+
outcome: 'block',
217+
reason: 'Region not allowed',
218+
priority: 100,
219+
enabled: true,
220+
}],
221+
});
222+
223+
// Option B — register after construction
224+
gov.registerCondition({
225+
name: 'high_cost',
226+
description: 'Block when session cost exceeds threshold',
227+
evaluator: (ctx, params) => (ctx.sessionCost ?? 0) > (params.maxCost as number),
228+
});
229+
230+
gov.addRule({
231+
id: 'cost-check',
232+
name: 'Cost check',
233+
condition: { type: 'high_cost', params: { maxCost: 10 } },
234+
outcome: 'block',
235+
reason: 'Session cost over budget',
236+
priority: 100,
237+
enabled: true,
238+
});
239+
```
240+
241+
Mirror methods are available on the instance: `registerCondition`, `unregisterCondition`, `getRegisteredCondition`, `getRegisteredConditions`, `clearConditionRegistry`. Custom evaluators must be **synchronous** — the policy engine is sync by design.
242+
192243
### CLI
193244

194245
```bash

packages/governance/CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
# Changelog
22

3+
## [0.17.0] - 2026-05-07 — Custom conditions reachable from `createGovernance()`
4+
5+
The condition registry (`registerCondition` / `unregisterCondition` /
6+
`getRegisteredCondition` / `getRegisteredConditions` /
7+
`clearConditionRegistry`) and `PolicyEngineConfig.conditions` were already
8+
on `PolicyEngine` since 0.15, but `GovernanceInstance` (the thing
9+
`createGovernance()` returns) didn't expose them — `instance.policies` is
10+
a `ReadonlyPolicyEngine` view that intentionally hides mutators. So
11+
callers who followed the documented `createGovernance()` flow had no path
12+
to register a custom condition without dropping down to
13+
`createPolicyEngine()` and re-wiring everything else themselves.
14+
15+
This release closes that gap. Additive only — no breaking changes.
16+
17+
### Added — `GovernanceConfig.conditions`
18+
19+
```ts
20+
const gov = createGovernance({
21+
conditions: [{
22+
name: "geo_fence",
23+
description: "Block actions outside allowed regions",
24+
evaluator: (ctx, params) => /* ... */ false,
25+
}],
26+
rules: [/* ... */],
27+
});
28+
```
29+
30+
Forwarded into the underlying `createPolicyEngine` call.
31+
32+
### Added — registry passthroughs on `GovernanceInstance`
33+
34+
Mirroring the existing `addRule` / `removeRule` pattern:
35+
36+
- `gov.registerCondition(entry, opts?)`
37+
- `gov.unregisterCondition(name)`
38+
- `gov.getRegisteredCondition(name)`
39+
- `gov.getRegisteredConditions()`
40+
- `gov.clearConditionRegistry(opts?)`
41+
42+
All thin forwarders to the engine.
43+
44+
### Changed — `GovernanceConfig.defaultOutcome` accepts the full `PolicyOutcome` union
45+
46+
Was `"allow" | "block"`; now matches `PolicyEngineConfig.defaultOutcome`
47+
(`"allow" | "block" | "warn" | "require_approval" | "mask"`). Existing
48+
callers passing `"allow"` or `"block"` are unaffected.
49+
50+
### Docs
51+
52+
README's "Quick Start" section gained a **Custom Conditions** subsection
53+
demonstrating both construction-time (`config.conditions`) and runtime
54+
(`gov.registerCondition()`) registration via `createGovernance()`. The
55+
previous custom-condition example used the lower-level `createPolicyEngine`
56+
which left users on the documented `createGovernance` path stuck.
57+
358
## [0.16.0] - 2026-04-30 — Per-policy multi-modal scan dispatch
459

560
0.15 introduced `governance-sdk/scan/multi-modal` as a host-callable

packages/governance/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,57 @@ if (decision.outcome === 'require_approval') {
189189
}
190190
```
191191

192+
### Custom Conditions
193+
194+
When the built-in condition types aren't enough, register your own evaluators directly on the governance instance — no need to drop down to `createPolicyEngine` for this. Pass them at construction or register them at runtime:
195+
196+
```typescript
197+
import { createGovernance } from 'governance-sdk';
198+
199+
// Option A — register at construction time
200+
const gov = createGovernance({
201+
conditions: [
202+
{
203+
name: 'geo_fence',
204+
description: 'Block actions outside allowed regions',
205+
evaluator: (ctx, params) => {
206+
const region = (ctx.metadata?.region as string | undefined) ?? '';
207+
const allowed = params.allowedRegions as string[];
208+
return region.length > 0 && !allowed.includes(region);
209+
},
210+
},
211+
],
212+
rules: [{
213+
id: 'geo-rule',
214+
name: 'Geo fence',
215+
condition: { type: 'geo_fence', params: { allowedRegions: ['us', 'eu'] } },
216+
outcome: 'block',
217+
reason: 'Region not allowed',
218+
priority: 100,
219+
enabled: true,
220+
}],
221+
});
222+
223+
// Option B — register after construction
224+
gov.registerCondition({
225+
name: 'high_cost',
226+
description: 'Block when session cost exceeds threshold',
227+
evaluator: (ctx, params) => (ctx.sessionCost ?? 0) > (params.maxCost as number),
228+
});
229+
230+
gov.addRule({
231+
id: 'cost-check',
232+
name: 'Cost check',
233+
condition: { type: 'high_cost', params: { maxCost: 10 } },
234+
outcome: 'block',
235+
reason: 'Session cost over budget',
236+
priority: 100,
237+
enabled: true,
238+
});
239+
```
240+
241+
Mirror methods are available on the instance: `registerCondition`, `unregisterCondition`, `getRegisteredCondition`, `getRegisteredConditions`, `clearConditionRegistry`. Custom evaluators must be **synchronous** — the policy engine is sync by design.
242+
192243
### CLI
193244

194245
```bash

packages/governance/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "governance-sdk",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "AI Agent Governance for TypeScript — policy enforcement, scoring, compliance, and audit for AI agents",
55
"type": "module",
66
"main": "./dist/index.js",

packages/governance/src/index.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,101 @@ describe("createGovernance", () => {
175175
});
176176
});
177177

178+
describe("createGovernance — custom conditions", () => {
179+
test("registerCondition + addRule lets enforce() match a custom condition", async () => {
180+
const gov = createGovernance();
181+
gov.registerCondition({
182+
name: "geo_fence",
183+
description: "Block actions outside allowed regions",
184+
evaluator: (ctx, params) => {
185+
const region = (ctx.metadata?.region as string | undefined) ?? "";
186+
const allowed = params.allowedRegions as string[];
187+
return region.length > 0 && !allowed.includes(region);
188+
},
189+
});
190+
191+
gov.addRule({
192+
id: "geo-rule",
193+
name: "Geo fence",
194+
condition: { type: "geo_fence", params: { allowedRegions: ["us", "eu"] } },
195+
outcome: "block",
196+
reason: "Region not allowed",
197+
priority: 100,
198+
enabled: true,
199+
});
200+
201+
const blocked = await gov.enforce({
202+
agentId: "a1",
203+
action: "tool_call",
204+
tool: "any",
205+
metadata: { region: "cn" },
206+
});
207+
assert.equal(blocked.blocked, true, "non-allowed region should be blocked");
208+
assert.equal(blocked.ruleId, "geo-rule");
209+
210+
const allowed = await gov.enforce({
211+
agentId: "a1",
212+
action: "tool_call",
213+
tool: "any",
214+
metadata: { region: "us" },
215+
});
216+
assert.equal(allowed.blocked, false, "allowed region should pass");
217+
});
218+
219+
test("config.conditions registers conditions at construction time", async () => {
220+
const gov = createGovernance({
221+
conditions: [
222+
{
223+
name: "high_cost",
224+
description: "Block when session cost exceeds threshold",
225+
evaluator: (ctx, params) => (ctx.sessionCost ?? 0) > (params.maxCost as number),
226+
},
227+
],
228+
rules: [
229+
{
230+
id: "cost-check",
231+
name: "Cost check",
232+
condition: { type: "high_cost", params: { maxCost: 10 } },
233+
outcome: "block",
234+
reason: "Session cost over budget",
235+
priority: 100,
236+
enabled: true,
237+
},
238+
],
239+
});
240+
241+
assert.ok(gov.getRegisteredCondition("high_cost"), "config.conditions should be registered");
242+
243+
const blocked = await gov.enforce({
244+
agentId: "a1",
245+
action: "tool_call",
246+
sessionCost: 15,
247+
});
248+
assert.equal(blocked.blocked, true);
249+
assert.equal(blocked.ruleId, "cost-check");
250+
251+
const allowed = await gov.enforce({
252+
agentId: "a1",
253+
action: "tool_call",
254+
sessionCost: 5,
255+
});
256+
assert.equal(allowed.blocked, false);
257+
});
258+
259+
test("unregisterCondition + clearConditionRegistry forward to engine", () => {
260+
const gov = createGovernance();
261+
gov.registerCondition({ name: "tmp", description: "tmp", evaluator: () => false });
262+
assert.ok(gov.getRegisteredCondition("tmp"));
263+
assert.equal(gov.unregisterCondition("tmp"), true);
264+
assert.equal(gov.getRegisteredCondition("tmp"), undefined);
265+
266+
gov.registerCondition({ name: "x", description: "x", evaluator: () => false });
267+
gov.clearConditionRegistry({ keepBuiltins: true });
268+
assert.equal(gov.getRegisteredCondition("x"), undefined);
269+
assert.ok(gov.getRegisteredCondition("tool_blocked"), "builtins kept");
270+
});
271+
});
272+
178273
describe("createPolicyEngine", () => {
179274
test("evaluates rules in priority order", () => {
180275
const engine = createPolicyEngine({

packages/governance/src/index.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ import {
3737
type IntegrityAuditEvent,
3838
} from "./audit-integrity.js";
3939
import type { AgentRegistration, GovernanceAssessment, FleetSummary } from "./types.js";
40-
import type { PolicyRule, PolicyEngine, PolicyStage, EnforcementContext, EnforcementDecision } from "./policy.js";
40+
import type {
41+
PolicyRule,
42+
PolicyEngine,
43+
PolicyOutcome,
44+
PolicyStage,
45+
EnforcementContext,
46+
EnforcementDecision,
47+
RegisteredConditionType,
48+
} from "./policy.js";
4149
import type { GovernanceStorage, StoredAgent, AuditEvent, AuditQueryFilters } from "./storage.js";
4250

4351
/**
@@ -76,7 +84,20 @@ export { createMemoryStorage } from "./storage.js";
7684
export interface GovernanceConfig {
7785
storage?: GovernanceStorage;
7886
rules?: PolicyRule[];
79-
defaultOutcome?: "allow" | "block";
87+
/**
88+
* Default outcome when no rules match. Accepts the full `PolicyOutcome`
89+
* union to mirror `PolicyEngineConfig.defaultOutcome` — the common case
90+
* is `"allow"` or `"block"`, but `"warn"` / `"require_approval"` /
91+
* `"mask"` are valid too.
92+
*/
93+
defaultOutcome?: PolicyOutcome;
94+
/**
95+
* Custom condition types to register on the underlying policy engine.
96+
* Mirrors `PolicyEngineConfig.conditions` so callers using
97+
* `createGovernance()` don't have to drop down to `createPolicyEngine()`
98+
* just to register a custom evaluator.
99+
*/
100+
conditions?: RegisteredConditionType[];
80101
/** When set, enforce() and register() POST to this URL instead of running locally */
81102
serverUrl?: string;
82103
/** Bearer token for remote calls — required when serverUrl is set */
@@ -189,6 +210,16 @@ export interface GovernanceInstance {
189210
addRule: (rule: PolicyRule) => void;
190211
/** Remove a policy rule by ID */
191212
removeRule: (ruleId: string) => void;
213+
/** Register a custom condition type on the underlying policy engine */
214+
registerCondition: (entry: RegisteredConditionType, opts?: { override?: boolean }) => void;
215+
/** Unregister a condition type by name */
216+
unregisterCondition: (name: string) => boolean;
217+
/** Get a registered condition type by name */
218+
getRegisteredCondition: (name: string) => RegisteredConditionType | undefined;
219+
/** List all registered condition types (custom + built-ins) */
220+
getRegisteredConditions: () => RegisteredConditionType[];
221+
/** Clear all registered conditions. Set `keepBuiltins: true` to re-register built-ins. */
222+
clearConditionRegistry: (opts?: { keepBuiltins?: boolean }) => void;
192223
/** Test API connectivity. Returns status without throwing. */
193224
connect: () => Promise<{ connected: boolean; mode: string; latencyMs: number }>;
194225
/** Current connection status (cached from last enforce/connect call). */
@@ -233,6 +264,7 @@ export function createGovernance(config: GovernanceConfig = {}): GovernanceInsta
233264
const policies = createPolicyEngine({
234265
rules: config.rules,
235266
defaultOutcome: config.defaultOutcome,
267+
conditions: config.conditions,
236268
});
237269

238270
// ── Integrity audit chain (opt-in) ───────────────────────────
@@ -600,6 +632,26 @@ export function createGovernance(config: GovernanceConfig = {}): GovernanceInsta
600632
policies.removeRule(ruleId);
601633
}
602634

635+
function registerCondition(entry: RegisteredConditionType, opts?: { override?: boolean }): void {
636+
policies.registerCondition(entry, opts);
637+
}
638+
639+
function unregisterCondition(name: string): boolean {
640+
return policies.unregisterCondition(name);
641+
}
642+
643+
function getRegisteredCondition(name: string): RegisteredConditionType | undefined {
644+
return policies.getRegisteredCondition(name);
645+
}
646+
647+
function getRegisteredConditions(): RegisteredConditionType[] {
648+
return policies.getRegisteredConditions();
649+
}
650+
651+
function clearConditionRegistry(opts?: { keepBuiltins?: boolean }): void {
652+
policies.clearConditionRegistry(opts);
653+
}
654+
603655
const noopStatus = () => ({ connected: true, mode: "local" as const, latencyMs: 0 });
604656

605657
const integrityChain = integrity
@@ -660,6 +712,11 @@ export function createGovernance(config: GovernanceConfig = {}): GovernanceInsta
660712
recordOutcome,
661713
score: scoreAgentFn, scoreFleet: scoreFleetFn,
662714
policies: readonlyPolicies, storage, addRule, removeRule,
715+
registerCondition,
716+
unregisterCondition,
717+
getRegisteredCondition,
718+
getRegisteredConditions,
719+
clearConditionRegistry,
663720
connect: remote ? remote.connect : async () => noopStatus(),
664721
status: remote ? remote.status : noopStatus,
665722
waitForApproval: remote

0 commit comments

Comments
 (0)