@vellumai/assistant 0.10.9 → 0.10.10-dev.202607162206.d08e98e
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 +1 -1
- package/Dockerfile +8 -0
- package/docs/activation-funnel-telemetry.md +13 -7
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
- package/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
- package/openapi.yaml +530 -107
- package/package.json +1 -1
- package/scripts/generate-openapi.ts +8 -0
- package/src/__tests__/activation-early-marking.test.ts +6 -5
- package/src/__tests__/agent-loop-override-profile.test.ts +22 -25
- package/src/__tests__/agent-wake-override-profile.test.ts +21 -48
- package/src/__tests__/app-builder-tool-scripts.test.ts +0 -1
- package/src/__tests__/app-bundler.test.ts +4 -12
- package/src/__tests__/app-executors.test.ts +2 -49
- package/src/__tests__/app-routes-csp.test.ts +114 -146
- package/src/__tests__/auth-fallback-events-store.test.ts +8 -1
- package/src/__tests__/build-persisted-content.test.ts +96 -0
- package/src/__tests__/bundle-scanner.test.ts +27 -1
- package/src/__tests__/call-controller.test.ts +291 -0
- package/src/__tests__/call-site-routing-connection-auto-resolve.test.ts +165 -0
- package/src/__tests__/chat-credential-redaction.test.ts +1395 -0
- package/src/__tests__/chat-reveal-guard-priming.test.ts +791 -0
- package/src/__tests__/compaction.benchmark.test.ts +2 -1
- package/src/__tests__/compactor-image-manifest-trust.test.ts +50 -0
- package/src/__tests__/config-loader-backfill.test.ts +9 -4
- package/src/__tests__/config-schema-cmd.test.ts +10 -11
- package/src/__tests__/config-schema.test.ts +190 -257
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +208 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +5 -19
- package/src/__tests__/conversation-agent-loop.test.ts +22 -9
- package/src/__tests__/conversation-error.test.ts +31 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +110 -0
- package/src/__tests__/conversation-process-callsite.test.ts +12 -19
- package/src/__tests__/conversation-routes-slash-commands.test.ts +7 -21
- package/src/__tests__/conversation-summarize-route.test.ts +36 -44
- package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +7 -7
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +0 -1
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +0 -1
- package/src/__tests__/conversation-usage.test.ts +4 -12
- package/src/__tests__/credential-routes.test.ts +250 -0
- package/src/__tests__/credential-security-invariants.test.ts +3 -0
- package/src/__tests__/db-migration-rollback.test.ts +22 -0
- package/src/__tests__/empty-response-hook.test.ts +186 -1
- package/src/__tests__/external-plugin-loader.test.ts +11 -5
- package/src/__tests__/heartbeat-service.test.ts +0 -28
- package/src/__tests__/host-shell-tool.test.ts +2 -0
- package/src/__tests__/inactive-tool-error-messages.test.ts +2 -2
- package/src/__tests__/inference-no-mode-boot-e2e.test.ts +52 -8
- package/src/__tests__/internal-telemetry-routes.test.ts +23 -4
- package/src/__tests__/invite-routes-http.test.ts +12 -16
- package/src/__tests__/list-all-apps.test.ts +0 -4
- package/src/__tests__/llm-context-resolution.test.ts +32 -56
- package/src/__tests__/llm-request-log-turn-query.test.ts +109 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +3 -52
- package/src/__tests__/llm-resolver.test.ts +342 -602
- package/src/__tests__/llm-schema.test.ts +79 -37
- package/src/__tests__/max-tokens-continue-hook.test.ts +19 -0
- package/src/__tests__/media-stream-output.test.ts +259 -3
- package/src/__tests__/media-stream-server-integration.test.ts +22 -1
- package/src/__tests__/media-stream-stt-session.test.ts +47 -0
- package/src/__tests__/memory-jobs-worker-cleanup-cadence.test.ts +33 -0
- package/src/__tests__/memory-recall-log-store.test.ts +47 -13
- package/src/__tests__/mock-gateway-ipc.ts +46 -1
- package/src/__tests__/mtime-cache.test.ts +61 -0
- package/src/__tests__/navigate-settings-tab.test.ts +2 -0
- package/src/__tests__/normalize-onboarding.test.ts +33 -0
- package/src/__tests__/onboarding-persona-write.test.ts +26 -0
- package/src/__tests__/plugin-api-resolve-credential.test.ts +140 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +166 -14
- package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +2 -0
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +38 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +27 -20
- package/src/__tests__/provider-connections-backfill.test.ts +138 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +10 -31
- package/src/__tests__/provider-registry-ollama.test.ts +8 -18
- package/src/__tests__/provider-send-message-override-profile.test.ts +23 -23
- package/src/__tests__/provider-usage-tracking.test.ts +9 -19
- package/src/__tests__/prune-old-conversations-job.test.ts +12 -0
- package/src/__tests__/published-app-updater.test.ts +22 -16
- package/src/__tests__/registry.test.ts +5 -16
- package/src/__tests__/retry-openrouter-only-normalization.test.ts +22 -23
- package/src/__tests__/retry-thinking-adaptive-only.test.ts +43 -48
- package/src/__tests__/retry-thinking-tool-choice.test.ts +57 -66
- package/src/__tests__/retry-verbosity-normalization.test.ts +24 -23
- package/src/__tests__/reveal-success-registry.test.ts +123 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +130 -0
- package/src/__tests__/secret-fixtures.ts +9 -0
- package/src/__tests__/server-history-render.test.ts +28 -0
- package/src/__tests__/skills.test.ts +9 -4
- package/src/__tests__/slack-share-routes.test.ts +0 -1
- package/src/__tests__/stt-stream-session.test.ts +6 -5
- package/src/__tests__/subagent-call-site-routing.test.ts +69 -95
- package/src/__tests__/subagent-disposal.test.ts +2 -0
- package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
- package/src/__tests__/subagent-fork-spawn.test.ts +2 -0
- package/src/__tests__/subagent-manager-notify.test.ts +2 -0
- package/src/__tests__/subagent-role-registry.test.ts +37 -0
- package/src/__tests__/subagent-spawn-and-await.test.ts +1 -0
- package/src/__tests__/subagent-terminal-message.test.ts +50 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +78 -5
- package/src/__tests__/surface-completion-nudge-hook.test.ts +19 -0
- package/src/__tests__/telemetry-routes.test.ts +99 -19
- package/src/__tests__/tool-audit.test.ts +34 -4
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -1
- package/src/__tests__/tool-profiler.test.ts +72 -1
- package/src/__tests__/tool-result-spool.test.ts +49 -4
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +0 -1
- package/src/__tests__/ui-channel-variants.test.ts +108 -0
- package/src/__tests__/ui-shape-teaching.test.ts +255 -0
- package/src/__tests__/usage-attribution.test.ts +18 -41
- package/src/__tests__/user-plugin-loader.test.ts +4 -4
- package/src/__tests__/voice-config-update.test.ts +46 -0
- package/src/__tests__/voice-session-bridge.test.ts +216 -84
- package/src/__tests__/workspace-migration-131-drop-web-fetch-mode.test.ts +120 -0
- package/src/agent/loop.ts +4 -3
- package/src/api/events/open-conversation.test.ts +64 -0
- package/src/api/events/open-conversation.ts +33 -0
- package/src/api/index.ts +6 -0
- package/src/api/responses/conversation-message.ts +5 -0
- package/src/apps/app-store.ts +25 -35
- package/src/bundler/app-bundler.ts +34 -48
- package/src/bundler/app-compiler.ts +39 -4
- package/src/bundler/bundle-scanner.ts +13 -0
- package/src/bundler/manifest.ts +1 -1
- package/src/calls/__tests__/voice-session-bridge.test.ts +47 -0
- package/src/calls/call-constants.ts +5 -0
- package/src/calls/call-controller.ts +100 -32
- package/src/calls/call-transport.ts +9 -0
- package/src/calls/media-stream-output.ts +107 -4
- package/src/calls/media-stream-server.ts +29 -9
- package/src/calls/media-stream-stt-session.ts +7 -1
- package/src/calls/media-turn-detector.ts +11 -1
- package/src/calls/voice-session-bridge.ts +84 -63
- package/src/cli/commands/__tests__/inference-providers.test.ts +270 -33
- package/src/cli/commands/__tests__/notifications.test.ts +24 -3
- package/src/cli/commands/config.help.ts +6 -6
- package/src/cli/commands/credentials.help.ts +13 -0
- package/src/cli/commands/credentials.ts +6 -1
- package/src/cli/commands/email.help.ts +7 -0
- package/src/cli/commands/email.ts +35 -1
- package/src/cli/commands/inference-providers.ts +168 -107
- package/src/cli/commands/inference.help.ts +118 -46
- package/src/cli/commands/memory/index.help.ts +23 -0
- package/src/cli/commands/memory/nodes.ts +146 -0
- package/src/cli/commands/notifications.help.ts +10 -10
- package/src/cli/commands/oauth/connect-surface-guidance.test.ts +40 -0
- package/src/cli/commands/oauth/connect-surface-guidance.ts +54 -0
- package/src/cli/commands/oauth/connect.test.ts +126 -0
- package/src/cli/commands/oauth/connect.ts +29 -6
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/status.test.ts +69 -3
- package/src/cli/commands/oauth/status.ts +50 -12
- package/src/cli/commands/plugins.help.ts +13 -2
- package/src/cli/commands/plugins.ts +61 -7
- package/src/cli/commands/telemetry.help.ts +13 -0
- package/src/cli/commands/telemetry.ts +45 -5
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +8 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +213 -9
- package/src/cli/lib/bundled-marketplace.json +93 -36
- package/src/cli/lib/inspect-plugin.ts +5 -1
- package/src/cli/lib/install-from-github.ts +68 -4
- package/src/cli/lib/plugin-catalog-local.ts +6 -2
- package/src/cli/lib/plugin-fingerprint.ts +3 -3
- package/src/cli/lib/upgrade-plugin.ts +236 -35
- package/src/config/__tests__/default-profile-catalog.test.ts +8 -12
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +137 -0
- package/src/config/__tests__/profile-materialization.test.ts +1 -88
- package/src/config/bundled-skills/AGENTS.md +3 -30
- package/src/config/bundled-skills/app-builder/SKILL.md +5 -3
- package/src/config/bundled-skills/app-builder/TOOLS.json +23 -0
- package/src/config/bundled-skills/app-builder/tools/app-open.ts +32 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +1 -1
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +7 -7
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/bundled-skills/schedule/TOOLS.json +1 -1
- package/src/config/bundled-skills/settings/TOOLS.json +3 -1
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +2 -0
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +18 -10
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +1 -1
- package/src/config/call-site-defaults.ts +3 -3
- package/src/config/feature-flag-registry.json +24 -39
- package/src/config/llm-resolver.ts +88 -475
- package/src/config/profile-materialization.ts +11 -11
- package/src/config/schema.ts +93 -127
- package/src/config/schemas/__tests__/live-voice.test.ts +24 -6
- package/src/config/schemas/__tests__/stt.test.ts +31 -3
- package/src/config/schemas/live-voice.ts +10 -4
- package/src/config/schemas/llm.ts +52 -15
- package/src/config/schemas/memory-lifecycle.ts +1 -1
- package/src/config/schemas/memory-retrospective.ts +8 -0
- package/src/config/schemas/memory-v2.ts +2 -2
- package/src/config/schemas/memory-v3.ts +1 -1
- package/src/config/schemas/services.ts +6 -3
- package/src/config/schemas/stt.ts +38 -21
- package/src/config/schemas/tts.ts +13 -18
- package/src/config/skills.ts +31 -21
- package/src/context/compactor.ts +44 -7
- package/src/context/post-turn-tool-result-truncation.ts +4 -2
- package/src/context/tool-result-spool.ts +26 -5
- package/src/conversations/__tests__/message-consolidation.test.ts +48 -0
- package/src/conversations/message-consolidation.ts +22 -2
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +5 -12
- package/src/daemon/app-source-watcher.ts +17 -23
- package/src/daemon/chat-credential-redaction.ts +1365 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +627 -34
- package/src/daemon/conversation-agent-loop.ts +71 -31
- package/src/daemon/conversation-error.ts +36 -14
- package/src/daemon/conversation-process.ts +22 -0
- package/src/daemon/conversation-store.ts +35 -0
- package/src/daemon/conversation-surfaces.ts +25 -25
- package/src/daemon/conversation-tool-setup.ts +33 -7
- package/src/daemon/conversation.ts +55 -5
- package/src/daemon/handlers/shared.ts +33 -2
- package/src/daemon/lifecycle.ts +4 -4
- package/src/daemon/message-types/conversations.ts +6 -17
- package/src/daemon/providers-setup.ts +8 -0
- package/src/daemon/tool-setup-types.ts +7 -1
- package/src/daemon/wake-conversation-ops.ts +10 -1
- package/src/hooks/types.ts +9 -0
- package/src/ipc/__tests__/email-ipc.test.ts +90 -0
- package/src/ipc/gateway-client.test.ts +59 -0
- package/src/ipc/gateway-client.ts +70 -27
- package/src/live-voice/__tests__/live-voice-events.test.ts +14 -2
- package/src/live-voice/__tests__/live-voice-integration.test.ts +116 -2
- package/src/live-voice/__tests__/live-voice-vad.test.ts +804 -13
- package/src/live-voice/__tests__/protocol.test.ts +122 -0
- package/src/live-voice/live-voice-session.ts +443 -40
- package/src/live-voice/protocol.ts +143 -1
- package/src/monitoring/__tests__/plugin-source-watch.test.ts +3 -1
- package/src/monitoring/plugin-source-watch.ts +3 -62
- package/src/notifications/README.md +1 -1
- package/src/permissions/checker.ts +8 -4
- package/src/persistence/__tests__/db-init-migrations-ok.test.ts +26 -0
- package/src/persistence/conversation-crud.ts +104 -2
- package/src/persistence/db-init.ts +14 -3
- package/src/persistence/job-handlers/cleanup.ts +15 -7
- package/src/persistence/llm-request-log-store.ts +88 -52
- package/src/persistence/migrations/298-move-memory-jobs-to-memory-db.ts +7 -31
- package/src/persistence/migrations/305-drop-contact-acl-columns.ts +3 -2
- package/src/persistence/migrations/326-move-injection-events-to-memory-db.ts +8 -34
- package/src/persistence/migrations/336-move-memory-v2-activation-logs-to-memory-db.ts +90 -0
- package/src/persistence/migrations/337-move-memory-recall-logs-to-memory-db.ts +114 -0
- package/src/persistence/migrations/338-move-memory-v3-selections-to-memory-db.ts +84 -0
- package/src/persistence/migrations/339-move-activation-sessions-to-memory-db.ts +52 -0
- package/src/persistence/migrations/__tests__/run-migrations.test.ts +155 -0
- package/src/persistence/migrations/helpers/relocation.ts +44 -1
- package/src/persistence/migrations/run-migrations.ts +25 -1
- package/src/persistence/schema/infrastructure.ts +9 -0
- package/src/persistence/schema/memory-core.ts +3 -0
- package/src/persistence/schema/memory-injection.ts +2 -0
- package/src/persistence/steps.ts +36 -0
- package/src/platform/client.test.ts +1 -44
- package/src/platform/client.ts +10 -20
- package/src/platform/consent-cache.test.ts +89 -23
- package/src/platform/consent-cache.ts +71 -33
- package/src/plugin-api/constants.ts +12 -0
- package/src/plugin-api/conversation-turn.ts +37 -14
- package/src/plugin-api/index.ts +11 -1
- package/src/plugin-api/resolve-credential.ts +75 -0
- package/src/plugin-api/vision-support.test.ts +8 -19
- package/src/plugin-api/vision-support.ts +26 -17
- package/src/plugins/collect-source-versions.ts +77 -0
- package/src/plugins/defaults/compaction/compact.ts +6 -0
- package/src/plugins/defaults/compaction/window-manager.ts +9 -0
- package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +18 -24
- package/src/plugins/defaults/empty-response/hooks/user-prompt-submit.ts +38 -0
- package/src/plugins/defaults/empty-response/refusal-quarantine.ts +99 -0
- package/src/plugins/defaults/image-fallback/__tests__/caption-cache-persistence.test.ts +7 -3
- package/src/plugins/defaults/index.ts +8 -1
- package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/activation-session-store.test.ts +48 -6
- package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +28 -22
- package/src/plugins/defaults/memory/__tests__/memory-log-stores-degraded.test.ts +148 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +80 -8
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +202 -0
- package/src/plugins/defaults/memory/__tests__/memory-v2-activation-log-store.test.ts +64 -25
- package/src/plugins/defaults/memory/__tests__/memory-v2-concept-frequency.test.ts +24 -11
- package/src/plugins/defaults/memory/__tests__/prompt-override.test.ts +70 -6
- package/src/plugins/defaults/memory/__tests__/table-relocation.test.ts +278 -0
- package/src/plugins/defaults/memory/activation-session-store.ts +27 -20
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -9
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +1 -8
- package/src/plugins/defaults/memory/graph/retriever.test.ts +6 -6
- package/src/plugins/defaults/memory/graph/store.ts +114 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +36 -1
- package/src/plugins/defaults/memory/graph/tools.ts +47 -13
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +16 -35
- package/src/plugins/defaults/memory/jobs-worker.ts +10 -1
- package/src/plugins/defaults/memory/memory-db.ts +3 -2
- package/src/plugins/defaults/memory/memory-recall-log-store.ts +129 -66
- package/src/plugins/defaults/memory/memory-retrospective-constants.ts +8 -0
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +11 -122
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +216 -0
- package/src/plugins/defaults/memory/memory-v2-activation-log-store.ts +84 -46
- package/src/plugins/defaults/memory/memory-v2-concept-frequency.ts +42 -32
- package/src/plugins/defaults/memory/path-containment.ts +21 -0
- package/src/plugins/defaults/memory/prompt-override.ts +51 -13
- package/src/plugins/defaults/memory/tools.test.ts +34 -0
- package/src/plugins/defaults/memory/tools.ts +12 -2
- package/src/plugins/defaults/memory/v2/__tests__/harness-compare.test.ts +19 -15
- package/src/plugins/defaults/memory/v2/__tests__/harness-oracle.test.ts +24 -19
- package/src/plugins/defaults/memory/v2/__tests__/harness-replay-input.test.ts +19 -15
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +22 -2
- package/src/plugins/defaults/memory/v2/__tests__/prompts-consolidation.test.ts +5 -3
- package/src/plugins/defaults/memory/v2/harness/oracle.ts +59 -41
- package/src/plugins/defaults/memory/v2/harness/replay-input.ts +29 -25
- package/src/plugins/defaults/memory/v2/migration.ts +46 -16
- package/src/plugins/defaults/memory/v2/prompts/consolidation.ts +4 -0
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +17 -15
- package/src/plugins/defaults/memory/v3/__tests__/gate.test.ts +24 -22
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +9 -5
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +277 -10
- package/src/plugins/defaults/memory/v3/__tests__/selection-log-store.test.ts +57 -5
- package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +9 -7
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +38 -7
- package/src/plugins/defaults/memory/v3/hot-set.test.ts +36 -18
- package/src/plugins/defaults/memory/v3/hot-set.ts +12 -14
- package/src/plugins/defaults/memory/v3/learned-edges.test.ts +47 -27
- package/src/plugins/defaults/memory/v3/learned-edges.ts +12 -14
- package/src/plugins/defaults/memory/v3/orchestrate.ts +139 -22
- package/src/plugins/defaults/memory/v3/prune.test.ts +10 -3
- package/src/plugins/defaults/memory/v3/prune.ts +17 -11
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +23 -11
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +70 -53
- package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +9 -6
- package/src/plugins/external-plugin-loader.ts +15 -15
- package/src/plugins/mtime-cache.ts +47 -0
- package/src/plugins/pipeline.ts +9 -1
- package/src/plugins/plugin-execution-context.ts +44 -0
- package/src/plugins/plugin-tree-walk.ts +31 -24
- package/src/plugins/source-fingerprint.ts +3 -4
- package/src/prompts/normalize-onboarding.ts +12 -0
- package/src/prompts/persona-resolver.ts +8 -0
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +6 -9
- package/src/providers/__tests__/registry-native-web-search.test.ts +4 -10
- package/src/providers/__tests__/retry-callsite.test.ts +235 -215
- package/src/providers/__tests__/satellite-connection-routing.test.ts +8 -16
- package/src/providers/atlascloud/client.ts +10 -49
- package/src/providers/baseten/client.ts +43 -0
- package/src/providers/call-site-routing.ts +26 -13
- package/src/providers/connection-resolution.ts +9 -11
- package/src/providers/fetch-provider-catalog.ts +4 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +29 -4
- package/src/providers/inference/__tests__/connection-availability-keyless.test.ts +78 -0
- package/src/providers/inference/adapter-factory.ts +27 -6
- package/src/providers/inference/auth.ts +29 -1
- package/src/providers/inference/backfill.ts +61 -10
- package/src/providers/inference/connection-availability.ts +3 -2
- package/src/providers/inference/resolve-auth.ts +9 -1
- package/src/providers/model-catalog.ts +37 -0
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +24 -2
- package/src/providers/openai/api-key-validation.ts +70 -0
- package/src/providers/retry.ts +2 -0
- package/src/providers/types.ts +11 -4
- package/src/providers/vellum-model-routing.test.ts +26 -0
- package/src/providers/vellum-model-routing.ts +30 -0
- package/src/providers/voice-error-copy.ts +47 -0
- package/src/runtime/agent-wake.ts +40 -24
- package/src/runtime/for-chat-mint-registry.ts +118 -0
- package/src/runtime/reveal-nonce.ts +49 -0
- package/src/runtime/reveal-success-registry.ts +306 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +88 -3
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +161 -30
- package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +7 -0
- package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +15 -0
- package/src/runtime/routes/app-management-routes.ts +152 -45
- package/src/runtime/routes/app-routes.ts +16 -77
- package/src/runtime/routes/canned-message-complete.ts +16 -16
- package/src/runtime/routes/conversation-management-routes.ts +17 -14
- package/src/runtime/routes/conversation-query-routes.ts +54 -6
- package/src/runtime/routes/conversation-routes.ts +48 -2
- package/src/runtime/routes/credential-routes.ts +116 -3
- package/src/runtime/routes/email-routes.ts +17 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +33 -5
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +6 -5
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -28
- package/src/runtime/routes/inference-send-routes.ts +1 -1
- package/src/runtime/routes/internal-telemetry-routes.ts +7 -6
- package/src/runtime/routes/plugins-routes.ts +37 -6
- package/src/runtime/routes/publish-routes.ts +15 -18
- package/src/runtime/routes/schedule-worker-routes.ts +9 -0
- package/src/runtime/routes/secret-routes.ts +10 -0
- package/src/runtime/routes/telemetry-routes.ts +149 -45
- package/src/schedule/__tests__/schedule-timezone.test.ts +101 -0
- package/src/schedule/__tests__/worker-watchdog.test.ts +209 -0
- package/src/schedule/schedule-store.ts +12 -1
- package/src/schedule/schedule-timezone.ts +63 -0
- package/src/schedule/scheduler.ts +100 -6
- package/src/schedule/worker-control.ts +19 -0
- package/src/security/auth-fallback-events-store.ts +7 -6
- package/src/security/secret-scanner.ts +26 -1
- package/src/services/published-app-updater.ts +6 -11
- package/src/stt/stt-stream-session.ts +36 -16
- package/src/subagent/manager.ts +34 -8
- package/src/telemetry/AGENTS.md +30 -1
- package/src/telemetry/__tests__/config-setting-snapshot.test.ts +18 -0
- package/src/telemetry/__tests__/outbox-test-harness.ts +5 -3
- package/src/telemetry/config-setting-snapshot.ts +44 -10
- package/src/telemetry/telemetry-event-sources.test.ts +124 -30
- package/src/telemetry/telemetry-event-sources.ts +137 -83
- package/src/telemetry/telemetry-events-outbox.test.ts +46 -1
- package/src/telemetry/telemetry-events-outbox.ts +60 -9
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire-validation.ts +39 -2
- package/src/telemetry/telemetry-wire.generated.ts +8 -0
- package/src/telemetry/tool-audit.ts +15 -9
- package/src/telemetry/tool-executed-events-store.test.ts +1 -1
- package/src/telemetry/turn-events-store.ts +20 -0
- package/src/telemetry/types.ts +45 -12
- package/src/telemetry/usage-telemetry-reporter.test.ts +294 -25
- package/src/telemetry/usage-telemetry-reporter.ts +117 -18
- package/src/telemetry/watchdog-direct-emit.test.ts +11 -3
- package/src/telemetry/watchdog-direct-emit.ts +11 -6
- package/src/tools/apps/executors.ts +11 -36
- package/src/tools/executor.ts +14 -1
- package/src/tools/host-terminal/host-shell.ts +6 -2
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +1 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +25 -0
- package/src/tools/network/web-fetch.ts +6 -2
- package/src/tools/skills/sandbox-runner.ts +5 -2
- package/src/tools/subagent/spawn.ts +7 -11
- package/src/tools/terminal/shell.ts +5 -2
- package/src/tools/tool-manifest.ts +0 -2
- package/src/tools/tool-profiler.ts +37 -6
- package/src/tools/ui-surface/channel-variants.ts +101 -0
- package/src/tools/ui-surface/definitions.ts +23 -67
- package/src/tools/ui-surface/surface-shape-docs.ts +229 -0
- package/src/tts/__tests__/provider-adapters.test.ts +18 -0
- package/src/tts/provider-catalog.ts +2 -4
- package/src/tts/providers/deepgram-provider.ts +10 -1
- package/src/types/onboarding-context.ts +8 -0
- package/src/usage/attribution.ts +18 -112
- package/src/{config/bundled-skills/messaging/tools/gmail-mime-helpers.ts → util/mime-type.ts} +4 -1
- package/src/util/provider-error-patterns.ts +7 -1
- package/src/util/worker-process.ts +105 -0
- package/src/watcher/__tests__/telemetry.test.ts +17 -4
- package/src/watcher/telemetry.ts +5 -5
- package/src/workspace/migrations/131-drop-web-fetch-mode.ts +61 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/workspace/provider-commit-message-generator.ts +6 -4
- package/src/__tests__/app-open-proxy.test.ts +0 -67
- package/src/onboarding/onboarding-research-events-store.test.ts +0 -230
- package/src/onboarding/onboarding-research-events-store.ts +0 -104
- package/src/runtime/routes/assets/vellum-design-system.css +0 -2236
- package/src/tools/apps/definitions.ts +0 -73
- package/src/tools/apps/open-proxy.ts +0 -43
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the chat-persist sentinel redaction path (LUM-2768).
|
|
3
|
+
*
|
|
4
|
+
* Covers the three stages independently: reveal-command parsing (pure
|
|
5
|
+
* string work), candidate resolution (mocked credential store), and the
|
|
6
|
+
* byte-match sentinel substitution. The failure-direction invariant is the
|
|
7
|
+
* core assertion set: anything short of an exact plaintext match must
|
|
8
|
+
* degrade to the plain (non-revealable) sentinel, never a mislabeled
|
|
9
|
+
* enriched one.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const SECRET_STORE: Record<string, string | null> = {};
|
|
14
|
+
|
|
15
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
16
|
+
getSecureKeyAsync: async (key: string) => SECRET_STORE[key] ?? null,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
mock.module("../tools/credentials/metadata-store.js", () => ({
|
|
20
|
+
getCredentialMetadataById: (id: string) =>
|
|
21
|
+
id === "11111111-2222-4333-8444-555555555555"
|
|
22
|
+
? { service: "openai", field: "api_key" }
|
|
23
|
+
: undefined,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// ── Imports (after mocks) ────────────────────────────────────────────────
|
|
27
|
+
import { neutralizeRedactedSentinels } from "@vellumai/service-contracts/redacted-credential";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
buildForChatSentinel,
|
|
31
|
+
buildLiveRevealGuardEntries,
|
|
32
|
+
collectRevealRefsFromCommand,
|
|
33
|
+
drainCandidateGuardedChunk,
|
|
34
|
+
drainSentinelGuardedText,
|
|
35
|
+
filterRefsByRevealProof,
|
|
36
|
+
guardForChatSentinels,
|
|
37
|
+
redactCandidateValuesLegacy,
|
|
38
|
+
redactSecretsForChat,
|
|
39
|
+
remintAuthoritiesFromCandidates,
|
|
40
|
+
resolveProvenRevealCandidates,
|
|
41
|
+
resolveRefIdentities,
|
|
42
|
+
resolveRevealCandidates,
|
|
43
|
+
swapLiveRevealValues,
|
|
44
|
+
} from "../daemon/chat-credential-redaction.js";
|
|
45
|
+
import {
|
|
46
|
+
currentForChatMintWatermark,
|
|
47
|
+
forChatMintsSince,
|
|
48
|
+
recordForChatMint,
|
|
49
|
+
resetForChatMintRegistryForTest,
|
|
50
|
+
} from "../runtime/for-chat-mint-registry.js";
|
|
51
|
+
import {
|
|
52
|
+
_resetRevealSuccessRegistryForTest,
|
|
53
|
+
openRevealProofWindow,
|
|
54
|
+
recordRevealSuccess,
|
|
55
|
+
} from "../runtime/reveal-success-registry.js";
|
|
56
|
+
import { redactSecrets } from "../security/secret-scanner.js";
|
|
57
|
+
import {
|
|
58
|
+
OPENAI_PROJECT_KEY_REDACTION_MARKER,
|
|
59
|
+
SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
60
|
+
} from "./secret-fixtures.js";
|
|
61
|
+
|
|
62
|
+
const UUID = "11111111-2222-4333-8444-555555555555";
|
|
63
|
+
|
|
64
|
+
describe("collectRevealRefsFromCommand", () => {
|
|
65
|
+
test("parses --service/--field flags", () => {
|
|
66
|
+
expect(
|
|
67
|
+
collectRevealRefsFromCommand(
|
|
68
|
+
"assistant credentials reveal --service openai --field api_key",
|
|
69
|
+
),
|
|
70
|
+
).toEqual([{ service: "openai", field: "api_key" }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("parses = and quoted flag values", () => {
|
|
74
|
+
expect(
|
|
75
|
+
collectRevealRefsFromCommand(
|
|
76
|
+
`assistant credentials reveal --service="github-app" --field 'pem'`,
|
|
77
|
+
),
|
|
78
|
+
).toEqual([{ service: "github-app", field: "pem" }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("parses a positional UUID", () => {
|
|
82
|
+
expect(
|
|
83
|
+
collectRevealRefsFromCommand(`assistant credentials reveal ${UUID}`),
|
|
84
|
+
).toEqual([{ id: UUID }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("splits compound commands so flags cannot bleed across invocations", () => {
|
|
88
|
+
expect(
|
|
89
|
+
collectRevealRefsFromCommand(
|
|
90
|
+
"assistant credentials reveal --service a --field b && assistant credentials reveal --service c --field d",
|
|
91
|
+
),
|
|
92
|
+
).toEqual([
|
|
93
|
+
{ service: "a", field: "b" },
|
|
94
|
+
{ service: "c", field: "d" },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("splits on a single & background separator", () => {
|
|
99
|
+
expect(
|
|
100
|
+
collectRevealRefsFromCommand(
|
|
101
|
+
"assistant credentials reveal --service a --field b & assistant credentials reveal --service c --field d",
|
|
102
|
+
),
|
|
103
|
+
).toEqual([
|
|
104
|
+
{ service: "a", field: "b" },
|
|
105
|
+
{ service: "c", field: "d" },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("does not treat && as two single-& separators", () => {
|
|
110
|
+
// The two-char operator must win at its position; a spurious empty split
|
|
111
|
+
// between the ampersands would drop the second invocation's flags.
|
|
112
|
+
expect(
|
|
113
|
+
collectRevealRefsFromCommand(
|
|
114
|
+
"assistant credentials reveal --service a --field b&&assistant credentials reveal --service c --field d",
|
|
115
|
+
),
|
|
116
|
+
).toEqual([
|
|
117
|
+
{ service: "a", field: "b" },
|
|
118
|
+
{ service: "c", field: "d" },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a quoted separator inside a flag value does not split the invocation", () => {
|
|
123
|
+
// Service/field identifiers are arbitrary strings; a shell separator
|
|
124
|
+
// inside quotes is part of the value. Cutting there would orphan the
|
|
125
|
+
// flags across two broken segments and stage nothing — and an
|
|
126
|
+
// unstageable reveal's opaque output could stream or persist raw.
|
|
127
|
+
expect(
|
|
128
|
+
collectRevealRefsFromCommand(
|
|
129
|
+
"assistant credentials reveal --service 'R&D' --field token",
|
|
130
|
+
),
|
|
131
|
+
).toEqual([{ service: "R&D", field: "token" }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("quoted separators cannot swallow a following invocation", () => {
|
|
135
|
+
expect(
|
|
136
|
+
collectRevealRefsFromCommand(
|
|
137
|
+
'assistant credentials reveal --service "a&&b;c" --field x && assistant credentials reveal --service d --field e',
|
|
138
|
+
),
|
|
139
|
+
).toEqual([
|
|
140
|
+
{ service: "a&&b;c", field: "x" },
|
|
141
|
+
{ service: "d", field: "e" },
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("unescapes backslash-escaped characters in unquoted flag values", () => {
|
|
146
|
+
// The shell strips escaping before the CLI sees argv, so the reveal
|
|
147
|
+
// route records proof under the UNESCAPED identity — the parse must
|
|
148
|
+
// agree or the staged ref can never match its proof.
|
|
149
|
+
expect(
|
|
150
|
+
collectRevealRefsFromCommand(
|
|
151
|
+
"assistant credentials reveal --service foo\\ bar --field token",
|
|
152
|
+
),
|
|
153
|
+
).toEqual([{ service: "foo bar", field: "token" }]);
|
|
154
|
+
expect(
|
|
155
|
+
collectRevealRefsFromCommand(
|
|
156
|
+
"assistant credentials reveal --service a\\&b --field x",
|
|
157
|
+
),
|
|
158
|
+
).toEqual([{ service: "a&b", field: "x" }]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("unescapes POSIX-escapable characters inside double-quoted values", () => {
|
|
162
|
+
expect(
|
|
163
|
+
collectRevealRefsFromCommand(
|
|
164
|
+
'assistant credentials reveal --service "a\\"b" --field x',
|
|
165
|
+
),
|
|
166
|
+
).toEqual([{ service: 'a"b', field: "x" }]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("stages every reveal invocation within one segment", () => {
|
|
170
|
+
// Command substitution nests several reveals in ONE segment; each must
|
|
171
|
+
// stage its own ref — parsing only the first flag pair would leave the
|
|
172
|
+
// later identities unstaged even though the route proves them.
|
|
173
|
+
expect(
|
|
174
|
+
collectRevealRefsFromCommand(
|
|
175
|
+
'echo "$(assistant credentials reveal --service a --field token)" "$(assistant credentials reveal --service b --field token)"',
|
|
176
|
+
),
|
|
177
|
+
).toEqual([
|
|
178
|
+
{ service: "a", field: "token" },
|
|
179
|
+
{ service: "b", field: "token" },
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a substitution's closing paren does not glue onto the parsed value", () => {
|
|
184
|
+
expect(
|
|
185
|
+
collectRevealRefsFromCommand(
|
|
186
|
+
'KEY="$(assistant credentials reveal --service openai --field api_key)"',
|
|
187
|
+
),
|
|
188
|
+
).toEqual([{ service: "openai", field: "api_key" }]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("stages two UUID reveals nested in one segment", () => {
|
|
192
|
+
const other = "99999999-9999-4999-8999-999999999999";
|
|
193
|
+
expect(
|
|
194
|
+
collectRevealRefsFromCommand(
|
|
195
|
+
`echo "$(assistant credentials reveal ${UUID})" "$(assistant credentials reveal ${other})"`,
|
|
196
|
+
),
|
|
197
|
+
).toEqual([{ id: UUID }, { id: other }]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("ignores commands without a reveal invocation", () => {
|
|
201
|
+
expect(collectRevealRefsFromCommand("echo hello")).toEqual([]);
|
|
202
|
+
expect(collectRevealRefsFromCommand("assistant credentials list")).toEqual(
|
|
203
|
+
[],
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("unparseable invocation yields no ref (fails safe)", () => {
|
|
208
|
+
expect(
|
|
209
|
+
collectRevealRefsFromCommand("assistant credentials reveal --service x"),
|
|
210
|
+
).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("resolveRevealCandidates", () => {
|
|
215
|
+
test("resolves service/field refs via the scoped store read", async () => {
|
|
216
|
+
SECRET_STORE["credential/openai/api_key"] = SYNTHETIC_OPENAI_PROJECT_KEY;
|
|
217
|
+
const out = await resolveRevealCandidates([
|
|
218
|
+
{ service: "openai", field: "api_key" },
|
|
219
|
+
]);
|
|
220
|
+
expect(out).toEqual([
|
|
221
|
+
{
|
|
222
|
+
service: "openai",
|
|
223
|
+
field: "api_key",
|
|
224
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("resolves a UUID ref through metadata and dedupes with the flag ref", async () => {
|
|
230
|
+
SECRET_STORE["credential/openai/api_key"] = SYNTHETIC_OPENAI_PROJECT_KEY;
|
|
231
|
+
const out = await resolveRevealCandidates([
|
|
232
|
+
{ id: UUID },
|
|
233
|
+
{ service: "openai", field: "api_key" },
|
|
234
|
+
]);
|
|
235
|
+
expect(out).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("drops unknown ids and missing secrets", async () => {
|
|
239
|
+
delete SECRET_STORE["credential/gone/api_key"];
|
|
240
|
+
const out = await resolveRevealCandidates([
|
|
241
|
+
{ id: "99999999-9999-4999-8999-999999999999" },
|
|
242
|
+
{ service: "gone", field: "api_key" },
|
|
243
|
+
]);
|
|
244
|
+
expect(out).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("keeps both values when the same credential was rotated and re-revealed", async () => {
|
|
248
|
+
// reveal v1 → `credentials set` → reveal v2 inside one turn: the route
|
|
249
|
+
// served BOTH plaintexts to the tool's stdout, so both must survive as
|
|
250
|
+
// candidates. Deduping on identity alone would drop one and let it
|
|
251
|
+
// stream/persist raw whenever the scanner cannot classify it.
|
|
252
|
+
const out = await resolveRevealCandidates([
|
|
253
|
+
{
|
|
254
|
+
service: "openai",
|
|
255
|
+
field: "api_key",
|
|
256
|
+
provenValue: "hunter2-rotated-alpha",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
service: "openai",
|
|
260
|
+
field: "api_key",
|
|
261
|
+
provenValue: "hunter2-rotated-beta",
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
expect(out).toEqual([
|
|
265
|
+
{ service: "openai", field: "api_key", value: "hunter2-rotated-alpha" },
|
|
266
|
+
{ service: "openai", field: "api_key", value: "hunter2-rotated-beta" },
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("still dedupes proven refs that agree on identity AND value", async () => {
|
|
271
|
+
const out = await resolveRevealCandidates([
|
|
272
|
+
{ service: "openai", field: "api_key", provenValue: "hunter2-same" },
|
|
273
|
+
{ service: "openai", field: "api_key", provenValue: "hunter2-same" },
|
|
274
|
+
]);
|
|
275
|
+
expect(out).toHaveLength(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a value with JSON-escapable bytes also yields its escaped encoding", async () => {
|
|
279
|
+
// `credentials reveal --json` prints the value through JSON.stringify,
|
|
280
|
+
// so newlines/quotes/backslashes reach stdout ESCAPED — a representation
|
|
281
|
+
// the raw exact-match list can never find. Both encodings must be
|
|
282
|
+
// candidates.
|
|
283
|
+
const out = await resolveRevealCandidates([
|
|
284
|
+
{ service: "github-app", field: "pem", provenValue: "line1\nline2" },
|
|
285
|
+
]);
|
|
286
|
+
expect(out).toEqual([
|
|
287
|
+
{ service: "github-app", field: "pem", value: "line1\nline2" },
|
|
288
|
+
{ service: "github-app", field: "pem", value: "line1\\nline2" },
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("drops values below the exact-match length floor", async () => {
|
|
293
|
+
// The fallback global-replaces every occurrence of a candidate's bytes
|
|
294
|
+
// for the rest of the turn; a trivial value like `ok` would shred
|
|
295
|
+
// unrelated text into markers, and below the floor the value carries
|
|
296
|
+
// no meaningful entropy to protect anyway.
|
|
297
|
+
const out = await resolveRevealCandidates([
|
|
298
|
+
{ service: "svc", field: "f", provenValue: "ok" },
|
|
299
|
+
{ service: "svc", field: "g", provenValue: "12345" },
|
|
300
|
+
{ service: "svc", field: "h", provenValue: "123456" },
|
|
301
|
+
]);
|
|
302
|
+
expect(out).toEqual([{ service: "svc", field: "h", value: "123456" }]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a value with no escapable bytes yields a single candidate", async () => {
|
|
306
|
+
const out = await resolveRevealCandidates([
|
|
307
|
+
{ service: "svc", field: "f", provenValue: "hunter2-plain" },
|
|
308
|
+
]);
|
|
309
|
+
expect(out).toHaveLength(1);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("resolveRefIdentities (eager staging lookup)", () => {
|
|
314
|
+
test("resolves a UUID ref to service/field and keeps the id", () => {
|
|
315
|
+
expect(resolveRefIdentities([{ id: UUID }])).toEqual([
|
|
316
|
+
{ id: UUID, service: "openai", field: "api_key" },
|
|
317
|
+
]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("passes an unknown id through for the proof-time fallback", () => {
|
|
321
|
+
const ref = { id: "99999999-9999-4999-8999-999999999999" };
|
|
322
|
+
expect(resolveRefIdentities([ref])).toEqual([ref]);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("filterRefsByRevealProof identity handling", () => {
|
|
327
|
+
test("a pre-resolved id ref stays proven after its metadata disappears", () => {
|
|
328
|
+
// Staging resolved the id to service/field while the metadata still
|
|
329
|
+
// existed; the credential was then removed before the tool result. The
|
|
330
|
+
// carried identity must satisfy the proof — a metadata re-lookup would
|
|
331
|
+
// fail and silently drop the ref for a value the tool already printed.
|
|
332
|
+
_resetRevealSuccessRegistryForTest();
|
|
333
|
+
openRevealProofWindow();
|
|
334
|
+
recordRevealSuccess(
|
|
335
|
+
"gone-service",
|
|
336
|
+
"api_key",
|
|
337
|
+
"hunter2-removed",
|
|
338
|
+
"nonce-test",
|
|
339
|
+
);
|
|
340
|
+
const proven = filterRefsByRevealProof(
|
|
341
|
+
[
|
|
342
|
+
{
|
|
343
|
+
id: "99999999-9999-4999-8999-999999999999",
|
|
344
|
+
service: "gone-service",
|
|
345
|
+
field: "api_key",
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
0,
|
|
349
|
+
"nonce-test",
|
|
350
|
+
);
|
|
351
|
+
expect(proven).toEqual([
|
|
352
|
+
{
|
|
353
|
+
service: "gone-service",
|
|
354
|
+
field: "api_key",
|
|
355
|
+
provenValue: "hunter2-removed",
|
|
356
|
+
},
|
|
357
|
+
]);
|
|
358
|
+
_resetRevealSuccessRegistryForTest();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("drainCandidateGuardedChunk (live tool output guard)", () => {
|
|
363
|
+
const candidates = [{ service: "svc", field: "f", value: "hunter2-opaque" }];
|
|
364
|
+
|
|
365
|
+
test("redacts a complete occurrence within one chunk", () => {
|
|
366
|
+
const out = drainCandidateGuardedChunk(
|
|
367
|
+
"value: hunter2-opaque\n",
|
|
368
|
+
candidates,
|
|
369
|
+
);
|
|
370
|
+
expect(out.emitText).toBe('value: <redacted type="Credential" />\n');
|
|
371
|
+
expect(out.bufferedRemainder).toBe("");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("holds a trailing partial occurrence, then redacts on completion", () => {
|
|
375
|
+
const first = drainCandidateGuardedChunk("out: hunter2-op", candidates);
|
|
376
|
+
expect(first.emitText).toBe("out: ");
|
|
377
|
+
expect(first.bufferedRemainder).toBe("hunter2-op");
|
|
378
|
+
const second = drainCandidateGuardedChunk(
|
|
379
|
+
first.bufferedRemainder + "aque done",
|
|
380
|
+
candidates,
|
|
381
|
+
);
|
|
382
|
+
expect(second.emitText).toBe('<redacted type="Credential" /> done');
|
|
383
|
+
expect(second.bufferedRemainder).toBe("");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("passes clean chunks through whole", () => {
|
|
387
|
+
const out = drainCandidateGuardedChunk("plain output\n", candidates);
|
|
388
|
+
expect(out.emitText).toBe("plain output\n");
|
|
389
|
+
expect(out.bufferedRemainder).toBe("");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("resolveProvenRevealCandidates skips refs without a proven value", () => {
|
|
393
|
+
expect(
|
|
394
|
+
resolveProvenRevealCandidates([
|
|
395
|
+
{ service: "svc", field: "f" },
|
|
396
|
+
{ service: "svc", field: "f", provenValue: "hunter2-opaque" },
|
|
397
|
+
]),
|
|
398
|
+
).toEqual(candidates);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("redactSecretsForChat", () => {
|
|
403
|
+
const candidates = [
|
|
404
|
+
{
|
|
405
|
+
service: "openai",
|
|
406
|
+
field: "api_key",
|
|
407
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
test("exact byte match produces the enriched (revealable) sentinel", () => {
|
|
412
|
+
const out = redactSecretsForChat(
|
|
413
|
+
`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
|
|
414
|
+
candidates,
|
|
415
|
+
);
|
|
416
|
+
expect(out).toBe(
|
|
417
|
+
"key: \u3014redacted:OpenAI Project Key:openai:api_key\u3015\n",
|
|
418
|
+
);
|
|
419
|
+
expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("colon-qualified candidate service stays revealable via encoding", () => {
|
|
423
|
+
// Vault keys rewritten by migration 018-rekey-compound-credential-keys
|
|
424
|
+
// carry colon-qualified services (`integration:google`). The sentinel
|
|
425
|
+
// must encode the delimiter instead of downgrading the proven match to
|
|
426
|
+
// the non-revealable shape.
|
|
427
|
+
const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
|
|
428
|
+
{
|
|
429
|
+
service: "integration:google",
|
|
430
|
+
field: "api_key",
|
|
431
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
432
|
+
},
|
|
433
|
+
]);
|
|
434
|
+
expect(out).toBe(
|
|
435
|
+
"key: \u3014redacted:OpenAI Project Key:integration%3Agoogle:api_key\u3015",
|
|
436
|
+
);
|
|
437
|
+
expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("no candidate match produces the plain sentinel — never a guess", () => {
|
|
441
|
+
const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
|
|
442
|
+
{ service: "openai", field: "api_key", value: "different-value" },
|
|
443
|
+
]);
|
|
444
|
+
expect(out).toBe("key: \u3014redacted:OpenAI Project Key\u3015");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("empty candidate list still redacts, as plain sentinels", () => {
|
|
448
|
+
const out = redactSecretsForChat(SYNTHETIC_OPENAI_PROJECT_KEY, []);
|
|
449
|
+
expect(out).toBe("\u3014redacted:OpenAI Project Key\u3015");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("a full PEM candidate is redacted whole, not just its header", () => {
|
|
453
|
+
// The scanner's `Private Key` pattern matches ONLY the
|
|
454
|
+
// `-----BEGIN … PRIVATE KEY-----` header, so a scanner-first pass would
|
|
455
|
+
// replace the header and leave the base64 body raw — and the full-value
|
|
456
|
+
// fallback would then miss because the intact value no longer exists.
|
|
457
|
+
// Candidate protection must run before the scanner so the whole key
|
|
458
|
+
// becomes one sentinel and no body bytes survive.
|
|
459
|
+
const pem =
|
|
460
|
+
"-----BEGIN RSA PRIVATE KEY-----\n" +
|
|
461
|
+
"MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Q\n" +
|
|
462
|
+
"uKUpRKfFLfRYC9AIKjbJTWit+CqvjfRasdf0123456789abcdefGHIJKLMNOPQR\n" +
|
|
463
|
+
"-----END RSA PRIVATE KEY-----";
|
|
464
|
+
const out = redactSecretsForChat(`here:\n${pem}\ndone`, [
|
|
465
|
+
{ service: "github-app", field: "pem", value: pem },
|
|
466
|
+
]);
|
|
467
|
+
expect(out).not.toContain("MIIBOgIBAAJBAKj34GkxFhD90");
|
|
468
|
+
expect(out).not.toContain("PRIVATE KEY");
|
|
469
|
+
expect(out).toBe(
|
|
470
|
+
"here:\n\u3014redacted:Private Key:github-app:pem\u3015\ndone",
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("duplicate plaintext across two identities degrades to the plain sentinel", () => {
|
|
475
|
+
// A byte match proves the value, not the vault identity: when two
|
|
476
|
+
// revealed credentials share the same plaintext, minting either chip
|
|
477
|
+
// would mislabel the span (and reveal the wrong value later if that
|
|
478
|
+
// credential rotates). The ambiguity must fail safe.
|
|
479
|
+
const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
|
|
480
|
+
{
|
|
481
|
+
service: "openai",
|
|
482
|
+
field: "api_key",
|
|
483
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
service: "litellm",
|
|
487
|
+
field: "api_key",
|
|
488
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
489
|
+
},
|
|
490
|
+
]);
|
|
491
|
+
expect(out).toBe("key: \u3014redacted:OpenAI Project Key\u3015");
|
|
492
|
+
expect(out).not.toContain(":openai:");
|
|
493
|
+
expect(out).not.toContain(":litellm:");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("duplicate candidate entries that agree on identity stay revealable", () => {
|
|
497
|
+
// The same reveal recorded twice (repeated command in one turn) is not
|
|
498
|
+
// ambiguous — only distinct identities sharing a value degrade.
|
|
499
|
+
const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
|
|
500
|
+
{
|
|
501
|
+
service: "openai",
|
|
502
|
+
field: "api_key",
|
|
503
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
service: "openai",
|
|
507
|
+
field: "api_key",
|
|
508
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
509
|
+
},
|
|
510
|
+
]);
|
|
511
|
+
expect(out).toBe(
|
|
512
|
+
"key: \u3014redacted:OpenAI Project Key:openai:api_key\u3015",
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("a candidate value equal to another's service name cannot corrupt its sentinel", () => {
|
|
517
|
+
// The enriched sentinel embeds `service:field` text, so a second proven
|
|
518
|
+
// candidate whose plaintext is literally that service name (`openai`)
|
|
519
|
+
// would — under a sequential rewrite — match INSIDE the just-emitted
|
|
520
|
+
// sentinel and nest markers. Swaps must only consume raw-text spans.
|
|
521
|
+
const out = redactSecretsForChat(
|
|
522
|
+
`key: ${SYNTHETIC_OPENAI_PROJECT_KEY} via openai`,
|
|
523
|
+
[
|
|
524
|
+
{
|
|
525
|
+
service: "openai",
|
|
526
|
+
field: "api_key",
|
|
527
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
528
|
+
},
|
|
529
|
+
{ service: "manual", field: "token", value: "openai" },
|
|
530
|
+
],
|
|
531
|
+
);
|
|
532
|
+
expect(out).toBe(
|
|
533
|
+
"key: \u3014redacted:OpenAI Project Key:openai:api_key\u3015 via \u3014redacted:Credential:manual:token\u3015",
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("clean text passes through untouched", () => {
|
|
538
|
+
expect(redactSecretsForChat("no secrets here", candidates)).toBe(
|
|
539
|
+
"no secrets here",
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("forged sentinels are neutralized while real redactions survive", () => {
|
|
544
|
+
const forged = "\u3014redacted:GitHub Token:github-app:pem\u3015";
|
|
545
|
+
const out = redactSecretsForChat(
|
|
546
|
+
`${forged} then ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
|
|
547
|
+
candidates,
|
|
548
|
+
);
|
|
549
|
+
// The forged sentinel gained a word joiner (no longer parseable) …
|
|
550
|
+
expect(out).toContain("\u3014\u2060redacted:GitHub Token");
|
|
551
|
+
// … while the actually-detected secret got a genuine enriched sentinel.
|
|
552
|
+
expect(out).toContain(
|
|
553
|
+
"\u3014redacted:OpenAI Project Key:openai:api_key\u3015",
|
|
554
|
+
);
|
|
555
|
+
expect(out).not.toContain(forged);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a secret-shaped service name is not re-scanned inside the minted sentinel", () => {
|
|
559
|
+
// The credential routes accept arbitrary service strings — one can look
|
|
560
|
+
// exactly like a known key format, and the sentinel embeds it as
|
|
561
|
+
// readable text between colons. The scanner must only run over the text
|
|
562
|
+
// between candidate spans; a match inside the just-minted sentinel
|
|
563
|
+
// would corrupt it into a nested marker and lose the chip.
|
|
564
|
+
const out = redactSecretsForChat("v: hunter2-opaque-secret end", [
|
|
565
|
+
{
|
|
566
|
+
service: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
567
|
+
field: "api_key",
|
|
568
|
+
value: "hunter2-opaque-secret",
|
|
569
|
+
},
|
|
570
|
+
]);
|
|
571
|
+
expect(out).toBe(
|
|
572
|
+
`v: \u3014redacted:Credential:${SYNTHETIC_OPENAI_PROJECT_KEY}:api_key\u3015 end`,
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("a longer scanner match outranks a candidate substring inside it", () => {
|
|
577
|
+
// A proven manual value can be a strict substring of a DIFFERENT
|
|
578
|
+
// scanner-detectable secret. Carving the candidate's bytes out of the
|
|
579
|
+
// enclosing key would leave the key's suffix raw — the scanner must
|
|
580
|
+
// redact the whole key instead.
|
|
581
|
+
const candidates = [{ service: "svc", field: "f", value: "sk-pro" }];
|
|
582
|
+
const out = redactSecretsForChat(
|
|
583
|
+
`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
|
|
584
|
+
candidates,
|
|
585
|
+
);
|
|
586
|
+
expect(out).toBe("key: \u3014redacted:OpenAI Project Key\u3015");
|
|
587
|
+
// A standalone occurrence still gets the exact-match fallback.
|
|
588
|
+
expect(redactSecretsForChat("v: sk-pro end", candidates)).toBe(
|
|
589
|
+
"v: \u3014redacted:Credential:svc:f\u3015 end",
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("a candidate value containing the sentinel trigger is still redacted whole", () => {
|
|
594
|
+
// Manual credential values are arbitrary strings — one may embed the
|
|
595
|
+
// sentinel trigger itself. Neutralizing the whole text BEFORE the
|
|
596
|
+
// exact-match pass would mutate the value's occurrence and the protect
|
|
597
|
+
// pass would miss it, so candidate spans resolve on the raw bytes and
|
|
598
|
+
// neutralization applies only to the text between them.
|
|
599
|
+
const value = "weird\u3014redacted:inner\u3015token";
|
|
600
|
+
const out = redactSecretsForChat(`v: ${value} end`, [
|
|
601
|
+
{ service: "svc", field: "f", value },
|
|
602
|
+
]);
|
|
603
|
+
expect(out).toBe("v: \u3014redacted:Credential:svc:f\u3015 end");
|
|
604
|
+
expect(out).not.toContain("weird");
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
describe("drainSentinelGuardedText (live-stream forgery guard)", () => {
|
|
609
|
+
test("neutralizes a complete forged sentinel within one chunk", () => {
|
|
610
|
+
const out = drainSentinelGuardedText(
|
|
611
|
+
"key: \u3014redacted:GitHub Token:github-app:pem\u3015 done",
|
|
612
|
+
);
|
|
613
|
+
expect(out.emitText).toBe(
|
|
614
|
+
"key: \u3014\u2060redacted:GitHub Token:github-app:pem\u3015 done",
|
|
615
|
+
);
|
|
616
|
+
expect(out.bufferedRemainder).toBe("");
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("holds back a trigger split across chunks, then neutralizes on completion", () => {
|
|
620
|
+
const first = drainSentinelGuardedText("look \u3014redac");
|
|
621
|
+
expect(first.emitText).toBe("look ");
|
|
622
|
+
expect(first.bufferedRemainder).toBe("\u3014redac");
|
|
623
|
+
const second = drainSentinelGuardedText(
|
|
624
|
+
first.bufferedRemainder + "ted:GitHub Token:github-app:pem\u3015",
|
|
625
|
+
);
|
|
626
|
+
expect(second.emitText).toBe(
|
|
627
|
+
"\u3014\u2060redacted:GitHub Token:github-app:pem\u3015",
|
|
628
|
+
);
|
|
629
|
+
expect(second.bufferedRemainder).toBe("");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("releases a held prefix that never completes into a trigger", () => {
|
|
633
|
+
const first = drainSentinelGuardedText("open \u3014red");
|
|
634
|
+
expect(first.bufferedRemainder).toBe("\u3014red");
|
|
635
|
+
const second = drainSentinelGuardedText(
|
|
636
|
+
first.bufferedRemainder + " herring",
|
|
637
|
+
);
|
|
638
|
+
expect(second.emitText).toBe("\u3014red herring");
|
|
639
|
+
expect(second.bufferedRemainder).toBe("");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("plain text passes through whole", () => {
|
|
643
|
+
const out = drainSentinelGuardedText("nothing suspicious here");
|
|
644
|
+
expect(out.emitText).toBe("nothing suspicious here");
|
|
645
|
+
expect(out.consumedRaw).toBe("nothing suspicious here");
|
|
646
|
+
expect(out.bufferedRemainder).toBe("");
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
describe("live reveal swap (stream plaintext hold-back)", () => {
|
|
651
|
+
const CANDIDATE = {
|
|
652
|
+
service: "openai",
|
|
653
|
+
field: "api_key",
|
|
654
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
655
|
+
};
|
|
656
|
+
const ENTRIES = buildLiveRevealGuardEntries([CANDIDATE]);
|
|
657
|
+
const SENTINEL = redactSecretsForChat(SYNTHETIC_OPENAI_PROJECT_KEY, [
|
|
658
|
+
CANDIDATE,
|
|
659
|
+
]);
|
|
660
|
+
|
|
661
|
+
test("buildLiveRevealGuardEntries pairs a detectable value with its enriched sentinel", () => {
|
|
662
|
+
expect(ENTRIES).toEqual([
|
|
663
|
+
{ value: SYNTHETIC_OPENAI_PROJECT_KEY, replacement: SENTINEL },
|
|
664
|
+
]);
|
|
665
|
+
expect(SENTINEL).toContain(":openai:api_key\u3015");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("buildLiveRevealGuardEntries covers a value the scanner cannot classify bare", () => {
|
|
669
|
+
// Several scanner patterns only match WITH context
|
|
670
|
+
// (`password=<value>` and friends), so a bare-undetectable candidate
|
|
671
|
+
// must still get an entry — dropping it let the plaintext cross the
|
|
672
|
+
// live stream raw while final persistence redacted the contextual
|
|
673
|
+
// occurrence. The replacement falls back to a generic-typed enriched
|
|
674
|
+
// sentinel built from the candidate identity.
|
|
675
|
+
expect(
|
|
676
|
+
buildLiveRevealGuardEntries([
|
|
677
|
+
{ service: "svc", field: "f", value: "not a real secret shape" },
|
|
678
|
+
]),
|
|
679
|
+
).toEqual([
|
|
680
|
+
{
|
|
681
|
+
value: "not a real secret shape",
|
|
682
|
+
replacement: "\u3014redacted:Credential:svc:f\u3015",
|
|
683
|
+
},
|
|
684
|
+
]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("persist redacts an unclassifiable candidate value via the exact-match fallback", () => {
|
|
688
|
+
// The live guard swaps a scanner-unclassifiable value,
|
|
689
|
+
// so persistence MUST redact it too — otherwise the SSE transcript
|
|
690
|
+
// hides the secret while the stored row keeps the raw plaintext and a
|
|
691
|
+
// refresh or history fetch exposes it. The persisted bytes must equal
|
|
692
|
+
// the streamed replacement exactly.
|
|
693
|
+
const candidates = [
|
|
694
|
+
{ service: "svc", field: "f", value: "not a real secret shape" },
|
|
695
|
+
];
|
|
696
|
+
const persisted = redactSecretsForChat(
|
|
697
|
+
"your token is not a real secret shape — keep it safe",
|
|
698
|
+
candidates,
|
|
699
|
+
);
|
|
700
|
+
expect(persisted).toBe(
|
|
701
|
+
"your token is \u3014redacted:Credential:svc:f\u3015 — keep it safe",
|
|
702
|
+
);
|
|
703
|
+
expect(persisted).not.toContain("not a real secret shape");
|
|
704
|
+
// Byte-identity with the live guard entry.
|
|
705
|
+
const [entry] = buildLiveRevealGuardEntries(candidates);
|
|
706
|
+
expect(entry!.replacement).toBe("\u3014redacted:Credential:svc:f\u3015");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("redactCandidateValuesLegacy covers unclassifiable values on legacy-marker surfaces", () => {
|
|
710
|
+
// The reveal command's own stdout persists into the
|
|
711
|
+
// tool_result row via the legacy `<redacted type/>` path (the tool
|
|
712
|
+
// detail panel renders no chips). An opaque/manual value with no
|
|
713
|
+
// scanner-recognizable shape must still be redacted there — the
|
|
714
|
+
// stored tool result must not retain a value every other surface
|
|
715
|
+
// hides.
|
|
716
|
+
const candidates = [
|
|
717
|
+
{ service: "svc", field: "f", value: "hunter2-opaque" },
|
|
718
|
+
];
|
|
719
|
+
expect(
|
|
720
|
+
redactCandidateValuesLegacy("stdout: hunter2-opaque\n", candidates),
|
|
721
|
+
).toBe('stdout: <redacted type="Credential" />\n');
|
|
722
|
+
// No candidates → byte-identical passthrough (legacy mode unchanged).
|
|
723
|
+
expect(redactCandidateValuesLegacy("stdout: hunter2-opaque\n", [])).toBe(
|
|
724
|
+
"stdout: hunter2-opaque\n",
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("redactCandidateValuesLegacy redacts a full PEM candidate whole", () => {
|
|
729
|
+
// Same PEM hazard on the legacy surface: the scanner recognizes only the
|
|
730
|
+
// header, so candidate protection must run before it or the key body
|
|
731
|
+
// persists raw in the tool-result row.
|
|
732
|
+
const pem =
|
|
733
|
+
"-----BEGIN RSA PRIVATE KEY-----\n" +
|
|
734
|
+
"MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Q\n" +
|
|
735
|
+
"-----END RSA PRIVATE KEY-----";
|
|
736
|
+
const out = redactCandidateValuesLegacy(`pem:\n${pem}\n`, [
|
|
737
|
+
{ service: "github-app", field: "pem", value: pem },
|
|
738
|
+
]);
|
|
739
|
+
expect(out).not.toContain("MIIBOgIBAAJBAKj34GkxFhD90");
|
|
740
|
+
expect(out).not.toContain("PRIVATE KEY");
|
|
741
|
+
expect(out).toBe('pem:\n<redacted type="Credential" />\n');
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("redactCandidateValuesLegacy does not depend on the sentinel flag's marker format", () => {
|
|
745
|
+
// The fallback protects legacy mode too — keeping a
|
|
746
|
+
// route-proven plaintext out of persisted rows is independent of
|
|
747
|
+
// which marker format the client renders. The output is the legacy
|
|
748
|
+
// marker, valid on flag-off surfaces.
|
|
749
|
+
const out = redactCandidateValuesLegacy("value: opaque-manual-secret", [
|
|
750
|
+
{ service: "svc", field: "f", value: "opaque-manual-secret" },
|
|
751
|
+
]);
|
|
752
|
+
expect(out).toBe('value: <redacted type="Credential" />');
|
|
753
|
+
expect(out).not.toContain("\u3014");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("redactCandidateValuesLegacy covers the JSON-escaped form printed by reveal --json", async () => {
|
|
757
|
+
// `reveal --json` stdout carries JSON.stringify({ok, value}) — a value
|
|
758
|
+
// with newlines or quotes appears escaped, bytes the raw value can
|
|
759
|
+
// never match. The resolved candidate list carries both encodings, so
|
|
760
|
+
// the escaped body must be redacted too (the scanner alone would only
|
|
761
|
+
// catch a recognizable header).
|
|
762
|
+
const value = 'multi\nline"secret\\body';
|
|
763
|
+
const candidates = await resolveRevealCandidates([
|
|
764
|
+
{ service: "svc", field: "f", provenValue: value },
|
|
765
|
+
]);
|
|
766
|
+
const stdout = JSON.stringify({ ok: true, value });
|
|
767
|
+
const out = redactCandidateValuesLegacy(stdout, candidates);
|
|
768
|
+
expect(out).toContain('<redacted type="Credential" />');
|
|
769
|
+
expect(out).not.toContain("multi");
|
|
770
|
+
expect(out).not.toContain("secret");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("redactCandidateValuesLegacy lets the scanner redact an enclosing secret whole", () => {
|
|
774
|
+
const out = redactCandidateValuesLegacy(
|
|
775
|
+
`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
|
|
776
|
+
[{ service: "svc", field: "f", value: "sk-pro" }],
|
|
777
|
+
);
|
|
778
|
+
expect(out).toBe(`key: ${OPENAI_PROJECT_KEY_REDACTION_MARKER}`);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("redactCandidateValuesLegacy covers a value containing the sentinel trigger", () => {
|
|
782
|
+
// Same raw-bytes-first ordering as the sentinel path: neutralizing the
|
|
783
|
+
// whole text before the exact match would mutate the value's occurrence
|
|
784
|
+
// and the opaque secret would persist almost intact.
|
|
785
|
+
const value = "weird\u3014redacted:inner\u3015token";
|
|
786
|
+
const out = redactCandidateValuesLegacy(`v: ${value} end`, [
|
|
787
|
+
{ service: "svc", field: "f", value },
|
|
788
|
+
]);
|
|
789
|
+
expect(out).toBe('v: <redacted type="Credential" /> end');
|
|
790
|
+
expect(out).not.toContain("weird");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("redactCandidateValuesLegacy keeps emitted markers intact when a value appears inside them", () => {
|
|
794
|
+
// The legacy marker's own text contains `redacted` \u2014 a candidate whose
|
|
795
|
+
// plaintext is that word must not rewrite inside a marker just emitted
|
|
796
|
+
// for another candidate.
|
|
797
|
+
const out = redactCandidateValuesLegacy("value: hunter2-opaque-secret", [
|
|
798
|
+
{ service: "svc", field: "f", value: "hunter2-opaque-secret" },
|
|
799
|
+
{ service: "other", field: "g", value: "redacted" },
|
|
800
|
+
]);
|
|
801
|
+
expect(out).toBe('value: <redacted type="Credential" />');
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("the persist fallback applies the duplicate-identity degrade rule", () => {
|
|
805
|
+
const candidates = [
|
|
806
|
+
{ service: "svc", field: "f", value: "shared plain value" },
|
|
807
|
+
{ service: "other", field: "g", value: "shared plain value" },
|
|
808
|
+
];
|
|
809
|
+
expect(redactSecretsForChat("echo shared plain value", candidates)).toBe(
|
|
810
|
+
"echo \u3014redacted:Credential\u3015",
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("a bare-unclassifiable duplicate plaintext degrades to the plain generic sentinel", () => {
|
|
815
|
+
// The unique-identity degrade rule applies to fallback entries too:
|
|
816
|
+
// two identities sharing an unclassifiable value must not mint an
|
|
817
|
+
// identity-carrying chip for either.
|
|
818
|
+
expect(
|
|
819
|
+
buildLiveRevealGuardEntries([
|
|
820
|
+
{ service: "svc", field: "f", value: "shared plain value" },
|
|
821
|
+
{ service: "other", field: "g", value: "shared plain value" },
|
|
822
|
+
]),
|
|
823
|
+
).toEqual([
|
|
824
|
+
{
|
|
825
|
+
value: "shared plain value",
|
|
826
|
+
replacement: "\u3014redacted:Credential\u3015",
|
|
827
|
+
},
|
|
828
|
+
]);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("buildLiveRevealGuardEntries degrades a duplicate plaintext to the plain sentinel, matching persist", () => {
|
|
832
|
+
// Two identities sharing one value: the persist seam degrades that span
|
|
833
|
+
// to the plain type-only sentinel, so the live swap must emit the same
|
|
834
|
+
// bytes — one deduped entry whose replacement carries no identity.
|
|
835
|
+
const entries = buildLiveRevealGuardEntries([
|
|
836
|
+
{
|
|
837
|
+
service: "openai",
|
|
838
|
+
field: "api_key",
|
|
839
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
service: "litellm",
|
|
843
|
+
field: "api_key",
|
|
844
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
845
|
+
},
|
|
846
|
+
]);
|
|
847
|
+
expect(entries).toEqual([
|
|
848
|
+
{
|
|
849
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
850
|
+
replacement: "\u3014redacted:OpenAI Project Key\u3015",
|
|
851
|
+
},
|
|
852
|
+
]);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("swaps a complete echoed value within one chunk", () => {
|
|
856
|
+
const raw = `Here it is: ${SYNTHETIC_OPENAI_PROJECT_KEY} — rotate it.`;
|
|
857
|
+
const out = drainSentinelGuardedText(raw, ENTRIES);
|
|
858
|
+
expect(out.emitText).toBe(`Here it is: ${SENTINEL} — rotate it.`);
|
|
859
|
+
expect(out.emitText).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
860
|
+
expect(out.consumedRaw).toBe(raw);
|
|
861
|
+
expect(out.bufferedRemainder).toBe("");
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("holds back a value split across chunks, then swaps on completion", () => {
|
|
865
|
+
const head = SYNTHETIC_OPENAI_PROJECT_KEY.slice(0, 12);
|
|
866
|
+
const tail = SYNTHETIC_OPENAI_PROJECT_KEY.slice(12);
|
|
867
|
+
const first = drainSentinelGuardedText(`token: ${head}`, ENTRIES);
|
|
868
|
+
expect(first.emitText).toBe("token: ");
|
|
869
|
+
expect(first.bufferedRemainder).toBe(head);
|
|
870
|
+
const second = drainSentinelGuardedText(
|
|
871
|
+
first.bufferedRemainder + tail + " end",
|
|
872
|
+
ENTRIES,
|
|
873
|
+
);
|
|
874
|
+
expect(second.emitText).toBe(`${SENTINEL} end`);
|
|
875
|
+
expect(second.emitText).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
876
|
+
expect(second.bufferedRemainder).toBe("");
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("releases a held value prefix that never completes", () => {
|
|
880
|
+
const head = SYNTHETIC_OPENAI_PROJECT_KEY.slice(0, 12);
|
|
881
|
+
const first = drainSentinelGuardedText(`prefix ${head}`, ENTRIES);
|
|
882
|
+
expect(first.bufferedRemainder).toBe(head);
|
|
883
|
+
const second = drainSentinelGuardedText(
|
|
884
|
+
first.bufferedRemainder + "-not-the-secret",
|
|
885
|
+
ENTRIES,
|
|
886
|
+
);
|
|
887
|
+
expect(second.emitText).toBe(`${head}-not-the-secret`);
|
|
888
|
+
expect(second.bufferedRemainder).toBe("");
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test("consumedRaw carries the plaintext for persist-side re-redaction", () => {
|
|
892
|
+
const raw = `value ${SYNTHETIC_OPENAI_PROJECT_KEY}.`;
|
|
893
|
+
const out = drainSentinelGuardedText(raw, ENTRIES);
|
|
894
|
+
// The mirror path re-redacts consumedRaw at persist; verify the round
|
|
895
|
+
// trip lands on the same sentinel the live stream emitted.
|
|
896
|
+
expect(redactSecretsForChat(out.consumedRaw, [CANDIDATE])).toBe(
|
|
897
|
+
out.emitText,
|
|
898
|
+
);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("swaps a self-overlapping value chunked at the overlap boundary", () => {
|
|
902
|
+
// A value whose proper prefix is also its suffix (`abcabc`), chunked
|
|
903
|
+
// exactly at the overlap (`abc` + `abc`). The hold must consume the
|
|
904
|
+
// complete occurrence instead of re-holding the trailing repeat —
|
|
905
|
+
// otherwise the first half is emitted as raw plaintext and the swap
|
|
906
|
+
// never sees the full value.
|
|
907
|
+
const entries = [{ value: "abcabc", replacement: "[SWAPPED]" }];
|
|
908
|
+
const first = drainSentinelGuardedText("abc", entries);
|
|
909
|
+
expect(first.emitText).toBe("");
|
|
910
|
+
expect(first.bufferedRemainder).toBe("abc");
|
|
911
|
+
const second = drainSentinelGuardedText(
|
|
912
|
+
first.bufferedRemainder + "abc",
|
|
913
|
+
entries,
|
|
914
|
+
);
|
|
915
|
+
expect(second.emitText).toBe("[SWAPPED]");
|
|
916
|
+
expect(second.consumedRaw).toBe("abcabc");
|
|
917
|
+
expect(second.bufferedRemainder).toBe("");
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("holds only the partial repeat after a complete self-overlapping occurrence", () => {
|
|
921
|
+
const entries = [{ value: "abcabc", replacement: "[SWAPPED]" }];
|
|
922
|
+
const out = drainSentinelGuardedText("abcabcabc", entries);
|
|
923
|
+
expect(out.emitText).toBe("[SWAPPED]");
|
|
924
|
+
expect(out.consumedRaw).toBe("abcabc");
|
|
925
|
+
expect(out.bufferedRemainder).toBe("abc");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("holds a completed value whole while a higher-priority match could claim its tail", () => {
|
|
929
|
+
// Candidate A ends with a proper prefix of longer
|
|
930
|
+
// (higher-priority) candidate B (`…-sk` / `sk-…`). At a chunk boundary
|
|
931
|
+
// right after a complete A the outcome is genuinely ambiguous — the
|
|
932
|
+
// next bytes decide whether the full-text swap consumes A or a B
|
|
933
|
+
// starting inside A's tail — so A must be held WHOLE. Splitting A to
|
|
934
|
+
// hold only B's prefix leaks A's head raw; committing A immediately
|
|
935
|
+
// leaks B's suffix raw when B completes.
|
|
936
|
+
const entries = [
|
|
937
|
+
{ value: "xx-sk", replacement: "[A]" },
|
|
938
|
+
{ value: "sk-yyyy", replacement: "[B]" },
|
|
939
|
+
];
|
|
940
|
+
const first = drainSentinelGuardedText("token xx-sk", entries);
|
|
941
|
+
expect(first.emitText).toBe("token ");
|
|
942
|
+
expect(first.consumedRaw).toBe("token ");
|
|
943
|
+
expect(first.bufferedRemainder).toBe("xx-sk");
|
|
944
|
+
|
|
945
|
+
// Continuation 1: B completes inside A's tail — matches the unchunked
|
|
946
|
+
// swap of `xx-sk-yyyy` (B wins, A's occurrence is blocked by overlap).
|
|
947
|
+
const bWins = drainSentinelGuardedText(
|
|
948
|
+
first.bufferedRemainder + "-yyyy ok",
|
|
949
|
+
entries,
|
|
950
|
+
);
|
|
951
|
+
expect(bWins.emitText).toBe("xx-[B] ok");
|
|
952
|
+
expect(bWins.consumedRaw).toBe("xx-sk-yyyy ok");
|
|
953
|
+
expect(bWins.bufferedRemainder).toBe("");
|
|
954
|
+
|
|
955
|
+
// Continuation 2: the threat dissolves — A swaps whole.
|
|
956
|
+
const aWins = drainSentinelGuardedText(
|
|
957
|
+
first.bufferedRemainder + " ok",
|
|
958
|
+
entries,
|
|
959
|
+
);
|
|
960
|
+
expect(aWins.emitText).toBe("[A] ok");
|
|
961
|
+
expect(aWins.consumedRaw).toBe("xx-sk ok");
|
|
962
|
+
expect(aWins.bufferedRemainder).toBe("");
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("equal-length overlapping entries stay chunk-independent", () => {
|
|
966
|
+
// `aba` outranks `bab` (equal length, earlier in the
|
|
967
|
+
// sorted order — same tie-break as `swapLiveRevealValues`). Chunks
|
|
968
|
+
// `bab` + `a` must produce the same output as the unchunked swap of
|
|
969
|
+
// `baba` (`b` + swapped `aba`), not commit the completed `bab` and
|
|
970
|
+
// emit a raw trailing `a`.
|
|
971
|
+
const entries = [
|
|
972
|
+
{ value: "aba", replacement: "[ABA]" },
|
|
973
|
+
{ value: "bab", replacement: "[BAB]" },
|
|
974
|
+
];
|
|
975
|
+
const first = drainSentinelGuardedText("bab", entries);
|
|
976
|
+
expect(first.emitText).toBe("");
|
|
977
|
+
expect(first.consumedRaw).toBe("");
|
|
978
|
+
expect(first.bufferedRemainder).toBe("bab");
|
|
979
|
+
const second = drainSentinelGuardedText(
|
|
980
|
+
first.bufferedRemainder + "a",
|
|
981
|
+
entries,
|
|
982
|
+
);
|
|
983
|
+
expect(second.emitText).toBe("b[ABA]");
|
|
984
|
+
expect(second.consumedRaw).toBe("baba");
|
|
985
|
+
expect(second.bufferedRemainder).toBe("");
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test("commits an equal-length occurrence once no higher-priority prefix survives", () => {
|
|
989
|
+
// Same entries, but the bytes after the completed `bab` rule out any
|
|
990
|
+
// pending `aba` — the occurrence commits whole with no held tail.
|
|
991
|
+
const entries = [
|
|
992
|
+
{ value: "aba", replacement: "[ABA]" },
|
|
993
|
+
{ value: "bab", replacement: "[BAB]" },
|
|
994
|
+
];
|
|
995
|
+
const out = drainSentinelGuardedText("bab end", entries);
|
|
996
|
+
expect(out.emitText).toBe("[BAB] end");
|
|
997
|
+
expect(out.consumedRaw).toBe("bab end");
|
|
998
|
+
expect(out.bufferedRemainder).toBe("");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("still holds another entry's prefix when it follows a completed value", () => {
|
|
1002
|
+
// Bytes AFTER the consumed occurrence are fair game: `sk` following a
|
|
1003
|
+
// complete A is genuinely ambiguous (B may be starting) and is held,
|
|
1004
|
+
// while A itself swaps whole.
|
|
1005
|
+
const entries = [
|
|
1006
|
+
{ value: "xx-sk", replacement: "[A]" },
|
|
1007
|
+
{ value: "sk-yyyy", replacement: "[B]" },
|
|
1008
|
+
];
|
|
1009
|
+
const first = drainSentinelGuardedText("token xx-sk sk", entries);
|
|
1010
|
+
expect(first.emitText).toBe("token [A] ");
|
|
1011
|
+
expect(first.consumedRaw).toBe("token xx-sk ");
|
|
1012
|
+
expect(first.bufferedRemainder).toBe("sk");
|
|
1013
|
+
const second = drainSentinelGuardedText(
|
|
1014
|
+
first.bufferedRemainder + "-yyyy done",
|
|
1015
|
+
entries,
|
|
1016
|
+
);
|
|
1017
|
+
expect(second.emitText).toBe("[B] done");
|
|
1018
|
+
expect(second.consumedRaw).toBe("sk-yyyy done");
|
|
1019
|
+
expect(second.bufferedRemainder).toBe("");
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("hold-back skips occurrences with the swap's greedy left-to-right semantics", () => {
|
|
1023
|
+
// `abab` with value `aba`: split/join consumes the occurrence at 0 and
|
|
1024
|
+
// does NOT match the overlapping occurrence at 2, so the guard must not
|
|
1025
|
+
// hold the trailing `b` (not a prefix) — live emit and persist-time
|
|
1026
|
+
// redaction stay byte-identical.
|
|
1027
|
+
const entries = [{ value: "aba", replacement: "[X]" }];
|
|
1028
|
+
const out = drainSentinelGuardedText("abab", entries);
|
|
1029
|
+
expect(out.emitText).toBe("[X]b");
|
|
1030
|
+
expect(out.consumedRaw).toBe("abab");
|
|
1031
|
+
expect(out.bufferedRemainder).toBe("");
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
test("swaps the longest matching value when one candidate prefixes another", () => {
|
|
1035
|
+
// Insertion order must not matter: with the shorter entry first, the
|
|
1036
|
+
// longer echoed value would otherwise get the shorter chip plus its
|
|
1037
|
+
// unmatched suffix emitted raw — inconsistent with persist-time
|
|
1038
|
+
// redaction, which redacts the whole longer span.
|
|
1039
|
+
const entries = [
|
|
1040
|
+
{ value: "sk-abc", replacement: "[SHORT]" },
|
|
1041
|
+
{ value: "sk-abcdef", replacement: "[LONG]" },
|
|
1042
|
+
];
|
|
1043
|
+
expect(swapLiveRevealValues("key: sk-abcdef end", entries)).toBe(
|
|
1044
|
+
"key: [LONG] end",
|
|
1045
|
+
);
|
|
1046
|
+
expect(swapLiveRevealValues("a sk-abc b sk-abcdef c", entries)).toBe(
|
|
1047
|
+
"a [SHORT] b [LONG] c",
|
|
1048
|
+
);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test("swapLiveRevealValues replaces multiple occurrences", () => {
|
|
1052
|
+
const text = `a ${SYNTHETIC_OPENAI_PROJECT_KEY} b ${SYNTHETIC_OPENAI_PROJECT_KEY} c`;
|
|
1053
|
+
expect(swapLiveRevealValues(text, ENTRIES)).toBe(
|
|
1054
|
+
`a ${SENTINEL} b ${SENTINEL} c`,
|
|
1055
|
+
);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("the live guard swaps a value containing the sentinel trigger", () => {
|
|
1059
|
+
// The emit transform resolves candidate spans and forgery
|
|
1060
|
+
// neutralization together on the raw bytes — sequencing neutralization
|
|
1061
|
+
// first would mutate this value's occurrence and stream it raw.
|
|
1062
|
+
const value = "weird\u3014redacted:inner\u3015token";
|
|
1063
|
+
const candidates = [{ service: "svc", field: "f", value }];
|
|
1064
|
+
const entries = buildLiveRevealGuardEntries(candidates);
|
|
1065
|
+
const out = drainSentinelGuardedText(`v: ${value} end`, entries);
|
|
1066
|
+
expect(out.emitText).toBe("v: \u3014redacted:Credential:svc:f\u3015 end");
|
|
1067
|
+
expect(out.bufferedRemainder).toBe("");
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("swapLiveRevealValues never rewrites inside an earlier entry's replacement", () => {
|
|
1071
|
+
// A replacement sentinel is not inert text — it embeds service/field
|
|
1072
|
+
// segments. When another entry's plaintext equals one of those segments
|
|
1073
|
+
// (a credential whose value is literally `openai`), a sequential
|
|
1074
|
+
// split/join would corrupt the just-emitted sentinel into nested
|
|
1075
|
+
// markers; the swap must only consume raw-text spans.
|
|
1076
|
+
const entries = [
|
|
1077
|
+
{
|
|
1078
|
+
value: "sk-long-secret-0123456789",
|
|
1079
|
+
replacement: "\u3014redacted:Credential:openai:api_key\u3015",
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
value: "openai",
|
|
1083
|
+
replacement: "\u3014redacted:Credential:manual:token\u3015",
|
|
1084
|
+
},
|
|
1085
|
+
];
|
|
1086
|
+
expect(
|
|
1087
|
+
swapLiveRevealValues(
|
|
1088
|
+
"sk-long-secret-0123456789 spoke to openai",
|
|
1089
|
+
entries,
|
|
1090
|
+
),
|
|
1091
|
+
).toBe(
|
|
1092
|
+
"\u3014redacted:Credential:openai:api_key\u3015 spoke to \u3014redacted:Credential:manual:token\u3015",
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
describe("persisted text rider", () => {
|
|
1098
|
+
test("buildPersistedAssistantContent stamps _redactionVersion on text blocks", async () => {
|
|
1099
|
+
const { buildPersistedAssistantContent } =
|
|
1100
|
+
await import("../daemon/conversation-agent-loop-handlers.js");
|
|
1101
|
+
const [block] = buildPersistedAssistantContent(
|
|
1102
|
+
[{ type: "text", text: "hello" }],
|
|
1103
|
+
[],
|
|
1104
|
+
);
|
|
1105
|
+
expect((block as { _redactionVersion?: number })._redactionVersion).toBe(2);
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
describe("legacy marker invariant", () => {
|
|
1110
|
+
test("redactSecrets output is byte-unchanged by this feature", () => {
|
|
1111
|
+
expect(redactSecrets(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`)).toBe(
|
|
1112
|
+
`key: ${OPENAI_PROJECT_KEY_REDACTION_MARKER}`,
|
|
1113
|
+
);
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
describe("--for-chat (daemon-minted sentinel channel)", () => {
|
|
1118
|
+
const FOR_CHAT_CANDIDATE = {
|
|
1119
|
+
service: "openai",
|
|
1120
|
+
field: "api_key",
|
|
1121
|
+
value: SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
1122
|
+
};
|
|
1123
|
+
const CANONICAL = buildForChatSentinel(FOR_CHAT_CANDIDATE);
|
|
1124
|
+
const MINT = {
|
|
1125
|
+
service: "openai",
|
|
1126
|
+
field: "api_key",
|
|
1127
|
+
sentinel: CANONICAL,
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
test("collectRevealRefsFromCommand carries no --for-chat signal — executed mints are the only authority", () => {
|
|
1131
|
+
// A parse of the requested command must never authorize re-minting: a
|
|
1132
|
+
// segment that merely QUOTES a reveal invocation (echo '… --for-chat …')
|
|
1133
|
+
// parses identically to one that runs it. The refs exist only to scope
|
|
1134
|
+
// the plaintext-swap candidate fetch; the re-mint allowlist comes from
|
|
1135
|
+
// the route-recorded mint registry and from candidates PROVEN by the
|
|
1136
|
+
// reveal-success registry.
|
|
1137
|
+
expect(
|
|
1138
|
+
collectRevealRefsFromCommand(
|
|
1139
|
+
"assistant credentials reveal --for-chat --service openai --field api_key",
|
|
1140
|
+
),
|
|
1141
|
+
).toEqual([{ service: "openai", field: "api_key" }]);
|
|
1142
|
+
expect(
|
|
1143
|
+
collectRevealRefsFromCommand(
|
|
1144
|
+
`assistant credentials reveal ${UUID} --for-chat`,
|
|
1145
|
+
),
|
|
1146
|
+
).toEqual([{ id: UUID }]);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("buildForChatSentinel stamps the scanner's type label", () => {
|
|
1150
|
+
expect(CANONICAL).toBe(
|
|
1151
|
+
"\u3014redacted:OpenAI Project Key:openai:api_key\u3015",
|
|
1152
|
+
);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test("buildForChatSentinel falls back to a generic label for unscannable values", () => {
|
|
1156
|
+
expect(
|
|
1157
|
+
buildForChatSentinel({
|
|
1158
|
+
service: "svc",
|
|
1159
|
+
field: "f",
|
|
1160
|
+
value: "plain-value-no-pattern",
|
|
1161
|
+
}),
|
|
1162
|
+
).toBe("\u3014redacted:Credential:svc:f\u3015");
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
test("guardForChatSentinels re-mints an identity match, even with a tampered type label", () => {
|
|
1166
|
+
const echoed = `Your key: ${CANONICAL} — click to reveal.`;
|
|
1167
|
+
expect(guardForChatSentinels(echoed, [MINT])).toBe(echoed);
|
|
1168
|
+
// A hand-altered type label is canonicalized away, not trusted — the
|
|
1169
|
+
// replacement is the route's original mint.
|
|
1170
|
+
const tampered = "see \u3014redacted:GitHub Token:openai:api_key\u3015";
|
|
1171
|
+
expect(guardForChatSentinels(tampered, [MINT])).toBe(`see ${CANONICAL}`);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test("guardForChatSentinels neutralizes unknown identities and plain shapes", () => {
|
|
1175
|
+
const unknown = "\u3014redacted:API Key:github-app:pem\u3015";
|
|
1176
|
+
const plain = "\u3014redacted:API Key\u3015";
|
|
1177
|
+
const out = guardForChatSentinels(`${unknown} ${plain}`, [MINT]);
|
|
1178
|
+
expect(out).toBe(
|
|
1179
|
+
"\u3014\u2060redacted:API Key:github-app:pem\u3015 \u3014\u2060redacted:API Key\u3015",
|
|
1180
|
+
);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("guardForChatSentinels equals plain neutralization with no recorded mints", () => {
|
|
1184
|
+
const text = `x ${CANONICAL} y \u3014redac partial`;
|
|
1185
|
+
expect(guardForChatSentinels(text, [])).toBe(
|
|
1186
|
+
neutralizeRedactedSentinels(text),
|
|
1187
|
+
);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
test("redactSecretsForChat preserves the daemon-minted sentinel and still redacts plaintext", () => {
|
|
1191
|
+
const text = `chip ${CANONICAL} and raw ${SYNTHETIC_OPENAI_PROJECT_KEY}`;
|
|
1192
|
+
const out = redactSecretsForChat(text, [FOR_CHAT_CANDIDATE], [MINT]);
|
|
1193
|
+
expect(out).toBe(`chip ${CANONICAL} and raw ${CANONICAL}`);
|
|
1194
|
+
expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test("a secret embedded inside a forged sentinel is still scanner-redacted", () => {
|
|
1198
|
+
// The forged span's identity matches no authority, so its bytes run
|
|
1199
|
+
// the full neutralize+scan pipeline — an attacker cannot smuggle a
|
|
1200
|
+
// real key through persistence by dressing it as a sentinel.
|
|
1201
|
+
const forged = `\u3014redacted:x:svc:${SYNTHETIC_OPENAI_PROJECT_KEY}\u3015`;
|
|
1202
|
+
const out = redactSecretsForChat(forged, [], [MINT]);
|
|
1203
|
+
expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
test("a candidate list alone is not re-mint authority (quoted command grants nothing)", () => {
|
|
1207
|
+
// The quoted-command scenario: the identity IS a reveal candidate
|
|
1208
|
+
// (staging parsed the echoed text), but the route never executed the
|
|
1209
|
+
// reveal, so no proof exists, handlers derive no authorities, and the
|
|
1210
|
+
// echoed sentinel is neutralized like any other forgery.
|
|
1211
|
+
const out = redactSecretsForChat(`chip ${CANONICAL}`, [FOR_CHAT_CANDIDATE]);
|
|
1212
|
+
expect(out).toBe(`chip ${neutralizeRedactedSentinels(CANONICAL)}`);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test("stream guard holds an unclosed trigger open until the sentinel completes, then re-mints", () => {
|
|
1216
|
+
const head = CANONICAL.slice(0, 20); // past the trigger, before the close
|
|
1217
|
+
const tail = CANONICAL.slice(20);
|
|
1218
|
+
const first = drainSentinelGuardedText(`token: ${head}`, [], [MINT]);
|
|
1219
|
+
expect(first.emitText).toBe("token: ");
|
|
1220
|
+
expect(first.bufferedRemainder).toBe(head);
|
|
1221
|
+
const second = drainSentinelGuardedText(
|
|
1222
|
+
first.bufferedRemainder + tail + " done",
|
|
1223
|
+
[],
|
|
1224
|
+
[MINT],
|
|
1225
|
+
);
|
|
1226
|
+
expect(second.emitText).toBe(`${CANONICAL} done`);
|
|
1227
|
+
expect(second.bufferedRemainder).toBe("");
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test("stream guard still eagerly neutralizes complete triggers without recorded mints", () => {
|
|
1231
|
+
const out = drainSentinelGuardedText("x \u3014redacted:oops not closed");
|
|
1232
|
+
expect(out.emitText).toBe("x \u3014\u2060redacted:oops not closed");
|
|
1233
|
+
expect(out.bufferedRemainder).toBe("");
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("stream guard releases (neutralized) an unclosed trigger past the hold cap", () => {
|
|
1237
|
+
const forged = "\u3014redacted:" + "a".repeat(600);
|
|
1238
|
+
const out = drainSentinelGuardedText(`pre ${forged}`, [], [MINT]);
|
|
1239
|
+
expect(out.emitText).toContain("\u3014\u2060redacted:");
|
|
1240
|
+
expect(out.bufferedRemainder).toBe("");
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
describe("plain-reveal re-mint authorities (retyped sentinel after a proven reveal)", () => {
|
|
1245
|
+
const CANDIDATE = {
|
|
1246
|
+
service: "test",
|
|
1247
|
+
field: "qa_token",
|
|
1248
|
+
value: "hunter2-manual-token-value",
|
|
1249
|
+
};
|
|
1250
|
+
const CANONICAL = buildForChatSentinel(CANDIDATE);
|
|
1251
|
+
const AUTHORITIES = remintAuthoritiesFromCandidates([CANDIDATE]);
|
|
1252
|
+
|
|
1253
|
+
test("remintAuthoritiesFromCandidates builds one canonical authority per identity", () => {
|
|
1254
|
+
// Encoding-variant candidates share an identity and must not produce
|
|
1255
|
+
// duplicate authorities; the sentinel is minted from the first
|
|
1256
|
+
// candidate's own metadata, never from any retyped span.
|
|
1257
|
+
const variants = [
|
|
1258
|
+
CANDIDATE,
|
|
1259
|
+
{ ...CANDIDATE, value: "hunter2-manual\\ntoken" },
|
|
1260
|
+
];
|
|
1261
|
+
expect(remintAuthoritiesFromCandidates(variants)).toEqual([
|
|
1262
|
+
{
|
|
1263
|
+
service: "test",
|
|
1264
|
+
field: "qa_token",
|
|
1265
|
+
sentinel: CANONICAL,
|
|
1266
|
+
},
|
|
1267
|
+
]);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
test("persist re-mints a model-retyped sentinel whose identity was proven this turn", () => {
|
|
1271
|
+
// The LUM-2768 repro: history shows the model its own redacted reply;
|
|
1272
|
+
// asked to "show it again", it retypes the sentinel instead of echoing
|
|
1273
|
+
// the plaintext. With the identity proven this turn, the retyped span
|
|
1274
|
+
// is restored to the canonical mint instead of degrading to
|
|
1275
|
+
// neutralized glyph text.
|
|
1276
|
+
const retyped = `here it is again: ${CANONICAL}`;
|
|
1277
|
+
expect(redactSecretsForChat(retyped, [CANDIDATE], AUTHORITIES)).toBe(
|
|
1278
|
+
retyped,
|
|
1279
|
+
);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test("a tampered label on a proven identity canonicalizes instead of neutralizing", () => {
|
|
1283
|
+
const tampered = "\u3014redacted:GitHub Token:test:qa_token\u3015";
|
|
1284
|
+
expect(redactSecretsForChat(tampered, [CANDIDATE], AUTHORITIES)).toBe(
|
|
1285
|
+
CANONICAL,
|
|
1286
|
+
);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test("live guard re-mints the retyped sentinel so the wire matches persistence", () => {
|
|
1290
|
+
const out = drainSentinelGuardedText(
|
|
1291
|
+
`again: ${CANONICAL}!`,
|
|
1292
|
+
[],
|
|
1293
|
+
AUTHORITIES,
|
|
1294
|
+
);
|
|
1295
|
+
expect(out.emitText).toBe(`again: ${CANONICAL}!`);
|
|
1296
|
+
expect(out.bufferedRemainder).toBe("");
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test("an unproven identity still neutralizes even with other authorities present", () => {
|
|
1300
|
+
const other = "\u3014redacted:Credential:prod:api_key\u3015";
|
|
1301
|
+
expect(redactSecretsForChat(other, [CANDIDATE], AUTHORITIES)).toBe(
|
|
1302
|
+
neutralizeRedactedSentinels(other),
|
|
1303
|
+
);
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
describe("for-chat mint registry", () => {
|
|
1308
|
+
test("returns only mints recorded after the captured watermark", () => {
|
|
1309
|
+
resetForChatMintRegistryForTest();
|
|
1310
|
+
recordForChatMint({
|
|
1311
|
+
service: "old",
|
|
1312
|
+
field: "f",
|
|
1313
|
+
sentinel: "s0",
|
|
1314
|
+
nonce: "n1",
|
|
1315
|
+
});
|
|
1316
|
+
const watermark = currentForChatMintWatermark();
|
|
1317
|
+
expect(forChatMintsSince(watermark)).toEqual([]);
|
|
1318
|
+
recordForChatMint({
|
|
1319
|
+
service: "openai",
|
|
1320
|
+
field: "api_key",
|
|
1321
|
+
sentinel: "s1",
|
|
1322
|
+
nonce: "n1",
|
|
1323
|
+
});
|
|
1324
|
+
expect(forChatMintsSince(watermark)).toEqual([
|
|
1325
|
+
{ service: "openai", field: "api_key", sentinel: "s1", nonce: "n1" },
|
|
1326
|
+
]);
|
|
1327
|
+
// A turn that started before the first mint sees both identities.
|
|
1328
|
+
expect(forChatMintsSince(0)).toHaveLength(2);
|
|
1329
|
+
resetForChatMintRegistryForTest();
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
test("records carry the executing conversation's nonce for consumer-side binding", () => {
|
|
1333
|
+
// The registry stores the secret nonce the executing tool shell
|
|
1334
|
+
// forwarded; the agent loop accepts only records matching its own
|
|
1335
|
+
// conversation's nonce AND an identity its run staged, so a concurrent
|
|
1336
|
+
// conversation's executed reveal never authorizes another that merely
|
|
1337
|
+
// names the identity.
|
|
1338
|
+
resetForChatMintRegistryForTest();
|
|
1339
|
+
recordForChatMint({
|
|
1340
|
+
service: "openai",
|
|
1341
|
+
field: "api_key",
|
|
1342
|
+
sentinel: "s1",
|
|
1343
|
+
nonce: "n1",
|
|
1344
|
+
});
|
|
1345
|
+
expect(forChatMintsSince(0)).toEqual([
|
|
1346
|
+
{ service: "openai", field: "api_key", sentinel: "s1", nonce: "n1" },
|
|
1347
|
+
]);
|
|
1348
|
+
resetForChatMintRegistryForTest();
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
test("dedupes per nonce+identity with the latest sentinel winning", () => {
|
|
1352
|
+
resetForChatMintRegistryForTest();
|
|
1353
|
+
recordForChatMint({
|
|
1354
|
+
service: "svc",
|
|
1355
|
+
field: "f",
|
|
1356
|
+
sentinel: "first",
|
|
1357
|
+
nonce: "n1",
|
|
1358
|
+
});
|
|
1359
|
+
recordForChatMint({
|
|
1360
|
+
service: "svc",
|
|
1361
|
+
field: "f",
|
|
1362
|
+
sentinel: "second",
|
|
1363
|
+
nonce: "n1",
|
|
1364
|
+
});
|
|
1365
|
+
expect(forChatMintsSince(0)).toEqual([
|
|
1366
|
+
{ service: "svc", field: "f", sentinel: "second", nonce: "n1" },
|
|
1367
|
+
]);
|
|
1368
|
+
resetForChatMintRegistryForTest();
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("concurrent same-credential reveals from different conversations both survive", () => {
|
|
1372
|
+
// The dedupe key includes the nonce: consumers filter by THEIR nonce
|
|
1373
|
+
// after this call, so an identity-only dedupe would let conversation
|
|
1374
|
+
// B's later reveal of the same credential clobber conversation A's
|
|
1375
|
+
// legitimate mint and neutralize A's echoed sentinel.
|
|
1376
|
+
resetForChatMintRegistryForTest();
|
|
1377
|
+
recordForChatMint({
|
|
1378
|
+
service: "svc",
|
|
1379
|
+
field: "f",
|
|
1380
|
+
sentinel: "s-a",
|
|
1381
|
+
nonce: "nonce-a",
|
|
1382
|
+
});
|
|
1383
|
+
recordForChatMint({
|
|
1384
|
+
service: "svc",
|
|
1385
|
+
field: "f",
|
|
1386
|
+
sentinel: "s-b",
|
|
1387
|
+
nonce: "nonce-b",
|
|
1388
|
+
});
|
|
1389
|
+
const mints = forChatMintsSince(0);
|
|
1390
|
+
expect(mints).toHaveLength(2);
|
|
1391
|
+
expect(mints.find((m) => m.nonce === "nonce-a")?.sentinel).toBe("s-a");
|
|
1392
|
+
expect(mints.find((m) => m.nonce === "nonce-b")?.sentinel).toBe("s-b");
|
|
1393
|
+
resetForChatMintRegistryForTest();
|
|
1394
|
+
});
|
|
1395
|
+
});
|