Compare commits

...

1 Commits

Author SHA1 Message Date
ONLY-yours 6f1380b07c feat(creds): add secure credential input mode via human intervention
Allow users to save credentials securely without exposing values in AI
context. When AI calls saveCreds with `fields` but no `values`, a secure
form renders via the intervention system for direct user input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 17:04:40 +08:00
14 changed files with 342 additions and 10 deletions
@@ -379,6 +379,32 @@ export class CredsExecutionRuntime {
};
}
// Secure input mode: fields provided without values — requires web UI
if ((!args.values || Object.keys(args.values).length === 0) && args.fields?.length) {
return {
content:
'Secure credential input is only available in the web UI. In background execution, credentials must be provided with values directly.',
state: {
key: args.key,
message: 'Secure input requires web UI',
success: false,
},
success: true,
};
}
if (!args.values || Object.keys(args.values).length === 0) {
return {
content:
'Failed to save credential: values must be provided as key-value pairs (e.g., { "API_KEY": "sk-xxx" }).',
error: {
message: 'values is empty or missing',
type: 'InvalidParams',
},
success: false,
};
}
await this.credsService.saveKVCred({
description: args.description,
key: args.key,
@@ -0,0 +1,138 @@
'use client';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Button, Flexbox, Text } from '@lobehub/ui';
import { Input, Tag } from 'antd';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { lambdaClient } from '@/libs/trpc/client';
import type { SaveCredsParams } from '../../../types';
import { styles } from './style';
const SecureCredentialForm = memo<BuiltinInterventionProps<SaveCredsParams>>(
({ args, interactionMode, onInteractionAction }) => {
const { t } = useTranslation('ui');
const isCustom = interactionMode === 'custom';
const { key, name, type, fields = [], description } = args;
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
for (const field of fields) {
init[field.name] = '';
}
return init;
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string>();
const allFilled = fields.every((f) => values[f.name]?.trim());
const handleSave = useCallback(async () => {
if (!onInteractionAction || !allFilled) return;
setSubmitting(true);
setError(undefined);
try {
// Save credential directly via tRPC — values never enter AI context
await lambdaClient.market.creds.createKV.mutate({
description,
key,
name,
type: type as 'kv-env' | 'kv-header',
values,
});
// Notify the tool chain with success metadata only (no secret values)
await onInteractionAction({
payload: { key, name, success: true },
type: 'submit',
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save credential');
setSubmitting(false);
}
}, [allFilled, description, key, name, onInteractionAction, type, values]);
const handleSkip = useCallback(async () => {
if (!onInteractionAction) return;
await onInteractionAction({
reason: 'User cancelled secure credential input',
type: 'skip',
});
}, [onInteractionAction]);
// Non-custom mode: show summary only (standard approve/reject buttons handle it)
if (!isCustom) {
return (
<Flexbox gap={8}>
<Text>
{t('common.save', { defaultValue: 'Save' })}: {name}
</Text>
<Text style={{ fontSize: 13 }} type="secondary">
{t('common.type', { defaultValue: 'Type' })}: {type} | Key: {key}
</Text>
{fields.length > 0 && (
<Text style={{ fontSize: 12 }} type="secondary">
{fields.map((f) => f.label || f.name).join(', ')}
</Text>
)}
</Flexbox>
);
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Text style={{ fontWeight: 500 }}>🔐 {name}</Text>
{description && (
<Text style={{ fontSize: 13 }} type="secondary">
{description}
</Text>
)}
<Flexbox horizontal gap={4}>
<Tag className={styles.tag}>{key}</Tag>
<Tag className={styles.tag}>{type}</Tag>
</Flexbox>
</div>
<Flexbox gap={8}>
{fields.map((field) => (
<Flexbox gap={4} key={field.name}>
<span className={styles.fieldLabel}>{field.label || field.name}</span>
<Input.Password
autoComplete="off"
placeholder={field.name}
status={error ? 'error' : undefined}
value={values[field.name]}
onChange={(e) => setValues((prev) => ({ ...prev, [field.name]: e.target.value }))}
onPressEnter={() => {
if (allFilled) handleSave();
}}
/>
</Flexbox>
))}
</Flexbox>
{error && (
<Text style={{ fontSize: 12 }} type="danger">
{error}
</Text>
)}
<div className={styles.footer}>
<Button onClick={handleSkip}>{t('form.skip', { defaultValue: 'Skip' })}</Button>
<Button disabled={!allFilled} loading={submitting} type="primary" onClick={handleSave}>
{t('common.save', { defaultValue: 'Save' })}
</Button>
</div>
</div>
);
},
);
SecureCredentialForm.displayName = 'SecureCredentialForm';
export default SecureCredentialForm;
@@ -0,0 +1,29 @@
import { createStaticStyles } from 'antd-style';
export const styles = createStaticStyles(({ css, cssVar }) => ({
fieldLabel: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
footer: css`
display: flex;
gap: 8px;
justify-content: flex-end;
padding-block-start: 4px;
`,
header: css`
display: flex;
flex-direction: column;
gap: 4px;
`,
root: css`
display: flex;
flex-direction: column;
gap: 12px;
`,
tag: css`
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
`,
}));
@@ -0,0 +1,8 @@
import type { BuiltinIntervention } from '@lobechat/types';
import { CredsApiName } from '../../types';
import SecureCredentialForm from './SecureCredentialForm';
export const CredsInterventions: Record<string, BuiltinIntervention> = {
[CredsApiName.saveCreds]: SecureCredentialForm as BuiltinIntervention,
};
@@ -1,4 +1,3 @@
// Client-side components for Creds tool
// Placeholder for future Render/Streaming components
export {};
export { CredsManifest } from '../manifest';
export * from '../types';
export { CredsInterventions } from './Intervention';
@@ -0,0 +1,24 @@
import type { DynamicInterventionResolver } from '@lobechat/types';
/**
* Dynamic intervention resolver for saveCreds secure input mode.
* Returns true (intervention needed) when `values` is missing/empty
* and `fields` is provided — indicating the user wants secure input.
*/
export const credsSecureInputAudit: DynamicInterventionResolver = async (toolArgs) => {
const values = toolArgs.values;
const fields = toolArgs.fields;
// Secure input mode: fields provided but no values
if (
fields &&
Array.isArray(fields) &&
fields.length > 0 &&
(!values || (typeof values === 'object' && Object.keys(values).length === 0))
) {
return true;
}
// Direct save mode: values provided, no intervention needed
return false;
};
@@ -592,7 +592,7 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
const raw = params as any;
const name: string = params.name || raw.displayName || params.key;
let values: Record<string, string> = params.values;
let values: Record<string, string> | undefined = params.values;
if (!values && typeof raw.value === 'string') {
values = {};
for (const line of (raw.value as string).split('\n')) {
@@ -603,6 +603,22 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
}
}
// Secure input mode: fields provided without values — the intervention component
// handles saving directly via tRPC, so the executor should not run.
// This branch handles edge cases (e.g., server-side execution).
if ((!values || Object.keys(values).length === 0) && raw.fields?.length > 0) {
return {
content:
'Secure credential input is only available in the web UI. Please use the LobeHub web interface to save credentials securely.',
state: {
key: params.key,
message: 'Secure input requires web UI',
success: false,
},
success: true,
};
}
if (!values || Object.keys(values).length === 0) {
return {
content:
+2
View File
@@ -1,3 +1,4 @@
export { credsSecureInputAudit } from './credsSecureInputAudit';
export { CredsExecutionRuntime, type ICredsService } from './ExecutionRuntime';
export {
checkCredsSatisfied,
@@ -23,6 +24,7 @@ export {
type InitiateOAuthConnectParams,
type InjectCredsToSandboxParams,
type InjectCredsToSandboxState,
type SaveCredsField,
type SaveCredsParams,
type SaveCredsState,
} from './types';
+31 -3
View File
@@ -84,7 +84,14 @@ export const CredsManifest: BuiltinToolManifest = {
},
{
description:
'Save a new credential securely. Use this when the user wants to store sensitive information like API keys, tokens, or secrets. The credential will be encrypted and stored securely.',
'Save a new credential securely. Use this when the user wants to store sensitive information like API keys, tokens, or secrets. The credential will be encrypted and stored securely. When the user chooses secure input mode, omit `values` and provide `fields` instead — a secure form will appear for the user to fill in the values directly without exposing them to the AI context.',
humanIntervention: {
dynamic: {
default: 'never',
policy: 'always',
type: 'credsSecureInput',
},
},
name: CredsApiName.saveCreds,
parameters: {
additionalProperties: false,
@@ -93,6 +100,27 @@ export const CredsManifest: BuiltinToolManifest = {
description: 'Optional description explaining what this credential is used for',
type: 'string',
},
fields: {
description:
'Field definitions for secure input mode. Provide this WITHOUT values to trigger a secure form where the user fills in credential values directly. Each field defines an environment variable or header key that the user will provide.',
items: {
additionalProperties: false,
properties: {
label: {
description: 'Display label for the field (defaults to name if omitted)',
type: 'string',
},
name: {
description:
'Environment variable or header name (e.g., "GITHUB_TOKEN", "OPENAI_API_KEY")',
type: 'string',
},
},
required: ['name'],
type: 'object',
},
type: 'array',
},
key: {
description:
'Unique identifier key for the credential (e.g., "openai", "github-token"). Use lowercase with hyphens.',
@@ -113,11 +141,11 @@ export const CredsManifest: BuiltinToolManifest = {
type: 'string',
},
description:
'Key-value pairs of the credential. For kv-env, the key should be the environment variable name (e.g., {"OPENAI_API_KEY": "sk-..."})',
'Key-value pairs of the credential. For kv-env, the key should be the environment variable name (e.g., {"OPENAI_API_KEY": "sk-..."}). Omit this when using secure input mode (provide `fields` instead).',
type: 'object',
},
},
required: ['key', 'name', 'type', 'values'],
required: ['key', 'name', 'type'],
type: 'object',
} satisfies JSONSchema7,
},
@@ -53,6 +53,31 @@ When a user mentions they want to use one of these services, use \`initiateOAuth
- **Explain the benefit**: Let users know that saved credentials are encrypted and can be easily reused across conversations.
</security_guidelines>
<secure_input_mode>
**Secure Save Flow** — when the user wants to save credentials without exposing values in chat history:
1. **User provides values directly** in the conversation (e.g., "save my key sk-xxx"):
- Call \`saveCreds\` with \`values\` directly — do NOT trigger secure input.
- After saving, remind the user: "Your credential has been saved. Note that the value appeared in the chat history — you may want to edit or delete the message above to remove the plaintext."
2. **User has NOT provided values yet** and wants to add a credential:
- Ask whether they want to:
a) Paste the value in chat (faster)
b) Use **secure input** (values won't appear in chat history)
3. **User chooses secure input**:
- Call \`saveCreds\` with metadata and \`fields\` only — omit \`values\`.
- Example: \`saveCreds({ key: "github", name: "GitHub Token", type: "kv-env", fields: [{ name: "GITHUB_TOKEN", label: "GitHub Token" }] })\`
- A secure form will appear for the user to fill in credential values directly.
- You will receive a confirmation after the user saves successfully.
4. **User says "secure save" but also provides plaintext** in their message:
- Save using \`saveCreds\` with \`values\` (since the values are already in chat context).
- After saving, remind the user to edit or delete the message containing the plaintext value.
**Important**: The \`fields\` parameter defines what the user will see in the secure form. Each field should have a \`name\` (the env var or header key) and optionally a \`label\` (human-readable display text).
</secure_input_mode>
<credential_saving_triggers>
Proactively suggest saving credentials when you detect:
- API keys (e.g., "sk-...", "api_...", patterns like "OPENAI_API_KEY=...")
+20 -2
View File
@@ -106,11 +106,28 @@ export interface InjectCredsToSandboxState {
success: boolean;
}
export interface SaveCredsField {
/**
* Display label for this field
*/
label?: string;
/**
* Environment variable or header name (e.g., "GITHUB_TOKEN")
*/
name: string;
}
export interface SaveCredsParams {
/**
* Optional description for the credential
*/
description?: string;
/**
* Field definitions for secure input mode.
* When provided without values, triggers the secure credential form
* where the user fills in values directly without exposing them to AI context.
*/
fields?: SaveCredsField[];
/**
* Unique key for the credential (used for reference)
*/
@@ -124,9 +141,10 @@ export interface SaveCredsParams {
*/
type: CredType;
/**
* Key-value pairs of the credential (for kv-env and kv-header types)
* Key-value pairs of the credential (for kv-env and kv-header types).
* Optional in secure input mode — user fills values via the secure form instead.
*/
values: Record<string, string>;
values?: Record<string, string>;
}
export interface SaveCredsState {
@@ -1,6 +1,8 @@
import { credsSecureInputAudit } from '@lobechat/builtin-tool-creds';
import { pathScopeAudit } from '@lobechat/builtin-tool-local-system';
import { type DynamicInterventionResolver } from '@lobechat/types';
export const dynamicInterventionAudits: Record<string, DynamicInterventionResolver> = {
credsSecureInput: credsSecureInputAudit,
pathScopeAudit,
};
@@ -8,6 +8,7 @@ import {
} from '@lobechat/builtin-tool-agent-marketplace/client';
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { CloudSandboxInterventions } from '@lobechat/builtin-tool-cloud-sandbox/client';
import { CredsInterventions, CredsManifest } from '@lobechat/builtin-tool-creds/client';
import {
GroupManagementInterventions,
GroupManagementManifest,
@@ -40,6 +41,7 @@ export const BuiltinToolInterventions: Record<string, Record<string, any>> = {
[AgentBuilderManifest.identifier]: AgentBuilderInterventions,
[AgentMarketplaceManifest.identifier]: AgentMarketplaceInterventions,
[CloudSandboxManifest.identifier]: CloudSandboxInterventions,
[CredsManifest.identifier]: CredsInterventions,
[GroupManagementManifest.identifier]: GroupManagementInterventions,
[GTDManifest.identifier]: GTDInterventions,
[LocalSystemIdentifier]: LocalSystemInterventions,
@@ -1,4 +1,5 @@
import { AgentMarketplaceIdentifier } from '@lobechat/builtin-tool-agent-marketplace';
import { CredsIdentifier } from '@lobechat/builtin-tool-creds';
import { UserInteractionIdentifier } from '@lobechat/builtin-tool-user-interaction';
import type { OnboardingAgentMarketplacePickSnapshot } from '@lobechat/types';
@@ -127,8 +128,22 @@ const handleAgentMarketplaceSubmit: CustomInteractionSubmitHandler = async (payl
};
};
const handleCredsSecureInputSubmit: CustomInteractionSubmitHandler = async (payload) => {
const key = pickString(payload.key);
const name = pickString(payload.name);
return {
options: {
createUserMessage: false,
toolResultContent: `Credential "${name || key}" saved successfully with key "${key}". The values were provided securely by the user and never appeared in the conversation.`,
},
payload,
};
};
const customInteractionSubmitHandlers = new Map<string, CustomInteractionSubmitHandler>([
[AgentMarketplaceIdentifier, handleAgentMarketplaceSubmit],
[CredsIdentifier, handleCredsSecureInputSubmit],
]);
export const isCustomInteractionIdentifier = (identifier: string) =>