@vellumai/assistant 0.12.6 → 0.12.7-staging.1

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.
Files changed (97) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +14 -14
  3. package/docs/architecture/integrations.md +5 -5
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/openapi.yaml +153 -5
  15. package/package.json +1 -1
  16. package/src/__tests__/channel-policy.test.ts +7 -0
  17. package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
  18. package/src/__tests__/identity-routes.test.ts +1 -0
  19. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  20. package/src/__tests__/openai-provider.test.ts +27 -0
  21. package/src/__tests__/skill-execute-input.test.ts +11 -0
  22. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  23. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  24. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  25. package/src/agent/loop.ts +133 -12
  26. package/src/calls/__tests__/call-controller.test.ts +273 -1
  27. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  28. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  29. package/src/calls/call-constants.ts +10 -0
  30. package/src/calls/call-controller.ts +359 -25
  31. package/src/calls/media-stream-server.ts +18 -0
  32. package/src/calls/media-stream-stt-session.ts +30 -9
  33. package/src/channels/config.ts +8 -0
  34. package/src/cli/lib/bundled-marketplace.json +39 -0
  35. package/src/config/composer-preferences.test.ts +97 -0
  36. package/src/config/composer-preferences.ts +95 -0
  37. package/src/config/feature-flag-registry.json +16 -8
  38. package/src/config/schemas/llm.ts +1 -0
  39. package/src/contacts/types.ts +1 -0
  40. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  41. package/src/daemon/conversation-agent-loop.ts +3 -0
  42. package/src/daemon/conversation-messaging.ts +4 -0
  43. package/src/daemon/conversation-process.ts +15 -0
  44. package/src/daemon/conversation-queue-manager.ts +2 -0
  45. package/src/daemon/conversation.ts +2 -0
  46. package/src/daemon/handlers/shared.ts +13 -2
  47. package/src/daemon/message-types/sync.ts +1 -0
  48. package/src/daemon/process-message.ts +5 -0
  49. package/src/i18n/__tests__/i18n.test.ts +25 -0
  50. package/src/i18n/assistant-text.ts +18 -0
  51. package/src/i18n/index.ts +1 -0
  52. package/src/i18n/messages.ts +11 -0
  53. package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
  54. package/src/ipc/routes/route-adapter.ts +9 -0
  55. package/src/notifications/README.md +1 -1
  56. package/src/notifications/signal.ts +4 -0
  57. package/src/persistence/conversation-key-store.ts +12 -11
  58. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  59. package/src/providers/inference/adapter-factory.ts +6 -0
  60. package/src/providers/ionet/client.test.ts +143 -0
  61. package/src/providers/ionet/client.ts +37 -0
  62. package/src/providers/model-catalog.ts +40 -0
  63. package/src/providers/openai/chat-completions-provider.ts +33 -2
  64. package/src/providers/retry.ts +10 -4
  65. package/src/providers/unparseable-tool-args.ts +29 -5
  66. package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
  67. package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
  68. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
  69. package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
  70. package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
  71. package/src/runtime/auth/route-policy.ts +44 -0
  72. package/src/runtime/auth/scopes.ts +15 -0
  73. package/src/runtime/auth/types.ts +1 -0
  74. package/src/runtime/channel-reply-delivery.ts +2 -0
  75. package/src/runtime/http-router.ts +61 -1
  76. package/src/runtime/http-types.ts +3 -0
  77. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  78. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  79. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  80. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  81. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  82. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  83. package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
  84. package/src/runtime/routes/composer-settings-routes.ts +76 -0
  85. package/src/runtime/routes/conversation-routes.ts +54 -14
  86. package/src/runtime/routes/identity-routes.ts +2 -0
  87. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  88. package/src/runtime/routes/index.ts +2 -0
  89. package/src/runtime/routes/migration-routes.ts +3 -1
  90. package/src/runtime/routes/types.ts +4 -1
  91. package/src/runtime/shared-principal-lookup.ts +235 -0
  92. package/src/tools/host-terminal/host-shell.ts +4 -2
  93. package/src/tools/shared/shell-output.test.ts +42 -0
  94. package/src/tools/shared/shell-output.ts +27 -4
  95. package/src/tools/skills/execute.ts +10 -7
  96. package/src/tools/terminal/shell.ts +2 -0
  97. package/src/tools/tool-approval-handler.ts +9 -1
