Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7e69d73
Encrypt personal MCP grants and keep them out of agent runtimes
soutar Aug 18, 2026
9720bbb
Confine the stdio upstream, pin its env, and require the personal scope
soutar Aug 18, 2026
7a9fb8c
Close the systemd user-manager escape, retire survivors before the mount
soutar Aug 18, 2026
d0ba933
Deny the container sockets, mirror the retirement into the installer
soutar Aug 18, 2026
0c28b99
Drop the confinement layer; make the key work without root
soutar Aug 18, 2026
2efb367
Pin the resolved executable for stdio OAuth servers
soutar Aug 18, 2026
e811efe
Remove a commit-message temp file committed by mistake
soutar Aug 19, 2026
a5875d9
Merge origin/main (packages/ layout) into the personal MCP OAuth branch
soutar Aug 19, 2026
05c578a
Merge current server layout into personal MCP hardening
tella-butler Aug 25, 2026
a3ef7f2
Merge remote-tracking branch 'origin/main' into harden-personal-mcp-o…
tella-butler Aug 25, 2026
bc1a55c
Fail closed when a legacy OAuth target was repointed
tella-butler Aug 25, 2026
fa06a79
Avoid shellcheck tilde warning in installer output
tella-butler Aug 25, 2026
087f11f
Merge remote-tracking branch 'origin/main' into harden-personal-mcp-o…
tella-butler Aug 25, 2026
4d67752
Merge remote-tracking branch 'origin/main' into harden-personal-mcp-o…
soutar Aug 25, 2026
5136a67
Merge origin/main and close OAuth binding gaps
tella-butler Aug 26, 2026
e03bb07
Merge remote-tracking branch 'origin/main' into HEAD
tella-butler Aug 26, 2026
e86fdec
Make manual MCP binding test deterministic
soutar Aug 27, 2026
41ea734
Merge remote-tracking branch 'origin/main' into HEAD
soutar Aug 27, 2026
9c7aed2
Close personal MCP routing gaps
soutar Aug 27, 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
35 changes: 35 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,41 @@ addresses, GitHub logins, and Slack ids resolve to the configured person.
Adding, removing, or re-scoping a server in `mcp-config.json` is picked up on
the next run/message and does not require a restart.

## Personal MCP OAuth grants

Browser-connected MCP grants are stored as authenticated AES-256-GCM
ciphertext. Open Session prefers an operator-supplied systemd credential for
the 32-byte key. A rootless simple-mode install instead mints a `0600` key next
to the store on first use, so the feature works without a root step. That
fallback protects a stray copy of the grant file. It does not protect a whole
state-directory backup, or defend against another process already running as
the same Unix user, because that process can read both files.

Remote HTTP MCP grants are mounted as coordinator-side in-process proxies. The
provider token is resolved immediately before the upstream connection and does
not enter engine config, process environment, command arguments, projected
sandbox files, or the run transcript. The grant is bound to the configured
upstream URL; repointing the same server name makes the connection fail closed
until it is reconnected.

Personal tokens are never injected into local stdio MCP processes. On a normal
rootless install those executables and their package entrypoints can be changed
by the same Unix identity as an agent run, so pinning a pathname would not make
the token handoff safe. A stdio MCP keeps its configured workspace credential.
Provider-specific personal grants such as Slack may still be used by
coordinator-owned UI actions that call the provider API directly.

On an operator install, per-user grants and `allowedUsers` visibility follow the
verified **prompter**, never the session creator. A teammate steering somebody
else's session therefore cannot spend or reveal the owner's personal grant. A
simple-mode install has no web identity gate and creates shared grants, which
matches its single-user trust model. The OAuth callback is tied to the same
verified account that started it whenever web sign-in is enabled.

Encryption at rest and coordinator proxying are useful reductions, not a
same-UID sandbox. Full isolation requires a small broker under a separate OS
identity that holds the key and issues narrow per-use capabilities.

## GitHub webhook actor trust (public repositories)

