@vellumai/assistant 0.3.18 → 0.3.19
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/ARCHITECTURE.md +4 -0
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-dispatch.test.ts +61 -1
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +1 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/calls/call-controller.ts +27 -6
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +42 -3
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/session-process.ts +12 -0
- package/src/memory/db-init.ts +9 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/guardian-action-store.ts +8 -0
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +30 -0
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/routes/guardian-approval-interception.ts +116 -0
- package/src/runtime/routes/inbound-message-handler.ts +94 -27
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRUD and atomic consume for scoped approval grants.
|
|
3
|
+
*
|
|
4
|
+
* Grants authorise exactly one tool execution. Two scope modes exist:
|
|
5
|
+
* - `request_id` — grant is bound to a specific pending request
|
|
6
|
+
* - `tool_signature` — grant is bound to a tool name + input digest
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - At most one successful consume per grant (CAS: active -> consumed).
|
|
10
|
+
* - Matching requires all non-null scope fields to match exactly.
|
|
11
|
+
* - Expired and revoked grants cannot be consumed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { and, eq, lt, sql } from 'drizzle-orm';
|
|
15
|
+
import { v4 as uuid } from 'uuid';
|
|
16
|
+
|
|
17
|
+
import { getDb, rawChanges } from './db.js';
|
|
18
|
+
import { scopedApprovalGrants } from './schema.js';
|
|
19
|
+
import { getLogger } from '../util/logger.js';
|
|
20
|
+
|
|
21
|
+
const log = getLogger('scoped-approval-grants');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export type ScopeMode = 'request_id' | 'tool_signature';
|
|
28
|
+
export type GrantStatus = 'active' | 'consumed' | 'expired' | 'revoked';
|
|
29
|
+
|
|
30
|
+
export interface ScopedApprovalGrant {
|
|
31
|
+
id: string;
|
|
32
|
+
assistantId: string;
|
|
33
|
+
scopeMode: ScopeMode;
|
|
34
|
+
requestId: string | null;
|
|
35
|
+
toolName: string | null;
|
|
36
|
+
inputDigest: string | null;
|
|
37
|
+
requestChannel: string;
|
|
38
|
+
decisionChannel: string;
|
|
39
|
+
executionChannel: string | null;
|
|
40
|
+
conversationId: string | null;
|
|
41
|
+
callSessionId: string | null;
|
|
42
|
+
requesterExternalUserId: string | null;
|
|
43
|
+
guardianExternalUserId: string | null;
|
|
44
|
+
status: GrantStatus;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
consumedAt: string | null;
|
|
47
|
+
consumedByRequestId: string | null;
|
|
48
|
+
createdAt: string;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Constants
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Max CAS retry attempts when a concurrent consumer steals the selected candidate. */
|
|
57
|
+
const MAX_CAS_RETRIES = 3;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function rowToGrant(row: typeof scopedApprovalGrants.$inferSelect): ScopedApprovalGrant {
|
|
64
|
+
return {
|
|
65
|
+
id: row.id,
|
|
66
|
+
assistantId: row.assistantId,
|
|
67
|
+
scopeMode: row.scopeMode as ScopeMode,
|
|
68
|
+
requestId: row.requestId,
|
|
69
|
+
toolName: row.toolName,
|
|
70
|
+
inputDigest: row.inputDigest,
|
|
71
|
+
requestChannel: row.requestChannel,
|
|
72
|
+
decisionChannel: row.decisionChannel,
|
|
73
|
+
executionChannel: row.executionChannel,
|
|
74
|
+
conversationId: row.conversationId,
|
|
75
|
+
callSessionId: row.callSessionId,
|
|
76
|
+
requesterExternalUserId: row.requesterExternalUserId,
|
|
77
|
+
guardianExternalUserId: row.guardianExternalUserId,
|
|
78
|
+
status: row.status as GrantStatus,
|
|
79
|
+
expiresAt: row.expiresAt,
|
|
80
|
+
consumedAt: row.consumedAt,
|
|
81
|
+
consumedByRequestId: row.consumedByRequestId,
|
|
82
|
+
createdAt: row.createdAt,
|
|
83
|
+
updatedAt: row.updatedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Create
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export interface CreateScopedApprovalGrantParams {
|
|
92
|
+
assistantId: string;
|
|
93
|
+
scopeMode: ScopeMode;
|
|
94
|
+
requestId?: string | null;
|
|
95
|
+
toolName?: string | null;
|
|
96
|
+
inputDigest?: string | null;
|
|
97
|
+
requestChannel: string;
|
|
98
|
+
decisionChannel: string;
|
|
99
|
+
executionChannel?: string | null;
|
|
100
|
+
conversationId?: string | null;
|
|
101
|
+
callSessionId?: string | null;
|
|
102
|
+
requesterExternalUserId?: string | null;
|
|
103
|
+
guardianExternalUserId?: string | null;
|
|
104
|
+
expiresAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createScopedApprovalGrant(params: CreateScopedApprovalGrantParams): ScopedApprovalGrant {
|
|
108
|
+
const db = getDb();
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
const id = uuid();
|
|
111
|
+
|
|
112
|
+
const row = {
|
|
113
|
+
id,
|
|
114
|
+
assistantId: params.assistantId,
|
|
115
|
+
scopeMode: params.scopeMode,
|
|
116
|
+
requestId: params.requestId ?? null,
|
|
117
|
+
toolName: params.toolName ?? null,
|
|
118
|
+
inputDigest: params.inputDigest ?? null,
|
|
119
|
+
requestChannel: params.requestChannel,
|
|
120
|
+
decisionChannel: params.decisionChannel,
|
|
121
|
+
executionChannel: params.executionChannel ?? null,
|
|
122
|
+
conversationId: params.conversationId ?? null,
|
|
123
|
+
callSessionId: params.callSessionId ?? null,
|
|
124
|
+
requesterExternalUserId: params.requesterExternalUserId ?? null,
|
|
125
|
+
guardianExternalUserId: params.guardianExternalUserId ?? null,
|
|
126
|
+
status: 'active' as const,
|
|
127
|
+
expiresAt: params.expiresAt,
|
|
128
|
+
consumedAt: null,
|
|
129
|
+
consumedByRequestId: null,
|
|
130
|
+
createdAt: now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
db.insert(scopedApprovalGrants).values(row).run();
|
|
135
|
+
|
|
136
|
+
log.info(
|
|
137
|
+
{
|
|
138
|
+
event: 'scoped_grant_created',
|
|
139
|
+
grantId: id,
|
|
140
|
+
scopeMode: params.scopeMode,
|
|
141
|
+
toolName: params.toolName ?? null,
|
|
142
|
+
assistantId: params.assistantId,
|
|
143
|
+
requestChannel: params.requestChannel,
|
|
144
|
+
decisionChannel: params.decisionChannel,
|
|
145
|
+
executionChannel: params.executionChannel ?? null,
|
|
146
|
+
expiresAt: params.expiresAt,
|
|
147
|
+
},
|
|
148
|
+
'Scoped approval grant created',
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return rowToGrant(row);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Consume by request ID (CAS: active -> consumed)
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export interface ConsumeByRequestIdResult {
|
|
159
|
+
ok: boolean;
|
|
160
|
+
grant: ScopedApprovalGrant | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Atomically consume a grant by request ID.
|
|
165
|
+
*
|
|
166
|
+
* Only succeeds when exactly one active, non-expired grant matches the
|
|
167
|
+
* given `requestId` and `assistantId`. Uses compare-and-swap on the
|
|
168
|
+
* `status` column so concurrent consumers race safely — at most one wins.
|
|
169
|
+
*/
|
|
170
|
+
export function consumeScopedApprovalGrantByRequestId(
|
|
171
|
+
requestId: string,
|
|
172
|
+
consumingRequestId: string,
|
|
173
|
+
assistantId: string,
|
|
174
|
+
now?: string,
|
|
175
|
+
): ConsumeByRequestIdResult {
|
|
176
|
+
const db = getDb();
|
|
177
|
+
const currentTime = now ?? new Date().toISOString();
|
|
178
|
+
|
|
179
|
+
// Two-step select-then-update with LIMIT 1 to consume exactly one grant
|
|
180
|
+
// even if duplicate rows exist (the index on request_id is non-unique).
|
|
181
|
+
for (let attempt = 0; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
182
|
+
const candidate = db
|
|
183
|
+
.select({ id: scopedApprovalGrants.id })
|
|
184
|
+
.from(scopedApprovalGrants)
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
eq(scopedApprovalGrants.requestId, requestId),
|
|
188
|
+
eq(scopedApprovalGrants.assistantId, assistantId),
|
|
189
|
+
eq(scopedApprovalGrants.scopeMode, 'request_id'),
|
|
190
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
191
|
+
sql`${scopedApprovalGrants.expiresAt} > ${currentTime}`,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
.limit(1)
|
|
195
|
+
.get();
|
|
196
|
+
|
|
197
|
+
if (!candidate) {
|
|
198
|
+
log.info(
|
|
199
|
+
{ event: 'scoped_grant_consume_miss', requestId, consumingRequestId, assistantId, scopeMode: 'request_id', attempt },
|
|
200
|
+
'No matching active grant found for request ID',
|
|
201
|
+
);
|
|
202
|
+
return { ok: false, grant: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
db.update(scopedApprovalGrants)
|
|
206
|
+
.set({
|
|
207
|
+
status: 'consumed',
|
|
208
|
+
consumedAt: currentTime,
|
|
209
|
+
consumedByRequestId: consumingRequestId,
|
|
210
|
+
updatedAt: currentTime,
|
|
211
|
+
})
|
|
212
|
+
.where(
|
|
213
|
+
and(
|
|
214
|
+
eq(scopedApprovalGrants.id, candidate.id),
|
|
215
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
.run();
|
|
219
|
+
|
|
220
|
+
if (rawChanges() === 0) {
|
|
221
|
+
// CAS failed — another consumer raced and won this candidate; retry with next match
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fetch the consumed grant to return to the caller
|
|
226
|
+
const row = db
|
|
227
|
+
.select()
|
|
228
|
+
.from(scopedApprovalGrants)
|
|
229
|
+
.where(eq(scopedApprovalGrants.id, candidate.id))
|
|
230
|
+
.get();
|
|
231
|
+
|
|
232
|
+
const grant = row ? rowToGrant(row) : null;
|
|
233
|
+
log.info(
|
|
234
|
+
{ event: 'scoped_grant_consume_success', grantId: grant?.id, requestId, consumingRequestId, assistantId, scopeMode: 'request_id' },
|
|
235
|
+
'Scoped approval grant consumed by request ID',
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return { ok: true, grant };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// All retry attempts exhausted — every candidate was stolen by concurrent consumers
|
|
242
|
+
log.info(
|
|
243
|
+
{ event: 'scoped_grant_consume_miss', requestId, consumingRequestId, assistantId, scopeMode: 'request_id', reason: 'cas_exhausted' },
|
|
244
|
+
'All CAS retry attempts exhausted for request ID consume',
|
|
245
|
+
);
|
|
246
|
+
return { ok: false, grant: null };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Consume by tool signature (CAS: active -> consumed)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
export interface ConsumeByToolSignatureParams {
|
|
254
|
+
toolName: string;
|
|
255
|
+
inputDigest: string;
|
|
256
|
+
consumingRequestId: string;
|
|
257
|
+
/** Optional context constraints — only matched when the grant has a non-null value */
|
|
258
|
+
assistantId?: string;
|
|
259
|
+
executionChannel?: string;
|
|
260
|
+
conversationId?: string;
|
|
261
|
+
callSessionId?: string;
|
|
262
|
+
requesterExternalUserId?: string;
|
|
263
|
+
now?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface ConsumeByToolSignatureResult {
|
|
267
|
+
ok: boolean;
|
|
268
|
+
grant: ScopedApprovalGrant | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Atomically consume a grant by tool name + input digest.
|
|
273
|
+
*
|
|
274
|
+
* All non-null scope fields on the grant must match the provided context.
|
|
275
|
+
* This is enforced via SQL conditions that check: either the grant field is
|
|
276
|
+
* NULL (wildcard), or it equals the provided value.
|
|
277
|
+
*
|
|
278
|
+
* If a CAS contention miss occurs (another consumer races and wins the
|
|
279
|
+
* selected candidate), re-selects and retries up to {@link MAX_CAS_RETRIES}
|
|
280
|
+
* times before giving up. This prevents false denials when multiple matching
|
|
281
|
+
* grants exist but a concurrent consumer steals the first pick.
|
|
282
|
+
*/
|
|
283
|
+
export function consumeScopedApprovalGrantByToolSignature(
|
|
284
|
+
params: ConsumeByToolSignatureParams,
|
|
285
|
+
): ConsumeByToolSignatureResult {
|
|
286
|
+
const db = getDb();
|
|
287
|
+
const currentTime = params.now ?? new Date().toISOString();
|
|
288
|
+
|
|
289
|
+
const conditions = [
|
|
290
|
+
eq(scopedApprovalGrants.toolName, params.toolName),
|
|
291
|
+
eq(scopedApprovalGrants.inputDigest, params.inputDigest),
|
|
292
|
+
eq(scopedApprovalGrants.scopeMode, 'tool_signature'),
|
|
293
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
294
|
+
sql`${scopedApprovalGrants.expiresAt} > ${currentTime}`,
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
// assistantId is always set on grants — scope consumption to the current
|
|
298
|
+
// assistant so grants minted for one assistant cannot be consumed by another.
|
|
299
|
+
if (params.assistantId !== undefined) {
|
|
300
|
+
conditions.push(eq(scopedApprovalGrants.assistantId, params.assistantId));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Context constraints: grant field must be NULL (any) or match exactly
|
|
304
|
+
if (params.executionChannel !== undefined) {
|
|
305
|
+
conditions.push(
|
|
306
|
+
sql`(${scopedApprovalGrants.executionChannel} IS NULL OR ${scopedApprovalGrants.executionChannel} = ${params.executionChannel})`,
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
// If caller provides no execution channel, only match grants with NULL (any)
|
|
310
|
+
conditions.push(sql`${scopedApprovalGrants.executionChannel} IS NULL`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (params.conversationId !== undefined) {
|
|
314
|
+
conditions.push(
|
|
315
|
+
sql`(${scopedApprovalGrants.conversationId} IS NULL OR ${scopedApprovalGrants.conversationId} = ${params.conversationId})`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
conditions.push(sql`${scopedApprovalGrants.conversationId} IS NULL`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (params.callSessionId !== undefined) {
|
|
322
|
+
conditions.push(
|
|
323
|
+
sql`(${scopedApprovalGrants.callSessionId} IS NULL OR ${scopedApprovalGrants.callSessionId} = ${params.callSessionId})`,
|
|
324
|
+
);
|
|
325
|
+
} else {
|
|
326
|
+
conditions.push(sql`${scopedApprovalGrants.callSessionId} IS NULL`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (params.requesterExternalUserId !== undefined) {
|
|
330
|
+
conditions.push(
|
|
331
|
+
sql`(${scopedApprovalGrants.requesterExternalUserId} IS NULL OR ${scopedApprovalGrants.requesterExternalUserId} = ${params.requesterExternalUserId})`,
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
conditions.push(sql`${scopedApprovalGrants.requesterExternalUserId} IS NULL`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const specificityOrder = sql`(CASE WHEN ${scopedApprovalGrants.executionChannel} IS NOT NULL THEN 1 ELSE 0 END
|
|
338
|
+
+ CASE WHEN ${scopedApprovalGrants.conversationId} IS NOT NULL THEN 1 ELSE 0 END
|
|
339
|
+
+ CASE WHEN ${scopedApprovalGrants.callSessionId} IS NOT NULL THEN 1 ELSE 0 END
|
|
340
|
+
+ CASE WHEN ${scopedApprovalGrants.requesterExternalUserId} IS NOT NULL THEN 1 ELSE 0 END) DESC`;
|
|
341
|
+
|
|
342
|
+
// Retry loop: if CAS fails because another consumer stole our candidate,
|
|
343
|
+
// re-select and try again — another matching active grant may still exist.
|
|
344
|
+
for (let attempt = 0; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
345
|
+
// Select a single matching grant to consume (prefer most specific: fewest NULL scope fields).
|
|
346
|
+
// This avoids burning multiple grants when a wildcard grant and a specific grant both match.
|
|
347
|
+
const candidate = db
|
|
348
|
+
.select({ id: scopedApprovalGrants.id })
|
|
349
|
+
.from(scopedApprovalGrants)
|
|
350
|
+
.where(and(...conditions))
|
|
351
|
+
.orderBy(specificityOrder)
|
|
352
|
+
.limit(1)
|
|
353
|
+
.get();
|
|
354
|
+
|
|
355
|
+
if (!candidate) {
|
|
356
|
+
log.info(
|
|
357
|
+
{ event: 'scoped_grant_consume_miss', toolName: params.toolName, scopeMode: 'tool_signature', attempt },
|
|
358
|
+
'No matching active grant found for tool signature',
|
|
359
|
+
);
|
|
360
|
+
return { ok: false, grant: null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
db.update(scopedApprovalGrants)
|
|
364
|
+
.set({
|
|
365
|
+
status: 'consumed',
|
|
366
|
+
consumedAt: currentTime,
|
|
367
|
+
consumedByRequestId: params.consumingRequestId,
|
|
368
|
+
updatedAt: currentTime,
|
|
369
|
+
})
|
|
370
|
+
.where(
|
|
371
|
+
and(
|
|
372
|
+
eq(scopedApprovalGrants.id, candidate.id),
|
|
373
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
.run();
|
|
377
|
+
|
|
378
|
+
if (rawChanges() === 0) {
|
|
379
|
+
// CAS failed — another consumer raced and won this candidate; retry with next match
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Fetch the consumed grant
|
|
384
|
+
const row = db
|
|
385
|
+
.select()
|
|
386
|
+
.from(scopedApprovalGrants)
|
|
387
|
+
.where(eq(scopedApprovalGrants.id, candidate.id))
|
|
388
|
+
.get();
|
|
389
|
+
|
|
390
|
+
const grant = row ? rowToGrant(row) : null;
|
|
391
|
+
log.info(
|
|
392
|
+
{ event: 'scoped_grant_consume_success', grantId: grant?.id, toolName: params.toolName, consumingRequestId: params.consumingRequestId, scopeMode: 'tool_signature' },
|
|
393
|
+
'Scoped approval grant consumed by tool signature',
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
return { ok: true, grant };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// All retry attempts exhausted — every candidate was stolen by concurrent consumers
|
|
400
|
+
log.info(
|
|
401
|
+
{ event: 'scoped_grant_consume_miss', toolName: params.toolName, scopeMode: 'tool_signature', reason: 'cas_exhausted' },
|
|
402
|
+
'All CAS retry attempts exhausted for tool signature consume',
|
|
403
|
+
);
|
|
404
|
+
return { ok: false, grant: null };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Expire grants past their TTL
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Bulk-expire all active grants whose `expiresAt` is at or before `now`.
|
|
413
|
+
* Returns the number of grants expired.
|
|
414
|
+
*/
|
|
415
|
+
export function expireScopedApprovalGrants(now?: string): number {
|
|
416
|
+
const db = getDb();
|
|
417
|
+
const currentTime = now ?? new Date().toISOString();
|
|
418
|
+
|
|
419
|
+
db.update(scopedApprovalGrants)
|
|
420
|
+
.set({
|
|
421
|
+
status: 'expired',
|
|
422
|
+
updatedAt: currentTime,
|
|
423
|
+
})
|
|
424
|
+
.where(
|
|
425
|
+
and(
|
|
426
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
427
|
+
sql`${scopedApprovalGrants.expiresAt} <= ${currentTime}`,
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
.run();
|
|
431
|
+
|
|
432
|
+
const count = rawChanges();
|
|
433
|
+
if (count > 0) {
|
|
434
|
+
log.info(
|
|
435
|
+
{ event: 'scoped_grant_expired', count },
|
|
436
|
+
`Expired ${count} scoped approval grant(s)`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return count;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Revoke active grants for a context
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
export interface RevokeContextParams {
|
|
448
|
+
assistantId?: string;
|
|
449
|
+
conversationId?: string;
|
|
450
|
+
callSessionId?: string;
|
|
451
|
+
requestChannel?: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Revoke all active grants matching the given context filters.
|
|
456
|
+
* At least one filter must be provided. Returns the number of
|
|
457
|
+
* grants revoked.
|
|
458
|
+
*
|
|
459
|
+
* Typical use: revoke all grants for a call session when the call ends.
|
|
460
|
+
*/
|
|
461
|
+
export function revokeScopedApprovalGrantsForContext(params: RevokeContextParams, now?: string): number {
|
|
462
|
+
const db = getDb();
|
|
463
|
+
const currentTime = now ?? new Date().toISOString();
|
|
464
|
+
|
|
465
|
+
const conditions = [eq(scopedApprovalGrants.status, 'active')];
|
|
466
|
+
|
|
467
|
+
if (params.assistantId !== undefined) {
|
|
468
|
+
conditions.push(eq(scopedApprovalGrants.assistantId, params.assistantId));
|
|
469
|
+
}
|
|
470
|
+
if (params.conversationId !== undefined) {
|
|
471
|
+
conditions.push(eq(scopedApprovalGrants.conversationId, params.conversationId));
|
|
472
|
+
}
|
|
473
|
+
if (params.callSessionId !== undefined) {
|
|
474
|
+
conditions.push(eq(scopedApprovalGrants.callSessionId, params.callSessionId));
|
|
475
|
+
}
|
|
476
|
+
if (params.requestChannel !== undefined) {
|
|
477
|
+
conditions.push(eq(scopedApprovalGrants.requestChannel, params.requestChannel));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Guard: at least one context filter must be provided to avoid revoking ALL active grants
|
|
481
|
+
if (conditions.length === 1) {
|
|
482
|
+
throw new Error('revokeScopedApprovalGrantsForContext requires at least one context filter');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
db.update(scopedApprovalGrants)
|
|
486
|
+
.set({
|
|
487
|
+
status: 'revoked',
|
|
488
|
+
updatedAt: currentTime,
|
|
489
|
+
})
|
|
490
|
+
.where(and(...conditions))
|
|
491
|
+
.run();
|
|
492
|
+
|
|
493
|
+
const count = rawChanges();
|
|
494
|
+
if (count > 0) {
|
|
495
|
+
log.info(
|
|
496
|
+
{
|
|
497
|
+
event: 'scoped_grant_revoked',
|
|
498
|
+
count,
|
|
499
|
+
assistantId: params.assistantId,
|
|
500
|
+
conversationId: params.conversationId,
|
|
501
|
+
callSessionId: params.callSessionId,
|
|
502
|
+
requestChannel: params.requestChannel,
|
|
503
|
+
},
|
|
504
|
+
`Revoked ${count} scoped approval grant(s) for context`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return count;
|
|
509
|
+
}
|
|
@@ -101,6 +101,19 @@ const WRAPPER_PROGRAMS = new Set([
|
|
|
101
101
|
// value of -u) as the wrapped program instead of `echo`.
|
|
102
102
|
const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir']);
|
|
103
103
|
|
|
104
|
+
// Bare filenames that `rm` is allowed to delete at Medium risk (instead of
|
|
105
|
+
// High) so workspace-scoped allow rules can approve them without the
|
|
106
|
+
// dangerous `allowHighRisk` flag. Only matches when the args contain no
|
|
107
|
+
// flags and exactly one of these filenames.
|
|
108
|
+
const RM_SAFE_BARE_FILES = new Set(['BOOTSTRAP.md', 'UPDATES.md']);
|
|
109
|
+
|
|
110
|
+
function isRmOfKnownSafeFile(args: string[]): boolean {
|
|
111
|
+
if (args.length !== 1) return false;
|
|
112
|
+
const target = args[0];
|
|
113
|
+
if (target.startsWith('-') || target.includes('/')) return false;
|
|
114
|
+
return RM_SAFE_BARE_FILES.has(target);
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
/**
|
|
105
118
|
* Given a segment whose program is a known wrapper, return the first
|
|
106
119
|
* non-flag argument (i.e. the wrapped program name). Returns `undefined`
|
|
@@ -385,6 +398,13 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
|
|
|
385
398
|
if (HIGH_RISK_PROGRAMS.has(prog)) return RiskLevel.High;
|
|
386
399
|
|
|
387
400
|
if (prog === 'rm') {
|
|
401
|
+
// `rm` of known safe workspace files (no flags, bare filename) is
|
|
402
|
+
// Medium rather than High so scope-limited allow rules can approve
|
|
403
|
+
// it without needing allowHighRisk, which would bypass path checks.
|
|
404
|
+
if (isRmOfKnownSafeFile(seg.args)) {
|
|
405
|
+
maxRisk = RiskLevel.Medium;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
388
408
|
return RiskLevel.High;
|
|
389
409
|
}
|
|
390
410
|
|
|
@@ -402,7 +422,14 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
|
|
|
402
422
|
}
|
|
403
423
|
|
|
404
424
|
if (WRAPPER_PROGRAMS.has(prog)) {
|
|
425
|
+
// `command -v` and `command -V` are read-only lookups (print where
|
|
426
|
+
// a command lives) — don't escalate to high risk for those.
|
|
427
|
+
if (prog === 'command' && seg.args.length > 0 && (seg.args[0] === '-v' || seg.args[0] === '-V')) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
405
430
|
const wrapped = getWrappedProgram(seg);
|
|
431
|
+
if (wrapped === 'rm') return RiskLevel.High;
|
|
432
|
+
if (wrapped && HIGH_RISK_PROGRAMS.has(wrapped)) return RiskLevel.High;
|
|
406
433
|
if (wrapped === 'curl' || wrapped === 'wget') {
|
|
407
434
|
maxRisk = RiskLevel.Medium;
|
|
408
435
|
continue;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper for minting scoped approval grants when a guardian-action
|
|
3
|
+
* request is resolved with tool metadata.
|
|
4
|
+
*
|
|
5
|
+
* Used by both the channel inbound path (inbound-message-handler.ts) and
|
|
6
|
+
* the desktop/IPC path (session-process.ts) to ensure grants are minted
|
|
7
|
+
* consistently regardless of which channel the guardian answers on.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { GuardianActionRequest } from '../memory/guardian-action-store.js';
|
|
11
|
+
import { createScopedApprovalGrant } from '../memory/scoped-approval-grants.js';
|
|
12
|
+
import { getLogger } from '../util/logger.js';
|
|
13
|
+
import { parseApprovalDecision } from './channel-approval-parser.js';
|
|
14
|
+
|
|
15
|
+
const log = getLogger('guardian-action-grant-minter');
|
|
16
|
+
|
|
17
|
+
/** TTL for scoped approval grants minted on guardian-action answer resolution. */
|
|
18
|
+
export const GUARDIAN_ACTION_GRANT_TTL_MS = 5 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mint a `tool_signature` scoped grant when a guardian-action request is
|
|
22
|
+
* resolved and the request carries tool metadata (toolName + inputDigest).
|
|
23
|
+
*
|
|
24
|
+
* Skips silently when:
|
|
25
|
+
* - The resolved request has no toolName/inputDigest (informational consult).
|
|
26
|
+
* - The guardian's answer is not an explicit approval (fail-closed).
|
|
27
|
+
*
|
|
28
|
+
* Fails silently on error -- grant minting is best-effort and must never
|
|
29
|
+
* block the guardian-action answer flow.
|
|
30
|
+
*/
|
|
31
|
+
export function tryMintGuardianActionGrant(params: {
|
|
32
|
+
resolvedRequest: GuardianActionRequest;
|
|
33
|
+
answerText: string;
|
|
34
|
+
decisionChannel: string;
|
|
35
|
+
guardianExternalUserId?: string;
|
|
36
|
+
}): void {
|
|
37
|
+
const { resolvedRequest, answerText, decisionChannel, guardianExternalUserId } = params;
|
|
38
|
+
|
|
39
|
+
// Only mint for requests that carry tool metadata -- informational
|
|
40
|
+
// ASK_GUARDIAN consults without tool context do not produce grants.
|
|
41
|
+
if (!resolvedRequest.toolName || !resolvedRequest.inputDigest) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Gate on explicit affirmative guardian decisions (fail-closed).
|
|
46
|
+
// Only mint when the deterministic parser recognises an approval keyword
|
|
47
|
+
// ("yes", "approve", "allow", "go ahead", etc.). Unrecognised text
|
|
48
|
+
// (e.g. "nope", "don't do that") is treated as non-approval and skipped,
|
|
49
|
+
// preventing ambiguous answers from producing grants.
|
|
50
|
+
const decision = parseApprovalDecision(answerText);
|
|
51
|
+
if (decision?.action !== 'approve_once' && decision?.action !== 'approve_always') {
|
|
52
|
+
log.info(
|
|
53
|
+
{
|
|
54
|
+
event: 'guardian_action_grant_skipped_no_approval',
|
|
55
|
+
toolName: resolvedRequest.toolName,
|
|
56
|
+
requestId: resolvedRequest.id,
|
|
57
|
+
answerText,
|
|
58
|
+
parsedAction: decision?.action ?? null,
|
|
59
|
+
decisionChannel,
|
|
60
|
+
},
|
|
61
|
+
'Skipped grant minting: guardian answer not classified as explicit approval',
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
createScopedApprovalGrant({
|
|
68
|
+
assistantId: resolvedRequest.assistantId,
|
|
69
|
+
scopeMode: 'tool_signature',
|
|
70
|
+
toolName: resolvedRequest.toolName,
|
|
71
|
+
inputDigest: resolvedRequest.inputDigest,
|
|
72
|
+
requestChannel: resolvedRequest.sourceChannel,
|
|
73
|
+
decisionChannel,
|
|
74
|
+
executionChannel: null,
|
|
75
|
+
conversationId: resolvedRequest.sourceConversationId,
|
|
76
|
+
callSessionId: resolvedRequest.callSessionId,
|
|
77
|
+
guardianExternalUserId: guardianExternalUserId ?? null,
|
|
78
|
+
expiresAt: new Date(Date.now() + GUARDIAN_ACTION_GRANT_TTL_MS).toISOString(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
log.info(
|
|
82
|
+
{
|
|
83
|
+
event: 'guardian_action_grant_minted',
|
|
84
|
+
toolName: resolvedRequest.toolName,
|
|
85
|
+
requestId: resolvedRequest.id,
|
|
86
|
+
callSessionId: resolvedRequest.callSessionId,
|
|
87
|
+
decisionChannel,
|
|
88
|
+
},
|
|
89
|
+
'Minted scoped approval grant for guardian-action answer resolution',
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.error(
|
|
93
|
+
{ err, toolName: resolvedRequest.toolName, requestId: resolvedRequest.id },
|
|
94
|
+
'Failed to mint scoped approval grant for guardian-action (non-fatal)',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|