package/.env.example CHANGED
@@ -2,6 +2,8 @@
2
2
  ANTHROPIC_API_KEY=sk-ant-...
3
3
  OPENAI_API_KEY=sk-...
4
4
  GEMINI_API_KEY=...
5
+ # Optional. IO Intelligence (io.net) open-models endpoint.
6
+ IONET_API_KEY=...
5
7
  # Optional. TypeSafe / Jev System One decision model.
6
8
  TYPESAFE_API_KEY=...
7
9
  # Optional for authenticated/proxied Ollama deployments
package/ARCHITECTURE.md CHANGED
@@ -83,6 +83,7 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
83
83
  | Profile | Scopes | Used by |
84
84
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
85
85
  | `actor_client_v1` | `chat.{read,write}`, `approval.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `calls.{read,write}`, `feature_flags.{read,write}` | Desktop, CLI clients |
86
+ | `contact_client_v1` | `chat.write`, `approval.write`, `attachments.write` | Non-guardian contact clients |
86
87
  | `gateway_ingress_v1` | `ingress.write`, `internal.write` | Gateway channel inbound + webhook forwarding |
87
88
  | `gateway_service_v1` | `chat.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `internal.write` | Gateway service-to-daemon calls |
88
89
  | `local_v1` | `local.all` | Local (loopback) conversation sessions |
@@ -98,8 +99,12 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
98
99
 
99
100
  3. **Local identity** — Local connections use deterministic identity resolution without tokens.
100
101
 
102
+ **Token role:** Each access and refresh token records the principal it belongs to, `guardian` or `contact`. The role picks the scope profile at mint time, and rotation carries it forward.
103
+
101
104
  **Route policy enforcement:** Every protected endpoint declares required scopes and allowed principal types in `src/runtime/auth/route-policy.ts`. The `enforcePolicy()` function checks the AuthContext against these requirements and returns 403 when access is denied. A guard test ensures every dispatched endpoint has a corresponding policy entry.
102
105
 
106
+ **Trust class enforcement:** A policy's `allowedTrustClasses` names whose turn may call a route, and an absent list means guardian only. A token whose scope profile is trust-checked (`isTrustCheckedScopeProfile` in `@vellumai/gateway-client`: every profile not on its exempt list, which is `contact_client_v1` today plus any unknown profile) reaches only a route whose list admits the trust class its principal resolves to on the `vellum-shared` channel. That class is read per request, so a revoked contact is refused on its next request. A trust-exempt token counts as the guardian with no lookup, so it reaches only a route whose list admits `guardian`, which every route naming no classes does. A refused caller gets 404, as an unmatched path does. The daemon's HTTP router and the gateway's IPC proxy both apply this rule (`tokenMayReachRoute` in `@vellumai/gateway-client`), since the daemon's IPC server runs no policy check of its own.
107
+
103
108
  **Credential storage:** Only hashed tokens are persisted. Access token hashes go in `credential_records`; refresh token hashes in `refresh_token_records`. Raw tokens are returned once and never stored server-side.
104
109
 
105
110
  **Notification scoping:** Guardian-sensitive notifications (`notification_intent`, `notification_conversation_created`) are published with `targetActorPrincipalId`, so the event hub delivers them, live, on reconnect replay, and from the `events/tail` recovery route, only to SSE connections whose verified `actorPrincipalId` is the guardian's. Connections without a principal never receive them. The payload's `targetGuardianPrincipalId` records the scoping.