A valid webhook signature proves that GitHub sent an event. It does **not**
Expand Down
17 changes: 14 additions & 3 deletions packages/core/opensession-server/opensession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import { startCodexUsagePoller } from "./src/server/codex-accounts";
import { FRONTEND_SRC, IS_DEV, SPA_HEADERS, ensureFrontendBuilt, frontend, isPrebuiltFrontend, scheduleFrontendRebuild, sharedCheckoutEditors, spaEntry } from "./src/server/frontend-build";
import { configuredIntegration } from "./src/server/config";
import { initHumanAsks } from "./src/server/human-asks";
import { interactiveMcpServers } from "./src/server/interactive-mcp";
import {
interactiveMcpServers,
personalMcpScopeForSession,
} from "./src/server/interactive-mcp";
import { homeDir, OPENSESSION_SESSIONS_DIR, stateDir } from "./src/server/paths";
import { shouldRedirectLegacyPublicPath } from "./src/server/legacy-public-prefix";
import { startPlainArchiveSweep } from "./src/server/plain-archive";
Expand Down Expand Up @@ -868,10 +871,18 @@ if (!g.__opensessionBooted) {
return automationResumeMcpForSession(session, bksSessionId);
const servers: Record<string, unknown> = session.goalId
? {
...interactiveMcpServers(user, bksSessionId),
...interactiveMcpServers(
user,
bksSessionId,
personalMcpScopeForSession(session),
),
"opensession-goal-self": createGoalSelfMcpServer(session.goalId),
}
: interactiveMcpServers(user, bksSessionId);
: interactiveMcpServers(
user,
bksSessionId,
personalMcpScopeForSession(session),
);
return servers;
} catch (e) {
console.error(
Expand Down
60 changes: 13 additions & 47 deletions packages/core/opensession-server/src/server/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { homeDir } from "./paths";
import { existsSync, readFileSync, copyFileSync, watchFile } from "fs";
import { writeFileAtomic } from "./shared/atomic-write";
import { configuredPaths } from "./config";
import { mcpOauthStatus, mcpSharedGrantHeader, mcpUserGrantHeader, mcpUserGrantToken, oauthPresetFor } from "./mcp-oauth";
import { mcpOauthStatus, removeAllMcpOauthGrants } from "./mcp-oauth";

const HOME = homeDir();
// mcp-config.json location. OPENSESSION_MCP_CONFIG env → config
Expand Down Expand Up @@ -78,52 +78,9 @@ export function withDynamicCredentials(
}
} catch {}
}
// OAuth-connected HTTP servers (src/server/mcp-oauth.ts): inject the run
// user's own grant first (per-user MCP identity), else the shared grant.
// Servers with a static Authorization header keep it unless a grant exists.
try {
for (const [name, cfg] of Object.entries(out)) {
if (!cfg || typeof cfg !== "object") continue;
const c: any = cfg;
const isHttp = c.type === "http" || c.type === "sse" || !!c.url;
if (!isHttp) {
// Stdio servers with a preset OAuth (slack): inject the grant token
// as the preset's env var — the run then acts AS THE PERSON
// (creator-first order), falling back to the static bot token.
const preset = oauthPresetFor(name);
if (preset?.envVar && c.command) {
const candidates = (Array.isArray(user) ? user : [user]).filter(
(u): u is string => !!u,
);
const token =
candidates
.map((u) => mcpUserGrantToken(name, u))
.find((t) => !!t) ??
mcpSharedGrantHeader(name)?.replace(/^Bearer\s+/i, "");
if (token)
out = {
...out,
[name]: { ...c, env: { ...c.env, [preset.envVar]: token } },
};
}
continue;
}
const candidates = (Array.isArray(user) ? user : [user]).filter(
(u): u is string => !!u,
);
const header =
candidates
.map((u) => mcpUserGrantHeader(name, u))
.find((h) => !!h) ?? mcpSharedGrantHeader(name);
if (!header) continue;
out = {
...out,
[name]: { ...c, headers: { ...c.headers, Authorization: header } },
};
}
} catch (e) {
console.error("[connections] mcp-oauth header injection failed:", e);
}
// Personal OAuth credentials deliberately do not participate in this
// overlay. They are mounted as server-side MCP proxies (mcp-oauth-proxy.ts),
// so no provider token can enter engine config, env, argv, or sandbox files.
return out;
}

