@vellumai/assistant 0.3.26 → 0.3.28
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 +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard tests for canonical guardian request routing invariants.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the canonical guardian request system maintains
|
|
5
|
+
* its key architectural invariants:
|
|
6
|
+
*
|
|
7
|
+
* 1. All decision paths route through `applyCanonicalGuardianDecision`
|
|
8
|
+
* 2. Identity checks are enforced before decisions are applied
|
|
9
|
+
* 3. Stale/expired/already-resolved decisions are rejected
|
|
10
|
+
* 4. Code-only messages return clarification (not auto-approve)
|
|
11
|
+
* 5. Disambiguation with multiple pending requests stays fail-closed
|
|
12
|
+
*
|
|
13
|
+
* The tests combine import-verification (ensuring callers reference the
|
|
14
|
+
* canonical primitive) and unit tests of the router and primitive functions.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
23
|
+
|
|
24
|
+
const testDir = mkdtempSync(join(tmpdir(), 'guardian-routing-invariants-test-'));
|
|
25
|
+
|
|
26
|
+
mock.module('../util/platform.js', () => ({
|
|
27
|
+
getDataDir: () => testDir,
|
|
28
|
+
isMacOS: () => process.platform === 'darwin',
|
|
29
|
+
isLinux: () => process.platform === 'linux',
|
|
30
|
+
isWindows: () => process.platform === 'win32',
|
|
31
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
32
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
33
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
34
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
35
|
+
ensureDataDir: () => {},
|
|
36
|
+
migrateToDataLayout: () => {},
|
|
37
|
+
migrateToWorkspaceLayout: () => {},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
mock.module('../util/logger.js', () => ({
|
|
41
|
+
getLogger: () =>
|
|
42
|
+
new Proxy({} as Record<string, unknown>, {
|
|
43
|
+
get: () => () => {},
|
|
44
|
+
}),
|
|
45
|
+
isDebug: () => false,
|
|
46
|
+
truncateForLog: (value: string) => value,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
applyCanonicalGuardianDecision,
|
|
51
|
+
} from '../approvals/guardian-decision-primitive.js';
|
|
52
|
+
import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
|
|
53
|
+
import {
|
|
54
|
+
getRegisteredKinds,
|
|
55
|
+
getResolver,
|
|
56
|
+
} from '../approvals/guardian-request-resolvers.js';
|
|
57
|
+
import {
|
|
58
|
+
createCanonicalGuardianRequest,
|
|
59
|
+
getCanonicalGuardianRequest,
|
|
60
|
+
} from '../memory/canonical-guardian-store.js';
|
|
61
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
62
|
+
import {
|
|
63
|
+
type GuardianReplyContext,
|
|
64
|
+
routeGuardianReply,
|
|
65
|
+
} from '../runtime/guardian-reply-router.js';
|
|
66
|
+
import * as pendingInteractions from '../runtime/pending-interactions.js';
|
|
67
|
+
|
|
68
|
+
initializeDb();
|
|
69
|
+
|
|
70
|
+
function resetTables(): void {
|
|
71
|
+
const db = getDb();
|
|
72
|
+
db.run('DELETE FROM scoped_approval_grants');
|
|
73
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
74
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
75
|
+
pendingInteractions.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
resetDb();
|
|
80
|
+
try {
|
|
81
|
+
rmSync(testDir, { recursive: true });
|
|
82
|
+
} catch {
|
|
83
|
+
// best-effort cleanup
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Helpers
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
92
|
+
return {
|
|
93
|
+
externalUserId: 'guardian-1',
|
|
94
|
+
channel: 'telegram',
|
|
95
|
+
isTrusted: false,
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
101
|
+
return {
|
|
102
|
+
externalUserId: undefined,
|
|
103
|
+
channel: 'desktop',
|
|
104
|
+
isTrusted: true,
|
|
105
|
+
...overrides,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function replyCtx(overrides: Partial<GuardianReplyContext> = {}): GuardianReplyContext {
|
|
110
|
+
return {
|
|
111
|
+
messageText: '',
|
|
112
|
+
channel: 'telegram',
|
|
113
|
+
actor: guardianActor(),
|
|
114
|
+
conversationId: 'conv-test',
|
|
115
|
+
...overrides,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function registerPendingToolApprovalInteraction(
|
|
120
|
+
requestId: string,
|
|
121
|
+
conversationId: string,
|
|
122
|
+
toolName: string = 'shell',
|
|
123
|
+
): ReturnType<typeof mock> {
|
|
124
|
+
const handleConfirmationResponse = mock(() => {});
|
|
125
|
+
const mockSession = {
|
|
126
|
+
handleConfirmationResponse,
|
|
127
|
+
} as unknown as import('../daemon/session.js').Session;
|
|
128
|
+
|
|
129
|
+
pendingInteractions.register(requestId, {
|
|
130
|
+
session: mockSession,
|
|
131
|
+
conversationId,
|
|
132
|
+
kind: 'confirmation',
|
|
133
|
+
confirmationDetails: {
|
|
134
|
+
toolName,
|
|
135
|
+
input: { command: 'echo hello' },
|
|
136
|
+
riskLevel: 'medium',
|
|
137
|
+
allowlistOptions: [
|
|
138
|
+
{
|
|
139
|
+
label: 'echo hello',
|
|
140
|
+
description: 'echo hello',
|
|
141
|
+
pattern: 'echo hello',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
scopeOptions: [
|
|
145
|
+
{
|
|
146
|
+
label: 'everywhere',
|
|
147
|
+
scope: 'everywhere',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return handleConfirmationResponse;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ===========================================================================
|
|
157
|
+
// SECTION 1: Import-verification guard tests
|
|
158
|
+
//
|
|
159
|
+
// These verify that all known decision entrypoints import from and call
|
|
160
|
+
// `applyCanonicalGuardianDecision` rather than inlining decision logic.
|
|
161
|
+
// ===========================================================================
|
|
162
|
+
|
|
163
|
+
describe('routing invariant: all decision paths reference applyCanonicalGuardianDecision', () => {
|
|
164
|
+
const srcRoot = resolve(__dirname, '..');
|
|
165
|
+
|
|
166
|
+
// The files that constitute decision entrypoints. Each must import
|
|
167
|
+
// `applyCanonicalGuardianDecision` from the guardian-decision-primitive.
|
|
168
|
+
const DECISION_ENTRYPOINTS = [
|
|
169
|
+
// Inbound channel router (Telegram/SMS/WhatsApp)
|
|
170
|
+
'runtime/guardian-reply-router.ts',
|
|
171
|
+
// HTTP API route handler (desktop and API clients)
|
|
172
|
+
'runtime/routes/guardian-action-routes.ts',
|
|
173
|
+
// IPC handler (desktop socket clients)
|
|
174
|
+
'daemon/handlers/guardian-actions.ts',
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
for (const relPath of DECISION_ENTRYPOINTS) {
|
|
178
|
+
test(`${relPath} imports applyCanonicalGuardianDecision`, () => {
|
|
179
|
+
const fullPath = join(srcRoot, relPath);
|
|
180
|
+
const source = readFileSync(fullPath, 'utf-8');
|
|
181
|
+
expect(source).toContain('applyCanonicalGuardianDecision');
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The inbound message handler and session-process both use routeGuardianReply
|
|
186
|
+
// which itself calls applyCanonicalGuardianDecision. Verify they reference
|
|
187
|
+
// the shared router rather than inlining decision logic.
|
|
188
|
+
const ROUTER_CONSUMERS = [
|
|
189
|
+
'runtime/routes/inbound-message-handler.ts',
|
|
190
|
+
'daemon/session-process.ts',
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
for (const relPath of ROUTER_CONSUMERS) {
|
|
194
|
+
test(`${relPath} uses routeGuardianReply (shared router)`, () => {
|
|
195
|
+
const fullPath = join(srcRoot, relPath);
|
|
196
|
+
const source = readFileSync(fullPath, 'utf-8');
|
|
197
|
+
expect(source).toContain('routeGuardianReply');
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test('daemon/session-process.ts no longer references legacy guardian-action interception', () => {
|
|
202
|
+
const fullPath = join(srcRoot, 'daemon/session-process.ts');
|
|
203
|
+
const source = readFileSync(fullPath, 'utf-8');
|
|
204
|
+
expect(source).not.toContain("../memory/guardian-action-store.js");
|
|
205
|
+
expect(source).not.toContain('getPendingDeliveriesByConversation');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('daemon/session-process.ts seeds router hints from delivery and conversation scopes', () => {
|
|
209
|
+
const fullPath = join(srcRoot, 'daemon/session-process.ts');
|
|
210
|
+
const source = readFileSync(fullPath, 'utf-8');
|
|
211
|
+
expect(source).toContain('listPendingCanonicalGuardianRequestsByDestinationConversation');
|
|
212
|
+
expect(source).toContain('listCanonicalGuardianRequests');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('guardian-reply-router routes all decisions through applyCanonicalGuardianDecision', () => {
|
|
216
|
+
const fullPath = join(srcRoot, 'runtime/guardian-reply-router.ts');
|
|
217
|
+
const source = readFileSync(fullPath, 'utf-8');
|
|
218
|
+
// The router must import and call the canonical primitive, not applyGuardianDecision
|
|
219
|
+
expect(source).toContain('applyCanonicalGuardianDecision');
|
|
220
|
+
// The router must NOT directly call the legacy applyGuardianDecision
|
|
221
|
+
expect(source).not.toContain('applyGuardianDecision(');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ===========================================================================
|
|
226
|
+
// SECTION 2: Identity enforcement invariants
|
|
227
|
+
// ===========================================================================
|
|
228
|
+
|
|
229
|
+
describe('routing invariant: identity checks enforced before decisions', () => {
|
|
230
|
+
beforeEach(() => resetTables());
|
|
231
|
+
|
|
232
|
+
test('non-matching actor identity is rejected by canonical primitive', async () => {
|
|
233
|
+
const req = createCanonicalGuardianRequest({
|
|
234
|
+
kind: 'tool_approval',
|
|
235
|
+
sourceType: 'channel',
|
|
236
|
+
conversationId: 'conv-1',
|
|
237
|
+
guardianExternalUserId: 'guardian-1',
|
|
238
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const result = await applyCanonicalGuardianDecision({
|
|
242
|
+
requestId: req.id,
|
|
243
|
+
action: 'approve_once',
|
|
244
|
+
actorContext: guardianActor({ externalUserId: 'imposter-99' }),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(result.applied).toBe(false);
|
|
248
|
+
if (result.applied) return;
|
|
249
|
+
expect(result.reason).toBe('identity_mismatch');
|
|
250
|
+
|
|
251
|
+
// Request must remain pending (no state change)
|
|
252
|
+
const unchanged = getCanonicalGuardianRequest(req.id);
|
|
253
|
+
expect(unchanged!.status).toBe('pending');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('trusted (desktop) actor bypasses identity check', async () => {
|
|
257
|
+
const req = createCanonicalGuardianRequest({
|
|
258
|
+
kind: 'tool_approval',
|
|
259
|
+
sourceType: 'desktop',
|
|
260
|
+
conversationId: 'conv-1',
|
|
261
|
+
guardianExternalUserId: 'guardian-1',
|
|
262
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = await applyCanonicalGuardianDecision({
|
|
266
|
+
requestId: req.id,
|
|
267
|
+
action: 'approve_once',
|
|
268
|
+
actorContext: trustedActor(),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(result.applied).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('request with no guardian binding accepts any actor', async () => {
|
|
275
|
+
const req = createCanonicalGuardianRequest({
|
|
276
|
+
kind: 'tool_approval',
|
|
277
|
+
sourceType: 'channel',
|
|
278
|
+
conversationId: 'conv-1',
|
|
279
|
+
// No guardianExternalUserId — open request
|
|
280
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = await applyCanonicalGuardianDecision({
|
|
284
|
+
requestId: req.id,
|
|
285
|
+
action: 'approve_once',
|
|
286
|
+
actorContext: guardianActor({ externalUserId: 'anyone' }),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.applied).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('identity mismatch on code-only message blocks detail leakage', async () => {
|
|
293
|
+
createCanonicalGuardianRequest({
|
|
294
|
+
kind: 'tool_approval',
|
|
295
|
+
sourceType: 'channel',
|
|
296
|
+
conversationId: 'conv-1',
|
|
297
|
+
guardianExternalUserId: 'guardian-1',
|
|
298
|
+
requestCode: 'ABC123',
|
|
299
|
+
toolName: 'shell',
|
|
300
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = await routeGuardianReply(replyCtx({
|
|
304
|
+
messageText: 'ABC123',
|
|
305
|
+
actor: guardianActor({ externalUserId: 'imposter' }),
|
|
306
|
+
conversationId: 'conv-1',
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
// Code-only clarification should be returned but must NOT reveal tool details
|
|
310
|
+
expect(result.consumed).toBe(true);
|
|
311
|
+
expect(result.type).toBe('code_only_clarification');
|
|
312
|
+
expect(result.replyText).toBe('Request not found.');
|
|
313
|
+
expect(result.decisionApplied).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ===========================================================================
|
|
318
|
+
// SECTION 3: Stale / expired / already-resolved rejection
|
|
319
|
+
// ===========================================================================
|
|
320
|
+
|
|
321
|
+
describe('routing invariant: stale/expired/already-resolved decisions rejected', () => {
|
|
322
|
+
beforeEach(() => resetTables());
|
|
323
|
+
|
|
324
|
+
test('expired request is rejected by canonical primitive', async () => {
|
|
325
|
+
const req = createCanonicalGuardianRequest({
|
|
326
|
+
kind: 'tool_approval',
|
|
327
|
+
sourceType: 'channel',
|
|
328
|
+
conversationId: 'conv-1',
|
|
329
|
+
guardianExternalUserId: 'guardian-1',
|
|
330
|
+
expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const result = await applyCanonicalGuardianDecision({
|
|
334
|
+
requestId: req.id,
|
|
335
|
+
action: 'approve_once',
|
|
336
|
+
actorContext: guardianActor(),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(result.applied).toBe(false);
|
|
340
|
+
if (result.applied) return;
|
|
341
|
+
expect(result.reason).toBe('expired');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('already-resolved request is rejected (first-writer-wins)', async () => {
|
|
345
|
+
const req = createCanonicalGuardianRequest({
|
|
346
|
+
kind: 'tool_approval',
|
|
347
|
+
sourceType: 'channel',
|
|
348
|
+
conversationId: 'conv-1',
|
|
349
|
+
guardianExternalUserId: 'guardian-1',
|
|
350
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// First decision succeeds
|
|
354
|
+
const first = await applyCanonicalGuardianDecision({
|
|
355
|
+
requestId: req.id,
|
|
356
|
+
action: 'approve_once',
|
|
357
|
+
actorContext: guardianActor(),
|
|
358
|
+
});
|
|
359
|
+
expect(first.applied).toBe(true);
|
|
360
|
+
|
|
361
|
+
// Second decision fails — request is no longer pending
|
|
362
|
+
const second = await applyCanonicalGuardianDecision({
|
|
363
|
+
requestId: req.id,
|
|
364
|
+
action: 'reject',
|
|
365
|
+
actorContext: guardianActor(),
|
|
366
|
+
});
|
|
367
|
+
expect(second.applied).toBe(false);
|
|
368
|
+
if (second.applied) return;
|
|
369
|
+
expect(second.reason).toBe('already_resolved');
|
|
370
|
+
|
|
371
|
+
// First decision stuck
|
|
372
|
+
const final = getCanonicalGuardianRequest(req.id);
|
|
373
|
+
expect(final!.status).toBe('approved');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('nonexistent request returns not_found', async () => {
|
|
377
|
+
const result = await applyCanonicalGuardianDecision({
|
|
378
|
+
requestId: 'nonexistent-id',
|
|
379
|
+
action: 'approve_once',
|
|
380
|
+
actorContext: guardianActor(),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(result.applied).toBe(false);
|
|
384
|
+
if (result.applied) return;
|
|
385
|
+
expect(result.reason).toBe('not_found');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('already-resolved request via router returns not_consumed (code lookup filters pending only)', async () => {
|
|
389
|
+
const req = createCanonicalGuardianRequest({
|
|
390
|
+
kind: 'tool_approval',
|
|
391
|
+
sourceType: 'channel',
|
|
392
|
+
conversationId: 'conv-1',
|
|
393
|
+
guardianExternalUserId: 'guardian-1',
|
|
394
|
+
requestCode: 'ABC123',
|
|
395
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Resolve the request first
|
|
399
|
+
await applyCanonicalGuardianDecision({
|
|
400
|
+
requestId: req.id,
|
|
401
|
+
action: 'approve_once',
|
|
402
|
+
actorContext: guardianActor(),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Attempt to resolve again via router with code prefix.
|
|
406
|
+
// Since getCanonicalGuardianRequestByCode only returns pending requests,
|
|
407
|
+
// the resolved request won't be found and the code won't match.
|
|
408
|
+
const result = await routeGuardianReply(replyCtx({
|
|
409
|
+
messageText: 'ABC123 approve',
|
|
410
|
+
conversationId: 'conv-1',
|
|
411
|
+
}));
|
|
412
|
+
|
|
413
|
+
// Code lookup filters by status='pending', so the resolved request is invisible.
|
|
414
|
+
// The router does not match the code and returns not_consumed.
|
|
415
|
+
expect(result.consumed).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('expired request via callback returns stale type', async () => {
|
|
419
|
+
const req = createCanonicalGuardianRequest({
|
|
420
|
+
kind: 'tool_approval',
|
|
421
|
+
sourceType: 'channel',
|
|
422
|
+
conversationId: 'conv-1',
|
|
423
|
+
guardianExternalUserId: 'guardian-1',
|
|
424
|
+
expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const result = await routeGuardianReply(replyCtx({
|
|
428
|
+
messageText: '',
|
|
429
|
+
callbackData: `apr:${req.id}:approve_once`,
|
|
430
|
+
conversationId: 'conv-1',
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
expect(result.consumed).toBe(true);
|
|
434
|
+
expect(result.type).toBe('canonical_decision_stale');
|
|
435
|
+
expect(result.decisionApplied).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ===========================================================================
|
|
440
|
+
// SECTION 4: Code-only messages return clarification, not auto-approve
|
|
441
|
+
// ===========================================================================
|
|
442
|
+
|
|
443
|
+
describe('routing invariant: code-only messages return clarification', () => {
|
|
444
|
+
beforeEach(() => resetTables());
|
|
445
|
+
|
|
446
|
+
test('code-only message returns clarification with request details', async () => {
|
|
447
|
+
const req = createCanonicalGuardianRequest({
|
|
448
|
+
kind: 'tool_approval',
|
|
449
|
+
sourceType: 'channel',
|
|
450
|
+
conversationId: 'conv-1',
|
|
451
|
+
guardianExternalUserId: 'guardian-1',
|
|
452
|
+
requestCode: 'A1B2C3',
|
|
453
|
+
toolName: 'shell',
|
|
454
|
+
questionText: 'Run shell command: ls -la',
|
|
455
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const result = await routeGuardianReply(replyCtx({
|
|
459
|
+
messageText: 'A1B2C3',
|
|
460
|
+
conversationId: 'conv-1',
|
|
461
|
+
}));
|
|
462
|
+
|
|
463
|
+
expect(result.consumed).toBe(true);
|
|
464
|
+
expect(result.type).toBe('code_only_clarification');
|
|
465
|
+
expect(result.decisionApplied).toBe(false);
|
|
466
|
+
// Must provide actionable instructions
|
|
467
|
+
expect(result.replyText).toContain('A1B2C3');
|
|
468
|
+
expect(result.replyText).toContain('approve');
|
|
469
|
+
expect(result.replyText).toContain('reject');
|
|
470
|
+
|
|
471
|
+
// The request must remain pending — NOT auto-approved
|
|
472
|
+
const unchanged = getCanonicalGuardianRequest(req.id);
|
|
473
|
+
expect(unchanged!.status).toBe('pending');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('code with decision text does apply the decision', async () => {
|
|
477
|
+
const req = createCanonicalGuardianRequest({
|
|
478
|
+
kind: 'tool_approval',
|
|
479
|
+
sourceType: 'channel',
|
|
480
|
+
conversationId: 'conv-1',
|
|
481
|
+
guardianExternalUserId: 'guardian-1',
|
|
482
|
+
requestCode: 'A1B2C3',
|
|
483
|
+
toolName: 'shell',
|
|
484
|
+
inputDigest: 'sha256:abc',
|
|
485
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
486
|
+
});
|
|
487
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
|
|
488
|
+
|
|
489
|
+
const result = await routeGuardianReply(replyCtx({
|
|
490
|
+
messageText: 'A1B2C3 approve',
|
|
491
|
+
conversationId: 'conv-1',
|
|
492
|
+
}));
|
|
493
|
+
|
|
494
|
+
expect(result.consumed).toBe(true);
|
|
495
|
+
expect(result.type).toBe('canonical_decision_applied');
|
|
496
|
+
expect(result.decisionApplied).toBe(true);
|
|
497
|
+
|
|
498
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
499
|
+
expect(resolved!.status).toBe('approved');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('code with reject text denies the request', async () => {
|
|
503
|
+
const req = createCanonicalGuardianRequest({
|
|
504
|
+
kind: 'tool_approval',
|
|
505
|
+
sourceType: 'channel',
|
|
506
|
+
conversationId: 'conv-1',
|
|
507
|
+
guardianExternalUserId: 'guardian-1',
|
|
508
|
+
requestCode: 'D4E5F6',
|
|
509
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
510
|
+
});
|
|
511
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
|
|
512
|
+
|
|
513
|
+
const result = await routeGuardianReply(replyCtx({
|
|
514
|
+
messageText: 'D4E5F6 reject',
|
|
515
|
+
conversationId: 'conv-1',
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
expect(result.consumed).toBe(true);
|
|
519
|
+
expect(result.decisionApplied).toBe(true);
|
|
520
|
+
|
|
521
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
522
|
+
expect(resolved!.status).toBe('denied');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ===========================================================================
|
|
527
|
+
// SECTION 5: Disambiguation with multiple pending requests stays fail-closed
|
|
528
|
+
// ===========================================================================
|
|
529
|
+
|
|
530
|
+
describe('routing invariant: disambiguation stays fail-closed', () => {
|
|
531
|
+
beforeEach(() => resetTables());
|
|
532
|
+
|
|
533
|
+
test('single hinted pending request accepts explicit plain-text approve without NL generator', async () => {
|
|
534
|
+
const req = createCanonicalGuardianRequest({
|
|
535
|
+
kind: 'tool_approval',
|
|
536
|
+
sourceType: 'channel',
|
|
537
|
+
conversationId: 'conv-1',
|
|
538
|
+
guardianExternalUserId: 'guardian-1',
|
|
539
|
+
requestCode: 'DDD444',
|
|
540
|
+
toolName: 'shell',
|
|
541
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
542
|
+
});
|
|
543
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
|
|
544
|
+
|
|
545
|
+
const result = await routeGuardianReply(replyCtx({
|
|
546
|
+
messageText: 'approve',
|
|
547
|
+
conversationId: 'conv-guardian-thread',
|
|
548
|
+
pendingRequestIds: [req.id],
|
|
549
|
+
approvalConversationGenerator: undefined,
|
|
550
|
+
}));
|
|
551
|
+
|
|
552
|
+
expect(result.consumed).toBe(true);
|
|
553
|
+
expect(result.type).toBe('canonical_decision_applied');
|
|
554
|
+
expect(result.decisionApplied).toBe(true);
|
|
555
|
+
|
|
556
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
557
|
+
expect(resolved!.status).toBe('approved');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('single hinted pending request does not auto-approve broad acknowledgment text', async () => {
|
|
561
|
+
const req = createCanonicalGuardianRequest({
|
|
562
|
+
kind: 'tool_approval',
|
|
563
|
+
sourceType: 'channel',
|
|
564
|
+
conversationId: 'conv-1',
|
|
565
|
+
guardianExternalUserId: 'guardian-1',
|
|
566
|
+
requestCode: 'GGG777',
|
|
567
|
+
toolName: 'shell',
|
|
568
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const result = await routeGuardianReply(replyCtx({
|
|
572
|
+
messageText: 'ok, what is this for?',
|
|
573
|
+
conversationId: 'conv-guardian-thread',
|
|
574
|
+
pendingRequestIds: [req.id],
|
|
575
|
+
approvalConversationGenerator: undefined,
|
|
576
|
+
}));
|
|
577
|
+
|
|
578
|
+
expect(result.consumed).toBe(false);
|
|
579
|
+
expect(result.type).toBe('not_consumed');
|
|
580
|
+
expect(result.decisionApplied).toBe(false);
|
|
581
|
+
|
|
582
|
+
const unchanged = getCanonicalGuardianRequest(req.id);
|
|
583
|
+
expect(unchanged!.status).toBe('pending');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('explicit empty pendingRequestIds hint stays fail-closed for trusted actors', async () => {
|
|
587
|
+
createCanonicalGuardianRequest({
|
|
588
|
+
kind: 'tool_approval',
|
|
589
|
+
sourceType: 'channel',
|
|
590
|
+
conversationId: 'conv-other',
|
|
591
|
+
guardianExternalUserId: 'guardian-1',
|
|
592
|
+
requestCode: 'HHH888',
|
|
593
|
+
toolName: 'shell',
|
|
594
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const result = await routeGuardianReply(replyCtx({
|
|
598
|
+
messageText: 'approve',
|
|
599
|
+
actor: trustedActor(),
|
|
600
|
+
conversationId: 'conv-unrelated',
|
|
601
|
+
pendingRequestIds: [],
|
|
602
|
+
approvalConversationGenerator: undefined,
|
|
603
|
+
}));
|
|
604
|
+
|
|
605
|
+
expect(result.consumed).toBe(false);
|
|
606
|
+
expect(result.type).toBe('not_consumed');
|
|
607
|
+
expect(result.decisionApplied).toBe(false);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('multiple hinted pending requests with plain-text approve returns disambiguation', async () => {
|
|
611
|
+
const req1 = createCanonicalGuardianRequest({
|
|
612
|
+
kind: 'tool_approval',
|
|
613
|
+
sourceType: 'channel',
|
|
614
|
+
conversationId: 'conv-1',
|
|
615
|
+
guardianExternalUserId: 'guardian-1',
|
|
616
|
+
requestCode: 'EEE555',
|
|
617
|
+
toolName: 'shell',
|
|
618
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const req2 = createCanonicalGuardianRequest({
|
|
622
|
+
kind: 'tool_approval',
|
|
623
|
+
sourceType: 'channel',
|
|
624
|
+
conversationId: 'conv-1',
|
|
625
|
+
guardianExternalUserId: 'guardian-1',
|
|
626
|
+
requestCode: 'FFF666',
|
|
627
|
+
toolName: 'file_write',
|
|
628
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const result = await routeGuardianReply(replyCtx({
|
|
632
|
+
messageText: 'approve',
|
|
633
|
+
conversationId: 'conv-guardian-thread',
|
|
634
|
+
pendingRequestIds: [req1.id, req2.id],
|
|
635
|
+
approvalConversationGenerator: undefined,
|
|
636
|
+
}));
|
|
637
|
+
|
|
638
|
+
expect(result.consumed).toBe(true);
|
|
639
|
+
expect(result.type).toBe('disambiguation_needed');
|
|
640
|
+
expect(result.decisionApplied).toBe(false);
|
|
641
|
+
expect(result.replyText).toContain('EEE555');
|
|
642
|
+
expect(result.replyText).toContain('FFF666');
|
|
643
|
+
|
|
644
|
+
const r1 = getCanonicalGuardianRequest(req1.id);
|
|
645
|
+
const r2 = getCanonicalGuardianRequest(req2.id);
|
|
646
|
+
expect(r1!.status).toBe('pending');
|
|
647
|
+
expect(r2!.status).toBe('pending');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('multiple pending requests without target return disambiguation (not auto-resolve)', async () => {
|
|
651
|
+
// Create two pending requests for the same guardian
|
|
652
|
+
const req1 = createCanonicalGuardianRequest({
|
|
653
|
+
kind: 'tool_approval',
|
|
654
|
+
sourceType: 'channel',
|
|
655
|
+
conversationId: 'conv-1',
|
|
656
|
+
guardianExternalUserId: 'guardian-1',
|
|
657
|
+
requestCode: 'AAA111',
|
|
658
|
+
toolName: 'shell',
|
|
659
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const req2 = createCanonicalGuardianRequest({
|
|
663
|
+
kind: 'tool_approval',
|
|
664
|
+
sourceType: 'channel',
|
|
665
|
+
conversationId: 'conv-1',
|
|
666
|
+
guardianExternalUserId: 'guardian-1',
|
|
667
|
+
requestCode: 'BBB222',
|
|
668
|
+
toolName: 'file_write',
|
|
669
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// The NL engine mock: returns a decision but no specific target.
|
|
673
|
+
// This simulates a guardian saying "yes" without specifying which request.
|
|
674
|
+
const mockGenerator = async () => ({
|
|
675
|
+
disposition: 'approve_once' as const,
|
|
676
|
+
replyText: 'Approved!',
|
|
677
|
+
targetRequestId: undefined,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const result = await routeGuardianReply(replyCtx({
|
|
681
|
+
messageText: 'yes approve it',
|
|
682
|
+
conversationId: 'conv-1',
|
|
683
|
+
pendingRequestIds: [req1.id, req2.id],
|
|
684
|
+
approvalConversationGenerator: mockGenerator as any,
|
|
685
|
+
}));
|
|
686
|
+
|
|
687
|
+
expect(result.consumed).toBe(true);
|
|
688
|
+
expect(result.type).toBe('disambiguation_needed');
|
|
689
|
+
expect(result.decisionApplied).toBe(false);
|
|
690
|
+
|
|
691
|
+
// Both requests must remain pending — fail-closed
|
|
692
|
+
const r1 = getCanonicalGuardianRequest(req1.id);
|
|
693
|
+
const r2 = getCanonicalGuardianRequest(req2.id);
|
|
694
|
+
expect(r1!.status).toBe('pending');
|
|
695
|
+
expect(r2!.status).toBe('pending');
|
|
696
|
+
|
|
697
|
+
// Disambiguation reply should list request codes
|
|
698
|
+
expect(result.replyText).toContain('AAA111');
|
|
699
|
+
expect(result.replyText).toContain('BBB222');
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test('single pending request does not need disambiguation', async () => {
|
|
703
|
+
const req = createCanonicalGuardianRequest({
|
|
704
|
+
kind: 'tool_approval',
|
|
705
|
+
sourceType: 'channel',
|
|
706
|
+
conversationId: 'conv-1',
|
|
707
|
+
guardianExternalUserId: 'guardian-1',
|
|
708
|
+
requestCode: 'CCC333',
|
|
709
|
+
toolName: 'shell',
|
|
710
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
711
|
+
});
|
|
712
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
|
|
713
|
+
|
|
714
|
+
// NL engine returns a decision without specifying target — but only one
|
|
715
|
+
// request is pending, so it should be resolved without disambiguation.
|
|
716
|
+
const mockGenerator = async () => ({
|
|
717
|
+
disposition: 'approve_once' as const,
|
|
718
|
+
replyText: 'Approved!',
|
|
719
|
+
targetRequestId: undefined,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const result = await routeGuardianReply(replyCtx({
|
|
723
|
+
messageText: 'yes',
|
|
724
|
+
conversationId: 'conv-1',
|
|
725
|
+
pendingRequestIds: [req.id],
|
|
726
|
+
approvalConversationGenerator: mockGenerator as any,
|
|
727
|
+
}));
|
|
728
|
+
|
|
729
|
+
expect(result.consumed).toBe(true);
|
|
730
|
+
expect(result.decisionApplied).toBe(true);
|
|
731
|
+
|
|
732
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
733
|
+
expect(resolved!.status).toBe('approved');
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ===========================================================================
|
|
738
|
+
// SECTION 6: Resolver registry integrity
|
|
739
|
+
// ===========================================================================
|
|
740
|
+
|
|
741
|
+
describe('routing invariant: resolver registry covers all built-in kinds', () => {
|
|
742
|
+
test('tool_approval resolver is registered', () => {
|
|
743
|
+
const resolver = getResolver('tool_approval');
|
|
744
|
+
expect(resolver).toBeDefined();
|
|
745
|
+
expect(resolver!.kind).toBe('tool_approval');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test('pending_question resolver is registered', () => {
|
|
749
|
+
const resolver = getResolver('pending_question');
|
|
750
|
+
expect(resolver).toBeDefined();
|
|
751
|
+
expect(resolver!.kind).toBe('pending_question');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test('unknown kind returns undefined (no default fallback)', () => {
|
|
755
|
+
expect(getResolver('nonexistent_kind')).toBeUndefined();
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('registered kinds include at least tool_approval and pending_question', () => {
|
|
759
|
+
const kinds = getRegisteredKinds();
|
|
760
|
+
expect(kinds).toContain('tool_approval');
|
|
761
|
+
expect(kinds).toContain('pending_question');
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// ===========================================================================
|
|
766
|
+
// SECTION 7: approve_always downgrade invariant
|
|
767
|
+
// ===========================================================================
|
|
768
|
+
|
|
769
|
+
describe('routing invariant: approve_always downgraded for guardian-on-behalf', () => {
|
|
770
|
+
beforeEach(() => resetTables());
|
|
771
|
+
|
|
772
|
+
test('approve_always is silently downgraded to approve_once by canonical primitive', async () => {
|
|
773
|
+
const req = createCanonicalGuardianRequest({
|
|
774
|
+
kind: 'tool_approval',
|
|
775
|
+
sourceType: 'channel',
|
|
776
|
+
conversationId: 'conv-1',
|
|
777
|
+
guardianExternalUserId: 'guardian-1',
|
|
778
|
+
toolName: 'shell',
|
|
779
|
+
inputDigest: 'sha256:abc',
|
|
780
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const result = await applyCanonicalGuardianDecision({
|
|
784
|
+
requestId: req.id,
|
|
785
|
+
action: 'approve_always',
|
|
786
|
+
actorContext: guardianActor(),
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
expect(result.applied).toBe(true);
|
|
790
|
+
|
|
791
|
+
// Status should be 'approved' (not some 'always_approved' state)
|
|
792
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
793
|
+
expect(resolved!.status).toBe('approved');
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// ===========================================================================
|
|
798
|
+
// SECTION 8: Callback routing uses applyCanonicalGuardianDecision
|
|
799
|
+
// ===========================================================================
|
|
800
|
+
|
|
801
|
+
describe('routing invariant: callback buttons route through canonical primitive', () => {
|
|
802
|
+
beforeEach(() => resetTables());
|
|
803
|
+
|
|
804
|
+
test('valid callback data applies decision via canonical primitive', async () => {
|
|
805
|
+
const req = createCanonicalGuardianRequest({
|
|
806
|
+
kind: 'tool_approval',
|
|
807
|
+
sourceType: 'channel',
|
|
808
|
+
conversationId: 'conv-1',
|
|
809
|
+
guardianExternalUserId: 'guardian-1',
|
|
810
|
+
toolName: 'shell',
|
|
811
|
+
inputDigest: 'sha256:abc',
|
|
812
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
813
|
+
});
|
|
814
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
|
|
815
|
+
|
|
816
|
+
const result = await routeGuardianReply(replyCtx({
|
|
817
|
+
messageText: '',
|
|
818
|
+
callbackData: `apr:${req.id}:approve_once`,
|
|
819
|
+
conversationId: 'conv-1',
|
|
820
|
+
}));
|
|
821
|
+
|
|
822
|
+
expect(result.consumed).toBe(true);
|
|
823
|
+
expect(result.type).toBe('canonical_decision_applied');
|
|
824
|
+
expect(result.decisionApplied).toBe(true);
|
|
825
|
+
|
|
826
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
827
|
+
expect(resolved!.status).toBe('approved');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test('callback with reject action denies the request', async () => {
|
|
831
|
+
const req = createCanonicalGuardianRequest({
|
|
832
|
+
kind: 'tool_approval',
|
|
833
|
+
sourceType: 'channel',
|
|
834
|
+
conversationId: 'conv-1',
|
|
835
|
+
guardianExternalUserId: 'guardian-1',
|
|
836
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
837
|
+
});
|
|
838
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
|
|
839
|
+
|
|
840
|
+
const result = await routeGuardianReply(replyCtx({
|
|
841
|
+
messageText: '',
|
|
842
|
+
callbackData: `apr:${req.id}:reject`,
|
|
843
|
+
conversationId: 'conv-1',
|
|
844
|
+
}));
|
|
845
|
+
|
|
846
|
+
expect(result.consumed).toBe(true);
|
|
847
|
+
expect(result.decisionApplied).toBe(true);
|
|
848
|
+
|
|
849
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
850
|
+
expect(resolved!.status).toBe('denied');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test('callback targeting different conversation is still processed (conversationId scoping removed for cross-channel)', async () => {
|
|
854
|
+
const req = createCanonicalGuardianRequest({
|
|
855
|
+
kind: 'tool_approval',
|
|
856
|
+
sourceType: 'channel',
|
|
857
|
+
conversationId: 'conv-other',
|
|
858
|
+
guardianExternalUserId: 'guardian-1',
|
|
859
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
860
|
+
});
|
|
861
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-other', 'shell');
|
|
862
|
+
|
|
863
|
+
const result = await routeGuardianReply(replyCtx({
|
|
864
|
+
messageText: '',
|
|
865
|
+
callbackData: `apr:${req.id}:approve_once`,
|
|
866
|
+
conversationId: 'conv-1', // different conversation — no longer rejected
|
|
867
|
+
}));
|
|
868
|
+
|
|
869
|
+
// Should be consumed — conversationId scoping was removed because in
|
|
870
|
+
// cross-channel flows the guardian's conversation differs from the
|
|
871
|
+
// requester's. Identity validation in the canonical decision primitive
|
|
872
|
+
// (guardianExternalUserId match) is the correct security boundary.
|
|
873
|
+
expect(result.consumed).toBe(true);
|
|
874
|
+
expect(result.decisionApplied).toBe(true);
|
|
875
|
+
|
|
876
|
+
// Request should be approved
|
|
877
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
878
|
+
expect(resolved!.status).toBe('approved');
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// ===========================================================================
|
|
883
|
+
// SECTION 9: Destination hint-based NL approval for missing guardianExternalUserId
|
|
884
|
+
// ===========================================================================
|
|
885
|
+
|
|
886
|
+
describe('routing invariant: destination hints enable NL approval without guardianExternalUserId', () => {
|
|
887
|
+
beforeEach(() => resetTables());
|
|
888
|
+
|
|
889
|
+
test('explicit pendingRequestIds from destination hints allows deterministic plain-text approval when guardianExternalUserId is missing', async () => {
|
|
890
|
+
// Voice-originated pending_question: no guardianExternalUserId
|
|
891
|
+
const req = createCanonicalGuardianRequest({
|
|
892
|
+
kind: 'tool_approval',
|
|
893
|
+
sourceType: 'voice',
|
|
894
|
+
sourceChannel: 'twilio',
|
|
895
|
+
conversationId: 'conv-voice-1',
|
|
896
|
+
toolName: 'shell',
|
|
897
|
+
requestCode: 'NL1234',
|
|
898
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
899
|
+
// guardianExternalUserId intentionally omitted
|
|
900
|
+
});
|
|
901
|
+
registerPendingToolApprovalInteraction(req.id, 'conv-voice-1', 'shell');
|
|
902
|
+
|
|
903
|
+
// The channel inbound router would compute pendingRequestIds from
|
|
904
|
+
// delivery-scoped lookup and pass them here. Simulate that.
|
|
905
|
+
const result = await routeGuardianReply(replyCtx({
|
|
906
|
+
messageText: 'approve',
|
|
907
|
+
channel: 'telegram',
|
|
908
|
+
actor: guardianActor({ externalUserId: 'guardian-tg-user' }),
|
|
909
|
+
conversationId: 'conv-guardian-chat',
|
|
910
|
+
pendingRequestIds: [req.id],
|
|
911
|
+
approvalConversationGenerator: undefined,
|
|
912
|
+
}));
|
|
913
|
+
|
|
914
|
+
expect(result.consumed).toBe(true);
|
|
915
|
+
expect(result.type).toBe('canonical_decision_applied');
|
|
916
|
+
expect(result.decisionApplied).toBe(true);
|
|
917
|
+
|
|
918
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
919
|
+
expect(resolved!.status).toBe('approved');
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test('without destination hints, missing guardianExternalUserId means no pending requests found', async () => {
|
|
923
|
+
// Voice-originated request: no guardianExternalUserId
|
|
924
|
+
const req = createCanonicalGuardianRequest({
|
|
925
|
+
kind: 'tool_approval',
|
|
926
|
+
sourceType: 'voice',
|
|
927
|
+
sourceChannel: 'twilio',
|
|
928
|
+
conversationId: 'conv-voice-2',
|
|
929
|
+
toolName: 'shell',
|
|
930
|
+
requestCode: 'NL5678',
|
|
931
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// No pendingRequestIds passed — identity-based fallback uses
|
|
935
|
+
// actor.externalUserId which does not match any request's
|
|
936
|
+
// guardianExternalUserId (since it's null).
|
|
937
|
+
const result = await routeGuardianReply(replyCtx({
|
|
938
|
+
messageText: 'approve',
|
|
939
|
+
channel: 'telegram',
|
|
940
|
+
actor: guardianActor({ externalUserId: 'guardian-tg-user' }),
|
|
941
|
+
conversationId: 'conv-guardian-chat',
|
|
942
|
+
// pendingRequestIds: undefined — no delivery hints
|
|
943
|
+
approvalConversationGenerator: undefined,
|
|
944
|
+
}));
|
|
945
|
+
|
|
946
|
+
// Identity-based lookup finds nothing because the request has no
|
|
947
|
+
// guardianExternalUserId, so the router returns not_consumed.
|
|
948
|
+
expect(result.consumed).toBe(false);
|
|
949
|
+
expect(result.type).toBe('not_consumed');
|
|
950
|
+
|
|
951
|
+
const unchanged = getCanonicalGuardianRequest(req.id);
|
|
952
|
+
expect(unchanged!.status).toBe('pending');
|
|
953
|
+
});
|
|
954
|
+
});
|