diff --git a/core/http/react-ui/e2e/chats-knowledge-proposal.spec.js b/core/http/react-ui/e2e/chats-knowledge-proposal.spec.js index acf31d7eae..aef2ba6c70 100644 --- a/core/http/react-ui/e2e/chats-knowledge-proposal.spec.js +++ b/core/http/react-ui/e2e/chats-knowledge-proposal.spec.js @@ -87,6 +87,25 @@ test('a small text is one passage, and without the model name the card says the await expect(page.getByTestId('change-knowledge-embed')).toHaveText("1 passage will be embedded with the server's embedding model.") }) +test('a flagged text shows a warning next to the text and can still be confirmed here', async ({ page }) => { + const s = await serve(page, [make({ id: 'k3', summary: 'Office Wi-Fi', content: 'Wi-Fi of the office\npassword: correct-horse-battery\n', warning: 'secret' }), make({ id: 'k4' })]) + await page.goto('/app/chats/proposals') + const cards = page.getByTestId('change-proposal') + await expect(cards).toHaveCount(2) + const flagged = cards.first() + await expect(flagged.getByTestId('change-warning')).toContainText('Check this text before you confirm.') + await expect(flagged.getByTestId('change-warning')).toContainText('holds a secret') + await expect(flagged.getByTestId('change-warning')).toContainText('A messaging chat cannot confirm it') + // The text is still shown, so the owner can judge it. + await expect(flagged.getByTestId('change-knowledge-preview')).toContainText('password: correct-horse-battery') + await expect(cards.nth(1).getByTestId('change-warning')).toHaveCount(0) + await page.setViewportSize({ width: 360, height: 740 }) + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0) + await flagged.getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(flagged).toHaveAttribute('data-state', 'applied') + expect(s.calls).toContain('POST k3 confirm') +}) + test('Confirm adds it and keeps Undo although the change has no change set; Undo takes it back', async ({ page }) => { const s = await serve(page, [make()]) await page.goto('/app/chats/proposals') diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json index 6694aee4e9..feac2038e1 100644 --- a/core/http/react-ui/public/locales/de/chats.json +++ b/core/http/react-ui/public/locales/de/chats.json @@ -802,6 +802,11 @@ "embed_one": "1 Textstelle wird mit {{model}} eingebettet.", "embed_other": "Etwa {{count}} Textstellen werden mit {{model}} eingebettet.", "serverModel": "dem Einbettungsmodell des Servers" + }, + "warning": { + "title": "Prüfe diesen Text, bevor du bestätigst.", + "secret": "Er enthält möglicherweise ein Geheimnis, etwa einen Schlüssel, ein Token oder ein Passwort. Nach dem Hinzufügen kann jeder Chat, der diese Sammlung durchsucht, es lesen. Ein Messaging-Chat kann ihn nicht bestätigen; nur du, hier.", + "other": "Der Server hat diesen Text als riskant markiert. Ein Messaging-Chat kann ihn nicht bestätigen; nur du, hier." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index 549df887df..4007477c73 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -1124,6 +1124,11 @@ "embed_one": "1 passage will be embedded with {{model}}.", "embed_other": "About {{count}} passages will be embedded with {{model}}.", "serverModel": "the server's embedding model" + }, + "warning": { + "title": "Check this text before you confirm.", + "secret": "It looks like it holds a secret, such as a key, a token or a password. Once it is added, every chat that searches this collection can read it. A messaging chat cannot confirm it; only you can, here.", + "other": "The server flagged this text as risky. A messaging chat cannot confirm it; only you can, here." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json index 9e5b0d125c..35c80e4787 100644 --- a/core/http/react-ui/public/locales/es/chats.json +++ b/core/http/react-ui/public/locales/es/chats.json @@ -802,6 +802,11 @@ "embed_one": "Se incrustará 1 pasaje con {{model}}.", "embed_other": "Se incrustarán unos {{count}} pasajes con {{model}}.", "serverModel": "el modelo de embeddings del servidor" + }, + "warning": { + "title": "Revisa este texto antes de confirmar.", + "secret": "Parece contener un secreto, como una clave, un token o una contraseña. Una vez añadido, cualquier chat que busque en esta colección podrá leerlo. Un chat de mensajería no puede confirmarlo; solo tú, aquí.", + "other": "El servidor marcó este texto como arriesgado. Un chat de mensajería no puede confirmarlo; solo tú, aquí." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json index d991c5195b..a21195a38b 100644 --- a/core/http/react-ui/public/locales/fr/chats.json +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -802,6 +802,11 @@ "embed_one": "1 passage sera vectorisé avec {{model}}.", "embed_other": "Environ {{count}} passages seront vectorisés avec {{model}}.", "serverModel": "le modèle d'embeddings du serveur" + }, + "warning": { + "title": "Vérifiez ce texte avant de confirmer.", + "secret": "Il semble contenir un secret, comme une clé, un jeton ou un mot de passe. Une fois ajouté, tout chat qui interroge cette collection peut le lire. Un chat de messagerie ne peut pas le confirmer ; vous seul le pouvez, ici.", + "other": "Le serveur a signalé ce texte comme risqué. Un chat de messagerie ne peut pas le confirmer ; vous seul le pouvez, ici." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json index d710b1b96b..1515a211f0 100644 --- a/core/http/react-ui/public/locales/id/chats.json +++ b/core/http/react-ui/public/locales/id/chats.json @@ -802,6 +802,11 @@ "embed_one": "1 bagian teks akan di-embed dengan {{model}}.", "embed_other": "Sekitar {{count}} bagian teks akan di-embed dengan {{model}}.", "serverModel": "model embedding milik server" + }, + "warning": { + "title": "Periksa teks ini sebelum Anda mengonfirmasi.", + "secret": "Teks ini tampak berisi rahasia, seperti kunci, token, atau kata sandi. Setelah ditambahkan, setiap obrolan yang mencari di koleksi ini dapat membacanya. Obrolan pesan tidak dapat mengonfirmasinya; hanya Anda, di sini.", + "other": "Server menandai teks ini berisiko. Obrolan pesan tidak dapat mengonfirmasinya; hanya Anda, di sini." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json index ecba636ce3..5b3a65a7ba 100644 --- a/core/http/react-ui/public/locales/it/chats.json +++ b/core/http/react-ui/public/locales/it/chats.json @@ -802,6 +802,11 @@ "embed_one": "1 passaggio verrà incorporato con {{model}}.", "embed_other": "Circa {{count}} passaggi verranno incorporati con {{model}}.", "serverModel": "il modello di embedding del server" + }, + "warning": { + "title": "Controlla questo testo prima di confermare.", + "secret": "Sembra contenere un segreto, come una chiave, un token o una password. Una volta aggiunto, ogni chat che cerca in questa raccolta può leggerlo. Una chat di messaggistica non può confermarlo; solo tu, qui.", + "other": "Il server ha segnalato questo testo come rischioso. Una chat di messaggistica non può confermarlo; solo tu, qui." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json index c3f997c41a..c4b9dbbeaf 100644 --- a/core/http/react-ui/public/locales/ko/chats.json +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -802,6 +802,11 @@ "embed_one": "구절 1개를 {{model}}(으)로 임베딩합니다.", "embed_other": "약 {{count}}개 구절을 {{model}}(으)로 임베딩합니다.", "serverModel": "서버의 임베딩 모델" + }, + "warning": { + "title": "확인하기 전에 이 텍스트를 점검하세요.", + "secret": "키, 토큰, 비밀번호 같은 비밀이 들어 있는 것으로 보입니다. 추가되면 이 컬렉션을 검색하는 모든 채팅이 읽을 수 있습니다. 메신저 채팅에서는 확인할 수 없으며, 여기에서 직접만 확인할 수 있습니다.", + "other": "서버가 이 텍스트를 위험한 것으로 표시했습니다. 메신저 채팅에서는 확인할 수 없으며, 여기에서 직접만 확인할 수 있습니다." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json index 2c3f96d55b..32646e5c13 100644 --- a/core/http/react-ui/public/locales/pt-BR/chats.json +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -802,6 +802,11 @@ "embed_one": "1 trecho será incorporado com {{model}}.", "embed_other": "Cerca de {{count}} trechos serão incorporados com {{model}}.", "serverModel": "o modelo de embeddings do servidor" + }, + "warning": { + "title": "Confira este texto antes de confirmar.", + "secret": "Ele parece conter um segredo, como uma chave, um token ou uma senha. Depois de adicionado, qualquer chat que pesquise esta coleção pode lê-lo. Um chat de mensagens não pode confirmá-lo; só você, aqui.", + "other": "O servidor marcou este texto como arriscado. Um chat de mensagens não pode confirmá-lo; só você, aqui." } }, "proposalsPage": { diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json index ef6753f05c..6fa9d3208e 100644 --- a/core/http/react-ui/public/locales/zh-CN/chats.json +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -802,6 +802,11 @@ "embed_one": "1 个段落将使用 {{model}} 做嵌入。", "embed_other": "约 {{count}} 个段落将使用 {{model}} 做嵌入。", "serverModel": "服务器的嵌入模型" + }, + "warning": { + "title": "确认之前请检查这段文字。", + "secret": "它看起来包含密钥、令牌或密码之类的机密。添加之后,搜索这个集合的每个聊天都能读到它。消息聊天无法确认它,只有你能在这里确认。", + "other": "服务器将这段文字标记为有风险。消息聊天无法确认它,只有你能在这里确认。" } }, "proposalsPage": { diff --git a/core/http/react-ui/src/components/chat/ChangeProposalCard.jsx b/core/http/react-ui/src/components/chat/ChangeProposalCard.jsx index 327ef9d78d..0eecd4c585 100644 --- a/core/http/react-ui/src/components/chat/ChangeProposalCard.jsx +++ b/core/http/react-ui/src/components/chat/ChangeProposalCard.jsx @@ -267,6 +267,12 @@ export default function ChangeProposalCard({ id, initial, t, tick = 0 }) { ))} {expiry &&
{t('changes.row.expires')}
{expiry}
} + {p.warning && ( +

+ {t('changes.warning.title')}{' '} + {t(`changes.warning.${p.warning}`, { defaultValue: t('changes.warning.other') })} +

+ )} {knowledge && edit === null && } {!knowledge && current !== undefined && edit === null && } {!knowledge && current === null &&

{t('changes.unreadFile')}

} diff --git a/core/http/react-ui/src/components/chat/changeproposal.css b/core/http/react-ui/src/components/chat/changeproposal.css index 4e538e0f03..9d546f385b 100644 --- a/core/http/react-ui/src/components/chat/changeproposal.css +++ b/core/http/react-ui/src/components/chat/changeproposal.css @@ -32,3 +32,6 @@ .pp-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; max-width: 720px; } .pp-item .cx-proposal, .pp-item .cx-receipt { margin-top: 0; } + +/* The warning of the threat scan on a knowledge text: it stays inside the card at any width. */ +.cp-warning { margin: var(--dk-space-2) 0; padding: var(--dk-space-2) var(--dk-space-3); overflow-wrap: anywhere; color: var(--dk-warn-text); background: var(--dk-warn-wash); border-left: 3px solid var(--dk-warn); border-radius: var(--dk-radius-sm, 4px); } diff --git a/core/services/studio/changes.go b/core/services/studio/changes.go index 68d2050b67..250f507c1b 100644 --- a/core/services/studio/changes.go +++ b/core/services/studio/changes.go @@ -217,7 +217,23 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change return contract.ChangeProposal{}, err } c.audit(ctx, proposals.ActionPropose, p, 0, "") - return p, nil + return withWarning(p), nil +} + +// fetch reads one proposal of the owner from the store, with its warning. +func (c *Changes) fetch(ctx context.Context, owner, id string) (contract.ChangeProposal, error) { + p, err := c.store.Get(ctx, owner, id) + if err != nil { + return p, err + } + return withWarning(p), nil +} + +// withWarning adds the warning of the threat scan to a proposal. It is worked +// out at each read, so a change of the patterns reaches the proposals that wait. +func withWarning(p contract.ChangeProposal) contract.ChangeProposal { + p.Warning = knowledgeWarning(p) + return p } // prepare asks the destination of a kind that has one whether it can take the @@ -332,13 +348,21 @@ func targetName(p contract.ChangeProposal) string { // settled first. func (c *Changes) List(ctx context.Context, user string, statuses ...contract.ProposalStatus) ([]contract.ChangeProposal, error) { c.sweep(ctx) - return c.store.List(ctx, principal(user), statuses...) + list, err := c.store.List(ctx, principal(user), statuses...) + for i := range list { + list[i] = withWarning(list[i]) + } + return list, err } // Get returns one proposal of the user. func (c *Changes) Get(ctx context.Context, user, id string) (contract.ChangeProposal, error) { c.sweep(ctx) - return c.store.Get(ctx, principal(user), id) + p, err := c.store.Get(ctx, principal(user), id) + if err != nil { + return p, err + } + return withWarning(p), nil } // Events returns the audit of the user, newest first. @@ -444,7 +468,7 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) } p.Status, p.Rev = contract.ProposalApplied, rev c.audit(ctx, proposals.ActionConfirm, p, rev, "") - return c.store.Get(ctx, owner, id) + return c.fetch(ctx, owner, id) } // confirmExternal applies a proposal of a kind with a destination of its own. @@ -473,7 +497,7 @@ func (c *Changes) confirmExternal(ctx context.Context, owner string, p contract. } p.Status = contract.ProposalApplied c.audit(ctx, proposals.ActionConfirm, p, 0, "") - return c.store.Get(ctx, owner, p.ID) + return c.fetch(ctx, owner, p.ID) } // write puts the files of a change into the home as one change set. @@ -503,7 +527,7 @@ func (c *Changes) Dismiss(ctx context.Context, user, id string) (contract.Change return contract.ChangeProposal{}, notPending(p) } c.audit(ctx, proposals.ActionDismiss, p, 0, "") - return c.store.Get(ctx, owner, id) + return c.fetch(ctx, owner, id) } // Revert undoes an applied change with the change set it wrote. @@ -546,7 +570,7 @@ func (c *Changes) Revert(ctx context.Context, user, id string) (contract.ChangeP c.s.log.Warn("studio: could not record the change set of a revert", "proposal", id, "error", err) } c.audit(ctx, proposals.ActionRevert, p, cs.Rev, "") - return c.store.Get(ctx, owner, id) + return c.fetch(ctx, owner, id) } // revertExternal takes back a change of a kind with a destination of its own. @@ -566,7 +590,7 @@ func (c *Changes) revertExternal(ctx context.Context, owner string, p contract.C return contract.ChangeProposal{}, fmt.Errorf("studio: could not revert the change: %w", err) } c.audit(ctx, proposals.ActionRevert, p, 0, "") - return c.store.Get(ctx, owner, p.ID) + return c.fetch(ctx, owner, p.ID) } // RevertLast reverts the change applied last for the user in the scope of diff --git a/core/services/studio/changetools.go b/core/services/studio/changetools.go index 6abb1cdeb7..c86f80eed4 100644 --- a/core/services/studio/changetools.go +++ b/core/services/studio/changetools.go @@ -323,6 +323,9 @@ func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.Raw // writes the question there and the person answers it there. return h.connectorProposed(ctx, c, p, autoNote), nil } + if p.Warning != "" { + autoNote += " The text looks like it holds a secret: the owner sees a warning next to it. Do not repeat the secret." + } return fmt.Sprintf("Proposal %s stored: %s (%s, %s). Nothing is saved yet: the owner must confirm it in Studio, and it expires in 24 hours. Do not say it is saved.%s", p.ID, p.Summary, p.Kind, p.Scope, autoNote), nil } diff --git a/core/services/studio/connectorchange.go b/core/services/studio/connectorchange.go index 3344b8e308..51f06162f5 100644 --- a/core/services/studio/connectorchange.go +++ b/core/services/studio/connectorchange.go @@ -164,7 +164,7 @@ func (h *Changes) sourceOf(c builtins.Chat) contract.ChangeSource { // confirm it. A change of a kind that changes what the agent may do needs a // paired sender, as the gate decides. func (h *Changes) senderMayConfirm(ctx context.Context, c builtins.Chat, p contract.ChangeProposal) bool { - if ok, _ := c.Holds(CapProposalsConfirm); !ok || p.Source.Sender == "" { + if ok, _ := c.Holds(CapProposalsConfirm); !ok || p.Source.Sender == "" || p.Warning != "" { return false } if p.Kind == contract.ChangeMemory { @@ -198,6 +198,12 @@ func (h *Changes) alwaysConfirm(ctx context.Context, p contract.ChangeProposal) func (h *Changes) connectorProposed(ctx context.Context, c builtins.Chat, p contract.ChangeProposal, autoNote string) string { head := fmt.Sprintf("Proposal %s stored: %s (%s, %s). Nothing is saved yet.", p.ID, p.Summary, p.Kind, p.Scope) conns := h.s.connectors + if p.Warning != "" { + // The host wrote nothing in the chat: a flagged text is never shown + // there to be answered, and the answer would not be taken. + return head + " The text looks like it holds a secret, so it cannot be confirmed from a chat: it waits for the owner in Studio, with a warning, and expires in 24 hours. " + + "Tell them it is not saved and that the owner decides. Do not repeat the secret and do not say it is saved." + autoNote + } if !h.senderMayConfirm(ctx, c, p) { return head + " The person cannot confirm changes from this chat, so it waits for the owner in Studio and expires in 24 hours. " + "Tell them it is not saved and that the owner decides. Do not say it is saved." + autoNote @@ -277,6 +283,12 @@ func (c *Connectors) answerChange(ctx context.Context, b connectors.Binding, m c ch.audit(ctx, proposals.ActionRefuse, p, 0, "not the sender who asked; "+detail) return v.T("change.not_you"), true } + if verb != "cancel" && p.Warning != "" { + // A flagged text is confirmed in the web interface, where the owner + // sees the warning next to the text. Dropping it is always allowed. + ch.audit(ctx, proposals.ActionRefuse, p, 0, "flagged text; "+detail) + return v.T("change.flagged"), true + } if verb == "cancel" { if _, err := ch.Dismiss(ctx, b.Owner, p.ID); err != nil { return v.T("proposal.failed", "error", changeErr(err)), true diff --git a/core/services/studio/hostmsg/locales/de.json b/core/services/studio/hostmsg/locales/de.json index 8be42d8aa4..4f21c92cfa 100644 --- a/core/services/studio/hostmsg/locales/de.json +++ b/core/services/studio/hostmsg/locales/de.json @@ -53,6 +53,7 @@ "proposal.multi": "Hier warten mehrere. Sag, welcher gemeint ist, zum Beispiel \"bestätigen CODE\" oder \"abbrechen CODE\":", "change.no_right": "Nur der Eigentümer kann das bestätigen.", "change.not_you": "Nur die Person, die gefragt hat, kann das bestätigen.", + "change.flagged": "Dieser Text enthält möglicherweise ein Geheimnis und kann deshalb nicht im Chat bestätigt werden. Prüfe den Vorschlag in der Weboberfläche.", "change.cannot": "Das konnte nicht rückgängig gemacht werden: {error}", "change.saved": "Gespeichert: {summary}. Antworte innerhalb von {minutes} Minuten mit \"rückgängig\", um es zurückzunehmen.", "change.done": "Erledigt: {summary}. Antworte innerhalb von {minutes} Minuten mit \"rückgängig\", um es zurückzunehmen.", diff --git a/core/services/studio/hostmsg/locales/en.json b/core/services/studio/hostmsg/locales/en.json index 0b09fbc50f..ab13a26084 100644 --- a/core/services/studio/hostmsg/locales/en.json +++ b/core/services/studio/hostmsg/locales/en.json @@ -53,6 +53,7 @@ "proposal.multi": "More than one is waiting here. Say which, for example \"confirm CODE\" or \"cancel CODE\":", "change.no_right": "Only the owner can confirm this.", "change.not_you": "Only the person who asked can confirm this.", + "change.flagged": "This text looks like it holds a secret, so it cannot be confirmed from a chat. Open the proposal in the web interface to check it.", "change.cannot": "I could not undo it: {error}", "change.saved": "Saved: {summary}. Reply \"undo\" within {minutes} minutes to take it back.", "change.done": "Done: {summary}. Reply \"undo\" within {minutes} minutes to take it back.", diff --git a/core/services/studio/hostmsg/locales/es.json b/core/services/studio/hostmsg/locales/es.json index 64e888607a..c347113776 100644 --- a/core/services/studio/hostmsg/locales/es.json +++ b/core/services/studio/hostmsg/locales/es.json @@ -53,6 +53,7 @@ "proposal.multi": "Hay más de uno esperando aquí. Indica cuál, por ejemplo \"confirmar CODIGO\" o \"cancelar CODIGO\":", "change.no_right": "Solo el propietario puede confirmar esto.", "change.not_you": "Solo la persona que lo pidió puede confirmar esto.", + "change.flagged": "Este texto parece contener un secreto, así que no se puede confirmar desde un chat. Revisa la propuesta en la interfaz web.", "change.cannot": "No se pudo deshacer: {error}", "change.saved": "Guardado: {summary}. Responde \"deshacer\" en los próximos {minutes} minutos para revertirlo.", "change.done": "Hecho: {summary}. Responde \"deshacer\" en los próximos {minutes} minutos para revertirlo.", diff --git a/core/services/studio/hostmsg/locales/fr.json b/core/services/studio/hostmsg/locales/fr.json index 94cd8b700a..8ed73a15a9 100644 --- a/core/services/studio/hostmsg/locales/fr.json +++ b/core/services/studio/hostmsg/locales/fr.json @@ -53,6 +53,7 @@ "proposal.multi": "Plusieurs propositions attendent ici. Précisez laquelle, par exemple « confirmer CODE » ou « annuler CODE » :", "change.no_right": "Seul le propriétaire peut confirmer cela.", "change.not_you": "Seule la personne qui l'a demandé peut confirmer cela.", + "change.flagged": "Ce texte semble contenir un secret, il ne peut donc pas être confirmé depuis un chat. Vérifiez la proposition dans l'interface web.", "change.cannot": "L'annulation n'a pas pu se faire : {error}", "change.saved": "Enregistré : {summary}. Répondez « annuler la modification » dans les {minutes} minutes pour revenir en arrière.", "change.done": "Fait : {summary}. Répondez « annuler la modification » dans les {minutes} minutes pour revenir en arrière.", diff --git a/core/services/studio/hostmsg/locales/it.json b/core/services/studio/hostmsg/locales/it.json index 2d9370ee9b..0f2d0677cd 100644 --- a/core/services/studio/hostmsg/locales/it.json +++ b/core/services/studio/hostmsg/locales/it.json @@ -53,6 +53,7 @@ "proposal.multi": "Qui ce n'è più di uno in attesa. Indica quale, ad esempio \"conferma CODICE\" oppure \"annulla CODICE\":", "change.no_right": "Solo il proprietario può confermare.", "change.not_you": "Solo la persona che l'ha chiesto può confermare.", + "change.flagged": "Questo testo sembra contenere un segreto, quindi non si può confermare da una chat. Controlla la proposta nell'interfaccia web.", "change.cannot": "Non è stato possibile annullare la modifica: {error}", "change.saved": "Salvato: {summary}. Rispondi \"annulla modifica\" entro {minutes} minuti per tornare indietro.", "change.done": "Fatto: {summary}. Rispondi \"annulla modifica\" entro {minutes} minuti per tornare indietro.", diff --git a/core/services/studio/hostmsg/locales/pt-BR.json b/core/services/studio/hostmsg/locales/pt-BR.json index 3f6db0a32a..76af65f168 100644 --- a/core/services/studio/hostmsg/locales/pt-BR.json +++ b/core/services/studio/hostmsg/locales/pt-BR.json @@ -53,6 +53,7 @@ "proposal.multi": "Há mais de uma esperando aqui. Diga qual, por exemplo \"confirmar CODIGO\" ou \"cancelar CODIGO\":", "change.no_right": "Só o proprietário pode confirmar isso.", "change.not_you": "Só a pessoa que pediu pode confirmar isso.", + "change.flagged": "Este texto parece conter um segredo, então não pode ser confirmado por um chat. Confira a proposta na interface web.", "change.cannot": "Não foi possível desfazer: {error}", "change.saved": "Salvo: {summary}. Responda \"desfazer\" em até {minutes} minutos para voltar atrás.", "change.done": "Pronto: {summary}. Responda \"desfazer\" em até {minutes} minutos para voltar atrás.", diff --git a/core/services/studio/knowledge.go b/core/services/studio/knowledge.go index 1925d85368..43e85fab9b 100644 --- a/core/services/studio/knowledge.go +++ b/core/services/studio/knowledge.go @@ -78,6 +78,21 @@ func (s *Service) knowledgeBlock(ctx context.Context, user, agentID string) (cod return "", "" } +// knowledgeWarning is the threat scan of a knowledge proposal. A text that +// reaches a collection is read back by every chat that searches it, so a secret +// in it would leak and a wrong text would mislead. It uses the same secret +// patterns as the check that holds a memory note back from "Save it and tell +// me" (secretLike). It returns a code of ChangeProposal.Warning, or "". +func knowledgeWarning(p contract.ChangeProposal) string { + if p.Kind != contract.ChangeKnowledge { + return "" + } + if secretLike.MatchString(p.Summary) || secretLike.MatchString(p.Content) { + return contract.WarningSecret + } + return "" +} + func validCollectionName(n string) bool { if n == "" || len(n) > maxCollectionName || !utf8.ValidString(n) { return false diff --git a/core/services/studio/knowledge_test.go b/core/services/studio/knowledge_test.go index b1aeeb2b97..36b93038da 100644 --- a/core/services/studio/knowledge_test.go +++ b/core/services/studio/knowledge_test.go @@ -275,6 +275,44 @@ var _ = Describe("knowledge changes", func() { Expect(fk.held()).To(BeEmpty()) }) + Describe("the threat scan", func() { + const leaky = "Wi-Fi of the office\npassword: correct-horse-battery\n" + + It("flags a text that looks like it holds a secret, keeps showing it, and lets the owner confirm it", func() { + p, err := propose(studio.ProposeInput{Target: "manuals", Content: leaky}) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Warning).To(Equal(contract.WarningSecret)) + Expect(p.Content).To(Equal(leaky), "the owner sees the text as it is") + got, err := r.svc.Changes().Get(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Warning).To(Equal(contract.WarningSecret)) + list, err := r.svc.Changes().List(ctx, alice) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Warning).To(Equal(contract.WarningSecret)) + + done, err := r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred(), "the web interface confirms it") + Expect(done.Warning).To(Equal(contract.WarningSecret)) + Expect(fk.held()).To(HaveLen(1)) + }) + + It("scans the summary too, and flags a key block", func() { + p, err := propose(studio.ProposeInput{Target: "manuals", Content: "plain", Summary: "token = abcdef123456"}) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Warning).To(Equal(contract.WarningSecret)) + p, err = propose(studio.ProposeInput{Target: "manuals", Content: "-----BEGIN RSA PRIVATE KEY-----\nabc\n"}) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Warning).To(Equal(contract.WarningSecret)) + }) + + It("does not flag an ordinary text", func() { + p, err := propose(studio.ProposeInput{Target: "manuals", Content: "Hold the reset button for ten seconds. The password is set again by the reset."}) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Warning).To(BeEmpty()) + }) + }) + It("keeps the limits of the other kinds: 10 waiting, 5 an hour", func() { for i := range contract.MaxChangesPerHour { _, err := propose(studio.ProposeInput{Target: "manuals", Content: strings.Repeat("a", i+1)}) @@ -410,6 +448,8 @@ var _ = Describe("knowledge changes from a messaging app", func() { switch text { case "add": return []studiotest.ToolStep{{Tool: "propose_change", Args: knowledgeArgs("manuals", "Hold reset for ten seconds.", "Reset steps")}, {Say: "ok"}} + case "leak": + return []studiotest.ToolStep{{Tool: "propose_change", Args: knowledgeArgs("manuals", "Wi-Fi\npassword: correct-horse-battery", "Wi-Fi of the office")}, {Say: "ok"}} case "forget": return []studiotest.ToolStep{{Tool: "revert_change", Args: `{}`}, {Say: "ok"}} } @@ -477,6 +517,23 @@ var _ = Describe("knowledge changes from a messaging app", func() { Expect(fk.held()).To(BeEmpty()) }) + It("does not write a flagged text in the chat, and a yes does not confirm it, but a no still drops it", func() { + setup(true, []string{"manuals"}) + say(paired, "leak", 1) + Expect(list()).To(HaveLen(1)) + Expect(list()[0].Warning).To(Equal(contract.WarningSecret)) + Expect(strings.Join(bot.answers(), "\n")).NotTo(ContainSubstring("correct-horse"), "the secret is not repeated in the chat") + Expect(r.h.ResultsFor("propose_change")[0].Out).To(ContainSubstring("cannot be confirmed from a chat")) + + say(paired, "yes", 1) + Expect(bot.answers()[len(bot.answers())-1]).To(ContainSubstring("cannot be confirmed from a chat")) + Expect(list()[0].Status).To(Equal(contract.ProposalPending)) + Expect(fk.held()).To(BeEmpty()) + + say(paired, "no", 1) + Expect(list()[0].Status).To(Equal(contract.ProposalDismissed)) + }) + It("is refused for a listed sender who is not paired: the capability is sensitive", func() { setup(true, []string{"manuals"}) say(listed, "add", 1) diff --git a/docs/content/features/collections.md b/docs/content/features/collections.md index 54e8132bcc..3c94d6dfd5 100644 --- a/docs/content/features/collections.md +++ b/docs/content/features/collections.md @@ -163,6 +163,13 @@ with these rules: (`max_chunk_size` and `chunk_overlap` in `GET /api/collections`). You can edit the text before you confirm. A change waits 24 hours, and you can have 10 waiting and an agent can make 5 an hour, as for the other kinds. +- **A scan for secrets.** Before a text is shown, LocalAI checks it with the same + patterns that keep a memory note from being saved at once (private keys, access + tokens, `password: ...` lines). A text that matches is not refused: it shows + with a warning in the card, and you can still confirm it in the web interface. + A messaging chat cannot confirm it. The bot writes nothing about it in the + chat, a `yes` there does not confirm it, and a `no` still drops it. The proposal + carries the code in `warning` (`secret`). - **What happens.** **Confirm** stores the text as one entry of the collection, titled with the summary, and the server splits it into passages and embeds it. **Undo** deletes that entry again. The embedding model must be installed; diff --git a/pkg/addons/contract/changeproposal.go b/pkg/addons/contract/changeproposal.go index a8f104f6c3..8520962828 100644 --- a/pkg/addons/contract/changeproposal.go +++ b/pkg/addons/contract/changeproposal.go @@ -79,6 +79,13 @@ const ( // Settled reports whether the proposal has an answer. func (s ProposalStatus) Settled() bool { return s != ProposalPending } +// The codes of ChangeProposal.Warning. +const ( + // WarningSecret: the text looks like it holds a secret, such as a private + // key, an access token or a password. + WarningSecret = "secret" +) + // ChangeSource says where a proposal came from, without naming a platform. // Kind is the origin of the chat as the host names it ("" for a chat a person // watches, "connector" for a messaging app). ID names the binding of a @@ -112,6 +119,12 @@ type ChangeProposal struct { BaseHash string `json:"base_hash,omitempty"` Source ChangeSource `json:"source,omitzero"` Status ProposalStatus `json:"status"` + // Warning is set by the host when the text of a proposal looks risky, as a + // stable code (see the Warning constants). The owner still sees the text, + // with a warning, and can confirm it in the web interface; a messaging chat + // cannot confirm it. The host works it out when it reads the proposal and + // does not keep it. + Warning string `json:"warning,omitempty"` // Auto is true when the host applied the change at once because the person // chose so (the memory save setting), not after a confirmation. The change // set is the same and can be undone the same way. diff --git a/swagger/docs.go b/swagger/docs.go index 3a5b845a0a..4499519774 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -17067,6 +17067,10 @@ const docTemplate = `{ }, "v": { "type": "integer" + }, + "warning": { + "description": "Warning is set by the host when the text of a proposal looks risky, as a\nstable code (see the Warning constants). The owner still sees the text,\nwith a warning, and can confirm it in the web interface; a messaging chat\ncannot confirm it. The host works it out when it reads the proposal and\ndoes not keep it.", + "type": "string" } } }, diff --git a/swagger/swagger.json b/swagger/swagger.json index aa97a617ba..5c5fba15a6 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -17064,6 +17064,10 @@ }, "v": { "type": "integer" + }, + "warning": { + "description": "Warning is set by the host when the text of a proposal looks risky, as a\nstable code (see the Warning constants). The owner still sees the text,\nwith a warning, and can confirm it in the web interface; a messaging chat\ncannot confirm it. The host works it out when it reads the proposal and\ndoes not keep it.", + "type": "string" } } }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index eb900c8a03..0ea6c997e5 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -940,6 +940,14 @@ definitions: type: string v: type: integer + warning: + description: |- + Warning is set by the host when the text of a proposal looks risky, as a + stable code (see the Warning constants). The owner still sees the text, + with a warning, and can confirm it in the web interface; a messaging chat + cannot confirm it. The host works it out when it reads the proposal and + does not keep it. + type: string type: object contract.ChangeScope: enum: