Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ This changelog documents internal development changes, refactors, tooling update

## [Unreleased]

### Added
- Added `TrustLevelSchema`, `SessionSourceTrustSchema`, and `McpAccessSchema` Zod schemas to `config-schemas.ts` with types exported from core index. `EdgeConfigSchema` now accepts optional `sessionSourceTrust` and `mcpAccess` fields. ([CYPACK-933](https://linear.app/ceedar/issue/CYPACK-933), [#971](https://github.com/ceedaragents/cyrus/pull/971))
- Added `getEffectiveMcpConfigPaths(sessionSource, repository)` to EdgeWorker that resolves trust level → allowed MCP slugs → file paths in `~/.cyrus/mcp-configs/`. Backward compatible: no trust config = all sources treated as trusted. ([CYPACK-933](https://linear.app/ceedar/issue/CYPACK-933), [#971](https://github.com/ceedaragents/cyrus/pull/971))
- Threaded `sessionSource` parameter through `buildAgentRunnerConfig()` for all three session creation paths (Linear, GitHub, Slack). ChatSessionHandler now accepts `mcpConfigPath` in deps for trust-filtered file-based MCP configs. ([CYPACK-933](https://linear.app/ceedar/issue/CYPACK-933), [#971](https://github.com/ceedaragents/cyrus/pull/971))
- Added `sessionSourceTrust` and `mcpAccess` to ConfigManager's `detectGlobalConfigChanges()` globalKeys array for hot-reload support. ([CYPACK-933](https://linear.app/ceedar/issue/CYPACK-933), [#971](https://github.com/ceedaragents/cyrus/pull/971))
- 27 new tests: 16 schema validation tests (`config-schemas.trust.test.ts`) and 11 EdgeWorker filtering tests (`EdgeWorker.session-source-trust.test.ts`). ([CYPACK-933](https://linear.app/ceedar/issue/CYPACK-933), [#971](https://github.com/ceedaragents/cyrus/pull/971))

## [0.2.33] - 2026-03-10

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- **Trusted/untrusted session source MCP access control** - Users can now configure which MCP servers are available to sessions from different sources (Linear, GitHub, Slack) via `sessionSourceTrust` and `mcpAccess` settings. Trusted sources get full MCP access while untrusted sources receive a restricted set, giving users control over tool exposure per platform. ([CYPACK-933](https://linear.app/ceedar/issue/CYPACK-933), [#971](https://github.com/ceedaragents/cyrus/pull/971))

## [0.2.33] - 2026-03-10

### Fixed
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/config-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,33 @@ const PromptDefaultsSchema = z.object({
"graphite-orchestrator": PromptTypeDefaultsSchema.optional(),
});

/**
* Trust level for a session source.
*/
export const TrustLevelSchema = z.enum(["trusted", "untrusted"]);

/**
* Known session source identifiers.
* Uses z.string() as the key to allow future sources without schema changes,
* while well-known sources ("linear", "github", "slack") are documented.
*/
export const SessionSourceTrustSchema = z.record(z.string(), TrustLevelSchema);

/**
* MCP access configuration per trust level.
* Maps trust levels to arrays of MCP server slugs (matching filenames
* in ~/.cyrus/mcp-configs/mcp-{slug}.json).
*
* Built-in MCPs (Linear, Cyrus Tools) are always injected regardless
* of trust level — only user-configured MCPs are subject to filtering.
*/
export const McpAccessSchema = z.object({
/** MCP server slugs available to sessions from trusted sources */
trusted: z.array(z.string()).optional(),
/** MCP server slugs available to sessions from untrusted sources */
untrusted: z.array(z.string()).optional(),
});

/**
* Configuration for a single repository/workspace pair
*/
Expand Down Expand Up @@ -241,6 +268,25 @@ export const EdgeConfigSchema = z.object({

/** Global defaults for prompt types (tool restrictions per prompt type) */
promptDefaults: PromptDefaultsSchema.optional(),

/**
* Maps session sources to trust levels.
* Keys are source identifiers (e.g., "linear", "github", "slack").
* Values are "trusted" or "untrusted".
*
* If not configured, all sources are treated as "trusted" (backward compatible).
* Unknown/new sources default to "untrusted" when this field is present.
*/
sessionSourceTrust: SessionSourceTrustSchema.optional(),

/**
* Controls which user-configured MCP servers (from ~/.cyrus/mcp-configs/)
* are available to sessions based on their source's trust level.
*
* If not configured, all user MCPs are available to all sources (backward compatible).
* Built-in MCPs (Linear, Cyrus Tools) are always available regardless.
*/
mcpAccess: McpAccessSchema.optional(),
});

/**
Expand All @@ -264,6 +310,9 @@ export type UserIdentifier = z.infer<typeof UserIdentifierSchema>;
export type UserAccessControlConfig = z.infer<
typeof UserAccessControlConfigSchema
>;
export type TrustLevel = z.infer<typeof TrustLevelSchema>;
export type SessionSourceTrust = z.infer<typeof SessionSourceTrustSchema>;
export type McpAccess = z.infer<typeof McpAccessSchema>;
export type RepositoryConfig = z.infer<typeof RepositoryConfigSchema>;
export type EdgeConfig = z.infer<typeof EdgeConfigSchema>;
export type RepositoryConfigPayload = z.infer<
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ export {
type EdgeConfigPayload,
EdgeConfigPayloadSchema,
EdgeConfigSchema,
type McpAccess,
McpAccessSchema,
type RepositoryConfig,
type RepositoryConfigPayload,
RepositoryConfigPayloadSchema,
RepositoryConfigSchema,
type RunnerType,
RunnerTypeSchema,
type SessionSourceTrust,
SessionSourceTrustSchema,
type TrustLevel,
TrustLevelSchema,
type UserAccessControlConfig,
UserAccessControlConfigSchema,
type UserIdentifier,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,27 @@ export type {
EdgeConfig,
EdgeConfigPayload,
EdgeWorkerConfig,
McpAccess,
OAuthCallbackHandler,
RepositoryConfig,
RepositoryConfigPayload,
RunnerType,
SessionSourceTrust,
TrustLevel,
UserAccessControlConfig,
UserIdentifier,
} from "./config-types.js";
export {
EdgeConfigPayloadSchema,
// Zod schemas for runtime validation
EdgeConfigSchema,
McpAccessSchema,
RepositoryConfigPayloadSchema,
RepositoryConfigSchema,
RunnerTypeSchema,
resolvePath,
SessionSourceTrustSchema,
TrustLevelSchema,
UserAccessControlConfigSchema,
UserIdentifierSchema,
} from "./config-types.js";
Expand Down
150 changes: 150 additions & 0 deletions packages/core/test/config-schemas.trust.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
EdgeConfigSchema,
McpAccessSchema,
SessionSourceTrustSchema,
TrustLevelSchema,
} from "../src/config-schemas.js";

describe("TrustLevelSchema", () => {
it("accepts 'trusted'", () => {
expect(TrustLevelSchema.parse("trusted")).toBe("trusted");
});

it("accepts 'untrusted'", () => {
expect(TrustLevelSchema.parse("untrusted")).toBe("untrusted");
});

it("rejects invalid values", () => {
expect(() => TrustLevelSchema.parse("semi-trusted")).toThrow();
expect(() => TrustLevelSchema.parse("")).toThrow();
expect(() => TrustLevelSchema.parse(42)).toThrow();
});
});

describe("SessionSourceTrustSchema", () => {
it("accepts well-known sources with trust levels", () => {
const config = {
linear: "trusted",
github: "untrusted",
slack: "untrusted",
};
expect(SessionSourceTrustSchema.parse(config)).toEqual(config);
});

it("accepts future/unknown source names", () => {
const config = {
linear: "trusted",
discord: "untrusted",
teams: "trusted",
};
expect(SessionSourceTrustSchema.parse(config)).toEqual(config);
});

it("accepts empty object", () => {
expect(SessionSourceTrustSchema.parse({})).toEqual({});
});

it("rejects invalid trust levels", () => {
expect(() => SessionSourceTrustSchema.parse({ linear: "maybe" })).toThrow();
});
});

describe("McpAccessSchema", () => {
it("accepts trusted and untrusted server slug arrays", () => {
const config = {
trusted: ["server-a", "server-b"],
untrusted: ["server-a"],
};
expect(McpAccessSchema.parse(config)).toEqual(config);
});

it("accepts empty arrays", () => {
const config = {
trusted: [],
untrusted: [],
};
expect(McpAccessSchema.parse(config)).toEqual(config);
});

it("accepts partial config (only trusted)", () => {
const config = { trusted: ["server-a"] };
expect(McpAccessSchema.parse(config)).toEqual(config);
});

it("accepts partial config (only untrusted)", () => {
const config = { untrusted: ["server-a"] };
expect(McpAccessSchema.parse(config)).toEqual(config);
});

it("accepts empty object", () => {
expect(McpAccessSchema.parse({})).toEqual({});
});
});

describe("EdgeConfigSchema with trust fields", () => {
const minimalEdgeConfig = {
repositories: [],
};

it("accepts config without trust fields (backward compatible)", () => {
const result = EdgeConfigSchema.parse(minimalEdgeConfig);
expect(result.sessionSourceTrust).toBeUndefined();
expect(result.mcpAccess).toBeUndefined();
});

it("accepts config with sessionSourceTrust", () => {
const config = {
...minimalEdgeConfig,
sessionSourceTrust: {
linear: "trusted",
github: "untrusted",
slack: "untrusted",
},
};
const result = EdgeConfigSchema.parse(config);
expect(result.sessionSourceTrust).toEqual({
linear: "trusted",
github: "untrusted",
slack: "untrusted",
});
});

it("accepts config with mcpAccess", () => {
const config = {
...minimalEdgeConfig,
mcpAccess: {
trusted: ["linear-mcp", "custom-db"],
untrusted: ["linear-mcp"],
},
};
const result = EdgeConfigSchema.parse(config);
expect(result.mcpAccess).toEqual({
trusted: ["linear-mcp", "custom-db"],
untrusted: ["linear-mcp"],
});
});

it("accepts config with both trust fields", () => {
const config = {
...minimalEdgeConfig,
sessionSourceTrust: {
linear: "trusted",
github: "untrusted",
},
mcpAccess: {
trusted: ["server-a", "server-b"],
untrusted: [],
},
};
const result = EdgeConfigSchema.parse(config);
expect(result.sessionSourceTrust).toEqual({
linear: "trusted",
github: "untrusted",
});
expect(result.mcpAccess).toEqual({
trusted: ["server-a", "server-b"],
untrusted: [],
});
});
});
5 changes: 5 additions & 0 deletions packages/edge-worker/src/ChatSessionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface ChatPlatformAdapter<TEvent> {
export interface ChatSessionHandlerDeps {
cyrusHome: string;
mcpConfig?: Record<string, McpServerConfig>;
/** File-based MCP config paths, filtered by trust level for this session source */
mcpConfigPath?: string | string[];
chatRepositoryPaths?: string[];
/** Factory function that creates the appropriate runner based on config.defaultRunner */
createRunner: (config: AgentRunnerConfig) => IAgentRunner;
Expand Down Expand Up @@ -420,6 +422,9 @@ export class ChatSessionHandler<TEvent> {
cyrusHome: this.deps.cyrusHome,
appendSystemPrompt: systemPrompt,
...(this.deps.mcpConfig ? { mcpConfig: this.deps.mcpConfig } : {}),
...(this.deps.mcpConfigPath
? { mcpConfigPath: this.deps.mcpConfigPath }
: {}),
...(resumeSessionId ? { resumeSessionId } : {}),
logger: sessionLogger,
maxTurns: 200,
Expand Down
2 changes: 2 additions & 0 deletions packages/edge-worker/src/ConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ export class ConfigManager extends EventEmitter {
"issueUpdateTrigger",
"linearWorkspaceSlug",
"userAccessControl",
"sessionSourceTrust",
"mcpAccess",
];

for (const key of globalKeys) {
Expand Down
Loading
Loading