@vellumai/assistant 0.3.18 → 0.3.20
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 +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +260 -422
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -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/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +154 -0
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: guardian-action answer resolution mints a scoped grant
|
|
3
|
+
* that the voice consumer can consume exactly once.
|
|
4
|
+
*
|
|
5
|
+
* Exercises the original voice bug scenario end-to-end:
|
|
6
|
+
* 1. Voice ASK_GUARDIAN fires -> guardian action request created with tool metadata
|
|
7
|
+
* 2. Guardian answers via desktop/Telegram -> request resolved
|
|
8
|
+
* 3. tryMintGuardianActionGrant mints a tool_signature grant
|
|
9
|
+
* 4. Voice consumer can consume the grant for the same tool+input
|
|
10
|
+
* 5. Second consume attempt is denied (one-time use)
|
|
11
|
+
* 6. Grant for a different assistantId is not consumable
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
const testDir = mkdtempSync(join(tmpdir(), 'guardian-action-grant-e2e-'));
|
|
21
|
+
|
|
22
|
+
// ── Platform + logger mocks ─────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
mock.module('../util/platform.js', () => ({
|
|
25
|
+
getDataDir: () => testDir,
|
|
26
|
+
isMacOS: () => process.platform === 'darwin',
|
|
27
|
+
isLinux: () => process.platform === 'linux',
|
|
28
|
+
isWindows: () => process.platform === 'win32',
|
|
29
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
30
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
31
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
32
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
33
|
+
ensureDataDir: () => {},
|
|
34
|
+
migrateToDataLayout: () => {},
|
|
35
|
+
migrateToWorkspaceLayout: () => {},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module('../util/logger.js', () => ({
|
|
39
|
+
getLogger: () =>
|
|
40
|
+
new Proxy({} as Record<string, unknown>, {
|
|
41
|
+
get: () => () => {},
|
|
42
|
+
}),
|
|
43
|
+
isDebug: () => false,
|
|
44
|
+
truncateForLog: (value: string) => value,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// ── Imports (after mocks) ───────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
|
|
50
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
51
|
+
import {
|
|
52
|
+
createGuardianActionRequest,
|
|
53
|
+
resolveGuardianActionRequest,
|
|
54
|
+
} from '../memory/guardian-action-store.js';
|
|
55
|
+
import { conversations, scopedApprovalGrants } from '../memory/schema.js';
|
|
56
|
+
import {
|
|
57
|
+
_internal,
|
|
58
|
+
} from '../memory/scoped-approval-grants.js';
|
|
59
|
+
|
|
60
|
+
const { consumeScopedApprovalGrantByToolSignature } = _internal;
|
|
61
|
+
import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
|
|
62
|
+
import type { ApprovalConversationGenerator } from '../runtime/http-types.js';
|
|
63
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
64
|
+
|
|
65
|
+
initializeDb();
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
resetDb();
|
|
69
|
+
try {
|
|
70
|
+
rmSync(testDir, { recursive: true });
|
|
71
|
+
} catch {
|
|
72
|
+
/* best effort */
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── Constants ───────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const ASSISTANT_ID = 'self';
|
|
79
|
+
const TOOL_NAME = 'execute_shell';
|
|
80
|
+
const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
|
|
81
|
+
const CONVERSATION_ID = 'conv-e2e';
|
|
82
|
+
|
|
83
|
+
// Mutable references populated by ensureFkParents()
|
|
84
|
+
let CALL_SESSION_ID = '';
|
|
85
|
+
let PENDING_QUESTION_IDS: string[] = [];
|
|
86
|
+
let pqIndex = 0;
|
|
87
|
+
|
|
88
|
+
function ensureConversation(id: string): void {
|
|
89
|
+
const db = getDb();
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
db.insert(conversations).values({
|
|
92
|
+
id,
|
|
93
|
+
title: `Conversation ${id}`,
|
|
94
|
+
createdAt: now,
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
}).run();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Create the FK parent rows required by guardian_action_requests. */
|
|
100
|
+
function ensureFkParents(): void {
|
|
101
|
+
ensureConversation(CONVERSATION_ID);
|
|
102
|
+
const session = createCallSession({
|
|
103
|
+
conversationId: CONVERSATION_ID,
|
|
104
|
+
provider: 'twilio',
|
|
105
|
+
fromNumber: '+15550001111',
|
|
106
|
+
toNumber: '+15550002222',
|
|
107
|
+
});
|
|
108
|
+
CALL_SESSION_ID = session.id;
|
|
109
|
+
|
|
110
|
+
// Pre-create enough pending questions for all tests in a suite run
|
|
111
|
+
PENDING_QUESTION_IDS = [];
|
|
112
|
+
pqIndex = 0;
|
|
113
|
+
for (let i = 0; i < 20; i++) {
|
|
114
|
+
const pq = createPendingQuestion(session.id, `Question ${i}`);
|
|
115
|
+
PENDING_QUESTION_IDS.push(pq.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function nextPendingQuestionId(): string {
|
|
120
|
+
return PENDING_QUESTION_IDS[pqIndex++];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clearTables(): void {
|
|
124
|
+
const db = getDb();
|
|
125
|
+
try {
|
|
126
|
+
db.run('DELETE FROM scoped_approval_grants');
|
|
127
|
+
} catch {
|
|
128
|
+
/* table may not exist */
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
132
|
+
} catch {
|
|
133
|
+
/* table may not exist */
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
137
|
+
} catch {
|
|
138
|
+
/* table may not exist */
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
db.run('DELETE FROM call_pending_questions');
|
|
142
|
+
} catch {
|
|
143
|
+
/* table may not exist */
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
db.run('DELETE FROM call_events');
|
|
147
|
+
} catch {
|
|
148
|
+
/* table may not exist */
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
db.run('DELETE FROM call_sessions');
|
|
152
|
+
} catch {
|
|
153
|
+
/* table may not exist */
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
db.run('DELETE FROM conversations');
|
|
157
|
+
} catch {
|
|
158
|
+
/* table may not exist */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Tests ───────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe('guardian-action grant mint -> voice consume integration', () => {
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
clearTables();
|
|
167
|
+
ensureFkParents();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('full flow: resolve guardian action with tool metadata -> mint grant -> voice consume succeeds once', async () => {
|
|
171
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
172
|
+
|
|
173
|
+
// Step 1: Create a guardian action request with tool metadata
|
|
174
|
+
// (simulates the voice ASK_GUARDIAN path)
|
|
175
|
+
const request = createGuardianActionRequest({
|
|
176
|
+
assistantId: ASSISTANT_ID,
|
|
177
|
+
kind: 'ask_guardian',
|
|
178
|
+
sourceChannel: 'voice',
|
|
179
|
+
sourceConversationId: CONVERSATION_ID,
|
|
180
|
+
callSessionId: CALL_SESSION_ID,
|
|
181
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
182
|
+
questionText: 'Can I run rm -rf /tmp/test?',
|
|
183
|
+
expiresAt: Date.now() + 60_000,
|
|
184
|
+
toolName: TOOL_NAME,
|
|
185
|
+
inputDigest,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(request.toolName).toBe(TOOL_NAME);
|
|
189
|
+
expect(request.inputDigest).toBe(inputDigest);
|
|
190
|
+
expect(request.status).toBe('pending');
|
|
191
|
+
|
|
192
|
+
// Step 2: Guardian answers -> resolve the request
|
|
193
|
+
const resolved = resolveGuardianActionRequest(
|
|
194
|
+
request.id,
|
|
195
|
+
'yes',
|
|
196
|
+
'telegram',
|
|
197
|
+
'guardian-user-123',
|
|
198
|
+
);
|
|
199
|
+
expect(resolved).not.toBeNull();
|
|
200
|
+
expect(resolved!.status).toBe('answered');
|
|
201
|
+
|
|
202
|
+
// Step 3: Mint a scoped grant from the resolved request
|
|
203
|
+
await tryMintGuardianActionGrant({
|
|
204
|
+
request: resolved!,
|
|
205
|
+
answerText: 'yes',
|
|
206
|
+
decisionChannel: 'telegram',
|
|
207
|
+
guardianExternalUserId: 'guardian-user-123',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Verify the grant was created
|
|
211
|
+
const db = getDb();
|
|
212
|
+
const grants = db
|
|
213
|
+
.select()
|
|
214
|
+
.from(scopedApprovalGrants)
|
|
215
|
+
.all();
|
|
216
|
+
expect(grants.length).toBe(1);
|
|
217
|
+
expect(grants[0].toolName).toBe(TOOL_NAME);
|
|
218
|
+
expect(grants[0].inputDigest).toBe(inputDigest);
|
|
219
|
+
expect(grants[0].scopeMode).toBe('tool_signature');
|
|
220
|
+
expect(grants[0].status).toBe('active');
|
|
221
|
+
expect(grants[0].assistantId).toBe(ASSISTANT_ID);
|
|
222
|
+
expect(grants[0].callSessionId).toBe(CALL_SESSION_ID);
|
|
223
|
+
|
|
224
|
+
// Step 4: Voice consumer consumes the grant
|
|
225
|
+
const consumeResult = consumeScopedApprovalGrantByToolSignature({
|
|
226
|
+
toolName: TOOL_NAME,
|
|
227
|
+
inputDigest,
|
|
228
|
+
consumingRequestId: 'voice-req-1',
|
|
229
|
+
assistantId: ASSISTANT_ID,
|
|
230
|
+
executionChannel: 'voice',
|
|
231
|
+
callSessionId: CALL_SESSION_ID,
|
|
232
|
+
conversationId: CONVERSATION_ID,
|
|
233
|
+
});
|
|
234
|
+
expect(consumeResult.ok).toBe(true);
|
|
235
|
+
expect(consumeResult.grant).not.toBeNull();
|
|
236
|
+
expect(consumeResult.grant!.status).toBe('consumed');
|
|
237
|
+
expect(consumeResult.grant!.consumedByRequestId).toBe('voice-req-1');
|
|
238
|
+
|
|
239
|
+
// Step 5: Second consume attempt fails (one-time use)
|
|
240
|
+
const secondConsume = consumeScopedApprovalGrantByToolSignature({
|
|
241
|
+
toolName: TOOL_NAME,
|
|
242
|
+
inputDigest,
|
|
243
|
+
consumingRequestId: 'voice-req-2',
|
|
244
|
+
assistantId: ASSISTANT_ID,
|
|
245
|
+
executionChannel: 'voice',
|
|
246
|
+
callSessionId: CALL_SESSION_ID,
|
|
247
|
+
conversationId: CONVERSATION_ID,
|
|
248
|
+
});
|
|
249
|
+
expect(secondConsume.ok).toBe(false);
|
|
250
|
+
expect(secondConsume.grant).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('grant minted for one assistantId cannot be consumed by another', async () => {
|
|
254
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
255
|
+
|
|
256
|
+
const request = createGuardianActionRequest({
|
|
257
|
+
assistantId: ASSISTANT_ID,
|
|
258
|
+
kind: 'ask_guardian',
|
|
259
|
+
sourceChannel: 'voice',
|
|
260
|
+
sourceConversationId: CONVERSATION_ID,
|
|
261
|
+
callSessionId: CALL_SESSION_ID,
|
|
262
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
263
|
+
questionText: 'Can I run the command?',
|
|
264
|
+
expiresAt: Date.now() + 60_000,
|
|
265
|
+
toolName: TOOL_NAME,
|
|
266
|
+
inputDigest,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Yes', 'telegram');
|
|
270
|
+
expect(resolved).not.toBeNull();
|
|
271
|
+
|
|
272
|
+
await tryMintGuardianActionGrant({
|
|
273
|
+
request: resolved!,
|
|
274
|
+
answerText: 'Yes',
|
|
275
|
+
decisionChannel: 'telegram',
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Attempt to consume with a different assistantId
|
|
279
|
+
const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
280
|
+
toolName: TOOL_NAME,
|
|
281
|
+
inputDigest,
|
|
282
|
+
consumingRequestId: 'voice-req-wrong',
|
|
283
|
+
assistantId: 'other-assistant',
|
|
284
|
+
executionChannel: 'voice',
|
|
285
|
+
callSessionId: CALL_SESSION_ID,
|
|
286
|
+
conversationId: CONVERSATION_ID,
|
|
287
|
+
});
|
|
288
|
+
expect(wrongAssistant.ok).toBe(false);
|
|
289
|
+
|
|
290
|
+
// Correct assistantId succeeds
|
|
291
|
+
const correctAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
292
|
+
toolName: TOOL_NAME,
|
|
293
|
+
inputDigest,
|
|
294
|
+
consumingRequestId: 'voice-req-correct',
|
|
295
|
+
assistantId: ASSISTANT_ID,
|
|
296
|
+
executionChannel: 'voice',
|
|
297
|
+
callSessionId: CALL_SESSION_ID,
|
|
298
|
+
conversationId: CONVERSATION_ID,
|
|
299
|
+
});
|
|
300
|
+
expect(correctAssistant.ok).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('no grant minted when guardian action request lacks tool metadata', async () => {
|
|
304
|
+
// Create a request without toolName/inputDigest (informational consult)
|
|
305
|
+
const request = createGuardianActionRequest({
|
|
306
|
+
assistantId: ASSISTANT_ID,
|
|
307
|
+
kind: 'ask_guardian',
|
|
308
|
+
sourceChannel: 'voice',
|
|
309
|
+
sourceConversationId: CONVERSATION_ID,
|
|
310
|
+
callSessionId: CALL_SESSION_ID,
|
|
311
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
312
|
+
questionText: 'What should I tell the caller?',
|
|
313
|
+
expiresAt: Date.now() + 60_000,
|
|
314
|
+
// No toolName or inputDigest
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Tell them to call back', 'vellum');
|
|
318
|
+
expect(resolved).not.toBeNull();
|
|
319
|
+
|
|
320
|
+
await tryMintGuardianActionGrant({
|
|
321
|
+
request: resolved!,
|
|
322
|
+
answerText: 'Tell them to call back',
|
|
323
|
+
decisionChannel: 'vellum',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// No grant should have been created
|
|
327
|
+
const db = getDb();
|
|
328
|
+
const grants = db
|
|
329
|
+
.select()
|
|
330
|
+
.from(scopedApprovalGrants)
|
|
331
|
+
.all();
|
|
332
|
+
expect(grants.length).toBe(0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('grant minted via desktop/vellum channel also consumable by voice', async () => {
|
|
336
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
337
|
+
|
|
338
|
+
const request = createGuardianActionRequest({
|
|
339
|
+
assistantId: ASSISTANT_ID,
|
|
340
|
+
kind: 'ask_guardian',
|
|
341
|
+
sourceChannel: 'voice',
|
|
342
|
+
sourceConversationId: CONVERSATION_ID,
|
|
343
|
+
callSessionId: CALL_SESSION_ID,
|
|
344
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
345
|
+
questionText: 'Permission to execute?',
|
|
346
|
+
expiresAt: Date.now() + 60_000,
|
|
347
|
+
toolName: TOOL_NAME,
|
|
348
|
+
inputDigest,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Guardian answers via desktop (vellum channel)
|
|
352
|
+
const resolved = resolveGuardianActionRequest(request.id, 'approve', 'vellum');
|
|
353
|
+
expect(resolved).not.toBeNull();
|
|
354
|
+
|
|
355
|
+
// Mint with decisionChannel: 'vellum' (desktop path)
|
|
356
|
+
await tryMintGuardianActionGrant({
|
|
357
|
+
request: resolved!,
|
|
358
|
+
answerText: 'approve',
|
|
359
|
+
decisionChannel: 'vellum',
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// The grant should have executionChannel: null (wildcard), so voice can consume
|
|
363
|
+
const consumeResult = consumeScopedApprovalGrantByToolSignature({
|
|
364
|
+
toolName: TOOL_NAME,
|
|
365
|
+
inputDigest,
|
|
366
|
+
consumingRequestId: 'voice-req-desktop',
|
|
367
|
+
assistantId: ASSISTANT_ID,
|
|
368
|
+
executionChannel: 'voice',
|
|
369
|
+
callSessionId: CALL_SESSION_ID,
|
|
370
|
+
conversationId: CONVERSATION_ID,
|
|
371
|
+
});
|
|
372
|
+
expect(consumeResult.ok).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('no grant minted when guardian answer is a denial', async () => {
|
|
376
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
377
|
+
|
|
378
|
+
const request = createGuardianActionRequest({
|
|
379
|
+
assistantId: ASSISTANT_ID,
|
|
380
|
+
kind: 'ask_guardian',
|
|
381
|
+
sourceChannel: 'voice',
|
|
382
|
+
sourceConversationId: CONVERSATION_ID,
|
|
383
|
+
callSessionId: CALL_SESSION_ID,
|
|
384
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
385
|
+
questionText: 'Can I run rm -rf /tmp/test?',
|
|
386
|
+
expiresAt: Date.now() + 60_000,
|
|
387
|
+
toolName: TOOL_NAME,
|
|
388
|
+
inputDigest,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Guardian explicitly denies the action
|
|
392
|
+
const resolved = resolveGuardianActionRequest(request.id, 'No', 'telegram', 'guardian-user-456');
|
|
393
|
+
expect(resolved).not.toBeNull();
|
|
394
|
+
|
|
395
|
+
await tryMintGuardianActionGrant({
|
|
396
|
+
request: resolved!,
|
|
397
|
+
answerText: 'No',
|
|
398
|
+
decisionChannel: 'telegram',
|
|
399
|
+
guardianExternalUserId: 'guardian-user-456',
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// No grant should have been created for a denial
|
|
403
|
+
const db = getDb();
|
|
404
|
+
const grants = db
|
|
405
|
+
.select()
|
|
406
|
+
.from(scopedApprovalGrants)
|
|
407
|
+
.all();
|
|
408
|
+
expect(grants.length).toBe(0);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test.each(['no', 'reject', 'deny', 'cancel'])('no grant minted for denial keyword: %s', async (denialWord) => {
|
|
412
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
413
|
+
|
|
414
|
+
const request = createGuardianActionRequest({
|
|
415
|
+
assistantId: ASSISTANT_ID,
|
|
416
|
+
kind: 'ask_guardian',
|
|
417
|
+
sourceChannel: 'voice',
|
|
418
|
+
sourceConversationId: CONVERSATION_ID,
|
|
419
|
+
callSessionId: CALL_SESSION_ID,
|
|
420
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
421
|
+
questionText: 'Permission to execute?',
|
|
422
|
+
expiresAt: Date.now() + 60_000,
|
|
423
|
+
toolName: TOOL_NAME,
|
|
424
|
+
inputDigest,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const resolved = resolveGuardianActionRequest(request.id, denialWord, 'telegram');
|
|
428
|
+
expect(resolved).not.toBeNull();
|
|
429
|
+
|
|
430
|
+
await tryMintGuardianActionGrant({
|
|
431
|
+
request: resolved!,
|
|
432
|
+
answerText: denialWord,
|
|
433
|
+
decisionChannel: 'telegram',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const db = getDb();
|
|
437
|
+
const grants = db
|
|
438
|
+
.select()
|
|
439
|
+
.from(scopedApprovalGrants)
|
|
440
|
+
.all();
|
|
441
|
+
expect(grants.length).toBe(0);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('no grant minted for unrecognised free-form answer without generator (fail-closed)', async () => {
|
|
445
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
446
|
+
|
|
447
|
+
const request = createGuardianActionRequest({
|
|
448
|
+
assistantId: ASSISTANT_ID,
|
|
449
|
+
kind: 'ask_guardian',
|
|
450
|
+
sourceChannel: 'voice',
|
|
451
|
+
sourceConversationId: CONVERSATION_ID,
|
|
452
|
+
callSessionId: CALL_SESSION_ID,
|
|
453
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
454
|
+
questionText: 'Can I run the command?',
|
|
455
|
+
expiresAt: Date.now() + 60_000,
|
|
456
|
+
toolName: TOOL_NAME,
|
|
457
|
+
inputDigest,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Free-form text that doesn't match a known approval phrase
|
|
461
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
|
|
462
|
+
expect(resolved).not.toBeNull();
|
|
463
|
+
|
|
464
|
+
await tryMintGuardianActionGrant({
|
|
465
|
+
request: resolved!,
|
|
466
|
+
answerText: 'Sure, go ahead and run it',
|
|
467
|
+
decisionChannel: 'telegram',
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// No grant — unrecognised text is not treated as approval (fail-closed)
|
|
471
|
+
const db = getDb();
|
|
472
|
+
const grants = db
|
|
473
|
+
.select()
|
|
474
|
+
.from(scopedApprovalGrants)
|
|
475
|
+
.all();
|
|
476
|
+
expect(grants.length).toBe(0);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test.each(['yes', 'approve', 'approve once', 'allow', 'go ahead'])('grant IS minted for approval keyword: %s', async (approveWord) => {
|
|
480
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
481
|
+
|
|
482
|
+
const request = createGuardianActionRequest({
|
|
483
|
+
assistantId: ASSISTANT_ID,
|
|
484
|
+
kind: 'ask_guardian',
|
|
485
|
+
sourceChannel: 'voice',
|
|
486
|
+
sourceConversationId: CONVERSATION_ID,
|
|
487
|
+
callSessionId: CALL_SESSION_ID,
|
|
488
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
489
|
+
questionText: 'Can I run the command?',
|
|
490
|
+
expiresAt: Date.now() + 60_000,
|
|
491
|
+
toolName: TOOL_NAME,
|
|
492
|
+
inputDigest,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const resolved = resolveGuardianActionRequest(request.id, approveWord, 'telegram');
|
|
496
|
+
expect(resolved).not.toBeNull();
|
|
497
|
+
|
|
498
|
+
await tryMintGuardianActionGrant({
|
|
499
|
+
request: resolved!,
|
|
500
|
+
answerText: approveWord,
|
|
501
|
+
decisionChannel: 'telegram',
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const db = getDb();
|
|
505
|
+
const grants = db
|
|
506
|
+
.select()
|
|
507
|
+
.from(scopedApprovalGrants)
|
|
508
|
+
.all();
|
|
509
|
+
expect(grants.length).toBe(1);
|
|
510
|
+
expect(grants[0].toolName).toBe(TOOL_NAME);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// LLM fallback two-tier classification tests
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
describe('guardian-action grant minter: two-tier classification (deterministic + LLM fallback)', () => {
|
|
519
|
+
beforeEach(() => {
|
|
520
|
+
clearTables();
|
|
521
|
+
ensureFkParents();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('deterministic parser works for exact phrases without needing the generator', async () => {
|
|
525
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
526
|
+
|
|
527
|
+
const request = createGuardianActionRequest({
|
|
528
|
+
assistantId: ASSISTANT_ID,
|
|
529
|
+
kind: 'ask_guardian',
|
|
530
|
+
sourceChannel: 'voice',
|
|
531
|
+
sourceConversationId: CONVERSATION_ID,
|
|
532
|
+
callSessionId: CALL_SESSION_ID,
|
|
533
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
534
|
+
questionText: 'Can I run the command?',
|
|
535
|
+
expiresAt: Date.now() + 60_000,
|
|
536
|
+
toolName: TOOL_NAME,
|
|
537
|
+
inputDigest,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const resolved = resolveGuardianActionRequest(request.id, 'yes', 'telegram');
|
|
541
|
+
expect(resolved).not.toBeNull();
|
|
542
|
+
|
|
543
|
+
// Provide a generator that should NOT be called (deterministic match first)
|
|
544
|
+
const generatorSpy: ApprovalConversationGenerator = async () => {
|
|
545
|
+
throw new Error('Generator should not be called for exact phrase match');
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
await tryMintGuardianActionGrant({
|
|
549
|
+
request: resolved!,
|
|
550
|
+
answerText: 'yes',
|
|
551
|
+
decisionChannel: 'telegram',
|
|
552
|
+
approvalConversationGenerator: generatorSpy,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const db = getDb();
|
|
556
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
557
|
+
expect(grants.length).toBe(1);
|
|
558
|
+
expect(grants[0].toolName).toBe(TOOL_NAME);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('free-form approval via LLM fallback mints a grant', async () => {
|
|
562
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
563
|
+
|
|
564
|
+
const request = createGuardianActionRequest({
|
|
565
|
+
assistantId: ASSISTANT_ID,
|
|
566
|
+
kind: 'ask_guardian',
|
|
567
|
+
sourceChannel: 'voice',
|
|
568
|
+
sourceConversationId: CONVERSATION_ID,
|
|
569
|
+
callSessionId: CALL_SESSION_ID,
|
|
570
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
571
|
+
questionText: 'Can I run the command?',
|
|
572
|
+
expiresAt: Date.now() + 60_000,
|
|
573
|
+
toolName: TOOL_NAME,
|
|
574
|
+
inputDigest,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
|
|
578
|
+
expect(resolved).not.toBeNull();
|
|
579
|
+
|
|
580
|
+
const mockGenerator: ApprovalConversationGenerator = async () => ({
|
|
581
|
+
disposition: 'approve_once',
|
|
582
|
+
replyText: 'Approved.',
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
await tryMintGuardianActionGrant({
|
|
586
|
+
request: resolved!,
|
|
587
|
+
answerText: 'Sure, go ahead and run it',
|
|
588
|
+
decisionChannel: 'telegram',
|
|
589
|
+
approvalConversationGenerator: mockGenerator,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const db = getDb();
|
|
593
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
594
|
+
expect(grants.length).toBe(1);
|
|
595
|
+
expect(grants[0].toolName).toBe(TOOL_NAME);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('ambiguous text returns keep_pending from generator, no grant minted', async () => {
|
|
599
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
600
|
+
|
|
601
|
+
const request = createGuardianActionRequest({
|
|
602
|
+
assistantId: ASSISTANT_ID,
|
|
603
|
+
kind: 'ask_guardian',
|
|
604
|
+
sourceChannel: 'voice',
|
|
605
|
+
sourceConversationId: CONVERSATION_ID,
|
|
606
|
+
callSessionId: CALL_SESSION_ID,
|
|
607
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
608
|
+
questionText: 'Can I run the command?',
|
|
609
|
+
expiresAt: Date.now() + 60_000,
|
|
610
|
+
toolName: TOOL_NAME,
|
|
611
|
+
inputDigest,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const resolved = resolveGuardianActionRequest(request.id, "I'm not sure about this", 'telegram');
|
|
615
|
+
expect(resolved).not.toBeNull();
|
|
616
|
+
|
|
617
|
+
const mockGenerator: ApprovalConversationGenerator = async () => ({
|
|
618
|
+
disposition: 'keep_pending',
|
|
619
|
+
replyText: 'Could you clarify?',
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await tryMintGuardianActionGrant({
|
|
623
|
+
request: resolved!,
|
|
624
|
+
answerText: "I'm not sure about this",
|
|
625
|
+
decisionChannel: 'telegram',
|
|
626
|
+
approvalConversationGenerator: mockGenerator,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const db = getDb();
|
|
630
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
631
|
+
expect(grants.length).toBe(0);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('generator failure falls back to no grant (fail-closed)', async () => {
|
|
635
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
636
|
+
|
|
637
|
+
const request = createGuardianActionRequest({
|
|
638
|
+
assistantId: ASSISTANT_ID,
|
|
639
|
+
kind: 'ask_guardian',
|
|
640
|
+
sourceChannel: 'voice',
|
|
641
|
+
sourceConversationId: CONVERSATION_ID,
|
|
642
|
+
callSessionId: CALL_SESSION_ID,
|
|
643
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
644
|
+
questionText: 'Can I run the command?',
|
|
645
|
+
expiresAt: Date.now() + 60_000,
|
|
646
|
+
toolName: TOOL_NAME,
|
|
647
|
+
inputDigest,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
|
|
651
|
+
expect(resolved).not.toBeNull();
|
|
652
|
+
|
|
653
|
+
const failingGenerator: ApprovalConversationGenerator = async () => {
|
|
654
|
+
throw new Error('LLM provider unavailable');
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
await tryMintGuardianActionGrant({
|
|
658
|
+
request: resolved!,
|
|
659
|
+
answerText: 'Sure, go ahead and run it',
|
|
660
|
+
decisionChannel: 'telegram',
|
|
661
|
+
approvalConversationGenerator: failingGenerator,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const db = getDb();
|
|
665
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
666
|
+
expect(grants.length).toBe(0);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('no generator provided and unrecognised text produces no grant', async () => {
|
|
670
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
671
|
+
|
|
672
|
+
const request = createGuardianActionRequest({
|
|
673
|
+
assistantId: ASSISTANT_ID,
|
|
674
|
+
kind: 'ask_guardian',
|
|
675
|
+
sourceChannel: 'voice',
|
|
676
|
+
sourceConversationId: CONVERSATION_ID,
|
|
677
|
+
callSessionId: CALL_SESSION_ID,
|
|
678
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
679
|
+
questionText: 'Can I run the command?',
|
|
680
|
+
expiresAt: Date.now() + 60_000,
|
|
681
|
+
toolName: TOOL_NAME,
|
|
682
|
+
inputDigest,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
|
|
686
|
+
expect(resolved).not.toBeNull();
|
|
687
|
+
|
|
688
|
+
// No generator provided — behaves like before, no LLM fallback
|
|
689
|
+
await tryMintGuardianActionGrant({
|
|
690
|
+
request: resolved!,
|
|
691
|
+
answerText: 'Sure, go ahead and run it',
|
|
692
|
+
decisionChannel: 'telegram',
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const db = getDb();
|
|
696
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
697
|
+
expect(grants.length).toBe(0);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test('deterministic "approve always" still mints a one-time grant (normalized to approve_once semantics)', async () => {
|
|
701
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
702
|
+
|
|
703
|
+
const request = createGuardianActionRequest({
|
|
704
|
+
assistantId: ASSISTANT_ID,
|
|
705
|
+
kind: 'ask_guardian',
|
|
706
|
+
sourceChannel: 'voice',
|
|
707
|
+
sourceConversationId: CONVERSATION_ID,
|
|
708
|
+
callSessionId: CALL_SESSION_ID,
|
|
709
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
710
|
+
questionText: 'Can I run the command?',
|
|
711
|
+
expiresAt: Date.now() + 60_000,
|
|
712
|
+
toolName: TOOL_NAME,
|
|
713
|
+
inputDigest,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const resolved = resolveGuardianActionRequest(request.id, 'approve always', 'telegram');
|
|
717
|
+
expect(resolved).not.toBeNull();
|
|
718
|
+
|
|
719
|
+
// Generator should NOT be called -- deterministic parser matches "approve always"
|
|
720
|
+
const generatorSpy: ApprovalConversationGenerator = async () => {
|
|
721
|
+
throw new Error('Generator should not be called for deterministic match');
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
await tryMintGuardianActionGrant({
|
|
725
|
+
request: resolved!,
|
|
726
|
+
answerText: 'approve always',
|
|
727
|
+
decisionChannel: 'telegram',
|
|
728
|
+
approvalConversationGenerator: generatorSpy,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Grant is minted (approve_always treated as approval), but it is still
|
|
732
|
+
// a one-time tool_signature grant -- no broader privilege is granted.
|
|
733
|
+
const db = getDb();
|
|
734
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
735
|
+
expect(grants.length).toBe(1);
|
|
736
|
+
expect(grants[0].scopeMode).toBe('tool_signature');
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('LLM fallback allowedActions excludes approve_always (guardian invariant)', async () => {
|
|
740
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
741
|
+
|
|
742
|
+
const request = createGuardianActionRequest({
|
|
743
|
+
assistantId: ASSISTANT_ID,
|
|
744
|
+
kind: 'ask_guardian',
|
|
745
|
+
sourceChannel: 'voice',
|
|
746
|
+
sourceConversationId: CONVERSATION_ID,
|
|
747
|
+
callSessionId: CALL_SESSION_ID,
|
|
748
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
749
|
+
questionText: 'Can I run the command?',
|
|
750
|
+
expiresAt: Date.now() + 60_000,
|
|
751
|
+
toolName: TOOL_NAME,
|
|
752
|
+
inputDigest,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
|
|
756
|
+
expect(resolved).not.toBeNull();
|
|
757
|
+
|
|
758
|
+
// Generator returns approve_always -- but the allowedActions constraint
|
|
759
|
+
// in the minter restricts to approve_once/reject, so the approval-
|
|
760
|
+
// conversation-turn layer will normalize this to keep_pending.
|
|
761
|
+
const mockGenerator: ApprovalConversationGenerator = async () => ({
|
|
762
|
+
disposition: 'approve_always',
|
|
763
|
+
replyText: 'Approved permanently.',
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
await tryMintGuardianActionGrant({
|
|
767
|
+
request: resolved!,
|
|
768
|
+
answerText: 'Sure, go ahead and run it',
|
|
769
|
+
decisionChannel: 'telegram',
|
|
770
|
+
approvalConversationGenerator: mockGenerator,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// No grant -- approve_always is not in LLM fallback allowedActions,
|
|
774
|
+
// so the disposition gets normalized to keep_pending (fail-closed).
|
|
775
|
+
const db = getDb();
|
|
776
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
777
|
+
expect(grants.length).toBe(0);
|
|
778
|
+
});
|
|
779
|
+
});
|