@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
|
@@ -50,7 +50,6 @@ import { formatShellOutput, MAX_OUTPUT_LENGTH } from '../tools/shared/shell-outp
|
|
|
50
50
|
|
|
51
51
|
// Dynamically import modules that depend on the mocked logger
|
|
52
52
|
const { NativeBackend } = await import('../tools/terminal/backends/native.js');
|
|
53
|
-
const { DockerBackend, _resetDockerChecks } = await import('../tools/terminal/backends/docker.js');
|
|
54
53
|
const { wrapCommand } = await import('../tools/terminal/sandbox.js');
|
|
55
54
|
const { ToolError } = await import('../util/errors.js');
|
|
56
55
|
|
|
@@ -589,7 +588,7 @@ describe('SandboxResult shape consistency across backends', () => {
|
|
|
589
588
|
});
|
|
590
589
|
|
|
591
590
|
test('wrapCommand disabled returns bash with sandboxed=false', () => {
|
|
592
|
-
const result = wrapCommand('echo hi', '/tmp', { enabled: false
|
|
591
|
+
const result = wrapCommand('echo hi', '/tmp', { enabled: false });
|
|
593
592
|
|
|
594
593
|
expect(result.command).toBe('bash');
|
|
595
594
|
expect(result.args).toEqual(['-c', '--', 'echo hi']);
|
|
@@ -597,7 +596,7 @@ describe('SandboxResult shape consistency across backends', () => {
|
|
|
597
596
|
});
|
|
598
597
|
|
|
599
598
|
test('wrapCommand disabled result has same shape as enabled result', () => {
|
|
600
|
-
const disabled = wrapCommand('echo hi', '/tmp', { enabled: false
|
|
599
|
+
const disabled = wrapCommand('echo hi', '/tmp', { enabled: false });
|
|
601
600
|
|
|
602
601
|
// Both must have: command (string), args (string[]), sandboxed (boolean)
|
|
603
602
|
expect(typeof disabled.command).toBe('string');
|
|
@@ -859,26 +858,20 @@ describe('Regression: edge cases in shared FileSystemOps', () => {
|
|
|
859
858
|
});
|
|
860
859
|
|
|
861
860
|
// ===========================================================================
|
|
862
|
-
// 9.
|
|
861
|
+
// 9. NativeBackend shape verification
|
|
863
862
|
// ===========================================================================
|
|
864
863
|
|
|
865
|
-
describe('
|
|
866
|
-
test('
|
|
867
|
-
// Verify both classes have a wrap method that returns SandboxResult
|
|
864
|
+
describe('NativeBackend: SandboxResult shape', () => {
|
|
865
|
+
test('NativeBackend has a wrap method', () => {
|
|
868
866
|
const native = new NativeBackend();
|
|
869
867
|
expect(typeof native.wrap).toBe('function');
|
|
870
|
-
|
|
871
|
-
_resetDockerChecks();
|
|
872
|
-
// DockerBackend requires a real sandbox root for construction
|
|
873
|
-
const docker = new DockerBackend(realpathSync('/tmp'), undefined, 1000, 1000);
|
|
874
|
-
expect(typeof docker.wrap).toBe('function');
|
|
875
868
|
});
|
|
876
869
|
|
|
877
870
|
test('disabled sandbox returns consistent bash -c -- invocation', () => {
|
|
878
871
|
// Various commands should all be wrapped consistently when disabled
|
|
879
872
|
const commands = ['echo hello', 'ls -la', 'cat /etc/hosts', 'true && false'];
|
|
880
873
|
for (const cmd of commands) {
|
|
881
|
-
const result = wrapCommand(cmd, '/tmp', { enabled: false
|
|
874
|
+
const result = wrapCommand(cmd, '/tmp', { enabled: false });
|
|
882
875
|
expect(result.command).toBe('bash');
|
|
883
876
|
expect(result.args[0]).toBe('-c');
|
|
884
877
|
expect(result.args[1]).toBe('--');
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'scoped-grants-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
migrateToDataLayout: () => {},
|
|
20
|
+
migrateToWorkspaceLayout: () => {},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
mock.module('../util/logger.js', () => ({
|
|
24
|
+
getLogger: () =>
|
|
25
|
+
new Proxy({} as Record<string, unknown>, {
|
|
26
|
+
get: () => () => {},
|
|
27
|
+
}),
|
|
28
|
+
isDebug: () => false,
|
|
29
|
+
truncateForLog: (value: string) => value,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
33
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
34
|
+
import {
|
|
35
|
+
_internal,
|
|
36
|
+
type CreateScopedApprovalGrantParams,
|
|
37
|
+
expireScopedApprovalGrants,
|
|
38
|
+
revokeScopedApprovalGrantsForContext,
|
|
39
|
+
} from '../memory/scoped-approval-grants.js';
|
|
40
|
+
|
|
41
|
+
const { consumeScopedApprovalGrantByRequestId, consumeScopedApprovalGrantByToolSignature, createScopedApprovalGrant } = _internal;
|
|
42
|
+
import {
|
|
43
|
+
canonicalJsonSerialize,
|
|
44
|
+
computeToolApprovalDigest,
|
|
45
|
+
} from '../security/tool-approval-digest.js';
|
|
46
|
+
|
|
47
|
+
initializeDb();
|
|
48
|
+
|
|
49
|
+
function clearTables(): void {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
db.delete(scopedApprovalGrants).run();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
resetDb();
|
|
56
|
+
try {
|
|
57
|
+
rmSync(testDir, { recursive: true });
|
|
58
|
+
} catch {
|
|
59
|
+
/* best effort */
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Helper to build grant params with sensible defaults
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
|
|
68
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
69
|
+
return {
|
|
70
|
+
assistantId: 'self',
|
|
71
|
+
scopeMode: 'request_id',
|
|
72
|
+
requestChannel: 'telegram',
|
|
73
|
+
decisionChannel: 'telegram',
|
|
74
|
+
expiresAt: futureExpiry,
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
// SCOPE MODE: request_id
|
|
81
|
+
// ===========================================================================
|
|
82
|
+
|
|
83
|
+
describe('scoped-approval-grants / request_id scope', () => {
|
|
84
|
+
beforeEach(() => clearTables());
|
|
85
|
+
|
|
86
|
+
test('create and consume by request_id succeeds', () => {
|
|
87
|
+
const grant = createScopedApprovalGrant(
|
|
88
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-1' }),
|
|
89
|
+
);
|
|
90
|
+
expect(grant.status).toBe('active');
|
|
91
|
+
expect(grant.requestId).toBe('req-1');
|
|
92
|
+
|
|
93
|
+
const result = consumeScopedApprovalGrantByRequestId('req-1', 'consumer-1', 'self');
|
|
94
|
+
expect(result.ok).toBe(true);
|
|
95
|
+
expect(result.grant).not.toBeNull();
|
|
96
|
+
expect(result.grant!.status).toBe('consumed');
|
|
97
|
+
expect(result.grant!.consumedByRequestId).toBe('consumer-1');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('second consume of same grant fails (one-time use)', () => {
|
|
101
|
+
createScopedApprovalGrant(
|
|
102
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-2' }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const first = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-a', 'self');
|
|
106
|
+
expect(first.ok).toBe(true);
|
|
107
|
+
|
|
108
|
+
const second = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-b', 'self');
|
|
109
|
+
expect(second.ok).toBe(false);
|
|
110
|
+
expect(second.grant).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('consume fails when no matching grant exists', () => {
|
|
114
|
+
const result = consumeScopedApprovalGrantByRequestId('nonexistent', 'consumer-x', 'self');
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('expired grant cannot be consumed', () => {
|
|
119
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
120
|
+
createScopedApprovalGrant(
|
|
121
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-expired', expiresAt: pastExpiry }),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = consumeScopedApprovalGrantByRequestId('req-expired', 'consumer-1', 'self');
|
|
125
|
+
expect(result.ok).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ===========================================================================
|
|
130
|
+
// SCOPE MODE: tool_signature
|
|
131
|
+
// ===========================================================================
|
|
132
|
+
|
|
133
|
+
describe('scoped-approval-grants / tool_signature scope', () => {
|
|
134
|
+
beforeEach(() => clearTables());
|
|
135
|
+
|
|
136
|
+
test('create and consume by tool signature succeeds', () => {
|
|
137
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
138
|
+
const grant = createScopedApprovalGrant(
|
|
139
|
+
grantParams({
|
|
140
|
+
scopeMode: 'tool_signature',
|
|
141
|
+
toolName: 'bash',
|
|
142
|
+
inputDigest: digest,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
expect(grant.status).toBe('active');
|
|
146
|
+
expect(grant.toolName).toBe('bash');
|
|
147
|
+
|
|
148
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
149
|
+
toolName: 'bash',
|
|
150
|
+
inputDigest: digest,
|
|
151
|
+
consumingRequestId: 'consumer-1',
|
|
152
|
+
});
|
|
153
|
+
expect(result.ok).toBe(true);
|
|
154
|
+
expect(result.grant!.status).toBe('consumed');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('second consume of tool_signature grant fails', () => {
|
|
158
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf' });
|
|
159
|
+
createScopedApprovalGrant(
|
|
160
|
+
grantParams({
|
|
161
|
+
scopeMode: 'tool_signature',
|
|
162
|
+
toolName: 'bash',
|
|
163
|
+
inputDigest: digest,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const first = consumeScopedApprovalGrantByToolSignature({
|
|
168
|
+
toolName: 'bash',
|
|
169
|
+
inputDigest: digest,
|
|
170
|
+
consumingRequestId: 'c1',
|
|
171
|
+
});
|
|
172
|
+
expect(first.ok).toBe(true);
|
|
173
|
+
|
|
174
|
+
const second = consumeScopedApprovalGrantByToolSignature({
|
|
175
|
+
toolName: 'bash',
|
|
176
|
+
inputDigest: digest,
|
|
177
|
+
consumingRequestId: 'c2',
|
|
178
|
+
});
|
|
179
|
+
expect(second.ok).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('mismatched input digest fails consume', () => {
|
|
183
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
184
|
+
createScopedApprovalGrant(
|
|
185
|
+
grantParams({
|
|
186
|
+
scopeMode: 'tool_signature',
|
|
187
|
+
toolName: 'bash',
|
|
188
|
+
inputDigest: digest,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const wrongDigest = computeToolApprovalDigest('bash', { cmd: 'pwd' });
|
|
193
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
194
|
+
toolName: 'bash',
|
|
195
|
+
inputDigest: wrongDigest,
|
|
196
|
+
consumingRequestId: 'c1',
|
|
197
|
+
});
|
|
198
|
+
expect(result.ok).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('mismatched tool name fails consume', () => {
|
|
202
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
203
|
+
createScopedApprovalGrant(
|
|
204
|
+
grantParams({
|
|
205
|
+
scopeMode: 'tool_signature',
|
|
206
|
+
toolName: 'bash',
|
|
207
|
+
inputDigest: digest,
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
212
|
+
toolName: 'python',
|
|
213
|
+
inputDigest: digest,
|
|
214
|
+
consumingRequestId: 'c1',
|
|
215
|
+
});
|
|
216
|
+
expect(result.ok).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('context constraint: executionChannel must match non-null grant field', () => {
|
|
220
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
221
|
+
createScopedApprovalGrant(
|
|
222
|
+
grantParams({
|
|
223
|
+
scopeMode: 'tool_signature',
|
|
224
|
+
toolName: 'bash',
|
|
225
|
+
inputDigest: digest,
|
|
226
|
+
executionChannel: 'telegram',
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Wrong channel
|
|
231
|
+
const wrong = consumeScopedApprovalGrantByToolSignature({
|
|
232
|
+
toolName: 'bash',
|
|
233
|
+
inputDigest: digest,
|
|
234
|
+
consumingRequestId: 'c1',
|
|
235
|
+
executionChannel: 'sms',
|
|
236
|
+
});
|
|
237
|
+
expect(wrong.ok).toBe(false);
|
|
238
|
+
|
|
239
|
+
// Correct channel
|
|
240
|
+
const correct = consumeScopedApprovalGrantByToolSignature({
|
|
241
|
+
toolName: 'bash',
|
|
242
|
+
inputDigest: digest,
|
|
243
|
+
consumingRequestId: 'c2',
|
|
244
|
+
executionChannel: 'telegram',
|
|
245
|
+
});
|
|
246
|
+
expect(correct.ok).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('null executionChannel on grant means any channel matches', () => {
|
|
250
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
251
|
+
createScopedApprovalGrant(
|
|
252
|
+
grantParams({
|
|
253
|
+
scopeMode: 'tool_signature',
|
|
254
|
+
toolName: 'bash',
|
|
255
|
+
inputDigest: digest,
|
|
256
|
+
executionChannel: null,
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
261
|
+
toolName: 'bash',
|
|
262
|
+
inputDigest: digest,
|
|
263
|
+
consumingRequestId: 'c1',
|
|
264
|
+
executionChannel: 'sms',
|
|
265
|
+
});
|
|
266
|
+
expect(result.ok).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('context constraint: conversationId must match non-null grant field', () => {
|
|
270
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
271
|
+
createScopedApprovalGrant(
|
|
272
|
+
grantParams({
|
|
273
|
+
scopeMode: 'tool_signature',
|
|
274
|
+
toolName: 'bash',
|
|
275
|
+
inputDigest: digest,
|
|
276
|
+
conversationId: 'conv-123',
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Mismatched
|
|
281
|
+
const wrong = consumeScopedApprovalGrantByToolSignature({
|
|
282
|
+
toolName: 'bash',
|
|
283
|
+
inputDigest: digest,
|
|
284
|
+
consumingRequestId: 'c1',
|
|
285
|
+
conversationId: 'conv-999',
|
|
286
|
+
});
|
|
287
|
+
expect(wrong.ok).toBe(false);
|
|
288
|
+
|
|
289
|
+
// Matched
|
|
290
|
+
const correct = consumeScopedApprovalGrantByToolSignature({
|
|
291
|
+
toolName: 'bash',
|
|
292
|
+
inputDigest: digest,
|
|
293
|
+
consumingRequestId: 'c2',
|
|
294
|
+
conversationId: 'conv-123',
|
|
295
|
+
});
|
|
296
|
+
expect(correct.ok).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('expired tool_signature grant cannot be consumed', () => {
|
|
300
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
301
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
302
|
+
createScopedApprovalGrant(
|
|
303
|
+
grantParams({
|
|
304
|
+
scopeMode: 'tool_signature',
|
|
305
|
+
toolName: 'bash',
|
|
306
|
+
inputDigest: digest,
|
|
307
|
+
expiresAt: pastExpiry,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
312
|
+
toolName: 'bash',
|
|
313
|
+
inputDigest: digest,
|
|
314
|
+
consumingRequestId: 'c1',
|
|
315
|
+
});
|
|
316
|
+
expect(result.ok).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('consume by tool signature only consumes one grant when multiple match', () => {
|
|
320
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
321
|
+
|
|
322
|
+
// Create a wildcard grant (no executionChannel) and a channel-specific grant.
|
|
323
|
+
// Both match when executionChannel='telegram', but only one should be consumed.
|
|
324
|
+
const wildcardGrant = createScopedApprovalGrant(
|
|
325
|
+
grantParams({
|
|
326
|
+
scopeMode: 'tool_signature',
|
|
327
|
+
toolName: 'bash',
|
|
328
|
+
inputDigest: digest,
|
|
329
|
+
executionChannel: null,
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
const specificGrant = createScopedApprovalGrant(
|
|
333
|
+
grantParams({
|
|
334
|
+
scopeMode: 'tool_signature',
|
|
335
|
+
toolName: 'bash',
|
|
336
|
+
inputDigest: digest,
|
|
337
|
+
executionChannel: 'telegram',
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
342
|
+
toolName: 'bash',
|
|
343
|
+
inputDigest: digest,
|
|
344
|
+
consumingRequestId: 'c1',
|
|
345
|
+
executionChannel: 'telegram',
|
|
346
|
+
});
|
|
347
|
+
expect(result.ok).toBe(true);
|
|
348
|
+
// The most specific grant (channel-specific) should be consumed first
|
|
349
|
+
expect(result.grant!.id).toBe(specificGrant.id);
|
|
350
|
+
|
|
351
|
+
// The wildcard grant should still be active and consumable
|
|
352
|
+
const second = consumeScopedApprovalGrantByToolSignature({
|
|
353
|
+
toolName: 'bash',
|
|
354
|
+
inputDigest: digest,
|
|
355
|
+
consumingRequestId: 'c2',
|
|
356
|
+
executionChannel: 'sms',
|
|
357
|
+
});
|
|
358
|
+
expect(second.ok).toBe(true);
|
|
359
|
+
expect(second.grant!.id).toBe(wildcardGrant.id);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ===========================================================================
|
|
364
|
+
// Expiry semantics
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
|
|
367
|
+
describe('scoped-approval-grants / expiry', () => {
|
|
368
|
+
beforeEach(() => clearTables());
|
|
369
|
+
|
|
370
|
+
test('expireScopedApprovalGrants transitions active past-TTL grants to expired', () => {
|
|
371
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
372
|
+
createScopedApprovalGrant(
|
|
373
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-e1', expiresAt: pastExpiry }),
|
|
374
|
+
);
|
|
375
|
+
createScopedApprovalGrant(
|
|
376
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-e2', expiresAt: pastExpiry }),
|
|
377
|
+
);
|
|
378
|
+
// Still active (future expiry)
|
|
379
|
+
createScopedApprovalGrant(
|
|
380
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-alive' }),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const count = expireScopedApprovalGrants();
|
|
384
|
+
expect(count).toBe(2);
|
|
385
|
+
|
|
386
|
+
// Verify the alive grant is still active
|
|
387
|
+
const alive = consumeScopedApprovalGrantByRequestId('req-alive', 'c1', 'self');
|
|
388
|
+
expect(alive.ok).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('already-consumed grants are not affected by expiry sweep', () => {
|
|
392
|
+
const _pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
393
|
+
createScopedApprovalGrant(
|
|
394
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-consumed', expiresAt: new Date(Date.now() + 60_000).toISOString() }),
|
|
395
|
+
);
|
|
396
|
+
consumeScopedApprovalGrantByRequestId('req-consumed', 'c1', 'self');
|
|
397
|
+
|
|
398
|
+
// Force the expiry time to the past for the consumed grant (simulating time passing)
|
|
399
|
+
// The sweep should not touch consumed grants
|
|
400
|
+
const count = expireScopedApprovalGrants();
|
|
401
|
+
expect(count).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// ===========================================================================
|
|
406
|
+
// Revoke semantics
|
|
407
|
+
// ===========================================================================
|
|
408
|
+
|
|
409
|
+
describe('scoped-approval-grants / revoke', () => {
|
|
410
|
+
beforeEach(() => clearTables());
|
|
411
|
+
|
|
412
|
+
test('revokeScopedApprovalGrantsForContext revokes active grants matching context', () => {
|
|
413
|
+
createScopedApprovalGrant(
|
|
414
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-r1', callSessionId: 'call-1' }),
|
|
415
|
+
);
|
|
416
|
+
createScopedApprovalGrant(
|
|
417
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-r2', callSessionId: 'call-1' }),
|
|
418
|
+
);
|
|
419
|
+
createScopedApprovalGrant(
|
|
420
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-r3', callSessionId: 'call-2' }),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const count = revokeScopedApprovalGrantsForContext({ callSessionId: 'call-1' });
|
|
424
|
+
expect(count).toBe(2);
|
|
425
|
+
|
|
426
|
+
// Revoked grant cannot be consumed
|
|
427
|
+
const revoked = consumeScopedApprovalGrantByRequestId('req-r1', 'c1', 'self');
|
|
428
|
+
expect(revoked.ok).toBe(false);
|
|
429
|
+
|
|
430
|
+
// Unaffected grant is still consumable
|
|
431
|
+
const alive = consumeScopedApprovalGrantByRequestId('req-r3', 'c1', 'self');
|
|
432
|
+
expect(alive.ok).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('revoked grants cannot be consumed', () => {
|
|
436
|
+
createScopedApprovalGrant(
|
|
437
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-revoke', conversationId: 'conv-1' }),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
revokeScopedApprovalGrantsForContext({ conversationId: 'conv-1' });
|
|
441
|
+
|
|
442
|
+
const result = consumeScopedApprovalGrantByRequestId('req-revoke', 'c1', 'self');
|
|
443
|
+
expect(result.ok).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('revokeScopedApprovalGrantsForContext throws when no context filters are provided', () => {
|
|
447
|
+
// Create a grant to ensure the guard is not based on empty results
|
|
448
|
+
createScopedApprovalGrant(
|
|
449
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-guard', callSessionId: 'call-guard' }),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Empty object: all fields undefined
|
|
453
|
+
expect(() => revokeScopedApprovalGrantsForContext({})).toThrow(
|
|
454
|
+
'revokeScopedApprovalGrantsForContext requires at least one context filter',
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// The grant should still be active (not revoked)
|
|
458
|
+
const result = consumeScopedApprovalGrantByRequestId('req-guard', 'c1', 'self');
|
|
459
|
+
expect(result.ok).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ===========================================================================
|
|
464
|
+
// tool-approval-digest: canonical serialization + hash
|
|
465
|
+
// ===========================================================================
|
|
466
|
+
|
|
467
|
+
describe('tool-approval-digest', () => {
|
|
468
|
+
test('canonicalJsonSerialize sorts keys recursively', () => {
|
|
469
|
+
const obj = { z: 1, a: { c: 3, b: 2 } };
|
|
470
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
471
|
+
expect(serialized).toBe('{"a":{"b":2,"c":3},"z":1}');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('canonicalJsonSerialize handles arrays (order preserved)', () => {
|
|
475
|
+
const obj = { items: [3, 1, 2], name: 'test' };
|
|
476
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
477
|
+
expect(serialized).toBe('{"items":[3,1,2],"name":"test"}');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('canonicalJsonSerialize handles null values', () => {
|
|
481
|
+
const obj = { a: null, b: 'hello' };
|
|
482
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
483
|
+
expect(serialized).toBe('{"a":null,"b":"hello"}');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('canonicalJsonSerialize handles nested arrays of objects', () => {
|
|
487
|
+
const obj = { list: [{ z: 1, a: 2 }, { y: 3, b: 4 }] };
|
|
488
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
489
|
+
expect(serialized).toBe('{"list":[{"a":2,"z":1},{"b":4,"y":3}]}');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('computeToolApprovalDigest is deterministic', () => {
|
|
493
|
+
const d1 = computeToolApprovalDigest('bash', { cmd: 'ls -la', cwd: '/tmp' });
|
|
494
|
+
const d2 = computeToolApprovalDigest('bash', { cwd: '/tmp', cmd: 'ls -la' });
|
|
495
|
+
expect(d1).toBe(d2);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('computeToolApprovalDigest differs for different inputs', () => {
|
|
499
|
+
const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
500
|
+
const d2 = computeToolApprovalDigest('bash', { cmd: 'pwd' });
|
|
501
|
+
expect(d1).not.toBe(d2);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('computeToolApprovalDigest differs for different tool names', () => {
|
|
505
|
+
const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
506
|
+
const d2 = computeToolApprovalDigest('python', { cmd: 'ls' });
|
|
507
|
+
expect(d1).not.toBe(d2);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('computeToolApprovalDigest is stable across key orderings (deeply nested)', () => {
|
|
511
|
+
const d1 = computeToolApprovalDigest('tool', {
|
|
512
|
+
config: { nested: { z: 1, a: 2 }, top: true },
|
|
513
|
+
name: 'test',
|
|
514
|
+
});
|
|
515
|
+
const d2 = computeToolApprovalDigest('tool', {
|
|
516
|
+
name: 'test',
|
|
517
|
+
config: { top: true, nested: { a: 2, z: 1 } },
|
|
518
|
+
});
|
|
519
|
+
expect(d1).toBe(d2);
|
|
520
|
+
});
|
|
521
|
+
});
|