Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0403677
Learn the user's writing style from sent mail; template gallery and o…
Taskmaster-1 Oct 28, 2026
d6b6449
Plain-language risk facts for approvals
Taskmaster-1 Oct 28, 2026
25ae675
Approvals that remind, escalate, expire, and need two people when it …
Taskmaster-1 Oct 28, 2026
df6177c
Memory: a Writing style card to learn, edit or relearn how you write
Taskmaster-1 Oct 28, 2026
a7ba83a
Automations: a gallery of ready-made templates
Taskmaster-1 Oct 28, 2026
2f977a0
Home: getting-started checklist from real state
Taskmaster-1 Oct 28, 2026
dd4ce52
Merge workstream A: writing style, templates, onboarding
Taskmaster-1 Oct 28, 2026
11691a3
Landing demo script judged by the real policy engine
Taskmaster-1 Oct 28, 2026
2a92f27
Landing: phone-approval hero, try-to-hack-it demo, current features
Taskmaster-1 Oct 28, 2026
da617af
Trust page: containment, permissions, data, audit, limits, disclosure
Taskmaster-1 Oct 28, 2026
44a6c1d
Merge workstream C: landing, hack-it demo, trust page
Taskmaster-1 Oct 28, 2026
6bc1e4f
Core: skills in the Agent Skills format, and a read never widens the …
Taskmaster-1 Oct 28, 2026
f2ebc8a
Skills for every user: built-ins, their own skills, and an API
Taskmaster-1 Oct 28, 2026
d01af21
Skills page: switch built-ins on or off, write, import and export you…
Taskmaster-1 Oct 28, 2026
594d167
Docs: skills, and a read never widens the write
Taskmaster-1 Oct 28, 2026
42bec4d
A second approval from the same person is 409 already_approved
Taskmaster-1 Oct 28, 2026
c7706c0
Approval cards show risk facts, expiry, escalation and two-person pro…
Taskmaster-1 Oct 28, 2026
dfc55b1
Docs: reminders, escalation, expiry, two-person sign-off, risk facts
Taskmaster-1 Oct 28, 2026
1042c58
Merge workstream D: skills, a read never widens the write
Taskmaster-1 Oct 28, 2026
edbc928
Merge workstream B: approvals that remind, escalate, expire; two-pers…
Taskmaster-1 Oct 28, 2026
08522d8
Attach any file in chat, and to emails
Taskmaster-1 Oct 28, 2026
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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Agent products that act in your tools share three problems: approvals that live
| | |
|---|---|
| **Policy in code** | Every tool declares an effect — `read`, `write`, `send`, `destructive`. A rule engine decides allow / ask / block per tool and per argument (e.g. *"email to @mycompany.com: allow; anyone else: ask"*). The model never sees or edits the policy. |
| **Taint tracking (Rule of Two)** | When a run reads untrusted content — an email body, a web page, an MCP result — it is marked tainted, and from then on no write or send is auto-approved, whatever the rules say. Prompt injection can still mislead the model, but it can't act without you. |
| **Taint tracking (Rule of Two)** | When a run reads untrusted content — an email body, a web page, an MCP result — it is marked tainted, and from then on no write or send is auto-approved, whatever the rules say. And a read never widens the write: a recipient, site or channel that first showed up in that content is named on the approval card, and no plan or learned rule can wave it through. Prompt injection can still mislead the model, but it can't act without you. |
| **Approvals you can edit** | Approval cards show the full payload. Edit the email before it goes, deny with a reason the agent sees, or approve from the inbox later — paused runs live in Postgres, not in a process. |
| **Durable runs** | Runs are leased to workers and checkpointed at every step. Restart a server mid-run and another worker picks it up; side-effecting calls interrupted mid-flight are reported as *unknown*, never silently retried. |
| **Works when you're not looking** | Automations fire on a schedule, on new email, or on a webhook from GitHub, Linear, Stripe or anything else. Each event becomes its own task, starts tainted, and lands in your inbox only when it needs a signature. |
Expand All @@ -30,13 +30,17 @@ Agent products that act in your tools share three problems: approvals that live
- Automations triggered by a cron schedule, new Gmail messages matching a search, or a secret webhook URL
- MCP connections over Streamable HTTP with OAuth 2.1 + PKCE, or bearer tokens; presets for GitHub, Linear, Notion, Atlassian, Sentry
- Approval inbox, editable approvals, deny-with-reason, cancel now / after this step
- Approve a plan, not every step: the agent proposes the exact actions it will take, you untick what you don't want, and the kept actions run without asking again (even after the task read outside content, because you saw each one)
- Approve a plan, not every step: the agent proposes the exact actions it will take, you untick what you don't want, and the kept actions run without asking again (even after the task read outside content, because you saw each one, unless a step names a recipient that first appeared in that content)
- Several pending actions from one step can be ticked and signed off together
- Approvals that learn: after you approve the same kind of action unchanged a few times, Synapse offers a visible, removable rule ("emails where every address is @acme.com"). Tasks that read outside content still ask
- Approve from your phone: install Synapse as an app and get push notifications with Approve and Deny buttons (the undo window still applies), or opt in to email alerts ([setup](docs/notifications.md))
- Approve from Slack: approvals arrive as a DM with Approve and Deny buttons, and the message updates wherever you decide ([setup](docs/slack.md))
- Undo window: approved sends and deletes wait a few seconds (default 10, configurable) with an Undo button
- Team workspaces: one set of rules for everyone in the team (the strictest answer wins, a team block can't be overridden), risky actions routed to an admin for sign-off, invite links, and a CSV audit export. Tasks, connections and memory stay personal ([how it works](docs/teams.md))
- Skills in the open Agent Skills format: six built in (inbox triage, meeting prep, follow-ups, weekly calendar review, polite declines, status updates), plus your own, written on the Skills page, imported as a `SKILL.md`, or saved from a chat. The agent sees one line per skill and loads the rest only when a task needs it; every use is recorded with the skill's version. Skills are instructions, never permissions ([how they work](docs/skills.md))
- Approvals that don't wait forever: one reminder, escalation to team admins, then expiry as an ordinary deny the agent sees. Optional two-person sign-off for irreversible actions (and sends): two different people, any deny ends it
- Plain-language risk facts on every approval, in the app and in push, Slack and email ("Sends to 2 people outside acme.com", "Can't be undone", "After reading outside content")
- Attach any file in chat (photos, videos, PDFs, documents; up to 25 MB each) with the paperclip, drag and drop, or paste. The agent reads text files and PDFs, and can attach any of your files to an email; the approval card shows exactly which files go out
- Knowledge base: upload docs, hybrid full-text + vector search (pgvector, reciprocal rank fusion)
- Memory you can read and edit; the agent can add notes, but not from a tainted run without asking
- Web search (Tavily, Brave or Exa) and SSRF-guarded web fetch
Expand Down Expand Up @@ -109,7 +113,7 @@ Defaults: `read` → allow, `write`/`send`/`destructive` → ask. Rules are eval
]
```

Taint overrides `allow` for anything but reads. In a [team workspace](docs/teams.md), the team's rules are evaluated alongside yours and the strictest answer wins. MCP tools inherit effects from their annotations (`readOnlyHint`, `destructiveHint`); tools that declare nothing are treated as destructive until you say otherwise on the Connections page.
Taint overrides `allow` for anything but reads. On top of that, **a read never widens the write**: in a tainted task, a send, a delete, or a write to a named destination (`to`, `channel`, `repo`, a URL…) whose recipient, site or destination never appeared in your own messages, your instructions and memory, or what the task did before it read outside content, asks with a reason that names it — *"New recipient attacker@evil.example appeared only after reading outside content."* A plan grant or a learned rule can't stand in for that approval; block rules still win. In a [team workspace](docs/teams.md), the team's rules are evaluated alongside yours and the strictest answer wins. MCP tools inherit effects from their annotations (`readOnlyHint`, `destructiveHint`); tools that declare nothing are treated as destructive until you say otherwise on the Connections page.

## Evals

Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ The main threats and how they are handled:
| Cross-site request forgery | State-changing API requests with a foreign `Origin` are rejected; session cookies are `SameSite=Lax`. |
| Runaway spend | Per-run step, token and cost limits and a per-day cost limit, checked before every model call. Users can only tighten the operator's caps. |
| Mis-annotated MCP tools | MCP `readOnlyHint`/`destructiveHint` are server-supplied hints. Users can override any tool's effect; MCP reads are never re-run after a crash; all MCP output taints the run. |
| Injected recipients ("also send a copy to…") | A read never widens the write: in a tainted run, a send, a delete or a write to a named destination whose recipient, site or destination did not appear in the user's own messages or the run's pre-taint context asks with a reason that names it. Plan grants and learned rules cannot cover it; block rules still win. Covered by the `injected-recipient` eval. |
| Skills | Skills are instructions, never permissions: they cannot change effects, rules or approvals, and `allowed-tools` is ignored. Saving a skill from a chat always asks, so injected content cannot plant one. |
| Taint laundering across turns | Taint is per chat: once any run in a chat has read untrusted content, every later run in it starts tainted. |
| Event triggers (new email, webhooks) | Each event starts its own task, tainted from its first message, so nothing but reads is auto-allowed. Event content is quoted in a delimited block whose closing tag third parties cannot forge. Webhook URLs carry a 192-bit secret (compared in constant time, rotatable) and are rate-limited per automation. |
| Google tokens | Sealed with `SYNAPSE_SECRET` (AES-256-GCM). The OAuth `state` is a sealed blob holding the user id and PKCE verifier, so a callback cannot complete for another account. Disconnecting revokes the grant at Google. Outgoing mail headers are stripped of line breaks (no header injection). |
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { documents, memories, policy, settings, usage } from "./routes/account";
import { automations, hooks } from "./routes/automations";
import { chats } from "./routes/chats";
import { clientMetadataDocument, connections, oauthCallback } from "./routes/connections";
import { files } from "./routes/files";
import { googleCallback, integrations } from "./routes/integrations";
import { models } from "./routes/models";
import { onboarding } from "./routes/onboarding";
import { push } from "./routes/push";
import { approvals, runs } from "./routes/runs";
import { skills } from "./routes/skills";
import { slackIntegration, slackInteractions } from "./routes/slack";
import { writingStyle } from "./routes/style";
import { suggestions } from "./routes/suggestions";
import { workspace } from "./routes/workspace";
import type { Services } from "./services";
Expand Down Expand Up @@ -81,12 +85,16 @@ export function createApp(services: Services) {
api.route("/policy", policy);
api.route("/memories", memories);
api.route("/documents", documents);
api.route("/files", files);
api.route("/settings", settings);
api.route("/usage", usage);
api.route("/models", models);
api.route("/automations", automations);
api.route("/skills", skills);
api.route("/push", push);
api.route("/workspace", workspace);
api.route("/writing-style", writingStyle);
api.route("/onboarding", onboarding);
api.get("/me", (c) => c.json({ user: c.var.user }));
app.route("/api", api);

Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/approval-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { orgDomain, riskFacts } from "@synapse/core";
import { approvalsNeeded, type Db, schema } from "@synapse/db";
import { and, desc, eq, inArray, isNotNull, or, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";

/**
* Waiting approvals as the inbox shows them: the call, plus when it expires, whether it
* was escalated, its two-person sign-off state and plain-language risk facts.
*/
export async function listApprovals(
db: Db,
where: SQL | undefined,
opts: { withRequester?: boolean } = {},
) {
const first = alias(schema.user, "first_approver");
const rows = await db
.select({
runId: schema.toolCalls.runId,
chatId: schema.runs.chatId,
chatTitle: schema.chats.title,
toolCallId: schema.toolCalls.id,
name: schema.toolCalls.name,
effect: schema.toolCalls.effect,
input: schema.toolCalls.input,
inputEdited: schema.toolCalls.inputEdited,
reason: schema.toolCalls.decisionReason,
preview: schema.toolCalls.preview,
/** "admin": the person's team requires an admin to decide; they only wait. */
approver: schema.toolCalls.approver,
tainted: schema.runs.tainted,
expiresAt: schema.toolCalls.expiresAt,
escalatedAt: schema.toolCalls.escalatedAt,
firstId: schema.toolCalls.firstApprovedBy,
firstName: first.name,
firstAt: schema.toolCalls.firstApprovedAt,
twoPerson: schema.workspaces.twoPerson,
createdAt: schema.toolCalls.createdAt,
requester: {
id: schema.user.id,
name: schema.user.name,
email: schema.user.email,
},
})
.from(schema.toolCalls)
.innerJoin(schema.runs, eq(schema.runs.id, schema.toolCalls.runId))
.innerJoin(schema.chats, eq(schema.chats.id, schema.runs.chatId))
.innerJoin(schema.user, eq(schema.user.id, schema.toolCalls.userId))
.leftJoin(schema.workspaceMembers, eq(schema.workspaceMembers.userId, schema.toolCalls.userId))
.leftJoin(schema.workspaces, eq(schema.workspaces.id, schema.workspaceMembers.workspaceId))
.leftJoin(first, eq(first.id, schema.toolCalls.firstApprovedBy))
.where(and(eq(schema.toolCalls.status, "awaiting_approval"), where))
.orderBy(desc(schema.toolCalls.createdAt));
return rows.map(({ firstId, firstName, firstAt, twoPerson, requester, ...r }) => ({
...r,
facts: riskFacts({
name: r.name,
effect: r.effect,
input: r.input,
tainted: r.tainted,
domain: orgDomain(requester.email),
}),
signoffs: {
needed: approvalsNeeded(r.effect, twoPerson),
first: firstId ? { id: firstId, name: firstName ?? "Someone", at: firstAt } : null,
},
...(opts.withRequester ? { requester } : {}),
}));
}

/**
* What a team's owners and admins may decide for members: calls a team rule reserves for
* an admin, calls escalated to them, and calls the two-person rule needs a second person for.
*/
export function teamDecidable(workspaceId: string, twoPerson: schema.TwoPersonRule | null) {
const effects =
twoPerson === "destructive"
? (["destructive"] as const)
: twoPerson === "destructive_send"
? (["destructive", "send"] as const)
: [];
return and(
eq(schema.workspaceMembers.workspaceId, workspaceId),
or(
eq(schema.toolCalls.approver, "admin"),
isNotNull(schema.toolCalls.escalatedAt),
...(effects.length ? [inArray(schema.toolCalls.effect, [...effects])] : []),
),
);
}
Loading
Loading