@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,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security test matrix for channel-agnostic scoped approval grants.
|
|
3
|
+
*
|
|
4
|
+
* This file covers scenarios NOT already tested in:
|
|
5
|
+
* - scoped-approval-grants.test.ts (CRUD, digest, basic consume semantics)
|
|
6
|
+
* - voice-scoped-grant-consumer.test.ts (voice bridge integration)
|
|
7
|
+
* - guardian-grant-minting.test.ts (grant minting on approval decisions)
|
|
8
|
+
*
|
|
9
|
+
* Additional scenarios tested here:
|
|
10
|
+
* 6. Requester identity mismatch denied
|
|
11
|
+
* 8. Concurrent consume attempts: only one succeeds
|
|
12
|
+
* 12. Restart behavior remains fail-closed — grants stored in persistent DB
|
|
13
|
+
*
|
|
14
|
+
* Cross-reference:
|
|
15
|
+
* 1. Voice happy path — voice-scoped-grant-consumer.test.ts
|
|
16
|
+
* 2. Replay denied — scoped-approval-grants.test.ts + voice-scoped-grant-consumer.test.ts
|
|
17
|
+
* 3. Tool mismatch denied — scoped-approval-grants.test.ts + voice-scoped-grant-consumer.test.ts
|
|
18
|
+
* 4. Input mismatch denied — scoped-approval-grants.test.ts
|
|
19
|
+
* 5. Execution-channel mismatch denied — scoped-approval-grants.test.ts
|
|
20
|
+
* 7. Expired grant denied — scoped-approval-grants.test.ts
|
|
21
|
+
* 9. Stale decision cannot mint extra grant — guardian-grant-minting.test.ts
|
|
22
|
+
* 10. Informational ASK_GUARDIAN cannot mint grant — guardian-grant-minting.test.ts
|
|
23
|
+
* 11. Guardian identity mismatch cannot mint grant — guardian-grant-minting.test.ts
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
31
|
+
|
|
32
|
+
const testDir = mkdtempSync(join(tmpdir(), 'scoped-grant-security-matrix-'));
|
|
33
|
+
|
|
34
|
+
mock.module('../util/platform.js', () => ({
|
|
35
|
+
getDataDir: () => testDir,
|
|
36
|
+
isMacOS: () => process.platform === 'darwin',
|
|
37
|
+
isLinux: () => process.platform === 'linux',
|
|
38
|
+
isWindows: () => process.platform === 'win32',
|
|
39
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
40
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
41
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
42
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
43
|
+
ensureDataDir: () => {},
|
|
44
|
+
migrateToDataLayout: () => {},
|
|
45
|
+
migrateToWorkspaceLayout: () => {},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
mock.module('../util/logger.js', () => ({
|
|
49
|
+
getLogger: () =>
|
|
50
|
+
new Proxy({} as Record<string, unknown>, {
|
|
51
|
+
get: () => () => {},
|
|
52
|
+
}),
|
|
53
|
+
isDebug: () => false,
|
|
54
|
+
truncateForLog: (value: string) => value,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
58
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
59
|
+
import {
|
|
60
|
+
_internal,
|
|
61
|
+
type CreateScopedApprovalGrantParams,
|
|
62
|
+
} from '../memory/scoped-approval-grants.js';
|
|
63
|
+
|
|
64
|
+
const { consumeScopedApprovalGrantByToolSignature, createScopedApprovalGrant } = _internal;
|
|
65
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
66
|
+
|
|
67
|
+
initializeDb();
|
|
68
|
+
|
|
69
|
+
function clearTables(): void {
|
|
70
|
+
const db = getDb();
|
|
71
|
+
db.delete(scopedApprovalGrants).run();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
resetDb();
|
|
76
|
+
try {
|
|
77
|
+
rmSync(testDir, { recursive: true });
|
|
78
|
+
} catch {
|
|
79
|
+
/* best effort */
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Helper to build grant params with sensible defaults
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
|
|
88
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
89
|
+
return {
|
|
90
|
+
assistantId: 'self',
|
|
91
|
+
scopeMode: 'tool_signature',
|
|
92
|
+
toolName: 'bash',
|
|
93
|
+
inputDigest: computeToolApprovalDigest('bash', { cmd: 'ls' }),
|
|
94
|
+
requestChannel: 'telegram',
|
|
95
|
+
decisionChannel: 'telegram',
|
|
96
|
+
expiresAt: futureExpiry,
|
|
97
|
+
...overrides,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ===========================================================================
|
|
102
|
+
// 6. Requester identity mismatch denied
|
|
103
|
+
// ===========================================================================
|
|
104
|
+
|
|
105
|
+
describe('security matrix: requester identity mismatch', () => {
|
|
106
|
+
beforeEach(() => clearTables());
|
|
107
|
+
|
|
108
|
+
test('grant scoped to a specific requester cannot be consumed by a different requester', () => {
|
|
109
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
110
|
+
createScopedApprovalGrant(
|
|
111
|
+
grantParams({
|
|
112
|
+
toolName: 'bash',
|
|
113
|
+
inputDigest: digest,
|
|
114
|
+
requesterExternalUserId: 'user-alice',
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Attempt to consume as a different user
|
|
119
|
+
const wrongUser = consumeScopedApprovalGrantByToolSignature({
|
|
120
|
+
toolName: 'bash',
|
|
121
|
+
inputDigest: digest,
|
|
122
|
+
consumingRequestId: 'c1',
|
|
123
|
+
requesterExternalUserId: 'user-bob',
|
|
124
|
+
});
|
|
125
|
+
expect(wrongUser.ok).toBe(false);
|
|
126
|
+
|
|
127
|
+
// Correct user succeeds
|
|
128
|
+
const correctUser = consumeScopedApprovalGrantByToolSignature({
|
|
129
|
+
toolName: 'bash',
|
|
130
|
+
inputDigest: digest,
|
|
131
|
+
consumingRequestId: 'c2',
|
|
132
|
+
requesterExternalUserId: 'user-alice',
|
|
133
|
+
});
|
|
134
|
+
expect(correctUser.ok).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('grant with null requesterExternalUserId allows any requester (wildcard)', () => {
|
|
138
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
139
|
+
createScopedApprovalGrant(
|
|
140
|
+
grantParams({
|
|
141
|
+
toolName: 'bash',
|
|
142
|
+
inputDigest: digest,
|
|
143
|
+
requesterExternalUserId: null,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Any user can consume when requester is null (wildcard)
|
|
148
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
149
|
+
toolName: 'bash',
|
|
150
|
+
inputDigest: digest,
|
|
151
|
+
consumingRequestId: 'c1',
|
|
152
|
+
requesterExternalUserId: 'user-anyone',
|
|
153
|
+
});
|
|
154
|
+
expect(result.ok).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('consume without providing requester only matches grants with null requester', () => {
|
|
158
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
159
|
+
|
|
160
|
+
// Grant scoped to a specific requester
|
|
161
|
+
createScopedApprovalGrant(
|
|
162
|
+
grantParams({
|
|
163
|
+
toolName: 'bash',
|
|
164
|
+
inputDigest: digest,
|
|
165
|
+
requesterExternalUserId: 'user-alice',
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Consume without specifying requester — should NOT match a requester-scoped grant
|
|
170
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
171
|
+
toolName: 'bash',
|
|
172
|
+
inputDigest: digest,
|
|
173
|
+
consumingRequestId: 'c1',
|
|
174
|
+
// No requesterExternalUserId provided
|
|
175
|
+
});
|
|
176
|
+
expect(result.ok).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ===========================================================================
|
|
181
|
+
// 8. Concurrent consume attempts: only one succeeds
|
|
182
|
+
// ===========================================================================
|
|
183
|
+
|
|
184
|
+
describe('security matrix: concurrent consume (CAS)', () => {
|
|
185
|
+
beforeEach(() => clearTables());
|
|
186
|
+
|
|
187
|
+
test('only one of multiple concurrent consumers succeeds for the same grant', () => {
|
|
188
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf /' });
|
|
189
|
+
createScopedApprovalGrant(
|
|
190
|
+
grantParams({
|
|
191
|
+
toolName: 'bash',
|
|
192
|
+
inputDigest: digest,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Simulate concurrent consumers racing to consume the same grant.
|
|
197
|
+
// Since SQLite is synchronous in Bun, we simulate by issuing
|
|
198
|
+
// back-to-back consume calls — the CAS mechanism ensures only the
|
|
199
|
+
// first succeeds.
|
|
200
|
+
const results: boolean[] = [];
|
|
201
|
+
for (let i = 0; i < 5; i++) {
|
|
202
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
203
|
+
toolName: 'bash',
|
|
204
|
+
inputDigest: digest,
|
|
205
|
+
consumingRequestId: `concurrent-consumer-${i}`,
|
|
206
|
+
});
|
|
207
|
+
results.push(result.ok);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Exactly one should succeed
|
|
211
|
+
const successes = results.filter(Boolean);
|
|
212
|
+
expect(successes.length).toBe(1);
|
|
213
|
+
|
|
214
|
+
// The first consumer should win
|
|
215
|
+
expect(results[0]).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('with multiple matching grants, each consumer gets at most one grant', () => {
|
|
219
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
220
|
+
|
|
221
|
+
// Create 3 grants for the same tool signature
|
|
222
|
+
for (let i = 0; i < 3; i++) {
|
|
223
|
+
createScopedApprovalGrant(
|
|
224
|
+
grantParams({
|
|
225
|
+
toolName: 'bash',
|
|
226
|
+
inputDigest: digest,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 5 consumers compete for 3 grants
|
|
232
|
+
const results: boolean[] = [];
|
|
233
|
+
for (let i = 0; i < 5; i++) {
|
|
234
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
235
|
+
toolName: 'bash',
|
|
236
|
+
inputDigest: digest,
|
|
237
|
+
consumingRequestId: `consumer-${i}`,
|
|
238
|
+
});
|
|
239
|
+
results.push(result.ok);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Exactly 3 should succeed (one per grant)
|
|
243
|
+
const successes = results.filter(Boolean);
|
|
244
|
+
expect(successes.length).toBe(3);
|
|
245
|
+
|
|
246
|
+
// The last 2 should fail
|
|
247
|
+
expect(results[3]).toBe(false);
|
|
248
|
+
expect(results[4]).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ===========================================================================
|
|
253
|
+
// 12. Restart behavior remains fail-closed — grants stored in persistent DB
|
|
254
|
+
// ===========================================================================
|
|
255
|
+
|
|
256
|
+
describe('security matrix: persistence and fail-closed behavior', () => {
|
|
257
|
+
beforeEach(() => clearTables());
|
|
258
|
+
|
|
259
|
+
test('grants survive DB re-initialization (simulating daemon restart)', () => {
|
|
260
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
261
|
+
|
|
262
|
+
// Create a grant
|
|
263
|
+
const grant = createScopedApprovalGrant(
|
|
264
|
+
grantParams({
|
|
265
|
+
toolName: 'bash',
|
|
266
|
+
inputDigest: digest,
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
expect(grant.status).toBe('active');
|
|
270
|
+
|
|
271
|
+
// Re-initialize the DB (simulates daemon restart — the SQLite file persists)
|
|
272
|
+
initializeDb();
|
|
273
|
+
|
|
274
|
+
// The grant should still be consumable after restart
|
|
275
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
276
|
+
toolName: 'bash',
|
|
277
|
+
inputDigest: digest,
|
|
278
|
+
consumingRequestId: 'post-restart-consumer',
|
|
279
|
+
});
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
expect(result.grant!.id).toBe(grant.id);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('consumed grants remain consumed after DB re-initialization', () => {
|
|
285
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
286
|
+
|
|
287
|
+
createScopedApprovalGrant(
|
|
288
|
+
grantParams({
|
|
289
|
+
toolName: 'bash',
|
|
290
|
+
inputDigest: digest,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Consume the grant
|
|
295
|
+
const first = consumeScopedApprovalGrantByToolSignature({
|
|
296
|
+
toolName: 'bash',
|
|
297
|
+
inputDigest: digest,
|
|
298
|
+
consumingRequestId: 'pre-restart-consumer',
|
|
299
|
+
});
|
|
300
|
+
expect(first.ok).toBe(true);
|
|
301
|
+
|
|
302
|
+
// Re-initialize the DB (simulates daemon restart)
|
|
303
|
+
initializeDb();
|
|
304
|
+
|
|
305
|
+
// The consumed grant must NOT be consumable again after restart
|
|
306
|
+
const second = consumeScopedApprovalGrantByToolSignature({
|
|
307
|
+
toolName: 'bash',
|
|
308
|
+
inputDigest: digest,
|
|
309
|
+
consumingRequestId: 'post-restart-consumer',
|
|
310
|
+
});
|
|
311
|
+
expect(second.ok).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('no grants means fail-closed (deny by default)', () => {
|
|
315
|
+
// Empty grant table — no grants at all
|
|
316
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'dangerous-command' });
|
|
317
|
+
|
|
318
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
319
|
+
toolName: 'bash',
|
|
320
|
+
inputDigest: digest,
|
|
321
|
+
consumingRequestId: 'consumer-1',
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Must fail closed — no grant = no permission
|
|
325
|
+
expect(result.ok).toBe(false);
|
|
326
|
+
expect(result.grant).toBeNull();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ===========================================================================
|
|
331
|
+
// Combined cross-scope invariants
|
|
332
|
+
// ===========================================================================
|
|
333
|
+
|
|
334
|
+
describe('security matrix: cross-scope invariants', () => {
|
|
335
|
+
beforeEach(() => clearTables());
|
|
336
|
+
|
|
337
|
+
test('grant for one assistant cannot be consumed by another assistant', () => {
|
|
338
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
339
|
+
createScopedApprovalGrant(
|
|
340
|
+
grantParams({
|
|
341
|
+
toolName: 'bash',
|
|
342
|
+
inputDigest: digest,
|
|
343
|
+
assistantId: 'assistant-alpha',
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Attempt consumption from a different assistant
|
|
348
|
+
const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
349
|
+
toolName: 'bash',
|
|
350
|
+
inputDigest: digest,
|
|
351
|
+
consumingRequestId: 'c1',
|
|
352
|
+
assistantId: 'assistant-beta',
|
|
353
|
+
});
|
|
354
|
+
expect(wrongAssistant.ok).toBe(false);
|
|
355
|
+
|
|
356
|
+
// Correct assistant succeeds
|
|
357
|
+
const correctAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
358
|
+
toolName: 'bash',
|
|
359
|
+
inputDigest: digest,
|
|
360
|
+
consumingRequestId: 'c2',
|
|
361
|
+
assistantId: 'assistant-alpha',
|
|
362
|
+
});
|
|
363
|
+
expect(correctAssistant.ok).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('all scope fields must match simultaneously for consumption', () => {
|
|
367
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
368
|
+
|
|
369
|
+
// Create a maximally-scoped grant
|
|
370
|
+
createScopedApprovalGrant(
|
|
371
|
+
grantParams({
|
|
372
|
+
toolName: 'bash',
|
|
373
|
+
inputDigest: digest,
|
|
374
|
+
assistantId: 'self',
|
|
375
|
+
executionChannel: 'voice',
|
|
376
|
+
conversationId: 'conv-123',
|
|
377
|
+
callSessionId: 'call-456',
|
|
378
|
+
requesterExternalUserId: 'user-alice',
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Each field mismatch should independently cause failure:
|
|
383
|
+
|
|
384
|
+
// Wrong execution channel
|
|
385
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
386
|
+
toolName: 'bash',
|
|
387
|
+
inputDigest: digest,
|
|
388
|
+
consumingRequestId: 'c-chan',
|
|
389
|
+
assistantId: 'self',
|
|
390
|
+
executionChannel: 'sms',
|
|
391
|
+
conversationId: 'conv-123',
|
|
392
|
+
callSessionId: 'call-456',
|
|
393
|
+
requesterExternalUserId: 'user-alice',
|
|
394
|
+
}).ok).toBe(false);
|
|
395
|
+
|
|
396
|
+
// Wrong conversation
|
|
397
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
398
|
+
toolName: 'bash',
|
|
399
|
+
inputDigest: digest,
|
|
400
|
+
consumingRequestId: 'c-conv',
|
|
401
|
+
assistantId: 'self',
|
|
402
|
+
executionChannel: 'voice',
|
|
403
|
+
conversationId: 'conv-999',
|
|
404
|
+
callSessionId: 'call-456',
|
|
405
|
+
requesterExternalUserId: 'user-alice',
|
|
406
|
+
}).ok).toBe(false);
|
|
407
|
+
|
|
408
|
+
// Wrong call session
|
|
409
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
410
|
+
toolName: 'bash',
|
|
411
|
+
inputDigest: digest,
|
|
412
|
+
consumingRequestId: 'c-call',
|
|
413
|
+
assistantId: 'self',
|
|
414
|
+
executionChannel: 'voice',
|
|
415
|
+
conversationId: 'conv-123',
|
|
416
|
+
callSessionId: 'call-999',
|
|
417
|
+
requesterExternalUserId: 'user-alice',
|
|
418
|
+
}).ok).toBe(false);
|
|
419
|
+
|
|
420
|
+
// Wrong requester
|
|
421
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
422
|
+
toolName: 'bash',
|
|
423
|
+
inputDigest: digest,
|
|
424
|
+
consumingRequestId: 'c-user',
|
|
425
|
+
assistantId: 'self',
|
|
426
|
+
executionChannel: 'voice',
|
|
427
|
+
conversationId: 'conv-123',
|
|
428
|
+
callSessionId: 'call-456',
|
|
429
|
+
requesterExternalUserId: 'user-bob',
|
|
430
|
+
}).ok).toBe(false);
|
|
431
|
+
|
|
432
|
+
// All fields match — succeeds
|
|
433
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
434
|
+
toolName: 'bash',
|
|
435
|
+
inputDigest: digest,
|
|
436
|
+
consumingRequestId: 'c-all',
|
|
437
|
+
assistantId: 'self',
|
|
438
|
+
executionChannel: 'voice',
|
|
439
|
+
conversationId: 'conv-123',
|
|
440
|
+
callSessionId: 'call-456',
|
|
441
|
+
requesterExternalUserId: 'user-alice',
|
|
442
|
+
}).ok).toBe(true);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -169,25 +169,7 @@ describe('session-manager', () => {
|
|
|
169
169
|
expect(() => getSessionEnv(session.id)).toThrow(/not active/);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
test('returns
|
|
173
|
-
const session = createSession(CONV_ID, CRED_IDS);
|
|
174
|
-
const started = await startSession(session.id);
|
|
175
|
-
const env = getSessionEnv(session.id, { dockerMode: true });
|
|
176
|
-
|
|
177
|
-
expect(env.HTTP_PROXY).toBe(`http://host.docker.internal:${started.port}`);
|
|
178
|
-
expect(env.HTTPS_PROXY).toBe(`http://host.docker.internal:${started.port}`);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test('returns 127.0.0.1 URL when dockerMode is false', async () => {
|
|
182
|
-
const session = createSession(CONV_ID, CRED_IDS);
|
|
183
|
-
const started = await startSession(session.id);
|
|
184
|
-
const env = getSessionEnv(session.id, { dockerMode: false });
|
|
185
|
-
|
|
186
|
-
expect(env.HTTP_PROXY).toBe(`http://127.0.0.1:${started.port}`);
|
|
187
|
-
expect(env.HTTPS_PROXY).toBe(`http://127.0.0.1:${started.port}`);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('returns 127.0.0.1 URL when no options are passed', async () => {
|
|
172
|
+
test('returns 127.0.0.1 URL for active session', async () => {
|
|
191
173
|
const session = createSession(CONV_ID, CRED_IDS);
|
|
192
174
|
const started = await startSession(session.id);
|
|
193
175
|
const env = getSessionEnv(session.id);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from 'bun:test';
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import type { Message } from '../providers/types.js';
|
|
4
4
|
|
|
@@ -49,14 +49,27 @@ mock.module('../security/secret-allowlist.js', () => ({
|
|
|
49
49
|
}));
|
|
50
50
|
|
|
51
51
|
// Mutable store so each test can configure its own messages
|
|
52
|
-
let mockDbMessages: Array<{ id: string; role: string; content: string }> = [];
|
|
52
|
+
let mockDbMessages: Array<{ id: string; role: string; content: string; metadata?: string | null }> = [];
|
|
53
53
|
let mockConversation: Record<string, unknown> | null = null;
|
|
54
|
+
let nextMockMessageId = 1;
|
|
54
55
|
|
|
55
56
|
mock.module('../memory/conversation-store.js', () => ({
|
|
56
57
|
getMessages: () => mockDbMessages,
|
|
57
58
|
getConversation: () => mockConversation,
|
|
58
59
|
createConversation: () => ({ id: 'conv-1' }),
|
|
59
60
|
listConversations: () => [],
|
|
61
|
+
addMessage: async (_conversationId: string, role: string, content: string, metadata?: Record<string, unknown>) => {
|
|
62
|
+
const id = `persisted-${nextMockMessageId++}`;
|
|
63
|
+
mockDbMessages.push({
|
|
64
|
+
id,
|
|
65
|
+
role,
|
|
66
|
+
content,
|
|
67
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
68
|
+
});
|
|
69
|
+
return { id };
|
|
70
|
+
},
|
|
71
|
+
setConversationOriginChannelIfUnset: () => {},
|
|
72
|
+
setConversationOriginInterfaceIfUnset: () => {},
|
|
60
73
|
}));
|
|
61
74
|
|
|
62
75
|
import { Session } from '../daemon/session.js';
|
|
@@ -67,6 +80,10 @@ function makeSession(): Session {
|
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
describe('loadFromDb history repair', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
nextMockMessageId = 1;
|
|
85
|
+
});
|
|
86
|
+
|
|
70
87
|
test('repairs corrupt persisted history: missing tool_result inserted', async () => {
|
|
71
88
|
mockConversation = {
|
|
72
89
|
id: 'conv-1',
|
|
@@ -220,4 +237,154 @@ describe('loadFromDb history repair', () => {
|
|
|
220
237
|
expect(messages).toHaveLength(2);
|
|
221
238
|
expect(messages[1].content).toEqual([{ type: 'text', text: 'Sure' }]);
|
|
222
239
|
});
|
|
240
|
+
|
|
241
|
+
test('untrusted actor load hides guardian-provenance history and context summary', async () => {
|
|
242
|
+
mockConversation = {
|
|
243
|
+
id: 'conv-1',
|
|
244
|
+
contextSummary: 'Sensitive guardian summary',
|
|
245
|
+
contextCompactedMessageCount: 3,
|
|
246
|
+
totalInputTokens: 0,
|
|
247
|
+
totalOutputTokens: 0,
|
|
248
|
+
totalEstimatedCost: 0,
|
|
249
|
+
};
|
|
250
|
+
mockDbMessages = [
|
|
251
|
+
{
|
|
252
|
+
id: 'm1',
|
|
253
|
+
role: 'user',
|
|
254
|
+
content: JSON.stringify([{ type: 'text', text: 'Guardian secret question' }]),
|
|
255
|
+
metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: 'm2',
|
|
259
|
+
role: 'assistant',
|
|
260
|
+
content: JSON.stringify([{ type: 'text', text: 'Guardian-only answer' }]),
|
|
261
|
+
metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'm3',
|
|
265
|
+
role: 'user',
|
|
266
|
+
content: JSON.stringify([{ type: 'text', text: 'Untrusted follow-up' }]),
|
|
267
|
+
metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'm4',
|
|
271
|
+
role: 'assistant',
|
|
272
|
+
content: JSON.stringify([{ type: 'text', text: 'Untrusted-safe reply' }]),
|
|
273
|
+
metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
const session = makeSession();
|
|
278
|
+
session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
|
|
279
|
+
await session.loadFromDb();
|
|
280
|
+
const messages = session.getMessages();
|
|
281
|
+
|
|
282
|
+
expect(messages).toHaveLength(2);
|
|
283
|
+
expect(messages[0].role).toBe('user');
|
|
284
|
+
expect(messages[0].content).toEqual([{ type: 'text', text: 'Untrusted follow-up' }]);
|
|
285
|
+
expect(messages[1].role).toBe('assistant');
|
|
286
|
+
expect(messages[1].content).toEqual([{ type: 'text', text: 'Untrusted-safe reply' }]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('ensureActorScopedHistory reloads when actor role changes', async () => {
|
|
290
|
+
mockConversation = {
|
|
291
|
+
id: 'conv-1',
|
|
292
|
+
contextSummary: null,
|
|
293
|
+
contextCompactedMessageCount: 0,
|
|
294
|
+
totalInputTokens: 0,
|
|
295
|
+
totalOutputTokens: 0,
|
|
296
|
+
totalEstimatedCost: 0,
|
|
297
|
+
};
|
|
298
|
+
mockDbMessages = [
|
|
299
|
+
{
|
|
300
|
+
id: 'm1',
|
|
301
|
+
role: 'user',
|
|
302
|
+
content: JSON.stringify([{ type: 'text', text: 'Guardian question' }]),
|
|
303
|
+
metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: 'm2',
|
|
307
|
+
role: 'assistant',
|
|
308
|
+
content: JSON.stringify([{ type: 'text', text: 'Guardian answer' }]),
|
|
309
|
+
metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: 'm3',
|
|
313
|
+
role: 'user',
|
|
314
|
+
content: JSON.stringify([{ type: 'text', text: 'Unverified ping' }]),
|
|
315
|
+
metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: 'm4',
|
|
319
|
+
role: 'assistant',
|
|
320
|
+
content: JSON.stringify([{ type: 'text', text: 'Unverified reply' }]),
|
|
321
|
+
metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
const session = makeSession();
|
|
326
|
+
|
|
327
|
+
session.setGuardianContext({ actorRole: 'guardian', sourceChannel: 'telegram' });
|
|
328
|
+
await session.ensureActorScopedHistory();
|
|
329
|
+
expect(session.getMessages()).toHaveLength(4);
|
|
330
|
+
|
|
331
|
+
session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
|
|
332
|
+
await session.ensureActorScopedHistory();
|
|
333
|
+
const downgradedMessages = session.getMessages();
|
|
334
|
+
expect(downgradedMessages).toHaveLength(2);
|
|
335
|
+
expect(downgradedMessages[0].content).toEqual([{ type: 'text', text: 'Unverified ping' }]);
|
|
336
|
+
expect(downgradedMessages[1].content).toEqual([{ type: 'text', text: 'Unverified reply' }]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('persistUserMessage reloads actor-scoped history before persisting on role switch', async () => {
|
|
340
|
+
mockConversation = {
|
|
341
|
+
id: 'conv-1',
|
|
342
|
+
contextSummary: null,
|
|
343
|
+
contextCompactedMessageCount: 0,
|
|
344
|
+
totalInputTokens: 0,
|
|
345
|
+
totalOutputTokens: 0,
|
|
346
|
+
totalEstimatedCost: 0,
|
|
347
|
+
};
|
|
348
|
+
mockDbMessages = [
|
|
349
|
+
{
|
|
350
|
+
id: 'm1',
|
|
351
|
+
role: 'user',
|
|
352
|
+
content: JSON.stringify([{ type: 'text', text: 'Guardian-only question' }]),
|
|
353
|
+
metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: 'm2',
|
|
357
|
+
role: 'assistant',
|
|
358
|
+
content: JSON.stringify([{ type: 'text', text: 'Guardian-only answer' }]),
|
|
359
|
+
metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'm3',
|
|
363
|
+
role: 'user',
|
|
364
|
+
content: JSON.stringify([{ type: 'text', text: 'Unverified ping' }]),
|
|
365
|
+
metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: 'm4',
|
|
369
|
+
role: 'assistant',
|
|
370
|
+
content: JSON.stringify([{ type: 'text', text: 'Unverified reply' }]),
|
|
371
|
+
metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const session = makeSession();
|
|
376
|
+
|
|
377
|
+
session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
|
|
378
|
+
await session.ensureActorScopedHistory();
|
|
379
|
+
expect(session.getMessages()).toHaveLength(2);
|
|
380
|
+
|
|
381
|
+
session.setGuardianContext({ actorRole: 'guardian', sourceChannel: 'telegram' });
|
|
382
|
+
await session.persistUserMessage('Guardian follow-up', []);
|
|
383
|
+
const messagesAfterPersist = session.getMessages();
|
|
384
|
+
|
|
385
|
+
expect(messagesAfterPersist).toHaveLength(5);
|
|
386
|
+
expect(messagesAfterPersist[0].content).toEqual([{ type: 'text', text: 'Guardian-only question' }]);
|
|
387
|
+
expect(messagesAfterPersist[1].content).toEqual([{ type: 'text', text: 'Guardian-only answer' }]);
|
|
388
|
+
expect(messagesAfterPersist[4].content).toEqual([{ type: 'text', text: 'Guardian follow-up' }]);
|
|
389
|
+
});
|
|
223
390
|
});
|