@@ -618,12 +623,12 @@ Every phone call connects over Twilio Media Streams: the voice webhook emits `<C
618
623
 
619
624
  Transcription mode is selected once per session in `media-stream-stt-session.ts`:
620
625
 
621
- - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (`src/calls/barge-in-guard.ts`, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches `CallController.handleBargeIn`, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
626
+ - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials. Interim `partial` events are forwarded to the controller as `handleCallerPartial`: they commit nothing and start no turn, they are never persisted and never reach a setup flow, and they exist so a boundary the front door held mid-thought can be replayed on the caller's newest words rather than only the ones the provider has finalized. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (`src/calls/barge-in-guard.ts`, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches `CallController.handleBargeIn`, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
622
627
  - **Batch fallback**: otherwise the session segments turns with the energy-based `MediaTurnDetector` and transcribes each completed turn via the same role's batch API. Both halves of a call read the `telephony` role, which is why a role names its consumer rather than a boundary.
623
628
 
624
- Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. Phone has no partial transcripts, so the hold verdict is never taught and routing is escalate-only. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
629
+ Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. A leg that carries the caller's own words is also the turn's endpoint decision, so it is taught the hold verdict: silence alone cannot tell a finished sentence from a thinking pause, and answering a fragment makes the rest of the sentence read as a barge-in that interrupts the assistant's own reply. On a hold the leg is discarded (its persisted user row rolled back through the handle's `discard`), the floor goes straight back to the caller, and the words are carried into their next final so the whole sentence is judged together. If no next words arrive, a replay timer (`voice.frontModel.endpointExtensionMs`) answers the sentence as it stands; any partial re-arms that timer and the next final cancels it. A partial also stretches the wait to at least `calls.voice.utteranceEndMs` plus a margin, because the provider still owes a final for those words and that final is the better boundary: racing it would ask the same words twice, once on the interim guess and once on the committed text. Reaching the timer with a partial outstanding therefore means no final is coming, so the interim text rides along as the only record of what the caller said. `voice.frontModel.endpointMaxExtensions` caps consecutive holds per sentence, after which the leg must answer. Openers, instructions, and other assistant-initiated turns are never dispatched that way. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
625
630
 
626
- Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, and how the turn settled. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
631
+ Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived and when the provider first had interim words for that same utterance, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, an endpoint hold, and how the turn settled. A held turn settles as `endpoint_hold` and hands both seeds to the turn that finally answers, so the caller's wait is still measured from the moment they stopped talking. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
627
632
 
628
633
  A credential preflight (`resolveTelephonyCredentialReadiness()` in `src/calls/telephony-credential-preflight.ts`) gates every call: it requires a credentialed, telephony-capable STT provider **and** a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive `<Say>` setup-required copy plus `<Hangup/>` instead of a doomed stream; outbound placement fails before dialing via `preflightVoiceIngress()` with the same user-facing message.
629
634
 
@@ -1238,14 +1243,10 @@ graph TB
1238
1243
  EXEC -->|"file_read / file_write / file_edit"| SB_FILE_TOOLS["Sandbox file tools<br/>path-scoped to sandbox root"]
1239
1244
  SB_FILE_TOOLS --> SB_FS
1240
1245
 
1241
- EXEC -->|"bash"| WRAP["wrapCommand()<br/>sandbox.ts"]
1242
-
1243
- WRAP --> NATIVE["NativeBackend"]
1244
-
1245
- NATIVE -->|"macOS"| SBPL["sandbox-exec<br/>SBPL profile<br/>deny-default + allow workdir"]
1246
- NATIVE -->|"Linux"| BWRAP["bwrap<br/>bubblewrap<br/>ro-root + rw-workdir<br/>unshare-net + unshare-pid"]
1247
- SBPL --> SB_FS["Sandbox filesystem root<br/>$VELLUM_WORKSPACE_DIR"]
1248
- BWRAP --> SB_FS
1246
+ EXEC -->|"bash"| WRAP["buildShellInvocation()<br/>(packages/environments/shell)"]
1247
+ WRAP --> SHELL["bash -c in $VELLUM_WORKSPACE_DIR<br/>sanitized env (safe-env allowlist)"]
1248
+ SHELL --> SB_FS["Workspace filesystem root<br/>$VELLUM_WORKSPACE_DIR"]
1249
+ SHELL -.->|containerized deployments| CONTAINER["Container boundary<br/>(Docker / remote host) provides<br/>OS-level isolation"]
1249
1250
 
1250
1251
  EXEC -->|"host_file_* / host_bash"| HOST_TOOLS["Host-target tools<br/>(unchanged by backend choice)"]
1251
1252
  EXEC -->|"computer_use_* (skill-projected<br/>in CU sessions only)"| SKILL_CU_TOOLS["CU skill tools<br/>(bundled computer-use skill)"]
@@ -1259,9 +1260,8 @@ graph TB
1259
1260
  USER --> CHECK
1260
1261
  ```
