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
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,26 @@ GOOGLE_CLIENT_SECRET=
# How often email triggers check for new mail, in seconds (min 30).
# TRIGGER_POLL_SECONDS=120

# ── Slack (optional) ─────────────────────────────────────────────────────────
# Approve and deny from Slack DMs. All three from your Slack app's Basic Information page;
# PUBLIC_URL must be public https for Slack to reach it. Setup: docs/slack.md
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=

# ── Notifications (optional) ─────────────────────────────────────────────────
# Web Push for approvals on phones and desktops. Setup: docs/notifications.md
# Generate a pair: bunx web-push generate-vapid-keys
# Unset in development: a pair is generated once into .data/vapid.json. Production: off until set.
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# Contact for push services: mailto:you@example.com (or an https URL). Required in production.
# VAPID_SUBJECT=mailto:you@example.com
# Email alerts (each user opts in under Settings → Notifications).
# SMTP_URL=smtps://user:password@smtp.example.com:465
# EMAIL_FROM=Synapse <synapse@example.com>
# Web app: register the service worker under `next dev` too (it always is in production builds).
# NEXT_PUBLIC_ENABLE_SW=true

# Allow MCP servers / web_fetch on private networks (self-hosted MCP on your LAN).
SYNAPSE_ALLOW_PRIVATE_NETWORK=false
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ Agent products that act in your tools share three problems: approvals that live
- 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)
- 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))
- 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 @@ -106,7 +109,7 @@ Defaults: `read` → allow, `write`/`send`/`destructive` → ask. Rules are eval
]
```

Taint overrides `allow` for anything but reads. 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. 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
13 changes: 9 additions & 4 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
"seed": "bun src/seed.ts"
},
"dependencies": {
"better-auth": "1.7.6",
"hono": "4.13.9",
"zod": "4.6.5",
"@synapse/core": "workspace:*",
"@synapse/db": "workspace:*",
"@synapse/tools": "workspace:*",
"ai": "7.0.116",
"drizzle-orm": "0.45.3"
"better-auth": "1.7.6",
"drizzle-orm": "0.45.3",
"hono": "4.13.9",
"nodemailer": "10.1.0",
"web-push": "3.6.7",
"zod": "4.6.5"
},
"devDependencies": {
"@types/web-push": "3.6.4"
}
}
17 changes: 15 additions & 2 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { chats } from "./routes/chats";
import { clientMetadataDocument, connections, oauthCallback } from "./routes/connections";
import { googleCallback, integrations } from "./routes/integrations";
import { models } from "./routes/models";
import { push } from "./routes/push";
import { approvals, runs } from "./routes/runs";
import { slackIntegration, slackInteractions } from "./routes/slack";
import { suggestions } from "./routes/suggestions";
import { workspace } from "./routes/workspace";
import type { Services } from "./services";

export function createApp(services: Services) {
Expand Down Expand Up @@ -47,15 +50,22 @@ export function createApp(services: Services) {
github: Boolean(config.GITHUB_CLIENT_ID),
google: Boolean(config.GOOGLE_CLIENT_ID),
},
integrations: { google: Boolean(config.google) },
features: { webSearch: Boolean(config.webSearch), embeddings: Boolean(config.embedding) },
integrations: { google: Boolean(config.google), slack: Boolean(config.slack) },
features: {
webSearch: Boolean(config.webSearch),
embeddings: Boolean(config.embedding),
push: Boolean(services.push),
emailAlerts: Boolean(services.email),
},
models: config.SYNAPSE_MODELS,
}),
);
app.get("/oauth/client-metadata.json", (c) => c.json(clientMetadataDocument(c)));
app.on(["GET", "POST"], "/api/auth/*", (c) => services.auth.handler(c.req.raw));
// Public: authenticated by the secret token in the URL, not a session.
app.route("/api/hooks", hooks);
// Public: authenticated by Slack's request signature.
app.route("/api/slack/interactions", slackInteractions);

const api = new Hono<AppEnv>();
api.use(requireUser);
Expand All @@ -65,6 +75,7 @@ export function createApp(services: Services) {
api.route("/connections/oauth/callback", oauthCallback);
api.route("/connections", connections);
api.route("/integrations/google/callback", googleCallback);
api.route("/integrations/slack", slackIntegration);
api.route("/integrations", integrations);
api.route("/policy/suggestions", suggestions);
api.route("/policy", policy);
Expand All @@ -74,6 +85,8 @@ export function createApp(services: Services) {
api.route("/usage", usage);
api.route("/models", models);
api.route("/automations", automations);
api.route("/push", push);
api.route("/workspace", workspace);
api.get("/me", (c) => c.json({ user: c.var.user }));
app.route("/api", api);

Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { parseModelRef } from "@synapse/core";
import type { GoogleAppConfig, WebSearchConfig } from "@synapse/tools";
import webpush from "web-push";
import { z } from "zod";
import type { SlackAppConfig } from "./notify/slack-api";

const csv = z.string().transform((s) =>
s
Expand All @@ -11,6 +13,10 @@ const csv = z.string().transform((s) =>
.filter(Boolean),
);

/** An empty value (`KEY=` in a .env file) counts as unset. */
const optional = <T extends z.ZodType>(schema: T) =>
z.preprocess((v) => (v === "" ? undefined : v), schema.optional());

const Env = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(8787),
Expand Down Expand Up @@ -57,8 +63,24 @@ const Env = z.object({
/** One Google OAuth app serves both "Sign in with Google" and the Gmail/Calendar integration. */
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
/** Slack app for approving from Slack (docs/slack.md). All three are needed. */
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_SIGNING_SECRET: z.string().optional(),
/** How often email triggers check for new mail. */
TRIGGER_POLL_SECONDS: z.coerce.number().int().min(30).default(120),

/** Web Push (VAPID). Generate with `bunx web-push generate-vapid-keys`; dev makes its own. */
VAPID_PUBLIC_KEY: optional(z.string().min(40)),
VAPID_PRIVATE_KEY: optional(z.string().min(20)),
/** Who push services can contact about this sender: "mailto:you@example.com" or an https URL. */
VAPID_SUBJECT: optional(
z.string().regex(/^(mailto:.+@.+|https:\/\/.+)$/, "must be mailto:… or https://…"),
),
/** Email alerts over SMTP, e.g. smtps://user:pass@smtp.example.com:465. */
SMTP_URL: optional(z.string().url()),
/** Sender for email alerts, e.g. "Synapse <synapse@example.com>". */
EMAIL_FROM: optional(z.string().min(3)),
});

export type Config = ReturnType<typeof loadConfig>;
Expand Down Expand Up @@ -94,9 +116,25 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
}
: null;

const slack: SlackAppConfig | null =
parsed.SLACK_CLIENT_ID && parsed.SLACK_CLIENT_SECRET && parsed.SLACK_SIGNING_SECRET
? {
clientId: parsed.SLACK_CLIENT_ID,
clientSecret: parsed.SLACK_CLIENT_SECRET,
signingSecret: parsed.SLACK_SIGNING_SECRET,
publicUrl: parsed.PUBLIC_URL,
}
: null;

return {
...parsed,
google,
slack,
push: pushConfig(parsed),
email:
parsed.SMTP_URL && parsed.EMAIL_FROM
? { smtpUrl: parsed.SMTP_URL, from: parsed.EMAIL_FROM }
: null,
SYNAPSE_MODELS: models,
SYNAPSE_SUMMARY_MODEL: summaryModel,
secret,
Expand Down Expand Up @@ -150,3 +188,39 @@ function devSecret(parsed: z.infer<typeof Env>): string {
writeFileSync(path, secret, { mode: 0o600 });
return secret;
}

export interface PushConfig {
publicKey: string;
privateKey: string;
subject: string;
}

/**
* VAPID keys from the environment. In development, unset keys are generated once and kept
* in .data/vapid.json (like the dev secret) so subscriptions survive restarts; production
* and tests never generate them, so push stays off until configured.
*/
function pushConfig(parsed: z.infer<typeof Env>): PushConfig | null {
const { VAPID_PUBLIC_KEY: publicKey, VAPID_PRIVATE_KEY: privateKey } = parsed;
if (Boolean(publicKey) !== Boolean(privateKey))
throw new Error("Set both VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY, or neither");
const fallbackSubject = parsed.PUBLIC_URL.startsWith("https://")
? parsed.PUBLIC_URL
: "mailto:synapse@localhost.localdomain";
if (publicKey && privateKey) {
if (parsed.NODE_ENV === "production" && !parsed.VAPID_SUBJECT)
throw new Error("VAPID_SUBJECT (mailto:you@example.com) is required with VAPID keys");
return { publicKey, privateKey, subject: parsed.VAPID_SUBJECT ?? fallbackSubject };
}
if (parsed.NODE_ENV !== "development") return null;
return { ...devVapidKeys(), subject: parsed.VAPID_SUBJECT ?? fallbackSubject };
}

function devVapidKeys(): { publicKey: string; privateKey: string } {
const path = ".data/vapid.json";
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
mkdirSync(dirname(path), { recursive: true });
const keys = webpush.generateVAPIDKeys();
writeFileSync(path, JSON.stringify(keys), { mode: 0o600 });
return keys;
}
162 changes: 162 additions & 0 deletions apps/server/src/decisions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { evaluateComposedPolicy, type RunEvent, UNDO_DENIAL } from "@synapse/core";
import { assertMayDecide, decideToolCalls, PgRunStore, schema, undoToolCall } from "@synapse/db";
import type { JSONValue } from "ai";
import { and, eq } from "drizzle-orm";
import type { DecisionSource } from "./notify/types";
import type { Services } from "./services";

export class DecisionInputError extends Error {
constructor(
readonly status: 400 | 404 | 422,
readonly code: string,
message: string,
) {
super(message);
}
}

export interface Decision {
toolCallId: string;
approved: boolean;
reason?: string;
/** Approve with this input instead of the agent's. */
input?: JSONValue;
}

/**
* Applies a person's decisions on one run's awaiting calls, wherever they were made (web,
* phone, Slack). All or nothing; then tells live viewers, wakes the run (or parks it until
* an undo window ends) and lets notification channels update what they showed.
*/
export async function decide(
services: Services,
args: { userId: string; runId: string; decisions: Decision[]; via: DecisionSource },
): Promise<{ executeAfter: Record<string, string> }> {
const { userId, runId, decisions, via } = args;
if (new Set(decisions.map((d) => d.toolCallId)).size !== decisions.length)
throw new DecisionInputError(400, "duplicate", "Each action can be decided once.");
for (const d of decisions)
if (d.approved && d.input !== undefined)
await assertEditAllowed(services, userId, runId, d.toolCallId, d.input);

const decided = await decideToolCalls(services.database.db, {
userId,
runId,
decisions: decisions.map((d) => ({
toolCallId: d.toolCallId,
approved: d.approved,
...(d.reason ? { reason: d.reason } : {}),
...(d.approved && d.input !== undefined ? { editedInput: d.input } : {}),
})),
via,
});
for (const [toolCallId, at] of Object.entries(decided.executeAfter))
await emitNow(services, runId, {
type: "tool.status",
toolCallId,
status: "approved",
executeAfter: at.toISOString(),
});
if (decided.requeued && decided.runnableAt) {
// Parked until the undo window ends: it is queued, not waiting on anyone.
await emitNow(services, runId, { type: "run.status", status: "queued" });
wakeAt(services, runId, decided.runnableAt);
} else await services.wake(runId);

await services.notifier.resolved(
decisions.map((d) => ({
// Whose task it is; a team admin may have decided it for them.
userId: decided.ownerId,
decidedBy: userId,
runId,
toolCallId: d.toolCallId,
approved: d.approved,
via,
executeAfter: decided.executeAfter[d.toolCallId] ?? null,
})),
);
return {
executeAfter: Object.fromEntries(
Object.entries(decided.executeAfter).map(([id, at]) => [id, at.toISOString()]),
),
};
}

/** Takes back an approved send or delete while its undo window is open. */
export async function undo(
services: Services,
args: { userId: string; runId: string; toolCallId: string },
) {
await undoToolCall(services.database.db, args);
const output = { type: "denied" as const, reason: UNDO_DENIAL };
await emitNow(services, args.runId, {
type: "tool.status",
toolCallId: args.toolCallId,
status: "denied",
});
await emitNow(services, args.runId, { type: "tool.result", toolCallId: args.toolCallId, output });
await services.wake(args.runId);
}

/**
* An edited input must not turn an allowed action into one the requester's rules (or their
* team's) block, nor slip past a team rule that needs an admin.
*/
async function assertEditAllowed(
services: Services,
userId: string,
runId: string,
toolCallId: string,
input: JSONValue,
) {
const { db } = services.database;
const [run] = await db
.select({ id: schema.runs.id, userId: schema.runs.userId })
.from(schema.runs)
.where(eq(schema.runs.id, runId));
if (!run) throw new DecisionInputError(404, "not_found", "Run not found.");
// Same check as the decision itself, so nobody learns about calls they can't decide.
await assertMayDecide(db, run, { userId, decisions: [{ toolCallId, approved: true }] });
const [call] = await db
.select({
name: schema.toolCalls.name,
effect: schema.toolCalls.effect,
approver: schema.toolCalls.approver,
})
.from(schema.toolCalls)
.where(
and(
eq(schema.toolCalls.runId, runId),
eq(schema.toolCalls.id, toolCallId),
eq(schema.toolCalls.userId, run.userId),
),
);
if (!call) throw new DecisionInputError(404, "not_found", "Action not found.");
const policy = await new PgRunStore(db).getPolicy(run.userId);
const verdict = evaluateComposedPolicy(
{ tool: call.name, effect: call.effect, input, tainted: true },
policy,
policy.teamRules,
);
if (verdict.decision === "block")
throw new DecisionInputError(422, "blocked", `The edited action is blocked: ${verdict.reason}`);
if (verdict.approver === "admin" && call.approver !== "admin")
throw new DecisionInputError(
422,
"needs_admin",
"With that change, your team requires an admin to approve this.",
);
}

export async function emitNow(services: Services, runId: string, event: RunEvent) {
await services.events.emit(runId, event);
services.events.forget(runId);
}

function wakeAt(services: Services, runId: string, at: Date) {
const timer = setTimeout(
() => void services.wake(runId).catch(() => {}),
Math.max(0, at.getTime() - Date.now()) + 25,
);
timer.unref?.();
}
Loading
Loading