@agent-assistant/policy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +414 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# `@agent-assistant/policy`
|
|
2
|
+
|
|
3
|
+
**Status:** IMPLEMENTED
|
|
4
|
+
**Version:** 0.1.0 (pre-1.0, provisional)
|
|
5
|
+
**Spec:** `docs/specs/v1-policy-spec.md`
|
|
6
|
+
**Implementation plan:** `docs/architecture/v1-policy-implementation-plan.md`
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## What This Package Does
|
|
11
|
+
|
|
12
|
+
`@agent-assistant/policy` is the classification, gating, and audit layer for assistant actions — the boundary between "the assistant decided to act" and "the action actually executes."
|
|
13
|
+
|
|
14
|
+
It provides:
|
|
15
|
+
|
|
16
|
+
- **PolicyEngine** — evaluates actions against registered policy rules, applies risk classification, and returns structured decisions (`allow`, `deny`, `require_approval`, `escalate`)
|
|
17
|
+
- **Risk classification** — `RiskClassifier` interface with a pluggable classify function; `defaultRiskClassifier` returns `medium` for all unclassified actions
|
|
18
|
+
- **Policy rule registration** — products register `PolicyRule` objects with priority ordering; evaluation is first-match-wins
|
|
19
|
+
- **Approval contract** — `ApprovalHint` on `require_approval` decisions; `ApprovalResolution` for recording outcomes after approval flows complete
|
|
20
|
+
- **Audit hooks** — `AuditSink` interface called on every `evaluate()` call; every decision is recorded regardless of outcome
|
|
21
|
+
- **InMemoryAuditSink** — test adapter with an accessible `events` array; no external infrastructure required
|
|
22
|
+
- **Proactive action flag** — `Action.proactive` is a required field; rules may apply stricter gating to proactive actions
|
|
23
|
+
- **Fallback decision** — configurable per engine instance; defaults to `require_approval` (default-block posture: unclassified/unmatched actions are gated behind approval rather than silently allowed or denied)
|
|
24
|
+
|
|
25
|
+
This package does **not** own approval UX, approval workflows, scheduling, notification flows, session lifecycle, message delivery, persistent rule storage, or product-specific action catalogs. All of that stays in product code or other packages.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install @agent-assistant/policy
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
No `@agent-assistant/*` runtime dependencies. Only `nanoid` is required at runtime.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createActionPolicy, InMemoryAuditSink } from '@agent-assistant/policy';
|
|
43
|
+
import type { PolicyRule, RiskClassifier } from '@agent-assistant/policy';
|
|
44
|
+
|
|
45
|
+
const auditSink = new InMemoryAuditSink();
|
|
46
|
+
|
|
47
|
+
// Supply a product-specific classifier
|
|
48
|
+
const classifier: RiskClassifier = {
|
|
49
|
+
classify(action) {
|
|
50
|
+
switch (action.type) {
|
|
51
|
+
case 'send_email': return 'high';
|
|
52
|
+
case 'create_draft': return 'medium';
|
|
53
|
+
case 'read_inbox': return 'low';
|
|
54
|
+
default: return 'medium';
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const policyEngine = createActionPolicy({ classifier, auditSink });
|
|
60
|
+
|
|
61
|
+
// Register rules
|
|
62
|
+
policyEngine.registerRule({
|
|
63
|
+
id: 'deny-critical',
|
|
64
|
+
priority: 1,
|
|
65
|
+
description: 'Deny all critical-risk actions in v1',
|
|
66
|
+
evaluate(action, riskLevel) {
|
|
67
|
+
if (riskLevel === 'critical') {
|
|
68
|
+
return {
|
|
69
|
+
action: 'deny',
|
|
70
|
+
ruleId: 'deny-critical',
|
|
71
|
+
riskLevel,
|
|
72
|
+
reason: 'Critical actions are not permitted.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return null; // defer to next rule
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
policyEngine.registerRule({
|
|
80
|
+
id: 'require-approval-high',
|
|
81
|
+
priority: 10,
|
|
82
|
+
description: 'Require human approval for high-risk actions',
|
|
83
|
+
evaluate(action, riskLevel) {
|
|
84
|
+
if (riskLevel === 'high') {
|
|
85
|
+
return {
|
|
86
|
+
action: 'require_approval',
|
|
87
|
+
ruleId: 'require-approval-high',
|
|
88
|
+
riskLevel,
|
|
89
|
+
reason: 'High-risk actions require explicit human approval.',
|
|
90
|
+
approvalHint: {
|
|
91
|
+
approver: 'user',
|
|
92
|
+
prompt: `The assistant is about to: ${action.description}. Approve?`,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Evaluate an action before executing it
|
|
101
|
+
const action = {
|
|
102
|
+
id: 'act-001',
|
|
103
|
+
type: 'send_email',
|
|
104
|
+
description: 'Send follow-up to stakeholders',
|
|
105
|
+
sessionId: 'sess-abc',
|
|
106
|
+
userId: 'user-xyz',
|
|
107
|
+
proactive: false,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// evaluate() returns EvaluationResult: { decision, auditEventId }
|
|
111
|
+
const { decision, auditEventId } = await policyEngine.evaluate(action);
|
|
112
|
+
|
|
113
|
+
if (decision.action === 'allow') {
|
|
114
|
+
// execute the action
|
|
115
|
+
} else if (decision.action === 'require_approval') {
|
|
116
|
+
// enter approval flow using decision.approvalHint, then record resolution:
|
|
117
|
+
// await policyEngine.recordApproval(auditEventId, { approved: true, resolvedAt: ... });
|
|
118
|
+
} else if (decision.action === 'deny') {
|
|
119
|
+
// surface denial to user
|
|
120
|
+
} else if (decision.action === 'escalate') {
|
|
121
|
+
// route to configured escalation target
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Risk Levels
|
|
128
|
+
|
|
129
|
+
| Level | Meaning | Default gating |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `low` | Reversible, internal, no external side effects | Auto-approve |
|
|
132
|
+
| `medium` | External but limited blast radius | Auto-approve with audit |
|
|
133
|
+
| `high` | Significant external consequences; hard to reverse | Require human approval |
|
|
134
|
+
| `critical` | Irreversible, broad impact, or affects shared state | Escalate or deny |
|
|
135
|
+
|
|
136
|
+
Products override gating behavior through registered policy rules. The defaults above describe intent, not enforcement — enforcement is through the rules you register.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Risk Classifier
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
interface RiskClassifier {
|
|
144
|
+
classify(action: Action): RiskLevel | Promise<RiskLevel>;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The `defaultRiskClassifier` returns `medium` for all actions. Pass your own classifier to `createActionPolicy`:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const policyEngine = createActionPolicy({ classifier: myClassifier });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Classifiers may be async — useful when external context (e.g., target branch protection, PR size) informs the risk level.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Policy Rules
|
|
159
|
+
|
|
160
|
+
Rules are product-supplied. The engine evaluates them in priority order (lower number = higher priority). The first rule returning a non-null decision wins. If no rule matches, the fallback decision applies.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
interface PolicyRule {
|
|
164
|
+
id: string;
|
|
165
|
+
priority?: number; // default 100; lower evaluates first
|
|
166
|
+
evaluate(
|
|
167
|
+
action: Action,
|
|
168
|
+
riskLevel: RiskLevel,
|
|
169
|
+
context: PolicyEvaluationContext
|
|
170
|
+
): PolicyDecision | null | Promise<PolicyDecision | null>;
|
|
171
|
+
description?: string;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Return `null`** to defer to the next rule. This is how you compose rules without conflicts.
|
|
176
|
+
|
|
177
|
+
**Rule management:**
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
policyEngine.registerRule(rule); // register; throws PolicyError if id already exists
|
|
181
|
+
policyEngine.removeRule('rule-id'); // remove; throws RuleNotFoundError if not found
|
|
182
|
+
policyEngine.listRules(); // returns rules sorted by priority, then registration order
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Decisions
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
interface PolicyDecision {
|
|
191
|
+
action: 'allow' | 'deny' | 'require_approval' | 'escalate';
|
|
192
|
+
ruleId: string;
|
|
193
|
+
riskLevel: RiskLevel;
|
|
194
|
+
reason?: string;
|
|
195
|
+
approvalHint?: ApprovalHint; // present when action is 'require_approval'
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
| Decision | Caller behavior |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `allow` | Execute the action |
|
|
202
|
+
| `deny` | Do not execute; surface a denial reason to the user |
|
|
203
|
+
| `require_approval` | Block execution; enter approval flow using `approvalHint` |
|
|
204
|
+
| `escalate` | Block execution; notify configured escalation target |
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Approval Contract
|
|
209
|
+
|
|
210
|
+
When a rule returns `require_approval`, it may include an `ApprovalHint`:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
interface ApprovalHint {
|
|
214
|
+
approver?: string; // suggested approver role (e.g., 'workspace_admin', 'user')
|
|
215
|
+
timeoutMs?: number; // suggested timeout before auto-escalating
|
|
216
|
+
prompt?: string; // message to present to the approver
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
After the product resolves the approval flow, record the outcome using `engine.recordApproval()`:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
interface ApprovalResolution {
|
|
224
|
+
approved: boolean;
|
|
225
|
+
approvedBy?: string;
|
|
226
|
+
resolvedAt: string; // ISO-8601
|
|
227
|
+
comment?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// auditEventId comes from the EvaluationResult returned by evaluate()
|
|
231
|
+
await policyEngine.recordApproval(auditEventId, {
|
|
232
|
+
approved: true,
|
|
233
|
+
approvedBy: 'user-xyz',
|
|
234
|
+
resolvedAt: new Date().toISOString(),
|
|
235
|
+
comment: 'Approved after review.',
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`recordApproval()` emits a new `AuditEvent` to the configured sink with the original action, decision, and the `ApprovalResolution` populated in the `approval` field. Throws `PolicyError` if the `auditEventId` is unknown (evicted from the bounded in-memory map after 1000 evaluations).
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Proactive Action Gating
|
|
244
|
+
|
|
245
|
+
`Action.proactive` is a **required**, non-optional boolean. Callers must be explicit about whether an action originated from a user turn or from a proactive engine.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// In a proactive capability handler:
|
|
249
|
+
const action: Action = {
|
|
250
|
+
id: nanoid(),
|
|
251
|
+
type: 'proactive_follow_up',
|
|
252
|
+
description: 'Proactive check-in on stale thread',
|
|
253
|
+
sessionId: wakeUpContext.sessionId,
|
|
254
|
+
userId: sessionUserId,
|
|
255
|
+
proactive: true, // required
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const { decision, auditEventId } = await policyEngine.evaluate(action);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Policy rules receive `context.proactive` and can apply stricter gating:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
policyEngine.registerRule({
|
|
265
|
+
id: 'proactive-high-require-approval',
|
|
266
|
+
priority: 5,
|
|
267
|
+
evaluate(action, riskLevel, context) {
|
|
268
|
+
if (context.proactive && (riskLevel === 'high' || riskLevel === 'critical')) {
|
|
269
|
+
return {
|
|
270
|
+
action: 'require_approval',
|
|
271
|
+
ruleId: 'proactive-high-require-approval',
|
|
272
|
+
riskLevel,
|
|
273
|
+
approvalHint: {
|
|
274
|
+
approver: 'user',
|
|
275
|
+
prompt: `The assistant is about to take a proactive action: ${action.description}. Approve?`,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Audit Hooks
|
|
287
|
+
|
|
288
|
+
Every `evaluate()` call records an `AuditEvent`, regardless of the decision:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
interface AuditEvent {
|
|
292
|
+
id: string;
|
|
293
|
+
action: Action;
|
|
294
|
+
riskLevel: RiskLevel;
|
|
295
|
+
decision: PolicyDecision;
|
|
296
|
+
evaluatedAt: string; // ISO-8601
|
|
297
|
+
approval?: ApprovalResolution; // populated by the product after approval resolution
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface AuditSink {
|
|
301
|
+
record(event: AuditEvent): Promise<void>;
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**For tests and local development:** use `InMemoryAuditSink`:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const sink = new InMemoryAuditSink();
|
|
309
|
+
const engine = createActionPolicy({ auditSink: sink });
|
|
310
|
+
|
|
311
|
+
// After evaluate():
|
|
312
|
+
console.log(sink.events); // AuditEvent[]
|
|
313
|
+
sink.clear(); // reset
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**For production:** implement `AuditSink` against your own persistence backend (database, log aggregator, cloud audit service).
|
|
317
|
+
|
|
318
|
+
**No-op sink** when audit is not needed:
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
const engine = createActionPolicy({ auditSink: { record: async () => {} } });
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Wiring Traits to Policy
|
|
327
|
+
|
|
328
|
+
The policy package does not read traits directly. Products map trait values to policy configuration at setup time:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import { createActionPolicy } from '@agent-assistant/policy';
|
|
332
|
+
|
|
333
|
+
const policyEngine = createActionPolicy({
|
|
334
|
+
fallbackDecision: traits.riskTolerance === 'cautious' ? 'deny' : 'require_approval',
|
|
335
|
+
classifier: buildClassifierFromTraits(traits),
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Fallback Decision
|
|
342
|
+
|
|
343
|
+
When no registered rule produces a non-null decision, the engine applies the fallback:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
// Default fallback: require_approval
|
|
347
|
+
const engine = createActionPolicy();
|
|
348
|
+
|
|
349
|
+
// Override to deny all unmatched actions:
|
|
350
|
+
const strictEngine = createActionPolicy({ fallbackDecision: 'deny' });
|
|
351
|
+
|
|
352
|
+
// Override to allow all unmatched actions (permissive dev setup):
|
|
353
|
+
const permissiveEngine = createActionPolicy({ fallbackDecision: 'allow' });
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The fallback is recorded in the audit event with `ruleId: 'fallback'`.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Error Types
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
// Base policy error
|
|
364
|
+
class PolicyError extends Error { cause?: unknown }
|
|
365
|
+
|
|
366
|
+
// Thrown by removeRule() when ruleId is not found
|
|
367
|
+
class RuleNotFoundError extends PolicyError { ruleId: string }
|
|
368
|
+
|
|
369
|
+
// Thrown when the risk classifier throws or returns an invalid value
|
|
370
|
+
class ClassificationError extends PolicyError { cause?: unknown }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## What Stays Outside This Package
|
|
376
|
+
|
|
377
|
+
| Concern | Where it lives |
|
|
378
|
+
|---|---|
|
|
379
|
+
| Product-specific action type catalogs | Product repos |
|
|
380
|
+
| Commercial tier and pricing enforcement | Product repos |
|
|
381
|
+
| Customer-specific escalation chains | Product repos |
|
|
382
|
+
| Approval UX (modals, Slack buttons, email) | Product repos |
|
|
383
|
+
| Approval workflow state and timeouts | Product repos |
|
|
384
|
+
| User authentication and identity | Relay foundation (relayauth) |
|
|
385
|
+
| Fleet-level rate limiting | Relay foundation / cloud infra |
|
|
386
|
+
| Content moderation and safety filtering | External services / product repos |
|
|
387
|
+
| Session lifecycle | `@agent-assistant/sessions` |
|
|
388
|
+
| Outbound message delivery | `@agent-assistant/surfaces` + Relay runtime |
|
|
389
|
+
| Hosted audit pipelines and storage | `AgentWorkforce/cloud` |
|
|
390
|
+
| Persistent rule storage | Deferred to v1.1 |
|
|
391
|
+
| Time-based auto-escalation | Deferred to v1.1 |
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Package Structure
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
packages/policy/
|
|
399
|
+
package.json — nanoid runtime dep only
|
|
400
|
+
tsconfig.json
|
|
401
|
+
src/
|
|
402
|
+
types.ts — Action, RiskLevel, RiskClassifier, PolicyRule, PolicyDecision,
|
|
403
|
+
EvaluationResult, PolicyEvaluationContext, ApprovalHint,
|
|
404
|
+
ApprovalResolution, AuditEvent, AuditSink, InMemoryAuditSink,
|
|
405
|
+
error classes
|
|
406
|
+
policy.ts — createActionPolicy factory and PolicyEngine implementation
|
|
407
|
+
index.ts — public re-exports
|
|
408
|
+
policy.test.ts — 64 tests
|
|
409
|
+
README.md
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
POLICY_PACKAGE_DIRECTION_READY
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-assistant/policy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Action classification, gating, and audit contracts for agent assistants",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"nanoid": "^5.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "^5.4.0",
|
|
27
|
+
"vitest": "^1.6.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/AgentWorkforce/agent-assistant"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|