Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions changes/144.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**An email account connected over IMAP is searched and watched properly again.** Searches, follow-ups and new-mail triggers read the server's answer wrongly and found nothing. After updating, watching starts again from now, so older mail isn't treated as new.
1 change: 1 addition & 0 deletions changes/144.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Replies and forwards go only where you meant them to.** Who a reply goes to now comes from the email's own sender, never from its text, and an address that only appears inside an email (even one that looks like a contact's) asks first, with a note saying exactly where it came from. A reply that the email quietly redirects elsewhere asks too, and follow-up drafts can no longer copy an address or link from the other person's message. Sentient can also forward email, and can read an email or page through a reader with no tools that hands back only the facts it was asked for.
2 changes: 2 additions & 0 deletions desktop/src/features/chat/toolMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IconListDetails,
IconPointer,
IconScreenshot,
IconShieldSearch,
IconSpeakerphone,
IconTerminal2,
IconUserBolt,
Expand Down Expand Up @@ -74,6 +75,7 @@ const KNOWN: Record<string, ToolMeta> = {
terminal_run: { running: 'Running a command', done: 'Ran a command', icon: IconTerminal2, would: 'Would run a command' },
delegate_task: { running: 'Asking a helper', done: 'Asked a helper', icon: IconUserBolt, would: 'Would ask a helper' },
delegate_tasks: { running: 'Asking helpers', done: 'Asked helpers', icon: IconUsersGroup, would: 'Would ask helpers' },
read_with_reader: { running: 'Reading it with a safe reader', done: 'Read it with a safe reader', icon: IconShieldSearch },
browser_open: { running: 'Opening a page', done: 'Opened a page', icon: IconWorldWww },
browser_snapshot: { running: 'Reading the page', done: 'Read the page', icon: IconListDetails },
browser_click: { running: 'Clicking', done: 'Clicked', icon: IconPointer, would: 'Would click' },
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,8 @@ export interface Subagent {
session_id: string | null
parent_call_id: string | null
goal: string
/** `reader`: the tool-less reader behind `read_with_reader` (#144); it never makes tool calls. */
kind?: 'worker' | 'reader'
status: SubagentStatus
background: boolean
summary: string | null
Expand Down
57 changes: 53 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,8 @@ approval). The run's own `ask_user` question still asks. A one-call task (`fixed
Code, never a model, checks what a call that should change something really did, right after it runs
(`sentient/agent/outcomes.py`). Checked: tools that declare `Tool.outcome` (`"email"|"message"|"file"|"event"|"home"`)
or `Tool.verify`, and every MCP tool that is not a look-up (its kind guessed from its name: `send_email` is `email`).
Today that is `file_write`, `gmail_send`, `gmail_reply`, `email_imap_send`, `gcal_create_event`, `gcal_quick_add`,
Today that is `file_write`, `gmail_send`, `gmail_reply`, `gmail_forward`, `email_imap_send`, `email_imap_reply`,
`email_imap_forward`, `gcal_create_event`, `gcal_quick_add`,
`slack_post_message`, `slack_reply_in_thread`, `discord_send_message`, `whatsapp_send_message`, the Home Assistant
actions (`home_assistant_turn_on`/`turn_off`/`set_temperature`/`activate_scene`/`run_script`/`open_cover`/`close_cover`/
`lock`/`unlock`/`set_alarm`) and MCP tools. A call
Expand Down Expand Up @@ -1389,7 +1390,20 @@ can't act.
`starttls`, port 143), `port`, `username`, `password` (app password, secret), `folders` (comma-separated mailbox names
to watch for new mail, default `INBOX`), optional `smtp_host`, `smtp_port`; connect signs in to IMAP (selecting every
watched folder) and SMTP when given, before saving. Tools `email_imap_search` (read), `email_imap_read` (read),
`email_imap_send` (send). Privacy filters like Gmail. Trigger `new_email`.
`email_imap_send` (send), `email_imap_reply {message_id, body, reply_all=false, mailbox="INBOX"}` (send; who it goes
to comes from the email's headers, below) and `email_imap_forward {message_id, to, note="", mailbox="INBOX"}` (send;
the original's headers and text quoted under the note, attachments kept). Privacy filters like Gmail. Trigger
`new_email`. The watcher's cursor per folder is `{uidvalidity, last_uid, searched: true}`; one at `last_uid` 0
without `searched` (taken while searches came back empty, before #144) starts again from now, once.
- **Replies and forwards (#144, ADR 0031).** `gmail_reply`, `email_imap_reply` and `gmail_forward
{message_id, to, note=""}` work alike. A reply never takes a recipient argument: `integrations.mail.reply_targets`
reads the original's headers (Reply-To, else From; the To of the user's own message, a nudge; the other To and Cc
addresses with `reply_all`). The reply tools declare `Tool.destinations_fn(arguments, ctx) ->
[{address, why: "sender"|"reply_to"|"yours"|"thread", sender}]` (`yours`: the recipients of the user's own message,
which needs the app's own word that the user sent it: Gmail's `SENT` label, or for IMAP the account's Sent mailbox;
a received email that only claims the user's From is answered like any other;
`thread`: the others on a reply-all), which the second look calls before the reply runs
(section 10). A forward refuses an email hidden by privacy filters, like a read.
- `webhook` ("Webhooks", builtin, no tools): its `triggers` are computed from the user's hooks,
`[{event: <hook id>, label: "When \"<name>\" is called"}]`; `integration.updated` is sent when hooks are created or deleted.
- `gcalendar.triggers` are `new_event` ("New event") and `updated_event` ("Changed event").
Expand Down Expand Up @@ -1489,7 +1503,10 @@ Follow-up suggestion: `suggestion_type` `follow_up_reply` or `follow_up_nudge`,
Approving it (the card's **Send reply** / **Send nudge**, which shows the exact draft) is the approval of that one send:
it creates an already approved task (`app.tasks.create_approved_call`, section 4) that runs right away, whatever
`tasks.require_plan_approval` says, and makes exactly one call with the draft unchanged: `gmail_reply {message_id, body}` in
the same thread, or `email_imap_send {to, subject, body, reply_to_message_id?}`. No model is involved in the send. A lasting
the same thread, or `email_imap_send {to, subject, body, reply_to_message_id?}`. No model is involved in the send. The
recipient is fixed from the thread's headers before the model reads the thread (#144); the thread reaches the model
marked as outside content, and a draft that copies an email address or a link only the other person's messages have is
dropped and tried again at the next check (`followups.copied_from_them`). A lasting
Never rule on that tool or its app (`tools.approvals.rules`) still applies: the run fails with the rule's message and
nothing is sent.
- `GET /api/notifications?limit=&unread_only=` → `{notifications: [Notification], unread}`
Expand Down Expand Up @@ -2187,6 +2204,23 @@ Every new tool declares a `Risk`; approvals behave as in section 1.
`arjun.mehta@...`; the user's own name counts). Every amount of money (`$1,200`, `500 rupees`, a number in an
`amount`/`price`/`total`... field) must appear there too. After outside content, a link in the call that came
from that content is flagged.
- **Recipients first** (#144, ADR 0031). Outside tool results are read as JSON (`second_look.read_places`): in an
item with a `from`/`sender_email` address and a `subject`, `body`, `snippet` or `to`, the `from` is the message's
sender, `to`/`cc`/`bcc`/`reply_to` are its header lines and every other value is its text; non-JSON text (a page,
a result cut for length) is text except its `"from": "..."` values. An address that outside content names
anywhere other than as a sender passes only when the user's words, trusted records or a saved memory have it: a
name match never vouches for it. Its warning names the place: `"... That address came from the text of an email
from Arjun Mehta <arjun@example.com> (Gmail), not from you."` (`the Cc line of an email from ...` for a header
line). A tool message the engine wrapped as outside content, or whose result starts with `outside_content`,
counts as outside content too. Outside content inside a user turn (a group member's message) is never read for
senders: it is all text.
- **Reply destinations.** For a tool with `destinations_fn` (the reply tools), its destinations are checked like
recipients and copied to the plan, so `approval_request.preview`, `undo_window.preview` and a task's
`pending_question.preview` get a `To` field. A `reply_to` destination on another domain than the sender, that the
user never gave, is flagged: `"This reply goes to x@evil.example, the address that email asked replies to go to,
not to the person who sent it (arjun@example.com)."` A `thread` destination (reply-all) is checked like a recipient
unless the user's words ask to reply to all ("reply all", "everyone"). A `destinations_fn` that fails (20 seconds
at most) flags the call: `"Sentient couldn't read who this reply goes to, so it checks with you first."`
- **Model** (`second_look.model_check`, only for effective risk `send`/`exec` of tools that are not `internal`,
`tools.approvals.second_look_model`, default on): when nothing was flagged yet and the call would otherwise run
without asking, or the run read outside content. One tool-free `fast`-role prompt sees only the user's own words
Expand Down Expand Up @@ -2281,7 +2315,22 @@ Every new tool declares a `Risk`; approvals behave as in section 1.
`{subagent_id, status: "running"}` at once; when it finishes, a summary is added to the chat as an assistant message,
`subagent.updated` fires and a notification is created.
- `delegate_tasks(tasks: list[{goal, context?}])` runs up to `subagents.max_concurrent` in parallel and returns every summary.
- **Subagent** `{subagent_id, session_id, parent_call_id, goal, status: "running"|"completed"|"error"|"cancelled", background, summary, error, tool_calls, started_at, finished_at, events: [ProgressUpdate]}`
- `read_with_reader(tool, fields: {name: kind}, arguments?: dict)` (risk `read`, internal; #144, ADR 0031) runs the
look-up `tool` with `arguments` in code and hands its result to the **reader**: one model call with no tools
(`fast` role, then the primary; Claude Code only inside a chat reply), the document marked as outside content and
cut to 12,000 characters. Kinds: `text` (one sentence, 300 characters), `email`, `url` (both must be written in the
document), `date` (`YYYY-MM-DD`, optional time), `number`, `yes_no`, `id` (no spaces, a digit, written in the
document) and choices `"a|b|c"`; at most 12 fields. Code keeps exactly those fields; a value of the wrong kind is
`null`. Returns `{source, fields, subagent_id}`, with `outside_content: <source>` first when the tool brings outside
content and a `text`, `email` or `url` value came back (the run is then marked, ADR 0018); a result of only dates,
numbers, yes or no, ids and choices does not mark it. Refused (`{error}`) for tools that aren't look-ups, that
could send data out, whose rule is Ask, Only if I asked or Hand it to me, that are hidden, the subagent tools, and
an address that could carry data to a site a marked run hasn't visited. Tool selection offers it with any tool
that reads outside content.
- **Subagent** `{subagent_id, session_id, parent_call_id, goal, kind: "worker"|"reader", status: "running"|"completed"|"error"|"cancelled", background, summary, error, tool_calls, started_at, finished_at, events: [ProgressUpdate]}`.
A reader (`kind: "reader"`) has `tool_calls: 0`, `goal` `"Read <source> for: <field names>"`, `summary` the JSON of
the fields returned, and two events: `info` (the fields asked for, with their kinds) and `final_answer` (the fields
returned), or `error`. `POST /api/subagents/{id}/cancel` stops a running reader (`status: "cancelled"`).
- `GET /api/sessions/{id}/subagents` → `[Subagent]` newest first; `GET /api/subagents/{id}` → `Subagent`;
`POST /api/subagents/{id}/cancel` → `Subagent`. Domain event `subagent.updated` → `Subagent` (without `events`).
- While a foreground subagent runs, the parent emits `tool_progress` with `kind: "subagent"` and `data: {subagent_id, message}`.
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0018-untrusted-content-gates-sending.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
- **Status:** Accepted
- **Date:** 2026-10-09
- **Amended by:** [ADR 0023](0023-mark-outside-content-in-prompts.md) (the same tools' results reach the model marked as outside content),
[ADR 0029](0029-group-chats-and-trusting-a-chat-again.md) (other people in a group chat are outside content; the user can trust a chat again)
[ADR 0029](0029-group-chats-and-trusting-a-chat-again.md) (other people in a group chat are outside content; the user can trust a chat again),
[ADR 0031](0031-recipients-first-and-a-tool-less-reader.md) (a reader result of only dates, numbers, yes or no, ids and choices doesn't mark a run)

## Context

Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0024-second-look-at-outgoing-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

- **Status:** Accepted
- **Date:** 2026-10-13
- **Amended by:** [ADR 0029](0029-group-chats-and-trusting-a-chat-again.md) (marked text inside a user turn is outside content)
- **Amended by:** [ADR 0029](0029-group-chats-and-trusting-a-chat-again.md) (marked text inside a user turn is outside content),
[ADR 0031](0031-recipients-first-and-a-tool-less-reader.md) (a name never vouches for an address outside content wrote; reply destinations come from the headers and are checked)

## Context

Expand Down
78 changes: 78 additions & 0 deletions docs/adr/0031-recipients-first-and-a-tool-less-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 0031. Fix where a message goes before reading what it says, and read outside documents with a tool-less reader

- **Status:** Accepted
- **Date:** 2026-10-17
- **Amends:** [ADR 0024](0024-second-look-at-outgoing-actions.md) (which recipients a name may vouch for; reply
destinations), [ADR 0018](0018-untrusted-content-gates-sending.md) (a reader result made only of dates, numbers,
yes or no, ids and choices does not mark a run)

## Context

[ADR 0018](0018-untrusted-content-gates-sending.md) makes every send ask once a run read outside content, and the
second look ([ADR 0024](0024-second-look-at-outgoing-actions.md)) explains on the card when a recipient isn't one the
user gave. Two gaps were left (#144). First, the second look let an address through when it matched a name the user
gave: "reply to Arjun" vouched for `arjun@evil.example` written in the email's own text, and a reply took its
destination from a Reply-To header nobody looked at. Second, the model that plans and acts still read every email in
full. The plan-then-execute and dual-LLM patterns (Willison, "Design patterns for securing LLM agents against prompt
injections", 2025; CaMeL, 2025) fix the destinations before untrusted text is read and give that text only to a model
that has no tools, whose answer is cut down to typed fields. A plain dual-LLM split still lets injected text swap a
recipient argument, so destinations have to be fixed in code.

## Decision

**Recipients first.** Who a send flow reaches is decided from the user's words, trusted records (contacts, memories,
Sentient's own results) and the thread's headers, never from message text:

- Replies take their destination from the original message's headers in code (`integrations/mail.reply_targets`):
From, or Reply-To, or the recipients of the user's own message; Cc with reply-all. `gmail_reply` and the new
`email_imap_reply` take no recipient argument. `Tool.destinations_fn` reads those headers before the call runs, so
the second look checks them and the approval card and undo window show "To". A Reply-To that sends the reply to
another address than the sender, on another domain, that the user never gave, is flagged ("This reply goes to
x@evil.example, the address that email asked replies to go to, not to the person who sent it (arjun@example.com).").
The others on a reply-all are checked like any recipient unless the user asked to reply to all, and headers that
can't be read make the reply ask.
- Forwards (`gmail_forward`, `email_imap_forward`) take `to` like any send; the second look checks it.
- Follow-ups already fixed their recipient from the headers before the model saw the thread. The thread now reaches
the model marked as outside content, and a draft that copies an address or a link only the other person's messages
have is dropped, like a draft with a blank.
- The second look reads outside tool results as JSON: the keys come from the tool, so an email item's `from` is its
sender and its `to`, `cc`, `reply_to` are header lines. An address that outside content names anywhere other than
as a message's sender (its text, its To or Cc line, a web page) passes only when the user's words or trusted records
have it. A name match no longer vouches for it. The card says where it was: "That address came from the text of an
email from Arjun Mehta <arjun@example.com> (Gmail), not from you." Only a tool's own JSON names a sender: text inside
a user turn (a group member's message) is all text.

**The reader.** `read_with_reader(tool, arguments, fields)` runs one look-up tool in code and hands its result to a
reader: one model call with no tools at all (on the `fast` role, then the primary; Claude Code only inside a chat
reply, [ADR 0022](0022-claude-through-your-own-claude-code.md)), with the document marked as outside content. Code
keeps only the requested fields, each checked against its kind: `text` (one short sentence), `email` and `url` (must be
written in the document), `date`, `number`, `yes_no`, `id` (no spaces, a digit, written in the document) or a list of
choices. Everything else is dropped. The reader is a subagent row with `kind = "reader"`, `tool_calls = 0` and a trace
of the fields asked for and returned. It only takes look-up tools, never one that sends or could carry data to a new
site in a marked run.

A reader result with only dates, numbers, yes or no, ids and choices cannot carry an instruction or a destination, so
it does not mark the run. Text, addresses and links do: the result says `outside_content` and the run is marked as if
the email itself were read, and an address in it is outside content to the second look. The tool is offered with any
tool that reads outside content, and the chat prompt says to decide destinations from the user, contacts or the
sender.

## Consequences

An email that asks to be forwarded to a new address, or names a look-alike of a real contact, can't change where
anything goes without a card that names where the address came from. "Forward Arjun's email to Priya" can run
without reading the email at all, so with Allow rules it needs no question. Reply-To redirections, a common phishing
trick, now ask. A send to a contact whose address also appears on a Cc line Sentient read asks once more than before,
in a run that already asks. The reader costs one short model call; a small model may fill fewer fields, which come
back null. A sender who spoofs From as a contact's look-alike is still treated as that sender when the user replies to
them, as every mail client does; the card shows the address.

## Alternatives considered

- **Route every email through the reader.** Safest, but the model then can't summarize or answer long threads well,
and small local models lose detail. The reader is offered next to the full-text tools instead; the code gates stay
the defence either way.
- **Let the reader return free text without marking the run.** The free text is exactly what an injection writes.
- **Trust any header address.** Every header of an email is written by its sender, so a Cc line is as open to
injection as the text; only the From of a message the user is answering is treated as its sender.
- **Drop Reply-To.** Mailing lists and support desks rely on it; asking once with a reason keeps them working.
Loading
Loading