@vellumai/assistant 0.3.15 → 0.3.16
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 +142 -0
- package/Dockerfile +1 -1
- package/README.md +5 -5
- package/docs/architecture/http-token-refresh.md +252 -0
- package/docs/architecture/memory.md +5 -4
- package/docs/architecture/scheduling.md +4 -88
- package/docs/runbook-trusted-contacts.md +283 -0
- package/docs/trusted-contact-access.md +247 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
- package/src/__tests__/access-request-decision.test.ts +331 -0
- package/src/__tests__/asset-materialize-tool.test.ts +7 -7
- package/src/__tests__/asset-search-tool.test.ts +15 -15
- package/src/__tests__/attachments-store.test.ts +13 -13
- package/src/__tests__/call-controller.test.ts +150 -4
- package/src/__tests__/call-conversation-messages.test.ts +2 -2
- package/src/__tests__/call-pointer-messages.test.ts +28 -0
- package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
- package/src/__tests__/channel-approval-routes.test.ts +108 -12
- package/src/__tests__/channel-guardian.test.ts +16 -14
- package/src/__tests__/checker.test.ts +24 -0
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
- package/src/__tests__/config-watcher.test.ts +358 -0
- package/src/__tests__/conversation-pairing.test.ts +24 -24
- package/src/__tests__/conversation-store.test.ts +36 -36
- package/src/__tests__/date-context.test.ts +179 -1
- package/src/__tests__/db-migration-rollback.test.ts +4 -7
- package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
- package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
- package/src/__tests__/gateway-only-guard.test.ts +188 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
- package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
- package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
- package/src/__tests__/guardian-action-sweep.test.ts +9 -9
- package/src/__tests__/guardian-outbound-http.test.ts +194 -2
- package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
- package/src/__tests__/handlers-telegram-config.test.ts +6 -6
- package/src/__tests__/hooks-runner.test.ts +13 -4
- package/src/__tests__/ingress-routes-http.test.ts +443 -0
- package/src/__tests__/intent-routing.test.ts +14 -0
- package/src/__tests__/ipc-snapshot.test.ts +2 -5
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-regressions.test.ts +16 -12
- package/src/__tests__/non-member-access-request.test.ts +282 -0
- package/src/__tests__/notification-decision-strategy.test.ts +136 -0
- package/src/__tests__/notification-routing-intent.test.ts +11 -1
- package/src/__tests__/notification-thread-candidates.test.ts +166 -0
- package/src/__tests__/recording-intent.test.ts +1 -0
- package/src/__tests__/recording-state-machine.test.ts +328 -17
- package/src/__tests__/registry.test.ts +17 -8
- package/src/__tests__/relay-server.test.ts +105 -0
- package/src/__tests__/reminder.test.ts +13 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
- package/src/__tests__/scheduler-recurrence.test.ts +50 -0
- package/src/__tests__/server-history-render.test.ts +8 -8
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-runtime-assembly.test.ts +49 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
- package/src/__tests__/slack-channel-config.test.ts +230 -0
- package/src/__tests__/subagent-manager-notify.test.ts +4 -4
- package/src/__tests__/swarm-session-integration.test.ts +2 -2
- package/src/__tests__/system-prompt.test.ts +43 -0
- package/src/__tests__/task-management-tools.test.ts +3 -3
- package/src/__tests__/task-tools.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +17 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
- package/src/__tests__/trusted-contact-verification.test.ts +360 -0
- package/src/__tests__/update-bulletin-format.test.ts +119 -0
- package/src/__tests__/update-bulletin-state.test.ts +129 -0
- package/src/__tests__/update-bulletin.test.ts +260 -0
- package/src/__tests__/update-template-contract.test.ts +29 -0
- package/src/agent/loop.ts +2 -2
- package/src/amazon/client.ts +2 -3
- package/src/calls/call-controller.ts +115 -34
- package/src/calls/call-conversation-messages.ts +2 -2
- package/src/calls/call-domain.ts +10 -3
- package/src/calls/call-pointer-messages.ts +17 -5
- package/src/calls/guardian-action-sweep.ts +77 -36
- package/src/calls/relay-server.ts +51 -12
- package/src/calls/twilio-routes.ts +3 -1
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -4
- package/src/cli/core-commands.ts +3 -3
- package/src/cli/map.ts +8 -5
- package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
- package/src/config/bundled-skills/tasks/SKILL.md +1 -1
- package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
- package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
- package/src/config/computer-use-prompt.ts +1 -0
- package/src/config/core-schema.ts +16 -0
- package/src/config/env-registry.ts +1 -0
- package/src/config/env.ts +16 -1
- package/src/config/memory-schema.ts +5 -0
- package/src/config/schema.ts +4 -0
- package/src/config/system-prompt.ts +69 -2
- package/src/config/templates/BOOTSTRAP.md +1 -1
- package/src/config/templates/IDENTITY.md +8 -4
- package/src/config/templates/SOUL.md +14 -0
- package/src/config/templates/UPDATES.md +16 -0
- package/src/config/templates/USER.md +5 -1
- package/src/config/types.ts +1 -0
- package/src/config/update-bulletin-format.ts +52 -0
- package/src/config/update-bulletin-state.ts +49 -0
- package/src/config/update-bulletin.ts +82 -0
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
- package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
- package/src/context/window-manager.ts +43 -3
- package/src/daemon/config-watcher.ts +1 -0
- package/src/daemon/connection-policy.ts +21 -1
- package/src/daemon/daemon-control.ts +164 -7
- package/src/daemon/date-context.ts +174 -1
- package/src/daemon/guardian-action-generators.ts +175 -0
- package/src/daemon/guardian-verification-intent.ts +120 -0
- package/src/daemon/handlers/apps.ts +1 -3
- package/src/daemon/handlers/config-channels.ts +2 -2
- package/src/daemon/handlers/config-heartbeat.ts +1 -1
- package/src/daemon/handlers/config-inbox.ts +55 -159
- package/src/daemon/handlers/config-ingress.ts +1 -1
- package/src/daemon/handlers/config-integrations.ts +1 -1
- package/src/daemon/handlers/config-platform.ts +1 -1
- package/src/daemon/handlers/config-scheduling.ts +2 -2
- package/src/daemon/handlers/config-slack-channel.ts +190 -0
- package/src/daemon/handlers/config-telegram.ts +1 -1
- package/src/daemon/handlers/config-twilio.ts +1 -1
- package/src/daemon/handlers/config-voice.ts +100 -0
- package/src/daemon/handlers/config.ts +3 -0
- package/src/daemon/handlers/misc.ts +83 -5
- package/src/daemon/handlers/navigate-settings.ts +27 -0
- package/src/daemon/handlers/recording.ts +270 -144
- package/src/daemon/handlers/sessions.ts +100 -17
- package/src/daemon/handlers/subagents.ts +3 -3
- package/src/daemon/handlers/work-items.ts +10 -7
- package/src/daemon/ipc-contract/integrations.ts +9 -1
- package/src/daemon/ipc-contract/messages.ts +4 -0
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/settings.ts +26 -0
- package/src/daemon/ipc-contract/shared.ts +2 -0
- package/src/daemon/ipc-contract/work-items.ts +1 -7
- package/src/daemon/ipc-contract-inventory.json +5 -1
- package/src/daemon/ipc-contract.ts +5 -1
- package/src/daemon/lifecycle.ts +306 -266
- package/src/daemon/recording-intent.ts +0 -41
- package/src/daemon/response-tier.ts +2 -2
- package/src/daemon/server.ts +6 -6
- package/src/daemon/session-agent-loop-handlers.ts +34 -9
- package/src/daemon/session-agent-loop.ts +15 -8
- package/src/daemon/session-history.ts +3 -2
- package/src/daemon/session-media-retry.ts +3 -0
- package/src/daemon/session-messaging.ts +38 -4
- package/src/daemon/session-notifiers.ts +2 -2
- package/src/daemon/session-process.ts +256 -23
- package/src/daemon/session-queue-manager.ts +2 -0
- package/src/daemon/session-runtime-assembly.ts +39 -0
- package/src/daemon/session-skill-tools.ts +13 -4
- package/src/daemon/session-tool-setup.ts +5 -6
- package/src/daemon/session.ts +19 -8
- package/src/daemon/tls-certs.ts +55 -13
- package/src/daemon/tool-side-effects.ts +13 -5
- package/src/gallery/default-gallery.ts +32 -9
- package/src/influencer/client.ts +2 -1
- package/src/memory/channel-delivery-store.ts +37 -567
- package/src/memory/channel-guardian-store.ts +66 -1317
- package/src/memory/conflict-store.ts +4 -4
- package/src/memory/conversation-attention-store.ts +0 -3
- package/src/memory/conversation-crud.ts +668 -0
- package/src/memory/conversation-queries.ts +361 -0
- package/src/memory/conversation-store.ts +45 -983
- package/src/memory/db-connection.ts +3 -0
- package/src/memory/db-init.ts +25 -0
- package/src/memory/delivery-channels.ts +175 -0
- package/src/memory/delivery-crud.ts +211 -0
- package/src/memory/delivery-status.ts +199 -0
- package/src/memory/embedding-backend.ts +70 -4
- package/src/memory/embedding-local.ts +12 -2
- package/src/memory/entity-extractor.ts +3 -8
- package/src/memory/fts-reconciler.ts +121 -0
- package/src/memory/guardian-action-store.ts +366 -3
- package/src/memory/guardian-approvals.ts +569 -0
- package/src/memory/guardian-bindings.ts +130 -0
- package/src/memory/guardian-rate-limits.ts +196 -0
- package/src/memory/guardian-verification.ts +520 -0
- package/src/memory/job-handlers/index-maintenance.ts +2 -1
- package/src/memory/job-utils.ts +8 -5
- package/src/memory/jobs-store.ts +66 -6
- package/src/memory/jobs-worker.ts +23 -1
- package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
- package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
- package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
- package/src/memory/migrations/100-core-tables.ts +1 -1
- package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
- package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
- package/src/memory/migrations/112-assistant-inbox.ts +1 -1
- package/src/memory/migrations/113-late-migrations.ts +1 -1
- package/src/memory/migrations/116-messages-fts.ts +13 -0
- package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
- package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
- package/src/memory/migrations/index.ts +8 -3
- package/src/memory/migrations/validate-migration-state.ts +114 -15
- package/src/memory/qdrant-circuit-breaker.ts +105 -0
- package/src/memory/retriever.ts +46 -13
- package/src/memory/schema-migration.ts +3 -0
- package/src/memory/schema.ts +25 -7
- package/src/memory/search/semantic.ts +8 -90
- package/src/notifications/README.md +1 -1
- package/src/notifications/broadcaster.ts +20 -2
- package/src/notifications/conversation-pairing.ts +3 -3
- package/src/notifications/decision-engine.ts +173 -8
- package/src/notifications/deliveries-store.ts +27 -8
- package/src/notifications/preferences-store.ts +7 -7
- package/src/notifications/thread-candidates.ts +234 -0
- package/src/notifications/types.ts +18 -0
- package/src/permissions/defaults.ts +11 -1
- package/src/permissions/prompter.ts +17 -0
- package/src/permissions/trust-store.ts +2 -0
- package/src/providers/failover.ts +19 -0
- package/src/providers/registry.ts +46 -1
- package/src/runtime/approval-message-composer.ts +1 -1
- package/src/runtime/channel-guardian-service.ts +15 -3
- package/src/runtime/channel-retry-sweep.ts +7 -2
- package/src/runtime/guardian-action-conversation-turn.ts +85 -0
- package/src/runtime/guardian-action-followup-executor.ts +301 -0
- package/src/runtime/guardian-action-message-composer.ts +245 -0
- package/src/runtime/guardian-outbound-actions.ts +26 -6
- package/src/runtime/guardian-verification-templates.ts +15 -9
- package/src/runtime/http-errors.ts +93 -0
- package/src/runtime/http-server.ts +133 -44
- package/src/runtime/http-types.ts +53 -0
- package/src/runtime/ingress-service.ts +237 -0
- package/src/runtime/middleware/error-handler.ts +4 -3
- package/src/runtime/middleware/rate-limiter.ts +160 -0
- package/src/runtime/middleware/request-logger.ts +71 -0
- package/src/runtime/middleware/twilio-validation.ts +7 -6
- package/src/runtime/pending-interactions.ts +12 -0
- package/src/runtime/routes/access-request-decision.ts +215 -0
- package/src/runtime/routes/app-routes.ts +25 -18
- package/src/runtime/routes/approval-routes.ts +18 -47
- package/src/runtime/routes/attachment-routes.ts +15 -41
- package/src/runtime/routes/call-routes.ts +20 -20
- package/src/runtime/routes/channel-delivery-routes.ts +6 -5
- package/src/runtime/routes/contact-routes.ts +4 -9
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +26 -57
- package/src/runtime/routes/debug-routes.ts +71 -0
- package/src/runtime/routes/events-routes.ts +3 -2
- package/src/runtime/routes/guardian-approval-interception.ts +221 -0
- package/src/runtime/routes/identity-routes.ts +14 -10
- package/src/runtime/routes/inbound-conversation.ts +3 -2
- package/src/runtime/routes/inbound-message-handler.ts +527 -62
- package/src/runtime/routes/ingress-routes.ts +174 -0
- package/src/runtime/routes/integration-routes.ts +78 -16
- package/src/runtime/routes/pairing-routes.ts +11 -10
- package/src/runtime/routes/secret-routes.ts +10 -18
- package/src/runtime/verification-rate-limiter.ts +83 -0
- package/src/schedule/schedule-store.ts +13 -1
- package/src/schedule/scheduler.ts +1 -1
- package/src/security/secret-ingress.ts +5 -2
- package/src/security/secret-scanner.ts +72 -6
- package/src/subagent/manager.ts +6 -4
- package/src/swarm/plan-validator.ts +4 -1
- package/src/tasks/task-runner.ts +3 -1
- package/src/tools/browser/api-map.ts +9 -6
- package/src/tools/calls/call-start.ts +20 -0
- package/src/tools/executor.ts +50 -568
- package/src/tools/permission-checker.ts +272 -0
- package/src/tools/registry.ts +14 -6
- package/src/tools/reminder/reminder-store.ts +7 -7
- package/src/tools/reminder/reminder.ts +6 -3
- package/src/tools/secret-detection-handler.ts +301 -0
- package/src/tools/subagent/message.ts +1 -1
- package/src/tools/system/voice-config.ts +62 -0
- package/src/tools/tasks/index.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +3 -3
- package/src/tools/tasks/work-item-update.ts +4 -5
- package/src/tools/tool-approval-handler.ts +192 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/watcher/watcher-store.ts +9 -9
- package/src/work-items/work-item-runner.ts +9 -6
- /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
- /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { getConfig } from '../config/loader.js';
|
|
2
|
+
import { getHookManager } from '../hooks/manager.js';
|
|
3
|
+
import { check, classifyRisk, generateAllowlistOptions, generateScopeOptions } from '../permissions/checker.js';
|
|
4
|
+
import type { PermissionPrompter } from '../permissions/prompter.js';
|
|
5
|
+
import { addRule } from '../permissions/trust-store.js';
|
|
6
|
+
import { RiskLevel } from '../permissions/types.js';
|
|
7
|
+
import { getLogger } from '../util/logger.js';
|
|
8
|
+
import type { ExecutionTarget } from './types.js';
|
|
9
|
+
import { buildPolicyContext } from './policy-context.js';
|
|
10
|
+
import { isSideEffectTool } from './side-effects.js';
|
|
11
|
+
import { wrapCommand } from './terminal/sandbox.js';
|
|
12
|
+
import type { Tool, ToolContext, ToolLifecycleEvent } from './types.js';
|
|
13
|
+
|
|
14
|
+
const log = getLogger('permission-checker');
|
|
15
|
+
|
|
16
|
+
export type PermissionDecision =
|
|
17
|
+
| { allowed: true; decision: string; riskLevel: string }
|
|
18
|
+
| { allowed: false; decision: string; riskLevel: string; content: string };
|
|
19
|
+
|
|
20
|
+
export class PermissionChecker {
|
|
21
|
+
private prompter: PermissionPrompter;
|
|
22
|
+
|
|
23
|
+
constructor(prompter: PermissionPrompter) {
|
|
24
|
+
this.prompter = prompter;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run risk classification, trust rule evaluation, and (if needed) user
|
|
29
|
+
* prompting for a tool invocation. Returns whether the tool is allowed
|
|
30
|
+
* to execute, along with the decision string and risk level for lifecycle
|
|
31
|
+
* event reporting.
|
|
32
|
+
*/
|
|
33
|
+
async checkPermission(
|
|
34
|
+
name: string,
|
|
35
|
+
input: Record<string, unknown>,
|
|
36
|
+
tool: Tool,
|
|
37
|
+
context: ToolContext,
|
|
38
|
+
executionTarget: ExecutionTarget,
|
|
39
|
+
emitLifecycleEvent: (event: ToolLifecycleEvent) => void,
|
|
40
|
+
sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
|
|
41
|
+
startTime: number,
|
|
42
|
+
computePreviewDiff: (toolName: string, input: Record<string, unknown>, workingDir: string) => { filePath: string; oldContent: string; newContent: string; isNewFile: boolean } | undefined,
|
|
43
|
+
): Promise<PermissionDecision> {
|
|
44
|
+
const risk = await classifyRisk(name, input, context.workingDir, undefined, undefined, context.signal);
|
|
45
|
+
const riskLevel: string = risk;
|
|
46
|
+
|
|
47
|
+
// Wrap the rest of permission evaluation so that any exception
|
|
48
|
+
// carries the classified risk level back to the caller. Without
|
|
49
|
+
// this, the executor's catch block would fall back to the default
|
|
50
|
+
// low risk, degrading audit/alert accuracy for high-risk attempts.
|
|
51
|
+
try {
|
|
52
|
+
const policyContext = buildPolicyContext(tool, context);
|
|
53
|
+
const result = await check(name, input, context.workingDir, policyContext, undefined, context.signal);
|
|
54
|
+
|
|
55
|
+
// Private threads force prompting for side-effect tools even when a
|
|
56
|
+
// trust/allow rule would auto-allow. Deny decisions are preserved —
|
|
57
|
+
// only allow → prompt promotion happens here.
|
|
58
|
+
if (
|
|
59
|
+
context.forcePromptSideEffects
|
|
60
|
+
&& result.decision === 'allow'
|
|
61
|
+
&& isSideEffectTool(name, input)
|
|
62
|
+
) {
|
|
63
|
+
result.decision = 'prompt';
|
|
64
|
+
result.reason = 'Private thread: side-effect tools require explicit approval';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (result.decision === 'deny') {
|
|
68
|
+
const durationMs = Date.now() - startTime;
|
|
69
|
+
emitLifecycleEvent({
|
|
70
|
+
type: 'permission_denied',
|
|
71
|
+
toolName: name,
|
|
72
|
+
executionTarget,
|
|
73
|
+
input,
|
|
74
|
+
workingDir: context.workingDir,
|
|
75
|
+
sessionId: context.sessionId,
|
|
76
|
+
conversationId: context.conversationId,
|
|
77
|
+
requestId: context.requestId,
|
|
78
|
+
riskLevel,
|
|
79
|
+
decision: 'deny',
|
|
80
|
+
reason: result.reason,
|
|
81
|
+
durationMs,
|
|
82
|
+
});
|
|
83
|
+
return { allowed: false, decision: 'denied', riskLevel, content: result.reason };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (result.decision === 'prompt') {
|
|
87
|
+
// Non-interactive sessions have no client to respond to prompts —
|
|
88
|
+
// deny immediately instead of blocking for the full permission timeout.
|
|
89
|
+
if (context.isInteractive === false) {
|
|
90
|
+
const durationMs = Date.now() - startTime;
|
|
91
|
+
log.info({ toolName: name, riskLevel }, 'Auto-denying prompt for non-interactive session');
|
|
92
|
+
emitLifecycleEvent({
|
|
93
|
+
type: 'permission_denied',
|
|
94
|
+
toolName: name,
|
|
95
|
+
executionTarget,
|
|
96
|
+
input,
|
|
97
|
+
workingDir: context.workingDir,
|
|
98
|
+
sessionId: context.sessionId,
|
|
99
|
+
conversationId: context.conversationId,
|
|
100
|
+
requestId: context.requestId,
|
|
101
|
+
riskLevel,
|
|
102
|
+
decision: 'deny',
|
|
103
|
+
reason: 'Non-interactive session: no client to approve prompt',
|
|
104
|
+
durationMs,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
allowed: false,
|
|
108
|
+
decision: 'denied',
|
|
109
|
+
riskLevel,
|
|
110
|
+
content: `Permission denied: tool "${name}" requires user approval but no interactive client is connected. The tool was not executed. To allow this tool in non-interactive sessions, add a trust rule via permission settings.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const allowlistOptions = await generateAllowlistOptions(name, input, context.signal);
|
|
115
|
+
const scopeOptions = generateScopeOptions(context.workingDir, name);
|
|
116
|
+
const previewDiff = computePreviewDiff(name, input, context.workingDir);
|
|
117
|
+
|
|
118
|
+
let sandboxed: boolean | undefined;
|
|
119
|
+
if (name === 'bash' && typeof input.command === 'string') {
|
|
120
|
+
const cfg = getConfig();
|
|
121
|
+
const sandboxConfig = context.sandboxOverride != null
|
|
122
|
+
? { ...cfg.sandbox, enabled: context.sandboxOverride }
|
|
123
|
+
: cfg.sandbox;
|
|
124
|
+
const wrapped = wrapCommand(input.command, context.workingDir, sandboxConfig);
|
|
125
|
+
sandboxed = wrapped.sandboxed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Proxied bash prompts are non-persistent — no trust rule saving allowed
|
|
129
|
+
const persistentDecisionsAllowed = !(
|
|
130
|
+
name === 'bash'
|
|
131
|
+
&& input.network_mode === 'proxied'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
emitLifecycleEvent({
|
|
135
|
+
type: 'permission_prompt',
|
|
136
|
+
toolName: name,
|
|
137
|
+
executionTarget,
|
|
138
|
+
input,
|
|
139
|
+
workingDir: context.workingDir,
|
|
140
|
+
sessionId: context.sessionId,
|
|
141
|
+
conversationId: context.conversationId,
|
|
142
|
+
requestId: context.requestId,
|
|
143
|
+
riskLevel,
|
|
144
|
+
reason: result.reason,
|
|
145
|
+
allowlistOptions,
|
|
146
|
+
scopeOptions,
|
|
147
|
+
diff: previewDiff,
|
|
148
|
+
sandboxed,
|
|
149
|
+
persistentDecisionsAllowed,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await getHookManager().trigger('permission-request', {
|
|
153
|
+
toolName: name,
|
|
154
|
+
input: sanitizeToolInput(name, input),
|
|
155
|
+
riskLevel,
|
|
156
|
+
sessionId: context.sessionId,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const response = await this.prompter.prompt(
|
|
160
|
+
name,
|
|
161
|
+
input,
|
|
162
|
+
riskLevel,
|
|
163
|
+
allowlistOptions,
|
|
164
|
+
scopeOptions,
|
|
165
|
+
previewDiff,
|
|
166
|
+
sandboxed,
|
|
167
|
+
context.conversationId,
|
|
168
|
+
executionTarget,
|
|
169
|
+
persistentDecisionsAllowed,
|
|
170
|
+
context.signal,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const decision = response.decision;
|
|
174
|
+
|
|
175
|
+
await getHookManager().trigger('permission-resolve', {
|
|
176
|
+
toolName: name,
|
|
177
|
+
decision: response.decision,
|
|
178
|
+
riskLevel,
|
|
179
|
+
sessionId: context.sessionId,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (response.decision === 'deny') {
|
|
183
|
+
const contextualDenial = typeof response.decisionContext === 'string'
|
|
184
|
+
? response.decisionContext.trim()
|
|
185
|
+
: '';
|
|
186
|
+
const denialMessage = contextualDenial.length > 0
|
|
187
|
+
? contextualDenial
|
|
188
|
+
: `Permission denied by user. The user chose not to allow the "${name}" tool. Do NOT retry this tool call immediately. Instead, tell the user that the action was not performed because they denied permission, and ask if they would like you to try again or take a different approach. Wait for the user to explicitly respond before retrying.`;
|
|
189
|
+
const denialReason = contextualDenial.length > 0
|
|
190
|
+
? `Permission denied (${name}): contextual policy`
|
|
191
|
+
: 'Permission denied by user';
|
|
192
|
+
const durationMs = Date.now() - startTime;
|
|
193
|
+
emitLifecycleEvent({
|
|
194
|
+
type: 'permission_denied',
|
|
195
|
+
toolName: name,
|
|
196
|
+
executionTarget,
|
|
197
|
+
input,
|
|
198
|
+
workingDir: context.workingDir,
|
|
199
|
+
sessionId: context.sessionId,
|
|
200
|
+
conversationId: context.conversationId,
|
|
201
|
+
requestId: context.requestId,
|
|
202
|
+
riskLevel,
|
|
203
|
+
decision: 'deny',
|
|
204
|
+
reason: denialReason,
|
|
205
|
+
durationMs,
|
|
206
|
+
});
|
|
207
|
+
return { allowed: false, decision, riskLevel, content: denialMessage };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (response.decision === 'always_deny') {
|
|
211
|
+
const ruleSaved = !!(persistentDecisionsAllowed && response.selectedPattern && response.selectedScope);
|
|
212
|
+
if (ruleSaved) {
|
|
213
|
+
addRule(name, response.selectedPattern!, response.selectedScope!, 'deny');
|
|
214
|
+
}
|
|
215
|
+
const denialReason = ruleSaved ? 'Permission denied by user (rule saved)' : 'Permission denied by user';
|
|
216
|
+
const denialMessage = ruleSaved
|
|
217
|
+
? `Permission denied by user, and a rule was saved to always deny the "${name}" tool for this pattern. Do NOT retry this tool call. Inform the user that this action has been permanently blocked by their preference. If the user wants to allow it in the future, they can update their permission rules.`
|
|
218
|
+
: `Permission denied by user. The user chose not to allow the "${name}" tool. Do NOT retry this tool call immediately. Instead, tell the user that the action was not performed because they denied permission, and ask if they would like you to try again or take a different approach. Wait for the user to explicitly respond before retrying.`;
|
|
219
|
+
const durationMs = Date.now() - startTime;
|
|
220
|
+
emitLifecycleEvent({
|
|
221
|
+
type: 'permission_denied',
|
|
222
|
+
toolName: name,
|
|
223
|
+
executionTarget,
|
|
224
|
+
input,
|
|
225
|
+
workingDir: context.workingDir,
|
|
226
|
+
sessionId: context.sessionId,
|
|
227
|
+
conversationId: context.conversationId,
|
|
228
|
+
requestId: context.requestId,
|
|
229
|
+
riskLevel,
|
|
230
|
+
decision: 'always_deny',
|
|
231
|
+
reason: denialReason,
|
|
232
|
+
durationMs,
|
|
233
|
+
});
|
|
234
|
+
return { allowed: false, decision, riskLevel, content: denialMessage };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
persistentDecisionsAllowed
|
|
239
|
+
&& (response.decision === 'always_allow' || response.decision === 'always_allow_high_risk')
|
|
240
|
+
&& response.selectedPattern
|
|
241
|
+
&& response.selectedScope
|
|
242
|
+
) {
|
|
243
|
+
const ruleOptions: {
|
|
244
|
+
allowHighRisk?: boolean;
|
|
245
|
+
executionTarget?: string;
|
|
246
|
+
} = {};
|
|
247
|
+
|
|
248
|
+
if (response.decision === 'always_allow_high_risk') {
|
|
249
|
+
ruleOptions.allowHighRisk = true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (policyContext?.executionTarget != null) {
|
|
253
|
+
ruleOptions.executionTarget = policyContext.executionTarget;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const hasOptions = Object.keys(ruleOptions).length > 0;
|
|
257
|
+
addRule(name, response.selectedPattern, response.selectedScope, 'allow', 100, hasOptions ? ruleOptions : undefined);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { allowed: true, decision, riskLevel };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// result.decision === 'allow'
|
|
264
|
+
return { allowed: true, decision: 'allow', riskLevel };
|
|
265
|
+
} catch (err) {
|
|
266
|
+
if (err instanceof Error) {
|
|
267
|
+
(err as Error & { riskLevel?: string }).riskLevel = riskLevel;
|
|
268
|
+
}
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -109,20 +109,25 @@ export function getAllTools(): Tool[] {
|
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Register multiple skill-origin tools at once.
|
|
112
|
-
*
|
|
112
|
+
* Skips any tool whose name collides with a core tool (logs a warning instead
|
|
113
|
+
* of throwing so the remaining tools in the batch still get registered).
|
|
114
|
+
* Throws if a tool name collides with a skill tool owned by a different skill.
|
|
113
115
|
* Allows replacement when the incoming tool has the same ownerSkillId as the existing one,
|
|
114
116
|
* which supports hot-reloading a skill without tearing down first.
|
|
115
117
|
*/
|
|
116
|
-
export function registerSkillTools(newTools: Tool[]):
|
|
117
|
-
//
|
|
118
|
+
export function registerSkillTools(newTools: Tool[]): Tool[] {
|
|
119
|
+
// Filter out tools that collide with core tools, and validate the rest.
|
|
120
|
+
const accepted: Tool[] = [];
|
|
118
121
|
for (const tool of newTools) {
|
|
119
122
|
const existing = tools.get(tool.name);
|
|
120
123
|
if (existing) {
|
|
121
124
|
const existingIsCore = existing.origin !== 'skill';
|
|
122
125
|
if (existingIsCore) {
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
log.warn(
|
|
127
|
+
{ toolName: tool.name, skillId: tool.ownerSkillId },
|
|
128
|
+
`Skill "${tool.ownerSkillId}" tried to register tool "${tool.name}" which conflicts with a core tool. Skipping.`,
|
|
125
129
|
);
|
|
130
|
+
continue;
|
|
126
131
|
}
|
|
127
132
|
// Existing is also a skill tool — only allow replacement from the same owner.
|
|
128
133
|
if (existing.ownerSkillId !== tool.ownerSkillId) {
|
|
@@ -131,11 +136,12 @@ export function registerSkillTools(newTools: Tool[]): void {
|
|
|
131
136
|
);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
139
|
+
accepted.push(tool);
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
// Collect unique skill IDs from the batch to bump ref counts
|
|
137
143
|
const skillIds = new Set<string>();
|
|
138
|
-
for (const tool of
|
|
144
|
+
for (const tool of accepted) {
|
|
139
145
|
tools.set(tool.name, tool);
|
|
140
146
|
if (tool.ownerSkillId) skillIds.add(tool.ownerSkillId);
|
|
141
147
|
log.info({ name: tool.name, ownerSkillId: tool.ownerSkillId }, 'Skill tool registered');
|
|
@@ -144,6 +150,8 @@ export function registerSkillTools(newTools: Tool[]): void {
|
|
|
144
150
|
for (const id of skillIds) {
|
|
145
151
|
skillRefCount.set(id, (skillRefCount.get(id) ?? 0) + 1);
|
|
146
152
|
}
|
|
153
|
+
|
|
154
|
+
return accepted;
|
|
147
155
|
}
|
|
148
156
|
|
|
149
157
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, asc, eq, lte } from 'drizzle-orm';
|
|
2
2
|
import { v4 as uuid } from 'uuid';
|
|
3
3
|
|
|
4
|
-
import { getDb } from '../../memory/db.js';
|
|
4
|
+
import { getDb, rawChanges } from '../../memory/db.js';
|
|
5
5
|
import { reminders } from '../../memory/schema.js';
|
|
6
6
|
import { cast,createRowMapper, parseJson } from '../../util/row-mapper.js';
|
|
7
7
|
|
|
@@ -107,12 +107,12 @@ export function listReminders(options?: { pendingOnly?: boolean }): ReminderRow[
|
|
|
107
107
|
export function cancelReminder(id: string): boolean {
|
|
108
108
|
const db = getDb();
|
|
109
109
|
const now = Date.now();
|
|
110
|
-
|
|
110
|
+
db
|
|
111
111
|
.update(reminders)
|
|
112
112
|
.set({ status: 'cancelled', updatedAt: now })
|
|
113
113
|
.where(and(eq(reminders.id, id), eq(reminders.status, 'pending')))
|
|
114
|
-
.run()
|
|
115
|
-
return (
|
|
114
|
+
.run();
|
|
115
|
+
return rawChanges() > 0;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
@@ -132,13 +132,13 @@ export function claimDueReminders(now: number): ReminderRow[] {
|
|
|
132
132
|
|
|
133
133
|
const claimed: ReminderRow[] = [];
|
|
134
134
|
for (const row of candidates) {
|
|
135
|
-
|
|
135
|
+
db
|
|
136
136
|
.update(reminders)
|
|
137
137
|
.set({ status: 'firing', firedAt: now, updatedAt: now })
|
|
138
138
|
.where(and(eq(reminders.id, row.id), eq(reminders.status, 'pending')))
|
|
139
|
-
.run()
|
|
139
|
+
.run();
|
|
140
140
|
|
|
141
|
-
if ((
|
|
141
|
+
if (rawChanges() === 0) continue;
|
|
142
142
|
|
|
143
143
|
claimed.push(parseRow({
|
|
144
144
|
...row,
|
|
@@ -22,7 +22,7 @@ export function executeReminderCreate(input: Record<string, unknown>): ToolExecu
|
|
|
22
22
|
const message = input.message as string | undefined;
|
|
23
23
|
const mode = (input.mode as string | undefined) ?? 'notify';
|
|
24
24
|
const routingIntentRaw = input.routing_intent as string | undefined;
|
|
25
|
-
const routingHintsRaw = input.routing_hints as
|
|
25
|
+
const routingHintsRaw = input.routing_hints as unknown;
|
|
26
26
|
|
|
27
27
|
if (!fireAtStr) {
|
|
28
28
|
return { content: 'Error: fire_at is required for create', isError: true };
|
|
@@ -47,10 +47,13 @@ export function executeReminderCreate(input: Record<string, unknown>): ToolExecu
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// Validate routing_hints if provided
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
if (
|
|
51
|
+
routingHintsRaw !== undefined
|
|
52
|
+
&& (!routingHintsRaw || typeof routingHintsRaw !== 'object' || Array.isArray(routingHintsRaw))
|
|
53
|
+
) {
|
|
52
54
|
return { content: 'Error: routing_hints must be a JSON object', isError: true };
|
|
53
55
|
}
|
|
56
|
+
const routingHints: RoutingHints = routingHintsRaw === undefined ? {} : routingHintsRaw as RoutingHints;
|
|
54
57
|
|
|
55
58
|
// Require strict ISO 8601 with timezone offset or Z to avoid ambiguous parsing
|
|
56
59
|
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(fireAtStr)) {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { getConfig } from '../config/loader.js';
|
|
2
|
+
import { getHookManager } from '../hooks/manager.js';
|
|
3
|
+
import { PermissionPrompter } from '../permissions/prompter.js';
|
|
4
|
+
import { RiskLevel } from '../permissions/types.js';
|
|
5
|
+
import { compileCustomPatterns, redactSecrets, scanText } from '../security/secret-scanner.js';
|
|
6
|
+
import type { SecretPattern } from '../security/secret-scanner.js';
|
|
7
|
+
import type { ExecutionTarget, ToolContext, ToolExecutionResult, ToolLifecycleEvent } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encapsulates post-execution secret detection, redaction, and action handling.
|
|
11
|
+
* Extracted from ToolExecutor to isolate the secret-scanning concern.
|
|
12
|
+
*/
|
|
13
|
+
export class SecretDetectionHandler {
|
|
14
|
+
private prompter: PermissionPrompter;
|
|
15
|
+
|
|
16
|
+
constructor(prompter: PermissionPrompter) {
|
|
17
|
+
this.prompter = prompter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scan a tool execution result for secrets and apply the configured action
|
|
22
|
+
* (redact, block, or prompt). Returns the (possibly modified) result, or
|
|
23
|
+
* a blocked result if secrets were blocked. Returns `null` when no secret
|
|
24
|
+
* handling was needed and the caller should continue normally.
|
|
25
|
+
*/
|
|
26
|
+
async handle(
|
|
27
|
+
execResult: ToolExecutionResult,
|
|
28
|
+
name: string,
|
|
29
|
+
input: Record<string, unknown>,
|
|
30
|
+
context: ToolContext,
|
|
31
|
+
executionTarget: ExecutionTarget,
|
|
32
|
+
riskLevel: string,
|
|
33
|
+
decision: string,
|
|
34
|
+
startTime: number,
|
|
35
|
+
emitLifecycleEvent: (context: ToolContext, event: ToolLifecycleEvent) => void,
|
|
36
|
+
sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
|
|
37
|
+
): Promise<{ result: ToolExecutionResult; earlyReturn: boolean }> {
|
|
38
|
+
const sdConfig = getConfig().secretDetection;
|
|
39
|
+
if (!sdConfig.enabled || execResult.isError) {
|
|
40
|
+
return { result: execResult, earlyReturn: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entropyConfig = { enabled: true, base64Threshold: sdConfig.entropyThreshold };
|
|
44
|
+
const compiledCustom = sdConfig.customPatterns?.length
|
|
45
|
+
? compileCustomPatterns(sdConfig.customPatterns)
|
|
46
|
+
: undefined;
|
|
47
|
+
|
|
48
|
+
const allMatches = this.collectMatches(execResult, entropyConfig, compiledCustom);
|
|
49
|
+
|
|
50
|
+
if (allMatches.length === 0) {
|
|
51
|
+
return { result: execResult, earlyReturn: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const matchSummary = allMatches.map((m) => ({
|
|
55
|
+
type: m.type,
|
|
56
|
+
redactedValue: m.redactedValue,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
emitLifecycleEvent(context, {
|
|
60
|
+
type: 'secret_detected',
|
|
61
|
+
toolName: name,
|
|
62
|
+
executionTarget,
|
|
63
|
+
input,
|
|
64
|
+
workingDir: context.workingDir,
|
|
65
|
+
sessionId: context.sessionId,
|
|
66
|
+
conversationId: context.conversationId,
|
|
67
|
+
requestId: context.requestId,
|
|
68
|
+
matches: matchSummary,
|
|
69
|
+
action: sdConfig.action,
|
|
70
|
+
detectedAtMs: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (sdConfig.action === 'redact') {
|
|
74
|
+
this.redactResult(execResult, entropyConfig, compiledCustom);
|
|
75
|
+
return { result: execResult, earlyReturn: false };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sdConfig.action === 'block') {
|
|
79
|
+
return this.handleBlock(
|
|
80
|
+
allMatches, name, input, context, executionTarget,
|
|
81
|
+
riskLevel, decision, startTime, emitLifecycleEvent, sanitizeToolInput,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (sdConfig.action === 'prompt') {
|
|
86
|
+
return this.handlePrompt(
|
|
87
|
+
allMatches, execResult, name, input, context, executionTarget,
|
|
88
|
+
riskLevel, decision, startTime, emitLifecycleEvent, sanitizeToolInput,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { result: execResult, earlyReturn: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private collectMatches(
|
|
96
|
+
execResult: ToolExecutionResult,
|
|
97
|
+
entropyConfig: { enabled: boolean; base64Threshold: number },
|
|
98
|
+
compiledCustom: SecretPattern[] | undefined,
|
|
99
|
+
) {
|
|
100
|
+
const contentMatches = scanText(execResult.content, entropyConfig, compiledCustom);
|
|
101
|
+
const diffMatches = execResult.diff
|
|
102
|
+
? scanText(execResult.diff.newContent, entropyConfig, compiledCustom)
|
|
103
|
+
: [];
|
|
104
|
+
const blockMatches = (execResult.contentBlocks ?? []).flatMap((block) => {
|
|
105
|
+
if (block.type === 'text') return scanText(block.text, entropyConfig, compiledCustom);
|
|
106
|
+
if (block.type === 'file' && block.extracted_text) return scanText(block.extracted_text, entropyConfig, compiledCustom);
|
|
107
|
+
return [];
|
|
108
|
+
});
|
|
109
|
+
return [...contentMatches, ...diffMatches, ...blockMatches];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private redactResult(
|
|
113
|
+
execResult: ToolExecutionResult,
|
|
114
|
+
entropyConfig: { enabled: boolean; base64Threshold: number },
|
|
115
|
+
compiledCustom: SecretPattern[] | undefined,
|
|
116
|
+
): void {
|
|
117
|
+
execResult.content = redactSecrets(execResult.content, entropyConfig, compiledCustom);
|
|
118
|
+
if (execResult.diff) {
|
|
119
|
+
execResult.diff = {
|
|
120
|
+
...execResult.diff,
|
|
121
|
+
newContent: redactSecrets(execResult.diff.newContent, entropyConfig, compiledCustom),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (execResult.contentBlocks) {
|
|
125
|
+
execResult.contentBlocks = execResult.contentBlocks.map((block) => {
|
|
126
|
+
if (block.type === 'text') {
|
|
127
|
+
return { ...block, text: redactSecrets(block.text, entropyConfig, compiledCustom) };
|
|
128
|
+
}
|
|
129
|
+
if (block.type === 'file' && block.extracted_text) {
|
|
130
|
+
return { ...block, extracted_text: redactSecrets(block.extracted_text, entropyConfig, compiledCustom) };
|
|
131
|
+
}
|
|
132
|
+
return block;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private handleBlock(
|
|
138
|
+
allMatches: Array<{ type: string; redactedValue: string }>,
|
|
139
|
+
name: string,
|
|
140
|
+
input: Record<string, unknown>,
|
|
141
|
+
context: ToolContext,
|
|
142
|
+
executionTarget: ExecutionTarget,
|
|
143
|
+
riskLevel: string,
|
|
144
|
+
decision: string,
|
|
145
|
+
startTime: number,
|
|
146
|
+
emitLifecycleEvent: (context: ToolContext, event: ToolLifecycleEvent) => void,
|
|
147
|
+
sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
|
|
148
|
+
): { result: ToolExecutionResult; earlyReturn: boolean } {
|
|
149
|
+
const types = [...new Set(allMatches.map((m) => m.type))].join(', ');
|
|
150
|
+
const blockedContent = `Tool output blocked: detected ${allMatches.length} potential secret(s) (${types}). Configure secretDetection.action to "redact" or "prompt" to allow output.`;
|
|
151
|
+
const durationMs = Date.now() - startTime;
|
|
152
|
+
const blockedResult: ToolExecutionResult = {
|
|
153
|
+
content: blockedContent,
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
emitLifecycleEvent(context, {
|
|
158
|
+
type: 'executed',
|
|
159
|
+
toolName: name,
|
|
160
|
+
executionTarget,
|
|
161
|
+
input,
|
|
162
|
+
workingDir: context.workingDir,
|
|
163
|
+
sessionId: context.sessionId,
|
|
164
|
+
conversationId: context.conversationId,
|
|
165
|
+
requestId: context.requestId,
|
|
166
|
+
riskLevel,
|
|
167
|
+
decision,
|
|
168
|
+
durationMs,
|
|
169
|
+
result: blockedResult,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
void getHookManager().trigger('post-tool-execute', {
|
|
173
|
+
toolName: name,
|
|
174
|
+
input: sanitizeToolInput(name, input),
|
|
175
|
+
riskLevel,
|
|
176
|
+
isError: true,
|
|
177
|
+
durationMs,
|
|
178
|
+
sessionId: context.sessionId,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return { result: blockedResult, earlyReturn: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async handlePrompt(
|
|
185
|
+
allMatches: Array<{ type: string; redactedValue: string }>,
|
|
186
|
+
execResult: ToolExecutionResult,
|
|
187
|
+
name: string,
|
|
188
|
+
input: Record<string, unknown>,
|
|
189
|
+
context: ToolContext,
|
|
190
|
+
executionTarget: ExecutionTarget,
|
|
191
|
+
riskLevel: string,
|
|
192
|
+
_decision: string,
|
|
193
|
+
startTime: number,
|
|
194
|
+
emitLifecycleEvent: (context: ToolContext, event: ToolLifecycleEvent) => void,
|
|
195
|
+
sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
|
|
196
|
+
): Promise<{ result: ToolExecutionResult; earlyReturn: boolean }> {
|
|
197
|
+
const types = [...new Set(allMatches.map((m) => m.type))].join(', ');
|
|
198
|
+
|
|
199
|
+
// Non-interactive sessions: auto-block secret output instead of waiting for prompt
|
|
200
|
+
if (context.isInteractive === false) {
|
|
201
|
+
const blockedContent = `Tool output blocked: detected ${allMatches.length} potential secret(s) (${types}). No interactive client available to approve.`;
|
|
202
|
+
const durationMs = Date.now() - startTime;
|
|
203
|
+
|
|
204
|
+
emitLifecycleEvent(context, {
|
|
205
|
+
type: 'permission_denied',
|
|
206
|
+
toolName: name,
|
|
207
|
+
executionTarget,
|
|
208
|
+
input,
|
|
209
|
+
workingDir: context.workingDir,
|
|
210
|
+
sessionId: context.sessionId,
|
|
211
|
+
conversationId: context.conversationId,
|
|
212
|
+
requestId: context.requestId,
|
|
213
|
+
riskLevel: RiskLevel.High,
|
|
214
|
+
decision: 'deny',
|
|
215
|
+
reason: 'Non-interactive session: auto-blocked secret output',
|
|
216
|
+
durationMs,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
void getHookManager().trigger('post-tool-execute', {
|
|
220
|
+
toolName: name,
|
|
221
|
+
input: sanitizeToolInput(name, input),
|
|
222
|
+
riskLevel,
|
|
223
|
+
isError: true,
|
|
224
|
+
durationMs,
|
|
225
|
+
sessionId: context.sessionId,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return { result: { content: blockedContent, isError: true }, earlyReturn: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const promptInput = {
|
|
232
|
+
_secretDetection: true,
|
|
233
|
+
summary: `Tool output contains ${allMatches.length} potential secret(s): ${types}`,
|
|
234
|
+
tool: name,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
emitLifecycleEvent(context, {
|
|
238
|
+
type: 'permission_prompt',
|
|
239
|
+
toolName: name,
|
|
240
|
+
executionTarget,
|
|
241
|
+
input: promptInput,
|
|
242
|
+
workingDir: context.workingDir,
|
|
243
|
+
sessionId: context.sessionId,
|
|
244
|
+
conversationId: context.conversationId,
|
|
245
|
+
requestId: context.requestId,
|
|
246
|
+
riskLevel: RiskLevel.High,
|
|
247
|
+
reason: `Secret detected in tool output: ${types}`,
|
|
248
|
+
allowlistOptions: [],
|
|
249
|
+
scopeOptions: [],
|
|
250
|
+
persistentDecisionsAllowed: false,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const response = await this.prompter.prompt(
|
|
254
|
+
name,
|
|
255
|
+
promptInput,
|
|
256
|
+
RiskLevel.High,
|
|
257
|
+
[], // no allowlist options
|
|
258
|
+
[], // no scope options
|
|
259
|
+
undefined, // no diff
|
|
260
|
+
undefined, // not sandboxed
|
|
261
|
+
context.conversationId,
|
|
262
|
+
executionTarget,
|
|
263
|
+
false, // no persistent decisions
|
|
264
|
+
context.signal,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (response.decision === 'deny' || response.decision === 'always_deny') {
|
|
268
|
+
const blockedContent = `Tool output blocked: user denied output containing ${allMatches.length} potential secret(s) (${types}).`;
|
|
269
|
+
const durationMs = Date.now() - startTime;
|
|
270
|
+
|
|
271
|
+
emitLifecycleEvent(context, {
|
|
272
|
+
type: 'permission_denied',
|
|
273
|
+
toolName: name,
|
|
274
|
+
executionTarget,
|
|
275
|
+
input,
|
|
276
|
+
workingDir: context.workingDir,
|
|
277
|
+
sessionId: context.sessionId,
|
|
278
|
+
conversationId: context.conversationId,
|
|
279
|
+
requestId: context.requestId,
|
|
280
|
+
riskLevel: RiskLevel.High,
|
|
281
|
+
decision: response.decision === 'always_deny' ? 'always_deny' : 'deny',
|
|
282
|
+
reason: `User denied output containing secrets: ${types}`,
|
|
283
|
+
durationMs,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
void getHookManager().trigger('post-tool-execute', {
|
|
287
|
+
toolName: name,
|
|
288
|
+
input: sanitizeToolInput(name, input),
|
|
289
|
+
riskLevel,
|
|
290
|
+
isError: true,
|
|
291
|
+
durationMs,
|
|
292
|
+
sessionId: context.sessionId,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { result: { content: blockedContent, isError: true }, earlyReturn: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// User allowed — pass content through unchanged
|
|
299
|
+
return { result: execResult, earlyReturn: false };
|
|
300
|
+
}
|
|
301
|
+
}
|