Expand Down Expand Up @@ -257,6 +214,15 @@ export function setMcpAllowedUsers(
export function removeMcpServer(name: string): { ok: true } | { error: string } {
const config = readMcpConfig();
if (!config.mcpServers[name]) return { error: `Server "${name}" not found` };
try {
removeAllMcpOauthGrants(name);
} catch {
return {
error:
`Server "${name}" was not removed because its personal grants ` +
"could not be revoked. Restore the protected credential and try again.",
};
}
delete config.mcpServers[name];
writeMcpConfig(config);
return { ok: true };
Expand Down
4 changes: 3 additions & 1 deletion packages/core/opensession-server/src/server/desk-voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@ async function voiceMcpServers(
sessionId: string,
): Promise<Array<{ name: string; server: InProcessMcpServer }>> {
const { interactiveMcpServers } = await import("./interactive-mcp");
return Object.entries(interactiveMcpServers(user, sessionId))
// Desk voice is a narrow spoken facade, not a full run: no personal
// provider tools, so pass no scope.
return Object.entries(interactiveMcpServers(user, sessionId, undefined))
.filter((entry): entry is [string, InProcessMcpServer] =>
Boolean((entry[1] as InProcessMcpServer | undefined)?.instance),
)
Expand Down
12 changes: 4 additions & 8 deletions packages/core/opensession-server/src/server/effective-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { UnifiedSession } from "./types";
import { resolveSessionRunInputs, type SessionRunInputs } from "./session-run-inputs";
import { filterMcpServers, STRIPE_CONFIRM_TOOLS } from "./runner-shared";
import { readMcpConfig } from "./connections";
import { mcpSharedGrantHeader, mcpUserGrantHeader } from "./mcp-oauth";
import { hasMcpOauthProxyGrantForUsers } from "./mcp-oauth";
import { userMatchesAny, commitAuthorFor } from "./shared/user-mappings";
import { configuredPaths } from "./config";
import { baseJournalKind, isUnattendedKind, deniedToolIds, runGateReason, runToolPolicy, readLocalInstructions, type RunToolPolicy } from "./run-policy";
Expand Down Expand Up @@ -152,17 +152,13 @@ export function describeMcpServers(
user: string | undefined,
grantUsers: Array<string | undefined>,
): McpServerRow[] {
const grantHolders = grantUsers.filter((u): u is string => !!u);
return explainMcpServers({
all: readMcpConfig().mcpServers || {},
included: filterMcpServers(scope ?? "all", user, grantUsers),
scope,
// The gate clears on any of these; de-duplicated so the reason reads as
// the set of identities tried, not as one name repeated.
gateUsers: [...new Set([user, ...grantUsers].filter((u): u is string => !!u))],
gateUsers: user ? [user] : [],
configPath: configuredPaths().mcpConfig,
hasOauthGrant: (name) =>
grantHolders.some((u) => mcpUserGrantHeader(name, u)) || !!mcpSharedGrantHeader(name),
hasOauthGrant: (name) => hasMcpOauthProxyGrantForUsers(name, [user]),
});
}

Expand Down Expand Up @@ -230,7 +226,7 @@ export async function inProcessServerNames(
}
const { interactiveMcpServers } = await import("./interactive-mcp");
const servers: Record<string, unknown> = {
...interactiveMcpServers(inputs.user, session.id),
...interactiveMcpServers(inputs.user, session.id, inputs.mcpServers ?? "all"),
};
if (inputs.inProcessMcpBranch === "interactive+goal-self" && session.goalId) {
servers["opensession-goal-self"] = createGoalSelfMcpServer(session.goalId);
Expand Down
54 changes: 50 additions & 4 deletions packages/core/opensession-server/src/server/interactive-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { findSession, touchNativeSession } from "./session-cache";
import { attachRepo, linkPr, resolveSessionRepoContext, sessionRepoIds, switchPrimaryRepo } from "./session-repos";
import { makeAskHandler } from "./asks";
import { activeSandboxFor } from "./session-sandbox";
import { mcpOauthProxyServers } from "./mcp-oauth-proxy";
import type { McpScope } from "./runner-shared";

type PreviewAction = "start" | "status" | "stop";
type PreviewModule = typeof import("./preview");
Expand Down Expand Up @@ -126,11 +128,42 @@ function papercutsServerFor(
};
}

/** The personal-proxy scope a session's own run should get, for launchers that
* rebuild a run's servers from the session file rather than from live run
* options (the run-rpc fallback builder, the resume path in opensession.ts).
* Old feed sessions can predate the persisted allowlist, and this cannot
* resolve their connectors, so those fail closed rather than widening. */
export function personalMcpScopeForSession(
session: { mcpServers?: string[]; externalRefs?: unknown[] } | undefined,
): McpScope {
if (session?.mcpServers?.length) return session.mcpServers;
if (session?.externalRefs?.length) return [];
return "all";
}

export function interactiveMcpServers(
user?: string,
sessionId?: string,
user: string | undefined,
sessionId: string | undefined,
/** Which external servers this run may see, so a personal proxy is never
* mounted wider than the run's own allowlist. REQUIRED, and deliberately
* not optional: every launcher has to make the choice, because forgetting
* it is silent — `buildOpencodeMcpConfig` drops a granted server on the
* assumption a proxy replaces it, so a launcher that omits the scope gets
* neither. Pass `undefined` only to mean "no personal tools at all"
* (Desk voice). */
personalMcpScope: McpScope | undefined,
): Record<string, unknown> {
const createdBy = user || productName();
const session = sessionId ? findSession(sessionId) : undefined;
// Personal provider tools are opt-in at the run launch sites. Other callers
// (notably Desk voice) deliberately consume a narrower interactive facade.
const personalMcp = session && personalMcpScope

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P1 — Pass the personal MCP scope from every run launcher

Personal proxies are now opt-in, but session-create.ts:644 still calls interactiveMcpServers(spec.user, bksId) and runner-session.ts:78 does the same for every Runner turn. Meanwhile buildOpencodeMcpConfig removes any server for which that user has a grant. Therefore a new local session's opening prompt, and every Runner prompt, gets neither the external server nor its proxy. For example, an opening request to post through a connected Slack account has no Slack tools, although a later ordinary local prompt does. Pass spec.runMcpServers ?? "all" and opts.mcpServers ?? "all" at those call sites and add coverage for both launch paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9720bbb, and this one was worse than reported. Rather than patch the two call sites, I made personalMcpScope a required parameter, because the failure is silent in both directions: buildOpencodeMcpConfig drops a granted server assuming a proxy replaces it, so a launcher that forgets the scope gets neither. Making it required had the compiler find two more launchers you had not flagged, in opensession.ts's resume path. Those and the run-rpc fallback builder now share one derivation, personalMcpScopeForSession. Desk voice passes undefined explicitly to keep its narrow facade. On coverage: I did not add per-launcher tests, since the type signature now enforces this at every present and future call site, which a test enumerating today's launchers would not.

? mcpOauthProxyServers(
personalMcpScope,
user,
[user],
)
: {};
return {
"opensession-sessions": createSessionsMcpServer({
createdBy,
Expand All @@ -145,6 +178,7 @@ export function interactiveMcpServers(
createdBy,
isAdmin: true,
}),
...personalMcp,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P1 — Make the personal proxy shadow the external MCP entry

This proxy does not handle ordinary local Pi turns after the latest main merge. createMcpRuntime adds every configured external server first (mcp-runtime.ts:312-318), records those names as taken, and then skips same-named inProcessMcp servers. Its external connector still detects personal grants and uses the legacy relay with [mcpGrantUser, user] (mcp-runtime.ts:246,256-264). For example, when Kent prompts a session created by Michiel, a configured tella entry wins over this proxy and the relay selects Michiel's grant first, despite this PR requiring the verified prompter's identity. Ensure the in-process OAuth proxy shadows/removes the same-named external entry in the new runtime, and do not use the creator-first legacy grant path for new turns.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c7aed2: the Pi MCP runtime now removes external entries shadowed by coordinator-owned in-process servers, and its external connector no longer selects or relays creator-first personal grants. Same-named personal proxies therefore own the runtime entry and use only the prompter identity supplied by interactive-mcp. Added coverage for in-process shadowing.

// Runners are deliberately trusted persistent machines for platform-locked
// work. Interactive-only: untrusted automation text must never reach one.
"opensession-runners": createRunnersMcpServer({ user, sessionId }),
Expand Down Expand Up @@ -316,7 +350,12 @@ export function interactiveMcpServers(
// recursion is lazy and terminates: this closure runs on a
// script's first mcp.* call, and the workflows server the rebuild
// produces is excluded from the allowlist anyway.
inProcessMcp: () => interactiveMcpServers(user, sessionId),
inProcessMcp: () =>
interactiveMcpServers(
user,
sessionId,
personalMcpScopeForSession(findSession(sessionId)),
),
}),
// Per-session scratch assets (previewed in the Assets tab).
// Works in Ask mode — writes land outside the checkout.
Expand Down Expand Up @@ -376,7 +415,14 @@ registerInteractiveMcpBuilder((sessionId, user) => {
if (sessionId && session?.automation) {
return automationSessionMcp(session, sessionId);
}
const servers = interactiveMcpServers(user, sessionId);
// Old feed sessions can predate the persisted allowlist. The asynchronous
// launch path still resolves their external connectors, but this fallback
// builder cannot; fail closed for personal proxies rather than widening.
const servers = interactiveMcpServers(
user,
sessionId,
personalMcpScopeForSession(session),
);
const goalId = session?.goalId;
if (goalId)
(servers as Record<string, unknown>)["opensession-goal-self"] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function find(name: string): McpServerCatalogEntry | undefined {
}

function wiredInteractive(): string[] {
return Object.keys(interactiveMcpServers("You", SESSION_ID));
return Object.keys(interactiveMcpServers("You", SESSION_ID, "all"));
}

/** Read the two object literals that compose the complete automation surface.
Expand Down
6 changes: 3 additions & 3 deletions packages/core/opensession-server/src/server/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { readMcpConfig } from "./connections";
import { mcpAuthHeader } from "./mcp-oauth";
import { mcpBoundAuthHeader } from "./mcp-oauth";

export class McpToolError extends Error {}

Expand All @@ -28,7 +28,7 @@ export async function listMcpTools(
| { url?: string; headers?: Record<string, string> }
| undefined;
if (!cfg?.url) throw new McpToolError(`No HTTP MCP server "${serverName}"`);
const oauth = mcpAuthHeader(serverName, user);
const oauth = mcpBoundAuthHeader(serverName, cfg, user);
const auth = oauth || cfg.headers?.Authorization;
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
requestInit: {
Expand Down Expand Up @@ -66,7 +66,7 @@ export async function callMcpTool<T = unknown>(
| { url?: string; headers?: Record<string, string> }
| undefined;
if (!cfg?.url) throw new McpToolError(`No HTTP MCP server "${serverName}"`);
const oauth = mcpAuthHeader(serverName, user);
const oauth = mcpBoundAuthHeader(serverName, cfg, user);
const auth = oauth || cfg.headers?.Authorization;
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
requestInit: {
Expand Down
Loading
Loading