1261
1262
 
1262
- - **Native backend**: Uses OS-level sandboxing — `sandbox-exec` with SBPL profiles on macOS, `bwrap` (bubblewrap) on Linux. Denies network access and restricts filesystem writes to the sandbox root, `/tmp`, `/private/tmp`, and `/var/folders` (macOS) or the sandbox root and `/tmp` (Linux).
1263
- - **Fail-closed**: The native backend refuses to execute unsandboxed if its prerequisites are unavailable, throwing `ToolError` with actionable messages on failure.
1264
- - **Host tools unchanged**: `host_bash`, `host_file_read`, `host_file_write`, and `host_file_edit` always execute directly on the host regardless of which sandbox backend is active.
1263
+ - **Workspace scoping (software-enforced)**: `file_read`, `file_write`, and `file_edit` path-check every access against `$VELLUM_WORKSPACE_DIR`, including symlink resolution. `bash` spawns `bash -c` in the workspace directory with a sanitized environment and is not OS-confined on local installs; commands are gated by risk classification and the conversation's auto-approve threshold, with workspace path checks applied to allowlisted commands on non-containerized installs. Containerized deployments (`vellum hatch --remote docker`, remote hosts) get OS-level isolation from the container boundary. Workflow scripts run in a separate QuickJS-WASM sandbox with no ambient capabilities.
1264
+ - **Host tools unchanged**: `host_bash`, `host_file_read`, `host_file_write`, and `host_file_edit` always execute directly on the host regardless of deployment mode.
1265
1265
  - Sandbox defaults: `file_*` and `bash` execute within `$VELLUM_WORKSPACE_DIR`.
1266
1266
  - Host access is explicit: `host_file_read`, `host_file_write`, `host_file_edit`, and `host_bash` are separate tools.
1267
1267
  - Prompt defaults: host tools and `computer_use_*` skill-projected actions default to `ask` unless a trust rule allowlists/denylists them.
@@ -269,7 +269,7 @@ Scripts executed via the `bash` tool can optionally run through a per-session HT
269
269
 
270
270
  ### Proxied Bash Execution Path
271
271
 
272
- When a bash command requires network access with credential injection, the sandbox backend switches from `network=none` to `network=bridge` and injects proxy environment variables so all HTTP/HTTPS traffic routes through the session proxy.
272
+ When a bash command requires network access with credential injection, the tool injects proxy environment variables into the spawned shell so all HTTP/HTTPS traffic routes through the session proxy.
273
273
 
