feat(studio): scan knowledge proposals for secrets

A text that a model proposes for a collection is checked with the secret
patterns that already keep a memory note from being saved at once. A
flagged text is still stored and shown, with a warning on the card, and
the owner can confirm it in the web interface. A messaging chat cannot
confirm it: the host does not write it in the chat, and a yes does not
apply it. A no still drops it.

The proposal carries the result in a new warning field, worked out at
each read.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 09:15:21 +00:00
1 parent a0e32049d4
commit 75c21675fc
28 files changed
+235 -9

No files matched your search

@@ -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')
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -802,6 +802,11 @@
"embed_one": "구절 1개를 {{model}}(으)로 임베딩합니다.",
"embed_other": "약 {{count}}개 구절을 {{model}}(으)로 임베딩합니다.",
"serverModel": "서버의 임베딩 모델"
},
"warning": {
"title": "확인하기 전에 이 텍스트를 점검하세요.",
"secret": "키, 토큰, 비밀번호 같은 비밀이 들어 있는 것으로 보입니다. 추가되면 이 컬렉션을 검색하는 모든 채팅이 읽을 수 있습니다. 메신저 채팅에서는 확인할 수 없으며, 여기에서 직접만 확인할 수 있습니다.",
"other": "서버가 이 텍스트를 위험한 것으로 표시했습니다. 메신저 채팅에서는 확인할 수 없으며, 여기에서 직접만 확인할 수 있습니다."
}
},
"proposalsPage": {
@@ -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": {
@@ -802,6 +802,11 @@
"embed_one": "1 个段落将使用 {{model}} 做嵌入。",
"embed_other": "约 {{count}} 个段落将使用 {{model}} 做嵌入。",
"serverModel": "服务器的嵌入模型"
},
"warning": {
"title": "确认之前请检查这段文字。",
"secret": "它看起来包含密钥、令牌或密码之类的机密。添加之后,搜索这个集合的每个聊天都能读到它。消息聊天无法确认它,只有你能在这里确认。",
"other": "服务器将这段文字标记为有风险。消息聊天无法确认它,只有你能在这里确认。"
}
},
"proposalsPage": {
@@ -267,6 +267,12 @@ export default function ChangeProposalCard({ id, initial, t, tick = 0 }) {
))}
{expiry && <div className="cx-plan__row" data-row="expires"><dt>{t('changes.row.expires')}</dt><dd>{expiry}</dd></div>}
</dl>
{p.warning && (
<p className="cx-help cx-help--err cp-warning" data-testid="change-warning" data-warning={p.warning}>
<b>{t('changes.warning.title')}</b>{' '}
{t(`changes.warning.${p.warning}`, { defaultValue: t('changes.warning.other') })}
</p>
)}
{knowledge && edit === null && <KnowledgePreview p={p} t={t} chunking={chunking} />}
{!knowledge && current !== undefined && edit === null && <Diff before={current ?? ''} after={p.content || ''} target={target} t={t} />}
{!knowledge && current === null && <p className="cx-help">{t('changes.unreadFile')}</p>}
@@ -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); }
+32 -8
View File
@@ -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
+3
View File
@@ -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
}
+13 -1
View File
@@ -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
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
+15
View File
@@ -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
+57
View File
@@ -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)
+7
View File
@@ -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;
+13
View File
@@ -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.
+4
View File
@@ -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"
}
}
},
+4
View File
@@ -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"
}
}
},
+8
View File
@@ -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: