@vellumai/assistant 0.3.16 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +74 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +139 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +180 -0
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +22 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +23 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +150 -8
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +16 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +46 -5
- package/src/cli/core-commands.ts +41 -1
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +450 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +17 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +65 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +6 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +36 -1
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +28 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +120 -4
- package/src/runtime/routes/inbound-message-handler.ts +100 -33
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- package/src/version.ts +29 -2
|
@@ -0,0 +1,443 @@
|
|
|
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 {
|
|
58
|
+
type CreateScopedApprovalGrantParams,
|
|
59
|
+
consumeScopedApprovalGrantByToolSignature,
|
|
60
|
+
createScopedApprovalGrant,
|
|
61
|
+
} from '../memory/scoped-approval-grants.js';
|
|
62
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
63
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
64
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
65
|
+
|
|
66
|
+
initializeDb();
|
|
67
|
+
|
|
68
|
+
function clearTables(): void {
|
|
69
|
+
const db = getDb();
|
|
70
|
+
db.delete(scopedApprovalGrants).run();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
afterAll(() => {
|
|
74
|
+
resetDb();
|
|
75
|
+
try {
|
|
76
|
+
rmSync(testDir, { recursive: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* best effort */
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Helper to build grant params with sensible defaults
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
|
|
87
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
88
|
+
return {
|
|
89
|
+
assistantId: 'self',
|
|
90
|
+
scopeMode: 'tool_signature',
|
|
91
|
+
toolName: 'bash',
|
|
92
|
+
inputDigest: computeToolApprovalDigest('bash', { cmd: 'ls' }),
|
|
93
|
+
requestChannel: 'telegram',
|
|
94
|
+
decisionChannel: 'telegram',
|
|
95
|
+
expiresAt: futureExpiry,
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
// 6. Requester identity mismatch denied
|
|
102
|
+
// ===========================================================================
|
|
103
|
+
|
|
104
|
+
describe('security matrix: requester identity mismatch', () => {
|
|
105
|
+
beforeEach(() => clearTables());
|
|
106
|
+
|
|
107
|
+
test('grant scoped to a specific requester cannot be consumed by a different requester', () => {
|
|
108
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
109
|
+
createScopedApprovalGrant(
|
|
110
|
+
grantParams({
|
|
111
|
+
toolName: 'bash',
|
|
112
|
+
inputDigest: digest,
|
|
113
|
+
requesterExternalUserId: 'user-alice',
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Attempt to consume as a different user
|
|
118
|
+
const wrongUser = consumeScopedApprovalGrantByToolSignature({
|
|
119
|
+
toolName: 'bash',
|
|
120
|
+
inputDigest: digest,
|
|
121
|
+
consumingRequestId: 'c1',
|
|
122
|
+
requesterExternalUserId: 'user-bob',
|
|
123
|
+
});
|
|
124
|
+
expect(wrongUser.ok).toBe(false);
|
|
125
|
+
|
|
126
|
+
// Correct user succeeds
|
|
127
|
+
const correctUser = consumeScopedApprovalGrantByToolSignature({
|
|
128
|
+
toolName: 'bash',
|
|
129
|
+
inputDigest: digest,
|
|
130
|
+
consumingRequestId: 'c2',
|
|
131
|
+
requesterExternalUserId: 'user-alice',
|
|
132
|
+
});
|
|
133
|
+
expect(correctUser.ok).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('grant with null requesterExternalUserId allows any requester (wildcard)', () => {
|
|
137
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
138
|
+
createScopedApprovalGrant(
|
|
139
|
+
grantParams({
|
|
140
|
+
toolName: 'bash',
|
|
141
|
+
inputDigest: digest,
|
|
142
|
+
requesterExternalUserId: null,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Any user can consume when requester is null (wildcard)
|
|
147
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
148
|
+
toolName: 'bash',
|
|
149
|
+
inputDigest: digest,
|
|
150
|
+
consumingRequestId: 'c1',
|
|
151
|
+
requesterExternalUserId: 'user-anyone',
|
|
152
|
+
});
|
|
153
|
+
expect(result.ok).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('consume without providing requester only matches grants with null requester', () => {
|
|
157
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
158
|
+
|
|
159
|
+
// Grant scoped to a specific requester
|
|
160
|
+
createScopedApprovalGrant(
|
|
161
|
+
grantParams({
|
|
162
|
+
toolName: 'bash',
|
|
163
|
+
inputDigest: digest,
|
|
164
|
+
requesterExternalUserId: 'user-alice',
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Consume without specifying requester — should NOT match a requester-scoped grant
|
|
169
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
170
|
+
toolName: 'bash',
|
|
171
|
+
inputDigest: digest,
|
|
172
|
+
consumingRequestId: 'c1',
|
|
173
|
+
// No requesterExternalUserId provided
|
|
174
|
+
});
|
|
175
|
+
expect(result.ok).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ===========================================================================
|
|
180
|
+
// 8. Concurrent consume attempts: only one succeeds
|
|
181
|
+
// ===========================================================================
|
|
182
|
+
|
|
183
|
+
describe('security matrix: concurrent consume (CAS)', () => {
|
|
184
|
+
beforeEach(() => clearTables());
|
|
185
|
+
|
|
186
|
+
test('only one of multiple concurrent consumers succeeds for the same grant', () => {
|
|
187
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf /' });
|
|
188
|
+
createScopedApprovalGrant(
|
|
189
|
+
grantParams({
|
|
190
|
+
toolName: 'bash',
|
|
191
|
+
inputDigest: digest,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Simulate concurrent consumers racing to consume the same grant.
|
|
196
|
+
// Since SQLite is synchronous in Bun, we simulate by issuing
|
|
197
|
+
// back-to-back consume calls — the CAS mechanism ensures only the
|
|
198
|
+
// first succeeds.
|
|
199
|
+
const results: boolean[] = [];
|
|
200
|
+
for (let i = 0; i < 5; i++) {
|
|
201
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
202
|
+
toolName: 'bash',
|
|
203
|
+
inputDigest: digest,
|
|
204
|
+
consumingRequestId: `concurrent-consumer-${i}`,
|
|
205
|
+
});
|
|
206
|
+
results.push(result.ok);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Exactly one should succeed
|
|
210
|
+
const successes = results.filter(Boolean);
|
|
211
|
+
expect(successes.length).toBe(1);
|
|
212
|
+
|
|
213
|
+
// The first consumer should win
|
|
214
|
+
expect(results[0]).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('with multiple matching grants, each consumer gets at most one grant', () => {
|
|
218
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
219
|
+
|
|
220
|
+
// Create 3 grants for the same tool signature
|
|
221
|
+
for (let i = 0; i < 3; i++) {
|
|
222
|
+
createScopedApprovalGrant(
|
|
223
|
+
grantParams({
|
|
224
|
+
toolName: 'bash',
|
|
225
|
+
inputDigest: digest,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 5 consumers compete for 3 grants
|
|
231
|
+
const results: boolean[] = [];
|
|
232
|
+
for (let i = 0; i < 5; i++) {
|
|
233
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
234
|
+
toolName: 'bash',
|
|
235
|
+
inputDigest: digest,
|
|
236
|
+
consumingRequestId: `consumer-${i}`,
|
|
237
|
+
});
|
|
238
|
+
results.push(result.ok);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Exactly 3 should succeed (one per grant)
|
|
242
|
+
const successes = results.filter(Boolean);
|
|
243
|
+
expect(successes.length).toBe(3);
|
|
244
|
+
|
|
245
|
+
// The last 2 should fail
|
|
246
|
+
expect(results[3]).toBe(false);
|
|
247
|
+
expect(results[4]).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
// 12. Restart behavior remains fail-closed — grants stored in persistent DB
|
|
253
|
+
// ===========================================================================
|
|
254
|
+
|
|
255
|
+
describe('security matrix: persistence and fail-closed behavior', () => {
|
|
256
|
+
beforeEach(() => clearTables());
|
|
257
|
+
|
|
258
|
+
test('grants survive DB re-initialization (simulating daemon restart)', () => {
|
|
259
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
260
|
+
|
|
261
|
+
// Create a grant
|
|
262
|
+
const grant = createScopedApprovalGrant(
|
|
263
|
+
grantParams({
|
|
264
|
+
toolName: 'bash',
|
|
265
|
+
inputDigest: digest,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
expect(grant.status).toBe('active');
|
|
269
|
+
|
|
270
|
+
// Re-initialize the DB (simulates daemon restart — the SQLite file persists)
|
|
271
|
+
initializeDb();
|
|
272
|
+
|
|
273
|
+
// The grant should still be consumable after restart
|
|
274
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
275
|
+
toolName: 'bash',
|
|
276
|
+
inputDigest: digest,
|
|
277
|
+
consumingRequestId: 'post-restart-consumer',
|
|
278
|
+
});
|
|
279
|
+
expect(result.ok).toBe(true);
|
|
280
|
+
expect(result.grant!.id).toBe(grant.id);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('consumed grants remain consumed after DB re-initialization', () => {
|
|
284
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
285
|
+
|
|
286
|
+
createScopedApprovalGrant(
|
|
287
|
+
grantParams({
|
|
288
|
+
toolName: 'bash',
|
|
289
|
+
inputDigest: digest,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Consume the grant
|
|
294
|
+
const first = consumeScopedApprovalGrantByToolSignature({
|
|
295
|
+
toolName: 'bash',
|
|
296
|
+
inputDigest: digest,
|
|
297
|
+
consumingRequestId: 'pre-restart-consumer',
|
|
298
|
+
});
|
|
299
|
+
expect(first.ok).toBe(true);
|
|
300
|
+
|
|
301
|
+
// Re-initialize the DB (simulates daemon restart)
|
|
302
|
+
initializeDb();
|
|
303
|
+
|
|
304
|
+
// The consumed grant must NOT be consumable again after restart
|
|
305
|
+
const second = consumeScopedApprovalGrantByToolSignature({
|
|
306
|
+
toolName: 'bash',
|
|
307
|
+
inputDigest: digest,
|
|
308
|
+
consumingRequestId: 'post-restart-consumer',
|
|
309
|
+
});
|
|
310
|
+
expect(second.ok).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('no grants means fail-closed (deny by default)', () => {
|
|
314
|
+
// Empty grant table — no grants at all
|
|
315
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'dangerous-command' });
|
|
316
|
+
|
|
317
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
318
|
+
toolName: 'bash',
|
|
319
|
+
inputDigest: digest,
|
|
320
|
+
consumingRequestId: 'consumer-1',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Must fail closed — no grant = no permission
|
|
324
|
+
expect(result.ok).toBe(false);
|
|
325
|
+
expect(result.grant).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ===========================================================================
|
|
330
|
+
// Combined cross-scope invariants
|
|
331
|
+
// ===========================================================================
|
|
332
|
+
|
|
333
|
+
describe('security matrix: cross-scope invariants', () => {
|
|
334
|
+
beforeEach(() => clearTables());
|
|
335
|
+
|
|
336
|
+
test('grant for one assistant cannot be consumed by another assistant', () => {
|
|
337
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
338
|
+
createScopedApprovalGrant(
|
|
339
|
+
grantParams({
|
|
340
|
+
toolName: 'bash',
|
|
341
|
+
inputDigest: digest,
|
|
342
|
+
assistantId: 'assistant-alpha',
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Attempt consumption from a different assistant
|
|
347
|
+
const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
348
|
+
toolName: 'bash',
|
|
349
|
+
inputDigest: digest,
|
|
350
|
+
consumingRequestId: 'c1',
|
|
351
|
+
assistantId: 'assistant-beta',
|
|
352
|
+
});
|
|
353
|
+
expect(wrongAssistant.ok).toBe(false);
|
|
354
|
+
|
|
355
|
+
// Correct assistant succeeds
|
|
356
|
+
const correctAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
357
|
+
toolName: 'bash',
|
|
358
|
+
inputDigest: digest,
|
|
359
|
+
consumingRequestId: 'c2',
|
|
360
|
+
assistantId: 'assistant-alpha',
|
|
361
|
+
});
|
|
362
|
+
expect(correctAssistant.ok).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('all scope fields must match simultaneously for consumption', () => {
|
|
366
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
367
|
+
|
|
368
|
+
// Create a maximally-scoped grant
|
|
369
|
+
createScopedApprovalGrant(
|
|
370
|
+
grantParams({
|
|
371
|
+
toolName: 'bash',
|
|
372
|
+
inputDigest: digest,
|
|
373
|
+
assistantId: 'self',
|
|
374
|
+
executionChannel: 'voice',
|
|
375
|
+
conversationId: 'conv-123',
|
|
376
|
+
callSessionId: 'call-456',
|
|
377
|
+
requesterExternalUserId: 'user-alice',
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Each field mismatch should independently cause failure:
|
|
382
|
+
|
|
383
|
+
// Wrong execution channel
|
|
384
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
385
|
+
toolName: 'bash',
|
|
386
|
+
inputDigest: digest,
|
|
387
|
+
consumingRequestId: 'c-chan',
|
|
388
|
+
assistantId: 'self',
|
|
389
|
+
executionChannel: 'sms',
|
|
390
|
+
conversationId: 'conv-123',
|
|
391
|
+
callSessionId: 'call-456',
|
|
392
|
+
requesterExternalUserId: 'user-alice',
|
|
393
|
+
}).ok).toBe(false);
|
|
394
|
+
|
|
395
|
+
// Wrong conversation
|
|
396
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
397
|
+
toolName: 'bash',
|
|
398
|
+
inputDigest: digest,
|
|
399
|
+
consumingRequestId: 'c-conv',
|
|
400
|
+
assistantId: 'self',
|
|
401
|
+
executionChannel: 'voice',
|
|
402
|
+
conversationId: 'conv-999',
|
|
403
|
+
callSessionId: 'call-456',
|
|
404
|
+
requesterExternalUserId: 'user-alice',
|
|
405
|
+
}).ok).toBe(false);
|
|
406
|
+
|
|
407
|
+
// Wrong call session
|
|
408
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
409
|
+
toolName: 'bash',
|
|
410
|
+
inputDigest: digest,
|
|
411
|
+
consumingRequestId: 'c-call',
|
|
412
|
+
assistantId: 'self',
|
|
413
|
+
executionChannel: 'voice',
|
|
414
|
+
conversationId: 'conv-123',
|
|
415
|
+
callSessionId: 'call-999',
|
|
416
|
+
requesterExternalUserId: 'user-alice',
|
|
417
|
+
}).ok).toBe(false);
|
|
418
|
+
|
|
419
|
+
// Wrong requester
|
|
420
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
421
|
+
toolName: 'bash',
|
|
422
|
+
inputDigest: digest,
|
|
423
|
+
consumingRequestId: 'c-user',
|
|
424
|
+
assistantId: 'self',
|
|
425
|
+
executionChannel: 'voice',
|
|
426
|
+
conversationId: 'conv-123',
|
|
427
|
+
callSessionId: 'call-456',
|
|
428
|
+
requesterExternalUserId: 'user-bob',
|
|
429
|
+
}).ok).toBe(false);
|
|
430
|
+
|
|
431
|
+
// All fields match — succeeds
|
|
432
|
+
expect(consumeScopedApprovalGrantByToolSignature({
|
|
433
|
+
toolName: 'bash',
|
|
434
|
+
inputDigest: digest,
|
|
435
|
+
consumingRequestId: 'c-all',
|
|
436
|
+
assistantId: 'self',
|
|
437
|
+
executionChannel: 'voice',
|
|
438
|
+
conversationId: 'conv-123',
|
|
439
|
+
callSessionId: 'call-456',
|
|
440
|
+
requesterExternalUserId: 'user-alice',
|
|
441
|
+
}).ok).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -105,9 +105,9 @@ mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
|
105
105
|
const originalFetch = globalThis.fetch;
|
|
106
106
|
|
|
107
107
|
import {
|
|
108
|
+
clearSlackChannelConfig,
|
|
108
109
|
getSlackChannelConfig,
|
|
109
110
|
setSlackChannelConfig,
|
|
110
|
-
clearSlackChannelConfig,
|
|
111
111
|
} from '../daemon/handlers/config-slack-channel.js';
|
|
112
112
|
|
|
113
113
|
afterAll(() => {
|
|
@@ -186,7 +186,7 @@ describe('Slack channel config handler', () => {
|
|
|
186
186
|
status: 200,
|
|
187
187
|
headers: { 'content-type': 'application/json' },
|
|
188
188
|
});
|
|
189
|
-
}) as typeof globalThis.fetch;
|
|
189
|
+
}) as unknown as typeof globalThis.fetch;
|
|
190
190
|
|
|
191
191
|
const result = await setSlackChannelConfig('xoxb-valid-bot-token');
|
|
192
192
|
expect(result.success).toBe(true);
|
|
@@ -204,7 +204,7 @@ describe('Slack channel config handler', () => {
|
|
|
204
204
|
status: 200,
|
|
205
205
|
headers: { 'content-type': 'application/json' },
|
|
206
206
|
});
|
|
207
|
-
}) as typeof globalThis.fetch;
|
|
207
|
+
}) as unknown as typeof globalThis.fetch;
|
|
208
208
|
|
|
209
209
|
const result = await setSlackChannelConfig('xoxb-bad-token');
|
|
210
210
|
expect(result.success).toBe(false);
|
|
@@ -297,15 +297,15 @@ describe('Trust Store', () => {
|
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
test('returns null when tool does not match', () => {
|
|
300
|
-
addRule('file_write', '
|
|
301
|
-
//
|
|
302
|
-
const match = findMatchingRule('
|
|
300
|
+
addRule('file_write', 'file_write:/tmp/*', '/tmp');
|
|
301
|
+
// host_file_read default is 'ask' so findMatchingRule (allow-only) won't find it
|
|
302
|
+
const match = findMatchingRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
|
|
303
303
|
expect(match).toBeNull();
|
|
304
304
|
});
|
|
305
305
|
|
|
306
306
|
test('returns null when pattern does not match', () => {
|
|
307
|
-
addRule('
|
|
308
|
-
const match = findMatchingRule('
|
|
307
|
+
addRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
|
|
308
|
+
const match = findMatchingRule('host_file_read', 'host_file_read:/var/log/syslog', '/tmp');
|
|
309
309
|
expect(match).toBeNull();
|
|
310
310
|
});
|
|
311
311
|
|
|
@@ -324,8 +324,8 @@ describe('Trust Store', () => {
|
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
test('does not match when scope is outside rule scope', () => {
|
|
327
|
-
addRule('
|
|
328
|
-
const match = findMatchingRule('
|
|
327
|
+
addRule('host_file_read', 'host_file_read:/home/user/project/*', '/home/user/project');
|
|
328
|
+
const match = findMatchingRule('host_file_read', 'host_file_read:/home/user/project/file.txt', '/home/other');
|
|
329
329
|
expect(match).toBeNull();
|
|
330
330
|
});
|
|
331
331
|
|
|
@@ -342,8 +342,8 @@ describe('Trust Store', () => {
|
|
|
342
342
|
});
|
|
343
343
|
|
|
344
344
|
test('does not match sibling path with shared prefix', () => {
|
|
345
|
-
addRule('
|
|
346
|
-
const match = findMatchingRule('
|
|
345
|
+
addRule('host_file_read', 'host_file_read:/home/user/project/*', '/home/user/project');
|
|
346
|
+
const match = findMatchingRule('host_file_read', 'host_file_read:/home/user/project/file.txt', '/home/user/project-evil');
|
|
347
347
|
expect(match).toBeNull();
|
|
348
348
|
});
|
|
349
349
|
|
|
@@ -360,8 +360,8 @@ describe('Trust Store', () => {
|
|
|
360
360
|
});
|
|
361
361
|
|
|
362
362
|
test('does not match sibling with glob-suffixed scope', () => {
|
|
363
|
-
addRule('
|
|
364
|
-
const match = findMatchingRule('
|
|
363
|
+
addRule('host_file_read', 'host_file_read:/home/user/project/*', '/home/user/project*');
|
|
364
|
+
const match = findMatchingRule('host_file_read', 'host_file_read:/home/user/project/file.txt', '/home/user/project-evil');
|
|
365
365
|
expect(match).toBeNull();
|
|
366
366
|
});
|
|
367
367
|
});
|
|
@@ -375,9 +375,9 @@ describe('Trust Store', () => {
|
|
|
375
375
|
});
|
|
376
376
|
|
|
377
377
|
test('matches exact string', () => {
|
|
378
|
-
addRule('
|
|
379
|
-
expect(findMatchingRule('
|
|
380
|
-
expect(findMatchingRule('
|
|
378
|
+
addRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
|
|
379
|
+
expect(findMatchingRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp')).not.toBeNull();
|
|
380
|
+
expect(findMatchingRule('host_file_read', 'host_file_read:/etc/passwd', '/tmp')).toBeNull();
|
|
381
381
|
});
|
|
382
382
|
|
|
383
383
|
test('matches file path pattern', () => {
|
|
@@ -545,9 +545,9 @@ describe('Trust Store', () => {
|
|
|
545
545
|
});
|
|
546
546
|
|
|
547
547
|
test('findMatchingRule ignores deny rules', () => {
|
|
548
|
-
// Use
|
|
549
|
-
addRule('
|
|
550
|
-
const match = findMatchingRule('
|
|
548
|
+
// Use host_file_read — it has an 'ask' default so findMatchingRule (allow-only) won't find it.
|
|
549
|
+
addRule('host_file_read', 'host_file_read:/etc/*', '/tmp', 'deny');
|
|
550
|
+
const match = findMatchingRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
|
|
551
551
|
expect(match).toBeNull();
|
|
552
552
|
});
|
|
553
553
|
|
|
@@ -806,12 +806,12 @@ describe('Trust Store', () => {
|
|
|
806
806
|
expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_file_edit-global')!);
|
|
807
807
|
});
|
|
808
808
|
|
|
809
|
-
test('findHighestPriorityRule matches default
|
|
809
|
+
test('findHighestPriorityRule matches default allow for host_bash', () => {
|
|
810
810
|
const match = findHighestPriorityRule('host_bash', ['ls'], '/tmp');
|
|
811
811
|
expect(match).not.toBeNull();
|
|
812
|
-
expect(match!.id).toBe('default:
|
|
813
|
-
expect(match!.decision).toBe('
|
|
814
|
-
expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:
|
|
812
|
+
expect(match!.id).toBe('default:allow-host_bash-global');
|
|
813
|
+
expect(match!.decision).toBe('allow');
|
|
814
|
+
expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:allow-host_bash-global')!);
|
|
815
815
|
});
|
|
816
816
|
|
|
817
817
|
test('findHighestPriorityRule matches default ask for computer_use_click', () => {
|
|
@@ -838,6 +838,7 @@ describe('Trust Store', () => {
|
|
|
838
838
|
expect(match).not.toBeNull();
|
|
839
839
|
expect(match!.id).toBe('default:allow-bash-rm-bootstrap');
|
|
840
840
|
expect(match!.decision).toBe('allow');
|
|
841
|
+
expect(match!.allowHighRisk).toBe(true);
|
|
841
842
|
// Outside workspace, the bootstrap rule doesn't match — the global
|
|
842
843
|
// default:allow-bash-global rule matches instead (not the bootstrap rule).
|
|
843
844
|
const other = findHighestPriorityRule('bash', ['rm BOOTSTRAP.md'], '/tmp/other-project');
|
|
@@ -852,6 +853,7 @@ describe('Trust Store', () => {
|
|
|
852
853
|
expect(match).not.toBeNull();
|
|
853
854
|
expect(match!.id).toBe('default:allow-bash-rm-updates');
|
|
854
855
|
expect(match!.decision).toBe('allow');
|
|
856
|
+
expect(match!.allowHighRisk).toBe(true);
|
|
855
857
|
// Outside workspace, should NOT match the updates rule
|
|
856
858
|
const other = findHighestPriorityRule('bash', ['rm UPDATES.md'], '/tmp/other-project');
|
|
857
859
|
expect(other).not.toBeNull();
|
|
@@ -85,14 +85,12 @@ mock.module('../runtime/approval-message-composer.js', () => ({
|
|
|
85
85
|
import {
|
|
86
86
|
createApprovalRequest,
|
|
87
87
|
createBinding,
|
|
88
|
-
findPendingAccessRequestForRequester,
|
|
89
|
-
getAllPendingApprovalsByGuardianChat,
|
|
90
88
|
} from '../memory/channel-guardian-store.js';
|
|
89
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
90
|
+
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
91
91
|
import {
|
|
92
92
|
createOutboundSession,
|
|
93
93
|
} from '../runtime/channel-guardian-service.js';
|
|
94
|
-
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
95
|
-
import { initializeDb, resetDb } from '../memory/db.js';
|
|
96
94
|
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
97
95
|
|
|
98
96
|
initializeDb();
|
|
@@ -110,7 +108,6 @@ const TEST_BEARER_TOKEN = 'test-token';
|
|
|
110
108
|
const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000;
|
|
111
109
|
|
|
112
110
|
function resetState(): void {
|
|
113
|
-
const { getDb } = require('../memory/db.js');
|
|
114
111
|
const db = getDb();
|
|
115
112
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
116
113
|
db.run('DELETE FROM channel_guardian_bindings');
|
|
@@ -177,7 +174,7 @@ describe('trusted contact lifecycle notification signals', () => {
|
|
|
177
174
|
const testRequestId = `req-deny-${Date.now()}`;
|
|
178
175
|
|
|
179
176
|
// Create a pending access request approval
|
|
180
|
-
const
|
|
177
|
+
const _approval = createApprovalRequest({
|
|
181
178
|
runId: `ingress-access-request-${Date.now()}`,
|
|
182
179
|
requestId: testRequestId,
|
|
183
180
|
conversationId: 'access-req-telegram-requester-user-456',
|
|
@@ -252,7 +249,7 @@ describe('trusted contact lifecycle notification signals', () => {
|
|
|
252
249
|
const testRequestId = `req-approve-${Date.now()}`;
|
|
253
250
|
|
|
254
251
|
// Create a pending access request approval
|
|
255
|
-
const
|
|
252
|
+
const _approval = createApprovalRequest({
|
|
256
253
|
runId: `ingress-access-request-${Date.now()}`,
|
|
257
254
|
requestId: testRequestId,
|
|
258
255
|
conversationId: 'access-req-telegram-requester-user-456',
|
|
@@ -426,6 +423,7 @@ describe('trusted contact activated notification signal', () => {
|
|
|
426
423
|
|
|
427
424
|
test('guardian verification does NOT emit activated signal', async () => {
|
|
428
425
|
// Create an inbound challenge (guardian flow, not trusted contact)
|
|
426
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
429
427
|
const { createVerificationChallenge } = require('../runtime/channel-guardian-service.js');
|
|
430
428
|
const { secret } = createVerificationChallenge('self', 'telegram');
|
|
431
429
|
|
|
@@ -77,16 +77,13 @@ import {
|
|
|
77
77
|
createBinding,
|
|
78
78
|
findPendingAccessRequestForRequester,
|
|
79
79
|
} from '../memory/channel-guardian-store.js';
|
|
80
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
81
|
+
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
80
82
|
import {
|
|
81
83
|
createOutboundSession,
|
|
82
84
|
validateAndConsumeChallenge,
|
|
83
85
|
} from '../runtime/channel-guardian-service.js';
|
|
84
|
-
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
85
|
-
import { initializeDb, resetDb } from '../memory/db.js';
|
|
86
86
|
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
87
|
-
import {
|
|
88
|
-
handleAccessRequestDecision,
|
|
89
|
-
} from '../runtime/routes/access-request-decision.js';
|
|
90
87
|
|
|
91
88
|
initializeDb();
|
|
92
89
|
|
|
@@ -102,7 +99,6 @@ afterAll(() => {
|
|
|
102
99
|
const TEST_BEARER_TOKEN = 'test-token';
|
|
103
100
|
|
|
104
101
|
function resetState(): void {
|
|
105
|
-
const { getDb } = require('../memory/db.js');
|
|
106
102
|
const db = getDb();
|
|
107
103
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
108
104
|
db.run('DELETE FROM channel_guardian_bindings');
|