274
274
  ```mermaid
275
275
  graph TB
@@ -283,8 +283,8 @@ graph TB
283
283
  PROMPT["Prompt user<br/>persistentDecisionsAllowed: false<br/>(no trust rule saving for proxied bash)"]
284
284
  end
285
285
 
286
- subgraph "Sandbox"
287
- SANDBOX["NativeBackend.wrap()<br/>networkMode: 'proxied'"]
286
+ subgraph "Shell Execution"
287
+ SHELL["bash -c spawn<br/>(buildShellInvocation)"]
288
288
  ENV_INJECT["Inject env vars:<br/>HTTP_PROXY, HTTPS_PROXY,<br/>NO_PROXY, NODE_EXTRA_CA_CERTS"]
289
289
  end
290
290
 
@@ -298,8 +298,8 @@ graph TB
298
298
  BASH_CALL --> EXECUTOR
299
299
  EXECUTOR --> PERM
300
300
  PERM --> PROMPT
301
- PROMPT -->|"allowed"| SANDBOX
302
- SANDBOX --> ENV_INJECT
301
+ PROMPT -->|"allowed"| SHELL
302
+ SHELL --> ENV_INJECT
303
303
  ENV_INJECT -->|"HTTP"| HTTP_FWD
304
304
  ENV_INJECT -->|"HTTPS CONNECT"| CONNECT
305
305
  CONNECT --> ROUTER
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
@@ -0,0 +1,111 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ isTrustCheckedScopeProfile,
5
+ routeAdmitsTrustClass,
6
+ tokenMayReachRoute,
7
+ TRUST_EXEMPT_SCOPE_PROFILES,
8
+ } from "../route-trust-class.js";
9
+
10
+ const CONTACT_ROUTE = ["guardian", "trusted_contact", "unverified_contact"];
11
+
12
+ const contactMayReach = (
13
+ allowed: readonly string[] | undefined,
14
+ resolve: () => Promise<string | undefined>,
15
+ ) => tokenMayReachRoute("contact_client_v1", allowed, resolve);
16
+
17
+ describe("routeAdmitsTrustClass", () => {
18
+ test("an absent list admits the guardian only", () => {
19
+ expect(routeAdmitsTrustClass(undefined, "guardian")).toBe(true);
20
+ expect(routeAdmitsTrustClass(undefined, "trusted_contact")).toBe(false);
21
+ expect(routeAdmitsTrustClass(undefined, "unknown")).toBe(false);
22
+ });
23
+
24
+ test("a listed class is admitted and an unlisted one refused", () => {
25
+ expect(routeAdmitsTrustClass(CONTACT_ROUTE, "trusted_contact")).toBe(true);
26
+ expect(routeAdmitsTrustClass(CONTACT_ROUTE, "unknown")).toBe(false);
27
+ });
28
+
29
+ test("a value outside the vocabulary is refused even when listed", () => {
30
+ expect(routeAdmitsTrustClass(["owner"], "owner")).toBe(false);
31
+ expect(routeAdmitsTrustClass(CONTACT_ROUTE, undefined)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("tokenMayReachRoute for a contact token", () => {
36
+ test("a route admitting only the guardian refuses without resolving", async () => {
37
+ const resolve = mock(async () => "trusted_contact");
38
+ expect(await contactMayReach(undefined, resolve)).toBe(false);
39
+ expect(await contactMayReach(["guardian"], resolve)).toBe(false);
40
+ expect(resolve).not.toHaveBeenCalled();
41
+ });
42
+
43
+ test.each([
44
+ ["trusted_contact", true],
45
+ ["unverified_contact", true],
46
+ ["unknown", false],
47
+ ["guardian", false],
48
+ [undefined, false],
49
+ ] as const)(
50
+ "a contact route resolving %p admits: %p",
51
+ async (trustClass, admitted) => {
52
+ expect(await contactMayReach(CONTACT_ROUTE, async () => trustClass)).toBe(
53
+ admitted,
54
+ );
55
+ },
56
+ );
57
+
58
+ test("a class the route does not list is refused", async () => {
59
+ expect(
60
+ await contactMayReach(
61
+ ["guardian", "trusted_contact"],
62
+ async () => "unverified_contact",
63
+ ),
64
+ ).toBe(false);
65
+ });
66
+
67
+ test("a resolver that throws refuses", async () => {
68
+ expect(
69
+ await contactMayReach(CONTACT_ROUTE, async () => {
70
+ throw new Error("gateway unreachable");
71
+ }),
72
+ ).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("isTrustCheckedScopeProfile", () => {
77
+ test("an exempt profile is not trust-checked", () => {
78
+ for (const profile of TRUST_EXEMPT_SCOPE_PROFILES) {
79
+ expect(isTrustCheckedScopeProfile(profile)).toBe(false);
80
+ }
81
+ });
82
+
83
+ test("the contact profile, unknown profiles and prototype keys are", () => {
84
+ for (const profile of [
85
+ "contact_client_v1",
86
+ "bogus_v1",
87
+ "toString",
88
+ "constructor",
89
+ "__proto__",
90
+ ]) {
91
+ expect(isTrustCheckedScopeProfile(profile)).toBe(true);
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("tokenMayReachRoute for a trust-exempt token", () => {
97
+ test.each([...TRUST_EXEMPT_SCOPE_PROFILES])(
98
+ "%s counts as the guardian without a lookup",
99
+ async (profile) => {
100
+ const resolve = mock(async () => "trusted_contact");
101
+ expect(await tokenMayReachRoute(profile, undefined, resolve)).toBe(true);
102
+ expect(await tokenMayReachRoute(profile, CONTACT_ROUTE, resolve)).toBe(
103
+ true,
104
+ );
105
+ expect(
106
+ await tokenMayReachRoute(profile, ["trusted_contact"], resolve),
107
+ ).toBe(false);
108
+ expect(resolve).not.toHaveBeenCalled();
109
+ },
110
+ );
111
+ });
@@ -56,8 +56,8 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
56
56
  *
57
57
  * `phone` is NOT exempt — voice ingress enforces the admission floor.
58
58
  *
59
- * `vellum` / `whatsapp` are NOT exempt — their floors are still enforced at
60
- * runtime — but they are hidden from the configurable UI; see
59
+ * `vellum` / `vellum-shared` / `whatsapp` are NOT exempt: their floors are
60
+ * still enforced at runtime, but they are hidden from the configurable UI; see
61
61
  * {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
62
62
  */
63
63
  export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
@@ -81,9 +81,12 @@ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
81
81
  * `vellum` is the local desktop/web client surface; the guardian is always
82
82
  * max-rank there, so the seed default admits them regardless of the floor.
83
83
  *
84
+ * `vellum-shared` carries conversations a guardian shares with a contact. Its
85
+ * seed default is `guardian_only`, which admits no contact.
84
86
  */
85
87
  export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
86
88
  "vellum",
89
+ "vellum-shared",
87
90
  "whatsapp",
88
91
  ]);
89
92
 
@@ -132,6 +132,15 @@ export type {
132
132
  TrustVerdict,
133
133
  } from "./trust-verdict-contract.js";
134
134
 
135
+ // Route trust class, applied by both the daemon router and the gateway IPC proxy
136
+ export {
137
+ DEFAULT_ROUTE_TRUST_CLASSES,
138
+ isTrustCheckedScopeProfile,
139
+ routeAdmitsTrustClass,
140
+ tokenMayReachRoute,
141
+ TRUST_EXEMPT_SCOPE_PROFILES,
142
+ } from "./route-trust-class.js";
143
+
135
144
  // Invite contract (shared gateway ↔ daemon) — hash/generate helpers,
136
145
  // channel gating, redemption outcome, method map + invite IPC schemas
137
146
  export {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Which trust classes a route admits, shared by the daemon's HTTP router and
3
+ * the gateway's IPC proxy so both enforcement points apply one rule.
4
+ *
5
+ * A route policy's `allowedTrustClasses` reaches the gateway on the IPC route
6
+ * schema as plain strings, and is absent from a null policy or a schema served
7
+ * by a daemon that predates the field. Absent means guardian only.
8
+ */
9
+
10
+ import { isTrustClass, type TrustClass } from "./trust-verdict-contract.js";
11
+
12
+ /**
13
+ * Scope profiles whose holders are not trust-checked: the guardian's own
14
+ * client, and service, local and single-route grants that route policy
15
+ * already governs. Any profile not listed here is trust-checked, so an
16
+ * unknown or newly added profile fails closed until it is judged exempt.
17
+ */
18
+ export const TRUST_EXEMPT_SCOPE_PROFILES: readonly string[] = [
19
+ "actor_client_v1",
20
+ "gateway_ingress_v1",
21
+ "gateway_service_v1",
22
+ "local_v1",
23
+ "oauth_proxy_v1",
24
+ "speech_relay_v1",
25
+ "ui_page_v1",
26
+ ];
27
+
28
+ const TRUST_EXEMPT_SET: ReadonlySet<string> = new Set(
29
+ TRUST_EXEMPT_SCOPE_PROFILES,
30
+ );
31
+
32
+ /**
33
+ * True when a token of `profile` reaches a route only if the route admits
34
+ * its holder's trust class.
35
+ */
36
+ export function isTrustCheckedScopeProfile(profile: string): boolean {
37
+ return !TRUST_EXEMPT_SET.has(profile);
38
+ }
39
+
40
+ /** Trust classes a route admits when its policy names none. */
41
+ export const DEFAULT_ROUTE_TRUST_CLASSES: readonly TrustClass[] = ["guardian"];
42
+
43
+ /**
44
+ * Whether a route admitting `allowedTrustClasses` admits a caller of
45
+ * `trustClass`. A value outside the vocabulary is refused, since a wire-sourced
46
+ * class can carry anything.
47
+ */
48
+ export function routeAdmitsTrustClass(
49
+ allowedTrustClasses: readonly string[] | undefined,
50
+ trustClass: string | undefined,
51
+ ): boolean {
52
+ if (typeof trustClass !== "string" || !isTrustClass(trustClass)) {
53
+ return false;
54
+ }
55
+ return (allowedTrustClasses ?? DEFAULT_ROUTE_TRUST_CLASSES).includes(
56
+ trustClass,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Whether a caller whose token carries `scopeProfile` may reach a route
62
+ * admitting `allowedTrustClasses`.
63
+ *
64
+ * A trust-exempt profile stands for the guardian, so it reaches exactly the
65
+ * routes that admit `guardian`, with no lookup. That is every route naming no
66
+ * classes.
67
+ *
68
+ * A trust-checked token never speaks for the guardian, so a route admitting no
69
+ * other class refuses it without calling `resolveTrustClass`. Otherwise the
70
+ * resolved class must be one the route admits and must not be `guardian`. A
71
+ * resolver that throws or resolves nothing refuses.
72
+ */
73
+ export async function tokenMayReachRoute(
74
+ scopeProfile: string,
75
+ allowedTrustClasses: readonly string[] | undefined,
76
+ resolveTrustClass: () => Promise<string | undefined>,
77
+ ): Promise<boolean> {
78
+ if (!isTrustCheckedScopeProfile(scopeProfile)) {
79
+ return routeAdmitsTrustClass(allowedTrustClasses, "guardian");
80
+ }
81
+ const allowed = allowedTrustClasses ?? DEFAULT_ROUTE_TRUST_CLASSES;
82
+ if (!allowed.some((trustClass) => trustClass !== "guardian")) {
83
+ return false;
84
+ }
85
+ let trustClass: string | undefined;
86
+ try {
87
+ trustClass = await resolveTrustClass();
88
+ } catch {
89
+ return false;
90
+ }
91
+ return (
92
+ trustClass !== "guardian" && routeAdmitsTrustClass(allowed, trustClass)
93
+ );
94
+ }
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",