@vellumai/assistant 0.3.27 → 0.4.0
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 +81 -4
- package/Dockerfile +2 -2
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +9 -5
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +21 -19
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +212 -36
- package/src/__tests__/notification-decision-fallback.test.ts +63 -3
- package/src/__tests__/notification-decision-strategy.test.ts +78 -0
- package/src/__tests__/notification-guardian-path.test.ts +15 -15
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +126 -59
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +358 -24
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +22 -16
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +33 -6
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +68 -326
- package/src/daemon/session-runtime-assembly.ts +119 -25
- package/src/daemon/session-tool-setup.ts +3 -2
- package/src/daemon/session.ts +4 -3
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +586 -0
- package/src/memory/channel-guardian-store.ts +2 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +20 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +5 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +56 -0
- package/src/notifications/copy-composer.ts +31 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +173 -0
- package/src/runtime/actor-trust-resolver.ts +221 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -71
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +717 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +20 -2
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +205 -529
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +53 -10
- package/src/tools/types.ts +13 -2
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* POST /v1/ingress/members/:id/block — block a member
|
|
9
9
|
*
|
|
10
10
|
* Invites:
|
|
11
|
-
* GET /v1/ingress/invites
|
|
12
|
-
* POST /v1/ingress/invites
|
|
13
|
-
* DELETE /v1/ingress/invites/:id
|
|
14
|
-
* POST /v1/ingress/invites/redeem
|
|
11
|
+
* GET /v1/ingress/invites — list invites
|
|
12
|
+
* POST /v1/ingress/invites — create an invite (supports voice)
|
|
13
|
+
* DELETE /v1/ingress/invites/:id — revoke an invite
|
|
14
|
+
* POST /v1/ingress/invites/redeem — redeem an invite (token or voice code)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
listIngressInvites,
|
|
21
21
|
listIngressMembers,
|
|
22
22
|
redeemIngressInvite,
|
|
23
|
+
redeemVoiceInviteCode,
|
|
23
24
|
revokeIngressInvite,
|
|
24
25
|
revokeIngressMember,
|
|
25
26
|
upsertIngressMember,
|
|
@@ -130,6 +131,11 @@ export function handleListInvites(url: URL): Response {
|
|
|
130
131
|
|
|
131
132
|
/**
|
|
132
133
|
* POST /v1/ingress/invites
|
|
134
|
+
*
|
|
135
|
+
* For voice invites, pass `sourceChannel: "voice"` with required
|
|
136
|
+
* `expectedExternalUserId` (E.164 phone). Voice codes are always 6 digits.
|
|
137
|
+
* The response will include a one-time `voiceCode` field that must be
|
|
138
|
+
* communicated to the invited user out-of-band.
|
|
133
139
|
*/
|
|
134
140
|
export async function handleCreateInvite(req: Request): Promise<Response> {
|
|
135
141
|
const body = (await req.json()) as Record<string, unknown>;
|
|
@@ -139,6 +145,8 @@ export async function handleCreateInvite(req: Request): Promise<Response> {
|
|
|
139
145
|
note: body.note as string | undefined,
|
|
140
146
|
maxUses: body.maxUses as number | undefined,
|
|
141
147
|
expiresInMs: body.expiresInMs as number | undefined,
|
|
148
|
+
expectedExternalUserId: body.expectedExternalUserId as string | undefined,
|
|
149
|
+
voiceCodeDigits: body.voiceCodeDigits as number | undefined,
|
|
142
150
|
});
|
|
143
151
|
|
|
144
152
|
if (!result.ok) {
|
|
@@ -161,10 +169,50 @@ export function handleRevokeInvite(inviteId: string): Response {
|
|
|
161
169
|
|
|
162
170
|
/**
|
|
163
171
|
* POST /v1/ingress/invites/redeem
|
|
172
|
+
*
|
|
173
|
+
* Unified invite redemption endpoint. Supports two modes:
|
|
174
|
+
*
|
|
175
|
+
* 1. **Token-based** (existing): pass `token`, `sourceChannel`, `externalUserId`, etc.
|
|
176
|
+
* 2. **Voice code** (new): pass `code` and `callerExternalUserId` (E.164 phone).
|
|
177
|
+
* Optionally pass `assistantId`.
|
|
178
|
+
*
|
|
179
|
+
* The presence of `code` in the body selects voice-code redemption.
|
|
164
180
|
*/
|
|
165
181
|
export async function handleRedeemInvite(req: Request): Promise<Response> {
|
|
166
182
|
const body = (await req.json()) as Record<string, unknown>;
|
|
167
183
|
|
|
184
|
+
// Voice-code redemption path: triggered when `code` is present
|
|
185
|
+
if (body.code != null) {
|
|
186
|
+
const callerExternalUserId = body.callerExternalUserId as string | undefined;
|
|
187
|
+
const code = body.code as string | undefined;
|
|
188
|
+
|
|
189
|
+
if (!callerExternalUserId || !code) {
|
|
190
|
+
return Response.json(
|
|
191
|
+
{ ok: false, error: 'callerExternalUserId and code are required' },
|
|
192
|
+
{ status: 400 },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = redeemVoiceInviteCode({
|
|
197
|
+
assistantId: body.assistantId as string | undefined,
|
|
198
|
+
callerExternalUserId,
|
|
199
|
+
sourceChannel: 'voice',
|
|
200
|
+
code,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
return Response.json({ ok: false, error: result.reason }, { status: 400 });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return Response.json({
|
|
208
|
+
ok: true,
|
|
209
|
+
type: result.type,
|
|
210
|
+
memberId: result.memberId,
|
|
211
|
+
...(result.type === 'redeemed' ? { inviteId: result.inviteId } : {}),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Token-based redemption path (default)
|
|
168
216
|
const result = redeemIngressInvite({
|
|
169
217
|
token: body.token as string | undefined,
|
|
170
218
|
externalUserId: body.externalUserId as string | undefined,
|
|
@@ -75,6 +75,9 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
|
|
|
75
75
|
|
|
76
76
|
const result = ctx.pairingStore.beginRequest({ pairingRequestId, pairingSecret, deviceId, deviceName });
|
|
77
77
|
if (!result.ok) {
|
|
78
|
+
if (result.reason === 'already_paired') {
|
|
79
|
+
return httpError('CONFLICT', 'This pairing request is already bound to another device', 409);
|
|
80
|
+
}
|
|
78
81
|
const statusCode = result.reason === 'invalid_secret' ? 403 : result.reason === 'not_found' ? 403 : 410;
|
|
79
82
|
return httpError('FORBIDDEN', 'Forbidden', statusCode);
|
|
80
83
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool grant request creation and guardian notification helper.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the "create/dedupe canonical tool_grant_request + emit notification"
|
|
5
|
+
* logic so non-guardian channel actors can escalate tool invocations that require
|
|
6
|
+
* guardian approval. Modeled after the access-request-helper pattern.
|
|
7
|
+
*
|
|
8
|
+
* Invariants preserved:
|
|
9
|
+
* - Unverified actors are fail-closed (caller must gate before calling).
|
|
10
|
+
* - Guardians cannot self-approve (grant minting uses guardian identity).
|
|
11
|
+
* - Notification routing goes through emitNotificationSignal().
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ChannelId } from '../channels/types.js';
|
|
15
|
+
import {
|
|
16
|
+
createCanonicalGuardianDelivery,
|
|
17
|
+
createCanonicalGuardianRequest,
|
|
18
|
+
listCanonicalGuardianRequests,
|
|
19
|
+
} from '../memory/canonical-guardian-store.js';
|
|
20
|
+
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
21
|
+
import { getLogger } from '../util/logger.js';
|
|
22
|
+
import { getGuardianBinding } from './channel-guardian-service.js';
|
|
23
|
+
import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
|
|
24
|
+
|
|
25
|
+
const log = getLogger('tool-grant-request-helper');
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export interface ToolGrantRequestParams {
|
|
32
|
+
assistantId: string;
|
|
33
|
+
sourceChannel: ChannelId;
|
|
34
|
+
conversationId: string;
|
|
35
|
+
requesterExternalUserId: string;
|
|
36
|
+
requesterChatId?: string;
|
|
37
|
+
requesterIdentifier?: string;
|
|
38
|
+
toolName: string;
|
|
39
|
+
inputDigest: string;
|
|
40
|
+
questionText: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ToolGrantRequestResult =
|
|
44
|
+
| { created: true; requestId: string; requestCode: string | null }
|
|
45
|
+
| { deduped: true; requestId: string; requestCode: string | null }
|
|
46
|
+
| { failed: true; reason: 'no_guardian_binding' | 'missing_identity' };
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helper
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create/dedupe a canonical tool_grant_request and emit a notification signal
|
|
54
|
+
* so the guardian can approve or deny the tool invocation.
|
|
55
|
+
*
|
|
56
|
+
* Returns a result indicating whether a new request was created, an existing
|
|
57
|
+
* one was deduped, or the escalation failed (no binding, missing identity).
|
|
58
|
+
*/
|
|
59
|
+
export function createOrReuseToolGrantRequest(
|
|
60
|
+
params: ToolGrantRequestParams,
|
|
61
|
+
): ToolGrantRequestResult {
|
|
62
|
+
const {
|
|
63
|
+
assistantId,
|
|
64
|
+
sourceChannel,
|
|
65
|
+
conversationId,
|
|
66
|
+
requesterExternalUserId,
|
|
67
|
+
requesterChatId,
|
|
68
|
+
requesterIdentifier,
|
|
69
|
+
toolName,
|
|
70
|
+
inputDigest,
|
|
71
|
+
questionText,
|
|
72
|
+
} = params;
|
|
73
|
+
|
|
74
|
+
if (!requesterExternalUserId) {
|
|
75
|
+
return { failed: true, reason: 'missing_identity' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const binding = getGuardianBinding(assistantId, sourceChannel);
|
|
79
|
+
if (!binding) {
|
|
80
|
+
log.debug(
|
|
81
|
+
{ sourceChannel, assistantId },
|
|
82
|
+
'No guardian binding for tool grant request escalation',
|
|
83
|
+
);
|
|
84
|
+
return { failed: true, reason: 'no_guardian_binding' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Deduplicate: skip creation if there is already a pending canonical request
|
|
88
|
+
// for the same requester + conversation + tool + input digest + guardian.
|
|
89
|
+
// Guardian identity is included so that after a guardian rebind, old requests
|
|
90
|
+
// tied to the previous guardian don't block creation of a new approvable request.
|
|
91
|
+
const existing = listCanonicalGuardianRequests({
|
|
92
|
+
status: 'pending',
|
|
93
|
+
requesterExternalUserId,
|
|
94
|
+
conversationId,
|
|
95
|
+
kind: 'tool_grant_request',
|
|
96
|
+
toolName,
|
|
97
|
+
});
|
|
98
|
+
const dedupeMatch = existing.find(
|
|
99
|
+
(r) => r.inputDigest === inputDigest && r.guardianExternalUserId === binding.guardianExternalUserId,
|
|
100
|
+
);
|
|
101
|
+
if (dedupeMatch) {
|
|
102
|
+
log.debug(
|
|
103
|
+
{
|
|
104
|
+
sourceChannel,
|
|
105
|
+
requesterExternalUserId,
|
|
106
|
+
toolName,
|
|
107
|
+
existingId: dedupeMatch.id,
|
|
108
|
+
},
|
|
109
|
+
'Skipping duplicate tool grant request notification',
|
|
110
|
+
);
|
|
111
|
+
return { deduped: true, requestId: dedupeMatch.id, requestCode: dedupeMatch.requestCode };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const senderLabel = requesterIdentifier || requesterExternalUserId;
|
|
115
|
+
const requestId = `tool-grant-${assistantId}-${sourceChannel}-${requesterExternalUserId}-${Date.now()}`;
|
|
116
|
+
|
|
117
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
118
|
+
id: requestId,
|
|
119
|
+
kind: 'tool_grant_request',
|
|
120
|
+
sourceType: 'channel',
|
|
121
|
+
sourceChannel,
|
|
122
|
+
conversationId,
|
|
123
|
+
requesterExternalUserId,
|
|
124
|
+
requesterChatId: requesterChatId ?? undefined,
|
|
125
|
+
guardianExternalUserId: binding.guardianExternalUserId,
|
|
126
|
+
toolName,
|
|
127
|
+
inputDigest,
|
|
128
|
+
questionText,
|
|
129
|
+
expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Emit notification so guardian is alerted. Uses 'guardian.question' as
|
|
133
|
+
// sourceEventName so that existing request-code guidance in the notification
|
|
134
|
+
// pipeline is preserved.
|
|
135
|
+
const signalPromise = emitNotificationSignal({
|
|
136
|
+
sourceEventName: 'guardian.question',
|
|
137
|
+
sourceChannel,
|
|
138
|
+
sourceSessionId: conversationId,
|
|
139
|
+
assistantId,
|
|
140
|
+
attentionHints: {
|
|
141
|
+
requiresAction: true,
|
|
142
|
+
urgency: 'high',
|
|
143
|
+
isAsyncBackground: false,
|
|
144
|
+
visibleInSourceNow: false,
|
|
145
|
+
},
|
|
146
|
+
contextPayload: {
|
|
147
|
+
requestId: canonicalRequest.id,
|
|
148
|
+
requestCode: canonicalRequest.requestCode,
|
|
149
|
+
sourceChannel,
|
|
150
|
+
requesterExternalUserId,
|
|
151
|
+
requesterChatId: requesterChatId ?? null,
|
|
152
|
+
requesterIdentifier: senderLabel,
|
|
153
|
+
toolName,
|
|
154
|
+
questionText,
|
|
155
|
+
},
|
|
156
|
+
dedupeKey: `tool-grant-request:${canonicalRequest.id}`,
|
|
157
|
+
onThreadCreated: (info) => {
|
|
158
|
+
createCanonicalGuardianDelivery({
|
|
159
|
+
requestId: canonicalRequest.id,
|
|
160
|
+
destinationChannel: 'vellum',
|
|
161
|
+
destinationConversationId: info.conversationId,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Record deliveries from the notification pipeline results (fire-and-forget).
|
|
167
|
+
void signalPromise.then((signalResult) => {
|
|
168
|
+
for (const result of signalResult.deliveryResults) {
|
|
169
|
+
if (result.channel === 'vellum') continue; // handled in onThreadCreated
|
|
170
|
+
if (result.channel !== 'telegram' && result.channel !== 'sms') continue;
|
|
171
|
+
createCanonicalGuardianDelivery({
|
|
172
|
+
requestId: canonicalRequest.id,
|
|
173
|
+
destinationChannel: result.channel,
|
|
174
|
+
destinationChatId: result.destination.length > 0 ? result.destination : undefined,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
log.info(
|
|
180
|
+
{
|
|
181
|
+
sourceChannel,
|
|
182
|
+
requesterExternalUserId,
|
|
183
|
+
toolName,
|
|
184
|
+
requestId: canonicalRequest.id,
|
|
185
|
+
requestCode: canonicalRequest.requestCode,
|
|
186
|
+
},
|
|
187
|
+
'Guardian notified of tool grant request',
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
created: true,
|
|
192
|
+
requestId: canonicalRequest.id,
|
|
193
|
+
requestCode: canonicalRequest.requestCode,
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/tools/executor.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveExecutionTarget } from './execution-target.js';
|
|
|
13
13
|
import { executeWithTimeout,safeTimeoutMs } from './execution-timeout.js';
|
|
14
14
|
import { PermissionChecker } from './permission-checker.js';
|
|
15
15
|
import { SecretDetectionHandler } from './secret-detection-handler.js';
|
|
16
|
+
import { extractAndSanitize } from './sensitive-output-placeholders.js';
|
|
16
17
|
import { applyEdit } from './shared/filesystem/edit-engine.js';
|
|
17
18
|
import { sandboxPolicy } from './shared/filesystem/path-policy.js';
|
|
18
19
|
import { MAX_FILE_SIZE_BYTES } from './shared/filesystem/size-guard.js';
|
|
@@ -182,6 +183,15 @@ export class ToolExecutor {
|
|
|
182
183
|
);
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// Sensitive output extraction: strip directives, replace raw values
|
|
187
|
+
// with placeholders, and attach bindings for agent-loop substitution.
|
|
188
|
+
// Runs before secret detection so that raw sensitive values are already
|
|
189
|
+
// replaced and won't trigger entropy-based redaction.
|
|
190
|
+
const { sanitizedContent, bindings } = extractAndSanitize(execResult.content);
|
|
191
|
+
if (bindings.length > 0) {
|
|
192
|
+
execResult = { ...execResult, content: sanitizedContent, sensitiveBindings: bindings };
|
|
193
|
+
}
|
|
194
|
+
|
|
185
195
|
// Secret detection on tool output
|
|
186
196
|
const secretResult = await this.secretDetectionHandler.handle(
|
|
187
197
|
execResult, name, input, context, executionTarget,
|
|
@@ -193,6 +203,8 @@ export class ToolExecutor {
|
|
|
193
203
|
execResult = secretResult.result;
|
|
194
204
|
|
|
195
205
|
const durationMs = Date.now() - startTime;
|
|
206
|
+
// Strip sensitiveBindings from lifecycle event to prevent raw values leaking
|
|
207
|
+
const { sensitiveBindings: _sb, ...safeResult } = execResult;
|
|
196
208
|
emitLifecycleEvent(context, {
|
|
197
209
|
type: 'executed',
|
|
198
210
|
toolName: name,
|
|
@@ -205,7 +217,7 @@ export class ToolExecutor {
|
|
|
205
217
|
riskLevel,
|
|
206
218
|
decision,
|
|
207
219
|
durationMs,
|
|
208
|
-
result:
|
|
220
|
+
result: safeResult,
|
|
209
221
|
});
|
|
210
222
|
|
|
211
223
|
void getHookManager().trigger('post-tool-execute', {
|
|
@@ -124,13 +124,13 @@ export function isGuardianControlPlaneInvocation(
|
|
|
124
124
|
export function enforceGuardianOnlyPolicy(
|
|
125
125
|
toolName: string,
|
|
126
126
|
input: Record<string, unknown>,
|
|
127
|
-
|
|
127
|
+
trustClass: string | undefined,
|
|
128
128
|
): { denied: boolean; reason?: string } {
|
|
129
129
|
if (!isGuardianControlPlaneInvocation(toolName, input)) {
|
|
130
130
|
return { denied: false };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
if (
|
|
133
|
+
if (trustClass === 'guardian' || trustClass === undefined) {
|
|
134
134
|
return { denied: false };
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitive output placeholder extraction and substitution.
|
|
3
|
+
*
|
|
4
|
+
* Tool outputs may contain `<vellum-sensitive-output kind="..." value="..." />`
|
|
5
|
+
* directives. This module:
|
|
6
|
+
* 1. Parses and strips those directives from tool output.
|
|
7
|
+
* 2. Replaces any raw sensitive values remaining in the output with stable,
|
|
8
|
+
* high-uniqueness placeholders so the LLM never sees the real values.
|
|
9
|
+
* 3. Returns bindings (placeholder -> real value) for deterministic
|
|
10
|
+
* post-generation substitution in the agent loop.
|
|
11
|
+
*
|
|
12
|
+
* Raw sensitive values MUST NOT be logged or emitted in lifecycle events.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Types
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type SensitiveOutputKind = 'invite_code';
|
|
20
|
+
|
|
21
|
+
export interface SensitiveOutputBinding {
|
|
22
|
+
kind: SensitiveOutputKind;
|
|
23
|
+
placeholder: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Directive regex
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const DIRECTIVE_RE =
|
|
32
|
+
/<vellum-sensitive-output\s+kind="([^"]+)"\s+value="([^"]+)"\s*\/>/g;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Placeholder generation
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const KIND_PREFIX: Record<SensitiveOutputKind, string> = {
|
|
39
|
+
invite_code: 'VELLUM_ASSISTANT_INVITE_CODE_',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const VALID_KINDS = new Set<string>(Object.keys(KIND_PREFIX));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate an 8-char uppercase base-36 short ID.
|
|
46
|
+
* Provides ~41 bits of entropy — sufficient for intra-request uniqueness.
|
|
47
|
+
*/
|
|
48
|
+
function generateShortId(): string {
|
|
49
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
50
|
+
let id = '';
|
|
51
|
+
for (let i = 0; i < 8; i++) {
|
|
52
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
53
|
+
}
|
|
54
|
+
return id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makePlaceholder(kind: SensitiveOutputKind): string {
|
|
58
|
+
return `${KIND_PREFIX[kind]}${generateShortId()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Public API
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface SanitizeResult {
|
|
66
|
+
sanitizedContent: string;
|
|
67
|
+
bindings: SensitiveOutputBinding[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract `<vellum-sensitive-output>` directives from tool output content,
|
|
72
|
+
* strip them, replace any remaining occurrences of the raw sensitive values
|
|
73
|
+
* with placeholders, and return the bindings for downstream substitution.
|
|
74
|
+
*
|
|
75
|
+
* Guarantees:
|
|
76
|
+
* - Directives are fully removed from the returned content.
|
|
77
|
+
* - Empty values are silently dropped.
|
|
78
|
+
* - Duplicate values produce a single binding (same placeholder).
|
|
79
|
+
* - Unknown kinds are silently ignored.
|
|
80
|
+
*/
|
|
81
|
+
export function extractAndSanitize(content: string): SanitizeResult {
|
|
82
|
+
const bindings: SensitiveOutputBinding[] = [];
|
|
83
|
+
const seenValues = new Map<string, SensitiveOutputBinding>();
|
|
84
|
+
|
|
85
|
+
// Step 1: parse directives
|
|
86
|
+
// Reset lastIndex for safety since the regex is global
|
|
87
|
+
DIRECTIVE_RE.lastIndex = 0;
|
|
88
|
+
let match: RegExpExecArray | undefined;
|
|
89
|
+
while ((match = DIRECTIVE_RE.exec(content) ?? undefined) !== undefined) {
|
|
90
|
+
const kind = match[1];
|
|
91
|
+
const value = match[2];
|
|
92
|
+
|
|
93
|
+
if (!value || value.trim().length === 0) continue;
|
|
94
|
+
if (!VALID_KINDS.has(kind)) continue;
|
|
95
|
+
|
|
96
|
+
const typedKind = kind as SensitiveOutputKind;
|
|
97
|
+
if (!seenValues.has(value)) {
|
|
98
|
+
const binding: SensitiveOutputBinding = {
|
|
99
|
+
kind: typedKind,
|
|
100
|
+
placeholder: makePlaceholder(typedKind),
|
|
101
|
+
value,
|
|
102
|
+
};
|
|
103
|
+
bindings.push(binding);
|
|
104
|
+
seenValues.set(value, binding);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (bindings.length === 0) {
|
|
109
|
+
return { sanitizedContent: content, bindings: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Step 2: strip directive tags
|
|
113
|
+
let sanitized = content.replace(DIRECTIVE_RE, '');
|
|
114
|
+
|
|
115
|
+
// Step 3: replace raw values with placeholders throughout remaining content
|
|
116
|
+
for (const binding of bindings) {
|
|
117
|
+
sanitized = sanitized.split(binding.value).join(binding.placeholder);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { sanitizedContent: sanitized, bindings };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply placeholder->value substitution to a text string.
|
|
125
|
+
* Used by the agent loop to resolve placeholders in streamed deltas
|
|
126
|
+
* and final message content.
|
|
127
|
+
*/
|
|
128
|
+
export function applySubstitutions(
|
|
129
|
+
text: string,
|
|
130
|
+
substitutionMap: ReadonlyMap<string, string>,
|
|
131
|
+
): string {
|
|
132
|
+
if (substitutionMap.size === 0) return text;
|
|
133
|
+
|
|
134
|
+
let result = text;
|
|
135
|
+
for (const [placeholder, value] of substitutionMap) {
|
|
136
|
+
result = result.split(placeholder).join(value);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Chunk-safe substitution for streaming text deltas.
|
|
143
|
+
*
|
|
144
|
+
* Because a placeholder like `VELLUM_ASSISTANT_INVITE_CODE_AB12CD34` may be
|
|
145
|
+
* split across consecutive streamed chunks, this function buffers a trailing
|
|
146
|
+
* segment that could be the start of an incomplete placeholder and returns it
|
|
147
|
+
* as `pending`. The caller must prepend `pending` to the next chunk.
|
|
148
|
+
*
|
|
149
|
+
* Returns `{ emit, pending }`:
|
|
150
|
+
* - `emit`: text safe to send to the client (all complete placeholders resolved).
|
|
151
|
+
* - `pending`: trailing text that might be an incomplete placeholder prefix.
|
|
152
|
+
*/
|
|
153
|
+
export function applyStreamingSubstitution(
|
|
154
|
+
text: string,
|
|
155
|
+
substitutionMap: ReadonlyMap<string, string>,
|
|
156
|
+
): { emit: string; pending: string } {
|
|
157
|
+
if (substitutionMap.size === 0) {
|
|
158
|
+
return { emit: text, pending: '' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// First, resolve any complete placeholders
|
|
162
|
+
let resolved = text;
|
|
163
|
+
for (const [placeholder, value] of substitutionMap) {
|
|
164
|
+
resolved = resolved.split(placeholder).join(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if the tail of resolved text could be an incomplete placeholder prefix.
|
|
168
|
+
// All current placeholders start with "VELLUM_ASSISTANT_".
|
|
169
|
+
const PREFIX = 'VELLUM_ASSISTANT_';
|
|
170
|
+
const minSuffixLen = 1; // At minimum, one char of the prefix
|
|
171
|
+
|
|
172
|
+
// Walk backwards from the end to find a trailing partial match of any placeholder prefix
|
|
173
|
+
let pendingStart = resolved.length;
|
|
174
|
+
for (let i = Math.max(0, resolved.length - getMaxPlaceholderLength(substitutionMap)); i < resolved.length; i++) {
|
|
175
|
+
const tail = resolved.slice(i);
|
|
176
|
+
// Check if any placeholder starts with this tail
|
|
177
|
+
if (tail.length >= minSuffixLen && PREFIX.startsWith(tail)) {
|
|
178
|
+
pendingStart = i;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
// Also check if any full placeholder key starts with this tail
|
|
182
|
+
for (const placeholder of substitutionMap.keys()) {
|
|
183
|
+
if (placeholder.startsWith(tail) && tail.length < placeholder.length) {
|
|
184
|
+
pendingStart = i;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (pendingStart !== resolved.length) break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
emit: resolved.slice(0, pendingStart),
|
|
193
|
+
pending: resolved.slice(pendingStart),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getMaxPlaceholderLength(map: ReadonlyMap<string, string>): number {
|
|
198
|
+
let max = 0;
|
|
199
|
+
for (const key of map.keys()) {
|
|
200
|
+
if (key.length > max) max = key.length;
|
|
201
|
+
}
|
|
202
|
+
return max;
|
|
203
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
|
|
2
|
+
import { createOrReuseToolGrantRequest } from '../runtime/tool-grant-request-helper.js';
|
|
2
3
|
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
3
4
|
import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
|
|
4
5
|
import { getLogger } from '../util/logger.js';
|
|
@@ -9,8 +10,8 @@ import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifec
|
|
|
9
10
|
|
|
10
11
|
const log = getLogger('tool-approval-handler');
|
|
11
12
|
|
|
12
|
-
function
|
|
13
|
-
return role === '
|
|
13
|
+
function isUntrustedGuardianTrustClass(role: ToolContext['guardianTrustClass']): boolean {
|
|
14
|
+
return role === 'trusted_contact' || role === 'unknown';
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
function requiresGuardianApprovalForActor(
|
|
@@ -25,10 +26,10 @@ function requiresGuardianApprovalForActor(
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function guardianApprovalDeniedMessage(
|
|
28
|
-
|
|
29
|
+
trustClass: ToolContext['guardianTrustClass'],
|
|
29
30
|
toolName: string,
|
|
30
31
|
): string {
|
|
31
|
-
if (
|
|
32
|
+
if (trustClass === 'unknown') {
|
|
32
33
|
return `Permission denied for "${toolName}": this action requires guardian approval from a verified channel identity.`;
|
|
33
34
|
}
|
|
34
35
|
return `Permission denied for "${toolName}": this action requires guardian approval and the current actor is not the guardian.`;
|
|
@@ -81,13 +82,13 @@ export class ToolApprovalHandler {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// Reject tool invocations targeting guardian control-plane endpoints from non-guardian actors.
|
|
84
|
-
const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.
|
|
85
|
+
const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianTrustClass);
|
|
85
86
|
if (guardianCheck.denied) {
|
|
86
87
|
log.warn({
|
|
87
88
|
toolName: name,
|
|
88
89
|
sessionId: context.sessionId,
|
|
89
90
|
conversationId: context.conversationId,
|
|
90
|
-
|
|
91
|
+
trustClass: context.guardianTrustClass,
|
|
91
92
|
reason: 'guardian_only_policy',
|
|
92
93
|
}, 'Guardian-only policy blocked tool invocation');
|
|
93
94
|
const durationMs = Date.now() - startTime;
|
|
@@ -117,7 +118,7 @@ export class ToolApprovalHandler {
|
|
|
117
118
|
let deferredConsumeParams: Parameters<typeof consumeGrantForInvocation>[0] | null = null;
|
|
118
119
|
|
|
119
120
|
if (
|
|
120
|
-
|
|
121
|
+
isUntrustedGuardianTrustClass(context.guardianTrustClass)
|
|
121
122
|
&& requiresGuardianApprovalForActor(name, input, executionTarget)
|
|
122
123
|
) {
|
|
123
124
|
const inputDigest = computeToolApprovalDigest(name, input);
|
|
@@ -232,7 +233,7 @@ export class ToolApprovalHandler {
|
|
|
232
233
|
toolName: name,
|
|
233
234
|
sessionId: context.sessionId,
|
|
234
235
|
conversationId: context.conversationId,
|
|
235
|
-
|
|
236
|
+
trustClass: context.guardianTrustClass,
|
|
236
237
|
executionTarget,
|
|
237
238
|
grantId: grantResult.grant.id,
|
|
238
239
|
}, 'Scoped grant consumed — allowing untrusted actor tool invocation');
|
|
@@ -266,15 +267,57 @@ export class ToolApprovalHandler {
|
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
// No matching grant or race condition — deny.
|
|
269
|
-
|
|
270
|
+
//
|
|
271
|
+
// For verified non-guardian actors with sufficient context, escalate to
|
|
272
|
+
// the guardian by creating a canonical tool_grant_request. Unverified
|
|
273
|
+
// actors remain fail-closed with no escalation.
|
|
274
|
+
let escalationMessage: string | undefined;
|
|
275
|
+
if (
|
|
276
|
+
context.guardianTrustClass === 'trusted_contact'
|
|
277
|
+
&& context.assistantId
|
|
278
|
+
&& context.executionChannel
|
|
279
|
+
&& context.requesterExternalUserId
|
|
280
|
+
) {
|
|
281
|
+
const inputDigest = deferredConsumeParams?.inputDigest
|
|
282
|
+
?? computeToolApprovalDigest(name, input);
|
|
283
|
+
const escalation = createOrReuseToolGrantRequest({
|
|
284
|
+
assistantId: context.assistantId,
|
|
285
|
+
sourceChannel: context.executionChannel as import('../channels/types.js').ChannelId,
|
|
286
|
+
conversationId: context.conversationId,
|
|
287
|
+
requesterExternalUserId: context.requesterExternalUserId,
|
|
288
|
+
requesterChatId: context.requesterChatId,
|
|
289
|
+
toolName: name,
|
|
290
|
+
inputDigest,
|
|
291
|
+
questionText: `Trusted contact is requesting permission to use "${name}"`,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if ('created' in escalation) {
|
|
295
|
+
const codeSuffix = escalation.requestCode
|
|
296
|
+
? ` (request code: ${escalation.requestCode})`
|
|
297
|
+
: '';
|
|
298
|
+
escalationMessage = `Permission denied for "${name}": this action requires guardian approval. `
|
|
299
|
+
+ `A request has been sent to the guardian${codeSuffix}. `
|
|
300
|
+
+ `Please retry after the guardian approves.`;
|
|
301
|
+
} else if ('deduped' in escalation) {
|
|
302
|
+
const codeSuffix = escalation.requestCode
|
|
303
|
+
? ` (request code: ${escalation.requestCode})`
|
|
304
|
+
: '';
|
|
305
|
+
escalationMessage = `Permission denied for "${name}": guardian approval is already pending${codeSuffix}. `
|
|
306
|
+
+ `Please retry after the guardian approves.`;
|
|
307
|
+
}
|
|
308
|
+
// If escalation.failed, fall through to generic denial message.
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const reason = escalationMessage ?? guardianApprovalDeniedMessage(context.guardianTrustClass, name);
|
|
270
312
|
log.warn({
|
|
271
313
|
toolName: name,
|
|
272
314
|
sessionId: context.sessionId,
|
|
273
315
|
conversationId: context.conversationId,
|
|
274
|
-
|
|
316
|
+
trustClass: context.guardianTrustClass,
|
|
275
317
|
executionTarget,
|
|
276
318
|
reason: 'guardian_approval_required',
|
|
277
319
|
grantMissReason: grantResult.reason,
|
|
320
|
+
escalated: !!escalationMessage,
|
|
278
321
|
}, 'Guardian approval gate blocked untrusted actor tool invocation (no matching grant)');
|
|
279
322
|
const durationMs = Date.now() - startTime;
|
|
280
323
|
emitLifecycleEvent({
|