@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.
Files changed (2) hide show
  1. package/README.md +414 -0
  2. 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
+ }