Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
'use client'

import { createElement, useMemo, useState } from 'react'
import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, SecretReveal } from '@sim/emcn'
import {
ArrowRight,
Button,
ChevronDown,
cn,
Expandable,
ExpandableContent,
SecretInput,
SecretReveal,
Tooltip,
toast,
} from '@sim/emcn'
import { useParams } from 'next/navigation'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
Expand All @@ -10,6 +21,12 @@ import type {
ChatMessageContext,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
usePersonalEnvironment,
useSavePersonalEnvironment,
useUpsertWorkspaceEnvironment,
} from '@/hooks/queries/environment'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
Expand Down Expand Up @@ -42,15 +59,27 @@ export const CREDENTIAL_TAG_TYPES = [
'sim_key',
'credential_id',
'link',
'secret_input',
] as const

export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]

export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const

export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number]

export interface CredentialTagData {
value?: string
type: CredentialTagType
provider?: string
redacted?: boolean
/**
* Env-var key name to save the pasted secret under (secret_input only),
* e.g. "OPENAI_API_KEY".
*/
name?: string
/** Where a secret_input value is persisted. Defaults to "workspace". */
scope?: SecretInputScope
}

export interface MothershipErrorTagData {
Expand Down Expand Up @@ -149,6 +178,17 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
return false
}
if (value.provider !== undefined && typeof value.provider !== 'string') return false
// secret_input is an empty input the user fills in — it carries a key name to
// save under, not a value.
if (value.type === 'secret_input') {
if (
value.scope !== undefined &&
!(SECRET_INPUT_SCOPES as readonly string[]).includes(value.scope as string)
) {
return false
}
return typeof value.name === 'string' && value.name.trim().length > 0
}
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
return typeof value.value === 'string'
}
Expand Down Expand Up @@ -612,9 +652,108 @@ const LockIcon = (props: { className?: string }) => (
</svg>
)

/**
* Inline "paste a secret" widget rendered for
* `<credential>{"type":"secret_input","name":"OPENAI_API_KEY"}</credential>`.
* Reuses the shared emcn SecretInput; the pasted value is saved straight to
* workspace (default) or personal environment variables under `name` and never
* flows back through the chat transcript.
*/
function SecretInputDisplay({ data }: { data: CredentialTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const secretName = (data.name ?? '').trim()
const scope: SecretInputScope = data.scope === 'personal' ? 'personal' : 'workspace'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Chat Secrets Default Shared

When a chat secret_input tag omits scope, this defaults the pasted credential to workspace and saves it through the shared workspace environment path. A user-provided API key entered in chat can therefore become a workspace secret instead of a user-only credential, exposing the key name and making the secret available to workspace-scoped flows.

Rule Used: API keys and other user-provided credentials shoul... (source)

Learned From
simstudioai/sim#2133


const [value, setValue] = useState('')
const [saved, setSaved] = useState(false)

const upsertWorkspace = useUpsertWorkspaceEnvironment()
const savePersonal = useSavePersonalEnvironment()
const personalQuery = usePersonalEnvironment()
const personalEnv = personalQuery.data
const { canEdit } = useUserPermissionsContext()

// Setting a workspace var needs write/admin (same gate as the secrets manager);
// personal vars are the user's own, so any member may set them.
const canManage = scope === 'personal' || canEdit

const isSaving = upsertWorkspace.isPending || savePersonal.isPending
// Personal saves replace the whole map, so block until existing vars are loaded.
const personalReady = scope !== 'personal' || personalEnv !== undefined
const canSave =
canManage && secretName.length > 0 && value.trim().length > 0 && !isSaving && personalReady

const handleSave = async () => {
if (!canSave) return
try {
if (scope === 'personal') {
// The personal POST replaces the whole map, so re-read the latest vars
// right before merging — a stale snapshot would drop keys saved elsewhere.
const { data: latest } = await personalQuery.refetch()
const merged: Record<string, string> = {}
for (const [key, entry] of Object.entries(latest ?? personalEnv ?? {}))
merged[key] = entry.value
merged[secretName] = value
await savePersonal.mutateAsync({ variables: merged })
Comment thread
Sg312 marked this conversation as resolved.
} else {
await upsertWorkspace.mutateAsync({ workspaceId, variables: { [secretName]: value } })
}
setValue('')
setSaved(true)
toast.success(`Saved ${secretName}`)
} catch {
toast.error(`Couldn't save ${secretName}. Please try again.`)
}
}

if (!secretName) return null
// Only confirm after the user saves via THIS widget. A fresh prompt always shows
// the input so the user can set or override the key, even if it already exists.
if (saved) return <SecretReveal redacted />
if (!canManage) return null

return (
<SecretInput
value={value}
onChange={setValue}
placeholder={`Paste ${secretName}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSave()
}
}}
endAdornment={
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='quiet'
className='size-[18px] rounded-sm p-0'
onClick={() => void handleSave()}
disabled={!canSave}
aria-label='Save'
>
<ArrowRight className='size-[13px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{isSaving ? 'Saving…' : 'Save'}</Tooltip.Content>
</Tooltip.Root>
}
/>
)
}

function CredentialDisplay({ data }: { data: CredentialTagData }) {
const { canEdit } = useUserPermissionsContext()

if (data.type === 'secret_input') {
return <SecretInputDisplay data={data} />
}

if (data.type === 'link') {
if (!data.provider) return null
// Connecting a credential mutates the workspace — hide it from read-only members.
if (!data.provider || !canEdit) return null
Comment thread
Sg312 marked this conversation as resolved.
const Icon = getCredentialIcon(data.provider) ?? LockIcon
return (
<a
Expand Down
Loading