@vellumai/assistant 0.3.19 → 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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -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 +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -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 +300 -32
- 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 +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- 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 +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -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 +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- 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__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- 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 +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- 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 +1 -1
- 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 +6 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- 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/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/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-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 +258 -432
- 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 +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- 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/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 +92 -35
- 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 -190
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- 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 +1 -1
- 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
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
2
|
|
|
3
3
|
import { getConfig } from '../../config/loader.js';
|
|
4
4
|
import type { SandboxConfig } from '../../config/schema.js';
|
|
5
|
-
import {
|
|
6
|
-
import { DEFAULT_SANDBOX_IMAGE } from './backends/docker.js';
|
|
5
|
+
import { isLinux, isMacOS } from '../../util/platform.js';
|
|
7
6
|
|
|
8
7
|
export interface SandboxCheckResult {
|
|
9
8
|
label: string;
|
|
@@ -14,73 +13,12 @@ export interface SandboxCheckResult {
|
|
|
14
13
|
export interface SandboxDiagnostics {
|
|
15
14
|
config: {
|
|
16
15
|
enabled: boolean;
|
|
17
|
-
backend: string;
|
|
18
|
-
dockerImage: string;
|
|
19
16
|
};
|
|
20
17
|
/** Why the active backend was selected (config vs platform default). */
|
|
21
18
|
activeBackendReason: string;
|
|
22
19
|
checks: SandboxCheckResult[];
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
function checkDockerCli(): SandboxCheckResult {
|
|
26
|
-
try {
|
|
27
|
-
const out = execSync('docker --version', { stdio: 'pipe', timeout: 5000, encoding: 'utf-8' }).trim();
|
|
28
|
-
return { label: 'Docker CLI installed', ok: true, detail: out };
|
|
29
|
-
} catch {
|
|
30
|
-
return { label: 'Docker CLI installed', ok: false, detail: 'docker not found in PATH' };
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function checkDockerDaemon(): SandboxCheckResult {
|
|
35
|
-
try {
|
|
36
|
-
execSync('docker info', { stdio: 'pipe', timeout: 10000 });
|
|
37
|
-
return { label: 'Docker daemon running', ok: true };
|
|
38
|
-
} catch {
|
|
39
|
-
return { label: 'Docker daemon running', ok: false, detail: 'daemon not reachable — start Docker Desktop or run "sudo systemctl start docker"' };
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function checkDockerImage(image: string): SandboxCheckResult {
|
|
44
|
-
try {
|
|
45
|
-
execFileSync('docker', ['image', 'inspect', image], { stdio: 'pipe', timeout: 10000 });
|
|
46
|
-
return { label: `Docker image available (${image})`, ok: true };
|
|
47
|
-
} catch {
|
|
48
|
-
// The default sandbox image is built locally from Dockerfile.sandbox — docker pull won't work.
|
|
49
|
-
const remediation = image === DEFAULT_SANDBOX_IMAGE
|
|
50
|
-
? 'build with: docker build --no-cache -t vellum-sandbox:latest -f assistant/Dockerfile.sandbox assistant'
|
|
51
|
-
: `pull with: docker pull ${image}`;
|
|
52
|
-
return { label: `Docker image available (${image})`, ok: false, detail: remediation };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function checkDockerMountProbe(image: string): SandboxCheckResult {
|
|
57
|
-
// Use the same sandbox path and writable-mount probe that the runtime
|
|
58
|
-
// preflight uses (checkMountProbe in docker.ts) so doctor validates
|
|
59
|
-
// exactly what runtime enforces.
|
|
60
|
-
const sandboxRoot = getSandboxWorkingDir();
|
|
61
|
-
try {
|
|
62
|
-
execFileSync(
|
|
63
|
-
'docker',
|
|
64
|
-
[
|
|
65
|
-
'run', '--rm',
|
|
66
|
-
'--mount', `type=bind,src=${sandboxRoot},dst=/workspace`,
|
|
67
|
-
image, 'test', '-w', '/workspace',
|
|
68
|
-
],
|
|
69
|
-
{ stdio: 'pipe', timeout: 15000 },
|
|
70
|
-
);
|
|
71
|
-
return { label: 'Docker mount writable', ok: true };
|
|
72
|
-
} catch (err) {
|
|
73
|
-
const msg = err instanceof Error ? err.message : 'unknown error';
|
|
74
|
-
return {
|
|
75
|
-
label: 'Docker mount writable',
|
|
76
|
-
ok: false,
|
|
77
|
-
detail: 'Cannot bind-mount sandbox root or /workspace is not writable. ' +
|
|
78
|
-
'If using Docker Desktop, enable file sharing for this path in Settings > Resources > File Sharing. ' +
|
|
79
|
-
`(${msg})`,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
22
|
function checkNativeBackend(): SandboxCheckResult {
|
|
85
23
|
if (isMacOS()) {
|
|
86
24
|
try {
|
|
@@ -105,15 +43,12 @@ function getActiveBackendReason(sandboxConfig: SandboxConfig): string {
|
|
|
105
43
|
if (!sandboxConfig.enabled) {
|
|
106
44
|
return 'Sandbox is disabled in configuration';
|
|
107
45
|
}
|
|
108
|
-
|
|
109
|
-
return 'Docker backend selected in configuration (sandbox.backend = "docker")';
|
|
110
|
-
}
|
|
111
|
-
return 'Native backend selected in configuration (sandbox.backend = "native")';
|
|
46
|
+
return 'Native backend selected';
|
|
112
47
|
}
|
|
113
48
|
|
|
114
49
|
/**
|
|
115
|
-
* Run sandbox backend diagnostics. Checks
|
|
116
|
-
*
|
|
50
|
+
* Run sandbox backend diagnostics. Checks native backend availability
|
|
51
|
+
* and reports current configuration.
|
|
117
52
|
*/
|
|
118
53
|
export function runSandboxDiagnostics(): SandboxDiagnostics {
|
|
119
54
|
const config = getConfig();
|
|
@@ -121,28 +56,12 @@ export function runSandboxDiagnostics(): SandboxDiagnostics {
|
|
|
121
56
|
|
|
122
57
|
const checks: SandboxCheckResult[] = [];
|
|
123
58
|
|
|
124
|
-
//
|
|
59
|
+
// Check native backend availability
|
|
125
60
|
checks.push(checkNativeBackend());
|
|
126
61
|
|
|
127
|
-
// Docker checks: CLI, daemon, image, container execution
|
|
128
|
-
const cliResult = checkDockerCli();
|
|
129
|
-
checks.push(cliResult);
|
|
130
|
-
|
|
131
|
-
if (cliResult.ok) {
|
|
132
|
-
const daemonResult = checkDockerDaemon();
|
|
133
|
-
checks.push(daemonResult);
|
|
134
|
-
|
|
135
|
-
if (daemonResult.ok) {
|
|
136
|
-
checks.push(checkDockerImage(sandboxConfig.docker.image));
|
|
137
|
-
checks.push(checkDockerMountProbe(sandboxConfig.docker.image));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
62
|
return {
|
|
142
63
|
config: {
|
|
143
64
|
enabled: sandboxConfig.enabled,
|
|
144
|
-
backend: sandboxConfig.backend,
|
|
145
|
-
dockerImage: sandboxConfig.docker.image,
|
|
146
65
|
},
|
|
147
66
|
activeBackendReason: getActiveBackendReason(sandboxConfig),
|
|
148
67
|
checks,
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { SandboxConfig } from '../../config/schema.js';
|
|
2
|
-
import { getSandboxWorkingDir } from '../../util/platform.js';
|
|
3
|
-
import { DockerBackend } from './backends/docker.js';
|
|
4
2
|
import { NativeBackend } from './backends/native.js';
|
|
5
3
|
import type { SandboxResult, WrapOptions } from './backends/types.js';
|
|
6
4
|
|
|
@@ -12,7 +10,7 @@ const nativeBackend = new NativeBackend();
|
|
|
12
10
|
* Wrap a shell command for sandboxed execution.
|
|
13
11
|
*
|
|
14
12
|
* When sandboxing is disabled, returns a plain bash invocation.
|
|
15
|
-
* When enabled, delegates to the
|
|
13
|
+
* When enabled, delegates to the native backend.
|
|
16
14
|
* Fails closed if the backend cannot be applied.
|
|
17
15
|
*
|
|
18
16
|
* @param options Per-invocation overrides (e.g. networkMode for proxied bash).
|
|
@@ -31,14 +29,5 @@ export function wrapCommand(
|
|
|
31
29
|
};
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
if (config.backend === 'docker') {
|
|
35
|
-
// Always mount the canonical sandbox fs root, not whatever workingDir
|
|
36
|
-
// happens to be. workingDir may be a subdirectory; the mount source
|
|
37
|
-
// must be the fixed root so the entire sandbox filesystem is available.
|
|
38
|
-
const sandboxRoot = getSandboxWorkingDir();
|
|
39
|
-
const backend = new DockerBackend(sandboxRoot, config.docker);
|
|
40
|
-
return backend.wrap(command, workingDir, options);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
32
|
return nativeBackend.wrap(command, workingDir, options);
|
|
44
33
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { platform } from 'node:os';
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
3
2
|
|
|
4
3
|
import { getConfig } from '../../config/loader.js';
|
|
5
4
|
import { RiskLevel } from '../../permissions/types.js';
|
|
@@ -21,32 +20,6 @@ import { wrapCommand } from './sandbox.js';
|
|
|
21
20
|
|
|
22
21
|
const log = getLogger('shell-tool');
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
* Returns the host address to bind the proxy to when Docker sandbox is active.
|
|
26
|
-
* On macOS, Docker Desktop routes host.docker.internal through the VM to
|
|
27
|
-
* 127.0.0.1, so no bind change is needed. On Linux, we need to bind to the
|
|
28
|
-
* Docker bridge gateway IP so containers can reach the proxy.
|
|
29
|
-
*/
|
|
30
|
-
function getDockerProxyHost(): string {
|
|
31
|
-
if (platform() !== 'linux') return '127.0.0.1';
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
// Docker bridge gateway is the default route from inside docker0 network.
|
|
35
|
-
// `ip -4 addr show docker0` outputs the gateway IP assigned to the bridge.
|
|
36
|
-
const output = execSync('ip -4 addr show docker0 2>/dev/null', { encoding: 'utf-8' });
|
|
37
|
-
const match = output.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
38
|
-
if (match) return match[1];
|
|
39
|
-
} catch {
|
|
40
|
-
// docker0 interface may not exist (e.g. rootless Docker, custom networks)
|
|
41
|
-
}
|
|
42
|
-
// Fallback: bind to localhost when docker0 is unavailable (e.g. rootless
|
|
43
|
-
// Docker, custom networks). This avoids EADDRNOTAVAIL from 172.17.0.1 while
|
|
44
|
-
// keeping the proxy off public interfaces — 0.0.0.0 would expose the
|
|
45
|
-
// unauthenticated credential proxy to the network. Docker containers won't
|
|
46
|
-
// be able to reach localhost on the host, but that's a safer failure mode.
|
|
47
|
-
return '127.0.0.1';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
23
|
class ShellTool implements Tool {
|
|
51
24
|
name = 'bash';
|
|
52
25
|
description = 'Execute a shell command on the local machine';
|
|
@@ -153,7 +126,6 @@ class ShellTool implements Tool {
|
|
|
153
126
|
const sandboxConfig = context.sandboxOverride != null
|
|
154
127
|
? { ...config.sandbox, enabled: context.sandboxOverride }
|
|
155
128
|
: config.sandbox;
|
|
156
|
-
const isDockerSandbox = sandboxConfig.enabled && sandboxConfig.backend === 'docker';
|
|
157
129
|
|
|
158
130
|
// Acquire proxy session if proxied mode is requested.
|
|
159
131
|
// `getOrStartSession` serializes per-conversation so concurrent proxied
|
|
@@ -170,9 +142,9 @@ class ShellTool implements Tool {
|
|
|
170
142
|
undefined,
|
|
171
143
|
getDataDir(),
|
|
172
144
|
context.proxyApprovalCallback,
|
|
173
|
-
|
|
145
|
+
undefined,
|
|
174
146
|
);
|
|
175
|
-
proxyEnv = getSessionEnv(session.id
|
|
147
|
+
proxyEnv = getSessionEnv(session.id);
|
|
176
148
|
} catch (err) {
|
|
177
149
|
log.error({ err }, 'Failed to start proxy session');
|
|
178
150
|
return {
|
|
@@ -1,14 +1,42 @@
|
|
|
1
|
+
import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
|
|
1
2
|
import { isToolBlocked } from '../security/parental-control-store.js';
|
|
3
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
2
4
|
import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
|
|
3
5
|
import { getLogger } from '../util/logger.js';
|
|
4
6
|
import { enforceGuardianOnlyPolicy } from './guardian-control-plane-policy.js';
|
|
5
7
|
import { getAllTools, getTool } from './registry.js';
|
|
8
|
+
import { isSideEffectTool } from './side-effects.js';
|
|
6
9
|
import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifecycleEvent } from './types.js';
|
|
7
10
|
|
|
8
11
|
const log = getLogger('tool-approval-handler');
|
|
9
12
|
|
|
13
|
+
function isUntrustedGuardianActorRole(role: ToolContext['guardianActorRole']): boolean {
|
|
14
|
+
return role === 'non-guardian' || role === 'unverified_channel';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requiresGuardianApprovalForActor(
|
|
18
|
+
toolName: string,
|
|
19
|
+
input: Record<string, unknown>,
|
|
20
|
+
executionTarget: ExecutionTarget,
|
|
21
|
+
): boolean {
|
|
22
|
+
// Side-effect tools always require guardian approval for untrusted actors.
|
|
23
|
+
// Read-only host execution is also blocked because it can leak sensitive
|
|
24
|
+
// local information (e.g. shell/file reads).
|
|
25
|
+
return isSideEffectTool(toolName, input) || executionTarget === 'host';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function guardianApprovalDeniedMessage(
|
|
29
|
+
actorRole: ToolContext['guardianActorRole'],
|
|
30
|
+
toolName: string,
|
|
31
|
+
): string {
|
|
32
|
+
if (actorRole === 'unverified_channel') {
|
|
33
|
+
return `Permission denied for "${toolName}": this action requires guardian approval from a verified channel identity.`;
|
|
34
|
+
}
|
|
35
|
+
return `Permission denied for "${toolName}": this action requires guardian approval and the current actor is not the guardian.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
export type PreExecutionGateResult =
|
|
11
|
-
| { allowed: true; tool: Tool }
|
|
39
|
+
| { allowed: true; tool: Tool; grantConsumed?: boolean }
|
|
12
40
|
| { allowed: false; result: ToolExecutionResult };
|
|
13
41
|
|
|
14
42
|
/**
|
|
@@ -22,7 +50,7 @@ export class ToolApprovalHandler {
|
|
|
22
50
|
* Returns the resolved Tool if all gates pass, or an early-return
|
|
23
51
|
* ToolExecutionResult if any gate blocks execution.
|
|
24
52
|
*/
|
|
25
|
-
checkPreExecutionGates(
|
|
53
|
+
async checkPreExecutionGates(
|
|
26
54
|
name: string,
|
|
27
55
|
input: Record<string, unknown>,
|
|
28
56
|
context: ToolContext,
|
|
@@ -30,7 +58,7 @@ export class ToolApprovalHandler {
|
|
|
30
58
|
riskLevel: string,
|
|
31
59
|
startTime: number,
|
|
32
60
|
emitLifecycleEvent: (event: ToolLifecycleEvent) => void,
|
|
33
|
-
): PreExecutionGateResult {
|
|
61
|
+
): Promise<PreExecutionGateResult> {
|
|
34
62
|
// Bail out immediately if the session was aborted before this tool started.
|
|
35
63
|
if (context.signal?.aborted) {
|
|
36
64
|
const durationMs = Date.now() - startTime;
|
|
@@ -111,6 +139,33 @@ export class ToolApprovalHandler {
|
|
|
111
139
|
return { allowed: false, result: { content: guardianCheck.reason!, isError: true } };
|
|
112
140
|
}
|
|
113
141
|
|
|
142
|
+
// Determine whether this invocation requires a scoped grant. Capture
|
|
143
|
+
// the consume params now but defer the actual atomic consumption until
|
|
144
|
+
// after all downstream policy gates (allowedToolNames, task-run
|
|
145
|
+
// preflight, tool registry) pass. This prevents wasting a one-time-use
|
|
146
|
+
// grant when a subsequent gate rejects the invocation.
|
|
147
|
+
let needsGrantConsumption = false;
|
|
148
|
+
let deferredConsumeParams: Parameters<typeof consumeGrantForInvocation>[0] | null = null;
|
|
149
|
+
|
|
150
|
+
if (
|
|
151
|
+
isUntrustedGuardianActorRole(context.guardianActorRole)
|
|
152
|
+
&& requiresGuardianApprovalForActor(name, input, executionTarget)
|
|
153
|
+
) {
|
|
154
|
+
const inputDigest = computeToolApprovalDigest(name, input);
|
|
155
|
+
needsGrantConsumption = true;
|
|
156
|
+
deferredConsumeParams = {
|
|
157
|
+
requestId: context.requestId,
|
|
158
|
+
toolName: name,
|
|
159
|
+
inputDigest,
|
|
160
|
+
consumingRequestId: context.requestId ?? `preexec-${context.sessionId}-${Date.now()}`,
|
|
161
|
+
assistantId: context.assistantId ?? 'self',
|
|
162
|
+
executionChannel: context.executionChannel,
|
|
163
|
+
conversationId: context.conversationId,
|
|
164
|
+
callSessionId: context.callSessionId,
|
|
165
|
+
requesterExternalUserId: context.requesterExternalUserId,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
114
169
|
// Gate tools not active for the current turn
|
|
115
170
|
if (context.allowedToolNames && !context.allowedToolNames.has(name)) {
|
|
116
171
|
const msg = `Tool "${name}" is not currently active. Load the skill that provides this tool first.`;
|
|
@@ -187,6 +242,89 @@ export class ToolApprovalHandler {
|
|
|
187
242
|
return { allowed: false, result: { content: msg, isError: true } };
|
|
188
243
|
}
|
|
189
244
|
|
|
245
|
+
// All policy gates passed. Now consume the scoped grant if one is
|
|
246
|
+
// required. Deferring consumption to this point ensures a downstream
|
|
247
|
+
// rejection (allowedToolNames, task-run preflight, registry lookup)
|
|
248
|
+
// does not waste the one-time-use grant.
|
|
249
|
+
//
|
|
250
|
+
// Retry polling is scoped to the voice channel where a race condition
|
|
251
|
+
// exists between fire-and-forget turn execution and LLM fallback grant
|
|
252
|
+
// minting (2-5s). Non-voice channels get an instant sync lookup so
|
|
253
|
+
// normal denials are not delayed.
|
|
254
|
+
if (needsGrantConsumption && deferredConsumeParams) {
|
|
255
|
+
const isVoice = context.executionChannel === 'voice';
|
|
256
|
+
const grantResult = await consumeGrantForInvocation(
|
|
257
|
+
deferredConsumeParams,
|
|
258
|
+
isVoice ? { signal: context.signal } : { maxWaitMs: 0 },
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (grantResult.ok) {
|
|
262
|
+
log.info({
|
|
263
|
+
toolName: name,
|
|
264
|
+
sessionId: context.sessionId,
|
|
265
|
+
conversationId: context.conversationId,
|
|
266
|
+
actorRole: context.guardianActorRole,
|
|
267
|
+
executionTarget,
|
|
268
|
+
grantId: grantResult.grant.id,
|
|
269
|
+
}, 'Scoped grant consumed — allowing untrusted actor tool invocation');
|
|
270
|
+
|
|
271
|
+
return { allowed: true, tool, grantConsumed: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Treat abort as a cancellation — not a grant denial. This matches
|
|
275
|
+
// the abort check at the top of checkPreExecutionGates so the caller
|
|
276
|
+
// sees a consistent "Cancelled" result instead of a spurious
|
|
277
|
+
// guardian_approval_required denial during voice barge-in.
|
|
278
|
+
if (grantResult.reason === 'aborted') {
|
|
279
|
+
const durationMs = Date.now() - startTime;
|
|
280
|
+
emitLifecycleEvent({
|
|
281
|
+
type: 'error',
|
|
282
|
+
toolName: name,
|
|
283
|
+
executionTarget,
|
|
284
|
+
input,
|
|
285
|
+
workingDir: context.workingDir,
|
|
286
|
+
sessionId: context.sessionId,
|
|
287
|
+
conversationId: context.conversationId,
|
|
288
|
+
requestId: context.requestId,
|
|
289
|
+
riskLevel,
|
|
290
|
+
decision: 'error',
|
|
291
|
+
durationMs,
|
|
292
|
+
errorMessage: 'Cancelled',
|
|
293
|
+
isExpected: true,
|
|
294
|
+
errorCategory: 'tool_failure',
|
|
295
|
+
});
|
|
296
|
+
return { allowed: false, result: { content: 'Cancelled', isError: true } };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// No matching grant or race condition — deny.
|
|
300
|
+
const reason = guardianApprovalDeniedMessage(context.guardianActorRole, name);
|
|
301
|
+
log.warn({
|
|
302
|
+
toolName: name,
|
|
303
|
+
sessionId: context.sessionId,
|
|
304
|
+
conversationId: context.conversationId,
|
|
305
|
+
actorRole: context.guardianActorRole,
|
|
306
|
+
executionTarget,
|
|
307
|
+
reason: 'guardian_approval_required',
|
|
308
|
+
grantMissReason: grantResult.reason,
|
|
309
|
+
}, 'Guardian approval gate blocked untrusted actor tool invocation (no matching grant)');
|
|
310
|
+
const durationMs = Date.now() - startTime;
|
|
311
|
+
emitLifecycleEvent({
|
|
312
|
+
type: 'permission_denied',
|
|
313
|
+
toolName: name,
|
|
314
|
+
executionTarget,
|
|
315
|
+
input,
|
|
316
|
+
workingDir: context.workingDir,
|
|
317
|
+
sessionId: context.sessionId,
|
|
318
|
+
conversationId: context.conversationId,
|
|
319
|
+
requestId: context.requestId,
|
|
320
|
+
riskLevel,
|
|
321
|
+
decision: 'deny',
|
|
322
|
+
reason,
|
|
323
|
+
durationMs,
|
|
324
|
+
});
|
|
325
|
+
return { allowed: false, result: { content: reason, isError: true } };
|
|
326
|
+
}
|
|
327
|
+
|
|
190
328
|
return { allowed: true, tool };
|
|
191
329
|
}
|
|
192
330
|
}
|
|
@@ -12,6 +12,9 @@ import { credentialStoreTool } from './credentials/vault.js';
|
|
|
12
12
|
import { memorySaveTool, memorySearchTool, memoryUpdateTool } from './memory/register.js';
|
|
13
13
|
import type { LazyToolDescriptor } from './registry.js';
|
|
14
14
|
import { vellumSkillsCatalogTool } from './skills/vellum-catalog.js';
|
|
15
|
+
import { setAvatarTool } from './system/avatar-generator.js';
|
|
16
|
+
import { navigateSettingsTabTool } from './system/navigate-settings.js';
|
|
17
|
+
import { openSystemSettingsTool } from './system/open-system-settings.js';
|
|
15
18
|
import { voiceConfigUpdateTool } from './system/voice-config.js';
|
|
16
19
|
import type { Tool } from './types.js';
|
|
17
20
|
import { screenWatchTool } from './watch/screen-watch.js';
|
|
@@ -68,6 +71,9 @@ export const explicitTools: Tool[] = [
|
|
|
68
71
|
screenWatchTool,
|
|
69
72
|
vellumSkillsCatalogTool,
|
|
70
73
|
voiceConfigUpdateTool,
|
|
74
|
+
setAvatarTool,
|
|
75
|
+
openSystemSettingsTool,
|
|
76
|
+
navigateSettingsTabTool,
|
|
71
77
|
];
|
|
72
78
|
|
|
73
79
|
// ── Lazy tool descriptors ───────────────────────────────────────────
|
package/src/tools/types.ts
CHANGED
|
@@ -138,6 +138,12 @@ export interface ToolContext {
|
|
|
138
138
|
principal?: string;
|
|
139
139
|
/** Guardian actor role for the session — used by the guardian control-plane policy gate. */
|
|
140
140
|
guardianActorRole?: 'guardian' | 'non-guardian' | 'unverified_channel';
|
|
141
|
+
/** Channel through which the tool invocation originates (e.g. 'telegram', 'voice'). Used for scoped grant consumption. */
|
|
142
|
+
executionChannel?: string;
|
|
143
|
+
/** Voice/call session ID, if the invocation originates from a call. Used for scoped grant consumption. */
|
|
144
|
+
callSessionId?: string;
|
|
145
|
+
/** External user ID of the requester (non-guardian actor). Used for scoped grant consumption. */
|
|
146
|
+
requesterExternalUserId?: string;
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
export interface DiffInfo {
|
package/src/util/diff.ts
CHANGED
|
@@ -54,7 +54,7 @@ interface Hunk {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const CONTEXT_LINES = 3;
|
|
57
|
-
const
|
|
57
|
+
const DEFAULT_MAX_EXACT_DIFF_LINES = 1000;
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Group diff entries into hunks with surrounding context lines.
|
|
@@ -108,24 +108,44 @@ const CYAN = '\x1b[36m';
|
|
|
108
108
|
const DIM = '\x1b[2m';
|
|
109
109
|
const RESET = '\x1b[0m';
|
|
110
110
|
|
|
111
|
+
export interface FormatDiffOptions {
|
|
112
|
+
maxExactLines?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatLargeDiffFallback(oldLines: string[], newLines: string[], filePath: string): string {
|
|
116
|
+
let output = `${DIM}--- a/${filePath}${RESET}\n`;
|
|
117
|
+
output += `${DIM}+++ b/${filePath}${RESET}\n`;
|
|
118
|
+
output += `${CYAN}@@ -1,${oldLines.length} +1,${newLines.length} @@${RESET}\n`;
|
|
119
|
+
|
|
120
|
+
for (const line of oldLines) {
|
|
121
|
+
output += `${RED}-${line}${RESET}\n`;
|
|
122
|
+
}
|
|
123
|
+
for (const line of newLines) {
|
|
124
|
+
output += `${GREEN}+${line}${RESET}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
/**
|
|
112
131
|
* Format a colored unified diff from old and new file content.
|
|
113
132
|
* Returns an empty string if the contents are identical.
|
|
114
133
|
*/
|
|
115
|
-
export function formatDiff(
|
|
134
|
+
export function formatDiff(
|
|
135
|
+
oldContent: string,
|
|
136
|
+
newContent: string,
|
|
137
|
+
filePath: string,
|
|
138
|
+
options: FormatDiffOptions = {},
|
|
139
|
+
): string {
|
|
116
140
|
if (oldContent === newContent) return '';
|
|
117
141
|
|
|
118
142
|
const oldLines = oldContent.split('\n');
|
|
119
143
|
const newLines = newContent.split('\n');
|
|
144
|
+
const maxExactLines = options.maxExactLines ?? DEFAULT_MAX_EXACT_DIFF_LINES;
|
|
120
145
|
|
|
121
146
|
// Guard against quadratic blowup on large files
|
|
122
|
-
if (oldLines.length >
|
|
123
|
-
|
|
124
|
-
const added = newLines.length;
|
|
125
|
-
let output = `${DIM}--- a/${filePath}${RESET}\n`;
|
|
126
|
-
output += `${DIM}+++ b/${filePath}${RESET}\n`;
|
|
127
|
-
output += `${DIM}[Diff too large to display: ${removed} lines → ${added} lines]${RESET}\n`;
|
|
128
|
-
return output;
|
|
147
|
+
if (oldLines.length > maxExactLines || newLines.length > maxExactLines) {
|
|
148
|
+
return formatLargeDiffFallback(oldLines, newLines, filePath);
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
const entries = computeLineDiff(oldLines, newLines);
|
|
@@ -159,11 +179,14 @@ export function formatDiff(oldContent: string, newContent: string, filePath: str
|
|
|
159
179
|
/**
|
|
160
180
|
* Format a "new file" diff (everything is added).
|
|
161
181
|
* Truncates to maxLines to avoid flooding the terminal.
|
|
182
|
+
* Pass `null` for unbounded output.
|
|
162
183
|
*/
|
|
163
|
-
export function formatNewFileDiff(content: string, filePath: string, maxLines = 20): string {
|
|
184
|
+
export function formatNewFileDiff(content: string, filePath: string, maxLines: number | null = 20): string {
|
|
164
185
|
const lines = content.split('\n');
|
|
165
|
-
const
|
|
166
|
-
const
|
|
186
|
+
const shouldTruncate = typeof maxLines === 'number' && Number.isFinite(maxLines);
|
|
187
|
+
const boundedMaxLines = shouldTruncate ? Math.max(0, Math.floor(maxLines)) : lines.length;
|
|
188
|
+
const truncated = lines.length > boundedMaxLines;
|
|
189
|
+
const displayLines = truncated ? lines.slice(0, boundedMaxLines) : lines;
|
|
167
190
|
|
|
168
191
|
let output = `${DIM}--- /dev/null${RESET}\n`;
|
|
169
192
|
output += `${DIM}+++ b/${filePath}${RESET}\n`;
|
|
@@ -174,7 +197,7 @@ export function formatNewFileDiff(content: string, filePath: string, maxLines =
|
|
|
174
197
|
}
|
|
175
198
|
|
|
176
199
|
if (truncated) {
|
|
177
|
-
output += `${DIM}... ${lines.length -
|
|
200
|
+
output += `${DIM}... ${lines.length - boundedMaxLines} more lines${RESET}\n`;
|
|
178
201
|
}
|
|
179
202
|
|
|
180
203
|
return output;
|