@dsh-enhanced/assistant-policy 0.1.3

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/cordis.patch.yml +13 -0
  4. package/lib/approval-reviewer.d.ts +39 -0
  5. package/lib/approval-reviewer.d.ts.map +1 -0
  6. package/lib/approval-reviewer.js +119 -0
  7. package/lib/approval-reviewer.js.map +1 -0
  8. package/lib/auto-review.d.ts +22 -0
  9. package/lib/auto-review.d.ts.map +1 -0
  10. package/lib/auto-review.js +442 -0
  11. package/lib/auto-review.js.map +1 -0
  12. package/lib/evaluator.d.ts +4 -0
  13. package/lib/evaluator.d.ts.map +1 -0
  14. package/lib/evaluator.js +127 -0
  15. package/lib/evaluator.js.map +1 -0
  16. package/lib/index.d.ts +20 -0
  17. package/lib/index.d.ts.map +1 -0
  18. package/lib/index.js +16 -0
  19. package/lib/index.js.map +1 -0
  20. package/lib/ledger.d.ts +253 -0
  21. package/lib/ledger.d.ts.map +1 -0
  22. package/lib/ledger.js +891 -0
  23. package/lib/ledger.js.map +1 -0
  24. package/lib/redaction.d.ts +2 -0
  25. package/lib/redaction.d.ts.map +1 -0
  26. package/lib/redaction.js +21 -0
  27. package/lib/redaction.js.map +1 -0
  28. package/lib/service.d.ts +124 -0
  29. package/lib/service.d.ts.map +1 -0
  30. package/lib/service.js +773 -0
  31. package/lib/service.js.map +1 -0
  32. package/lib/session-event-registration.d.ts +23 -0
  33. package/lib/session-event-registration.d.ts.map +1 -0
  34. package/lib/session-event-registration.js +384 -0
  35. package/lib/session-event-registration.js.map +1 -0
  36. package/lib/settlement.d.ts +30 -0
  37. package/lib/settlement.d.ts.map +1 -0
  38. package/lib/settlement.js +54 -0
  39. package/lib/settlement.js.map +1 -0
  40. package/lib/sqlite.d.ts +9 -0
  41. package/lib/sqlite.d.ts.map +1 -0
  42. package/lib/sqlite.js +276 -0
  43. package/lib/sqlite.js.map +1 -0
  44. package/lib/tool-risk.d.ts +10 -0
  45. package/lib/tool-risk.d.ts.map +1 -0
  46. package/lib/tool-risk.js +321 -0
  47. package/lib/tool-risk.js.map +1 -0
  48. package/lib/types.d.ts +73 -0
  49. package/lib/types.d.ts.map +1 -0
  50. package/lib/types.js +2 -0
  51. package/lib/types.js.map +1 -0
  52. package/lib/version.d.ts +2 -0
  53. package/lib/version.d.ts.map +1 -0
  54. package/lib/version.js +2 -0
  55. package/lib/version.js.map +1 -0
  56. package/package.json +98 -0
package/lib/service.js ADDED
@@ -0,0 +1,773 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import { Context, Service } from '@deepseek-ai/cordis';
3
+ import Schema from '@deepseek-ai/schemastery';
4
+ import { registerAutoReviewAnswerer } from './auto-review.js';
5
+ import { approvalPermissionFingerprint, approvalPermissionStateOf, getApprovalReviewer, setApprovalReviewer, } from './approval-reviewer.js';
6
+ import { registerApprovalReviewerSessionEvent } from './session-event-registration.js';
7
+ import { AUTO_REVIEW_APPROVAL_REASON, HUMAN_APPROVAL_REASON, classifyToolRisk, } from './tool-risk.js';
8
+ import { compilePolicy, evaluatePolicy } from './evaluator.js';
9
+ import { PolicyLedger, PolicyLedgerError, } from './ledger.js';
10
+ const subjectKindSchema = Schema.union(['agent', 'background', 'external', '*']);
11
+ const resourceKindSchema = Schema.union([
12
+ 'automation',
13
+ 'credential',
14
+ 'evolution',
15
+ 'filesystem',
16
+ 'memory',
17
+ 'message',
18
+ 'network',
19
+ 'tool',
20
+ 'wiki',
21
+ '*',
22
+ ]);
23
+ const initiatorSchema = Schema.union(['background', 'external', 'foreground']);
24
+ const ruleSchema = Schema.object({
25
+ id: Schema.string().min(1).required(),
26
+ effect: Schema.union(['allow', 'deny']).required(),
27
+ subject: Schema.union([Schema.object({
28
+ kind: subjectKindSchema,
29
+ id: Schema.string(),
30
+ workspace: Schema.string(),
31
+ principal: Schema.string(),
32
+ })]),
33
+ actions: Schema.array(Schema.string().min(1)),
34
+ resource: Schema.union([Schema.object({
35
+ kind: resourceKindSchema,
36
+ id: Schema.string(),
37
+ })]),
38
+ context: Schema.union([Schema.object({
39
+ initiators: Schema.array(initiatorSchema),
40
+ })]),
41
+ budget: Schema.union([Schema.object({
42
+ id: Schema.string().min(1).required(),
43
+ amount: Schema.number().min(Number.MIN_VALUE).required(),
44
+ })]),
45
+ });
46
+ const configSchema = Schema.object({
47
+ databasePath: Schema.string().required(),
48
+ toolDefaultEffect: Schema.union(['deny', 'allow']).default('deny'),
49
+ autoReview: Schema.union([Schema.object({
50
+ enabled: Schema.boolean().default(true),
51
+ provider: Schema.string().min(1),
52
+ model: Schema.string().min(1),
53
+ timeoutMs: Schema.number().step(1).min(1).max(300_000).default(30_000),
54
+ maxTokens: Schema.number().step(1).min(1).max(4_096).default(512),
55
+ })]),
56
+ proposalMaintenanceIntervalMs: Schema.number().step(1).min(0).max(2_147_483_647).default(15_000),
57
+ rules: Schema.array(ruleSchema).default([]),
58
+ budgets: Schema.array(Schema.object({
59
+ id: Schema.string().min(1).required(),
60
+ metric: Schema.string().min(1).required(),
61
+ limit: Schema.number().min(Number.MIN_VALUE).required(),
62
+ periodMs: Schema.number().step(1).min(1).required(),
63
+ scope: Schema.union(['global', 'subject', 'workspace']).required(),
64
+ })).default([]),
65
+ });
66
+ const NATIVE_FULL_ADOPTIONS_GLOBAL_KEY = '__dshEnhancedAssistantPolicyNativeFullAdoptionsV1__';
67
+ const NATIVE_FULL_UNSETTLED_GLOBAL_KEY = '__dshEnhancedAssistantPolicyNativeFullUnsettledV1__';
68
+ /**
69
+ * One process-wide barrier, including across HMR module copies. A service-local
70
+ * map lets a replacement instance observe reviewer=none and execute before the
71
+ * older instance's durability barrier settles.
72
+ */
73
+ function sharedNativeFullAdoptions() {
74
+ const shared = globalThis;
75
+ const current = shared[NATIVE_FULL_ADOPTIONS_GLOBAL_KEY];
76
+ if (current !== undefined)
77
+ return current;
78
+ const created = new WeakMap();
79
+ Object.defineProperty(shared, NATIVE_FULL_ADOPTIONS_GLOBAL_KEY, {
80
+ configurable: false,
81
+ enumerable: false,
82
+ writable: false,
83
+ value: created,
84
+ });
85
+ return created;
86
+ }
87
+ function sharedNativeFullUnsettled() {
88
+ const shared = globalThis;
89
+ const current = shared[NATIVE_FULL_UNSETTLED_GLOBAL_KEY];
90
+ if (current !== undefined)
91
+ return current;
92
+ const created = new WeakMap();
93
+ Object.defineProperty(shared, NATIVE_FULL_UNSETTLED_GLOBAL_KEY, {
94
+ configurable: false,
95
+ enumerable: false,
96
+ writable: false,
97
+ value: created,
98
+ });
99
+ return created;
100
+ }
101
+ function denial(reasonCode) {
102
+ return { effect: 'deny', reasonCode, ruleId: undefined };
103
+ }
104
+ function compileBudgets(configs) {
105
+ const budgets = new Map();
106
+ for (const config of configs) {
107
+ if (budgets.has(config.id))
108
+ throw new Error(`assistant-policy: duplicate budget id: ${config.id}`);
109
+ budgets.set(config.id, Object.freeze({ ...config }));
110
+ }
111
+ return budgets;
112
+ }
113
+ export class AssistantPolicyService extends Service {
114
+ static Config = configSchema;
115
+ policy;
116
+ ledger;
117
+ budgets;
118
+ toolDefaultEffect;
119
+ initiators = new WeakMap();
120
+ nativeFullAdoptions = sharedNativeFullAdoptions();
121
+ nativeFullUnsettled = sharedNativeFullUnsettled();
122
+ reviewerEventRegistration;
123
+ policyContext;
124
+ active = true;
125
+ constructor(ctx, input, options = {}) {
126
+ super(ctx, 'assistantPolicy');
127
+ this.policyContext = ctx;
128
+ this.reviewerEventRegistration = registerApprovalReviewerSessionEvent(ctx);
129
+ let config;
130
+ try {
131
+ config = AssistantPolicyService.Config(input);
132
+ }
133
+ catch (error) {
134
+ throw new Error(`assistant-policy: invalid configuration: ${String(error)}`, { cause: error });
135
+ }
136
+ this.policy = compilePolicy(config.rules ?? []);
137
+ this.budgets = compileBudgets(config.budgets ?? []);
138
+ this.toolDefaultEffect = config.toolDefaultEffect ?? 'deny';
139
+ this.ledger = new PolicyLedger({
140
+ path: config.databasePath,
141
+ ...(options.now === undefined ? {} : { now: options.now }),
142
+ });
143
+ registerAutoReviewAnswerer(ctx, config.autoReview);
144
+ // PermissionPresetService predates AssistantPolicy's third permission
145
+ // dimension. Repair exact legacy native-full sessions at creation/resume,
146
+ // and keep the same promise as a tool-dispatch barrier so concurrent calls
147
+ // cannot observe reviewer=none before its durable flush settles.
148
+ ctx.inject(['sessions', 'permissionPresets'], (permissionCtx) => {
149
+ const adopt = (session) => {
150
+ void this.ensureNativeFullReviewer(permissionCtx, session);
151
+ };
152
+ permissionCtx.on('session/created', adopt);
153
+ for (const session of permissionCtx.sessions.list())
154
+ adopt(session);
155
+ });
156
+ ctx.effect(() => () => {
157
+ this.active = false;
158
+ this.ledger.close();
159
+ }, 'assistant-policy.database');
160
+ if ((config.proposalMaintenanceIntervalMs ?? 15_000) > 0) {
161
+ ctx.effect(() => {
162
+ const timer = setInterval(() => {
163
+ if (!this.active)
164
+ return;
165
+ try {
166
+ this.ledger.expireProposals(100);
167
+ }
168
+ catch {
169
+ // Maintenance is conservative and retried on the next bounded tick.
170
+ }
171
+ }, config.proposalMaintenanceIntervalMs ?? 15_000);
172
+ timer.unref();
173
+ return () => clearInterval(timer);
174
+ }, 'assistant-policy.proposal-maintenance');
175
+ }
176
+ ctx.inject(['tools'], (toolsCtx) => {
177
+ toolsCtx.on('tools/pre-execute', async (execution, next) => {
178
+ const agent = execution.agent;
179
+ const workspace = agent?.session.header.cwd;
180
+ if (agent === undefined || workspace === undefined || !isAbsolute(workspace))
181
+ return next();
182
+ const adoption = await this.ensureNativeFullReviewer(toolsCtx, agent.session);
183
+ if (adoption === 'unavailable') {
184
+ return {
185
+ kind: 'deny',
186
+ reason: 'assistant-policy: full-access reviewer migration could not be persisted; no tool was executed',
187
+ };
188
+ }
189
+ // `ready` only certifies the compatibility write's durability. A Web
190
+ // permission change can win after that promise resolves but before
191
+ // this continuation runs, so authorization must always fold the live
192
+ // three-dimensional state again at the final synchronous boundary.
193
+ if (getApprovalReviewer(agent.session) === 'none')
194
+ return next();
195
+ const risk = classifyToolRisk({
196
+ name: execution.name,
197
+ arguments: execution.arguments,
198
+ workspace,
199
+ });
200
+ if (risk === 'allow')
201
+ return next();
202
+ const permission = approvalPermissionStateOf(agent.session.events);
203
+ const configuredApproval = toolsCtx.get('approval')?.config.policy;
204
+ const approval = permission.approvalPolicyEvent
205
+ ? permission.approvalPolicy
206
+ : configuredApproval;
207
+ if (approval === 'never') {
208
+ return {
209
+ kind: 'deny',
210
+ reason: 'assistant-policy: [approval-disabled] approval is disabled by session policy; '
211
+ + 'no user approval was requested',
212
+ };
213
+ }
214
+ if (risk === 'defer-native-approval')
215
+ return next();
216
+ return {
217
+ kind: 'ask',
218
+ reason: risk === 'ask-review' ? AUTO_REVIEW_APPROVAL_REASON : HUMAN_APPROVAL_REASON,
219
+ };
220
+ });
221
+ toolsCtx.tools.guard(createPolicyToolGuard(this));
222
+ });
223
+ }
224
+ /**
225
+ * Join or initiate the one process-wide durability barrier for an exact
226
+ * legacy native-full session. `ready` means its reviewer state crossed the
227
+ * shared flush barrier; `not-applicable` means no compatibility event was
228
+ * required; `unavailable` is fail-closed after persistence/compensation
229
+ * could not establish a usable terminal state.
230
+ */
231
+ async reconcileNativeFullReviewer(session) {
232
+ if (!this.active)
233
+ return 'unavailable';
234
+ return await this.ensureNativeFullReviewer(this.policyContext, session);
235
+ }
236
+ nativeFullCandidate(ctx, session) {
237
+ const state = approvalPermissionStateOf(session.events);
238
+ if (state.reviewerEvent
239
+ || !state.sandboxModeEvent || state.sandboxMode !== 'danger-full-access'
240
+ || !state.approvalPolicyEvent || state.approvalPolicy !== 'never')
241
+ return false;
242
+ const selected = session.events.findLast(event => event.type === 'permission/preset')?.data.preset;
243
+ if (typeof selected !== 'string' || selected === '')
244
+ return false;
245
+ const presets = ctx.get('permissionPresets');
246
+ if (presets === undefined)
247
+ return false;
248
+ try {
249
+ const spec = presets.resolve(selected);
250
+ return spec.sandbox === 'danger-full-access'
251
+ && spec.approval === 'never'
252
+ && presets.current(session.events) === selected;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ }
258
+ async ensureNativeFullReviewer(ctx, session) {
259
+ const inFlight = this.nativeFullAdoptions.get(session);
260
+ if (inFlight !== undefined)
261
+ return await inFlight;
262
+ const unsettled = this.nativeFullUnsettled.get(session);
263
+ if (unsettled !== undefined) {
264
+ if (approvalPermissionFingerprint(session.events) !== unsettled.fingerprint) {
265
+ // A later permission operation owns the session now. Never compensate
266
+ // over its new state merely because an older migration was ambiguous.
267
+ this.nativeFullUnsettled.delete(session);
268
+ }
269
+ else {
270
+ const sessions = ctx.get('sessions');
271
+ if (sessions === undefined)
272
+ return 'unavailable';
273
+ const recovery = Promise.resolve().then(async () => {
274
+ try {
275
+ await this.reviewerEventRegistration.assertReady();
276
+ if (approvalPermissionFingerprint(session.events) !== unsettled.fingerprint) {
277
+ this.nativeFullUnsettled.delete(session);
278
+ return 'unavailable';
279
+ }
280
+ let compensation = unsettled;
281
+ if (unsettled.phase === 'widened') {
282
+ setApprovalReviewer(session, 'user');
283
+ compensation = {
284
+ fingerprint: approvalPermissionFingerprint(session.events),
285
+ phase: 'compensating',
286
+ };
287
+ this.nativeFullUnsettled.set(session, compensation);
288
+ }
289
+ if (await sessions.flush(session)
290
+ && approvalPermissionFingerprint(session.events) === compensation.fingerprint
291
+ && getApprovalReviewer(session) === 'user') {
292
+ this.nativeFullUnsettled.delete(session);
293
+ }
294
+ }
295
+ catch {
296
+ // Keep the exact unsettled fingerprint for a later reader/flush retry.
297
+ }
298
+ // A caller that observed an ambiguous widening must retry after the
299
+ // conservative terminal state is durably established.
300
+ return 'unavailable';
301
+ });
302
+ this.nativeFullAdoptions.set(session, recovery);
303
+ try {
304
+ return await recovery;
305
+ }
306
+ finally {
307
+ if (this.nativeFullAdoptions.get(session) === recovery) {
308
+ this.nativeFullAdoptions.delete(session);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ if (getApprovalReviewer(session) === 'none')
314
+ return 'ready';
315
+ if (!this.nativeFullCandidate(ctx, session))
316
+ return 'not-applicable';
317
+ const sessions = ctx.get('sessions');
318
+ if (sessions === undefined)
319
+ return 'unavailable';
320
+ const adoption = Promise.resolve().then(async () => {
321
+ try {
322
+ await this.reviewerEventRegistration.assertReady();
323
+ }
324
+ catch {
325
+ return 'unavailable';
326
+ }
327
+ let expectedPermission;
328
+ try {
329
+ if (!setApprovalReviewer(session, 'none') && getApprovalReviewer(session) !== 'none') {
330
+ return 'unavailable';
331
+ }
332
+ expectedPermission = approvalPermissionFingerprint(session.events);
333
+ this.nativeFullUnsettled.set(session, {
334
+ fingerprint: expectedPermission,
335
+ phase: 'widened',
336
+ });
337
+ if (await sessions.flush(session)) {
338
+ // A Web permission change may race the durability barrier. Never let
339
+ // the old full-state migration authorize a call after a newer
340
+ // downgrade or reviewer selection has won.
341
+ if (approvalPermissionFingerprint(session.events) === expectedPermission
342
+ && getApprovalReviewer(session) === 'none') {
343
+ this.nativeFullUnsettled.delete(session);
344
+ return 'ready';
345
+ }
346
+ this.nativeFullUnsettled.delete(session);
347
+ return 'not-applicable';
348
+ }
349
+ }
350
+ catch {
351
+ // Fall through to the conservative in-memory compensation below.
352
+ }
353
+ // A newer Web/picker choice owns the state now. The failed older
354
+ // migration must deny its tool, but must not append reviewer=user over
355
+ // that newer choice merely because its own flush acknowledgement failed.
356
+ if (expectedPermission !== undefined
357
+ && approvalPermissionFingerprint(session.events) !== expectedPermission) {
358
+ this.nativeFullUnsettled.delete(session);
359
+ return 'unavailable';
360
+ }
361
+ try {
362
+ setApprovalReviewer(session, 'user');
363
+ const compensation = {
364
+ fingerprint: approvalPermissionFingerprint(session.events),
365
+ phase: 'compensating',
366
+ };
367
+ this.nativeFullUnsettled.set(session, compensation);
368
+ // The first flush may have thrown after committing reviewer=none.
369
+ // Best-effort a second barrier so a cold resume cannot observe only
370
+ // the widening half of this compatibility migration.
371
+ if (await sessions.flush(session)
372
+ && approvalPermissionFingerprint(session.events) === compensation.fingerprint
373
+ && getApprovalReviewer(session) === 'user') {
374
+ this.nativeFullUnsettled.delete(session);
375
+ }
376
+ }
377
+ catch { }
378
+ return 'unavailable';
379
+ });
380
+ this.nativeFullAdoptions.set(session, adoption);
381
+ try {
382
+ return await adoption;
383
+ }
384
+ finally {
385
+ if (this.nativeFullAdoptions.get(session) === adoption)
386
+ this.nativeFullAdoptions.delete(session);
387
+ }
388
+ }
389
+ assertActive() {
390
+ if (!this.active)
391
+ throw new Error('assistant-policy service is disposed');
392
+ }
393
+ evaluate(request) {
394
+ this.assertActive();
395
+ if (this.ledger.getEmergencyStop().enabled)
396
+ return denial('emergency-stop');
397
+ return evaluatePolicy(this.policy, request);
398
+ }
399
+ authorize(request, options = {}) {
400
+ let decision = this.evaluate(request);
401
+ if (decision.effect === 'allow' && decision.budget !== undefined) {
402
+ decision = this.consumeAuthorizationBudget(request.subject, decision, options.idempotencyKey);
403
+ }
404
+ this.ledger.appendAudit({
405
+ actor: `${request.subject.kind}:${request.subject.id}`,
406
+ action: request.action,
407
+ resource: request.resource,
408
+ outcome: decision.effect === 'allow' ? 'allowed' : 'denied',
409
+ reasonCode: decision.reasonCode,
410
+ details: options.auditDetails ?? {},
411
+ });
412
+ return decision;
413
+ }
414
+ /** Read-only declaration lookup for consumers that must prove metric compatibility before reserving. */
415
+ getBudgetConfig(budgetId) {
416
+ this.assertActive();
417
+ return this.budgets.get(budgetId);
418
+ }
419
+ reserve(input) {
420
+ this.assertActive();
421
+ const result = this.reserveConfiguredBudget(input);
422
+ this.ledger.appendAudit({
423
+ actor: `${input.subject.kind}:${input.subject.id}`,
424
+ action: 'budget.reserve',
425
+ resource: { kind: 'budget', id: input.budgetId },
426
+ outcome: result.status,
427
+ reasonCode: result.replayed ? 'idempotent-replay' : 'reserved',
428
+ details: { amount: input.amount, remaining: result.remaining },
429
+ });
430
+ return result;
431
+ }
432
+ finalize(reservationId, actualAmount) {
433
+ this.assertActive();
434
+ const result = this.ledger.finalize(reservationId, actualAmount);
435
+ this.ledger.appendAudit({
436
+ actor: 'system',
437
+ action: 'budget.finalize',
438
+ resource: { kind: 'budget-reservation', id: reservationId },
439
+ outcome: result.status,
440
+ reasonCode: result.replayed ? 'idempotent-replay' : 'finalized',
441
+ details: { actualAmount, remaining: result.remaining },
442
+ });
443
+ return result;
444
+ }
445
+ release(reservationId) {
446
+ this.assertActive();
447
+ const result = this.ledger.release(reservationId);
448
+ this.ledger.appendAudit({
449
+ actor: 'system',
450
+ action: 'budget.release',
451
+ resource: { kind: 'budget-reservation', id: reservationId },
452
+ outcome: result.status,
453
+ reasonCode: result.replayed ? 'idempotent-replay' : 'released',
454
+ details: { remaining: result.remaining },
455
+ });
456
+ return result;
457
+ }
458
+ propose(input) {
459
+ this.assertActive();
460
+ const result = this.ledger.propose(input);
461
+ this.ledger.appendAudit({
462
+ actor: input.requester,
463
+ action: 'approval.propose',
464
+ resource: { kind: 'approval-proposal', id: result.proposalId },
465
+ outcome: result.status,
466
+ reasonCode: result.replayed ? 'idempotent-replay' : 'proposal-created',
467
+ details: { diffHash: result.diffHash, expiresAt: result.expiresAt },
468
+ });
469
+ return result;
470
+ }
471
+ decideProposal(input) {
472
+ this.assertActive();
473
+ const result = this.ledger.decideProposal(input);
474
+ this.ledger.appendAudit({
475
+ actor: input.principal,
476
+ action: 'approval.decide',
477
+ resource: { kind: 'approval-proposal', id: result.proposalId },
478
+ outcome: result.status,
479
+ reasonCode: result.replayed ? 'idempotent-replay' : 'principal-decision',
480
+ details: { decision: input.decision, status: result.status },
481
+ });
482
+ return result;
483
+ }
484
+ expireProposals(limit = 100) {
485
+ this.assertActive();
486
+ return this.ledger.expireProposals(limit);
487
+ }
488
+ /**
489
+ * Read one proposal's current status without deciding it. Policy deliberately
490
+ * never calls back into a domain, so a domain reconciler polls this seam and
491
+ * then commits the decided outcome through its own approval gate.
492
+ */
493
+ getProposal(proposalId) {
494
+ this.assertActive();
495
+ return this.ledger.getProposal(proposalId);
496
+ }
497
+ /**
498
+ * Read-only recovery for the cross-database window where Policy committed a
499
+ * proposal but the owning domain did not persist its proposal ID.
500
+ */
501
+ getProposalByIdempotencyKey(input) {
502
+ this.assertActive();
503
+ return this.ledger.getProposalByIdempotencyKey(input);
504
+ }
505
+ /**
506
+ * Atomic cross-database recovery/creation under an absolute deadline. Once the
507
+ * deadline passes without a proposal, Policy tombstones the idempotency key so
508
+ * another process cannot create an orphan approval dispatch later.
509
+ */
510
+ recoverOrCreateProposal(input) {
511
+ this.assertActive();
512
+ const result = this.ledger.recoverOrCreateProposal(input);
513
+ this.ledger.appendAudit({
514
+ actor: input.requester,
515
+ action: 'approval.recover-or-create',
516
+ resource: { kind: 'approval-idempotency', id: input.idempotencyKey },
517
+ outcome: result.kind === 'proposal' ? result.proposal.status : 'abandoned',
518
+ reasonCode: result.kind === 'proposal'
519
+ ? (result.proposal.replayed ? 'idempotent-recovery' : 'proposal-created')
520
+ : (result.replayed ? 'abandonment-replay' : 'deadline-elapsed'),
521
+ details: result.kind === 'proposal'
522
+ ? { proposalId: result.proposal.proposalId, expiresAt: result.proposal.expiresAt }
523
+ : { notAfter: result.notAfter, abandonedAt: result.abandonedAt },
524
+ });
525
+ return result;
526
+ }
527
+ listPendingApprovalDispatches(limit = 100, after) {
528
+ this.assertActive();
529
+ return this.ledger.listPendingApprovalDispatches(limit, after);
530
+ }
531
+ markApprovalDispatchEnqueued(proposalId, expectedVersion) {
532
+ this.assertActive();
533
+ const result = this.ledger.markApprovalDispatchEnqueued(proposalId, expectedVersion);
534
+ this.ledger.appendAudit({
535
+ actor: 'system:approval-dispatch',
536
+ action: 'approval.dispatch.enqueue',
537
+ resource: { kind: 'approval-proposal', id: proposalId },
538
+ outcome: result.state,
539
+ reasonCode: result.replayed ? 'idempotent-replay' : 'enqueued',
540
+ details: { payloadHash: result.payloadHash, dispatchVersion: result.version },
541
+ });
542
+ return result;
543
+ }
544
+ setEmergencyStop(input) {
545
+ this.assertActive();
546
+ const state = this.ledger.setEmergencyStop(input);
547
+ this.ledger.appendAudit({
548
+ actor: input.actor,
549
+ action: 'policy.emergency-stop',
550
+ resource: { kind: 'policy', id: 'emergency-stop' },
551
+ outcome: input.enabled ? 'enabled' : 'disabled',
552
+ reasonCode: 'owner-change',
553
+ details: { enabled: input.enabled, reason: input.reason },
554
+ });
555
+ return state;
556
+ }
557
+ getEmergencyStop() {
558
+ this.assertActive();
559
+ return this.ledger.getEmergencyStop();
560
+ }
561
+ queryAudit(options = {}) {
562
+ this.assertActive();
563
+ return this.ledger.queryAudit(options);
564
+ }
565
+ health() {
566
+ this.assertActive();
567
+ return {
568
+ emergencyStop: this.ledger.getEmergencyStop().enabled,
569
+ lastAuditSequence: this.ledger.health().lastAuditSequence,
570
+ };
571
+ }
572
+ bindInitiator(agent, initiator, principal) {
573
+ this.assertActive();
574
+ const previous = this.initiators.get(agent);
575
+ const binding = {
576
+ initiator,
577
+ ...(principal === undefined ? {} : { principal }),
578
+ token: Symbol('assistant-policy-initiator'),
579
+ };
580
+ this.initiators.set(agent, binding);
581
+ let active = true;
582
+ return () => {
583
+ if (!active)
584
+ return;
585
+ active = false;
586
+ if (this.initiators.get(agent)?.token !== binding.token)
587
+ return;
588
+ if (previous === undefined)
589
+ this.initiators.delete(agent);
590
+ else
591
+ this.initiators.set(agent, previous);
592
+ };
593
+ }
594
+ authorizeAgent(agent, action, resource, options = {}) {
595
+ this.assertActive();
596
+ if (agent === undefined)
597
+ return this.auditAgentIdentityFailure(action, resource, 'missing-agent');
598
+ const workspace = agent.session.header.cwd;
599
+ if (workspace === undefined || !isAbsolute(workspace)) {
600
+ return this.auditAgentIdentityFailure(action, resource, 'missing-workspace');
601
+ }
602
+ const preset = agent.session.header.agentPreset;
603
+ if (preset === undefined || preset === '') {
604
+ return this.auditAgentIdentityFailure(action, resource, 'missing-agent-preset');
605
+ }
606
+ const authority = this.initiators.get(agent);
607
+ return this.authorize({
608
+ subject: {
609
+ kind: 'agent',
610
+ id: preset,
611
+ workspace,
612
+ ...(authority?.principal === undefined ? {} : { principal: authority.principal }),
613
+ },
614
+ action,
615
+ resource,
616
+ context: { initiator: authority?.initiator ?? 'foreground' },
617
+ }, options);
618
+ }
619
+ authorizeToolExecution(execution) {
620
+ this.assertActive();
621
+ const agent = execution.agent;
622
+ if (agent === undefined)
623
+ return this.auditToolIdentityFailure(execution, 'missing-agent');
624
+ const workspace = agent.session.header.cwd;
625
+ if (workspace === undefined || !isAbsolute(workspace)) {
626
+ return this.auditToolIdentityFailure(execution, 'missing-workspace');
627
+ }
628
+ const preset = agent.session.header.agentPreset;
629
+ if (preset === undefined || preset === '') {
630
+ return this.auditToolIdentityFailure(execution, 'missing-agent-preset');
631
+ }
632
+ const authority = this.initiators.get(agent);
633
+ const request = {
634
+ subject: {
635
+ kind: 'agent',
636
+ id: preset,
637
+ workspace,
638
+ ...(authority?.principal === undefined ? {} : { principal: authority.principal }),
639
+ },
640
+ action: 'execute',
641
+ resource: { kind: 'tool', id: execution.name },
642
+ context: { initiator: authority?.initiator ?? 'foreground' },
643
+ };
644
+ let decision = this.evaluate(request);
645
+ if (decision.reasonCode === 'default-deny' && this.toolDefaultEffect === 'allow') {
646
+ decision = { effect: 'allow', reasonCode: 'tool-default-allow', ruleId: undefined };
647
+ }
648
+ if (decision.effect === 'allow' && decision.budget !== undefined) {
649
+ decision = this.consumeAuthorizationBudget(request.subject, decision, `tool:${String(execution.rootCallId)}:${String(execution.callId)}`);
650
+ }
651
+ this.ledger.appendAudit({
652
+ actor: `${request.subject.kind}:${request.subject.id}`,
653
+ action: request.action,
654
+ resource: request.resource,
655
+ outcome: decision.effect === 'allow' ? 'allowed' : 'denied',
656
+ reasonCode: decision.reasonCode,
657
+ details: {
658
+ callId: execution.callId,
659
+ rootCallId: execution.rootCallId,
660
+ arguments: execution.arguments,
661
+ },
662
+ });
663
+ return decision;
664
+ }
665
+ auditAgentIdentityFailure(action, resource, reasonCode) {
666
+ const decision = denial(reasonCode);
667
+ this.ledger.appendAudit({
668
+ actor: 'unknown-agent',
669
+ action,
670
+ resource,
671
+ outcome: 'denied',
672
+ reasonCode,
673
+ details: {},
674
+ });
675
+ return decision;
676
+ }
677
+ consumeAuthorizationBudget(subject, decision, idempotencyKey) {
678
+ const charge = decision.budget;
679
+ if (charge === undefined)
680
+ return decision;
681
+ if (idempotencyKey === undefined || idempotencyKey.trim() === '') {
682
+ return denial('budget-idempotency-required');
683
+ }
684
+ try {
685
+ const reservation = this.reserveConfiguredBudget({
686
+ budgetId: charge.id,
687
+ subject,
688
+ amount: charge.amount,
689
+ idempotencyKey,
690
+ });
691
+ this.ledger.finalize(reservation.reservationId, charge.amount);
692
+ return decision;
693
+ }
694
+ catch (error) {
695
+ if (error instanceof PolicyLedgerError && error.code === 'budget-exhausted') {
696
+ return denial('budget-exhausted');
697
+ }
698
+ if (error instanceof AssistantPolicyError && error.code === 'budget-not-configured') {
699
+ return denial('budget-not-configured');
700
+ }
701
+ throw error;
702
+ }
703
+ }
704
+ reserveConfiguredBudget(input) {
705
+ const budget = this.budgets.get(input.budgetId);
706
+ if (budget === undefined) {
707
+ throw new AssistantPolicyError('budget-not-configured', `budget is not configured: ${input.budgetId}`);
708
+ }
709
+ let scope;
710
+ switch (budget.scope) {
711
+ case 'global':
712
+ scope = 'global';
713
+ break;
714
+ case 'subject':
715
+ scope = `${input.subject.kind}:${input.subject.id}`;
716
+ break;
717
+ case 'workspace':
718
+ if (input.subject.workspace === undefined || input.subject.workspace === '') {
719
+ throw new AssistantPolicyError('missing-workspace', 'workspace-scoped budget requires a workspace');
720
+ }
721
+ scope = `workspace:${input.subject.workspace}`;
722
+ break;
723
+ }
724
+ const ledgerInput = {
725
+ scope,
726
+ metric: budget.metric,
727
+ limit: budget.limit,
728
+ amount: input.amount,
729
+ periodMs: budget.periodMs,
730
+ idempotencyKey: input.idempotencyKey,
731
+ };
732
+ return this.ledger.reserve(ledgerInput);
733
+ }
734
+ auditToolIdentityFailure(execution, reasonCode) {
735
+ const decision = denial(reasonCode);
736
+ this.ledger.appendAudit({
737
+ actor: 'unknown',
738
+ action: 'execute',
739
+ resource: { kind: 'tool', id: execution.name },
740
+ outcome: 'denied',
741
+ reasonCode,
742
+ details: {
743
+ callId: execution.callId,
744
+ rootCallId: execution.rootCallId,
745
+ arguments: execution.arguments,
746
+ },
747
+ });
748
+ return decision;
749
+ }
750
+ }
751
+ export function createPolicyToolGuard(service) {
752
+ return (execution) => {
753
+ try {
754
+ const decision = service.authorizeToolExecution(execution);
755
+ if (decision.effect === 'allow')
756
+ return undefined;
757
+ return `assistant-policy: ${decision.reasonCode}${decision.ruleId === undefined ? '' : ` (${decision.ruleId})`}`;
758
+ }
759
+ catch {
760
+ return 'assistant-policy: policy-error';
761
+ }
762
+ };
763
+ }
764
+ export class AssistantPolicyError extends Error {
765
+ code;
766
+ constructor(code, message) {
767
+ super(message);
768
+ this.code = code;
769
+ this.name = 'AssistantPolicyError';
770
+ }
771
+ }
772
+ export const Config = AssistantPolicyService.Config;
773
+ //# sourceMappingURL=service.js.map